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 TrainerCallbacks (a task: ppo run refuses them since v0.76.0), 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: the schema accepts task: grpo and task: ppo only, halt=True requires detector, rejects mlx; rm_ensemble requires ≥2 reward functions. task: ppo then refuses the detector at trainer setup (see below), so in practice the detectors are GRPO-only. 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.
task: ppo refuses these flags (v0.76.0, #1441). reward_hack_detector, reward_hack_mitigation and echo_trap_enabled on task: ppo stop the run when the trainer is built, before any model loads: task: ppo cannot use <flags>: this trl's experimental PPOTrainer takes no reward functions, so the reward/completion signal these callbacks read is never captured. They are wired on GRPO only. The config itself loads, so soup doctor --config does not catch it, and the soup train --help text for --reward-hack-detector still says "GRPO/PPO". rl_checkpoint_save_every_steps is not affected, and a PPO run that sets none of these three flags is not refused. (Since closed in v0.76.0: through v0.75.2 a PPO run carrying one of these flags was announced and loaded, and then never halted, mitigated or trapped anything, because the signal buffer was never written.)
info_rm needs a reward that varies on both sides of the batch median (v0.76.0, #1438). On a two-valued reward such as reward_fn: accuracy (0 or 1), or a continuous reward where half the batch lands on the same value, a step whose median split leaves either half constant now casts no vote and logs one warning naming the cause. A step where every reward is identical still votes once a baseline exists. (Since closed in v0.76.0: the pooled-variance floor collapsed to 1e-9, so a first step near 50% success recorded a phantom separation of about 31,623 as the baseline, later healthier steps read as a collapse, and the run could be halted or its beta changed on a false signal. Detector logs and mitigation_log.jsonl from earlier runs are not comparable; on a two-valued reward use rm_ensemble or the length and repetition signals.)
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. On transformers 5, its teacher generation failed with a bare AttributeError on the first prompt until v0.76.0, which reads the chat-template encoding as a mapping and passes its attention mask to generate (#1238).)
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.
wasserstein_aligned no longer scores positions that have no teacher target (v0.76.0, #1426). A byte-fallback piece, an empty decoded piece or a student-only special token aligns to no teacher token. It used to get an all-zero teacher row, which softmaxes to uniform, and each such position added about V/2 to the loss. These positions now leave both the sum and the denominator under the label mask, the attention mask and no mask, and the trainer decodes student special tokens to the empty string, as it already does for the teacher. A wasserstein_aligned run from before v0.76.0 included those positions in its loss.
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→ errorminillm_enabled: truewith the default offline settings (minillm_on_policy: falseandminillm_teacher_mix_ratio: 0.0) → error (v0.76.0, #692). The message startsminillm_enabled with minillm_on_policy=false requires minillm_teacher_mix_ratio > 0, so set a ratio in (0, 1] or use--minillm-on-policy. Ratio 0 stays legal on the on-policy path, where it only means student-only sampling and the loss is stillKL(student || teacher).
(Since closed in v0.76.0: that default combination used to load and forward the teacher, then blend it at weight zero, so the MiniLLM term was KL(student || stopgrad(student)) while ordinary cross-entropy kept the loss curve healthy.)
Only --minillm-on-policy is a CLI flag. minillm_enabled, minillm_teacher_mix_ratio, minillm_pretrain_anchor_weight and minillm_pretrain_anchor_path are soup.yaml fields with no CLI counterpart: upstream's own docs showed four soup train --minillm-* flags that raised No such option (exit 2), and replaced them with the YAML above (#979).
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.76.0: 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.76.0 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; task: ppo refuses these keys)
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 (its help text still says "grpo/ppo"), 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. The schema gate is task in {grpo, ppo} non-mlx, but task: ppo is refused at trainer setup (see the detector section), so this is GRPO-only in practice. 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.
Re-run this
Each row is a way a finished run could differ from the config you wrote. The wording follows upstream's changelog; the PR numbers are there so you can read the fix.
| If you ran | What was different | What to do |
|---|---|---|
task: distill with gradient_accumulation_steps above 1 (this includes soup draft distill and the soup shrink --heal step, which train through task: distill) | _DistillTrainer.compute_loss returned a mean over its own microbatch, so the same effective batch split four ways produced four times the gradient and changed the effective learning rate. The trainer now weights each microbatch by its share of the window's trained tokens (#720 in #742, completed in #932 in #943) | Re-run |
uld_strategy: wasserstein_aligned | Positions with no teacher target added about V/2 each to the loss (#1426) | Re-run if the loss mattered to the result |
minillm_enabled: true with the default offline settings | The teacher was blended in at weight zero, so the MiniLLM term was KL(student || stopgrad(student)) while ordinary cross-entropy kept the loss curve healthy (#692). It is now refused at load | Set minillm_teacher_mix_ratio above 0, or use --minillm-on-policy, and re-run |
GRPO with reward_hack_detector: info_rm on a two-valued reward such as reward_fn: accuracy, with --reward-hack-halt or a mitigation other than log_only | A phantom baseline could make the detector read healthy steps as a collapse, so the halt or the beta change may have been spurious (#1438) | Re-run if a halt or a beta change ended or altered the run |
task: ppo with reward_hack_detector, reward_hack_mitigation or echo_trap_enabled | The flags were announced and then never acted: the signal buffer was never written (#1441). They are now refused before the model loads | Nothing was detected or mitigated; use task: grpo for these flags |
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.