Files
vh 5af8e2bc17 feat(intern-decision): h2o-lightning-4b is the production decision seat; fix forward, no INC fallback
Prime (18:05, via albok-dev): go to h2o and fix forward, do not fall back to
INC. albok-dev removed the chapter margin first (the two published nonfiction
titles declared visibility: public). switch-to-h2o.sh ran 18:05:13, h2o healthy
18:07:40; /health at the pin, /decide/shared 200, 422 over the cap, inline image
answers, image URL refused by the shim and by vLLM's media-domain backstop.
rollback-to-inc.sh stays on the host as an artifact only.
2026-10-12 18:08:31 -07:00

31 KiB
Raw Permalink Blame History

intern-decision — the decision seat (fv-ml1 GPU 1, :8033)

LIVE: h2o-lightning-4b, since 2026-10-12 1807 PT: the production seat. Prime, 1805, via albok-dev: "okay, go to h2o, and fix forward. do not fall back to inc." compose.yaml = compose.h2o.yaml. Nemi's judge runs the v2 note question at concurrency 4.

⚠ No fallback to INC. rollback-to-inc.sh and compose.inc.yaml stay on the host as an artifact, but problems on this seat are fixed forward (judge wording, thresholds, visibility declarations), not by switching back. Using INC again would need Prime's explicit word.

What happened on 2026-10-12:

  1. h2o 1311–1403 (judge v1), rolled back. Adult flags went from 88 to 242. The v1 question asked two things at once.
  2. The GPU 3 investigation. The floor was ruled out, and the v2 question was shipped (albok-nemi 0.1.18).
  3. h2o 1704–1747 (v2), rolled back. Two margins moved:
    • nonfiction adult 0 → 79, from one textbook chapter at 0.531;
    • memory personal 6 → 18.
  4. h2o for good from 1807. albok-dev fixed the chapter margin first, by declaring the two published nonfiction titles visibility: public, so the judge is no longer consulted for them. The personal-question reading is being re-fitted forward on the live seat.

The h2o seat (compose.h2o.yaml; production since 2026-10-12 1807)

URL http://10.251.50.54:8033 = http://intern-decision.fv.internal:8033. /health is open; everything else needs Authorization: Bearer $(secret get intern-decision/api-token) (the same value as albok/judge-token)
Model h2oai/h2o-lightning-4b @ b43f759b6385d21182f87c3829931ec3d4dfa98a, from /tank/aimodels/h2o-lightning-4b-b43f759b (read-only). model.safetensors sha256 2d51561f…d4d11497b6; tokenizer, shim, serve_config, config and chat template all equal Hugging Face at that revision
Serving vllm/vllm-openai:v0.30.0 (container intern-decision-vllm, loopback :18299), args exactly as benched: --max-model-len 40960 --max-num-seqs 16, the vendor's vision limits, VLLM_USE_FLASHINFER_SAMPLER=0
KV pinned in bytes: --kv-cache-memory 4810363371 (4.48 GiB = 135,558 tokens, 3.31× concurrency at 40,960). GPU 1 is shared with meromero-rp and gen-small, so a util-sized KV would drift on a co-boot. --gpu-memory-utilization 0.1506 now only gates vLLM's boot check
Front container intern-decision (python:3.12-slim, host network, :8033), running conf/front/run.sh: the vendor's unmodified h2o_lightning_shim.py on loopback :18291, behind services/h2o-decision-front/front.py
State none beyond the intern-decision_vllm-cache volume (compile cache)
Rollback compose.inc.yaml is the INC compose, same .env. See Rollback to INC below

