"""v0.8.0 Loop `[F]` — STEP F2+F3: the §6.1 two-tier failure classifier.

CONTRACT-2 (the locked v0.8.0 Loop design brief, "CONTRACT 2" +
"Appendix A"; source-of-truth design §6.1; see docs/LOOP.md for the
shipped contributor reference). F1 (`loop_input.py`) produced the
validated `FInput`; this module CONSUMES it and emits exactly one §6.1
class or `unknown`.

Two tiers (F2 shipped Tier-2; F3 added Tier-1 IN FRONT):
  * **Tier-1 (F3)** — the `torch.cuda.OutOfMemoryError` regex fast-path:
    on the definitive OOM signature -> always `genuine-oom`, decided by
    `Tier.TIER1`, with the predicted-vs-actual delta and the
    kv-calc-bug routing gate. It reads ONLY the structured `[E]` fields
    F3's additive `[E]` touch persists (pt1.predicted_b_breakdown,
    pt3.failure_log_excerpt → error_substring, pt3.actual.{...}, or the
    richer pt5 by A-iii precedence) — NEVER raw logs (CONTRACT-1:
    classifier.py is a pure bundle reader). Tier-1 is the ONLY path
    allowed to set `route_as_kv_calc_bug=True`, and ONLY when
    `failure_class==genuine-oom` AND `predicted_b_breakdown` AND
    `attempted_alloc_mib` AND `gpu_worker_reported_mib` are ALL present
    (else `genuine-oom` is still classified+filed, but
    `route_as_kv_calc_bug=False` — honest degrade, never a
    confidently-wrong kv-calc-bug filing).
  * **Tier-2 (F2)** — the semantic-fingerprint DB + Appendix-A seed
    matchers. Reached ONLY when Tier-1 finds no OOM signature (Tier-1
    returns `None` -> fall straight through; F2 behaviour byte-unchanged).
    Tier-2 NEVER sets `route_as_kv_calc_bug` (hard-False there).

§6.1 enum (VERBATIM — exactly these 6, no 7th value can leak):
    genuine-oom | overlay-arch-drift | kernel-unsupported |
    quant-unsupported | benign-cold-start | unknown

§6.1 routing (implemented + commented below):
  * classifier emits exactly one enumerated class OR `unknown`;
  * `benign-cold-start` is SUPPRESSED — `should_file=False` (never filed);
  * `unknown` -> `should_file=False` + `review_queue=True` (maintainer
    review queue `.pull-captures/_review-queue/`; NEVER auto-files a
    kv-calc bug);
  * `route_as_kv_calc_bug` is ALWAYS False in F2 — F3's Tier-1 owns it.

Mislabel safeguard: the §6.1 `failure_class` this module emits is the
value F1's `dedup_tuple()`/`dedup_hash()` consume into the §6.3 dedup key,
so a misclassification yields a different hash and can NEVER silently
merge with a real OOM.

PURE-PYTHON + the SAME PyYAML loader the rest of `scripts/lib/profiles/`
uses (`compat.py` `yaml.safe_load`) — no new dependency. House style
mirrors `deriver.py` (`class …(str, Enum)`, frozen dataclasses, return
structured results, never raise for an expected outcome).
"""

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional

try:  # same import discipline as compat.py — reuse, do NOT add a dep.
    import yaml
except ModuleNotFoundError as exc:  # pragma: no cover - env guard
    raise RuntimeError(
        "scripts.lib.profiles.classifier requires PyYAML; install "
        "python3-yaml or pip install pyyaml"
    ) from exc

# v0.8.2 CONTRACT-1.1 — F2 is retyped to the `BaseCaptureBundle` Protocol so
# a schema==1 `FInput` AND a schema==2 `FInputGate` both satisfy the consumer
# surface by structural subtyping (pure static retype — verified: there is no
# `isinstance(finput, FInput)` anywhere in this module, only annotations, so
# the schema==1 path is byte-identical incl. dedup-hash).
from scripts.lib.profiles.loop_input import BaseCaptureBundle

# The seed DB ships next to this module (RED-LINE F2 file).
_DEFAULT_DB = Path(__file__).with_name("failure_fingerprints.yml")

