181 lines
7.0 KiB
Python
181 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Golden case regression runner for Jyotish full-reading output contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import date
|
|
from typing import Any, Dict, List
|
|
|
|
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
ENGINE = os.path.join(ROOT_DIR, "scripts", "jyotish_engine.py")
|
|
DEFAULT_CASES = os.path.join(ROOT_DIR, "tests", "golden", "golden_cases.json")
|
|
|
|
|
|
def get_path(data: Dict[str, Any], dotted_path: str) -> Any:
|
|
cur: Any = data
|
|
for part in dotted_path.split("."):
|
|
if not isinstance(cur, dict) or part not in cur:
|
|
raise KeyError(dotted_path)
|
|
cur = cur[part]
|
|
return cur
|
|
|
|
|
|
def is_non_empty(value: Any) -> bool:
|
|
return value is not None and value != {} and value != []
|
|
|
|
|
|
def parse_date(value: str) -> date:
|
|
return date.fromisoformat(str(value)[:10])
|
|
|
|
|
|
def current_period_covers_reference(period: Dict[str, Any], reference_date: str) -> bool:
|
|
if not isinstance(period, dict):
|
|
return False
|
|
start = period.get("start") or period.get("start_date")
|
|
end = period.get("end") or period.get("end_date")
|
|
if not start or not end:
|
|
return False
|
|
ref = parse_date(reference_date)
|
|
return parse_date(start) <= ref <= parse_date(end)
|
|
|
|
|
|
def check_quality_gates(data: Dict[str, Any], case: Dict[str, Any]) -> List[str]:
|
|
gates = case.get("quality_gates", {})
|
|
failures: List[str] = []
|
|
modules = data.get("modules", {})
|
|
if not isinstance(modules, dict):
|
|
return ["modules must be an object"]
|
|
|
|
min_modules = gates.get("min_modules")
|
|
if min_modules is not None and len(modules) < min_modules:
|
|
failures.append(f"module count too low: {len(modules)} < {min_modules}")
|
|
|
|
for path in gates.get("non_empty_paths", []):
|
|
try:
|
|
value = get_path(data, path)
|
|
if not is_non_empty(value):
|
|
failures.append(f"empty quality path: {path}")
|
|
except KeyError:
|
|
failures.append(f"missing quality path: {path}")
|
|
|
|
invariants = gates.get("invariants", {})
|
|
if invariants.get("validation_valid") and modules.get("validation", {}).get("valid") is not True:
|
|
failures.append("validation.valid is not true")
|
|
|
|
expected_sav = invariants.get("ashtakavarga_sav_total")
|
|
if expected_sav is not None:
|
|
sav_total = modules.get("ashtakavarga", {}).get("sav", {}).get("total")
|
|
if sav_total != expected_sav:
|
|
failures.append(f"ashtakavarga SAV total mismatch: {sav_total} != {expected_sav}")
|
|
|
|
min_remedy_evidence = invariants.get("min_remedies_evidence")
|
|
if min_remedy_evidence is not None:
|
|
evidence = modules.get("remedies", {}).get("evidence_chain", [])
|
|
if len(evidence) < min_remedy_evidence:
|
|
failures.append(f"remedies evidence too thin: {len(evidence)} < {min_remedy_evidence}")
|
|
|
|
if invariants.get("dasha_current_covers_reference"):
|
|
reference_date = case.get("input", {}).get("today")
|
|
current = modules.get("dasha", {}).get("current_dasha", {})
|
|
if not reference_date or not current_period_covers_reference(current, reference_date):
|
|
failures.append("current dasha does not cover reference date")
|
|
|
|
if invariants.get("dasa_convergence_non_empty"):
|
|
domains = modules.get("dasa_convergence", {}).get("top_convergent_domains", [])
|
|
if not domains:
|
|
failures.append("dasa convergence has no top domains")
|
|
|
|
if invariants.get("transit_has_references"):
|
|
refs = modules.get("transit_multi_reference", {}).get("references", {})
|
|
if len(refs) < 3:
|
|
failures.append(f"transit references too thin: {len(refs)} < 3")
|
|
|
|
boundaries = gates.get("boundaries", {})
|
|
forbidden_terms = boundaries.get("forbidden_absolute_terms", [])
|
|
if forbidden_terms:
|
|
text = json.dumps({
|
|
"summary": data.get("summary"),
|
|
"warnings": data.get("warnings"),
|
|
"remedies": modules.get("remedies"),
|
|
"dasa_convergence": modules.get("dasa_convergence"),
|
|
}, ensure_ascii=False)
|
|
for term in forbidden_terms:
|
|
if term and term in text:
|
|
failures.append(f"forbidden absolute term found: {term}")
|
|
|
|
return failures
|
|
|
|
|
|
def run_full_reading(py: str, case: Dict[str, Any]) -> Dict[str, Any]:
|
|
inp = case["input"]
|
|
cmd = [
|
|
py,
|
|
ENGINE,
|
|
"full-reading",
|
|
"--year", str(inp["year"]),
|
|
"--month", str(inp["month"]),
|
|
"--day", str(inp["day"]),
|
|
"--hour", str(inp["hour"]),
|
|
"--minute", str(inp["minute"]),
|
|
"--lat", str(inp["lat"]),
|
|
"--lon", str(inp["lon"]),
|
|
"--tz", str(inp["tz"]),
|
|
]
|
|
if inp.get("today"):
|
|
cmd.extend(["--today", inp["today"]])
|
|
out = subprocess.check_output(cmd, text=True)
|
|
return json.loads(out)
|
|
|
|
|
|
def run_cases(py: str, cases_path: str) -> Dict[str, Any]:
|
|
with open(cases_path, "r", encoding="utf-8") as f:
|
|
suite = json.load(f)
|
|
results: List[Dict[str, Any]] = []
|
|
for case in suite.get("cases", []):
|
|
case_result = {"id": case.get("id"), "passed": True, "failures": []}
|
|
try:
|
|
data = run_full_reading(py, case)
|
|
errors = data.get("errors", [])
|
|
if len(errors) > case.get("max_errors", 0):
|
|
case_result["passed"] = False
|
|
case_result["failures"].append(f"too many errors: {len(errors)} > {case.get('max_errors', 0)}; {errors[:5]}")
|
|
for path in case.get("expected_output_paths", []):
|
|
try:
|
|
value = get_path(data, path)
|
|
if value is None or value == {} or value == []:
|
|
case_result["passed"] = False
|
|
case_result["failures"].append(f"empty output path: {path}")
|
|
except KeyError:
|
|
case_result["passed"] = False
|
|
case_result["failures"].append(f"missing output path: {path}")
|
|
quality_failures = check_quality_gates(data, case)
|
|
if quality_failures:
|
|
case_result["passed"] = False
|
|
case_result["failures"].extend(quality_failures)
|
|
except Exception as exc:
|
|
case_result["passed"] = False
|
|
case_result["failures"].append(str(exc))
|
|
results.append(case_result)
|
|
failed = [r for r in results if not r["passed"]]
|
|
return {"valid": len(failed) == 0, "total": len(results), "failed": len(failed), "results": results}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Run Jyotish golden case regressions")
|
|
parser.add_argument("--python", default=sys.executable, help="Python executable used to run jyotish_engine.py")
|
|
parser.add_argument("--cases", default=DEFAULT_CASES, help="Path to golden_cases.json")
|
|
args = parser.parse_args()
|
|
result = run_cases(args.python, args.cases)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 0 if result["valid"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|