v0.75.0: MLX honours its config, and unknown keys refuse the load

All 60 pull requests in this release came from outside the maintainer, by 22 people. That is the second release running written entirely by other people, and the fourth in a row whose headline is a flag that validated, was documented, and reached nothing.

Upstream gave it no codename. It titled the release with a sentence again, and the sentence is the finding: on backend: mlx, six training options were accepted and read by nothing, so the same soup.yaml trained a different recipe on Apple Silicon than it did on a CUDA box, silently.

Re-run this

Fourteen of this release's fixes mean something you already ran was wrong, or was judged on a prompt it never trained on. None of them raised, and most exited 0.

If youWhat happenedWhat to do
Ran any supervised fine-tune on backend: mlxdata.train_on_responses_only defaults to true and reached nothing, so the run trained on your system and user turns as well as the assistant's, and the adapter metadata recorded mask_prompt: false regardlessRe-run. Measured over 16 two-turn conversations: 772 supervised tokens before, 146 with a correct mask
Set training.optimizer, scheduler, warmup_ratio or weight_decay on MLXAll four were dropped. Every run built a bare AdamW at a constant learning rate, whatever the recipe said, and all 32 allowlisted optimizer names became AdamWRe-run. A name MLX cannot express is refused by name now instead of becoming AdamW
Set training.max_grad_norm on MLXIt reached nothing, so nothing clipped, while the same config clipped at 1.0 on every transformers runRe-run if you use optimizer: sgd or saw gradient spikes. On an Adam-family run the difference is in the fifth decimal
Ran MLX with gradient_accumulation_steps or gradient_checkpointing setBoth took mlx-lm's own dataclass defaults, 1 and False, so your effective batch size and your memory saving were not the ones you configured, and adapter_config.json hardcoded both, so you could not tell from the output afterwardsRe-run. Note the upgrade change below: the default is 4, so an untouched config now trains at a 4x effective batch
Selected grpo_variant: gspoThe objective centred the per-token log-ratio across the batch before the completion mask was applied, so one padding token shifted the loss and gradient of every unmasked row sharing its column, and picked up a non-zero gradient of its ownRe-run. The objective is also replaced entirely, so these runs cannot be reproduced either way
Set training.loraplus_lr_ratioIt was forwarded to TrainingArguments, which has no such field, so every run that set it crashed with a TypeError before the first step. An advertised, schema-accepted optionRun it. It builds a real PEFT LoRA+ optimizer now, with save and resume state proven to survive
Distilled with uld_strategy: wasserstein or topk_align across two different tokenizersBoth forwarded the student's own token ids to the teacher, clamped into range. Clamping does not translate a token between vocabularies, so the teacher was conditioned on the wrong text while the loss stayed finite and plausibleRe-run with uld_strategy: wasserstein_aligned. The old run is not salvageable
Distilled anything with the default train_on_responses_onlyThe ULD loss ignored the response-only label mask and the causal shift the cross-entropy term already applied, so it optimised prompt tokens and the shift-boundary position tooRe-run
Chatted with, served, benchmarked, diffed or judged a model on a Llama-3, Gemma or Mistral templateThe rendered chat template was handed to the tokenizer, which added its own special tokens on top, so inference sent a doubled BOS that training never saw. 2 BOS down to 1 on vendor templates, 1 down to 0 on Soup's own presetsRe-judge and re-benchmark. The weights are fine; soup ship verdicts, soup bench numbers, soup diff comparisons and usage.prompt_tokens were all taken on a prompt that differed from training by a token
Ran soup data validateIt judged a row by top-level key presence while the loader runs the real converters, so the two disagreed. Six formats skipped validation entirely and reported every row valid regardless of content: prm, pre_tokenized, input_output, video, multimodal and raftRe-validate. On upstream's own datasets this shifted 120 of 360 file-by-format verdicts, every one an over-count
Used data.interleave with the over or probs strategyval_split ran after the padding, and a padded copy is the same row, so one row could land in both train and val. Your validation metric was computed partly on rows you trained onRe-run and re-read the metric. Fixed on the eager local-file and all-Hub paths; the streaming path still leaks
Used data.streaming: true with a single Hugging Face Hub dataset nameThe flag was ignored and the full dataset was materialisedRe-run. It streams now, capped at 1,000,000 rows with a warning
Hit soup eval autoIt ran the eval, saved the results, and then died with TypeError: expected str, bytes or os.PathLike object, not OptionInfo, because an unpassed typer parameter keeps a truthy sentinel as its default. Mid-training auto-eval reported it as a failed evalThe results were already saved. Re-run for the report
Exported with soup export --format awq and no --calibration-dataAutoAWQ silently downloaded its own large default calibration dataset, so the artifact was calibrated on data you never choseRe-export with explicit calibration data. It is required now, and refused before the quantizer is even imported