# The normalized error-substring slice is length-bounded (same 240-char
# discipline as `[E]`'s `results_detail.error` cap, capture.py:254). The
# fingerprint salt is `arch_family + engine_version` (§6.1 verbatim).
_ERROR_SUBSTRING_CAP = 240


class FailureClass(str, Enum):
    """The §6.1 class enum — VERBATIM, exactly these 6. No additions.

    House style: `class …(str, Enum)` (mirrors `deriver.py` Confidence /
    DeriverErrorKind). The membership of THIS enum is the hard guarantee
    that no 7th value can ever leak out of `classify()`.
    """

    GENUINE_OOM = "genuine-oom"
    OVERLAY_ARCH_DRIFT = "overlay-arch-drift"
    KERNEL_UNSUPPORTED = "kernel-unsupported"
    QUANT_UNSUPPORTED = "quant-unsupported"
    BENIGN_COLD_START = "benign-cold-start"
    UNKNOWN = "unknown"


# §6.1 acceptance: `benign-cold-start` is suppressed (not filed); `unknown`
# -> maintainer review queue (never auto-files). Every OTHER class files a
# (deduped, F5) issue — but NEVER a kv-calc bug in F2 (that is F3 Tier-1).
_SUPPRESSED_NEVER_FILED = {FailureClass.BENIGN_COLD_START, FailureClass.UNKNOWN}


class Tier(str, Enum):
    """Which tier decided. F2 only ever emits `TIER2` (Tier-2 DB) or
    `NONE_UNKNOWN` (no match -> `unknown`). `TIER1` is the F3 SEAM — it is
    defined here so F3 plugs Tier-1 in front of Tier-2 without changing
    this enum, but F2 NEVER returns it.
    """

    TIER1 = "tier1"  # F3 SEAM — never emitted by F2.
    TIER2 = "tier2"
    NONE_UNKNOWN = "none-unknown"


@dataclass(frozen=True)
class ClassificationResult:
    """The §6.1 classifier verdict (returned, never raised).

    `failure_class`        — exactly one of the 6 `FailureClass` members.
    `tier`                 — which tier decided (F2: TIER2 or NONE_UNKNOWN;
                             TIER1 reserved for F3).
    `fingerprint`          — sha256(error_substring + arch_family +
                             engine_version)[:12] hex (same [:12]
                             truncation as F1 `dedup_hash`, for
                             consistency; documented in the YAML header).
    `should_file`          — False for `benign-cold-start` (suppressed) AND
                             `unknown` (review queue). True otherwise.
    `route_as_kv_calc_bug` — ALWAYS False in F2. F3's Tier-1 owns this
                             gate: only `genuine-oom` WITH all three Tier-1
                             inputs present (pt1.predicted_b_breakdown +
                             pt3.actual.attempted_alloc_mib +
                             pt3.actual.gpu_worker_reported_mib) may ever
                             set it. F2 has none of that data; hard-False.
    `review_queue`         — True iff `unknown` (-> maintainer review
                             queue `.pull-captures/_review-queue/`).
    `error_substring`      — the normalized, redacted, length-bounded slice
                             that was fingerprinted (surfaced for the F5
                             issue body / maintainer review).
    `matched_rule`         — id of the seed rule that matched, or None
                             (exact-hash hit -> the fingerprint; no match
                             -> None; Tier-1 OOM fast-path -> the literal
                             "tier1-oom-fastpath").
    `predicted_vs_actual_delta_mib`
                           — F3 Tier-1 ONLY: the candidate kv-calc-bug
                             signal = actual_peak_mib - predicted_total_mib
                             (gpu_worker measured peak minus the summed [B]
                             predicted breakdown), or None when Tier-1 did
                             not fire / inputs incomplete. NEVER fabricated.
    `tier1_inputs`         — F3 Tier-1 ONLY: the resolved
                             {predicted_b_breakdown, attempted_alloc_mib,
                             gpu_worker_reported_mib, source} triple
                             (source ∈ {pt5, pt3+pt1, None}). `None` when
                             Tier-1 did not fire.
    """

    failure_class: FailureClass
    tier: Tier
    fingerprint: str
    should_file: bool
    route_as_kv_calc_bug: bool
    review_queue: bool
    error_substring: str
    matched_rule: Optional[str] = None
    # F3 Tier-1 additive telemetry (default None so every existing F2
    # construction + assertion is byte-unaffected — F2 never sets these).
    predicted_vs_actual_delta_mib: Optional[int] = None
    tier1_inputs: Optional[dict] = None


