CONTRACT-1.1 capture-on-hard-block (additive only; zero v0.8.0 decision-logic
change — the safetensors/GGUF paths are byte-unchanged):
- capture.py: new SEPARATE emit_gate_capture() (the emit_override_capture
byte-preserving precedent — NOT invoked by emit_capture()) writing a
pt1-gate.json + schema:2 manifest.json (outcome:hard-block, exact shipped
abort_reason, failure_class:null) per the per-abort-stratum key table
(model/arch/quant null pre-deriver; topology best-effort/nullable, capture-
only resolve; post-C0 always null). New shared write_last_marker() helper
(atomic tmp+os.replace) called from BOTH emit_capture() and the gate
emitter (centralization mandate — gate-only is the commonest failure).
- pull.py: pass-through capture on the 7 terminal hard-block return paths
(deriver / profile-like / hardware-sm-undetermined / C0 / C2a / no-fit-model
/ C1) — emits a bundle before the existing `return res`; the decision is
byte-unchanged; injectable gate_capture_fn; never raises.
- loop_input.py: BaseCaptureBundle typing.Protocol (Optional[dict] pt2-5);
FInput satisfies it by construction (verified: no isinstance(finput,FInput)
anywhere in F2/F5 — pure static retype, schema==1 byte-identical incl.
dedup_hash); new FInputGate + read_gate_bundle() (schema==2; validates ONLY
the always-present row + outcome==hard-block + failure_class is None — does
NOT reuse the 22-key validator); FInputGate.dedup_tuple() uses .get(k,None)
(behaviour-neutral schema-1, crash-safe schema-2, deterministic null-topo).
- classifier.py / dedup.py: F2+F5 parameter annotations retyped FInput ->
BaseCaptureBundle. The dedup.py FInput.dedup_hash(_EffProxy()) unbound-class
idiom is FENCED (unchanged — not tidied). Additive gate_abort_reason
_match_condition kind (reads pt1_gate.abort_reason; bool like sibling kinds;
no enum/routing change).
- failure_fingerprints.yml: seeded gate_abort_reason rules keyed on the
verified shipped abort_reason strings — only engine-support-unknown/
no-arch-row -> kernel-unsupported (public-filed); runtime-incompatible /
disk-short / hard-block / catch-alls -> unknown (review-queued, not filed).
- tests: extended test-{pull,pullemit-capture,loop-input,classifier,dedup}.sh
with the V1 RED-LINE proofs (emit_capture() still writes ONLY pt1-4; a
schema==1 bundle yields byte-identical FInput / ClassificationResult /
dedup_hash / effective_dedup_hash pre/post the protocol lift; the dedup
fence holds) + gate-emitter / read_gate_bundle / gate_abort_reason routing
/ shared .last marker coverage; all 22 shipped test-*.sh green in the CI
condition (gitignored .pull-captures absent), kv-calc --calibration 11/11.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
706 lines
31 KiB
Python
706 lines
31 KiB
Python
"""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,
|
||
)
|