Files
Jyotisha/scripts/rectification_prior_calibration.py
T
Jesse_Chen 62afa521f4 fix(rectification): keep proportional prior after holdout calibration (BUG-560)
Implement offset and softmax relative-support scales and a public holdout calibration script, but leave the default proportional because no scheme passed the coverage-and-width gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 23:05:42 +08:00

291 lines
11 KiB
Python

#!/usr/bin/env python3
"""Compare relative_support scales on public minute-rectification cases.
Reads development cases for inspection and the sealed holdout for the
enablement gate. Scores each window once, then rebuilds public candidate
decisions under proportional / offset / softmax temperatures.
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
import traceback
from calendar import monthrange
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from uuid import NAMESPACE_URL, uuid5
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.rectification.candidate_contrast import cluster_contexts_by_signature, context_time
from scripts.rectification.contracts import normalize_rectification_request
from scripts.rectification.decision_policy import build_candidate_decisions
from scripts.rectification.scoring_service import (
build_event_contribution_matrix,
score_from_matrix,
scoreable_request,
)
DEVELOPMENT_MANIFEST = ROOT / "references" / "real_case_calibration" / "minute_rectification_development_v1.json"
HOLDOUT_MANIFEST = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v3.json"
LEAD = 8
TODAY = date(2026, 9, 6)
KIND_BY_DOMAIN = {
"education": "education_milestone",
"career": "career_change",
"relationship": "relationship_commitment",
"relocation": "relocation",
"health_pressure": "self_health_event",
"health": "self_health_event",
"finance": "finance_change",
"family": "family_event",
}
SCHEMES: tuple[dict[str, Any], ...] = (
{"id": "proportional", "mode": "proportional", "temperature": None},
{"id": "offset", "mode": "offset", "temperature": None},
{"id": "softmax_0.25", "mode": "softmax", "temperature": Decimal("0.25")},
{"id": "softmax_0.5", "mode": "softmax", "temperature": Decimal("0.5")},
{"id": "softmax_1", "mode": "softmax", "temperature": Decimal("1")},
{"id": "softmax_2", "mode": "softmax", "temperature": Decimal("2")},
)
def _clock(value: str) -> int:
return int(value[:2]) * 60 + int(value[3:5])
def _hhmm_from_minutes(value: int) -> str:
wrapped = value % 1440
return f"{wrapped // 60:02d}:{wrapped % 60:02d}"
def _shift_clock(value: str, delta: int) -> str:
return _hhmm_from_minutes(_clock(value) + delta)
def _expand_date(raw: str, precision: str) -> tuple[str, str]:
text = str(raw or "").strip()
if precision == "day":
day = date.fromisoformat(text)
return day.isoformat(), day.isoformat()
if precision == "month":
month = date.fromisoformat(f"{text}-01") if len(text) == 7 else date.fromisoformat(text[:10]).replace(day=1)
last = monthrange(month.year, month.month)[1]
return month.isoformat(), month.replace(day=last).isoformat()
year = int(text[:4])
return f"{year}-01-01", f"{year}-12-31"
def _event_kind(event: dict[str, Any]) -> str:
domain = str(event.get("domain") or "")
description = str(event.get("description") or "").lower()
if domain == "relationship" and not any(token in description for token in ("married", "wedding", "wife", "husband")):
return "relationship_start"
return KIND_BY_DOMAIN[domain]
def _request_from_case(case: dict[str, Any]) -> dict[str, Any]:
birth = case["birth"]
true_time = str(birth["time"])[:5]
radius = int(case.get("candidate_radius_minutes") or 10)
events = []
for event in case.get("events") or []:
precision = str(event.get("precision") or "year")
start, end = _expand_date(str(event.get("date") or ""), precision)
domain = str(event.get("domain") or "")
events.append({
"id": str(uuid5(NAMESPACE_URL, str(event.get("id") or ""))),
"domain": domain,
"event_kind": _event_kind(event),
"date_start": start,
"date_end": end,
"precision": precision,
"summary": str(event.get("description") or event.get("id") or domain)[:200],
})
return normalize_rectification_request({
"birth_date": str(birth["date"]),
"start_time": _shift_clock(true_time, -radius),
"end_time": _shift_clock(true_time, radius),
"lat": float(birth["latitude"]),
"lon": float(birth["longitude"]),
"tz": float(birth["timezone_offset"]),
"events": events,
}, today=TODAY)
def _cluster_bounds(time: str, clusters: list[dict[str, Any]]) -> tuple[str, str]:
for cluster in clusters:
times = [str(item)[:5] for item in cluster.get("times") or [] if str(item)]
if time not in times:
continue
ordered = sorted(times, key=_clock)
return ordered[0], ordered[-1]
return time, time
def _union_metrics(
decisions: list[dict[str, Any]],
*,
true_time: str,
start_time: str,
clusters: list[dict[str, Any]],
) -> dict[str, Any]:
if not decisions:
return {"width": None, "covers": False, "peak": 0, "valid_count": 0}
peak = max(int(item["relative_support"]) for item in decisions)
valid = [item for item in decisions if peak - int(item["relative_support"]) < LEAD]
origin = _clock(start_time)
points: list[int] = []
for item in valid:
time = str(item["time"])[:5]
lo, hi = _cluster_bounds(time, clusters)
for stamp in (time, lo, hi):
points.append((_clock(stamp) - origin) % 1440)
width = (max(points) - min(points) + 1) if points else None
true_offset = (_clock(true_time) - origin) % 1440
covers = width is not None and min(points) <= true_offset <= max(points)
return {
"width": width,
"covers": covers,
"peak": peak,
"valid_count": len(valid),
"supports": [int(item["relative_support"]) for item in decisions],
}
def _score_case(case: dict[str, Any]) -> dict[str, Any]:
request = _request_from_case(case)
scoring = scoreable_request(request)
built = build_event_contribution_matrix(scoring)
rows = score_from_matrix(scoring, built)
contexts = built.get("static_contexts") if isinstance(built.get("static_contexts"), list) else None
clusters = cluster_contexts_by_signature(
[item for item in (contexts or []) if isinstance(item, dict) and context_time(item)]
) if contexts else []
true_time = str(case["birth"]["time"])[:5]
schemes = {}
for scheme in SCHEMES:
decisions = build_candidate_decisions(
rows,
result_id=str(uuid5(NAMESPACE_URL, case["case_id"])),
static_contexts=contexts,
support_mode=scheme["mode"],
temperature=scheme["temperature"],
)
schemes[scheme["id"]] = _union_metrics(
decisions,
true_time=true_time,
start_time=request["start_time"],
clusters=clusters,
)
return {
"case_id": case["case_id"],
"radius": int(case.get("candidate_radius_minutes") or 10),
"schemes": schemes,
}
def _summarize(rows: list[dict[str, Any]]) -> dict[str, Any]:
table: dict[str, Any] = {}
for scheme in SCHEMES:
widths = [int(item["schemes"][scheme["id"]]["width"]) for item in rows if item["schemes"][scheme["id"]]["width"] is not None]
covered = sum(1 for item in rows if item["schemes"][scheme["id"]]["covers"])
table[scheme["id"]] = {
"n": len(rows),
"coverage": covered,
"median_width": statistics.median(widths) if widths else None,
"mean_width": round(statistics.mean(widths), 2) if widths else None,
}
return table
def _pick_scheme(holdout: dict[str, Any]) -> dict[str, Any]:
baseline = holdout["proportional"]
old_coverage = int(baseline["coverage"])
old_median = baseline["median_width"]
passing = []
for scheme_id, row in holdout.items():
if scheme_id == "proportional":
continue
median = row["median_width"]
if median is None or old_median is None:
continue
if int(row["coverage"]) >= old_coverage - 1 and median < old_median:
passing.append((scheme_id, row))
if not passing:
return {"id": "proportional", "enabled": False, "reason": "no_scheme_passed_holdout_gate"}
preferred = [item for item in passing if item[0] == "offset"]
chosen = preferred[0] if preferred else min(passing, key=lambda item: (item[1]["median_width"], -item[1]["coverage"]))
return {"id": chosen[0], "enabled": True, "reason": "passed_holdout_gate"}
def _load_cases(path: Path, *, holdout_only: bool) -> list[dict[str, Any]]:
payload = json.loads(path.read_text(encoding="utf-8"))
cases = list(payload.get("cases") or [])
if holdout_only:
cases = [item for item in cases if item.get("excluded_from_tuning") is True]
return cases
def run(limit: int | None = None) -> dict[str, Any]:
development_cases = _load_cases(DEVELOPMENT_MANIFEST, holdout_only=False)
holdout_cases = _load_cases(HOLDOUT_MANIFEST, holdout_only=True)
if limit is not None:
development_cases = development_cases[:limit]
holdout_cases = holdout_cases[:limit]
development_rows = [_score_case(item) for item in development_cases]
holdout_rows = [_score_case(item) for item in holdout_cases]
development = _summarize(development_rows)
holdout = _summarize(holdout_rows)
return {
"lead": LEAD,
"development": development,
"holdout": holdout,
"selection": _pick_scheme(holdout),
"development_rows": development_rows,
"holdout_rows": holdout_rows,
}
def _print_table(title: str, table: dict[str, Any]) -> None:
print(title)
print(f"{'scheme':<16} {'coverage':>10} {'median':>10} {'mean':>10}")
for scheme_id, row in table.items():
print(
f"{scheme_id:<16} "
f"{row['coverage']}/{row['n']:<7} "
f"{str(row['median_width']):>10} "
f"{str(row['mean_width']):>10}"
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--limit", type=int, default=None)
parser.add_argument("--json-out", type=Path)
args = parser.parse_args()
try:
report = run(args.limit)
except Exception:
traceback.print_exc()
return 1
_print_table("development", report["development"])
print()
_print_table("holdout", report["holdout"])
print()
print("selection", json.dumps(report["selection"], ensure_ascii=False))
if args.json_out:
args.json_out.parent.mkdir(parents=True, exist_ok=True)
args.json_out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())