Files
Jyotisha/scripts/research/guided_collect_holdout_replay.py
T
Jesse_ChenandClaude Fable 5.1 dc732825d8 fix(rectification): 有题就接着问,题问完才出卡;引导窗口不再硬贴领域
出卡时机只看题源有没有空:撤回「门槛达标就短路采集线」的写法,同时
按 D2 保住「题源全空就按现行规则出卡」——门槛只在还有题可问时挡住
出卡,precision_gate_met 改成只上报(新挂在决策与公开投影上),不再
单独决定时机。引导窗口题在无领域轨道上改问开放题,一个时间窗只问一
次;录入卡提交的是「YYYY 年 M 月,<领域>方面有一件事」,不再是题干
的三选一列表。记忆化 golden 只补一个新键并冻结墙钟。离线回放改成注
入真值方向的边界事件,另跑一组反方向对照。Skill 10.0.28。

BUG-747~752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-16 12:16:55 +00:00

489 lines
18 KiB
Python

#!/usr/bin/env python3
"""Offline T2 replay: after six probes, inject guided_collect_windows events.
F5 (2026-09-16): the injected event now lands on the **true** candidate's own
boundary date for that window's track and index, so the replay measures what a
user who really lived through that window would report. A second, opposite
pass injects the boundary of the candidate furthest from the true minute, as a
control. Neither pass is a merge gate; both numbers go in the progress note.
Does not change production defaults. Writes a compact JSON summary for the
progress note. Not a merge gate.
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
import time
import traceback
from calendar import monthrange
from datetime import date
from pathlib import Path
from typing import Any, Sequence
from uuid import NAMESPACE_URL, uuid5
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.active_rectification_event_engine import ( # noqa: E402
AYANAMSA,
NODE_MODE,
compute_candidate_static_contexts,
)
from scripts.rectification.event_probes import ( # noqa: E402
GUIDED_ANY_DOMAIN,
GUIDED_COLLECT_LIMIT,
_birth_year,
_context_time,
_guided_track_starts,
_guided_year_windows,
_remaining_contexts,
_scoreable,
_static_contexts,
discriminating_event_probes,
guided_collect_windows,
)
from scripts.rectification.refinement_packet import window_scan # noqa: E402
from scripts.rectification.scoring_service import ( # noqa: E402
build_event_contribution_matrix,
score_from_matrix,
scoreable_request,
)
from scripts.rectification_policy import DELIVERY_MAX_WIDTH_MINUTES # noqa: E402
from scripts.research.cluster_width_lib import ( # noqa: E402
SEPARATION_LEAD,
delivery_from_public,
merge_adjacent_traced,
public_from_clusters,
raw_signature_clusters,
still_valid_public,
)
from scripts.research.cluster_width_probe import replay_public # noqa: E402
from scripts.research.minute_resolution_sweep import MINUTE_STEP, scoring_request_for # noqa: E402
from scripts.research.probe_supply_after_six import ASK_COUNT, KIND_BY_DOMAIN # noqa: E402
HOLDOUT = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v4.json"
REPORT_JSON = ROOT / "docs" / "research" / "guided_collect_holdout_2026_09_16.json"
TODAY = date(2026, 9, 16)
RADII = (10, 30, 60)
RANGE_DELIVERY_TIE_PERCENT = 3
def _hhmm(value: object) -> str | None:
text = str(value or "")[:5]
return text if len(text) == 5 and text[2] == ":" else None
def load_cases() -> list[dict[str, Any]]:
payload = json.loads(HOLDOUT.read_text(encoding="utf-8"))
return list(payload.get("cases") or [])
#: Windows are asked open now (BUG-749); the replay still needs one concrete
#: domain to write into the ledger, so an open window uses this one.
DEFAULT_OPEN_DOMAIN = "career"
def synthetic_event(
window: dict[str, Any],
index: int,
*,
when: date,
) -> dict[str, Any]:
"""One month-precision event on `when`, labelled with the window's domain."""
last = monthrange(when.year, when.month)[1]
raw_domain = str(window.get("domain") or "")
domain = DEFAULT_OPEN_DOMAIN if raw_domain in {"", GUIDED_ANY_DOMAIN} else raw_domain
kind = KIND_BY_DOMAIN.get(domain, "career_change")
stamp = f"{when.year:04d}-{when.month:02d}"
return {
"id": str(uuid5(NAMESPACE_URL, f"guided-collect:{index}:{domain}:{stamp}")),
"domain": domain,
"event_kind": kind,
"date_start": f"{stamp}-01",
"date_end": f"{stamp}-{last:02d}",
"precision": "month",
"summary": f"guided {domain} {stamp}",
}
def _minutes(value: str) -> int:
hour, minute = value.split(":")[:2]
return int(hour) * 60 + int(minute)
def window_boundary_dates(
request: dict[str, Any],
built: dict[str, Any],
*,
candidate_times: Sequence[str],
today: date,
) -> dict[tuple[int, int, int], dict[str, date]]:
"""Map each guided window to the per-candidate boundary date behind it.
Mirrors `guided_collect_windows`'s own loop so the (track, index) pairing
is the engine's, not a guess: for every window key (year, month_lo,
month_hi) it returns {candidate_time: that candidate's boundary date}.
"""
birth_date = str(request.get("birth_date") or "").strip()
birth_year = _birth_year(birth_date)
if birth_year is None:
return {}
remaining = _remaining_contexts(built, candidate_times)
if len(remaining) < 2:
remaining = _static_contexts(built)
work = [item for item in remaining if _scoreable(item)]
if len(work) < 2:
return {}
lo, hi = birth_year + 5, min(today.year, birth_year + 80)
vim_cache: dict[tuple[Any, ...], list[date]] = {}
narayana_cache: dict[tuple[Any, ...], list[date] | None] = {}
starts_by_time: dict[str, dict[str, list[date]]] = {}
for context in work:
stamp = _context_time(context)
if not stamp:
continue
starts_by_time[stamp] = _guided_track_starts(
context,
birth_date=birth_date,
lo=lo,
hi=hi,
include_pratyantar=True,
varga_narayana=True,
vim_cache=vim_cache,
narayana_cache=narayana_cache,
)
out: dict[tuple[int, int, int], dict[str, date]] = {}
for track in ("vim", "nara", "nara:d9", "nara:d10"):
lengths = [len(starts_by_time.get(str(_context_time(item)) or "", {}).get(track) or []) for item in work]
if not lengths or min(lengths) == 0:
continue
for index in range(min(lengths)):
per_time: dict[str, date] = {}
for context in work:
stamp = _context_time(context)
dates = (starts_by_time.get(stamp or "") or {}).get(track) or []
if stamp and index < len(dates):
per_time[stamp] = dates[index]
gathered = list(per_time.values())
if len(gathered) < 2 or min(gathered) == max(gathered):
continue
for year, month_lo, month_hi in _guided_year_windows(gathered):
if year < lo or year > hi:
continue
out.setdefault((year, month_lo, month_hi), per_time)
return out
def posterior_state(
*,
rows: Sequence[dict[str, Any]],
contexts: Sequence[dict[str, Any]],
probes: Sequence[dict[str, Any]],
true_time: str,
) -> dict[str, Any]:
raw = raw_signature_clusters(contexts)
by_time = {stamp: row for row in rows if (stamp := _hhmm(row.get("time")))}
merged, _trace = merge_adjacent_traced(raw, by_time)
public = public_from_clusters(merged, rows)
prior = {stamp: float(row.get("score") or 0) for row in public if (stamp := _hhmm(row.get("time")))}
replay = replay_public(probes=probes, public=public, prior=prior, true_time=true_time)
posterior = []
eliminated = set(replay["eliminated"])
scores = dict(replay["scores"])
for row in public:
stamp = _hhmm(row.get("time"))
if not stamp:
continue
posterior.append({**row, "score": scores.get(stamp, row.get("score") or 0)})
valid = still_valid_public(posterior, scores, eliminated, lead=SEPARATION_LEAD)
delivery = delivery_from_public(valid)
return {
"scores": scores,
"eliminated": eliminated,
"public": posterior,
"valid": valid,
"delivery": delivery,
"merged": merged,
}
def precision_gate(valid: Sequence[dict[str, Any]], scores: dict[str, float]) -> dict[str, Any]:
delivery = delivery_from_public(valid)
width = delivery.get("width")
ranked = sorted(
valid,
key=lambda row: (
-float(scores.get(_hhmm(row.get("time")) or "", row.get("score") or 0)),
str(row.get("time") or ""),
),
)
if not ranked:
return {
"met": False,
"width": width,
"tied_for_first": False,
"gap": None,
"percents": [],
}
if len(ranked) == 1:
return {
"met": width is not None and width <= DELIVERY_MAX_WIDTH_MINUTES,
"width": width,
"tied_for_first": False,
"gap": None,
"percents": [100],
}
s0 = float(scores.get(_hhmm(ranked[0].get("time")) or "", ranked[0].get("score") or 0))
s1 = float(scores.get(_hhmm(ranked[1].get("time")) or "", ranked[1].get("score") or 0))
tied = s0 == s1
total = sum(
max(float(scores.get(_hhmm(row.get("time")) or "", row.get("score") or 0)), 0.0)
for row in ranked
)
percents = []
for row in ranked[:3]:
score = max(float(scores.get(_hhmm(row.get("time")) or "", row.get("score") or 0)), 0.0)
percents.append(round(score / total * 100) if total > 0 else 0)
gap = percents[0] - percents[1]
met = (
width is not None
and width <= DELIVERY_MAX_WIDTH_MINUTES
and gap > RANGE_DELIVERY_TIE_PERCENT
and not tied
)
return {
"met": met,
"width": width,
"tied_for_first": tied,
"gap": gap,
"percents": percents,
}
def remaining_times(state: dict[str, Any]) -> list[str]:
times: list[str] = []
seen: set[str] = set()
for row in state["valid"]:
stamp = _hhmm(row.get("time"))
if stamp and stamp not in seen and stamp not in state["eliminated"]:
seen.add(stamp)
times.append(stamp)
for member in row.get("cluster_times") or []:
clock = _hhmm(member)
if clock and clock not in seen and clock not in state["eliminated"]:
seen.add(clock)
times.append(clock)
return times
def evaluate_case(
case: dict[str, Any],
radius: int,
direction: str = "truth",
) -> dict[str, Any]:
true_time = str(case["birth"]["time"])[:5]
request = scoring_request_for(case, radius)
request["ayanamsa"] = AYANAMSA
request["node_mode"] = NODE_MODE
request["minute_step"] = MINUTE_STEP
static_contexts = compute_candidate_static_contexts(request)
built = build_event_contribution_matrix(request, static_contexts=static_contexts)
rows = score_from_matrix(request, built)
times = [stamp for row in rows if (stamp := _hhmm(row.get("time")))]
probes = discriminating_event_probes(
{**request, "refresh_probes": False, "asked_probe_keys": []},
built,
scan=window_scan(built),
candidate_times=times,
representative_time=true_time,
today=TODAY,
)
state = posterior_state(rows=rows, contexts=static_contexts, probes=probes[:ASK_COUNT], true_time=true_time)
gate = precision_gate(state["valid"], state["scores"])
if gate["met"]:
return {
"case_id": case.get("case_id"),
"radius": radius,
"direction": direction,
"events_needed": 0,
"windows": 0,
"met": True,
"after_six": gate,
}
remaining = remaining_times(state)
windows = guided_collect_windows(
request,
built,
candidate_times=remaining or times,
today=TODAY,
)
boundaries = window_boundary_dates(
request,
built,
candidate_times=remaining or times,
today=TODAY,
)
pool = remaining or times
if direction == "truth":
source_time = true_time
else:
source_time = max(
pool,
key=lambda stamp: abs(_minutes(stamp) - _minutes(true_time)),
) if pool else true_time
extras: list[dict[str, Any]] = []
last_gate = gate
injected_from: list[str] = []
for index, window in enumerate(windows):
key = (int(window["year"]), int(window["month_lo"]), int(window["month_hi"]))
per_time = boundaries.get(key) or {}
when = per_time.get(source_time)
if when is None and per_time:
when = per_time[min(per_time, key=lambda stamp: abs(_minutes(stamp) - _minutes(source_time)))]
if when is None:
when = date(int(window["year"]), int(window["month_lo"]), 1)
injected_from.append(f"{source_time}:{when.isoformat()}")
extras.append(synthetic_event(window, index, when=when))
injected = {**request, "events": list(request["events"]) + extras}
rebuilt = build_event_contribution_matrix(
scoreable_request(injected),
static_contexts=static_contexts,
)
new_rows = score_from_matrix(scoreable_request(injected), rebuilt)
state = posterior_state(
rows=new_rows,
contexts=static_contexts,
probes=probes[:ASK_COUNT],
true_time=true_time,
)
last_gate = precision_gate(state["valid"], state["scores"])
if last_gate["met"]:
return {
"case_id": case.get("case_id"),
"radius": radius,
"direction": direction,
"events_needed": len(extras),
"windows": len(windows),
"met": True,
"after_six": gate,
"final": last_gate,
"injected": injected_from,
}
return {
"case_id": case.get("case_id"),
"radius": radius,
"direction": direction,
"events_needed": None,
"windows": len(windows),
"met": False,
"after_six": gate,
"final": last_gate,
"injected": injected_from,
}
def summarize(rows: Sequence[dict[str, Any]], radius: int, direction: str) -> dict[str, Any]:
subset = [
row for row in rows
if row.get("radius") == radius and row.get("direction") == direction and not row.get("error")
]
needed = [int(row["events_needed"]) for row in subset if row.get("events_needed") is not None]
# Cases the six probes had not already settled: the only ones the guided
# windows can be credited for.
from_guided = [
int(row["events_needed"]) for row in subset
if row.get("events_needed") not in (None, 0)
]
met = sum(1 for row in subset if row.get("met"))
return {
"radius": radius,
"direction": direction,
"n": len(subset),
"met": met,
"met_after_six_only": sum(1 for row in subset if row.get("events_needed") == 0),
"met_via_guided": len(from_guided),
"median_events_to_gate": statistics.median(needed) if needed else None,
"median_events_via_guided": statistics.median(from_guided) if from_guided else None,
"median_windows": statistics.median([int(row.get("windows") or 0) for row in subset]) if subset else None,
"unmet": sum(1 for row in subset if not row.get("met")),
"errors": sum(
1 for row in rows
if row.get("radius") == radius and row.get("direction") == direction and row.get("error")
),
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--radii", default="10,30,60")
parser.add_argument("--directions", default="truth,opposite")
parser.add_argument("--json-out", default=str(REPORT_JSON))
args = parser.parse_args()
radii = tuple(int(item) for item in str(args.radii).split(",") if item.strip())
directions = tuple(item.strip() for item in str(args.directions).split(",") if item.strip())
cases = load_cases()
if args.limit:
cases = cases[: args.limit]
started = time.perf_counter()
rows: list[dict[str, Any]] = []
for case in cases:
for radius in radii:
for direction in directions:
label = f"{case.get('case_id')} ±{radius} {direction}"
try:
result = evaluate_case(case, radius, direction)
rows.append(result)
print(
f"{label} needed={result.get('events_needed')} "
f"met={result.get('met')} windows={result.get('windows')}",
flush=True,
)
except Exception as exc: # noqa: BLE001
rows.append({
"case_id": case.get("case_id"),
"radius": radius,
"direction": direction,
"error": f"{type(exc).__name__}: {exc}",
"trace": traceback.format_exc(limit=8),
"met": False,
"events_needed": None,
})
print(f"{label} ERROR {type(exc).__name__}: {exc}", flush=True)
summaries = [summarize(rows, radius, direction) for radius in radii for direction in directions]
payload = {
"generated_at": TODAY.isoformat(),
"holdout": str(HOLDOUT.relative_to(ROOT)).replace("\\", "/"),
"ask_count": ASK_COUNT,
"guided_collect_limit": GUIDED_COLLECT_LIMIT,
"injection": (
"truth = the true candidate's own boundary date for that window's "
"track and index; opposite = the boundary of the remaining "
"candidate furthest from the true minute"
),
"delivery_max_width_minutes": DELIVERY_MAX_WIDTH_MINUTES,
"tie_percent": RANGE_DELIVERY_TIE_PERCENT,
"elapsed_s": round(time.perf_counter() - started, 1),
"summaries": summaries,
"rows": [
{key: value for key, value in row.items() if key != "trace"}
for row in rows
],
"errors": [row for row in rows if row.get("error")],
}
out = Path(args.json_out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summaries, ensure_ascii=False, indent=2), flush=True)
print(f"wrote {out} in {payload['elapsed_s']}s", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())