Is the adapter alive: soup adapters check and audit
Two commands added in v0.76.0 answer the question a healthy loss curve cannot: did this run produce an adapter that does anything, and did it train the way the config said? Both exist because each of the failures below looked like a normal run. v0.72.0 saved layer-streamed adapters under keys that loaded as the untuned base, a streamed run on peft 0.21 or newer saved an adapter holding zero tensors, and the MLX backend accepted training options it silently dropped (see what MLX honours).
| Command | Question | Needs |
|---|---|---|
soup adapters check | Is this LoRA adapter, saved by the transformers path, trained at all? | the adapter directory only |
soup adapters audit | Did the run use the settings in soup.yaml? | the adapter directory and the config |
Neither loads a model, uses a GPU or touches the network. They are part of the soup adapters group described on the adapters page.
soup adapters check
soup adapters check ./out/adapter
soup adapters check ./out/adapter --jsonIt reads adapter_model.safetensors and adapter_config.json and prints one verdict as its last line: alive, or inactive: <reason>.
Adapter: /home/you/project/out/adapter
Total ||ΔW||_F: 13.316776
Live fraction: 100.0% (4/4 layers)
All-zero lora_B layers: 0/4
Leaked .inner. keys: 0
aliveFor every LoRA pair it computes ||ΔW||_F = scaling x ||B A||_F in float64, where scaling is lora_alpha / r (lora_alpha / sqrt(r) when the config sets use_rslora, and 1.0 when adapter_config.json has no r or lora_alpha). The total is the square root of the sum of the squares. A layer is live when its lora_B is not all zero and its delta is above zero.
The verdicts
The first match in this list wins, so a leaked-key adapter reports the leak even if its weights are also zero.
| Last line | What it means |
|---|---|
inactive: leaked .inner. keys detected (N tensors) | Tensor names carry the extra .inner. segment that v0.72.0 layer streaming wrote. Loaded into an ordinary model they match nothing, and the result is the untuned base. The weights themselves can be healthy; the adapter is still reported inactive |
inactive: incomplete LoRA pairs detected (N orphans) | A lora_A without its lora_B, or the reverse |
inactive: shape mismatch in LoRA projections (N layers) | A pair whose shapes cannot multiply. Best effort: a pair that happens to fit the transposed layout is read as that layout and reported alive |
inactive: non-finite weights detected (NaN/Inf) | The printed total reads nan |
inactive: no lora weights found | No lora_A / lora_B tensors at all. This is the verdict for the zero-tensor adapter described on the v0.75.1 page, and for a non-LoRA PEFT adapter such as VeRA |
inactive: all lora_B layers are zero (N/N) | The adapter is still at its initial state: lora_B starts at zero, and nothing moved it |
inactive: lora_B layers are zero (k/N) | Any all-zero lora_B marks the whole adapter inactive, even when the other layers moved |
inactive: total ||ΔW||_F is 0.0 | Every delta is zero by another route |
alive | Every pair is complete, finite and non-zero, and the total delta is above zero |
Optional lines list the offending tensors for Zero lora_B projections:, Incomplete LoRA pairs:, Shape-mismatched LoRA pairs: and Leaked wrapper keys: (up to five, then ... and N more); Standalone non-LoRA tensors: N gives only a count. In the per-layer names an internal __LORA__ placeholder stands where lora_A or lora_B would be.
Exit codes
| Code | Meaning |
|---|---|
| 0 | alive |
| 2 | inactive. A usage error (missing ADAPTER, unknown flag) is also 2, so read the last line as well as the code |
| 1 | The command could not read the adapter (see below) |
Exit 1 covers: a directory that is missing, outside the current directory or a symlink; no adapter_model.safetensors; an adapter_model.bin (re-save adapter as safetensors); a weights file over 2 GiB, which is refused from its size before it is opened; a corrupt weights file; an adapter_config.json that is not a JSON object; and a missing safetensors package.
What it cannot check
- It needs
safetensorsand NumPy. The command is described upstream as running without PyTorch, and it does, but neither package is a dependency of the light core install. On a core install it exits 1 withsafetensors package required; pip install safetensors. The training extras bring both. - MLX adapters exit 1. The MLX trainer writes its weights as
adapters.safetensors, and the command only looks foradapter_model.safetensors, so an MLX adapter directory reportsno adapter_model.safetensors found. Use the audit command below for those: it reads the MLX record. - A weights file holding
bfloat16tensors exits 1. The command reads the tensors through the NumPy interface ofsafetensors, and NumPy has nobfloat16type. On a hand-built file with bf16 tensors, read by the v0.76.0 binary, the result wasCorrupt or unreadable safetensors weights in adapter_model.safetensors: data type 'bfloat16' not understoodand exit 1, while a float32 file read normally. Whether an installedml_dtypespackage changes that was not checked, and which of your adapters hold bf16 tensors depends on how they were trained and saved. - Only LoRA adapters saved by the transformers path are in scope. A VeRA adapter, or any other non-LoRA PEFT adapter, has no
lora_A/lora_Bpairs and reportsinactive: no lora weights found, which is a statement about the format, not about whether it trained. rank_patternandalpha_patternare ignored. An adapter with per-module ranks gets the singlelora_alpha / rscaling, so the printed||ΔW||_Fis a health number, not an exact delta.alivemeans the adapter moved away from its initial state. It says nothing about whether the model got better. For that, runsoup ship.
--json
--json prints one object with sorted keys: adapter, verdict, verdict_line, reason, total_frobenius (null when not finite), live_fraction, live_layers, total_layers, all_zero_lora_b_layers, inner_keys, orphaned_layers, shape_mismatches, standalone_tensors and per_layer (per pair: name, frobenius, is_b_zero, has_non_finite, shape_a, shape_b). The exit code is the same as in text mode.
soup adapters audit
soup adapters audit ./out/adapter --config soup.yaml
soup adapters audit ./out/adapter -c soup.yaml --jsoncheck asks whether the adapter is alive. audit asks whether the run that produced it used what you asked for. It loads soup.yaml through the schema (an omitted key is compared at its schema default), reads the settings recorded in adapter_config.json next to the weights, and compares them row by row.
The rows are optimizer, learning_rate (against the recorded peak, not the value copied from the config), scheduler, warmup_ratio, weight_decay, max_grad_norm, gradient_accumulation_steps, gradient_checkpointing, data.train_on_responses_only, lora.r and lora.alpha, plus nan_skip_fraction for task: grpo. Each ends as ok, DIVERGED or unknown. A setting the record cannot speak to is unknown and never ok.
The case it was written for is a warmup that rounds away. warmup_ratio: 0.03 over 12 optimizer updates is zero warmup steps, so training started at the peak learning rate:
warmup_ratio: 0.03 x 12 optimizer updates rounds to 0, so training started at the peak learning rate; raise warmup_ratio or lower gradient_accumulation_steps to produce more updates
4 divergence(s)What the record contains depends on the backend
| Adapter from | What adapter_config.json records | What audit can check |
|---|---|---|
| The MLX backend | The effective optimizer, peak learning rate, schedule, warmup and total updates, weight decay, gradient clipping, accumulation, checkpointing, the response-only mask and the LoRA shape | All eleven rows |
| The transformers backend | PEFT's own file, which holds LoRA shape and nothing else | Only lora.r and lora.alpha. The other nine rows read unknown, and the footer says so |
| Neither (an empty or unrecognised record) | Nothing Soup can read | Every row is unknown |
An adapter that agrees on what it can check exits 0 with No divergences, 9 unchecked (or 11 unchecked for an empty record). That is a statement about what was checkable, not a clean bill for the run. A CI job should read checked_count in the --json output to tell "everything agreed" from "nothing could be compared".
Exit codes
| Code | Meaning |
|---|---|
| 0 | No row diverged. unknown rows do not fail the command |
| 2 | At least one row is DIVERGED |
| 1 | A path outside the working directory or a symlink, no adapter_config.json, a missing or invalid config, or a config the schema refuses (an unknown key exits 1 with Could not load <path>: unknown config key '<key>' - refused.) |
A forgotten --config is exit 2 as well. --config is a required option, Click rejects the call before the command runs, and Click's code is 2, the same as a divergence. A script that treats 2 as "the run diverged" cannot tell the two apart from the code alone.
--json
The object holds record_kind (peft, mlx or unknown), diverged_count, unknown_count, checked_count, rows (per row: setting, asked, ran, status, detail) and unknown_reason. Warnings from loading the config go to stderr, so stdout parses.
Where to go next
- Adapter lifecycle and the adapters page: diff, merge, bisect, blame, pr and the lockfile.
- Supply-chain security:
scan,sign,verifyandcheck-safetensors, which ask different questions (is it safe to load, is it the file that was signed). - Exit codes: both commands in the table, including the codes that collide with Click's.
- A v0.75.1 adapter with nothing in it: the failure this was built to catch.
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.