出卡时机只看题源有没有空:撤回「门槛达标就短路采集线」的写法,同时 按 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
489 lines
18 KiB
Python
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())
|