Post-train X-rays (v0.66.0)

Mechanistic-interpretability on every fine-tune. Four probe families that surface what changed inside the model, not just what changed in outputs. Pure descriptive — no auto-mitigation, designed for CI logging and model cards.

soup probe sae-diff — Sparse Autoencoder feature movement

bash
soup probe sae-diff ./gemma_scope.safetensors ./pre_acts.json ./post_acts.json \
  --top-k 20 --output ./sae_diff.json

Encode pre-FT and post-FT activation batches through a Sparse Autoencoder; report the top-K features whose mean activation moved the most. Closed SAE-repo allowlist — and as of v0.71.8 --auto-download fetches one from the HF Hub into ~/.soup/sae-cache/ (validated against the allowlist BEFORE any network call, via an SSRF-hardened snapshot_download):

  • Gemma Scope (2B / 9B / 27B residual-stream)
  • EleutherAI Pythia SAEs
  • JBloomAus Llama SAEs
  • OpenAI GPT-2 SAE

Bounds: top-k [1, 10K], up to 1M features, up to 1M tokens per batch, 16 MiB evidence cap. v0.71.8 also adds soup probe sleeper --weights, soup probe truth / harm, soup probe interference --measure (live PEFT multi-adapter N×N matrix), and soup train --capture-activations. That last one has a partner flag, soup train --capture-prompts <path>, which records the prompts the activations belong to; the capture is not much use without them, and the path is cwd-contained and symlink-rejecting like every other write Soup makes.

soup probe sleeper — defection-agent classifier

bash
soup probe sleeper meta-llama/Llama-3-8B --evidence ./activations.json --output ./sleeper.json

A linear defection probe applied to a 2D activation tensor ({"activations": [[...], ...]}, one row per token, each row as wide as the probe's hidden size, 4096 for Llama-3-8B; a mismatch exits 2). It reports the flagged-token rate and a verdict, and exits 2 on MAJOR:

Flagged rateVerdict
≤1%OK
≤5%MINOR
>5%MAJOR

The bundled probe weights are synthetic placeholders. With no --weights, the probe is a deterministic vector seeded from the base name: the math is real, but the six bundled bases carry no real calibrated vectors (the module itself lists this as a known limitation), so a verdict from the bundled probe only exercises the plumbing. It says nothing about your model. For a real reading pass --weights <probe.npz|.npy|.safetensors>, which also accepts any base id, or derive a probe from your own labelled activations with compute_contrast_probe (the contrast-pair method). The same holds for soup probe truth and soup probe harm, which take the same arguments (an honesty probe and a misuse probe, with 5% / 20% bands).

The base id is matched case-insensitively against the six bundled ids, and nothing shorter is accepted. The bundled bases are Qwen/Qwen2-7B, google/gemma-2-2b, google/gemma-2-9b, meta-llama/Llama-2-7B, meta-llama/Llama-3-8B and mistralai/Mistral-7B-v0.1; a short name such as llama-3-8b exits 2 (soup probe pack --list prints them). With no --evidence the command prints the probe's description and a "Pass --evidence" notice and exits 0 without scoring anything; if you also pass -o, the file it writes is a neutral report with 0 tokens and verdict OK, which is not a result. 16 MiB cap; symlink rejection.

soup probe sleeper, truth, harm and sae-diff import numpy, which Soup's light core install does not declare; it arrives with the training stack. On a bare pip install soup-cli they exit 1 with ModuleNotFoundError: No module named 'numpy' (checked at v0.76.0 on an install without it). soup probe pack and soup probe interference (the losses-JSON form) do not need it.

soup probe interference — N×N adapter compatibility matrix

bash
soup probe interference ./losses.json --output ./matrix.json

Input is operator-measured per-pair losses; output is an N×N catastrophic-interference matrix:

score(A → B) = (loss(A_target | A+B) - loss(A_target | A alone)) / loss(A alone)

The losses file is JSON, where the key "a|b" is the loss on A's domain with A and B loaded and "a|a" is the baseline with A alone:

json
{"adapters": ["a", "b"], "losses": {"a|a": 1.0, "a|b": 1.05, "b|a": 1.3, "b|b": 1.0}}

Or let Soup measure them: soup probe interference --measure ./eval.jsonl --base-model <id> --adapter a=./a --adapter b=./b [--device cpu] (two or more adapters).

Absolute scoreVerdict
<5%OK
<20%MINOR
≥20%MAJOR (exit 2, gates CI)

Bounds: 2..16 adapters (4..256 pairs). Adapter names ≤256 chars; markup-escaped before render against injection.

soup probe pack — bundled probes per base

bash
soup probe pack meta-llama/Llama-3-8B --output ./pack.json
soup probe pack --list

Per-base manifest listing the sleeper, truth and harm probes for that base (the same six base ids as above; any other id exits 2). Metadata only: no weights are embedded, and there is no probe-weights fetcher, so bring your own with --weights (no command fetches probe weights; soup probe sae-diff --auto-download downloads an SAE, which is not a probe weight). 1..32 probes per pack; per-field caps against operator-controlled-input bloat.

soup rewind: which rows were behind a loss spike (v0.76.0)

bash
soup rewind                       # the most recent run
soup rewind <run-id> --step 412 --top 20 --json rewind.json

A training flight recorder and its reader. During an SFT run, training.rewind_log (on by default) writes <output>/rewind.jsonl: for every micro-batch, the dataset row indices and each row's loss and token count. soup rewind reads it back, finds the steps whose loss jumped, and ranks the rows of the worst one by how much of that step's loss they carried. A step is a critical spike when its token-weighted loss is not finite, and a warning spike when it is more than twice the median of the previous 20 measured steps (it needs at least 3 of them). It then rebuilds the dataset to preview those rows, and withholds the preview with a notice when the dataset on disk no longer matches the one the run trained on. --no-preview skips loading the dataset, --step N details a step of your choice, and --top sets how many rows to list.

Three limits worth knowing. The recorder is SFT-only, so a DPO or GRPO run leaves no log, and soup rewind lists that among the three causes of a missing one. It is also switched off for resumed runs, with packing, and on a distributed launch (the run prints one line saying so); soup rewind then finds no usable log and exits 1. A run with no spike prints No loss spikes recorded., exits 0 and, unless you pass --step, does not write the --json file. And when a loss is not finite the JSON carries NaN and Infinity literals, which a strict JSON parser rejects. Exit codes are 0 for an analysis (a clean run included), 1 for a missing run, log or step (or a log with no recorded micro-batches), and 2 for an invalid --json path or a mistyped flag. The full reference is on the rewind page.

Influence-function blame (the CLI scores are synthetic)

Bundled in v0.66 alongside the probe family: a DataInf-style row attribution kernel that scores each training row by the cosine of its gradient against a probe gradient, times the row norm, and ranks the top rows. The kernel is real, but soup adapters blame does not feed it your adapter's gradients: the CLI calls it with no probe function, so it ranks deterministic synthetic vectors seeded from the adapter path, dataset path and layer label. Treat its table as a placeholder until a caller supplies real per-row gradients; see Adapters. The idea it is meant for pairs with v0.67 soup adapters bisect: bisect finds which checkpoint broke, blame is meant to say which rows caused it.

See also

  • Diagnose — the 7-probe report card (6 at v0.56, citation recall added in v0.71.10); v0.66 adds 4 more probes on top.
  • Rewind — name the dataset rows behind a loss spike.
  • Adapter lifecycle — v0.67 bisect finds the broken checkpoint; blame is the intended follow-up for row-level attribution.

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.