One more that is not a run but a dataset: a non-dict message in a multimodal, chatml, audio or video row raised AttributeError out of the converter instead of being dropped, so one bad line in a large JSONL took the whole dataset with it. chatml is what format detection returns for a bare {"messages": [...]} row, which makes it the default path for the most common dataset shape.

The headline: six options MLX accepted and never read

A field can be declared by the schema, validated on load, printed in the reference, and still be read by nothing on the backend you picked. That is not a hypothetical here: it is the fourth release in a row to fix an instance of it, and this one found six in one backend.

The full table, the measurements behind each, and the allowlists MLX actually supports are on the MLX backend page. The short version:

  • Response-only masking is not comparable between the two backends, and MLX is now the stricter one. Setting mlx-lm's own flag was not the fix, because it masks a single prefix and supervises only the final assistant turn. Soup injects a per-token mask instead, excludes the assistant header where transformers includes it, and refuses a template it cannot align rather than approximating.
  • Only 8 of Soup's 32 optimizer names have an MLX equivalent. The other 24 are refused by name. Four schedulers are supported, and a non-zero weight decay on an optimizer whose MLX constructor takes none is refused rather than dropped.
  • Schedules are built in optimizer-update units, not iterations, because that is what MLX drives a callable learning rate from. Measured on Apple Silicon, a cosine run with warmup_ratio: 0.2 now reports 10 distinct learning rates across 40 iterations where the old path reported one.

One upgrade change, and it applies to anyone who never touched the field. gradient_accumulation_steps defaults to 4. An MLX run that used to update the optimizer on every micro-batch now accumulates over four first: a 4x larger effective batch size and roughly a quarter as many optimizer updates for the same iters. That is the schema default finally taking effect, so a differently-converging run after upgrading is expected rather than a regression.

One shipped recipe changes behaviour. qwen3-8b-sft-mlx does not set data.train_on_responses_only, so it takes the true default. Qwen3's chat template injects its thinking block only for the last assistant turn, so multi-turn rows are now refused at dataset construction, before the training loop, naming data.train_on_responses_only: false as the remedy. Single-turn rows are unaffected, and so are llama3.1-8b-sft-mlx and gemma3-4b-sft-mlx.

Two MLX recipe repository IDs were also repaired, and one is a user-visible rename: gemma3-9b-sft-mlx is now gemma3-4b-sft-mlx, because there is no 9B Gemma 3 and the old ID raised RepositoryNotFoundError. qwen3-8b-sft-mlx moved from a non-existent Qwen3-8B-Instruct-4bit to Qwen3-8B-4bit.

Two guards for the pattern this release is named for

Fixing six instances of a class is worth less than being able to see the seventh, so this release ships the instruments as well.

A declared config field that reaches no consumer now fails the test suite. Upstream publishes the hedge that has to travel with it: a companion issue records the guard's measured leak, so it is a ratchet rather than a proof. It catches a field nothing reads; it does not catch a field something reads wrongly.

soup doctor --config soup.yaml lists the settings your config writes that its task and backend do not read. Only fields you actually set, never schema defaults, each with the reason and the issue that recorded it.

bash
soup doctor --config soup.yaml

The scope is deliberately narrow, and that framing is upstream's own: the table covers task: sft on backend: mlx, seven entries, and every other pair reports nothing rather than guessing. Every one of those seven is already something the MLX trainer warns about at runtime, so the value added is the timing, not new knowledge. Backend support is declared by hand rather than inferred, because inference does not work: reachability over the import graph detected none of five independently-known gaps, reading the trainer module alone invents gaps for fields that live in helper modules, and --dry-run exits before a trainer is ever constructed.

Two disclosures ride along. Five entries were removed from that table before merge because the same release wired those fields, and the guard is what noticed. And the all-clear message overclaims: CUDA-only kernels such as use_liger are unread on MLX and absent from the table entirely, so read "every setting is read" as the narrower "nothing in the declared table is unread". soup doctor --config exits 2 when the config cannot be read, parsed or validated, all three deliberately agreeing so the leg can gate CI.

Breaking: an unknown config key refuses the load

v0.74.0 reported every key no config model declares, a typo like quantizaton or a field that only exists on a newer Soup, as a warning that named v0.75 as the release that would start refusing. This is that release.

