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 third release running written almost entirely by other people (24 of 24 pull requests in v0.73.3, 116 of 120 in v0.74.0, and now 60 of 60), 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, by upstream's count 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. Two riders: an adapter trained with train_on_responses_only: false saw the doubled BOS in training, so it now differs from inference by one token the other way, and the vLLM, SGLang and DeepSpeed-MII engines are not fixed (tracked upstream as #781). See the ship page
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 eval (upstream's note about the training callback; at v0.75.2 no trainer hands the eval block to that callback, so soup train never reaches it, see Experiments)The 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

What v0.76.0 changed about the rows above:

  • The doubled BOS on the vLLM, SGLang and DeepSpeed-MII engines was fixed in v0.76.0 (#867, #890, #891). soup serve --backend vllm is handed token ids, SGLang is sent the ids at its /generate endpoint, and MII receives a string that carries its ids through a tokenizer wrapper, whenever the chat template rendered the prompt. A verdict, benchmark or usage.prompt_tokens taken through one of those three engines on v0.75.2 or earlier was measured on a prompt that differed from training by a token. A model with no chat template still goes to the engine as a string. The rider about train_on_responses_only: false is fixed for new runs as well (#788): an adapter trained on v0.75.x still carries the extra BOS.
  • loraplus_lr_ratio is refused on backend: mlx at load (#1324), because the MLX optimizer uses one learning rate for every LoRA tensor. The row's "Run it" is true of the transformers backend, where since v0.76.0 it applies on every LoRA task (#745, #1080) and no longer only on sft, pretrain and embedding.
  • The streaming data.interleave leak is closed (#702, #729). The streaming over split is now sized over distinct rows, so a row and its recycled copy no longer land on both sides of the split. It falls back to repeated content only when it must, and then warns with the count.
  • eval.auto_eval: true runs after soup train now (#1487, #1546), so the soup eval auto row's parenthetical about the training callback no longer holds; see Experiments.

What v0.77.0 changed: AWQ export is removed (#1951), so re-exporting an AWQ artifact needs v0.76.x and its [awq] extra, for example pip install "soup-cli[awq]<0.77"; upstream notes that both AutoAWQ and AutoGPTQ are archived and incompatible with [train]. The row's other half, that the artifact was calibrated on data you never chose, still stands for an export made before v0.75.0.

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 them in one backend (upstream counts six; its fix entries name eight settings).

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. (Since v0.76.0 the allowlist is 24 names, not 32; eight of the 24 have an MLX equivalent and the rest are refused by name on MLX. adamw_hf and muon now work only on this backend. See the optimizer zoo.) 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. Every other pair has no table, so the command prints its Every setting this config writes is read on this backend. line for them regardless, which there means "not reviewed". 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.

(Updated in v0.76.0: the table is larger and the all-clear line reads differently. The declared entries are now four: task: sft on backend: mlx with 18 settings instead of seven, task: sft on backend: unsloth with three, and SFT on the transformers backend with a vision or an audio modality with two each. The line for a config with nothing unread now reads None of the N setting(s) known to be unread on task=<t> backend=<b> is switched on in this config. The narrowness disclosure above still stands: outside those four entries the command reports nothing, which is not an all-clear (upstream's #1815). See the MLX backend page and Settings Soup refuses at load. Since v0.77.0 the table has eight entries: sft on mlx with 12 settings, dpo, orpo, simpo and kto on mlx with 9 each, sft on unsloth with none, and vision and audio SFT on transformers with one each.)

(Added in v0.76.0: soup adapters audit <adapter-dir> --config soup.yaml reads the settings an adapter's adapter_config.json recorded back after a run and reports ok, DIVERGED or unknown for each one it can compare against the config. It exits 0 when no row diverged (unknown rows do not fail), 2 on a divergence and also when --config is forgotten, and 1 on a usage or read error. See Is the adapter alive.)

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. (Since closed in v0.76.0, #894 and #909: both commands refuse an unknown key with the same message and exit 1.)

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. (Corrected in v0.76.0, #1223 and #1313: that is accurate for the MLX backend only. On the transformers backend no evaluation ran at all. The task wrappers handed the held-out split to the trainer and none set an evaluation schedule, whose Hugging Face default is "no", so the default data.val_split: 0.1 withheld 10% of your rows from training and used them for nothing, and val_loss stayed empty until v0.76.0. It is the first release in which a transformers run fills the series described below; see the v0.76.0 page.) 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. (Python is >=3.11,<3.14 since v0.77.0.)

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.

(Since v0.76.0, #1232: gspo and every other non-standard variant also apply grpo_beta as a KL penalty, which they validated and dropped before. See GRPO Plus.)

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 (a guard that no option of soup ui reaches at v0.75.2, since a valid token is always generated; see the Web UI page).
  • 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.
  • Telemetry is wired to command exit, strictly opt-in, and sends nothing as shipped (#529). It is off unless SOUP_TELEMETRY=1. --no-telemetry is a root option and goes before the command (soup --no-telemetry train); after the command name it is refused with No such option. The payload is seven hardware-only fields: Soup version, command name, Python major and minor, operating system, architecture, duration and an anonymous id. The key bundled in the wheel is a placeholder the sender refuses by name, printing Notice: telemetry is not yet live., so no request is made unless you supply a key of your own. The payload, the policy and the placeholder.
  • 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 opening sentence is the headline: "Gate failed: 0.114 nat [0.099, 0.130], 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 model the shipped llama3.1-8b-sft-mlx recipe names trained on an 8 GB M1 under a smaller config than the recipe's (LoRA r=8, batch 1, max_length: 512, one epoch) in 48 iterations and 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 for every other pair it prints the same all-clear line whether or not a setting is unread, so there it means "not reviewed". The field-consumer guard behind it has a measured leak, and its all-clear message overclaims. (Since v0.76.0 it has four declared entries covering 25 settings, 18 of them on task: sft with backend: mlx; the narrowness stands, and upstream tracks it as #1815. Since v0.77.0 it has eight entries, with 12 settings on task: sft with backend: mlx; #1815 is still open.)
  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. (All four halves of this item are closed in v0.76.0. soup plan and soup apply refuse an unknown key (#909); they run only that check, not the schema validators, so soup plan --config writes a plan for training.quantization_aware: true, which soup doctor --config refuses (see Settings Soup refuses at load). soup draft distill prints Invalid rendered distill config: and soup shrink prints Invalid rendered heal config:, both exiting 1 instead of a traceback (#895, #904). The terminal-escaping helper now has one copy, and a test fails if another appears (#896, #907). The Web UI answers HTTP 413 above 1 MiB on /api/config/validate, /api/train/start, /api/config/from-form and /api/chat/send, and above 8 KiB on /api/data/inspect (#897, #915, #939, #948).)
  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. (Since v0.76.0, #1143: the streamed CUDA NF4 forward follows bitsandbytes' own fused-versus-dequantized dispatch, the same kernels resident NF4 uses; the backward still dequantises. The only evidence that this closes the Blackwell divergence is contributor text and tests: benchmarks/ has no post-#1143 row, and upstream's #776 is still open in its records. 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.