# ---------------------------------------------------------------------------
# error_substring extraction (CONTRACT-2 source precedence).
# ---------------------------------------------------------------------------
def _norm_error_substring(text: str) -> str:
    """Normalize + length-bound an error signal.

    Lowercase (matchers are case-insensitive by being compared lowercase),
    collapse whitespace (multi-line tracebacks -> a stable single string so
    the fingerprint is deterministic), strip, cap to `_ERROR_SUBSTRING_CAP`.
    The text is ALREADY `[E]`-redacted (pt3.failure_log_excerpt /
    results_detail.error go through `_redact_text`); F2 must NOT re-redact
    blindly (CONTRACT-3 stage-1: artifacts arrive pre-redacted).
    """
    collapsed = " ".join(str(text).split())
    return collapsed.lower()[:_ERROR_SUBSTRING_CAP]


def _extract_error_substring(finput: BaseCaptureBundle) -> str:
    """CONTRACT-2 source precedence (works on TODAY's shipped [E] schema;
    forward-compatible with F3's additive fields, never requires them):

      1. pt3.failure_log_excerpt  — the F3/G6-A field; ABSENT until F3
                                     ships. Tolerated-if-present.
      2. pt3.failure              — the bare string; ALWAYS present in the
                                     [E]-shipped pt3 schema (capture.py).
      3. pt4 results_detail       — first `red` cap's `error`.

    `pt3.actual` is an F3-only structured object F2 NEVER reads (CONTRACT-1
    keeps `[F]` a pure bundle reader; F2 only reads what `[E]` ships today
    plus the additive excerpt if a future capture carries it).
    """
    pt3 = finput.pt3_boot or {}

    # (1) F3/G6-A forward-compat field (absent on today's [E] schema).
    excerpt = pt3.get("failure_log_excerpt")
    if excerpt:
        return _norm_error_substring(excerpt)

    # (2) the bare pt3.failure string — always present in [E]'s pt3 schema.
    failure = pt3.get("failure")
    if failure:
        return _norm_error_substring(failure)

    # (3) first red cap's redacted error in pt4.results_detail.
    pt4 = finput.pt4_smoke or {}
    results = pt4.get("results") or {}
    details = pt4.get("results_detail") or {}
    for cap, verdict in sorted(results.items()):
        if verdict == "red":
            detail = details.get(cap) or {}
            err = detail.get("error")
            if err:
                return _norm_error_substring(err)
            # red but no error text — still a real signal; key on the cap.
            return _norm_error_substring(f"smoke red:{cap}")

    return ""


def _fingerprint(error_substring: str, arch_family: str,
                 engine_version: str) -> str:
    """`sha256(error_substring + arch_family + engine_version)[:12]`.

    §6.1 verbatim salt is `arch_family + engine_version`. Truncated to 12
    hex chars to match F1's `dedup_hash` ([:12]) — consistency documented
    in `failure_fingerprints.yml`'s header.
    """
    joined = f"{error_substring}{arch_family}{engine_version}"
    return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:12]


# ---------------------------------------------------------------------------
# Seed DB load + Tier-2 matching.
# ---------------------------------------------------------------------------
def _load_db(db_path: Path) -> dict:
    """Load the seed DB with the SAME loader the rest of
    `scripts/lib/profiles/` uses (`compat.py` `yaml.safe_load`). No new
    dependency. A missing/empty file degrades to an all-`unknown` DB
    (honest: classify nothing rather than crash the offline loop).
    """
    if not db_path.is_file():
        return {"exact_fingerprints": {}, "condition_matchers": []}
    with db_path.open("r", encoding="utf-8") as fh:
        data = yaml.safe_load(fh) or {}
    data.setdefault("exact_fingerprints", {})
    data.setdefault("condition_matchers", [])
    return data