What the front adds (services/h2o-decision-front)

  • Admission, refuse-not-trim, at 32,000 tokens. It counts with h2o's own tokenizer through vLLM /tokenize, over the shim's own render of each question, and checks both the largest prompt and the shim's own total (shared prefix plus tails). Over the cap gets 422 "admission: N max-prompt / M total tokens against the 32000-token seat limit; refused, not trimmed". The logic is infra-hermes's tools/h2o-admission/admission_proxy.py (30f36f2), unchanged.
  • The bearer token on every path except GET /health.
  • /decide and /decide/shared, translated onto /v1/systemone. This keeps albok-nemi's judge, the seat's only production caller, working unchanged. One call holds at most 16 questions and is split greedily beyond that, as INC did. orderings other than none → 422. Each result carries probabilities, top, confidence, calibration, native (the shim's answer object, unchanged), input_tokens, model and call. INC's prompt_sha256, readout and timing fields are gone.
  • /health reports model {name, source, revision}, the cap, the endpoints and vLLM's max_model_len, and returns 503 when the shim is not healthy.
  • /v1/systemone and /api/alpha/decisions pass through after admission, and /v1/models passes through as is.

Verification at the 1311 cutover (2026-10-12, 1311–1318 PT, on fv-ml1 loopback)

check result expected
/health ok, model h2o-lightning-4b @ b43f759b…, cap 32,000, max_model_len 40,960 the pin
over the cap: one question over a ~40k-token state 422, "40078 max-prompt / 40078 total … refused, not trimmed" 422
16 questions at the cap (31,528 max-prompt / 31,999 shim total), warm, N = 3 200; 2.105 / 2.061 / 1.999 s ~2 s (bench)
/decide/shared smoke: two yes/no decisions over a line holding a phone number and a street address 200 in 0.52 s; p(yes) explicit 0.052, personal info 0.876 the right way round
GPU 1, h2o's vLLM process, at rest / after the 16q@32k calls 14,420 / 15,590 MiB budget 16,847, bench peak 15,566

The 16q@32k and /decide/shared rows show the path works; the speed and memory numbers are infra-hermes's, below.

infra-hermes, 1316–1318, same seat: the bench's own harness (jevbench.cli v1.2.16 with the typesafe adapter, plus the Amendment 1 gate probe driver). Bearer passed by --key-env. Artifacts are on fv-ml1 in /tank/bench/cutover/jevbench/ and /tank/bench/cutover-probe.json.

check result reference
JevBench public, one pass 205/231 205 ×3 in the Amendment 1 bench
depth cells: evidence at depth 1k / 8k / 16k / 30k supported at every depth (p 0.89–0.94), admitted at 31,505 / 31,212 / 12,208 / 31,744 tokens the 16k cell was under-filled by the probe's calibration, not by the seat. It passed at 29,508 in Amendment 1, and the 30k depth passes here
over the cap 422 at 32,020 / 32,046 / 33,463 / 39,845 tokens, each naming the count
usage.input_tokens against the admitted count equal in every admitted cell the admission honesty property
largest admissible, 1 question / 16 questions (31,417 / 31,907 tokens) first 251 / 2,395 ms; warm median 226 / 2,159 ms bench front 2,033 ms; the bar is 2.93 s. The +126 ms is the front's translation and auth
4 concurrent 16q × 32k calls all 4 correct, wall 7,178 ms. GPU 1 peak at 50 ms sampling: 95,178 MiB = meromero 43,998 + gen-small 34,994 + seat 16,164 seat budget 16,847, so 683 MiB headroom; the bench peak was 15,566

⚠ 683 MiB is the seat's margin under 4-way 32k load. That is tighter than the bench's peak, because of the byte-pinned KV size and concurrent growth. It passes, but it is the number to re-measure before anything else lands on GPU 1.

Before any (re)create

GPU 1 holds meromero-rp (~44.0 GB) and gen-small (~35.0 GB). Read the free memory first, with scripts/oneoff/fv-ml1-gpucheck.sh 1 0.1506 intern-decision-vllm, and stop if it fails. The vLLM container takes about 3 minutes to reach healthy (graph capture). The front waits on it, and the healthcheck has a 300 s start period.

Images: inline only (Prime 2026-10-12, via brokkr)

The h2o seat must never fetch an image URL. compose.h2o.yaml sets LIGHTNING_IMAGE_URLS=off on the front, and the vendor shim then refuses every image URL with 422 "image URLs are not accepted here". Inline data: URIs and base64 keep working. vLLM also runs with --allowed-media-domains media.invalid as a backstop. That is a reserved name that never resolves, and vLLM 0.30.0 checks every http(s) media URL against it before fetching, while data: URLs bypass the check.

The shim switch was verified live at 1402, on the h2o seat with the front recreated alone:

  • the same https://media.invalid/red.png request went from "could not be fetched" (a fetch was attempted) to "not accepted here" (refused before any fetch);
  • inline red still answered red at 0.984;
  • before the change, inline red/blue/green answered at 0.98+ and no-image came out near-uniform.

The vLLM backstop was first exercised on the GPU 3 eval seat at 1428 (same image and flag): an https image URL posted straight to vLLM got 400 "The URL must be from one of the allowed domains: ['media.invalid']", with no fetch. Repeat that check on this seat's loopback port (:18299) on the next switch. INC needs neither setting, because its served checkpoint has no vision tower.

Switching between the two

The final switch: switch-to-h2o.sh ran from 180513, h2o healthy at 180740, with the same checks all passing. The 1704 switch: switch-to-h2o.sh ran from 170223 (rc 0) and h2o was healthy at 170454. The 1705 checks: /health at the pin, with floor 0.801; /decide/shared 200; a 422 over the cap; inline red → red at 0.984; an https image URL refused by the shim; the vLLM backstop refusing example.com (400); GPU 1 resting at 14,430 MiB.

Both run ON fv-ml1 and share one .env.

  • To h2o: sudo bash /opt/docker/compose/intern-decision/switch-to-h2o.sh. It stops INC and checks that GPU 1 has 16,900 MiB free (h2o's measured budget). If not, it restarts INC and exits 2. Otherwise it copies compose.h2o.yaml over compose.yaml and waits up to 300 s for /health. Only do this once albok has re-fitted the judge.
  • To INC: sudo bash /opt/docker/compose/intern-decision/rollback-to-inc.sh. It stops h2o, copies compose.inc.yaml over compose.yaml and waits up to 180 s. Image intern-decision-serve:0.1.3 and its intern-decision_triton-cache volume survive the down. Used 2026-10-12, 140328 → healthy 140356, and again 174746 → healthy 174811.

After either switch, make stacks/intern-decision/compose.yaml match the host again (it is h2o since 1807).

⚠ Tell albok-dev on either switch: Nemi's judge concurrency depends on the seat. INC scores on one inference thread (INV-2, ~16 calls/s at most), so the judge runs with concurrency 1 against it (albok's nemi.yaml, since 2026-10-12). With 4 in flight on INC, the 1533–1619 walk queued behind itself and lost 339 calls to 30 s timeouts. On h2o, vLLM batches, so it goes back to 4.


History: the Intern-Decision-4B seat (2026-09-30 → 10-12 1308, 1403–1702, 1748–1805; retired, no fallback per Prime)

LIVE from 2026-09-30 0941 PT to 2026-10-12 1308 PT, and again since 2026-10-12 1403 PT on fv-ml1 GPU 1, with cap 9.0 GiB and MAX_TOKENS 7,168. Acceptance on the live service is below. semif is stopped and kept as the rollback.

Intern-Decision-4B typed decisions on fv-ml1 GPU 1, the utility card beside vllm-coder, the erp/meromero seats and scriberr. It replaced semif on Prime's ruling of 2026-09-30, ~0510 PT: "replace semif with intern-decision now". The pick came from the previous night's bench, docs/pfi/jev-candidates-bench-2026-09-30.md: the only candidate at least as accurate as SemIf on our sets, faster at our shape, and inside the memory budget.

Intern-Decision-4B (Shanghai AI Lab, Apache-2.0) is a Qwen3.5-4B finetune. It takes a state plus up to 16 named questions and scores every question's options in one forward pass with no decoding. It reads each answer from the logits just before a <decision> marker. services/intern-decision-serve/ loads it once and scores through the checkpoint's own inference.py (DecisionEngine.predict). It keeps semif-serve's HTTP surface, so a semif caller changes only the URL and the token. The contract is services/intern-decision-serve/intern-decision-serve.contract.md.

URL http://10.251.50.54:8033 = http://intern-decision.fv.internal:8033. /health is open; POSTs need Authorization: Bearer $(secret get intern-decision/api-token)
Model internlm/Intern-Decision-4B @ 0e5e6aa7d6d750e2b1504ba11a8136cb58aeb3cd, BF16, from /tank/aimodels/huggingface, mounted read-only and offline
Scoring code the snapshot's inference.py, sha256 c904e2c6…43b29863, checked before it is imported. Startup refuses any other file
Stack torch 2.10.0+cu128, transformers 5.17.0, fla 0.5.2, causal-conv1d 1.7.0: the bench's stack
Image intern-decision-serve:<version>, built on fv-ml1 from services/intern-decision-serve/
State None. The service never downloads (HF_HUB_OFFLINE=1, read-only mount). If the weights are ever lost, re-pull the pinned revision by hand.
Rollback stacks/semif is stopped, not removed. See Rollback below.

API (semif-serve's)

T=$(secret get intern-decision/api-token)
curl -s -H "Authorization: Bearer $T" http://intern-decision.fv.internal:8033/decide -d '{
  "id": "q1", "state": "Health checks passed in all three zones.",
  "question": "Did the deployment succeed?",
  "options": [{"id":"yes","description":"It succeeded."},{"id":"no","description":"It failed."}]}'
  • POST /decide takes one decision, {id, state, question, options[2..16]}.
  • POST /decide/shared takes {state, decisions: [{id, question, options}]}.
  • GET /health reports the pins, the limits and the chunking rule.
  • Order averaging: "orderings": "rotations", or "all" for ≤ 4 options, is still accepted, with semif's combined block. It is barely needed: the bench found Intern-Decision reorders 8 of 144 labels when the options are reversed, against SemIf's 30, and at one ordering it matched SemIf-with-rotations.
  • Each result carries semif's fields where they mean something: option_ids, probabilities, input_tokens, prompt_sha256, prompt_version, model, readout, probability_status, and on /decide also total_seconds and forward_seconds. New fields:
    • top;
    • confidence, the model's own;
    • calibration, the model's own temperature (T = 1.99241824);
    • native, the model's answer object, unchanged;
    • call (index, field, questions): which prompt the decision was asked in.
  • Past MAX_QUEUE (32) requests in progress, new POSTs get 429 busy before their body is read.

What a semif caller must know (every deliberate difference is in the contract, "Deltas")

  1. Option ids are shown to the model. The prompt prints A = <id>: <description>, so an id is part of the question. Use meaningful or neutral ids, not misleading ones.
  2. Decisions in one /decide/shared call are asked together, in one prompt. An answer can depend on the other questions in its call.
    • The bench measured this on Wyrd: 79/84 asked one decision at a time, 77/84 with a turn's 4 decisions in one prompt.
    • A call holds at most 16 questions. More are split greedily in request order: 1–16, 17–32, … . /health says so.
  3. probabilities are temperature-scaled by the model's shipped calibration. SemIf's were raw. The argmax is the same either way. This calibration is the vendor's, fitted on its own data, not ours.
  4. Gone:
    • option_logits (the model's runtime does not expose logits);
    • the prefix-cache timing fields;
    • /health.semif_commit;
    • per-workload calibration. Any workload is a 422, exactly as the deployed semif behaved with its empty table.
  5. MAX_TOKENS is 7,168 per call: the state plus all its questions. It is lower than the model's own 8,192 so that every call the API accepts fits the VRAM cap.
    • The count is taken before the forward pass, so a longer call is a clear 422 invalid_request ("Example has N tokens, above 7168; truncation is forbidden"). It is never truncated.
    • A request with more decisions is split into more calls, and each call must fit.

VRAM: 32k-token calls since 2026-09-30 1330 (scriberr moved to GPU 3)

Current setting: VRAM_CAP_GIB=14.4, MAX_TOKENS=32768 (Prime: move scriberr to GPU 3 and "extend the jev endpoint to hit 32k tokens if possible"). With scriberr gone, GPU 1 holds only the static vLLM seats and this service. This container may therefore use its rest (8,812 MiB) plus GPU 1's nvidia-smi Free (6,625 MiB), which is 15,437 MiB. The 14.4 GiB cap plus the ~660 MiB outside the allocator puts the card ceiling at ~15,408 MiB.

⚠ 2026-10-05: the 0234 Docker-move restart broke this budget. vllm-meromero-rp sized its KV from --gpu-memory-utilization, re-profiled during a co-boot, and came back 8.7 GiB larger, so Free fell to 3,981 MiB, about 2.4 GB short of a 32k call. Fixed 2131–2135 the same night: meromero and coder now pin KV in bytes (playbooks/fv-ml1-kv-pin-20261005.yaml), and Free reads 11,801 MiB, a 20,613 MiB budget, so ~5.4 GB is spare at the 15,220 peak. The static seats can no longer drift across restarts. Later that night (2226–2249), the GPU 0/1 rebalance (playbooks/fv-ml1-rebalance-20261005.yaml) left this card with gen-small, meromero-rp and this service only. Free read 10,864 MiB right after, and should settle near ~9.7 GB once gen-small's post-boot growth levels off. That is a ~18.5 GB budget, so ~3.3 GB spare at the 32k peak.

Measured on the live service (per-process nvidia-smi every 0.1 s; single noul question unless noted; n=3, deterministic):

call tokens card peak MiB wall (warm)
3,187 9,306 0.15 s
12,187 11,206 0.68 s
24,187 13,552 1.49 s
29,987 14,692 1.92 s
32,768 (limit) 15,220 2.12 s
32,765, 16 questions 15,220 2.15 s
32,769 refused 422 before the forward 0.30 s

Spare at the limit: 217 MiB. JevBench v1.2.16 through /v1/systemone after the change: 202/231, with 0 answer and 0 probability diffs against the bench's r1..r4. /decide is unchanged.

⚠ Cold-shape latency (MEASURED 2026-09-30, was inferred): the fast kernels (triton + fla) autotune per shape bucket. Buckets are 2,048 tokens wide — proven: after warming one call per bucket, 12 calls at random sizes spread 8k–32k were all warm (worst 2.09 s, the same curve as the warmed sizes; no +6 s), and every cold call that crossed into its own bucket cost 6.5–9 s while same-bucket repeats cost <0.3 s. 16 buckets to MAX_TOKENS=32,768. A full cold warm-up took 109 s (16 buckets; the service's own startup warm-up and the script's two calibration probes warm 3 of them along the way; a second run totals 17 s). Per-bucket cold cost grows with size: 6.5 s at 4k → 8.9 s at 32k.

  • Durable now: the autotune cache (TRITON_CACHE_DIR=/tmp/triton-cache, incl. fla's *.autotune.json) lives on the named volume intern-decision_triton-cache, mounted at a mount point baked into the image owned by 10001 (a fresh named volume inherits it; without that, the volume is root-owned and Triton cannot write). Proven: 3,200+ files owned by uid 10001 in the volume; after up -d --force-recreate a previously warmed 32k call answered in 2.11 s.
  • When to warm: run scripts/intern-decision-warmup (on nh3-dev; token from the vault) after an IMAGE CHANGE — a new triton/fla version keys new cache entries. Not needed after ordinary recreates. The script reads MAX_TOKENS from /health, aims each bucket just under its edge with a two-point live calibration (single-probe "calibration" oscillates ±500 tokens around the edge), prints per-bucket wall times, and exits non-zero if any bucket was missed.
  • Cold autotune also costs a few MiB of transient scratch: GPU 1 per-process peak measured 15,218 MiB warm at the 32k limit (budget 15,220), but 15,224 during a COLD 32k autotune. The volume means the cold path now happens only after an image change — worth knowing when GPU 1 headroom is being read during a deploy.

The table below is the ORIGINAL budget (9.0 GiB / 7,168 tokens, beside scriberr) and is kept for history.

VRAM (history): fits beside scriberr's peak, whatever the request

Budget (infra-ops, 2026-09-30): GPU 1 needs nvidia-smi Free ≥ 15,400 MiB before this service starts. That is our card peak of 9,876 MiB plus scriberr's peak of 5,496 MiB (with its 120 s slices), rounded up. So both can peak at the same moment. Read nvidia-smi's own Free, not total − used: the driver reserves 640 MiB on every card. The deploy at 0940 read 15,442.

torch.cuda.set_per_process_memory_fraction caps only torch's allocator. The two knobs in .env are therefore chosen together and change together:

card footprint  <=  VRAM_CAP_GIB 9.0 (9,216 MiB)  +  660 MiB outside the allocator (measured)  =  9,876 MiB
MAX_TOKENS 7168  =  the largest call measured to fit under that cap, so no accepted call reaches the 503
on the live service, GPU 1 (per-process nvidia-smi, 0.1 s sampling, 2,939 samples over 316 s of acceptance load) MiB
at rest after startup 8,812
at rest after the acceptance (512 MiB release slack keeps a little cache) 8,856
peak: largest requests, 64 decisions × 16 options, 4 calls × 7,168 tokens, N = 3 9,866; torch max_reserved 8.996 of 9.0 GiB
GPU 1 Free before the deploy / at rest after / lowest during the acceptance 15,442 / 6,581 / 5,569, which stays above scriberr's 5,496
  • ⚠ At 7,168 tokens the allocator reaches the cap with about 4 MiB to spare. It fits because the allocator frees its cache and retries before it fails.
    • Measured: every call up to the limit answered 200. That is 145 shared calls on the live service, including the size search near the boundary, 3 × 4 calls at 7,168 tokens with 16 questions, and 3 at 7,168 tokens with one question. No 503 was seen.
    • The allocator's cache state depends on request history. If a max-size call ever does return 503, lower MAX_TOKENS. Do not raise the cap.
  • Raising either knob needs a fresh measurement (acceptance/checks.py --checks fit,maxreq) and the budget re-checked against scriberr.
  • Over the cap the answer is 503 out_of_memory. Memory returns to the resting baseline and the service keeps answering. This was proven on GPU 3 with a deliberately tight cap.

Measured on GPU 3 before the deploy (alone on the card), which is where the knobs came from:

measured on fv-ml1 GPU 3 (alone on the card), image 0.1.0 MiB
at rest (nvidia-smi, whole card less 2 MiB idle) 8,820 (torch reserved 8,160 + outside 660)
outside the allocator (CUDA context + loaded kernels), at rest / after 2×30 concurrent requests / after the latency shapes / after 4 × 8,191-token calls 660 / 660 / 662 / 662
card peak at cap 9.0 GiB (fit search and a 64 × 8,191-token request, which got 503) 9,876
peak with 4 × 8,191-token calls at cap 9.25 GiB (answered 200) / uncapped 10,134 / 10,422 (allocator 9,760)
largest 16-question call that answers 200: at cap 9.0 / at 9.25 GiB 7,168 / 8,191 tokens
startup peak (weights + vision tower until the swap + warm-up) allocator 8.86 GiB. A cap below ~8.9 GiB cannot start (it fails closed; 8.5 was refused)
  • One inference thread is load-bearing (contract INV-2). torch keeps CUDA state per host thread (cuBLAS handles and workspaces). The first build scored on anyio's 40-thread pool: a concurrent burst added 252 MiB outside the cap and 326 MiB inside it, and the peak reached 10,392 MiB. Load, warm-up and every call now run on one thread.
  • Released after bursts: when a call leaves reserved memory more than 512 MiB over the resting baseline, the engine calls empty_cache(). Measured: back to 7.97 GiB after every large request.
  • The rest footprint is 0.9 GB below the bench's 9,736 MiB. The service takes no images, so the vision tower (0.62 GiB) is swapped for a stub after the first warm-up, and the warm-up cache is released. Startup proves the swap changes nothing: the warm-up answer must be bit-identical (INV-7).

Latency

Live, from nh3-dev (0942 PT). The URL is intern-decision.fv.internal:8033, and the round trip is 22.9 ms on average (19.5–27.7 ms over 10 pings). GPU 1 is shared with the vLLM seats and scriberr. The harness is bench_shape.py from the Jev bench, 3 runs × 20 requests; each cell is the median with the run-median range. "server" is the service's own time.

shape end to end from nh3-dev server GPU 3 loopback before deploy SemIf 0.1.4 (README, from nh3-dev)
1 decision, short (~180 tok) 53.9 ms (53.3–54.2) 35.2 36.4 69
21 binary criteria, one /decide/shared (2 calls: 16 + 5) 114.3 ms (114.1–114.3) 80.3 82.3 159
1 decision over the ~3,900-token state 211.2 ms (210.8–211.6) 186.8 191.4
16 criteria over the ~3,900-token state (1 call, 4,579 tok) 238.3 ms (237.8–238.7) 205.3 216.8 (bench loopback: 505)
largest request (4 calls × 7,168 tok), N = 3 1,519–1,543 ms

Server-side time on GPU 1 matches GPU 3 within a few ms, so the vLLM neighbours were not measurably slowing it at 0942. The rest of the end-to-end time is the network (~23 ms round trip plus the HTTP exchange).

Acceptance (2026-09-30)

Raw results: services/intern-decision-serve/acceptance/gpu3-2026-09-30/ (compare.json, one directory per process lifetime). The harness is the bench's own. bench_sets.py --backend semif speaks semif-serve's API, so it drove this service unchanged. compare.py scores it as the bench's analyze.py did.

On GPU 3 before the deploy (the harness and the floor)

check (GPU 3, cap 9.25 GiB, N = 3 fresh processes unless noted) result bench / floor
positive control, pooled 259, single ordering 240, 240, 240 bench 240 (4 repeats); the pooled set resolves ±4 pts
positive control, Wyrd /84, single 79, 79, 79 bench 79
row by row against the bench's native rows (560 rows, single / rotations / negative) 0 top changes, max Δp 0.000 in every repeat: bit-identical bench floor: 0 labels moved across 4 restarts
rotations, pooled / Wyrd 236 / 77 (×3) bench 236 / 77
negative control (descriptions rotated, 144): same top / follows the description / right vs the original gold 10 / 122 / 14 (×3) bench 10 / 122 / 14
A-vs-A in the process (authored144 twice) 0/144 flips, Δp 0.0 (×3)
A-vs-A across restarts (r1r2, r1r3, r2~r3; 560 rows, single and rotations) 0 flips, Δp 0.0
Wyrd, one /decide/shared per turn (4 decisions in one prompt) 78/84 (×3); 1/84 row differs from the bench's 77 expected: field names are positional here, decision names in the bench (contract)
more than 16 questions: 20 decisions in one request, against the same decisions asked as 16 + 4 2 calls [16, 4], all 20 tops equal, Δp 0, same prompt hashes
401: no token / wrong token, both POSTs; /health open 401 / 401; 200
429: 48 concurrent requests, MAX_QUEUE 32 32 answered, 16 × 429 busy
largest request at MAX_TOKENS 8,192 (64 × 16 options, 4 × 8,191 tok), cap 9.25 GiB 200 ×3; card peak 10,134 MiB superseded by the 9.0 / 7,168 knobs (VRAM)
over the cap (the same request at a deliberately tight cap of 8.9 GiB) 503 out_of_memory; reserved 7.969 GiB before, 7.969 after, next request 200; then all 560 single rows answered, bit-identical to the bench
fit under the cap: the largest 16-question call that answers 200 8,191 tok (the whole API) at 9.25 GiB; 7,168 tok at 9.0 GiB
cap below startup 8.5 GiB: startup refused (OOM loading), fails closed

On the live service (GPU 1, 0942–0946 PT, through http://intern-decision.fv.internal:8033 from nh3-dev)

Raw results: services/intern-decision-serve/acceptance/gpu1-live/.

check result reference
positive control, pooled 259, single ordering 240 bench 240; GPU 3 240 ×3
positive control, Wyrd /84 79 bench 79
row by row against the bench's native rows (560 single + 144 negative) 0 top changes, max Δp 0.000
negative control: same top / follows the description / right vs gold 10 / 122 / 14 bench 10 / 122 / 14
401 without or with a wrong token (both POSTs); /health open 401 / 401; 200
largest accepted request: 64 decisions × 16 options, 4 calls × 7,168 tok 200 ×3
one question at 7,168 tok 200 ×3; one token more (7,171) → 422 "above 7168"
any 503 across the whole live acceptance none (server log: 128 + 145 × 200, 18 × 422, 4 × 401)

The live pass is N = 1 for the positive control. It is read against the GPU 3 floor: the same image moved 0 of 560 rows across 3 restarts, and 0 against the bench's 4 restarts.

Building and deploying

# from nh3-dev. /opt/docker/src is root-owned, so create the version dir with sudo first.
ssh infra-ops@10.251.50.54 'sudo -n install -d -o infra-ops -g infra-ops /opt/docker/src/intern-decision-serve-X.Y.Z | cat'
tar -C services/intern-decision-serve -cf - --exclude=.venv --exclude=.pytest_cache --exclude=__pycache__ \
    --exclude=acceptance --exclude='*.egg-info' . \
  | ssh infra-ops@10.251.50.54 'tar -x -C /opt/docker/src/intern-decision-serve-X.Y.Z'
# on fv-ml1
cd /opt/docker/src/intern-decision-serve-X.Y.Z && docker build -t intern-decision-serve:X.Y.Z .
# from nh3-dev: compose.yaml, .env.example and this README (never .env)
scripts/deploy-stack.sh fv-ml1 intern-decision
# on fv-ml1: .env is infra-ops's, mode 0600. Rewrite it IN PLACE to keep the owner and mode.
cd /opt/docker/compose/intern-decision && new=$(sed 's/^IMAGE=.*/IMAGE=intern-decision-serve:X.Y.Z/' .env) \
  && printf '%s\n' "$new" > .env && docker compose config -q && docker compose up -d
# IMAGE CHANGE ONLY (not ordinary recreates — the triton-cache volume carries those):
# warm every length bucket once the container is healthy (~2 min, prints per-bucket times):
scripts/intern-decision-warmup        # token from vault; MAX_TOKENS read from /health

Before any deploy onto GPU 1, check two things. If either fails, stop; do not squeeze scriberr.

  • nvidia-smi's own Free on GPU 1 must be at least 15,400 MiB: nvidia-smi -i 1 --query-gpu=memory.free --format=csv. That is our card peak of 9,876 MiB plus scriberr's 5,496, rounded up. Do not use total − used, which misses the driver's 640 MiB reserve.
  • Scriberr must not be running a job. This command must print 0: docker logs --since 2m scriberr | grep -c "Processing single-track job".

Startup fails closed. A container that never reaches healthy did not pass its own checks: the inference.py hash, the pinned snapshot, the warm-up, the text-only swap and the prompt hash. Read docker logs intern-decision. Record the deploy with scripts/ops-log.

To move the model forward, change REVISION and INFERENCE_PY_SHA256 in config.py. Re-run the bench's sets through the service (services/intern-decision-serve/acceptance/), and re-measure the VRAM before changing the cap.

Rollback (INC → semif)

semif is stopped, not removed, and is kept as the rollback. Only on Prime's word:

cd /opt/docker/compose/intern-decision && docker compose stop     # first: the two do not both fit GPU 1
cd /opt/docker/compose/semif && docker compose start

Then check GPU 1's free memory against scriberr's peak. Callers go back to :8032 and secret get semif/api-token.