diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5fa59632..6d1005d9 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -217,3 +217,19 @@ - 相关记录:BUG-009 - 复发自:无 - 修复版本:待提交(本地可测) + +## BUG-012 | 分钟校正 safeguards PR 与主线证据契约分叉 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:分钟候选输入身份、公共 holdout 校验、三引擎证据包、PR quality gate +- 用户现象:PR 与 `main` 在五个文件发生冲突,聚焦测试通过但完整 quick quality gate 在 API 安全测试收集阶段失败。 +- 触发条件:基于旧基线开发 v1 holdout safeguards,同时主线独立升级到 v2/v3 证据契约并移除旧 API store 导入。 +- 根因:PR 复用了旧 holdout 字段和 `true node` 默认,未执行与 CI 相同的 quick quality gate;主线已经采用 `mean node` 和更严格的 sealed holdout schema。 +- 修复:以主线为准新增兼容的输入指纹、邻近分钟探针和语义哈希;新增 v4 独立审核、日级事件、假分钟承诺门禁及非生产 intake;不恢复旧自动评估循环。 +- 验证:分钟校正聚焦回归、脚本直接执行检查、Ruff、Python compilation 和 quick quality gate。 +- 防复发:新 safeguards 必须以当前 schema 向前升级;候选身份必须继承产品计算默认;PR 验收必须包含 workflow 实际执行的 quick quality gate。 +- 相关记录:BUG-009、ERR-045、ERR-053、ERR-086 +- 复发自:无 +- 修复版本:待提交(本地可测) diff --git a/references/real_case_calibration/minute_rectification_holdout_v4_intake.json b/references/real_case_calibration/minute_rectification_holdout_v4_intake.json new file mode 100644 index 00000000..0de46d91 --- /dev/null +++ b/references/real_case_calibration/minute_rectification_holdout_v4_intake.json @@ -0,0 +1,16 @@ +{ + "schema_version": "minute-rectification-holdout-v4-intake", + "status": "collecting_independently_reviewed_cases", + "minimum_gate": { + "public_aa_cases": 20, + "events_per_case": 3, + "domains_per_case": 2, + "independent_event_sources_per_case": 2, + "negative_minutes_per_case": 4, + "day_precision_events_per_case": 3 + }, + "cases": [], + "production_tuning_allowed": false, + "verified_minute_claim_allowed": false, + "boundary": "This intake queue is not a frozen holdout. Promotion requires a new version, a passed source audit and a scoring identity frozen before blind replay." +} diff --git a/scripts/candidate_time_sensitivity_scan.py b/scripts/candidate_time_sensitivity_scan.py index 295ed4b0..a0d61fb5 100644 --- a/scripts/candidate_time_sensitivity_scan.py +++ b/scripts/candidate_time_sensitivity_scan.py @@ -11,6 +11,14 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Any +try: + from scripts.rectification_input_contract import ( + candidate_input_fingerprint, + stability_probe_contract, + ) +except ModuleNotFoundError: # pragma: no cover - direct script execution + from rectification_input_contract import candidate_input_fingerprint, stability_probe_contract + ROOT = Path(__file__).resolve().parents[1] ENGINE = ROOT / "scripts" / "jyotish_engine.py" @@ -60,6 +68,7 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = rows.append({ "time": moment.strftime("%Y-%m-%d %H:%M"), "offset_minutes": offset, + "input_fingerprint": candidate_input_fingerprint(point), "d1_ascendant": asc.get("sign"), "d1_degree_in_sign": asc.get("degree_in_sign"), "divisional_ascendants": divisional, @@ -68,14 +77,22 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = unavailable_vargas = [varga.upper() for varga in _VARGAS if all(row["divisional_ascendants"][varga.upper()] is None for row in rows)] supported_vargas = [varga.lower() for varga in _VARGAS if varga.upper() not in unavailable_vargas] modal = Counter(signatures).most_common(1)[0][0] - for row, signature in zip(rows, signatures): - row["sensitivity_count"] = sum(left != right for left, right in zip(signature, modal)) + for row, signature in zip(rows, signatures, strict=True): + row["sensitivity_count"] = sum( + left != right for left, right in zip(signature, modal, strict=True) + ) row["sensitive_layers"] = [ - name for name, current, typical in zip(("D1", "D4", "D9", "D10", "D24", "D30"), signature, modal) + name + for name, current, typical in zip( + ("D1", "D4", "D9", "D10", "D24", "D30"), + signature, + modal, + strict=True, + ) if current != typical ] transitions = [] - for previous, current in zip(rows, rows[1:]): + for previous, current in zip(rows, rows[1:], strict=False): changed = [name for name in ("d1_ascendant", "divisional_ascendants") if previous[name] != current[name]] if changed: transitions.append({"between": [previous["time"], current["time"]], "changed": changed}) @@ -88,6 +105,17 @@ 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").strip().lower(), + "node_mode": str( + payload.get("node_mode", payload.get("nodeMode", "mean")) + ).strip().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/minute_rectification_holdout_intake.py b/scripts/minute_rectification_holdout_intake.py new file mode 100644 index 00000000..13f88d27 --- /dev/null +++ b/scripts/minute_rectification_holdout_intake.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Append one independently reviewed case to a non-production v4 intake queue.""" + +from __future__ import annotations + +import argparse +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +try: + from scripts.minute_rectification_holdout_validator import case_errors +except ModuleNotFoundError: # pragma: no cover - direct script execution + from minute_rectification_holdout_validator import case_errors + +INTAKE_SCHEMA_VERSION = "minute-rectification-holdout-v4-intake" +DEFAULT_INTAKE = ( + Path(__file__).resolve().parents[1] + / "references" + / "real_case_calibration" + / "minute_rectification_holdout_v4_intake.json" +) + + +def append_case(path: Path, case: dict[str, Any]) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schema_version") != INTAKE_SCHEMA_VERSION: + return {"appended": False, "errors": ["intake_schema_required"]} + gate = data.get("minimum_gate") if isinstance(data.get("minimum_gate"), dict) else {} + cases = data.get("cases") if isinstance(data.get("cases"), list) else [] + case_id = str(case.get("case_id") or "").strip() + if not case_id: + return {"appended": False, "errors": ["missing_case_id"]} + if any(isinstance(existing, dict) and existing.get("case_id") == case_id for existing in cases): + return {"appended": False, "errors": ["duplicate_case_id"]} + errors = case_errors(case, gate, require_review_safeguards=True) + if errors: + return {"appended": False, "errors": errors} + + cases.append({**case, "ingested_at": datetime.now(UTC).isoformat().replace("+00:00", "Z")}) + data["cases"] = cases + data["status"] = "collecting_independently_reviewed_cases" + data["production_tuning_allowed"] = False + 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": [], + "case_count": len(cases), + "verified_minute_claim_allowed": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", nargs="?", type=Path, default=DEFAULT_INTAKE) + parser.add_argument("--case-json", required=True) + args = parser.parse_args() + result = append_case(args.manifest, json.loads(args.case_json)) + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if result["appended"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/minute_rectification_holdout_validator.py b/scripts/minute_rectification_holdout_validator.py index 32faf88f..a0908862 100644 --- a/scripts/minute_rectification_holdout_validator.py +++ b/scripts/minute_rectification_holdout_validator.py @@ -13,6 +13,7 @@ DEFAULT_MANIFEST = ROOT / "references" / "real_case_calibration" / "minute_recti SUPPORTED_SCHEMA_VERSIONS = { "minute-rectification-holdout-v2", "minute-rectification-holdout-v3", + "minute-rectification-holdout-v4", } ALLOWED_DOMAINS = { "education", "relocation", "relationship", "career", "finance", "health_pressure", @@ -142,6 +143,71 @@ def _case_errors(case: Any, gate: dict[str, Any]) -> list[str]: return errors +def _review_safeguard_errors(case: Any, gate: dict[str, Any]) -> list[str]: + """Validate safeguards required for newly admitted v4 holdout cases.""" + if not isinstance(case, dict): + return ["case_must_be_object"] + errors: list[str] = [] + if not isinstance(case.get("adjudicator"), str) or not case.get("adjudicator", "").strip(): + errors.append("missing_independent_adjudicator") + if case.get("independent_human_reviewed") is not True: + errors.append("independent_review_not_attested") + if case.get("frozen_before_scoring") is not True: + errors.append("case_not_frozen_before_scoring") + + events = case.get("events") if isinstance(case.get("events"), list) else [] + day_precision_count = sum( + isinstance(event, dict) + and event.get("precision") == "day" + and _parse_event_date(event.get("date"), event.get("precision")) is not None + for event in events + ) + if day_precision_count < int(gate.get("day_precision_events_per_case", 3)): + errors.append("insufficient_day_precision_events") + + offsets = case.get("false_minute_offsets") if isinstance(case.get("false_minute_offsets"), list) else [] + commitments = ( + case.get("false_minute_commitments") + if isinstance(case.get("false_minute_commitments"), list) + else [] + ) + committed_offsets: list[int] = [] + hashes: list[str] = [] + for item in commitments: + if not isinstance(item, dict): + errors.append("invalid_false_minute_commitment") + continue + offset = item.get("offset_minutes") + commitment_hash = item.get("commitment_hash") + if not isinstance(offset, int) or offset == 0: + errors.append("invalid_false_minute_commitment_offset") + else: + committed_offsets.append(offset) + if ( + not isinstance(commitment_hash, str) + or len(commitment_hash) != 64 + or any(character not in "0123456789abcdef" for character in commitment_hash.lower()) + ): + errors.append("invalid_false_minute_commitment_hash") + else: + hashes.append(commitment_hash.lower()) + if any(key in item for key in ("candidate_minute", "published_minute", "birth_time")): + errors.append("false_minute_commitment_leaks_time") + if sorted(committed_offsets) != sorted(offsets): + errors.append("false_minute_commitments_do_not_match_offsets") + if len(hashes) != len(set(hashes)): + errors.append("duplicate_false_minute_commitment_hash") + return errors + + +def case_errors(case: Any, gate: dict[str, Any], *, require_review_safeguards: bool = False) -> list[str]: + """Public case-level validator shared by frozen manifests and intake tooling.""" + errors = _case_errors(case, gate) + if require_review_safeguards: + errors.extend(_review_safeguard_errors(case, gate)) + return sorted(set(errors)) + + 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 {} @@ -154,7 +220,10 @@ def validate(manifest_path: Path = DEFAULT_MANIFEST) -> dict[str, Any]: if manifest.get("frozen_before_replay") is not True: manifest_errors.append("benchmark_not_frozen_before_replay") if ( - manifest.get("schema_version") == "minute-rectification-holdout-v3" + manifest.get("schema_version") in { + "minute-rectification-holdout-v3", + "minute-rectification-holdout-v4", + } and manifest.get("source_audit_status") != "passed_before_freeze" ): manifest_errors.append("source_content_audit_not_passed_before_freeze") @@ -165,8 +234,9 @@ def validate(manifest_path: Path = DEFAULT_MANIFEST) -> dict[str, Any]: seen_ids: set[str] = set() invalid_details: list[dict[str, Any]] = [] valid_cases = 0 + require_review_safeguards = manifest.get("schema_version") == "minute-rectification-holdout-v4" for case in cases: - errors = _case_errors(case, gate) + errors = case_errors(case, gate, require_review_safeguards=require_review_safeguards) case_id = case.get("case_id") if isinstance(case, dict) else "non_object_case" if isinstance(case_id, str) and case_id in seen_ids: errors.append("duplicate_case_id") diff --git a/scripts/minute_rectification_source_audit.py b/scripts/minute_rectification_source_audit.py new file mode 100644 index 00000000..e2c8f0e5 --- /dev/null +++ b/scripts/minute_rectification_source_audit.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Audit reusable public AA cases before admitting them to a 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 _birth_source(subject: dict[str, Any]) -> dict[str, Any]: + if isinstance(subject.get("birth_source"), dict): + return subject["birth_source"] + birth = subject.get("birth") if isinstance(subject.get("birth"), dict) else {} + return birth.get("source") if isinstance(birth.get("source"), dict) else {} + + +def _is_aa(source: dict[str, Any]) -> bool: + return source.get("time_accuracy_rating") == "AA" or source.get("rodden_rating") == "AA" + + +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 + source = _birth_source(subject) + source_url = str(source.get("url") or "") + if not _is_aa(source) or not source_url: + continue + label = str(subject.get("name") or subject.get("subject_label") or case.get("case_id") or "unnamed") + entry = entries.setdefault(source_url, { + "subject": label, + "birth_source_url": source_url, + "case_ids": [], + "day_precision_event_dates": set(), + }) + entry["case_ids"].append(str(case.get("case_id") or label)) + for event in _events(case): + event_date = str(event.get("event_date") or event.get("date") or "") + if len(event_date) == 10: + entry["day_precision_event_dates"].add(event_date) + + cases = [] + for entry in entries.values(): + event_count = len(entry["day_precision_event_dates"]) + cases.append({ + "subject": entry["subject"], + "birth_source_url": entry["birth_source_url"], + "case_ids": sorted(set(entry["case_ids"])), + "existing_day_precision_event_count": event_count, + "additional_day_precision_events_required": max(0, 3 - event_count), + "review_and_commitment_controls_required": True, + }) + cases.sort(key=lambda case: (case["additional_day_precision_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, + "production_tuning_allowed": False, + "boundary": ( + "Source discovery is not holdout validation. Every promoted case still requires " + "independent review, day-precision events and committed false-minute controls." + ), + } + + +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/scripts/rectification_input_contract.py b/scripts/rectification_input_contract.py new file mode 100644 index 00000000..82ea4770 --- /dev/null +++ b/scripts/rectification_input_contract.py @@ -0,0 +1,113 @@ +"""Stable, privacy-safe identities for birth-time rectification evidence.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timedelta +from typing import Any + +INPUT_CONTRACT_VERSION = "rectification-input-v1" +REQUIRED_FIELDS = ("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]: + """Normalize only calculation-bearing fields using deployed defaults.""" + missing = [field for field in REQUIRED_FIELDS 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) + node_mode = str(case.get("node_mode", case.get("nodeMode", "mean"))).strip().lower() + if node_mode not in {"mean", "true"}: + raise ValueError("node_mode must be mean or true") + + return { + "year": year, + "month": month, + "day": day, + "hour": hour, + "minute": minute, + "second": second, + "lat": float(case["lat"]), + "lon": float(case["lon"]), + "tz": float(case["tz"]), + "ayanamsa": str(case.get("ayanamsa", "lahiri")).strip().lower(), + "node_mode": node_mode, + } + + +def candidate_input_fingerprint(case: dict[str, Any]) -> str: + payload = { + "schema_version": INPUT_CONTRACT_VERSION, + "calculation_input": canonical_birth_input(case), + } + return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() + + +def stability_probe_contract(case: dict[str, Any]) -> dict[str, Any]: + """Materialize adjacent-minute identities without claiming that they passed.""" + baseline = canonical_birth_input(case) + center = datetime( + baseline["year"], + baseline["month"], + baseline["day"], + baseline["hour"], + baseline["minute"], + baseline["second"], + ) + probes = [] + for offset in STABILITY_OFFSETS: + moment = center + timedelta(minutes=offset) + probe = { + **baseline, + "year": moment.year, + "month": moment.month, + "day": moment.day, + "hour": moment.hour, + "minute": moment.minute, + "second": moment.second, + } + 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(baseline), + "probes": probes, + "minute_confirmation_allowed": False, + "blocker": "public_blind_minute_holdout_not_closed", + "boundary": ( + "Probe identities are reproducible inputs, not evidence that a minute 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 while raw artifact hashes remain intact.""" + normalized = _semantic_normalize(value) + return hashlib.sha256(_canonical_json(normalized).encode("utf-8")).hexdigest() diff --git a/scripts/rectification_three_engine_packet.py b/scripts/rectification_three_engine_packet.py index da6728c2..b8579a70 100644 --- a/scripts/rectification_three_engine_packet.py +++ b/scripts/rectification_three_engine_packet.py @@ -1,9 +1,7 @@ """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 @@ -11,6 +9,19 @@ from typing import Any from domain_calculation_service import compute_chart +try: + from scripts.rectification_input_contract import ( + candidate_input_fingerprint, + canonical_birth_input, + stability_probe_contract, + ) +except ModuleNotFoundError: # pragma: no cover - direct script execution + from rectification_input_contract import ( + candidate_input_fingerprint, + canonical_birth_input, + stability_probe_contract, + ) + ROOT = Path(__file__).resolve().parents[1] JYOTISHGANIT_ROOT = ROOT / "references" / "open_source_sources" / "jyotishganit" PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") @@ -19,29 +30,12 @@ SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpi def canonical_case_input(case: dict[str, Any]) -> dict[str, Any]: """Normalize only calculation-bearing fields before hashing or engine dispatch.""" - required = ("year", "month", "day", "hour", "minute", "lat", "lon", "tz") - missing = [key for key in required if key not in case] - if missing: - raise ValueError("case is missing required birth fields") - return { - "year": int(case["year"]), - "month": int(case["month"]), - "day": int(case["day"]), - "hour": int(case["hour"]), - "minute": int(case["minute"]), - "second": int(case.get("second", 0)), - "lat": float(case["lat"]), - "lon": float(case["lon"]), - "tz": float(case["tz"]), - "ayanamsa": str(case.get("ayanamsa", "lahiri")).strip().lower(), - "node_mode": str(case.get("node_mode", case.get("nodeMode", "mean"))).strip().lower(), - } + return canonical_birth_input(case) def case_hash(case: dict[str, Any]) -> str: """Stable identity for evidence correlation; never exposes birth data.""" - payload = json.dumps(canonical_case_input(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]: @@ -140,6 +134,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..16ebc1d6 100644 --- a/scripts/three_engine_high_rigor_parity.py +++ b/scripts/three_engine_high_rigor_parity.py @@ -14,9 +14,10 @@ ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -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 benchmarks.jyotish.scripts.run_pyjhora_compare import build_pyjhora_sample # noqa: E402 +from benchmarks.jyotish.scripts.run_skill_baseline import run_sample # noqa: E402 +from scripts.rectification_input_contract import semantic_evidence_hash # noqa: E402 +from scripts.three_engine_parity_runner import _capture_jyotishganit_raw # noqa: E402 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_api_server_security.py b/tests/test_api_server_security.py index 5498c69d..8db4ee86 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -1570,6 +1570,7 @@ def test_capability_audit_scans_registry_and_local_sources() -> None: assert audit['surfaces']['app_routes'] == ['admin/codes', 'home', 'login'] assert set(audit['surfaces']['app_visible_topics']) == { 'Birth Rectification', + 'Case Validation', 'Synastry 16-factor', } assert '/api/deep_varga_avastha' in audit['surfaces']['api_endpoints'] @@ -1599,14 +1600,14 @@ def test_capability_audit_scans_registry_and_local_sources() -> None: assert all('ux_next_action' in row for row in ux['next_queue']) ux_by_id = {row['id']: row for row in ux['rows']} assert ux_by_id['birth_time_rectifier']['ux_level'] in {'excellent', 'usable'} + assert ux_by_id['case_validator']['ux_level'] == 'excellent' assert ux_by_id['synastry_16factor']['ux_level'] == 'excellent' for technique_id in [ 'ashtakavarga_pav', 'ashtakavarga_sodhita', 'bhava_bala', 'career_engine', - 'case_validator', - 'deep_varga_avastha', + 'deep_varga_avastha', 'divisional_yoga', 'kakshya', 'kp_system', diff --git a/tests/test_candidate_time_sensitivity_scan.py b/tests/test_candidate_time_sensitivity_scan.py index da8a0476..40ee522c 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"]["settings"]["node_mode"] == "mean" + 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_minute_rectification_holdout_intake.py b/tests/test_minute_rectification_holdout_intake.py new file mode 100644 index 00000000..1003753a --- /dev/null +++ b/tests/test_minute_rectification_holdout_intake.py @@ -0,0 +1,73 @@ +import hashlib +import json +from copy import deepcopy +from pathlib import Path + +from scripts.minute_rectification_holdout_intake import DEFAULT_INTAKE, append_case +from scripts.minute_rectification_holdout_validator import DEFAULT_MANIFEST + + +def _reviewed_case() -> dict: + case = deepcopy(json.loads(DEFAULT_MANIFEST.read_text(encoding="utf-8"))["cases"][0]) + case["case_id"] = "new-reviewed-case" + case["adjudicator"] = "independent-reviewer" + case["independent_human_reviewed"] = True + case["frozen_before_scoring"] = True + case["false_minute_commitments"] = [ + { + "offset_minutes": offset, + "commitment_hash": hashlib.sha256(f"control:{offset}".encode()).hexdigest(), + } + for offset in case["false_minute_offsets"] + ] + return case + + +def _queue(path: Path) -> None: + path.write_text(json.dumps({ + "schema_version": "minute-rectification-holdout-v4-intake", + "minimum_gate": { + "events_per_case": 3, + "domains_per_case": 2, + "independent_event_sources_per_case": 2, + "negative_minutes_per_case": 4, + "day_precision_events_per_case": 3, + }, + "cases": [], + }), encoding="utf-8") + + +def test_intake_appends_reviewed_case_but_keeps_release_blocked(tmp_path: Path) -> None: + path = tmp_path / "intake.json" + _queue(path) + + report = append_case(path, _reviewed_case()) + data = json.loads(path.read_text(encoding="utf-8")) + + assert report["appended"] is True + assert report["verified_minute_claim_allowed"] is False + assert data["production_tuning_allowed"] is False + assert data["verified_minute_claim_allowed"] is False + assert data["cases"][0]["ingested_at"].endswith("Z") + + +def test_intake_rejects_missing_review_and_commitments(tmp_path: Path) -> None: + path = tmp_path / "intake.json" + _queue(path) + case = _reviewed_case() + case["independent_human_reviewed"] = False + case["false_minute_commitments"] = [] + + report = append_case(path, case) + + assert report["appended"] is False + assert "independent_review_not_attested" in report["errors"] + assert "false_minute_commitments_do_not_match_offsets" in report["errors"] + + +def test_default_intake_is_non_production_and_empty() -> None: + data = json.loads(DEFAULT_INTAKE.read_text(encoding="utf-8")) + + assert data["cases"] == [] + assert data["production_tuning_allowed"] is False + assert data["verified_minute_claim_allowed"] is False diff --git a/tests/test_minute_rectification_holdout_validator.py b/tests/test_minute_rectification_holdout_validator.py index 1ea3298d..42623896 100644 --- a/tests/test_minute_rectification_holdout_validator.py +++ b/tests/test_minute_rectification_holdout_validator.py @@ -1,3 +1,4 @@ +import hashlib import json from copy import deepcopy from pathlib import Path @@ -49,6 +50,52 @@ def test_v3_validator_requires_content_source_audit_before_freeze(tmp_path: Path assert report["status"] == "blocked_awaiting_public_aa_cases" +def _add_v4_review_safeguards(manifest: dict) -> None: + manifest["schema_version"] = "minute-rectification-holdout-v4" + manifest["source_audit_status"] = "passed_before_freeze" + manifest["minimum_gate"]["day_precision_events_per_case"] = 3 + for case in manifest["cases"]: + case["adjudicator"] = "independent-reviewer" + case["independent_human_reviewed"] = True + case["frozen_before_scoring"] = True + case["false_minute_commitments"] = [ + { + "offset_minutes": offset, + "commitment_hash": hashlib.sha256( + f"{case['case_id']}:{offset}:sealed".encode() + ).hexdigest(), + } + for offset in case["false_minute_offsets"] + ] + + +def test_v4_validator_accepts_reviewed_cases_with_committed_false_minutes(tmp_path: Path) -> None: + manifest = deepcopy(_manifest()) + _add_v4_review_safeguards(manifest) + path = tmp_path / "v4.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + + report = validate(path) + + assert report["manifest_errors"] == [] + assert report["invalid_case_details"] == [] + + +def test_v4_validator_rejects_unreviewed_or_uncommitted_cases(tmp_path: Path) -> None: + manifest = deepcopy(_manifest()) + _add_v4_review_safeguards(manifest) + case = manifest["cases"][0] + case["independent_human_reviewed"] = False + case["false_minute_commitments"].pop() + path = tmp_path / "invalid-v4.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + + errors = validate(path)["invalid_case_details"][0]["errors"] + + assert "independent_review_not_attested" in errors + assert "false_minute_commitments_do_not_match_offsets" in errors + + def test_validator_rejects_tuning_case_and_non_independent_event_source(tmp_path: Path) -> None: manifest = deepcopy(_manifest()) case = manifest["cases"][0] diff --git a/tests/test_minute_rectification_source_audit.py b/tests/test_minute_rectification_source_audit.py new file mode 100644 index 00000000..106d8cbf --- /dev/null +++ b/tests/test_minute_rectification_source_audit.py @@ -0,0 +1,24 @@ +import json +from pathlib import Path + +from scripts.minute_rectification_source_audit import build_source_audit + + +def test_source_audit_supports_current_holdout_shape_and_counts_day_precision(tmp_path: Path) -> None: + source = tmp_path / "cases.json" + source.write_text(json.dumps({"cases": [{ + "case_id": "public-case", + "subject_label": "Public case", + "birth": {"source": {"rodden_rating": "AA", "url": "https://example.test/birth"}}, + "events": [ + {"date": "2001-01-01"}, + {"date": "2002"}, + ], + }]}), encoding="utf-8") + + report = build_source_audit([source]) + + assert report["public_aa_case_count"] == 1 + assert report["cases"][0]["existing_day_precision_event_count"] == 1 + assert report["cases"][0]["additional_day_precision_events_required"] == 2 + assert report["production_tuning_allowed"] is False diff --git a/tests/test_rectification_input_contract.py b/tests/test_rectification_input_contract.py new file mode 100644 index 00000000..3846bce4 --- /dev/null +++ b/tests/test_rectification_input_contract.py @@ -0,0 +1,47 @@ +from scripts.rectification_input_contract import ( + candidate_input_fingerprint, + canonical_birth_input, + semantic_evidence_hash, + stability_probe_contract, +) + +CASE = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "lat": 0.0, + "lon": 0.0, + "tz": 0.0, +} + + +def test_contract_uses_deployed_mean_node_default_and_stable_identity() -> None: + reordered = {key: CASE[key] for key in reversed(CASE)} + + assert canonical_birth_input(CASE)["node_mode"] == "mean" + assert candidate_input_fingerprint(CASE) == candidate_input_fingerprint(reordered) + assert candidate_input_fingerprint(CASE) == candidate_input_fingerprint({**CASE, "nodeMode": "MEAN"}) + + +def test_candidate_fingerprint_changes_with_calculation_input() -> None: + assert candidate_input_fingerprint(CASE) != candidate_input_fingerprint({**CASE, "minute": 1}) + assert candidate_input_fingerprint(CASE) != candidate_input_fingerprint({**CASE, "node_mode": "true"}) + + +def test_stability_contract_records_adjacent_probes_without_confirming() -> None: + contract = stability_probe_contract(CASE) + + assert [probe["offset_minutes"] for probe in contract["probes"]] == [-5, -2, -1, 1, 2, 5] + assert contract["minute_confirmation_allowed"] is False + assert contract["status"] == "pending_score_comparison" + + +def test_semantic_hash_normalizes_only_known_order_insensitive_lists() -> None: + left = {"aspects": {"gives": ["Mars", "Saturn"]}, "ordered_scores": [2, 1]} + reordered_aspects = {"ordered_scores": [2, 1], "aspects": {"gives": ["Saturn", "Mars"]}} + reordered_scores = {"ordered_scores": [1, 2], "aspects": {"gives": ["Saturn", "Mars"]}} + + assert semantic_evidence_hash(left) == semantic_evidence_hash(reordered_aspects) + assert semantic_evidence_hash(left) != semantic_evidence_hash(reordered_scores) diff --git a/tests/test_rectification_three_engine_packet.py b/tests/test_rectification_three_engine_packet.py index b34ffd94..ff2e295d 100644 --- a/tests/test_rectification_three_engine_packet.py +++ b/tests/test_rectification_three_engine_packet.py @@ -1,6 +1,5 @@ from scripts.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} @@ -10,6 +9,8 @@ def test_packet_is_private_and_never_confirms(monkeypatch) -> None: monkeypatch.setattr("scripts.rectification_three_engine_packet._jyotishganit_d1", lambda _: {"Sun": "Aries"}) packet = build_packet(CASE) assert packet["case_hash"] == case_hash(CASE) + assert packet["input_contract_hash"] == packet["case_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"