def _pt3_timeout_but_pt4_green(finput: BaseCaptureBundle) -> bool:
    """Appendix A row 7: pt3 boot timed-out/not-ready BUT pt4 smoked green
    (server became *healthy* after a slow first request) -> benign cold
    start.

    Requires a POSITIVE later-health signal: boot not-ok AND pt4 has at
    least ONE `green` cap AND NO `red` cap. An all-`unsmoked` pt4 is NOT a
    cold start — it means smoke never ran (the boot genuinely failed); that
    path must fall through to the log matchers / `unknown`, never be
    silently suppressed as benign.
    """
    pt3 = finput.pt3_boot or {}
    if pt3.get("ok"):
        return False
    pt4 = finput.pt4_smoke or {}
    results = pt4.get("results") or {}
    if not results:
        return False
    if any(v == "red" for v in results.values()):
        return False
    return any(v == "green" for v in results.values())


def _results_detail_http_404(finput: BaseCaptureBundle) -> bool:
    """Appendix A row 8: historical negative control — [E] bug #1
    served-model-name 404 in pt4.results_detail (HTTP 404 status).
    """
    pt4 = finput.pt4_smoke or {}
    for detail in (pt4.get("results_detail") or {}).values():
        if isinstance(detail, dict) and detail.get("status") == 404:
            return True
    return False


def _match_condition(rule: dict, finput: BaseCaptureBundle,
                     error_substring: str) -> bool:
    """Evaluate ONE Appendix-A condition matcher. FIRST match wins
    (caller iterates in YAML order). Unknown `kind` -> no match (a future
    matcher kind never crashes the offline classifier).
    """
    kind = rule.get("kind")

    if kind == "structural":
        pred = rule.get("predicate")
        if pred == "pt3_timeout_but_pt4_green":
            return _pt3_timeout_but_pt4_green(finput)
        if pred == "results_detail_http_404":
            return _results_detail_http_404(finput)
        return False

    if kind == "pt4_results":
        # The #145 class: a capability `red` while boot is green. Maps via
        # pt4, NOT a pt3 boot failure.
        pt3 = finput.pt3_boot or {}
        if rule.get("require_boot_green") and not pt3.get("ok"):
            return False
        pt4 = finput.pt4_smoke or {}
        results = pt4.get("results") or {}
        return results.get(rule.get("capability")) == rule.get("value")

    if kind == "log_substring":
        if not error_substring:
            return False
        return any(
            str(s).lower() in error_substring
            for s in (rule.get("any") or [])
        )

    if kind == "log_substring_all":
        if not error_substring:
            return False
        subs = rule.get("all") or []
        return bool(subs) and all(
            str(s).lower() in error_substring for s in subs
        )

    # v0.8.2 CONTRACT-1.1 M1 — the ADDITIVE `gate_abort_reason` matcher kind.
    #
    # A new INPUT PREDICATE (exactly analogous to adding a column to a
    # lookup): it reads the EXACT shipped `pt1_gate.abort_reason` sub-token a
    # capture-on-hard-block bundle carries and matches it against a value
    # list — returning bool exactly like the sibling kinds. It is §6.1-
    # NEUTRAL by construction: `_match_condition` -> bool, the class is
    # assigned downstream via `_coerce_class` (the 6-member clamp). It does
    # NOT touch the `FailureClass` enum, `_SUPPRESSED_NEVER_FILED`,
    # `_coerce_class`, or any routing — an implementation-contract addition,
    # NOT a §6.1 design-unlock. A schema==1 [E] bundle has no
    # `pt1_gate.abort_reason` -> `.get` is None -> no match -> the shipped
    # schema==1 classification path is byte-identical (this kind appears in
    # NO shipped seed rule; only the new gate-only rules use it).
    if kind == "gate_abort_reason":
        pt1 = finput.pt1_gate or {}
        ar = pt1.get("abort_reason")
        if not ar:
            return False
        return any(str(ar) == str(v) for v in (rule.get("any") or []))

    return False


