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] 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