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
148 lines
4.0 KiB
Python
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)
|