Loop Hardening (v0.70.0 → live in v0.71.11)
Six surfaces that protect the training loop from the failure modes that cost a real GPU-hour. Shipped schema-first in v0.70.0; as of v0.71.11 every callback is live — the detectors install real GRPO/PPO TrainerCallbacks, the distillation losses compute inside the distill trainer, the RL checkpoints write real state, and soup iterative-dpo runs the full loop. Validated end-to-end on SmolLM2-135M.
--reward-hack-detector — InfoRM + RM-ensemble divergence
soup train --config grpo.yaml \
--reward-hack-detector info_rm \
--reward-hack-haltbase, task and reward_model are config keys, so they live in grpo.yaml rather than on the command line. (Do not paste a # comment inside a backslash chain: the comment ends the command, and the flags below it run as commands of their own.)
Two detectors:
info_rm— InfoRM Cluster-Separation Index (Wang et al. 2024, arXiv 2402.09345). Drops when the policy collapses onto a degenerate reward-maximising subspace.rm_ensemble— mean pairwise variance across an RM ensemble (cap 32). When ensemble members disagree, the policy is exploiting one of them.
Math kernels compute_cluster_separation, compute_rm_ensemble_divergence, classify_hack_signal are LIVE with OK / WARN / HACK bands at 0.10 / 0.30 relative drop. --reward-hack-halt auto-stops on HACK (exit 2). Cross-validator: task in {grpo, ppo} only, halt=True requires detector, rejects mlx; rm_ensemble requires ≥2 reward functions. Composes with v0.34 soup why for anomaly explanation. Live as of v0.71.11 — the GRPO callback reads per-step rewards via a shared thread-safe capture buffer, classifies the verdict, logs it to state.log_history, and halts on HACK.
v0.71.26 closes the loop. These detectors no longer only halt:
--reward-hack-mitigation kl_control|pid_lagrangianmakes the trainer self-correct mid-run — raise the KL penalty, shape the reward, roll back to the last-good checkpoint, then early-stop only as a last resort. See Closed-loop reward-hacking auto-mitigation.
training.uld_strategy — Universal Logit Distillation
# soup.yaml
task: distill
training:
teacher_model: meta-llama/Llama-3.1-8B-Instruct # required by task: distill
uld_strategy: wasserstein_aligned # the one that handles two different tokenizersBoizard et al. 2024 (arXiv 2402.12030). Three strategies, and only one of them is cross-tokenizer. Getting that wrong is not a slow run, it is a run that trains on the wrong text and reports a plausible loss the whole way, so read the split before you pick:
| Strategy | Tokenizers | What it does |
|---|---|---|
wasserstein_aligned | Different | Re-tokenizes and aligns decoded character spans, so Llama → Mistral or GPT-2 BPE → Llama SentencePiece is a real comparison |
wasserstein | Must be interchangeable | 1-D Wasserstein distance over sorted teacher / student logits, no alignment. Cheap, and correct only for two sizes in one family |
topk_align | Must be interchangeable | Top-K teacher logits matched via a BPE-overlap heuristic. Sharper signal, same tokenizer requirement |
uld_top_k: 32 is required for topk_align. _MAX_VOCAB_SIZE=262144 covers multilingual SentencePiece and GPT-OSS 200K vocabularies. All three are gated to task='distill' and reject the mlx backend. Live as of v0.71.11. (v0.71.12 also adds distill_mode: sequence for hard-label sequence-level KD, mutually exclusive with the ULD logit path.)
wassersteinandtopk_alignwere sold as cross-tokenizer here until v0.75.0, and they never were. Both forward the student's owninput_idsstraight to the teacher, clamped into its vocabulary range to avoid an index error. Clamping does not translate a token between two vocabularies, it just picks some other token. So a mismatched pair fed the teacher garbled or simply different text, and the distillation term was computed against logits for that wrong text, while the loss stayed finite and plausible the whole run. Nothing raised and nothing warned.Both strategies now refuse a tokenizer pair that is not interchangeable, at
setup(), namingwasserstein_alignedas the working alternative. The check reuses the samesame_tokenizerprobe Soup already trusts to decide whether a speculative-decoding draft is interchangeable with its target. The same-tokenizer fast path is untouched. If you distilled across two different tokenizers withwassersteinortopk_alignon any earlier release, that run is not salvageable, so re-run it withwasserstein_aligned.A second defect in the same family shipped alongside: the ULD loss ignored the response-only label mask and the causal shift that the cross-entropy term already applied, so under the default
train_on_responses_onlyit was optimising prompt tokens and the shift-boundary position too. Both loss functions now take the labels and mask onlabels != -100after the same shift.
training.minillm_enabled — reverse-KL with 3 stability tricks bundled
task: distill
training:
teacher_model: meta-llama/Llama-3.1-8B-Instruct # required by task: distill
minillm_enabled: true
minillm_teacher_mix_ratio: 0.3
minillm_length_normalize: true
minillm_pretrain_anchor_weight: 0.1
minillm_pretrain_anchor_path: ./pretrain.jsonlGu et al. 2024 (arXiv 2306.08543). All three §3 stability tricks bundled: teacher-mixed sampling (mix teacher samples into the on-policy rollout), length normalisation (per-token KL averaged), pretrain-loss anchor (regularise toward an anchor distribution at weight α).
There is one real CLI flag in this family, and everything above is config:
soup train --config distill.yaml --minillm-on-policyIt switches the rollout from a teacher-forced approximation to a genuine on-policy one: a fresh autoregressive sample is drawn from the teacher-student mixture each step, then scored with the length-normalised reverse KL. training.minillm_rollout_length sizes it, defaulting to min(max_length, 32).
Cross-validators reject silent no-ops:
anchor_weight=0withanchor_pathset → erroranchor_weight > 0withpath = None→ error
Gated to task='distill'. Live as of v0.71.11 — the teacher-mixed, length-normalised reverse-KL term plus the optional pretrain-anchor SFT term train end-to-end; the anchor reader is cwd-contained + symlink-rejecting with a per-line byte cap.
training.rl_checkpoint_save_every_steps — mid-epoch PPO/GRPO ckpt
# config keys under training:, not CLI flags
# rl_checkpoint_save_every_steps: 200
# rl_checkpoint_keep_last: 4
# rl_checkpoint_include_optimizer: true
# rl_checkpoint_include_ref_model: true
# rl_checkpoint_include_rollout_buffer: true
soup train --config ppo.yamlTorchTune explicitly punts mid-epoch checkpointing. Soup enforces the bounds save_every_steps ∈ [1, 10M], keep_last ∈ [1, 100] (oldest pruned).
Composes with v0.32 spike recovery + v0.40 reference-model regen, but the saved state is restored in exactly one place at v0.75.2: the rollback rung of the pid_lagrangian reward-hack controller. Loss-spike recovery and a crashed run do not restore it. Saving is live as of v0.71.11 — it writes a real adapter + optimizer state + JSON manifest every N steps and prunes to rl_checkpoint_keep_last. (v0.71.11 also made the GRPO reference-model EMA update in place, eliminating the three model-sized allocations per step. As of v0.75.2 that update only runs if the callback is handed a separate reference model, which the Hugging Face callback handler does not do, so ref_model_ema_alpha changes nothing in practice; see GRPO Plus.)
soup iterative-dpo — sample → score → re-pair → retrain driver
soup iterative-dpo --base-model HuggingFaceTB/SmolLM2-135M-Instruct \
--reward-model ./rm \
--prompts ./prompts.jsonl \
--output-dir ./iter-dpo \
--rounds 4 --pairs-per-round 4000--base-model, --reward-model, --prompts and --output-dir are all required here; unlike soup train, this driver takes the reward model as a flag rather than from a config. The two models are handed straight to from_pretrained, so each is a Hub id or a local path (a sequence-classification model for --reward-model); a registry:// reference is not resolved by this command, and --plan-only would accept one without noticing.
Frozen IterativeDPOPlan with a consecutive-round_index invariant and canonical per-round artifacts:
./iter-dpo/round-00/pairs.jsonl
./iter-dpo/round-00/adapter/
./iter-dpo/round-01/pairs.jsonl
./iter-dpo/round-01/adapter/
...Rounds are numbered from zero: --rounds 4 plans round-00 to round-03. There is no resume: the command has no --resume flag and the runner does not skip a round whose adapter already exists, so after a crash it starts again from round-00 (point --output-dir somewhere new if you want to keep the earlier files). --plan-only renders the validated plan and exits 0. Live as of v0.71.11 — each round samples completions from the previous round's adapter, then trains a fresh LoRA from the base on that round's harvested pairs.
training.echo_trap_enabled — RAGEN multi-turn n-gram repetition detector
# add to the training: block of your grpo.yaml (task: grpo or ppo, not mlx)
training:
echo_trap_enabled: true
echo_trap_threshold: 0.6
echo_trap_halt: truesoup train --config grpo.yamlZhu et al. 2025 (arXiv 2504.14437). Pure-Python n-gram repetition rate per trajectory + a batch mean — when an agent's rollout collapses into "echoing itself" (the same n-gram pattern appearing repeatedly within and across turns), this catches it before the reward model rewards the degenerate policy.
--echo-trap-tokenizer-aware is the one real CLI flag here, and the trade-off is worth stating before you reach for it: scoring repetition over tokens rather than characters catches subword repetition that punctuation-heavy text hides, but it makes the score tokenizer-specific instead of vocabulary-agnostic, so two runs on different tokenizers stop being comparable. Everything else in this family (echo_trap_enabled, echo_trap_threshold, echo_trap_halt) is a training. config key, not a flag.
OK / WARN / TRAP bands at 0.30 / 0.60. DoS caps _MAX_NGRAM_N=32, _MAX_TRAJECTORY_TOKENS=1M, _MAX_BATCH_TRAJECTORIES=100k. Gated to task in {grpo, ppo} non-mlx. Composes with v0.53.11 GRPOStabilityCallback. Live as of v0.71.11 — the GRPO callback scores per-trajectory n-gram repetition, logs the verdict, and halts on TRAP when training.echo_trap_halt is set.
Numbers
+337 tests in v0.70.0 (11,487 → 11,824); the live wiring in v0.71.11 is part of the broader v0.71 sweep, which closed at 16,529 tests across 319 files in v0.71.41. At v0.74.0 the suite spanned 439 test files; upstream stopped publishing a total it counted at the tag, so this page names the number that reproduces and not one it cannot check.
See also
- Closed-loop reward-hacking auto-mitigation (v0.71.26) — the release that turns these detectors from "halt" into "self-correct".
- Lean install + live wiring (v0.71) — the release that made all six of these surfaces real.
- Adapter lifecycle (v0.67) —
soup adapters bisectfinds which mid-epoch ckpt regressed. - Anti-trend insurance (v0.68) —
soup distill-prompt+ ULD pair up to bridge tokeniser gaps. - Soup Loop (v0.58) — iterative-DPO runs inside a
soup loopiteration.
Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.