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

Seven subcommands

bash
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 | --no-autoroll-on-regress]
soup loop replay [<iteration-id>] [--extract <dir>]

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_shipped and iteration_count, plus runs_today and last_run_date for the daily cap (soup loop status prints the five counters, and runs_today as N / cap when 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% records the canary and its traffic share in .soup/loop.yaml; automatic rollback on a regression is on by default (--no-autoroll-on-regress turns it off). The split itself is made by soup serve, which since v0.76.0 reads that file (see Serving the split below). The router 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() returns OK / MAJOR / UNKNOWN using v0.26 Quant-Lobotomy thresholds (5-pct regression band, min 30 samples)
  • BucketStats is mutable with threading.Lock for live request streams
  • Cross-field validation: canary == stable rejected, traffic_pct ∈ [0, 100]
  • rollback returns a new policy with the canary cleared (sticky-on-rollback)

Serving the split

(Since v0.76.0, #815. Through v0.75.2 soup loop canary only wrote the state file and soup serve never read it, so no request was routed to a canary.) The routing works on the transformers backend, when the canary adapter was loaded with soup serve --adapters:

bash
soup serve --model ./base --adapters candidate=./candidate
soup loop canary candidate --traffic 5% --autoroll-on-regress

Start soup serve from the directory that holds .soup/: it takes the loop state and canary-stats.json from the working directory, and no flag is needed for them.

  • The key. Send a stable key as the conversation_id field of the request body or an X-Conversation-Id header on POST /v1/chat/completions. The router hashes it, so every request in one conversation stays in the same bucket. A request with no key keeps the normal active-adapter behavior, and an explicit adapter field still wins. Clients choose their own key, so a client can deliberately pick one that hashes into the canary bucket.
  • The stable bucket uses the served base model, even when another adapter was activated by hand. POST /v1/adapters/activate/<name> remains an explicit 100% cutover for unkeyed requests and does not change the canary policy.
  • What is counted. Completed generations and generation errors are counted per bucket in .soup/canary-stats.json. The verdict therefore compares error rates; it does not score the quality of the output.
  • The verdict. soup loop watch evaluates those counters after at least 30 canary samples. The verdict is MAJOR when the canary's success rate trails the stable bucket's by more than five percentage points, and with --autoroll-on-regress the watcher atomically clears the canary and its share from loop.yaml. The serving process re-reads the policy when the state file is replaced, so the rollback applies from the next keyed request.
  • Buffering. Counters are flushed after 64 outcomes or two seconds, and on orderly shutdown, so the stats file and the verdict lag traffic by that window, and a crash can lose it. A canary that kills the serving process never records its own outcome: recording happens only after generation returns or raises, so such crashes are invisible to the verdict unless external monitoring sees them. Rollout ids keep one promotion's samples from being counted for the next.
  • Not routed. A request with no key, a MoLE-serving run, a soup serve with no --adapters loaded, and any non-transformers backend are not routed by this mechanism. soup serve warns once per failure episode if the policy file cannot be read or the canary adapter is not loaded.

This section is read from the code and from upstream's documentation (utils/canary_router.py, commands/serve.py, utils/loop_daemon.py); the serve-side routing was not run here. No measurement of the split's accuracy or of rollback latency exists, so none is given.

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:

  1. daily-cap (UTC-day rollover via reset_daily_counter_if_new_day)
  2. estimate-sanity — refuse implausible cost estimates
  3. monthly-budget — projected total vs configured cap

Budget-skipped iterations produce no manifests — no half-records to confuse soup loop replay.

The cost estimate prices the GPU from a rate table, and the table grew in v0.76.0 (#831). L4, A10G, H200, B200, the RTX 40 and 50 series and the RTX PRO 6000 had no rate and counted as $0, so the budget gate never fired for them; they are priced now, and the gate does. Names that the old unanchored patterns mispriced (A100X, A1000, T400) now resolve to unknown instead. The table is a ballpark: laptop parts such as RTX 4090 Laptop GPU still price as the desktop card, which overstates a laptop run, and the A10G row is a whole AWS instance.

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.

bash
soup loop init <served-model> --eval <suite> --baseline registry://<id> --pre-wired
soup loop watch --pre-wired --pack-cans

Two 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. Installs SIGTERM / SIGINT handlers.
  • Reloads state every iteration so external pause / resume takes effect immediately without restarting the daemon.
  • --detach spawns python -m soup_cli.cli loop watch --foreground via argv-list subprocess.Popen — no shell, no string interpolation.
  • maybe_rollback fires only on "MAJOR" canary verdict.

Iteration manifests

Every iteration writes a frozen IterationRecord to .soup-loops/<iteration_id>/iteration.json:

text
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 (None: the deploy only activated the adapter)
shipped             true
rolled_back         false
estimated_cost_usd  2.40
notes               ""             # a string; "activate POST failed" after a refused deploy

canary_verdict is None when the pre-wired deploy stage only activated the adapter (v0.76.0, #815). Activation is a full hot-swap at 100% of traffic, so no canary split ran and there is no verdict to report. (Through v0.75.2 the stage recorded OK from the activation POST's bare 2xx. A manifest from an older release with canary_verdict: OK says only that the activation succeeded.) A real verdict comes from soup loop watch evaluating the counters described under Serving the split.

new_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/activate rollout are operator-driven via WatchConfig. Since closed in v0.71.4: --pre-wired binds 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 serve that was started with --tool-auth-token (v0.75.1 and later).
  • Canary routing needs keyed requests on the transformers backend and counts errors, not output quality (see Serving the split). Its counters lag traffic by up to 64 outcomes or two seconds.
  • --detach is a single-process subprocess; full daemonization (double-fork, session leader, /dev/null fds) is deferred.

See also

  • Trace-to-preference — the HarvestFn half of the loop
  • Eval-gated training — the GateFn half
  • Registry — where baseline and shipped runs live
  • Diagnose — pair with soup train --diagnose-gate for a second safety net
  • Advise — what to run before a loop iteration
  • Trace ecosystem — v0.63 soup ingest feeds the loop's HarvestFn
  • Adapter lifecycle (v0.67) — soup lock commits a SHA256(base || dataset || env) closure so the whole team coordinates on identical reproducible loop iterations; soup adapters bisect finds the broken iteration when the canary regresses
  • Pre-flight & tooling (v0.64) — soup plan / soup apply drift-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.