From 31dc3a9b97a3874e6a160c1e57c5e7c7cd05ed17 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Tue, 21 Jul 2026 17:49:43 +0800 Subject: [PATCH 1/5] feat: add rectification input evidence contract --- .../2026-07-21-minute-rectification-p0.md | 9 ++ ...26-07-21-minute-rectification-p0-design.md | 32 +++++++ scripts/candidate_time_sensitivity_scan.py | 12 +++ .../jyotishganit_vs_local_field_comparison.py | 12 ++- scripts/rectification_input_contract.py | 96 +++++++++++++++++++ scripts/rectification_three_engine_packet.py | 8 +- scripts/three_engine_high_rigor_parity.py | 7 +- tests/test_candidate_time_sensitivity_scan.py | 3 + ..._jyotishganit_vs_local_field_comparison.py | 27 +++++- tests/test_rectification_input_contract.py | 55 +++++++++++ .../test_rectification_three_engine_packet.py | 16 ++++ 11 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-21-minute-rectification-p0.md create mode 100644 docs/superpowers/specs/2026-07-21-minute-rectification-p0-design.md create mode 100644 scripts/rectification_input_contract.py create mode 100644 tests/test_rectification_input_contract.py diff --git a/docs/superpowers/plans/2026-07-21-minute-rectification-p0.md b/docs/superpowers/plans/2026-07-21-minute-rectification-p0.md new file mode 100644 index 00000000..2cb52507 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-minute-rectification-p0.md @@ -0,0 +1,9 @@ +# Minute Rectification P0 Plan + +1. Add failing tests for canonical input hashes, candidate fingerprints and + pending stability probes. +2. Add a small shared input-contract helper and wire it into the sensitivity + scanner and private three-engine receipt. +3. Correct public-case coordinate defaults and test them. +4. Add a semantic evidence-hash helper without replacing raw artifact hashes. +5. Run focused tests and inspect the resulting JSON contracts. diff --git a/docs/superpowers/specs/2026-07-21-minute-rectification-p0-design.md b/docs/superpowers/specs/2026-07-21-minute-rectification-p0-design.md new file mode 100644 index 00000000..ad845ed1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-minute-rectification-p0-design.md @@ -0,0 +1,32 @@ +# Minute Rectification P0: Input Evidence Contract + +## Goal + +Make a candidate-minute calculation reproducible across the local scanner and +the three-engine receipt without changing scoring weights or confirmation rules. + +## Scope + +- Canonicalize birth input with Lahiri and mean-node defaults. +- Hash the canonical input for every scanned candidate minute. +- Publish the required `-5`, `-2`, `-1`, `+1`, `+2`, and `+5` minute + stability probes as pending evidence, not as a confidence result. +- Correct the public Steve Jobs comparison defaults to the canonical San + Francisco coordinates. +- Preserve raw evidence hashes and add a separate semantic hash for known + order-insensitive aspect lists. + +## Non-goals + +- No new questionnaire format, scoring weights, or candidate confirmation path. +- No claim that a candidate is accurate to the minute. +- No raw birth input or external raw response exposed to the browser. + +## Acceptance + +- Equivalent input mappings have the same canonical hash. +- Every candidate row carries a distinct input fingerprint when the minute + differs. +- Stability probes are explicit and always state that minute confirmation is + blocked pending public blind holdout evidence. +- The public comparison defaults match the canonical Steve Jobs source. diff --git a/scripts/candidate_time_sensitivity_scan.py b/scripts/candidate_time_sensitivity_scan.py index 295ed4b0..d7001b8b 100644 --- a/scripts/candidate_time_sensitivity_scan.py +++ b/scripts/candidate_time_sensitivity_scan.py @@ -11,6 +11,8 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Any +from scripts.rectification_input_contract import candidate_input_fingerprint, stability_probe_contract + ROOT = Path(__file__).resolve().parents[1] ENGINE = ROOT / "scripts" / "jyotish_engine.py" @@ -63,6 +65,7 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = "d1_ascendant": asc.get("sign"), "d1_degree_in_sign": asc.get("degree_in_sign"), "divisional_ascendants": divisional, + "input_fingerprint": candidate_input_fingerprint(point), }) signatures = [tuple([row["d1_ascendant"], *row["divisional_ascendants"].values()]) for row in rows] unavailable_vargas = [varga.upper() for varga in _VARGAS if all(row["divisional_ascendants"][varga.upper()] is None for row in rows)] @@ -88,6 +91,15 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = "uncertainty_minutes": uncertainty_minutes, "step_minutes": step_minutes, "rows": rows, + "input_contract": { + "version": "rectification-input-v1", + "center_input_fingerprint": candidate_input_fingerprint(payload), + "settings": { + "ayanamsa": str(payload.get("ayanamsa") or "lahiri").lower(), + "node_mode": str(payload.get("node_mode") or "true").lower(), + }, + }, + "stability_contract": stability_probe_contract(payload), "transitions": transitions, "supported_vargas": [varga.upper() for varga in supported_vargas], "unavailable_vargas": unavailable_vargas, diff --git a/scripts/jyotishganit_vs_local_field_comparison.py b/scripts/jyotishganit_vs_local_field_comparison.py index 677bf1e6..137058b4 100644 --- a/scripts/jyotishganit_vs_local_field_comparison.py +++ b/scripts/jyotishganit_vs_local_field_comparison.py @@ -143,20 +143,24 @@ def build(args: argparse.Namespace) -> dict[str, Any]: } -def main() -> int: +def build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser() ap.add_argument("--year", type=int, default=1955) ap.add_argument("--month", type=int, default=2) ap.add_argument("--day", type=int, default=24) ap.add_argument("--hour", type=int, default=19) ap.add_argument("--minute", type=int, default=15) - ap.add_argument("--latitude", type=float, default=37.3382) - ap.add_argument("--longitude", type=float, default=-122.0383) + ap.add_argument("--latitude", type=float, default=37.7749) + ap.add_argument("--longitude", type=float, default=-122.4194) ap.add_argument("--timezone", type=float, default=-8.0) ap.add_argument("--location", default="San Francisco, CA") ap.add_argument("--name", default="Steve Jobs public") ap.add_argument("--output") - args = ap.parse_args() + return ap + + +def main() -> int: + args = build_parser().parse_args() payload = build(args) text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) if args.output: diff --git a/scripts/rectification_input_contract.py b/scripts/rectification_input_contract.py new file mode 100644 index 00000000..5acd4da5 --- /dev/null +++ b/scripts/rectification_input_contract.py @@ -0,0 +1,96 @@ +"""Stable, privacy-safe input identities for birth-time rectification evidence.""" +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timedelta +from typing import Any + + +_REQUIRED = ("year", "month", "day", "hour", "minute", "lat", "lon", "tz") +_STABILITY_OFFSETS = (-5, -2, -1, 1, 2, 5) + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + +def canonical_birth_input(case: dict[str, Any]) -> dict[str, Any]: + """Return the calculation-relevant input with explicit calculation settings.""" + missing = [field for field in _REQUIRED if case.get(field) is None] + if missing: + raise ValueError(f"missing rectification input fields: {', '.join(missing)}") + year, month, day = int(case["year"]), int(case["month"]), int(case["day"]) + hour, minute, second = int(case["hour"]), int(case["minute"]), int(case.get("second", 0)) + datetime(year, month, day, hour, minute, second) + return { + "version": "rectification-input-v1", + "birth": { + "year": year, + "month": month, + "day": day, + "hour": hour, + "minute": minute, + "second": second, + "latitude": float(case["lat"]), + "longitude": float(case["lon"]), + "timezone": float(case["tz"]), + }, + "ayanamsa": str(case.get("ayanamsa") or "lahiri").lower(), + # Preserve the deployed request-level default; callers must make any + # node-mode change explicit so it receives a different fingerprint. + "node_mode": str(case.get("node_mode") or "true").lower(), + } + + +def candidate_input_fingerprint(case: dict[str, Any]) -> str: + return hashlib.sha256(_canonical_json(canonical_birth_input(case)).encode("utf-8")).hexdigest() + + +def stability_probe_contract(case: dict[str, Any]) -> dict[str, Any]: + """List required local perturbations without claiming they have passed.""" + baseline = canonical_birth_input(case) + birth = baseline["birth"] + center = datetime(birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"], birth["second"]) + probes = [] + for offset in _STABILITY_OFFSETS: + moment = center + timedelta(minutes=offset) + probe = { + "year": moment.year, + "month": moment.month, + "day": moment.day, + "hour": moment.hour, + "minute": moment.minute, + "second": moment.second, + "lat": birth["latitude"], + "lon": birth["longitude"], + "tz": birth["timezone"], + "ayanamsa": baseline["ayanamsa"], + "node_mode": baseline["node_mode"], + } + probes.append({"offset_minutes": offset, "input_fingerprint": candidate_input_fingerprint(probe)}) + return { + "scope": "candidate_minute_stability_contract", + "status": "pending_score_comparison", + "baseline_input_fingerprint": candidate_input_fingerprint(case), + "probes": probes, + "minute_confirmation_allowed": False, + "blocker": "public_blind_minute_holdout_not_closed", + "boundary": "Probe identities are reproducible inputs, not evidence that a minute has passed stability or outcome validation.", + } + + +def _semantic_normalize(value: Any, *, parent_key: str | None = None) -> Any: + if isinstance(value, dict): + return {key: _semantic_normalize(item, parent_key=key) for key, item in sorted(value.items())} + if isinstance(value, list): + normalized = [_semantic_normalize(item, parent_key=parent_key) for item in value] + if parent_key in {"gives", "receives"}: + return sorted(normalized, key=_canonical_json) + return normalized + return value + + +def semantic_evidence_hash(value: Any) -> str: + """Hash known order-insensitive evidence fields while retaining raw hashes elsewhere.""" + return hashlib.sha256(_canonical_json(_semantic_normalize(value)).encode("utf-8")).hexdigest() diff --git a/scripts/rectification_three_engine_packet.py b/scripts/rectification_three_engine_packet.py index 6f0655bb..1f9f74bb 100644 --- a/scripts/rectification_three_engine_packet.py +++ b/scripts/rectification_three_engine_packet.py @@ -1,15 +1,14 @@ """Build a privacy-safe, request-level three-engine rectification parity packet.""" from __future__ import annotations -import hashlib import importlib -import json import sys from datetime import datetime from pathlib import Path from typing import Any from domain_calculation_service import compute_chart +from scripts.rectification_input_contract import candidate_input_fingerprint, stability_probe_contract ROOT = Path(__file__).resolve().parents[1] JYOTISHGANIT_ROOT = ROOT / "references" / "open_source_sources" / "jyotishganit" @@ -19,8 +18,7 @@ SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpi def case_hash(case: dict[str, Any]) -> str: """Stable identity for evidence correlation; never exposes birth data.""" - payload = json.dumps(case, sort_keys=True, ensure_ascii=True, separators=(",", ":")) - return hashlib.sha256(payload.encode()).hexdigest() + return candidate_input_fingerprint(case) def _local_d1(case: dict[str, Any]) -> dict[str, str]: @@ -121,6 +119,8 @@ def build_packet( return { "scope": "request_level_three_engine_d1_parity", "case_hash": case_hash(case), + "input_contract_hash": candidate_input_fingerprint(case), + "stability_contract": stability_probe_contract(case), "engine_status": engine_status, "match_count": sum(row["status"] == "match" for row in rows), "mismatch_count": sum(row["status"] == "mismatch" for row in rows), diff --git a/scripts/three_engine_high_rigor_parity.py b/scripts/three_engine_high_rigor_parity.py index e0d2c58f..5357e0da 100644 --- a/scripts/three_engine_high_rigor_parity.py +++ b/scripts/three_engine_high_rigor_parity.py @@ -17,6 +17,7 @@ if str(ROOT) not in sys.path: from benchmarks.jyotish.scripts.run_pyjhora_compare import build_pyjhora_sample from benchmarks.jyotish.scripts.run_skill_baseline import run_sample from scripts.three_engine_parity_runner import _capture_jyotishganit_raw +from scripts.rectification_input_contract import semantic_evidence_hash ORACLE = ROOT / "references" / "oracle" ARTIFACTS = ORACLE / "artifacts" @@ -146,9 +147,9 @@ def build() -> dict[str, Any]: manifest = { "case_id": "steve_jobs_public_1955_lahiri", "birth_data_policy": "public_case_only", "blocked_reason": "none", "engines": { - "VedAstro": {"status": "official_verified", "official_raw_response_path": "artifacts/" + ved_path.name, "artifact_hash": _sha(ved_path), "settings": ved["settings"]}, - "PyJHora_JHora": {"status": "imported", "raw_output_path": "artifacts/" + py_path.name, "artifact_hash": _sha(py_path), "settings": pyjhora["settings"]}, - "jyotishganit": {"status": "imported", "raw_output_path": "artifacts/" + jy_path.name, "artifact_hash": _sha(jy_path), "settings": {"ayanamsa": jyotish["ayanamsa"]}}, + "VedAstro": {"status": "official_verified", "official_raw_response_path": "artifacts/" + ved_path.name, "artifact_hash": _sha(ved_path), "semantic_hash": semantic_evidence_hash(ved), "settings": ved["settings"]}, + "PyJHora_JHora": {"status": "imported", "raw_output_path": "artifacts/" + py_path.name, "artifact_hash": _sha(py_path), "semantic_hash": semantic_evidence_hash(pyjhora), "settings": pyjhora["settings"]}, + "jyotishganit": {"status": "imported", "raw_output_path": "artifacts/" + jy_path.name, "artifact_hash": _sha(jy_path), "semantic_hash": semantic_evidence_hash(jyotish), "settings": {"ayanamsa": jyotish["ayanamsa"]}}, }, "comparison_rows": rows, "method_arbitration": { diff --git a/tests/test_candidate_time_sensitivity_scan.py b/tests/test_candidate_time_sensitivity_scan.py index da8a0476..6556bcab 100644 --- a/tests/test_candidate_time_sensitivity_scan.py +++ b/tests/test_candidate_time_sensitivity_scan.py @@ -25,3 +25,6 @@ def test_scanner_reports_real_divisional_transitions(monkeypatch): assert report["transitions"] assert report["pending_layers"] == ["UL", "A7", "A10", "KP_cusp"] assert report["rows"][0]["divisional_ascendants"]["D9"] in {"Aries", "Taurus"} + assert report["input_contract"]["center_input_fingerprint"] + assert report["rows"][0]["input_fingerprint"] != report["rows"][1]["input_fingerprint"] + assert report["stability_contract"]["minute_confirmation_allowed"] is False diff --git a/tests/test_jyotishganit_vs_local_field_comparison.py b/tests/test_jyotishganit_vs_local_field_comparison.py index 81d2c437..287db67b 100644 --- a/tests/test_jyotishganit_vs_local_field_comparison.py +++ b/tests/test_jyotishganit_vs_local_field_comparison.py @@ -1,21 +1,38 @@ import json -import subprocess from pathlib import Path +from scripts import jyotishganit_vs_local_field_comparison as comparison + ROOT = Path(__file__).resolve().parents[1] INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" -def test_jyotishganit_vs_local_comparison_outputs_sign_rows_and_hash(): - out = subprocess.check_output(["python3", "scripts/jyotishganit_vs_local_field_comparison.py"], cwd=ROOT, text=True) - data = json.loads(out) +def test_jyotishganit_vs_local_comparison_outputs_sign_rows_and_hash(monkeypatch): + def fake_local(_): + return {code: {"Sun": {"sign": "Aries"}, "Moon": {"sign": "Taurus"}} for code in comparison.TARGETS["D2"] + comparison.TARGETS["D4"] + comparison.TARGETS["D9"] + comparison.TARGETS["D10"]} + + def fake_jyotishganit(_): + return { + "divisionalCharts": { + code.lower(): {"houses": [{"occupants": [{"celestialBody": "Sun", "sign": "Aries"}, {"celestialBody": "Moon", "sign": "Taurus"}]}]} + for code in comparison.TARGETS + }, + "panchanga": {}, + "ashtakavarga": {"sav": {}}, + } + + monkeypatch.setattr(comparison, "local_varga", fake_local) + monkeypatch.setattr(comparison, "jyotishganit_raw", fake_jyotishganit) + data = comparison.build(comparison.build_parser().parse_args([])) assert data["scope"] == "jyotishganit_vs_local_field_comparison" assert data["claim_status"] == "observation_only" assert data["production_tuning_allowed"] is False assert data["truth_matrix_allowed"] is False - assert data["summary"]["row_count"] >= 32 + assert data["summary"]["row_count"] == 8 assert data["comparison_hash"] + assert data["request"]["latitude"] == 37.7749 + assert data["request"]["longitude"] == -122.4194 assert {row["section"] for row in data["rows"]} == {"D2", "D4", "D9", "D10"} assert data["coverage"]["panchanga_jyotishganit"] is True assert data["coverage"]["BAV_SAV_jyotishganit"] is True diff --git a/tests/test_rectification_input_contract.py b/tests/test_rectification_input_contract.py new file mode 100644 index 00000000..2483552f --- /dev/null +++ b/tests/test_rectification_input_contract.py @@ -0,0 +1,55 @@ +from scripts.rectification_input_contract import ( + candidate_input_fingerprint, + canonical_birth_input, + semantic_evidence_hash, + stability_probe_contract, +) + + +CASE = { + "year": 1955, + "month": 2, + "day": 24, + "hour": 19, + "minute": 15, + "lat": 37.7749, + "lon": -122.4194, + "tz": -8, +} + + +def test_canonical_birth_input_is_order_independent_and_explicit_about_settings(): + reordered = {key: CASE[key] for key in reversed(CASE)} + + first = canonical_birth_input(CASE) + second = canonical_birth_input(reordered) + + assert first == second + assert first["ayanamsa"] == "lahiri" + assert first["node_mode"] == "true" + assert candidate_input_fingerprint(CASE) == candidate_input_fingerprint(reordered) + + +def test_candidate_fingerprint_changes_when_only_the_minute_changes(): + next_minute = {**CASE, "minute": 16} + + assert candidate_input_fingerprint(CASE) != candidate_input_fingerprint(next_minute) + + +def test_stability_probes_are_explicit_but_do_not_claim_minute_confirmation(): + contract = stability_probe_contract(CASE) + + assert contract["status"] == "pending_score_comparison" + assert [probe["offset_minutes"] for probe in contract["probes"]] == [-5, -2, -1, 1, 2, 5] + assert contract["minute_confirmation_allowed"] is False + assert contract["blocker"] == "public_blind_minute_holdout_not_closed" + assert all("input_fingerprint" in probe for probe in contract["probes"]) + + +def test_semantic_evidence_hash_ignores_known_order_insensitive_aspect_lists_only(): + left = {"aspects": {"gives": ["Mars", "Saturn"], "receives": ["Moon", "Sun"]}, "value": [2, 1]} + right = {"value": [2, 1], "aspects": {"receives": ["Sun", "Moon"], "gives": ["Saturn", "Mars"]}} + changed = {"value": [1, 2], "aspects": {"receives": ["Sun", "Moon"], "gives": ["Saturn", "Mars"]}} + + assert semantic_evidence_hash(left) == semantic_evidence_hash(right) + assert semantic_evidence_hash(left) != semantic_evidence_hash(changed) diff --git a/tests/test_rectification_three_engine_packet.py b/tests/test_rectification_three_engine_packet.py index 116dc7d6..daf227cb 100644 --- a/tests/test_rectification_three_engine_packet.py +++ b/tests/test_rectification_three_engine_packet.py @@ -8,6 +8,22 @@ SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" if str(SCRIPTS) not in sys.path: sys.path.insert(0, str(SCRIPTS)) +from rectification_three_engine_packet import build_packet, case_hash + +CASE = {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 0.0, "lon": 0.0, "tz": 0.0} + +def test_packet_is_private_and_never_confirms(monkeypatch) -> None: + import rectification_three_engine_packet as module + monkeypatch.setattr(module, "_local_d1", lambda _: {"Sun": "Aries"}) + monkeypatch.setattr(module, "_pyjhora_d1", lambda _: {"Sun": "Aries"}) + monkeypatch.setattr(module, "_jyotishganit_d1", lambda _: {"Sun": "Aries"}) + packet = build_packet(CASE) + assert packet["case_hash"] == case_hash(CASE) + assert packet["input_contract_hash"] + assert packet["stability_contract"]["minute_confirmation_allowed"] is False + assert "year" not in str(packet) + assert packet["can_confirm"] is False + assert packet["vedastro"]["status"] == "requires_gateway_raw_archive" def test_packet_queues_a_privacy_safe_vedastro_receipt(monkeypatch) -> None: import rectification_three_engine_packet as packet From 0fb5fefe4b81de31e47650590a0fb52fa961216f Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Tue, 21 Jul 2026 19:27:39 +0800 Subject: [PATCH 2/5] feat: enforce minute holdout evidence contract --- .../minute_rectification_holdout_validator.py | 47 ++++++++++++-- ..._minute_rectification_holdout_validator.py | 62 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/scripts/minute_rectification_holdout_validator.py b/scripts/minute_rectification_holdout_validator.py index 3822e38a..120ddd37 100644 --- a/scripts/minute_rectification_holdout_validator.py +++ b/scripts/minute_rectification_holdout_validator.py @@ -10,6 +10,39 @@ ROOT = Path(__file__).resolve().parents[1] DEFAULT_MANIFEST = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v1.json" +def _valid_events(events: list[Any], *, minimum: int, birth_url: str) -> bool: + if len(events) < minimum: + return False + for event in events: + if not isinstance(event, dict): + return False + source = event.get("source") if isinstance(event.get("source"), dict) else {} + date = str(event.get("event_date") or "") + # Month/year-only biographies cannot distinguish neighbouring minutes. + if len(date) != 10 or not source.get("url") or str(source["url"]) == birth_url: + return False + return True + + +def _valid_negative_controls(controls: list[Any], *, minimum: int) -> bool: + if len(controls) < minimum: + return False + offsets: list[int] = [] + commitments: set[str] = set() + for control in controls: + if not isinstance(control, dict): + return False + offset = control.get("offset_minutes") + commitment = str(control.get("commitment_hash") or "") + if not isinstance(offset, int) or offset == 0 or len(commitment) != 64: + return False + if any(key in control for key in ("candidate_minute", "published_minute", "birth_time")): + return False + offsets.append(offset) + commitments.add(commitment) + return any(offset < 0 for offset in offsets) and any(offset > 0 for offset in offsets) and len(set(offsets)) == len(offsets) and len(commitments) == len(controls) + + def validate(manifest_path: Path = DEFAULT_MANIFEST) -> dict[str, Any]: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) gate = manifest.get("minimum_gate") if isinstance(manifest.get("minimum_gate"), dict) else {} @@ -23,13 +56,15 @@ def validate(manifest_path: Path = DEFAULT_MANIFEST) -> dict[str, Any]: birth = case.get("birth_source") if isinstance(case.get("birth_source"), dict) else {} events = case.get("events") if isinstance(case.get("events"), list) else [] negatives = case.get("negative_minutes") if isinstance(case.get("negative_minutes"), list) else [] - required = birth.get("time_accuracy_rating") == "AA" and bool(birth.get("url")) - required = required and len(events) >= int(gate.get("events_per_case", 3)) - required = required and len(negatives) >= int(gate.get("negative_minutes_per_case", 4)) - if required: - valid_cases += 1 + case_id = str(case.get("case_id") or "unnamed_case") + if birth.get("time_accuracy_rating") != "AA" or not birth.get("url"): + invalid.append(f"{case_id}:birth_source_invalid") + elif not _valid_events(events, minimum=int(gate.get("events_per_case", 3)), birth_url=str(birth["url"])): + invalid.append(f"{case_id}:events_invalid") + elif not _valid_negative_controls(negatives, minimum=int(gate.get("negative_minutes_per_case", 4))): + invalid.append(f"{case_id}:negative_controls_invalid") else: - invalid.append(str(case.get("case_id") or "unnamed_case")) + valid_cases += 1 needed = int(gate.get("public_aa_cases", 20)) status = "ready_for_blind_replay" if valid_cases >= needed else "blocked_awaiting_public_aa_cases" return { diff --git a/tests/test_minute_rectification_holdout_validator.py b/tests/test_minute_rectification_holdout_validator.py index 455085e9..ccedb647 100644 --- a/tests/test_minute_rectification_holdout_validator.py +++ b/tests/test_minute_rectification_holdout_validator.py @@ -6,3 +6,65 @@ def test_empty_public_minute_protocol_blocks_verified_claims() -> None: assert report["status"] == "blocked_awaiting_public_aa_cases" assert report["verified_minute_claim_allowed"] is False assert report["valid_public_aa_cases"] == 0 +import hashlib +import json + +from scripts.minute_rectification_holdout_validator import validate + + +def _case(*, offsets=(-5, -2, 2, 5), event_count=3): + controls = [ + { + "control_id": f"control-{index}", + "offset_minutes": offset, + "commitment_hash": hashlib.sha256(f"fixed-control-{index}".encode()).hexdigest(), + } + for index, offset in enumerate(offsets) + ] + return { + "case_id": "public-aa-case", + "birth_source": { + "url": "https://example.test/birth-record", + "time_accuracy_rating": "AA", + }, + "events": [ + { + "event_date": f"200{index}-01-0{index + 1}", + "source": {"url": f"https://independent.example.test/event-{index}"}, + } + for index in range(event_count) + ], + "negative_minutes": controls, + } + + +def _manifest(case): + return { + "benchmark_id": "test-minute-holdout", + "minimum_gate": {"public_aa_cases": 1, "events_per_case": 3, "negative_minutes_per_case": 4}, + "boundary": "test boundary", + "cases": [case], + } + + +def test_validator_requires_independent_dated_events_and_committed_controls(tmp_path): + path = tmp_path / "holdout.json" + path.write_text(json.dumps(_manifest(_case())), encoding="utf-8") + + report = validate(path) + + assert report["status"] == "ready_for_blind_replay" + assert report["valid_public_aa_cases"] == 1 + assert report["verified_minute_claim_allowed"] is False + + +def test_validator_rejects_one_sided_or_uncommitted_false_minutes(tmp_path): + path = tmp_path / "holdout.json" + case = _case(offsets=(-5, -2, -1, -1)) + case["negative_minutes"][0].pop("commitment_hash") + path.write_text(json.dumps(_manifest(case)), encoding="utf-8") + + report = validate(path) + + assert report["status"] == "blocked_awaiting_public_aa_cases" + assert report["invalid_cases"] == ["public-aa-case:negative_controls_invalid"] From 76a2ca7fd92cbc3e3852d8c746b8a6d0085b998e Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Tue, 21 Jul 2026 19:32:47 +0800 Subject: [PATCH 3/5] feat: audit public minute holdout sources --- ...ctification_public_aa_source_audit_v1.json | 180 ++++++++++++++++++ scripts/minute_rectification_source_audit.py | 85 +++++++++ .../test_minute_rectification_source_audit.py | 44 +++++ 3 files changed, 309 insertions(+) create mode 100644 references/real_case_calibration/minute_rectification_public_aa_source_audit_v1.json create mode 100644 scripts/minute_rectification_source_audit.py create mode 100644 tests/test_minute_rectification_source_audit.py diff --git a/references/real_case_calibration/minute_rectification_public_aa_source_audit_v1.json b/references/real_case_calibration/minute_rectification_public_aa_source_audit_v1.json new file mode 100644 index 00000000..9c928a29 --- /dev/null +++ b/references/real_case_calibration/minute_rectification_public_aa_source_audit_v1.json @@ -0,0 +1,180 @@ +{ + "additional_public_aa_cases_required": 3, + "boundary": "Source discovery is not holdout validation. Cases require independent dated events and committed two-sided false-minute controls before blind replay.", + "cases": [ + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_03_14.htm", + "case_ids": [ + "albert_ii_accession_2005" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Albert II, Prince of Monaco" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_06_04.htm", + "case_ids": [ + "jolie_marriage_2014" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Angelina Jolie" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_08_04.htm", + "case_ids": [ + "obama_election_2008" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Barack Obama" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_06_14.htm", + "case_ids": [ + "boy_george_grammy_1984" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Boy George" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_02_27.htm", + "case_ids": [ + "chelsea_clinton_marriage_2010" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Chelsea Clinton" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/astro-databank/Trump,_Donald", + "case_ids": [ + "trump_inauguration_2017" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Donald Trump" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_07_06.htm", + "case_ids": [ + "kahlo_bus_injury_1925", + "kahlo_marriage_1929" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Frida Kahlo" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_08_29.htm", + "case_ids": [ + "bergman_oscar_1945" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Ingrid Bergman" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_02_11.htm", + "case_ids": [ + "aniston_emmy_2002" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Jennifer Aniston" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_06_09.htm", + "case_ids": [ + "depp_marriage_2015" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Johnny Depp" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/astro-databank/DiCaprio,_Leonardo", + "case_ids": [ + "dicaprio_oscar_2016" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Leonardo DiCaprio" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/astro-databank/Markle,_Meghan", + "case_ids": [ + "markle_marriage_2018" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Meghan Markle" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_06_22.htm", + "case_ids": [ + "streep_oscar_1983" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Meryl Streep" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/astro-databank/Winfrey,_Oprah", + "case_ids": [ + "winfrey_oprah_show_launch_1986" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Oprah Winfrey" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_10_20.htm", + "case_ids": [ + "snoop_marriage_1997" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Snoop Dogg" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_02_24.htm", + "case_ids": [ + "jobs_iphone_2007" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "Steve Jobs" + }, + { + "additional_dated_events_required": 2, + "birth_source_url": "https://www.astro.com/adbvip/adbvip_06_21.htm", + "case_ids": [ + "william_marriage_2011" + ], + "existing_dated_event_count": 1, + "negative_controls_required": 4, + "subject": "William, Prince of Wales" + } + ], + "minimum_public_aa_cases": 20, + "public_aa_case_count": 17, + "scope": "minute_rectification_public_aa_source_audit" +} diff --git a/scripts/minute_rectification_source_audit.py b/scripts/minute_rectification_source_audit.py new file mode 100644 index 00000000..640b0f3b --- /dev/null +++ b/scripts/minute_rectification_source_audit.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Audit reusable public AA cases before adding them to the minute holdout.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SOURCES = ( + ROOT / "references" / "real_case_calibration" / "replay_manifest.json", + ROOT / "references" / "real_case_calibration" / "replay_manifest_holdout_v2.json", + ROOT / "references" / "real_case_calibration" / "replay_manifest_probe3_v2.json", + ROOT / "references" / "real_case_calibration" / "public_context_manifest.json", +) + + +def _events(case: dict[str, Any]) -> list[dict[str, Any]]: + raw = case.get("events") if isinstance(case.get("events"), list) else case.get("event_outcomes") + return [event for event in raw or [] if isinstance(event, dict)] + + +def build_source_audit(paths: list[Path] | tuple[Path, ...] = DEFAULT_SOURCES) -> dict[str, Any]: + entries: dict[str, dict[str, Any]] = {} + for path in paths: + data = json.loads(path.read_text(encoding="utf-8")) + for case in data.get("cases", []): + if not isinstance(case, dict): + continue + subject = case.get("subject") if isinstance(case.get("subject"), dict) else case + birth = subject.get("birth_source") if isinstance(subject.get("birth_source"), dict) else {} + source_url = str(birth.get("url") or "") + if birth.get("time_accuracy_rating") != "AA" or not source_url: + continue + name = str(subject.get("name") or case.get("case_id") or "unnamed") + entry = entries.setdefault(source_url, { + "subject": name, + "birth_source_url": source_url, + "case_ids": [], + "dated_event_dates": set(), + }) + entry["case_ids"].append(str(case.get("case_id") or name)) + for event in _events(case): + date = str(event.get("event_date") or event.get("date") or "") + if len(date) == 10: + entry["dated_event_dates"].add(date) + cases = [] + for entry in entries.values(): + event_count = len(entry["dated_event_dates"]) + cases.append({ + "subject": entry["subject"], + "birth_source_url": entry["birth_source_url"], + "case_ids": sorted(entry["case_ids"]), + "existing_dated_event_count": event_count, + "additional_dated_events_required": max(0, 3 - event_count), + "negative_controls_required": 4, + }) + cases.sort(key=lambda case: (case["additional_dated_events_required"], case["subject"])) + return { + "scope": "minute_rectification_public_aa_source_audit", + "public_aa_case_count": len(cases), + "minimum_public_aa_cases": 20, + "additional_public_aa_cases_required": max(0, 20 - len(cases)), + "cases": cases, + "boundary": "Source discovery is not holdout validation. Cases require independent dated events and committed two-sided false-minute controls before blind replay.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("sources", nargs="*", type=Path, default=list(DEFAULT_SOURCES)) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = build_source_audit(args.sources) + text = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + if args.output: + args.output.write_text(text + "\n", encoding="utf-8") + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_minute_rectification_source_audit.py b/tests/test_minute_rectification_source_audit.py new file mode 100644 index 00000000..004c8da9 --- /dev/null +++ b/tests/test_minute_rectification_source_audit.py @@ -0,0 +1,44 @@ +import json +from pathlib import Path + +from scripts.minute_rectification_source_audit import build_source_audit + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_source_audit_deduplicates_public_aa_cases_and_reports_missing_evidence(tmp_path): + manifest = tmp_path / "cases.json" + manifest.write_text(json.dumps({ + "cases": [ + { + "case_id": "case-a", + "subject": {"name": "A", "birth_source": {"url": "https://birth.example/a", "time_accuracy_rating": "AA"}}, + "event_outcomes": [{"event_date": "2000-01-01"}], + }, + { + "case_id": "case-a-duplicate", + "subject": {"name": "A", "birth_source": {"url": "https://birth.example/a", "time_accuracy_rating": "AA"}}, + "event_outcomes": [{"event_date": "2001-01-01"}], + }, + { + "case_id": "case-b", + "subject": {"name": "B", "birth_source": {"url": "https://birth.example/b", "time_accuracy_rating": "A"}}, + }, + ], + }), encoding="utf-8") + + audit = build_source_audit([manifest]) + + assert audit["public_aa_case_count"] == 1 + assert audit["cases"][0]["subject"] == "A" + assert audit["cases"][0]["additional_dated_events_required"] == 1 + assert audit["cases"][0]["negative_controls_required"] == 4 + + +def test_frozen_source_audit_matches_the_reusable_public_case_manifests(): + artifact = json.loads((ROOT / "references/real_case_calibration/minute_rectification_public_aa_source_audit_v1.json").read_text(encoding="utf-8")) + + assert artifact == build_source_audit() + assert artifact["public_aa_case_count"] == 17 + assert artifact["additional_public_aa_cases_required"] == 3 From cfa34bac32ae9d5962f5b6f0fb6c262ef34ef37a Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Tue, 21 Jul 2026 20:01:30 +0800 Subject: [PATCH 4/5] feat: add reviewed minute holdout intake --- .../minute_rectification_holdout_intake.py | 45 +++++++++++++++++ .../minute_rectification_holdout_validator.py | 28 +++++++---- ...est_minute_rectification_holdout_intake.py | 50 +++++++++++++++++++ ..._minute_rectification_holdout_validator.py | 14 ++++++ 4 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 scripts/minute_rectification_holdout_intake.py create mode 100644 tests/test_minute_rectification_holdout_intake.py diff --git a/scripts/minute_rectification_holdout_intake.py b/scripts/minute_rectification_holdout_intake.py new file mode 100644 index 00000000..951d6a67 --- /dev/null +++ b/scripts/minute_rectification_holdout_intake.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Append one independently reviewed, frozen minute-rectification holdout case.""" +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from scripts.minute_rectification_holdout_validator import case_error, validate + + +def append_case(path: Path, case: dict[str, Any]) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + gate = data.get("minimum_gate") if isinstance(data.get("minimum_gate"), dict) else {} + case_id = str(case.get("case_id") or "").strip() + if not case_id: + return {"appended": False, "errors": ["case_id_missing"], "validation": validate(path)} + if any(str(existing.get("case_id") or "") == case_id for existing in data.get("cases", []) if isinstance(existing, dict)): + return {"appended": False, "errors": ["case_id_duplicate"], "validation": validate(path)} + error = case_error(case, gate) + if error: + return {"appended": False, "errors": [error], "validation": validate(path)} + data.setdefault("cases", []).append({ + **case, + "ingested_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + }) + data["status"] = "collecting_independently_reviewed_cases" + data["verified_minute_claim_allowed"] = False + path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return {"appended": True, "errors": [], "validation": validate(path)} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("--case-json", required=True, help="One complete, independently reviewed case JSON object.") + args = parser.parse_args() + print(json.dumps(append_case(args.manifest, json.loads(args.case_json)), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/minute_rectification_holdout_validator.py b/scripts/minute_rectification_holdout_validator.py index 120ddd37..587bdc6f 100644 --- a/scripts/minute_rectification_holdout_validator.py +++ b/scripts/minute_rectification_holdout_validator.py @@ -43,6 +43,22 @@ def _valid_negative_controls(controls: list[Any], *, minimum: int) -> bool: return any(offset < 0 for offset in offsets) and any(offset > 0 for offset in offsets) and len(set(offsets)) == len(offsets) and len(commitments) == len(controls) +def case_error(case: dict[str, Any], gate: dict[str, Any]) -> str | None: + """Return the one blocking reason for a prospective minute-holdout case.""" + birth = case.get("birth_source") if isinstance(case.get("birth_source"), dict) else {} + events = case.get("events") if isinstance(case.get("events"), list) else [] + negatives = case.get("negative_minutes") if isinstance(case.get("negative_minutes"), list) else [] + if not str(case.get("adjudicator") or "").strip() or case.get("independent_human_reviewed") is not True or case.get("frozen_before_scoring") is not True: + return "independent_review_invalid" + if birth.get("time_accuracy_rating") != "AA" or not birth.get("url"): + return "birth_source_invalid" + if not _valid_events(events, minimum=int(gate.get("events_per_case", 3)), birth_url=str(birth["url"])): + return "events_invalid" + if not _valid_negative_controls(negatives, minimum=int(gate.get("negative_minutes_per_case", 4))): + return "negative_controls_invalid" + return None + + def validate(manifest_path: Path = DEFAULT_MANIFEST) -> dict[str, Any]: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) gate = manifest.get("minimum_gate") if isinstance(manifest.get("minimum_gate"), dict) else {} @@ -53,16 +69,10 @@ def validate(manifest_path: Path = DEFAULT_MANIFEST) -> dict[str, Any]: if not isinstance(case, dict): invalid.append("non_object_case") continue - birth = case.get("birth_source") if isinstance(case.get("birth_source"), dict) else {} - events = case.get("events") if isinstance(case.get("events"), list) else [] - negatives = case.get("negative_minutes") if isinstance(case.get("negative_minutes"), list) else [] case_id = str(case.get("case_id") or "unnamed_case") - if birth.get("time_accuracy_rating") != "AA" or not birth.get("url"): - invalid.append(f"{case_id}:birth_source_invalid") - elif not _valid_events(events, minimum=int(gate.get("events_per_case", 3)), birth_url=str(birth["url"])): - invalid.append(f"{case_id}:events_invalid") - elif not _valid_negative_controls(negatives, minimum=int(gate.get("negative_minutes_per_case", 4))): - invalid.append(f"{case_id}:negative_controls_invalid") + error = case_error(case, gate) + if error: + invalid.append(f"{case_id}:{error}") else: valid_cases += 1 needed = int(gate.get("public_aa_cases", 20)) diff --git a/tests/test_minute_rectification_holdout_intake.py b/tests/test_minute_rectification_holdout_intake.py new file mode 100644 index 00000000..9d4e63f9 --- /dev/null +++ b/tests/test_minute_rectification_holdout_intake.py @@ -0,0 +1,50 @@ +import json + +from scripts.minute_rectification_holdout_intake import append_case + + +def _case(): + return { + "case_id": "case-1", + "adjudicator": "independent-reviewer", + "independent_human_reviewed": True, + "frozen_before_scoring": True, + "birth_source": {"url": "https://birth.example/case-1", "time_accuracy_rating": "AA"}, + "events": [ + {"event_date": "2000-01-01", "source": {"url": "https://event.example/1"}}, + {"event_date": "2001-01-01", "source": {"url": "https://event.example/2"}}, + {"event_date": "2002-01-01", "source": {"url": "https://event.example/3"}}, + ], + "negative_minutes": [ + {"control_id": "a", "offset_minutes": -5, "commitment_hash": "a" * 64}, + {"control_id": "b", "offset_minutes": -2, "commitment_hash": "b" * 64}, + {"control_id": "c", "offset_minutes": 2, "commitment_hash": "c" * 64}, + {"control_id": "d", "offset_minutes": 5, "commitment_hash": "d" * 64}, + ], + } + + +def test_intake_only_appends_a_case_that_passes_the_minute_evidence_contract(tmp_path): + manifest = tmp_path / "holdout.json" + manifest.write_text(json.dumps({ + "benchmark_id": "test", "minimum_gate": {"public_aa_cases": 20, "events_per_case": 3, "negative_minutes_per_case": 4}, "cases": [], + }), encoding="utf-8") + + result = append_case(manifest, _case()) + + assert result["appended"] is True + saved = json.loads(manifest.read_text(encoding="utf-8")) + assert saved["cases"][0]["case_id"] == "case-1" + + +def test_intake_rejects_unreviewed_or_duplicate_cases(tmp_path): + manifest = tmp_path / "holdout.json" + manifest.write_text(json.dumps({ + "benchmark_id": "test", "minimum_gate": {"public_aa_cases": 20, "events_per_case": 3, "negative_minutes_per_case": 4}, "cases": [], + }), encoding="utf-8") + invalid = _case() + invalid["adjudicator"] = "" + + assert append_case(manifest, invalid)["appended"] is False + assert append_case(manifest, _case())["appended"] is True + assert append_case(manifest, _case())["appended"] is False diff --git a/tests/test_minute_rectification_holdout_validator.py b/tests/test_minute_rectification_holdout_validator.py index ccedb647..ebd46073 100644 --- a/tests/test_minute_rectification_holdout_validator.py +++ b/tests/test_minute_rectification_holdout_validator.py @@ -23,6 +23,9 @@ def _case(*, offsets=(-5, -2, 2, 5), event_count=3): ] return { "case_id": "public-aa-case", + "adjudicator": "independent-reviewer", + "independent_human_reviewed": True, + "frozen_before_scoring": True, "birth_source": { "url": "https://example.test/birth-record", "time_accuracy_rating": "AA", @@ -68,3 +71,14 @@ def test_validator_rejects_one_sided_or_uncommitted_false_minutes(tmp_path): assert report["status"] == "blocked_awaiting_public_aa_cases" assert report["invalid_cases"] == ["public-aa-case:negative_controls_invalid"] + + +def test_validator_rejects_cases_without_independent_frozen_review(tmp_path): + path = tmp_path / "holdout.json" + case = _case() + case["independent_human_reviewed"] = False + path.write_text(json.dumps(_manifest(case)), encoding="utf-8") + + report = validate(path) + + assert report["invalid_cases"] == ["public-aa-case:independent_review_invalid"] From 7ad0e406754e49db0e89c4921666429a2684c7cc Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Tue, 21 Jul 2026 22:12:06 +0800 Subject: [PATCH 5/5] fix: prevent repeated birth-time assessment loops --- frontend/src/app/page.tsx | 21 +++++++++++++++++++ .../birth-time-candidate-result.tsx | 4 ++-- ...se-birth-time-automatic-journey-effects.ts | 5 +++-- frontend/tests/birth-time-user-errors.test.ts | 2 ++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index aeba5cb7..11bf6aa7 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -86,6 +86,19 @@ type Profile = BirthTimeDraft & { districtCode: string; rectificationCaseId: string; }; + +function birthTimeAssessmentSignature(value: BirthTimeDraft) { + return [ + value.date, + value.time, + value.reportedTime, + value.birthTimeSource, + value.birthTimePeriod, + value.birthTimeClue, + value.uncertaintyBeforeMinutes, + value.uncertaintyAfterMinutes, + ].join("|"); +} type ChartLibraryRecord = { id: string; role: "self" | "other"; @@ -741,6 +754,7 @@ export default function Home() { const uiPreview = useRef(false); const uiPreviewMode = useRef(null); const birthTimeRevisionPending = useRef(false); + const lastBirthTimeAssessmentSignature = useRef(""); const birthTimeGuided = useBirthTimeGuidedJourney({ journey: birthTimeJourney, preview: process.env.NODE_ENV === "development" && uiPreview.current, @@ -1542,6 +1556,7 @@ export default function Home() { rectificationCaseId: result.caseId, }; setBirthTimeJourney(result); + lastBirthTimeAssessmentSignature.current = birthTimeAssessmentSignature(nextProfile); setBirthTimeError(""); setProfile(assessedProfile); setProfileDraft(assessedProfile); @@ -1603,6 +1618,12 @@ export default function Home() { try { await persistProfile(profileDraft); if (birthTimeRevisionPending.current) { + if (lastBirthTimeAssessmentSignature.current === birthTimeAssessmentSignature(profileDraft)) { + birthTimeRevisionPending.current = false; + setBirthTimeError("出生资料未变化,重新评估会重复同一结果。请补充不同领域且能注明年月的关键经历,再继续校时。"); + setOnboardingStep("rectification"); + return; + } setBirthTimeAssessmentPhase("assessing"); const assessedProfile = await assessSavedBirthTime(profileDraft); birthTimeRevisionPending.current = false; diff --git a/frontend/src/components/birth-time-candidate-result.tsx b/frontend/src/components/birth-time-candidate-result.tsx index 50708638..e6912e9a 100644 --- a/frontend/src/components/birth-time-candidate-result.tsx +++ b/frontend/src/components/birth-time-candidate-result.tsx @@ -124,8 +124,8 @@ function TerminalAction({ controller, error, path }: { return (
尚未达到采用条件 -

