"""v0.8.0 Loop `[F]` — STEP F1: the `FInput` capture-bundle reader. CONTRACT-1 (the locked v0.8.0 Loop design brief, "CONTRACT 1"; see docs/LOOP.md for the shipped contributor reference). `[E]` (`capture.py`) is the **producer**; this module is the **consumer**: it parses ONE on-disk capture directory into a validated `FInput` object that the later `[F]` STEPs (F2 classifier / F4 trust pipeline / F5 dedup) consume. F1 is a STRICT boundary validator — any schema/shape violation raises a clear typed `CaptureBundleError` (never a silent partial parse; downstream STEPs trust this object). This module owns ONLY: * `FInput` — the parsed-bundle dataclass (CONTRACT-1 shape); * `read_capture_bundle()`— parse + hard-validate one capture dir; * the CONTRACT-1 key-normalization helpers + the §6.2 consensus 9-tuple and §6.3 dedup 7-tuple builders (pure functions over the manifest — `[F]` owns the canonical construction; `[E]` does NOT normalize). It is PURE-PYTHON, stdlib only (json / pathlib / dataclasses / hashlib), matching the no-external-deps style of its `scripts/lib/profiles/` siblings (`capture.py`, `deriver.py`). It touches NO shipped code — F1 is purely additive (two NEW files only). Binding rules enforced in code + comment (CONTRACT-1): * F1 MUST NOT treat `manifest["outcome"]` as the §6.1 class enum — it is `[E]`'s interim honest 3-state (`failed > partial > ok`, `capture.py:524-534`). There is deliberately NO accessor here that claims `outcome` is the §6.1 class; `[F]` derives `failure_class` itself in a later STEP (F2/F3). The raw value is surfaced (`outcome` property) but never re-interpreted as a class. * `failure_class` is authoritatively `null` in EVERY `[E]` manifest (`capture.py:569`) — F1 just SURFACES that null; it never expects `[E]` to have pre-classified. * Key normalization is `[F]`'s job: `quant_label` is the raw `weight_format` case-as-emitted (`capture.py:389-391`) and is LOWERCASED for keying here. `arch_family` is used VERBATIM — it is `config.json["architectures"][0]` (`deriver.py:679-680`), already an exact identifier, NOT re-normalized. * `model_id ≡ manifest["model"]` and `engine_version ≡ manifest["engine_pin"]` are shipped aliases (`capture.py:553/568`, `497-500`) — exposed here as the canonical accessors. * `topology_class` (coarse `NxVRAMMiB`) and `topology_summary_canonical` (full sorted `(name,vram)` list) are deliberately two resolutions: keys use `topology_class`; `submission_fingerprint` uses `topology_summary_canonical`. Not interchangeable. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass from pathlib import Path from typing import Optional # The schema version `[E]` stamps (`capture.py:42` `SCHEMA = 1`). F1 hard- # asserts the bundle is this exact schema — a strict boundary refuses an # unrecognised schema rather than mis-parsing a future shape. EXPECTED_SCHEMA = 1 class CaptureBundleError(Exception): """Raised on ANY capture-bundle schema/shape violation. F1 is a strict boundary validator: a malformed / wrong-schema / shape- violating bundle MUST raise this (never a silent partial `FInput`) so the downstream `[F]` STEPs can trust every field on a returned object. """ # Required per-point capture artifacts and the `point` value each must # carry (`capture.py:426-430`, `442-489`). pt5 is intentionally NOT here — # it is conditional (override-accepted only, `pull.py:1082`). _REQUIRED_POINTS = { "pt1-gate.json": "gate", "pt2-download.json": "download", "pt3-boot.json": "boot", "pt4-smoke.json": "smoke", } _PT5_FILENAME = "pt5-override-capture.json" _MANIFEST_FILENAME = "manifest.json" @dataclass class FInput: """Parsed from a SINGLE capture dir (CONTRACT-1). `manifest` — manifest.json (schema==1 hard-asserted). All consensus/dedup inputs are FIRST-CLASS keys here. `pt1_gate` ... `pt4_smoke` — the 4 §6 capture-point artifacts (required). `pt5_override` — pt5-override-capture.json as a dict, or None when the file is absent (present iff override-accepted path). `raw_bundle_path` — the OPTIONAL `report.sh --redact` human-triage attachment. **NOT produced by `[E]`** (`capture.py` writes no such file — CONTRACT-1 / Kimi-r1 L1); it is an externally-supplied maintainer attachment only. F1 never synthesizes it: it defaults to None and is only ever the explicit value the caller passed. """ manifest: dict pt1_gate: dict pt2_download: dict pt3_boot: dict pt4_smoke: dict pt5_override: Optional[dict] = None # NOT produced by [E] — optional externally-supplied attachment only. raw_bundle_path: Optional[Path] = None # ---- CONTRACT-1 canonical accessors ------------------------------- # These are the SHIPPED aliases (`capture.py:553/568`, `497-500`); # exposed as the canonical names so downstream STEPs do not re-derive. @property def model_id(self) -> str: """`model_id ≡ manifest["model"]` (shipped alias).""" return self.manifest["model"] @property def engine_version(self) -> str: """`engine_version ≡ manifest["engine_pin"]` (shipped alias).""" return self.manifest["engine_pin"] @property def quant_label(self) -> str: """Normalized (LOWERCASED) `quant_label` for keying. Raw `manifest["quant_label"]` is `weight_format` case-as-emitted (`capture.py:389-391`); CONTRACT-1 binds `[F]` to lowercase it for every key it builds. Use this — never the raw manifest value — when constructing consensus / dedup keys. """ return _norm_quant(self.manifest["quant_label"]) @property def arch_family(self) -> str: """`arch_family` used VERBATIM (CONTRACT-1 G3 RESOLVED). It is `config.json["architectures"][0]` (`deriver.py:679-680`) — already an exact identifier, NOT a normalized family. F1 must NOT re-normalize it. """ return self.manifest["arch_family"] @property def failure_class(self) -> None: """Authoritatively `None` in every `[E]` manifest (`capture.py:569`). F1 only SURFACES this null — `[F]` computes the real `failure_class` in a later STEP (F2/F3). Never expect `[E]` to have pre-classified. """ return self.manifest["failure_class"] @property def outcome(self) -> str: """The RAW `[E]` interim 3-state `outcome` (`failed|partial|ok`). BINDING RULE (CONTRACT-1): this is `[E]`'s honest interim signal ONLY (`capture.py:521-534`) — it is **NOT** the §6.1 class enum. There is deliberately NO accessor on `FInput` that claims `outcome` is the §6.1 class; `[F]` derives `failure_class` itself downstream. Surfaced here so STEPs can read the raw value, never re-interpret. """ return self.manifest["outcome"] # ---- CONTRACT-3 / CONTRACT-4 canonical key builders --------------- def consensus_key(self) -> tuple: """The §6.2 consensus 9-tuple (CONTRACT-3, verbatim order). `(model, quant_label, arch_family, topology_class, engine_version/pin, kv_calc_version, selected_ctx, kv_format, smoke_capability_set)` — normalization applied (`quant_label` lowercased; `arch_family` verbatim; `engine_version≡engine_pin`, `model≡model_id` per CONTRACT-1). A DIFFERENT key from §6.3 by design (Codex-r4 M2) — it validates *success* anchors, carrying `selected_ctx`/`kv_format`/`smoke_capability_set` and NO `failure_class`. `smoke_capability_set` is a tuple (hashable / order-stable: it ships sorted, `capture.py:259`). """ m = self.manifest scs = m["smoke_capability_set"] return ( m["model"], _norm_quant(m["quant_label"]), m["arch_family"], m["topology_class"], m["engine_pin"], m["kv_calc_version"], m["selected_ctx"], m["kv_format"], tuple(scs) if scs is not None else (), ) def dedup_tuple(self) -> tuple: """The §6.3 dedup 7-tuple (CONTRACT-4, verbatim order). `(model_id, quant_label, arch_family, kv_calc_version, engine_version, failure_class, topology_class)` — normalized (`quant_label` lowercased; `arch_family` verbatim; `engine_version≡engine_pin`, `model_id≡model`). A DIFFERENT key from §6.2 by design — it dedups *failure* issues, carrying `failure_class` and NO ctx/KV/smoke. `failure_class` is `None` here on every `[E]` manifest; `[F]` fills it in a later STEP and re-builds this tuple — its presence IN the tuple is the §6.1 mislabel safeguard (a misclassification yields a different tuple, can't silently merge with a real OOM). """ m = self.manifest return ( m["model"], _norm_quant(m["quant_label"]), m["arch_family"], m["kv_calc_version"], m["engine_pin"], m["failure_class"], m["topology_class"], ) def dedup_hash(self) -> str: """`sha256("\\x1f".join(dedup_tuple))[:12]` (CONTRACT-4). Same `\\x1f`-join + sha256 convention as `[E]`'s `submission_fingerprint` (`capture.py:378-381`), truncated to 12 hex chars — the bounded, collision-safe `loop:dedup-` label primitive. F5 owns the issue-tracker side; F1 owns this canonical deterministic serialization so every STEP hashes identically. """ joined = "\x1f".join(str(p) for p in self.dedup_tuple()) return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:12] # --------------------------------------------------------------------------- # Normalization helpers (CONTRACT-1 binding rules — `[F]`'s job, not `[E]`'s). # --------------------------------------------------------------------------- def _norm_quant(value) -> str: """`quant_label` -> lowercased for keying (CONTRACT-1). Raw value is `weight_format` case-as-emitted (`capture.py:389-391`). A `None` (theoretically possible if the deriver surfaced no `weight_format`) is normalized to the literal string ``"none"`` so the key is always a deterministic string (same defensive `str()` discipline as `submission_fingerprint`, `capture.py:539`). """ if value is None: return "none" return str(value).lower() # --------------------------------------------------------------------------- # Strict bundle validation + load. # --------------------------------------------------------------------------- def _load_json(path: Path) -> dict: if not path.is_file(): raise CaptureBundleError(f"missing required artifact: {path.name}") try: obj = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: raise CaptureBundleError( f"unreadable/invalid JSON in {path.name}: {exc}" ) from exc if not isinstance(obj, dict): raise CaptureBundleError( f"{path.name} must be a JSON object, got {type(obj).__name__}" ) return obj def _require_keys(obj: dict, keys: tuple, where: str) -> None: missing = [k for k in keys if k not in obj] if missing: raise CaptureBundleError( f"{where} missing required key(s): {', '.join(sorted(missing))}" ) def read_capture_bundle( capture_dir, *, raw_bundle_path=None, ) -> FInput: """Parse + STRICTLY validate ONE `[E]` capture directory into `FInput`. `capture_dir` is `/.pull-captures///` (`capture.py:436-439`). Required: pt1-pt4 + manifest. Optional: pt5 (None when absent — present iff override-accepted, `pull.py:1082`). `raw_bundle_path` is the OPTIONAL `report.sh --redact` attachment — **NOT produced by `[E]`** (CONTRACT-1 / Kimi-r1 L1); F1 never synthesizes it. It is only ever the explicit value the caller passes (default None). Forward-compat: future `[F]` STEPs (F3/G6-A) ADD `predicted_b_breakdown` to pt1 and `failure_log_excerpt` + `actual.{...}` to pt3. F1 MUST tolerate those being present OR absent — it validates only the `[E]`-shipped required shape and never rejects an additive future key. Raises `CaptureBundleError` on ANY schema/shape violation (schema != 1, a missing required artifact, a wrong `point`, malformed JSON). """ cdir = Path(capture_dir) if not cdir.is_dir(): raise CaptureBundleError(f"capture dir does not exist: {cdir}") # ---- manifest (required; schema==1 hard-asserted) ------------------ manifest = _load_json(cdir / _MANIFEST_FILENAME) if manifest.get("schema") != EXPECTED_SCHEMA: raise CaptureBundleError( f"manifest schema must be {EXPECTED_SCHEMA}, " f"got {manifest.get('schema')!r}" ) # All CONTRACT-1 first-class consensus/dedup inputs MUST be present — # F1 is the boundary that guarantees them to downstream STEPs. _require_keys( manifest, ( "schema", "slug", "utc_ts", "submission_fingerprint", "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", "failure_class", "club3090_commit", "outcome", "capture_points", ), "manifest.json", ) # BINDING RULE: failure_class is authoritatively null in every [E] # manifest (capture.py:569) — F1 enforces that invariant (a non-null # value means [E] wrongly classified, which it must never do). if manifest["failure_class"] is not None: raise CaptureBundleError( "manifest.failure_class must be null in an [E] bundle " f"([F] classifies, not [E]); got {manifest['failure_class']!r}" ) # ---- pt1-pt4 (required); each schema/point hard-asserted ----------- pts: dict = {} for fname, expected_point in _REQUIRED_POINTS.items(): obj = _load_json(cdir / fname) # pt1 carries `schema` (`capture.py:443`); pt2-4 do not ship a # `schema` key (`capture.py:454/464/478`) — assert it ONLY where # `[E]` actually emits it (assert-where-present, never invent a # constraint `[E]` does not satisfy). if "schema" in obj and obj["schema"] != EXPECTED_SCHEMA: raise CaptureBundleError( f"{fname} schema must be {EXPECTED_SCHEMA}, " f"got {obj['schema']!r}" ) if obj.get("point") != expected_point: raise CaptureBundleError( f"{fname} point must be {expected_point!r}, " f"got {obj.get('point')!r}" ) pts[fname] = obj # ---- pt5 (optional — present iff override-accepted) --------------- pt5_path = cdir / _PT5_FILENAME pt5_override: Optional[dict] = None if pt5_path.is_file(): pt5_override = _load_json(pt5_path) if pt5_override.get("point") != "override_capture": raise CaptureBundleError( f"{_PT5_FILENAME} point must be 'override_capture', " f"got {pt5_override.get('point')!r}" ) return FInput( manifest=manifest, pt1_gate=pts["pt1-gate.json"], pt2_download=pts["pt2-download.json"], pt3_boot=pts["pt3-boot.json"], pt4_smoke=pts["pt4-smoke.json"], pt5_override=pt5_override, raw_bundle_path=( Path(raw_bundle_path) if raw_bundle_path is not None else None ), )