text
Config validation error:

  unknown config key 'data.max_len' - did you mean 'max_length' or 'video_maxlen'? Refused.
unknown config key 'training.quantizaton' - did you mean 'quantization' or 'quantization_aware'? Refused.

soup train exits 1 before the training stack is imported. soup sweep, soup doctor --config and soup ship --config refuse the same way, and the API and Web UI loader raises ValueError with the same text, rendered through the SPA's own escaping. Nothing is defaulted and nothing is guessed: the suggestion is a hint for you, not a substitution the loader makes.

The message says Refused. rather than Not applied., deliberately, because the second would read as if the run went ahead without the key. The soup sweep guard, which always refused, changes wording the same way.

Three things are worth knowing before you upgrade:

  1. A config that must stay loadable on v0.74 as well needs the key removed, not renamed. v0.74 warns and ignores it, v0.75 refuses it, and neither applies it, so there is nothing to preserve by keeping it.
  2. A root-level lora: block is not an unknown key. That is the LLaMA-Factory and Axolotl spelling the schema has accepted and moved under training since v0.40.1, and the detector now shares the validator's remap so a spelling the validator accepts can never be one the detector refuses. The release review caught the first draft refusing it.
  3. soup plan and soup apply do not run this check, because they read the YAML as a plain mapping. That gap is filed, not fixed.

Two hardening fixes came out of the same review. Key names reached the terminal unescaped, so Rich markup or raw escape bytes in a YAML key could restyle or spoof it; the loader now uses the shared escaping helper. And the scan was unbounded: 50,000 bogus keys cost about 31 seconds of CPU per Web UI request, measured. It stops at 100 findings and says so.

All 167 recipes, all 21 templates and the shipped example configs scan clean. Two soup fetch examples configs that used the root-level spelling were moved to the canonical training.lora.

Validation loss existed nowhere

It was computed on every backend and thrown away. SoupTrainerCallback.on_log read logs["loss"] and never logs["eval_loss"], so an evaluation step left the last training loss in place and re-reported that to every sink: the evaluated number existed nowhere at all. There was also nowhere to put it, no metrics column and no event field, and the display read only grad_norm, speed and gpu_mem out of its keyword arguments.

The live panel, the metrics table and the /api/train/stream frame now each carry val_loss as its own series, never folded into loss, and the panel gains a Val loss row.

The storage rule is the part worth copying:

  • The panel carries the last measured value forward between evaluations, so the row does not blink.
  • The stored and streamed values do not. A step where no evaluation ran records NULL, so a series read back has one point per measurement rather than one per logged step.
  • Existing ~/.soup/experiments.db files are migrated in place, and rows written before this ship read NULL rather than a fabricated 0.0, because an unmeasured value must not read as a measured one.

MLX produces it too, through the hook that had been called on every evaluation and did nothing, because the base-class method is a pass and therefore a silent no-op rather than an error. Verified on Apple Silicon with a real validation split: 9 display updates, 5 database rows and 5 stream events for 5 evaluations.

torch>=2.6.0, which closes the limitation v0.74.0 published

v0.74.0 shipped a known limitation rather than hiding it: the declared torch>=2.5.0 floor did not work with trl>=0.29, because at 2.5.1 a module TRL imports does not exist, so DPO, KTO, GRPO and BCO were unavailable. pip could not catch it, since TRL declares no torch dependency at all, and CI never saw it because >=2.5.0 always resolves to the newest torch. A fresh install was unaffected; a pinned 2.5.x environment was not.

That release deliberately refused to bump the floor, because 2.5.1 was measured to fail and 2.6 had not been measured to work. This release measured it. The floor is torch>=2.6.0, the reason is that TRL 0.29's preference trainers need the public FSDP2 API introduced there, and CI now proves it rather than asserting it.

The rest of the stack is unchanged: transformers>=5.16.1,<6.0.0, trl>=0.29.0,<1.0.0, peft>=0.20.0,<1.0.0, accelerate>=0.27.0, mcp>=1.10.0,<3, Python >=3.10,<3.13.

Breaking: grpo_variant: gspo is a different objective

gspo was a column-centering heuristic. It is now the published Group Sequence Policy Optimization algorithm (Qwen Team, arXiv:2507.18071): a length-normalised sequence importance ratio with sequence-level surrogate clipping at a default radius of 0.2 or an operator-supplied grpo_delta, isolating masked tokens at exactly 0.0 gradient, invariant to padding tokens and to whether they sit left or right, and invariant to batch permutation.