# ---------------------------------------------------------------------------
# F3 — §6.1 Tier-1 rule fast-path (CONTRACT-2 A-i/A-ii/A-ii′/A-iii).
#
# Tier-1 plugs IN FRONT of Tier-2. It ONLY handles the definitive OOM
# fast-path; ANYTHING else (no OOM signature) returns None and falls
# straight through to F2's unchanged Tier-2 (no F2 regression).
#
# §6.1 verbatim: "regex for the definitive OOM signature
# (torch.cuda.OutOfMemoryError during weight/KV allocation). Match ->
# extract attempted-allocation size from traceback + gpu_worker.py measured
# peak -> diff vs kv-calc predicted -> emit predicted-vs-actual delta =
# candidate kv-calc bug."
#
# classifier.py is a PURE bundle reader (CONTRACT-1): it reads the
# structured `pt3.actual` / `pt1.predicted_b_breakdown` / `pt5` fields `[E]`
# emitted — it NEVER parses raw logs (the regex below is only the OOM
# *signature* detector on the already-redacted excerpt; the numbers come
# from `[E]`'s `pt3.actual`).
#
# Routing gate (CONTRACT-2 A-ii′ + §11, verbatim): set
# route_as_kv_calc_bug=True ONLY when failure_class==genuine-oom AND
# predicted_b_breakdown AND attempted_alloc_mib AND gpu_worker_reported_mib
# are ALL present; else classify normally (genuine-oom) but
# route_as_kv_calc_bug=False (honest degrade — never a confidently-wrong
# kv-calc-bug filing).
#
# A-iii input precedence (verbatim):
#   pt5 structured fields
#     > (pt3 failure_log_excerpt + pt3.actual + pt1 predicted_b_breakdown)
#       > pt3.failure bare string
# ---------------------------------------------------------------------------

# The definitive OOM signature. `torch.cuda.OutOfMemoryError` is the
# CLASSIC weight/KV-allocation OOM vLLM/torch raises; tolerate the common
# phrasings.
#
# F8-fix: the on-rig F8 validator proved the CLASSIC-only signature MISSES
# the very common modern vLLM v0.21.0+ kv-calc-relevant failure: vLLM
# nightly (bf610c2f) raises a CLEAN `ValueError` from
# `_check_enough_kv_cache_memory` — NOT `torch.cuda.OutOfMemoryError` —
# whenever the requested max_model_len's KV cache exceeds the measured-
# available KV memory (verbatim shape from a captured real vLLM KV-OOM):
#
#   ValueError: To serve at least one request with the models's max seq len
#   (2000000), (22.89 GiB KV cache is needed, which is larger than the
#   available KV cache memory (20.89 GiB). ...
#
# plus the historically-seen older vLLM phrasing
# "No available memory for the cache blocks". Both are a GENUINE OOM (the
# request cannot be served on the available memory) and are exactly the
# kv-calc-bug-relevant class Tier-1 must route. Widen the signature
# ADDITIVELY (the classic torch path is the FIRST alternative — never
# regressed; the new alternatives only ADD coverage).
_RE_OOM_SIGNATURE = re.compile(
    r"torch\.cuda\.outofmemoryerror"
    r"|cuda out of memory"
    r"|cuda error: out of memory"
    r"|outofmemoryerror: cuda"
    # F8-fix: real modern vLLM v0.21.0+ KV-cache-too-large ValueError.
    r"|kv cache is needed, which is larger than the available kv cache"
    r" memory"
    # F8-fix: older vLLM "no KV blocks fit" phrasing (defensive).
    r"|no available memory for the cache blocks",
    re.IGNORECASE,
)


def _predicted_total_mib(breakdown) -> Optional[int]:
    """Sum a `[B]` `{component: GB}` breakdown -> MiB total (the predicted
    side of the delta). None when not a usable numeric mapping — NEVER
    fabricate (the §1 confidently-wrong rule). Mirrors
    `capture._predicted_total_mib` (same convention; kept local so the
    classifier has no import-time dependency on capture.py).
    """
    if not isinstance(breakdown, dict) or not breakdown:
        return None
    total_gb = 0.0
    saw = False
    for v in breakdown.values():
        if isinstance(v, (int, float)):
            total_gb += float(v)
            saw = True
    if not saw:
        return None
    return int(round(total_gb * 1024.0))


