Files
stack/src/lead/gates.py
kert d6590a2357
All checks were successful
CI / lint (push) Successful in 37s
CI / test (push) Successful in 3m32s
CI / notebooks-smoke (push) Successful in 1m48s
Infra CI / notebooks (push) Successful in 1m47s
Infra CI / zotero (push) Successful in 20s
Infra CI / docs (push) Successful in 1m11s
Infra CI / api (push) Successful in 1m19s
Infra CI / llm (push) Successful in 1m0s
Infra CI / mc (push) Successful in 20s
Infra CI / bcda (push) Successful in 1m59s
CI / lint (pull_request) Successful in 42s
CI / notebooks-smoke (pull_request) Successful in 1m54s
Infra CI / notebooks (pull_request) Successful in 1m0s
CI / test (pull_request) Successful in 3m40s
Infra CI / docs (pull_request) Successful in 15s
Infra CI / zotero (pull_request) Successful in 17s
Infra CI / bcda (pull_request) Successful in 17s
Infra CI / api (pull_request) Successful in 18s
Infra CI / mc (pull_request) Successful in 17s
Infra CI / llm (pull_request) Successful in 19s
fix(lead): keep every manifest row and refuse a repeated Databricks start
2026-10-31 22:28:52 -04:00

148 lines
4.0 KiB
Python

"""Promotion gates and the record of runs that did not pass them.
A candidate is promoted only when every gate holds. A failed or discarded
run still appends one run row and one finding row. The actor is not part
of either hash.
"""
from __future__ import annotations
import hashlib
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from lead.ledger.store import append
from lead.targets import by_id
_RELATIONS = frozenset({"failed", "discarded", "promoted"})
@dataclass(frozen=True)
class GateInput:
"""What one candidate showed, out of time, against named claims."""
beats_automl: bool
beats_naive: bool
train_through: int
test_year: int
calibration_error: float
calibration_tolerance: float
conformal_coverage: float
coverage_target: float
subgroup_pass: bool
feature_claim_ids: tuple[str, ...]
known_claim_ids: frozenset[str]
@dataclass(frozen=True)
class GateResult:
"""Empty reasons means the candidate is promoted."""
promoted: bool
reasons: tuple[str, ...]
def promotion_gates(item: GateInput) -> GateResult:
"""Check the Part 3 gates. Reasons stay in a fixed order."""
reasons: list[str] = []
if not item.beats_automl or not item.beats_naive:
reasons.append("baseline")
if item.test_year != item.train_through + 1:
reasons.append("out_of_time")
if (
math.isnan(item.calibration_error)
or item.calibration_error > item.calibration_tolerance
):
reasons.append("calibration")
if (
math.isnan(item.conformal_coverage)
or item.conformal_coverage < item.coverage_target
):
reasons.append("coverage")
if not item.subgroup_pass:
reasons.append("subgroup")
if not item.feature_claim_ids or any(
claim_id not in item.known_claim_ids for claim_id in item.feature_claim_ids
):
reasons.append("claim")
return GateResult(promoted=not reasons, reasons=tuple(reasons))
def _hash(*parts: str) -> str:
return hashlib.sha256("\n".join(parts).encode()).hexdigest()
def record_outcome(
con,
*,
actor: str,
target_id: str,
model: str,
where_ran: str,
finished_on: str,
metrics: str,
gate: GateResult,
relation: str,
claim_id: str = "",
) -> tuple[bool, bool]:
"""Append a run and a finding. Return whether each insert was new.
Raises:
ValueError: ``relation`` is not failed, discarded, or promoted.
KeyError: ``target_id`` is not in the registry.
"""
if relation not in _RELATIONS:
raise ValueError(relation)
by_id(target_id)
reasons = ",".join(gate.reasons)
run_hash = _hash(
target_id, model, where_ran, finished_on, metrics, reasons, relation
)
finding_hash = _hash(run_hash, relation, claim_id)
run_wrote = append(
con,
"run",
{
"id": run_hash,
"target_id": target_id,
"where_ran": where_ran,
"finished_on": finished_on,
"model": model,
"metrics": metrics,
"gate_results": reasons,
"content_hash": run_hash,
"actor": actor,
},
)
finding: dict[str, object] = {
"id": finding_hash,
"relation": relation,
"numbers": reasons,
"content_hash": finding_hash,
"actor": actor,
}
if claim_id:
finding["claim_id"] = claim_id
finding_wrote = append(con, "finding", finding)
return run_wrote, finding_wrote
def events_in_window(
*,
window_start: str,
alarm_on: str,
events: Sequence[Mapping[str, str]],
) -> tuple[str, ...]:
"""Citations whose day falls inside the window, including both ends."""
start = window_start[:10]
end = alarm_on[:10]
found: list[str] = []
for event in events:
day = str(event.get("event_on") or "")[:10]
if start <= day <= end:
found.append(
str(event.get("citation") or event.get("series_or_event") or "")
)
return tuple(found)