候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。

- +

候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。请补充不同领域、可注明年月的关键经历;仅重复原出生资料不会生成新结果。

+ {error ?

{error}

: null} 会建立新的记录,当前结果仍会保留。
diff --git a/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts b/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts index 6cb311b4..35b30b07 100644 --- a/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts +++ b/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts @@ -16,6 +16,7 @@ import { } from "@/lib/birth-time-guided-effect-coordinator"; import type { StableActionIdentityRegistry } from "@/lib/birth-time-guided-effect-coordinator"; import { runBirthTimeScoringPoll, scoringPollDelay } from "@/lib/birth-time-guided-polling"; +import { birthTimeUserError } from "@/lib/birth-time-user-error"; type AutomaticEffectsInput = { readonly journey: JourneyClientResponse | null; @@ -82,7 +83,7 @@ export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput if (publishCurrentJourney({ expected, current: latest.current, next, publish: onJourney })) latest.current = next; }).catch((caught: unknown) => { if (latest.current?.caseId === expected.caseId && latest.current.turnVersion === expected.turnVersion) { - setError(caught instanceof Error ? caught.message : "暂时无法生成下一题,请重试。"); + setError(birthTimeUserError(caught)); } }); }, [actionRegistry, generationIdentity, generationRequests, generationRun, latest, onJourney, preview, setError]); @@ -114,7 +115,7 @@ export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput latest.current = result.turn; if (result.kind === "exhausted") setError("评分仍在进行。你可以稍后继续,或重新检查状态。"); }).catch((caught: unknown) => { - if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "暂时无法读取评分进度,请稍后重试。"); + if (!controller.signal.aborted) setError(birthTimeUserError(caught)); }); }); return () => { cancelStart(); controller.abort(); }; diff --git a/frontend/tests/birth-time-user-errors.test.ts b/frontend/tests/birth-time-user-errors.test.ts index 445a3d44..7f2a0190 100644 --- a/frontend/tests/birth-time-user-errors.test.ts +++ b/frontend/tests/birth-time-user-errors.test.ts @@ -18,4 +18,6 @@ test("all guided journey mutations normalize implementation errors", () => { const source = readFileSync(new URL("../src/hooks/use-birth-time-guided-journey.ts", import.meta.url), "utf8"); assert.ok((source.match(/setError\(birthTimeUserError\(caught\)\)/g) ?? []).length >= 1); assert.equal(source.includes("setError(caught.message"), false); + const automatic = readFileSync(new URL("../src/hooks/use-birth-time-automatic-journey-effects.ts", import.meta.url), "utf8"); + assert.equal((automatic.match(/setError\(birthTimeUserError\(caught\)\)/g) ?? []).length, 2); });