def _resolve_tier1_inputs(finput: BaseCaptureBundle) -> dict:
    """Resolve the THREE Tier-1 inputs by the A-iii precedence
    (pt5 > pt3-triple). Returns a dict ALWAYS carrying the keys
    `predicted_b_breakdown`, `attempted_alloc_mib`,
    `gpu_worker_reported_mib`, `source` (each value may be None).

    `source`:
      "pt5"      — taken from the override-accepted pt5 (richest); its
                   `actual.{boot_peak_mib,gpu_worker_reported_mib}` is the
                   measured side, `predicted_b_breakdown` the predicted.
                   pt5's measured "attempted alloc" proxy is `boot_peak_mib`
                   (the peak the forced low-confidence boot actually hit).
      "pt3+pt1"  — the normal-path triple: pt1.predicted_b_breakdown +
                   pt3.actual.{attempted_alloc_mib,gpu_worker_reported_mib}.
      None       — neither source carried usable structured fields.
    """
    # --- (1) pt5 structured fields win when present (A-iii) -------------
    pt5 = finput.pt5_override
    if isinstance(pt5, dict):
        pred = pt5.get("predicted_b_breakdown")
        actual = pt5.get("actual")
        if isinstance(actual, dict):
            return {
                "predicted_b_breakdown": pred,
                # pt5's measured "attempted/used" side is boot_peak_mib.
                "attempted_alloc_mib": actual.get("boot_peak_mib"),
                "gpu_worker_reported_mib": actual.get(
                    "gpu_worker_reported_mib"
                ),
                "source": "pt5",
            }

    # --- (2) the normal-path triple: pt3.actual + pt1.predicted --------
    pt3 = finput.pt3_boot or {}
    pt1 = finput.pt1_gate or {}
    actual = pt3.get("actual")
    if isinstance(actual, dict) or pt1.get("predicted_b_breakdown") is not None:
        a = actual if isinstance(actual, dict) else {}
        return {
            "predicted_b_breakdown": pt1.get("predicted_b_breakdown"),
            "attempted_alloc_mib": a.get("attempted_alloc_mib"),
            "gpu_worker_reported_mib": a.get("gpu_worker_reported_mib"),
            "source": "pt3+pt1",
        }

    return {
        "predicted_b_breakdown": None,
        "attempted_alloc_mib": None,
        "gpu_worker_reported_mib": None,
        "source": None,
    }