Existing gspo configs will yield different losses and gradients and will not reproduce prior runs. That is upstream's own wording and it is the whole warning.

grpo_delta is now accepted for gspo as well as required for two_sided, and rejected on every other variant.

The Langfuse live pull

soup ingest has always been a file normaliser: you export from the vendor dashboard, it parses. Langfuse is now the one source Soup fetches for itself.

bash
export LANGFUSE_PUBLIC_KEY=pk-lf-...
export LANGFUSE_SECRET_KEY=sk-lf-...

soup ingest --source langfuse --pull --since 24h --output traces.jsonl

One row per GENERATION observation, off the Observations API v2 because /api/public/traces leaves Langfuse Cloud on 16 November 2026. The bounds are the interesting half and they are all documented on the trace ecosystem page: credentials from the environment only so they never reach the audit log's argv, HTTPS through the same SSRF validator as --slack-url, redirects refused rather than followed with credentials attached, 30 s per request, 64 MiB per response, and a --max-pages cap that exits 1 and writes nothing rather than handing over a truncated dataset.

One correction rode along: the hint this path used to print named LANGFUSE_KEY, which is not a variable Langfuse reads. It names the key pair now.

Security

  • Web UI read endpoints and SSE streams require authentication. Run configurations, logs and system metrics answered unauthenticated until now; only / and /api/health stay open, so the dashboard can still load. SSE authenticates with a short-lived, single-use ticket exchanged over an authenticated POST, so a durable token never rides in a query string. A non-loopback bind without a valid token is rejected with exit 2.
  • soup ui --public no longer serves the FastAPI docs to the LAN. /openapi.json, /docs, /docs/oauth2-redirect and /redoc are absent (404) on a non-loopback bind and unchanged on loopback. Upstream classifies it honestly as reconnaissance, not disclosure: every endpoint the schema describes already answered 401 after the fix above, and this predates it. They are removed rather than gated because /docs is a browser navigation that cannot carry a bearer header, so gating would have broken the page for a developer while leaving /openapi.json readable by any HTTP client.
  • A Web UI training subprocess can no longer hang when no client reads its output. Output drains into a bounded background ring buffer, with multi-subscriber replay via Last-Event-ID.

Also in this release

  • Multipack FFD placement is O(N log N), via a segment tree of per-bin remaining capacity rather than a scan of every open bin. The property that mattered is that the packing is unchanged, verified against the old function over 6,000 randomized cases, 5,384 of them with repeated lengths, for zero mismatches: altering which item lands in which bin would silently change every multipack run. Measured on one box, 5.5x faster at 1,000 rows, 37x at 10,000 and 96x at 30,000.
  • packing: true no longer raises on TRL 0.29. It is an SFTConfig field and was being passed as an SFTTrainer keyword argument, whose __init__ takes none. Separately, packing_cross_doc_attn_mask is now refused at config load, because it never mapped to a valid TRL packing strategy on any released version and was always a TypeError at setup rather than a working mask.
  • task: embedding works again, in two ways: lora.r: 0 full fine-tuning no longer raises Lora rank r must be > 0, and the embedding trainer no longer crashes after setup because it delegated train and save_model but not model or args.
  • PPO forwards training.epochs and ppo_kl_penalty to TRL 0.29's own field names, keeping the legacy spellings working. Worth stating because the two epochs are different things: epochs is passes over the dataset, ppo_epochs is optimization passes inside each PPO update.
  • soup migrate accepts a valid competitor config named *.jsonl. The guard branched on the filename alone, and the content sniff written to gate it had no call site, so a YAML config saved as config.jsonl exited 2 with "got JSONL" and could not be migrated at all. It also reads utf-8-sig now, so a BOM written by Windows tooling cannot defeat it.
  • soup doctor stops reporting the optional [train] stack as missing required dependencies. A core-only install gets one pip install "soup-cli[train]" suggestion, with the CUDA wheel index on NVIDIA hardware, instead of bare per-package floors.
  • Terminal charts route through Rich, so NO_COLOR and redirected output carry no raw ANSI escapes while interactive charts keep their colour.
  • The GEMM throughput forecast measures in the card's resolved stream dtype, bfloat16 where the hardware has it and float16 otherwise, instead of hard-coding bf16, and it prints the dtype beside the TFLOPS and the clock.
  • A Turkish README.tr.md, behind a CI gate that checks what a translation was synced from rather than only when. Every translation carries a synced-from stamp over LF-normalised bytes, so a CRLF checkout on Windows agrees with Linux and macOS; translations are found by glob, so a new language cannot land unchecked; and each section must keep the original's fenced-code languages, external URLs and inline-code spans, in both directions. Run against the previous draft, that last check found 29 dropped and 3 invented code references, including two release-note bullets that do not exist in the English README.
  • Four new recipes take the catalog to 167: deepseek-v4-flash-dpo, kimi-k2.6-dpo, qwen3.5-0.8b-grpo and qwen3.5-2b-grpo. The two DPO recipes carry upstream's own rider: they are not trained, because those bases are MoE and multi-node, so no hyperparameter in them is a measured recommendation. Their values are stated as consistency with their siblings instead, and the two GRPO recipes say explicitly that they follow the GRPO templates rather than inheriting their SFT siblings' values.

