soup loop — the production data flywheel, all from the CLI
NVIDIA's data-flywheel reference needs a multi-service stack. Tracing and observability tools capture the traffic, but turning those traces into a training set, and the training set into a gated release, is a separate job they do not do. soup loop is that job in one command, offline.
soup loop (v0.58.0) ships it as one CLI on a laptop. It connects 8 of Soup's existing uniques — v0.26 Trace-to-Preference, v0.26 Eval-Gated Training, v0.26 Registry lineage, v0.26 Quant-Lobotomy verdicts, v0.26 Soup Cans, v0.25 Autopilot, v0.54 Advise, v0.55 Eval Design, v0.56 Diagnose — into a single loop:
production traces → preference pairs → eval-gated DPO → canary deploy → auto-rollback
Six subcommands
soup loop init <served-model> --eval <suite> --baseline registry://<id> \
--monthly-budget 50usd --max-runs-per-day 3
soup loop status
soup loop watch [--foreground|--detach] [--max-iterations N] [--poll-interval F]
soup loop pause / resume
soup loop canary <adapter> --traffic 5% [--autoroll-on-regress]
soup loop replay [<iteration-id>]Control plane
The full loop state lives in a single atomic file: .soup/loop.yaml (JSON-formatted via tempfile.mkstemp + os.replace, cwd-containment, direct os.lstat symlink rejection, 1 MiB cap, POSIX 0o600).
Frozen LoopState schema:
served_model/eval_suite/baseline/status(one of{running, paused, stopped})- lifetime counters
traces_collected,pairs_distilled,runs_gated,adapters_shippedanditeration_count, plusruns_todayandlast_run_datefor the daily cap (soup loop statusprints the five counters, andruns_todayasN / capwhen a daily cap is set) - canary + budget + daily-cap metadata
- iteration metadata
to_dict returns a MappingProxyType so callers can't mutate state through it. Only with_status and bumped are sanctioned mutators.
Canary router
soup loop canary <adapter> --traffic 5% --autoroll-on-regress splits traffic via deterministic SHA-256 hash routing:
- 4-byte slice mod
_HASH_MOD = 10_000→ ±0.01% split granularity - Per-request
route(policy, request_key)is pure - Verdict via
BucketStats.verdict()returnsOK/MAJOR/UNKNOWNusing v0.26 Quant-Lobotomy thresholds (5-pct regression band, min 30 samples) BucketStatsis mutable withthreading.Lockfor live request streams- Cross-field validation: canary == stable rejected,
traffic_pct ∈ [0, 100] rollbackreturns a new policy with the canary cleared (sticky-on-rollback)
Budget guardrails
parse_budget_string accepts "50usd" / "50" / "50 USD" (≤ $1M hard cap). Each iteration runs through check_budget → frozen BudgetDecision(proceed, reason, projected_total_usd, runs_today). Check order:
- daily-cap (UTC-day rollover via
reset_daily_counter_if_new_day) - estimate-sanity — refuse implausible cost estimates
- monthly-budget — projected total vs configured cap
Budget-skipped iterations produce no manifests — no half-records to confuse soup loop replay.
Watch daemon
soup loop watch orchestrates 5 stage callables: HarvestFn / TrainFn / GateFn / DeployFn / CostFn.
You do not have to write those callables. Since v0.71.4, --pre-wired binds real ones that compose the surfaces you already have: v0.26 trace-to-preference for the harvest, a soup train subprocess for the train, the v0.55 eval-gate for the gate, and the v0.30 /v1/adapters/activate hot-swap endpoint for the deploy. It is opt-in on both entry points, and --pack-cans additionally seals each iteration as a Soup Can.
soup loop init <served-model> --eval <suite> --baseline registry://<id> --pre-wired
soup loop watch --pre-wired --pack-cansTwo environment variables point the pre-wired stages at your own infrastructure: SOUP_LOOP_TRACE_DIR for where harvested traces live, and SOUP_LOOP_SERVE_ENDPOINT for the server to hot-swap against. Without --pre-wired the stage bindings are still no-op stubs, which is the shape the daemon shipped in at v0.58.
The deploy stage sends no bearer token, and since v0.75.1 the adapter routes can require one. It POSTs to /v1/adapters/activate/<name> with no Authorization header, and there is no environment variable to give it a token. From v0.75.1 POST /v1/adapters/activate/{name}, POST /v1/adapters/deactivate and GET /v1/adapters on soup serve check --tool-auth-token whenever one is set, and soup serve already refuses a non-loopback bind without one. So the pre-wired deploy only works against a loopback soup serve started without --tool-auth-token. Against a protected server the activation is answered 401, the iteration records activate POST failed and nothing is promoted. The endpoint itself must be a URL whose host is localhost, a loopback address, or a private or link-local IP literal, and plain http is accepted only for loopback: a private or link-local address needs https, and soup serve has no TLS option, so in practice the deploy stage reaches a loopback soup serve or one behind a TLS proxy. Any other hostname is refused rather than resolved.
run_once(state, config)is pure with respect to time (testable).watch(config)is the long-running daemon. InstallsSIGTERM/SIGINThandlers.- Reloads state every iteration so external
pause/resumetakes effect immediately without restarting the daemon. --detachspawnspython -m soup_cli.cli loop watch --foregroundvia argv-listsubprocess.Popen— no shell, no string interpolation.maybe_rollbackfires only on"MAJOR"canary verdict.
Iteration manifests
Every iteration writes a frozen IterationRecord to .soup-loops/<iteration_id>/iteration.json:
iteration_id iter-20260515T231600-a1f3b2c4
started_at 2026-05-15T23:16:00Z
finished_at 2026-05-15T23:25:12Z
pairs_harvested 89
run_id run-8f3
gate_verdict OK # OK | MAJOR | SKIPPED
canary_verdict OK # OK | MAJOR | UNKNOWN | None
shipped true
rolled_back false
estimated_cost_usd 2.40
notes "" # a string; "activate POST failed" after a refused deploynew_iteration_id = iter- + UTC timestamp + 8-hex uuid.uuid4() (for example iter-20260515T231600-a1f3b2c4). soup loop replay walks the directory in chronological order.
soup loop replay with no argument lists the recorded iteration ids, and with an id it prints that iteration's manifest. --extract <dir> unpacks the iteration's Soup Can (.soup-loops/<iteration_id>/iteration.can) into <dir> instead of printing the manifest. That file exists only for iterations that ran with --pack-cans; otherwise the command exits 1 with no .can for <id>. <dir> must sit under the current directory and not be a symlink (exit 2).
Known limitations
- Stage callbacks default to no-op stubs — live trace ingestion, registry baseline auto-pick, and
/v1/adapters/activaterollout are operator-driven viaWatchConfig. Since closed in v0.71.4:--pre-wiredbinds production stage callables, so the default is the only thing that is still a stub. - The pre-wired deploy stage sends no bearer token (see above), so it cannot activate an adapter on a
soup servethat was started with--tool-auth-token(v0.75.1 and later). --detachis a single-process subprocess; full daemonization (double-fork, session leader, /dev/null fds) is deferred.
See also
- Trace-to-preference — the
HarvestFnhalf of the loop - Eval-gated training — the
GateFnhalf - Registry — where
baselineand shipped runs live - Diagnose — pair with
soup train --diagnose-gatefor a second safety net - Advise — what to run before a loop iteration
- Trace ecosystem — v0.63
soup ingestfeeds the loop'sHarvestFn - Adapter lifecycle (v0.67) —
soup lockcommits a SHA256(base || dataset || env) closure so the whole team coordinates on identical reproducible loop iterations;soup adapters bisectfinds the broken iteration when the canary regresses - Pre-flight & tooling (v0.64) —
soup plan/soup applydrift-check the run plan before each loop iteration triggers a training
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.