def _tier1_oom_fastpath(
    finput: BaseCaptureBundle, error_substring: str
) -> Optional[ClassificationResult]:
    """The §6.1 Tier-1 fast-path. Returns a `ClassificationResult` IFF the
    definitive OOM signature is present (always `genuine-oom`); else `None`
    -> the caller falls through to the unchanged Tier-2.

    A-iii precedence for the OOM-signature SOURCE: the pt3
    `failure_log_excerpt` (already in `error_substring` via
    `_extract_error_substring`'s precedence chain) else the pt3 `failure`
    bare string. The NUMBERS are read from the structured `pt3.actual` /
    `pt1.predicted_b_breakdown` / `pt5` fields (`_resolve_tier1_inputs`) —
    classifier.py never parses raw logs (CONTRACT-1).

    F8-fix: the OOM-SIGNATURE DETECTION scans the FULL (lowercased)
    pt3.failure_log_excerpt / pt3.failure / pt5.exit_error_summary — NOT
    the 240-char-capped `error_substring`. The on-rig F8 validator proved
    real modern vLLM v0.21.0+ emits ~110 chars of `gpu_worker.py` +
    `EngineCore`/`ValueError:` log prefix BEFORE the diagnostic phrase
    `KV cache is needed, which is larger than the available KV cache
    memory`, so that phrase falls PAST the `_ERROR_SUBSTRING_CAP`
    truncation and the (even widened) signature regex would never see it
    when scanned against `error_substring`. `error_substring` itself
    (the fingerprint + telemetry value) stays 240-capped and BYTE-
    UNCHANGED; only the Tier-1 signature *detection surface* is widened
    to the full excerpt. Tier-2 is byte-unaffected (it still keys on the
    capped `error_substring`).
    """
    pt3 = finput.pt3_boot or {}
    pt5 = finput.pt5_override

    # OOM-signature DETECTION surface (A-iii precedence + F8-fix): the FULL
    # untruncated excerpt text. Precedence mirrors `_extract_error_substring`
    # (pt3.failure_log_excerpt > pt3.failure) PLUS the pt5 exit summary.
    # Lowercased only (the signature regex is case-insensitive anyway; this
    # keeps it cheap + deterministic). The capped `error_substring` is still
    # ALSO scanned so any future signal that lands within 240 chars (and
    # nowhere else) is not lost — union of both surfaces, never narrower.
    full_excerpt = pt3.get("failure_log_excerpt") or pt3.get("failure") or ""
    sig_parts = [error_substring or "", str(full_excerpt).lower()]
    if isinstance(pt5, dict) and pt5.get("exit_error_summary"):
        sig_parts.append(str(pt5["exit_error_summary"]).lower())
    sig_text = " ".join(p for p in sig_parts if p)
    if not _RE_OOM_SIGNATURE.search(sig_text):
        return None  # not the OOM fast-path -> fall through to Tier-2.

    # Definitive OOM -> genuine-oom (Tier-1).
    cls = FailureClass.GENUINE_OOM
    inputs = _resolve_tier1_inputs(finput)

    pred_mib = _predicted_total_mib(inputs.get("predicted_b_breakdown"))
    attempted = inputs.get("attempted_alloc_mib")
    gpu_worker = inputs.get("gpu_worker_reported_mib")

    # Routing gate (CONTRACT-2 A-ii′ + §11): ALL THREE present.
    all_three_present = (
        inputs.get("predicted_b_breakdown") is not None
        and attempted is not None
        and gpu_worker is not None
    )
    route_as_kv_calc_bug = bool(all_three_present)

    # predicted-vs-actual delta = measured gpu_worker peak - predicted
    # total. Only when BOTH sides are usable numbers (never fabricate).
    delta: Optional[int] = None
    if gpu_worker is not None and pred_mib is not None:
        try:
            delta = int(gpu_worker) - int(pred_mib)
        except (TypeError, ValueError):
            delta = None

    # §6.1 routing: genuine-oom is a real, fileable failure (should_file
    # True) regardless of the kv-calc-bug gate. The kv-calc-bug signal is
    # the ADDITIONAL routing on top, gated on all-3-present (honest degrade
    # otherwise: classified + filed as a normal issue, never a
    # confidently-wrong kv-calc bug).
    fp = _fingerprint(
        error_substring, finput.arch_family, finput.engine_version
    )
    return ClassificationResult(
        failure_class=cls,
        tier=Tier.TIER1,
        fingerprint=fp,
        should_file=cls not in _SUPPRESSED_NEVER_FILED,
        route_as_kv_calc_bug=route_as_kv_calc_bug,
        review_queue=False,
        error_substring=error_substring,
        matched_rule="tier1-oom-fastpath",
        predicted_vs_actual_delta_mib=delta,
        tier1_inputs=inputs,
    )


def _coerce_class(value) -> FailureClass:
    """Map a DB string to the enum. ANY value not in the 6-member §6.1
    enum (a corrupt/typo'd seed row, an out-of-enum 7th value) degrades to
    `unknown` — the hard guarantee that no 7th value can leak.
    """
    try:
        return FailureClass(str(value))
    except ValueError:
        return FailureClass.UNKNOWN