What was measured, and what was not

No maintainer gate, and upstream says so plainly: nothing in this release was gated by a new measurement. Three contributor records landed in the window instead, and all three are in the public benchmarks directory.

A pinned CUDA host allocation does not consume /dev/shm. Measured on an NVIDIA L4, two fresh-process pinned runs each added exactly 4 GiB to RssShmem with no change to RssAnon and no change to /dev/shm usage, while the pageable control did the opposite. The conclusion is a design one: the streaming pinned-store path does not need a /dev/shm free-space pre-flight. The record is careful about what it does not establish, and so are we: it does not cover other drivers or kernels, it says nothing about reclaimability, and its host-wide counters are explicitly not attributable to the allocation, because other tenants shared the box.

A QuEST W4A4 supervised gate failed, and is published as written. Its own first line is the headline: *gate failed, 0.114 nat, one training seed, one model, fake quantization, not parity, not an integration, not an efficiency claim.* The failure criterion was preregistered and both halves of it failed. No arm was promoted, the reserved confirmation panel was never touched, and a missing provenance field was left missing rather than backfilled, with the record stating that a driver version queried two days later is not evidence of the driver used on the day. There is no QuEST option in Soup and none is implied.

An 8 GB M1 MLX run, whose own header says it is not a gate. What it establishes: the shipped llama3.1-8b-sft-mlx recipe trains on an 8 GB Mac, 48 iterations in 71 s at a 5.154 GB peak, with the adapter written and reloadable, and dispatch asserted before the timer started so a transformers-path number could not be published as an MLX one. The mechanism is the useful part: a model too big for RAM there does not fail, it pages, because MLX memory-maps weights, so low free memory is the normal shape of this workload and an allocation-failure check would not fire. That is the WDDM spill trap arrived at from the opposite platform. What it does not establish is longer than what it does, and the record says so: no correctness, gradient-exactness or quality claim, the loss curves are memorisation over 48 repeated synthetic rows, and every throughput figure in it is an order of magnitude rather than a benchmark — re-running one model at the same config from a warm cache gave a 3.5x different rate.

The preprint is untouched. No measured number moves and the scope does not change, because nothing in this window touches layer streaming's mechanism or its architecture list. It stays scoped to v0.73.0, as its own header says.

Known limitations

  1. The unknown-key refusal is a hard break for a config that was silently carrying an unknown key. v0.74 warned and ignored it, v0.75 refuses it, and neither applies it.
  2. soup doctor --config knows seven settings on one task and backend pair, and reports nothing elsewhere rather than guessing. The field-consumer guard behind it has a measured leak, and its all-clear message overclaims.
  3. soup plan and soup apply do not run the unknown-key check. soup draft distill and soup shrink surface the loader's ValueError as a raw traceback. Six command modules still carry a private copy of the terminal-escaping helper the loader now imports from one place. The Web UI's YAML endpoints have no request-size cap.
  4. Streamed-NF4 bit-exactness does not hold on Blackwell. Two CUDA-only tests fail on an RTX 5070 (sm_120) at 4.9e-4 and 3.9e-3, while the quantization: none variants pass — so the divergence is in the bitsandbytes NF4 path on that card rather than in layer streaming. CI has no GPU runner and cannot see it, and the root cause is not established. It is not the same defect as the one v0.73.0 repaired, which had a megabytes-per-layer boundary rather than a card-architecture one, and unlike that one this is not fixed. See what "bit-exact" does and does not mean.
  5. The Turkish README was re-synced for this release by release tooling, not by its translation owner. It passes the structural gate; the contributor's polish is the standard it was merged under.
  6. The reward-hack mitigation controller has never had a valid pid_lagrangian run. The mechanism is implemented and the mode is selectable; its efficacy is not established.
  7. Layer streaming remains BETA.

See also

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.