Files
club-3090/docs/LOOP.md

23 KiB
Raw Blame History

The Loop phase — [F] of the v0.8.0 pull pipeline (#147)

Contributor/maintainer guide for the v0.8.0 Loop phase. The Pull-Emit-Derived stage (docs/PULL_EMIT_DERIVED.md) is the producer: for a download-eligible non-curated model it boots the weights, smokes the endpoint, and writes the §6 capture artifacts. [F] is the consumer: it reads that on-disk bundle, classifies what happened, runs an inbound-trust pipeline over success anchors, and dedups failure issues into the tracker. It is the loop that turns one user's boot/OOM into a classified, deduped, consensus-keyable signal feeding the calibration backbone.

[F] is not a run_pull() stage. Unlike [E] (which runs inside run_pull()), [F] is offline / at-submit-time: it never puts network/issue-tracker I/O on the gate path. [E] writes honest structured truth and explicitly does not classify (failure_class is null in every [E] manifest, and outcome is an interim 3-state); [F] is the phase that does the classifying, trusting, deduping, and consuming.

The authoritative spec is the test, not this doc

Per the locked v0.8.0 stop-condition, the executable specs are authoritative. For [F] those are scripts/tests/test-loop-input.sh (CONTRACT-1: the FInput reader), scripts/tests/test-classifier.sh (CONTRACT-2: the two-tier classifier + the Appendix-A seed corpus), scripts/tests/test-trust-pipeline.sh (CONTRACT-3 / 3a: the inbound-trust pipeline), scripts/tests/test-dedup.sh (CONTRACT-4: the dedup key + label scheme + submit path), and scripts/tests/test-kvcalc-version.sh (CONTRACT-5: the content-derived version provenance). Where this prose and any of those tests ever disagree, the test is correct; fix the doc. This document is the explanatory companion.

For the producer it consumes see docs/PULL_EMIT_DERIVED.md; for the gate that precedes both, docs/PULL_GATE.md.

On-rig status: the five test-*.sh suites listed above are green and are the shipped F1F6 contract. The real [F] end-to-end run over live capture dirs (the 7 real capture dirs from the [E] on-rig run, a deliberately-induced boot-OOM, and the Appendix-A seed replay) is the genuine validator and is the remaining step (F8). Nothing below describes the on-rig [F] run as validated; it describes the shipped, unit-verified F1F6 behaviour. A green mocked suite is necessary-not-sufficient — that is the lesson [E] taught.


1. Where [F] sits, and the bundle it consumes

[E] writes, schema v1, redacted JSON, under:

<repo>/.pull-captures/<slug-sanitized>/<utc-ts>/

A capture directory contains four required capture points + a top-level manifest, plus a fifth file only on the override-accepted path:

File Point Role for [F]
pt1-gate.json pre-download gate verdict the predicted side of the OOM delta (predicted_b_breakdown, post-[B])
pt2-download.json download download ok / failure
pt3-boot.json boot the raw boot failure (not classified by [E]), plus the container-log excerpt + the parsed actual numbers
pt4-smoke.json post-boot capability-aware smoke per-capability green/red/unsmoked results
manifest.json §6.2/§6.3 manifest every consensus/dedup input as a first-class key + submission_fingerprint + failure_class:null + the interim outcome
pt5-override-capture.json override force-capture present iff override-accepted; richer predicted/actual + calibration_signal_not_validated:true

Two binding rules [F] enforces, both grounded in shipped [E] code:

  • failure_class is authoritatively null in every [E] manifest. [F] computes it; F1 actively rejects a bundle whose manifest failure_class is non-null (a non-null value means [E] wrongly classified, which it must never do).
  • outcome is [E]'s interim honest 3-state (failed > partial > ok), NOT the §6.1 class enum. F1 surfaces the raw value but there is deliberately no accessor that reinterprets it as a class. The final failure taxonomy is owned by the [F] classifier (§6.1), which derives it from the bundle itself.

2. F1 — the FInput capture-bundle reader (CONTRACT-1)

scripts/lib/profiles/loop_input.py parses one capture directory into a strict, validated FInput object that the later STEPs consume. It is a boundary validator: any schema/shape violation raises a typed CaptureBundleError — never a silent partial parse, so downstream STEPs can trust every field.

What F1 enforces:

  • manifest["schema"] == 1 (hard-asserted); pt1 carries schema and is asserted, pt2pt4 do not ship a schema key so it is asserted only-where-present; each ptN's point value must be exactly its expected value.
  • every CONTRACT-1 first-class consensus/dedup key is present on the manifest (model, quant_label, arch_family, topology_class, engine_pin, engine_version, kv_calc_version, selected_ctx, kv_format, smoke_capability_set, topology_summary_canonical, model_id, submission_fingerprint, outcome, …).
  • pt5 is optional and loaded only when present (override-accepted path).
  • forward-compat: F1 tolerates F3's additive keys (pt1.predicted_b_breakdown, pt3.failure_log_excerpt, pt3.actual) being present or absent — it validates only the [E]-shipped required shape and never rejects an additive key.

Key normalization is [F]'s job, not [E]'s (CONTRACT-1):

Field Normalization Why
quant_label lowercased for keying raw value is weight_format case-as-emitted
arch_family verbatim it is config.json["architectures"][0] — already an exact identifier; re-normalizing would corrupt it
model_id ≡ manifest["model"] shipped alias
engine_version ≡ manifest["engine_pin"] shipped alias

F1 also owns the canonical key builders (single-sourced here so every STEP hashes identically):

  • consensus_key() — the §6.2 9-tuple (model, quant_label, arch_family, topology_class, engine_pin, kv_calc_version, selected_ctx, kv_format, smoke_capability_set). Validates success anchors.
  • dedup_tuple() — the §6.3 7-tuple (model_id, quant_label, arch_family, kv_calc_version, engine_version, failure_class, topology_class). Dedups failure issues. Its failure_class slot is the manifest's None here; F5 substitutes the classifier's class.
  • dedup_hash()sha256("\x1f".join(str(p) for p in tuple))[:12], the same \x1f+sha256 convention [E] uses for submission_fingerprint, truncated to 12 hex.

The §6.2 consensus key and the §6.3 dedup key are deliberately different keys — the consensus key carries selected_ctx/kv_format/ smoke_capability_set and no failure_class; the dedup key carries failure_class and no ctx/KV/smoke. Conflating them re-opens a closed design finding.


3. F2/F3 — the §6.1 two-tier failure classifier (CONTRACT-2)

scripts/lib/profiles/classifier.py consumes an FInput and emits exactly one §6.1 class or unknown. The §6.1 enum is verbatim, exactly these six (no 7th value can leak — enforced by FailureClass membership and a _coerce_class that degrades any out-of-enum value to unknown):

genuine-oom | overlay-arch-drift | kernel-unsupported |
quant-unsupported | benign-cold-start | unknown

Two tiers; Tier-1 (F3) plugs in front of Tier-2 (F2):

Tier-1 — the OOM fast-path (F3)

A regex for the definitive OOM signature (torch.cuda.OutOfMemoryError / "cuda out of memory" / variants) over the already-redacted error substring. On a match → always genuine-oom, decided by Tier.TIER1. The numbers are read from the structured [E] fields, never from raw logs (classifier.py is a pure bundle reader — it only detects the OOM signature; the magnitudes come from [E]'s parsed pt3.actual).

Tier-1 needs all three inputs to route a kv-calc bug:

  1. pt1.predicted_b_breakdown — the predicted [B] GB breakdown, persisted by F3's additive [E] touch for all post-[B] captures (previously only on the override path);
  2. pt3.actual.attempted_alloc_mib — parsed by [E] at capture time from the OOM traceback in the container log;
  3. pt3.actual.gpu_worker_reported_mib — parsed by [E] from the gpu-worker measured-peak line.

The input precedence (A-iii) is: pt5 structured fields > (pt3 failure_log_excerpt + pt3.actual + pt1.predicted_b_breakdown) > the bare pt3.failure string.

Routing gate (verbatim): route_as_kv_calc_bug=True only when failure_class == genuine-oom AND all three inputs are present. Otherwise the failure is still classified genuine-oom and still filed as a normal issue — but route_as_kv_calc_bug=False. This is the honest-degrade rule: never a confidently-wrong kv-calc-bug filing on incomplete inputs. The predicted-vs-actual delta (gpu_worker_reported_mib predicted_total) is computed only when both sides are usable numbers; never fabricated.

Tier-2 — the semantic-fingerprint DB (F2)

Reached only when Tier-1 finds no OOM signature. error_substring is extracted by source precedence (pt3.failure_log_excerptpt3.failure → first red cap's pt4.results_detail.error), normalized and length-capped, then fingerprint = sha256(error_substring + arch_family + engine_version)[:12] (the §6.1 verbatim salt).

The seed DB ships at scripts/lib/profiles/failure_fingerprints.yml with two sections:

  • exact_fingerprints: — a hash-keyed exact-match table. Seeded empty by design: real fingerprints depend on this rig's live error text (the arch/engine salt makes them rig/pin-specific). It is grown via maintainer-classified submissions — when a maintainer reclassifies an unknown, the resolved (fingerprint → class) is appended so the next identical run is an O(1) hit.
  • condition_matchers: — the Appendix-A binding seed corpus, ordered; first match wins. This is what classifies today (the exact table grows on top of it). Each row maps a structural/substring condition to its §6.1 class — e.g. the #145 streaming-dead case (boot green, streaming red, mapped via pt4) → quant-unsupported; a weight-load dtype error → quant-unsupported; a missing-symbol/patch-absent error → overlay-arch-drift; an SM90/FA3 kernel assert → kernel-unsupported; a cold-start-then-green or a historical served-name-404 → the in-enum benign-cold-start; an OutOfMemoryError substring → genuine-oom. Anything unmatched → unknown.

§6.1 routing rules (implemented)

Class should_file Routed
benign-cold-start False — suppressed (never filed)
unknown False — to the maintainer review queue (.pull-captures/_review-queue/); never auto-files a kv-calc bug
any other (genuine-oom, overlay-arch-drift, kernel-unsupported, quant-unsupported) True — files a deduped issue (F5) kv-calc bug only via Tier-1 + all-3-present

route_as_kv_calc_bug is hard-False in F2 — only F3's Tier-1 may ever set it True. The failure_class the classifier emits is the value F5 hashes into the §6.3 dedup key, so a misclassification yields a different hash and can never silently merge with a real OOM (the §6.1 mislabel safeguard).

The F3 [E]-side additions (what F3 added to the producer)

F3 made a bounded, additive, byte-preserving touch to the [E] producer so Tier-1 works on the normal boot path, not just the override path:

  • pt1.predicted_b_breakdown — persisted for all post-[B] captures.
  • pt3.failure_log_excerpt — a bounded, _redact-scrubbed docker compose logs --no-color excerpt (the container log, where the vLLM OOM traceback and the gpu-worker peak line actually land — not compose-up stderr, which structurally cannot contain the in-container traceback). --no-color is mandatory: ANSI escapes in container logs corrupt the Tier-1 regex.
  • pt3.actual.{attempted_alloc_mib, gpu_worker_reported_mib} — parsed by [E] at capture time from that excerpt into structured int|None fields. classifier.py only reads these; it never touches raw logs.

4. F4 — the §6.2 inbound-trust pipeline (CONTRACT-3 / 3a)

scripts/lib/profiles/trust_pipeline.py consumes the F1 FInput + the F2/F3 ClassificationResult and runs the 4-stage success-anchor pipeline:

raw  ->  candidate  ->  validated  ->  tier1
Stage Gate Stop reason if it fails
raw the bundle arrived [E]-redacted; the manifest first-class keys are the machine source (the report.sh --redact Markdown is an optional human-triage attachment, never required, never re-redacted). A well-formed FInput is at least raw.
candidate re-derive submission_fingerprint from the manifest with [E]'s exact \x1f+sha256 8-tuple and verify it equals the claimed value; then the capability-aware smoke gate. fingerprint-mismatch (stays raw); no-green-capability
validated topology plausible (a basic GPU/VRAM bounds floor — not a hardware model) AND (multi-submission consensus on the full §6.2 9-tuple OR maintainer_promoted=True). topology-implausible; insufficient-consensus
tier1 curated anchor only — emits the would-be calibration/<model>.yml row shape (status: candidate-tier1, never active). derived → derived-tier1-deferred-v0.8.1

Key properties grounded in the shipped code:

  • submission_fingerprint is re-derived and verified, never re-minted. [E] mints it; F4 recomputes it byte-exactly and rejects a mismatch as a tampered/corrupt correlation. It is correlation, not security — the fields are user-controlled; the real trust gate is consensus + maintainer promotion.
  • Capability-aware smoke graduation is green-only. The graduation_set is exactly the capabilities with pt4.results[cap] == "green". unsmoked/red caps never graduate. A partial anchor graduates only its green caps, never the model wholesale — this is the #145-class guard (a model that boots and answers plain-chat while streaming/tools are silently dead must not graduate those caps).
  • Consensus is matching, not automation. F4 ships the consensus-key matching primitive (count submissions whose full 9-tuple equals this anchor's; default consensus_n=2) plus the maintainer_promoted manual hook. The N≥2 auto-promotion automation is the deferred sub-scope; early-phase maintainer manual promotion is the v0.8.0 mechanism, which the design explicitly permits.
  • Success anchors are NOT gated on a predicted-vs-actual delta. The "delta ≤ tolerance" check belongs to the §6.1 failure → kv-calc-bug branch (pt5 / Tier-1). A successful boot has no OOM delta. F4's tolerance argument is accepted-and-ignored on purpose so a caller that mistakenly passes one does not break — it has no effect on the verdict.
  • CONTRACT-3a — derived anchors are classifier+dedup-only this phase. The calibration backbone hard-cross-ref-validates every row against COMPOSE_REGISTRY; a generic-dense derived model has no registry entry, so it cannot be a calibration row as-is. F4 detects curated-vs-derived: a derived anchor stops at validated with reason derived-tier1-deferred-v0.8.1 (it still flows fully through the classifier + dedup — F4 just does not push it to the calibration backbone). The derived → calibration-backbone bridge is deferred to v0.8.1. A curated anchor may reach tier1, where F4 emits the would-be calibration row shape only — it never edits calibration YAML and never runs kv-calc; that ingestion is a separate, later, manual/maintainer concern.

5. F5 — the §6.3 dedup key + label scheme + submit path (CONTRACT-4)

scripts/lib/profiles/dedup.py consumes F1 + the classifier result and runs the dedup-or-file submit path.

  • The effective dedup key. F5 reuses F1's normalized 7-tuple and substitutes the classifier's failure_class into position 5 (it was None pre-classification), then hashes with F1's exact sha256("\x1f".join(...))[:12] convention via F1's own primitive (zero convention drift — single-sourced in loop_input.py). All 7 dimensions are in the hash — no dimension can be silently dropped from the match.

  • Bounded, collision-safe labels. Exactly three, all bounded:

    • loop:dedup-<12hex> — the dedup primitive;
    • class:<failure_class> — one of the 6 §6.1 enum values;
    • arch:<arch_family> — a short identifier, slug-sanitized + length-capped.

    No raw model: / engine: / kvcalc: / topo: labels are ever produced — those values are unbounded (arbitrary HF slugs, moving image pins) and live in the issue body, never as labels. The full canonical 7-tuple is written into the body as a fenced ```json block with a stable schema marker.

  • Verify-body-before-+1. Query gh issue list --label loop:dedup-<hash> --state all; if a candidate exists, parse its body tuple and verify it equals the full effective 7-tuple before adding a +1. A sha12 truncation can (astronomically rarely, or via a crafted body) collide — F5 never +1's on a hash-label match alone; a body-tuple mismatch is treated as no-match → open a new issue (never silently merge two distinct failures).

  • Filing policy first. Before any gh I/O: should_file == False short-circuits — benign-cold-start is suppressed (not filed, not spooled-as-issue); unknown goes to the review-queue spool, not the tracker. F5 never files a "kv-calc bug" — route_as_kv_calc_bug is F3's calibration signal, a different pipeline; the boundary is stated in the code so a future reader does not wire calibration-bug filing into dedup.

  • gh-guarded, never blocks. Every gh call goes through an injectable runner. A missing/unauthenticated/failed/timed-out gh degrades to a local spool (.pull-captures/_dedup-queue/<hash>.json) the maintainer can replay. F5 never raises and never blocks [E] — CONTRACT-1 keeps [F] off the gate path. bootstrap_labels() idempotently creates the bounded class:* set with gh label create --force; loop:dedup-* and arch:* are created per-issue (their value space is per-failure, not a fixed bootstrap set).


6. F6 — version provenance integrity (CONTRACT-5)

The kv_calc_version string stamped into every capture manifest feeds the §6.2 consensus key, the §6.3 dedup key, and submission_fingerprint. F6 makes it content-derived:

kv_calc_version = "kvcalc-v0.8.0+" + sha256(<content>)[:12]
<content> = bytes(tools/kv-calc.py)
            + sorted-concat(scripts/lib/profiles/calibration/*.yml)

joined with the same \x1f separator and sha256 convention as submission_fingerprint, computed once at import. If the content surface is unreadable for any reason the producer degrades to the bare "kvcalc-v0.8.0" and never raises ([E]/pull must not crash over a provenance-label refinement).

CONTRACT-5 (i) — residual-risk note (stated here by mandate). Before F6, kv_calc_version was a hardcoded constant. A manual constant is only as strong as manual bump discipline: a calibration-affecting edit to tools/kv-calc.py or to the calibration corpus that did not change the constant would let materially-different runs silently collide on the consensus / dedup / submission_fingerprint keys — i.e. two runs with different kv-calc behaviour would be treated as the same anchor. Deriving the version from a content hash of the kv-calc math and the calibration corpus is the mitigation: any math or corpus edit moves the hash automatically, with no manual bump needed, so the freshness guarantee no longer depends on discipline. This is purely how the version string is computed — zero decision-logic touch (gate states, terminals, [B], [C1], verdicts, the strata table are all untouched). test-kvcalc-version.sh asserts the version moves when the kv-calc math changes and when a calibration/*.yml changes — the regression that is only satisfiable because the content-derivation is mandatory.

F6 also synced the on-rig topology fixture to the live detect_gpu_topology format so topology_summary_canonical can be relied on for automated validation (the G1 verification gate; the live-capture confirmation is part of the on-rig F8 step).


7. In scope for v0.8.0 vs deferred to v0.8.1

Item Status
Capture-bundle reader, two-tier classifier, inbound-trust pipeline, dedup + structured-label submit path v0.8.0 (F1F6, shipped + unit-verified; on-rig F8 pending)
Consensus automation (N≥2 auto-promote) deferred sub-scope — v0.8.0 ships the consensus-key matching primitive + maintainer manual promotion (the design explicitly permits early-phase manual)
Derived-anchor → calibration-backbone Tier-1 ingestion v0.8.1 — v0.8.0 [F] scopes derived anchors to classifier + dedup only; curated-only Tier-1
GGUF / .bin / whichllm v0.8.1 — no GGUF capture path exists; the classifier must not assume one
Security / threat model on submission_fingerprint out — it is correlation, not security (fields are user-controlled); the trust gate is consensus + maintainer promotion, not crypto
On-rig [F] validation F8 — the remaining step (the genuine validator; a green mocked suite is necessary-not-sufficient)

The §10-R9 risk, carried forward (not silently assumed away)

[F] does not assume the loop reaches self-sustaining volume. The design carries an explicit risk that this doc states faithfully:

Per-anchor maintainer manual promotion has the same scaling ceiling as per-model releases. v0.8.0 reshapes, it does not escape, the release treadmill — and it bets that community submission volume reaches §6.2 consensus. The explicit kill/reassess criterion: if 90 days post-v0.8.0 the consensus path is still <10% of validated anchors, the replacement mechanism has failed → reassess whether the curated backbone must stay the primary support surface.

This is a carried risk, not an assumed outcome. The Loop closes the failure-intelligence + dedup loop for every model (curated and derived) today; whether it becomes self-sustaining is what the 90-day measurement decides.


8. Running the executable specs

From the repo root:

bash scripts/tests/test-loop-input.sh        # CONTRACT-1 (FInput reader)
bash scripts/tests/test-classifier.sh        # CONTRACT-2 (two-tier classifier + Appendix A)
bash scripts/tests/test-trust-pipeline.sh    # CONTRACT-3 / 3a (inbound-trust pipeline)
bash scripts/tests/test-dedup.sh             # CONTRACT-4 (dedup key + labels + submit path)
bash scripts/tests/test-kvcalc-version.sh    # CONTRACT-5 (content-derived version provenance)
python3 tools/kv-calc.py --calibration       # must stay Overall: 22/22 (100%)

These are the authoritative spec; this document is the explanatory companion. Where they disagree, the test wins. [F] is purely additive — it touches no shipped pipeline decision logic, so the prior suites and the kv-calc 22/22 contract are unaffected.