# ---------------------------------------------------------------------------
# Public API.
# ---------------------------------------------------------------------------
def classify(
    finput: BaseCaptureBundle,
    *,
    fingerprint_db_path: Optional[Path] = None,
) -> ClassificationResult:
    """Classify ONE capture bundle into exactly one §6.1 class or
    `unknown` (CONTRACT-2 Tier-2).

    F2 = Tier-2 ONLY. Order:
      1. Extract `error_substring` (CONTRACT-2 source precedence).
      2. Compute `fingerprint = sha256(error_substring + arch_family +
         engine_version)[:12]`.
      3. F3 SEAM: Tier-1 plugs in HERE, in front of Tier-2 (it will
         consult pt1.predicted_b_breakdown + pt3.actual.{...}). F2 has no
         Tier-1 data and does not implement it — fall straight through.
      4. Tier-2: exact-fingerprint table hit -> that class. Else first
         matching Appendix-A condition matcher -> its class. Else
         `unknown`.
      5. §6.1 routing: `benign-cold-start` + `unknown` -> should_file
         False; `unknown` -> review_queue True; `route_as_kv_calc_bug`
         HARD-False (F3 Tier-1 owns it).

    Never raises for an expected outcome (house style: structured result).
    """
    db = _load_db(
        Path(fingerprint_db_path) if fingerprint_db_path else _DEFAULT_DB
    )

    error_substring = _extract_error_substring(finput)
    arch_family = finput.arch_family
    engine_version = finput.engine_version
    fp = _fingerprint(error_substring, arch_family, engine_version)

    # ---- F3 Tier-1 (IN FRONT of Tier-2) --------------------------------
    # The §6.1 Tier-1 rule fast-path: regex the definitive OOM signature;
    # on a match -> always `genuine-oom`, decided by Tier.TIER1, with the
    # predicted-vs-actual delta + the all-3-present kv-calc-bug routing
    # gate (CONTRACT-2 A-ii′ + §11). It reads ONLY the structured `[E]`
    # fields (pt5 > pt3.actual+pt1.predicted_b_breakdown, A-iii precedence)
    # — never raw logs (CONTRACT-1: classifier.py is a pure bundle reader).
    # NO OOM signature -> `None` -> fall straight through to the unchanged
    # Tier-2 block below (no F2 regression).
    t1 = _tier1_oom_fastpath(finput, error_substring)
    if t1 is not None:
        return t1

    # ---- Tier-2 --------------------------------------------------------
    matched_rule: Optional[str] = None
    tier = Tier.TIER2

    # (a) exact hash-keyed table (grown by maintainer-classified
    #     submissions; seeded empty — see YAML header).
    exact = db.get("exact_fingerprints") or {}
    if fp in exact:
        cls = _coerce_class(exact[fp])
        matched_rule = fp
    else:
        # (b) Appendix-A condition matchers — FIRST match wins.
        cls = None
        for rule in db.get("condition_matchers") or []:
            if _match_condition(rule, finput, error_substring):
                cls = _coerce_class(rule.get("class"))
                matched_rule = rule.get("id")
                break
        if cls is None:
            # (c) unmatched -> unknown -> maintainer review queue.
            cls = FailureClass.UNKNOWN
            tier = Tier.NONE_UNKNOWN

    # ---- §6.1 routing rules -------------------------------------------
    # exactly one class or `unknown` (guaranteed by _coerce_class +
    # FailureClass membership — no 7th value can leak).
    # `benign-cold-start` SUPPRESSED (not filed); `unknown` -> review
    # queue, not filed, never auto-files a kv-calc bug.
    should_file = cls not in _SUPPRESSED_NEVER_FILED
    review_queue = cls is FailureClass.UNKNOWN

    # HARD-WIRED False in F2. F3's Tier-1 is the ONLY code allowed to set
    # this True, and only for `genuine-oom` WITH all three Tier-1 inputs
    # present (pt1.predicted_b_breakdown + pt3.actual.attempted_alloc_mib
    # + pt3.actual.gpu_worker_reported_mib). F2 owns NEITHER the data nor
    # the gate — even a genuine-oom is should_file=True but
    # route_as_kv_calc_bug=False here (honest degrade: classified + filed
    # as a normal issue, never confidently-wrong as a kv-calc bug).
    route_as_kv_calc_bug = False

    return ClassificationResult(
        failure_class=cls,
        tier=tier,
        fingerprint=fp,
        should_file=should_file,
        route_as_kv_calc_bug=route_as_kv_calc_bug,
        review_queue=review_queue,
        error_substring=error_substring,
        matched_rule=matched_rule,
    )
