From 85e420cba48d4b0e17beb29f89210cfabd23ef9d Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Sun, 12 Jul 2026 15:13:30 +0800 Subject: [PATCH] strengthen external parity diagnostics --- .../jyotish/scripts/run_pyjhora_compare.py | 34 +++++++++-- .../jyotish/scripts/run_skill_baseline.py | 4 +- docs/research/pre_work_error_ledger.md | 1 + scripts/candidate_time_sensitivity_scan.py | 21 ++++++- scripts/jyotish_api_server.py | 6 +- scripts/pyjhora_parity_summary.py | 61 +++++++++++++++++++ tests/test_candidate_time_sensitivity_scan.py | 7 ++- tests/test_pyjhora_compare_cli.py | 17 ++++++ tests/test_pyjhora_parity_summary.py | 17 ++++++ web/rectification.html | 2 +- 10 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 scripts/pyjhora_parity_summary.py create mode 100644 tests/test_pyjhora_compare_cli.py create mode 100644 tests/test_pyjhora_parity_summary.py diff --git a/benchmarks/jyotish/scripts/run_pyjhora_compare.py b/benchmarks/jyotish/scripts/run_pyjhora_compare.py index d21398a6..e6009193 100644 --- a/benchmarks/jyotish/scripts/run_pyjhora_compare.py +++ b/benchmarks/jyotish/scripts/run_pyjhora_compare.py @@ -3,6 +3,7 @@ # or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally # and are intentionally not committed. #!/usr/bin/env python3 +import argparse import csv import json import os @@ -99,7 +100,7 @@ def tuple_to_date(t): return f'{int(y):04d}-{int(m):02d}-{int(d):02d}' -def build_pyjhora_sample(sample): +def build_pyjhora_sample(sample, *, node_mode='mean'): swe = patch_swisseph() from jhora import utils, const from jhora.panchanga import drik @@ -109,6 +110,8 @@ def build_pyjhora_sample(sample): # Align benchmark口径: Lahiri + mean sidereal year. PyJHora default is TRUE_PUSHYA. const._DEFAULT_AYANAMSA_MODE = 'LAHIRI' drik.set_ayanamsa_mode('LAHIRI') + const.set_node_mode(node_mode == 'true') + drik.set_planet_list(set_rahu_ketu_as_true_nodes=(node_mode == 'true')) try: const.dhasa_year_duration_default = const.DHASA_YEAR_DURATION.MEAN_SIDEREAL_YEAR except Exception: @@ -162,6 +165,7 @@ def build_pyjhora_sample(sample): dasha['error'] = f'{type(exc).__name__}: {exc}' return { + 'settings': {'ayanamsa': 'lahiri', 'node_mode': node_mode}, 'sample_id': sample['id'], 'engine': 'PyJHora_4_8_6_lahiri_patched', 'parameters': { @@ -320,14 +324,36 @@ def write_report(samples, rows): return '\n'.join(lines) -def main(): +def main(argv=None): + parser = argparse.ArgumentParser(description='Compare public benchmark samples against PyJHora.') + parser.add_argument('--sample-id', action='append', default=[], help='Run only a named benchmark sample; repeatable.') + parser.add_argument('--build-local', action='store_true', help='Explicitly generate missing local canonical baselines.') + parser.add_argument('--node-mode', choices=['mean', 'true'], default='mean', help='Match the node convention before comparing.') + args = parser.parse_args(argv) PYJHORA_OUT.mkdir(parents=True, exist_ok=True) samples = json.loads(DATA.read_text()) + if args.sample_id: + requested = set(args.sample_id) + samples = [sample for sample in samples if sample['id'] in requested] + missing = requested - {sample['id'] for sample in samples} + if missing: + parser.error(f'unknown sample id(s): {", ".join(sorted(missing))}') all_rows = [] for sample in samples: - pyjhora = build_pyjhora_sample(sample) + local_path = LOCAL_CANON / f"{sample['id']}.canonical.json" + if not local_path.exists() and args.build_local: + from run_skill_baseline import run_sample + baseline = run_sample(sample) + if not baseline.get('ok'): + parser.error(f'failed to build local baseline for {sample["id"]}: {baseline.get("error", "unknown error")}') + if not local_path.exists(): + parser.error( + f'missing local canonical baseline for {sample["id"]}; ' + 'run with --build-local or run_skill_baseline.py first' + ) + pyjhora = build_pyjhora_sample(sample, node_mode=args.node_mode) (PYJHORA_OUT / f"{sample['id']}.pyjhora.json").write_text(json.dumps(pyjhora, ensure_ascii=False, indent=2)) - local = json.loads((LOCAL_CANON / f"{sample['id']}.canonical.json").read_text()) + local = json.loads(local_path.read_text()) all_rows.extend(compare_one(sample['id'], local, pyjhora)) matrix = OUT / 'pyjhora_comparison_matrix.csv' diff --git a/benchmarks/jyotish/scripts/run_skill_baseline.py b/benchmarks/jyotish/scripts/run_skill_baseline.py index e1462a59..367619c5 100644 --- a/benchmarks/jyotish/scripts/run_skill_baseline.py +++ b/benchmarks/jyotish/scripts/run_skill_baseline.py @@ -10,7 +10,7 @@ import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -SKILL_SCRIPT = Path(__file__).resolve().parents[2] / 'scripts' / 'jyotish_engine.py' +SKILL_SCRIPT = Path(__file__).resolve().parents[3] / 'scripts' / 'jyotish_engine.py' PYTHON = Path(__import__('sys').executable) DATA = ROOT / 'data/benchmark_samples.json' OUT = ROOT / 'outputs' @@ -31,6 +31,8 @@ def safe_get(obj, *keys, default=None): def run_sample(sample): + RAW.mkdir(parents=True, exist_ok=True) + CANON.mkdir(parents=True, exist_ok=True) birth = sample['birth'] cmd = [ str(PYTHON), str(SKILL_SCRIPT), 'full-reading', diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 1d5be933..7805f662 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -79,6 +79,7 @@ For large architecture or release work, also read: | ERR-046 | Report-renderer SSRF/file PoC could not run because the Playwright Chromium binary was absent and installation exceeded the desktop outer timeout. | blocked environment | Keep route/JS-denial tests; rerun isolated HTTP/file PoC only after a verified Chromium installation, then update this ledger with the measured request count. | | ERR-047 | Initial `slow` marker partition for `test_api_server_security.py` still exceeded the 120-second desktop budget; heavy paths extend beyond VedAstro/high-rigor prefix groups. | active profiling blocker | Profile test node IDs in bounded subprocess batches, mark only measured heavy tests, and keep fast-security acceptance separate from long CI integration coverage. | | ERR-048 | Candidate-time scanner assumed all documented D4/D24/D30 divisions were exposed by `jyotish_engine.py varga`; actual `--d4` failed at runtime. | mitigated 2026-07-12 | Candidate scans must record unsupported Varga flags as `unavailable_vargas`; only successfully computed D1/D9/D10 fields may drive local sensitivity output until a unified Varga contract exists. | +| ERR-049 | PyJHora benchmark runner executed on `--help`, used a wrong repository-root path in `run_skill_baseline.py`, and failed when reused without pre-created output directories. | mitigated 2026-07-12 | Keep `tests/test_pyjhora_compare_cli.py`; require explicit `--build-local`, safe argparse help, correct repo root, and directory creation inside `run_sample()`. | ## Fragment Sweep Command Set diff --git a/scripts/candidate_time_sensitivity_scan.py b/scripts/candidate_time_sensitivity_scan.py index cbe40f4c..173017b7 100644 --- a/scripts/candidate_time_sensitivity_scan.py +++ b/scripts/candidate_time_sensitivity_scan.py @@ -39,6 +39,21 @@ def _varga_ascendant(payload: dict[str, Any], varga: str) -> str | None: return None +def _all_varga_ascendants(payload: dict[str, Any]) -> dict[str, str | None]: + values = {varga.upper(): None for varga in _VARGAS} + try: + raw = _engine_json("varga", {**payload, "varga": "all"}) + except subprocess.CalledProcessError: + return values + for name, chart in (raw.get("divisional_charts") or {}).items(): + if not isinstance(chart, dict): + continue + for varga in _VARGAS: + if name.startswith(varga.upper() + "_"): + values[varga.upper()] = chart.get("ascendant") + return values + + def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = 30, step_minutes: int = 1) -> dict[str, Any]: required = ("year", "month", "day", "hour", "minute", "lat", "lon", "tz") missing = [key for key in required if payload.get(key) is None] @@ -53,7 +68,7 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = point = {**payload, "year": moment.year, "month": moment.month, "day": moment.day, "hour": moment.hour, "minute": moment.minute} chart = _engine_json("chart", point) asc = chart.get("ascendant", {}) - divisional = {varga.upper(): _varga_ascendant(point, varga) for varga in _VARGAS} + divisional = _all_varga_ascendants(point) rows.append({ "time": moment.strftime("%Y-%m-%d %H:%M"), "offset_minutes": offset, @@ -62,6 +77,8 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = "divisional_ascendants": divisional, }) 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)] + 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)) @@ -69,7 +86,6 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = name for name, current, typical in zip(("D1", "D4", "D9", "D10", "D24", "D30"), signature, modal) if current != typical ] - unavailable_vargas = [varga.upper() for varga in _VARGAS if all(row["divisional_ascendants"][varga.upper()] is None for row in rows)] transitions = [] for previous, current in zip(rows, rows[1:]): changed = [name for name in ("d1_ascendant", "divisional_ascendants") if previous[name] != current[name]] @@ -85,6 +101,7 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = "step_minutes": step_minutes, "rows": rows, "transitions": transitions, + "supported_vargas": [varga.upper() for varga in supported_vargas], "unavailable_vargas": unavailable_vargas, "pending_layers": ["UL", "A7", "A10", "KP_cusp"], "boundary": "Actual local D1/Varga differences only. Unsupported Varga CLI flags are explicitly unavailable. Event answers still require an explicit event-to-candidate adjudication model before minute-level rectification.", diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index d805157a..4b9199a9 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -1308,10 +1308,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif path == '/api/rectification/questionnaire': self._json(build_rectification_questionnaire(body)) elif path == '/api/rectification/sensitivity_scan': + uncertainty = int(body.get('time_uncertainty_minutes') or 30) + step_minutes = int(body.get('step_minutes') or (5 if uncertainty > 15 else 1)) self._json(scan_candidate_times( body, - uncertainty_minutes=int(body.get('time_uncertainty_minutes') or 30), - step_minutes=int(body.get('step_minutes') or 1), + uncertainty_minutes=uncertainty, + step_minutes=step_minutes, )) elif path == '/api/rectification/answers': questionnaire = body.get('questionnaire') diff --git a/scripts/pyjhora_parity_summary.py b/scripts/pyjhora_parity_summary.py new file mode 100644 index 00000000..bb40ea76 --- /dev/null +++ b/scripts/pyjhora_parity_summary.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Summarize reviewable PyJHora comparison matrices without overstating coverage.""" + +from __future__ import annotations + +import argparse +import csv +import json +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + + +REQUIRED_FULL_PARITY = ("D1", "D9", "D10", "D2", "D4", "Vimshottari", "Shadbala", "Ashtakavarga") +MATRIX_SECTION_MAP = {"ascendant": "D1", "planet": "D1", "dasha": "Vimshottari", "D9": "D9", "D10": "D10"} + + +def summarize_matrix(path: str | Path, *, settings: dict[str, Any]) -> dict[str, Any]: + path = Path(path) + rows = list(csv.DictReader(path.open(encoding="utf-8"))) + status_counts = Counter(str(row.get("status") or "unknown") for row in rows) + sections: dict[str, dict[str, int]] = defaultdict(lambda: {"total": 0, "match": 0, "mismatch": 0}) + covered = set() + for row in rows: + section = MATRIX_SECTION_MAP.get(str(row.get("section") or "")) + if not section: + continue + covered.add(section) + sections[section]["total"] += 1 + if row.get("status") == "match": + sections[section]["match"] += 1 + elif row.get("status") == "mismatch": + sections[section]["mismatch"] += 1 + missing = [field for field in REQUIRED_FULL_PARITY if field not in covered] + return { + "scope": "pyjhora_same_chart_parity_summary", + "matrix_path": str(path), + "tested": bool(rows), + "settings": settings, + "row_counts": dict(status_counts), + "coverage": dict(sorted(sections.items())), + "covered_outputs": sorted(covered), + "missing_required_outputs": missing, + "status": "partial_verified" if rows and not status_counts.get("mismatch") else "partial_mismatch", + "full_parity_verified": not missing and not status_counts.get("mismatch"), + "boundary": "Only covered outputs are compared. This summary cannot promote full parity while required outputs are absent.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("matrix") + parser.add_argument("--ayanamsa", default="lahiri") + parser.add_argument("--node-mode", default="mean", choices=["mean", "true"]) + args = parser.parse_args() + print(json.dumps(summarize_matrix(args.matrix, settings={"ayanamsa": args.ayanamsa, "node_mode": args.node_mode}), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_candidate_time_sensitivity_scan.py b/tests/test_candidate_time_sensitivity_scan.py index 6076a07c..d6507c10 100644 --- a/tests/test_candidate_time_sensitivity_scan.py +++ b/tests/test_candidate_time_sensitivity_scan.py @@ -7,7 +7,12 @@ def test_scanner_reports_real_divisional_transitions(monkeypatch): if command == "chart": return {"ascendant": {"sign": "Leo", "degree_in_sign": 10 + minute / 100}} ascendant = "Aries" if minute % 2 else "Taurus" - return {"divisional_charts": {"D": {"ascendant": ascendant}}} + return { + "divisional_charts": { + "D9_Navamsa": {"ascendant": ascendant}, + "D10_Dasamsa": {"ascendant": ascendant}, + } + } monkeypatch.setattr(scanner, "_engine_json", fake_engine) report = scanner.scan_candidate_times( diff --git a/tests/test_pyjhora_compare_cli.py b/tests/test_pyjhora_compare_cli.py new file mode 100644 index 00000000..af20eb8f --- /dev/null +++ b/tests/test_pyjhora_compare_cli.py @@ -0,0 +1,17 @@ +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +RUNNER = ROOT / "benchmarks" / "jyotish" / "scripts" / "run_pyjhora_compare.py" + + +def test_pyjhora_compare_help_is_non_executing(): + result = subprocess.run( + [sys.executable, str(RUNNER), "--help"], cwd=ROOT, capture_output=True, text=True, timeout=15 + ) + + assert result.returncode == 0 + assert "--build-local" in result.stdout + assert "FileNotFoundError" not in result.stderr diff --git a/tests/test_pyjhora_parity_summary.py b/tests/test_pyjhora_parity_summary.py new file mode 100644 index 00000000..0e8dc0c5 --- /dev/null +++ b/tests/test_pyjhora_parity_summary.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from scripts.pyjhora_parity_summary import summarize_matrix + + +def test_summary_marks_partial_verified_when_only_d1_d9_d10_dasha_are_covered(tmp_path: Path): + matrix = tmp_path / "matrix.csv" + matrix.write_text( + "section,status\nascendant,match\nD9,match\nD10,match\ndasha,match\n", + encoding="utf-8", + ) + + result = summarize_matrix(matrix, settings={"ayanamsa": "lahiri", "node_mode": "mean"}) + + assert result["status"] == "partial_verified" + assert result["full_parity_verified"] is False + assert result["missing_required_outputs"] == ["D2", "D4", "Shadbala", "Ashtakavarga"] diff --git a/web/rectification.html b/web/rectification.html index ea77b72f..54a98fd4 100644 --- a/web/rectification.html +++ b/web/rectification.html @@ -9,7 +9,7 @@ let questionnaire; const asObject=f=>Object.fromEntries(new FormData(f).entries()); const fail=e=>document.querySelector('#error').textContent=`请求失败:${e.message}。请检查输入后重试。`; async function post(url,body){const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw new Error(d.error||r.status);return d} -document.querySelector('#birth').onsubmit=async e=>{e.preventDefault();document.querySelector('#error').textContent='正在计算候选盘…';try{const p=asObject(e.target);for(const k of Object.keys(p))p[k]=Number(p[k]);const [q,scan]=await Promise.all([post('/api/rectification/questionnaire',p),post('/api/rectification/sensitivity_scan',p)]);questionnaire=q;document.querySelector('#error').textContent='';document.querySelector('#scan').innerHTML=`
${JSON.stringify({candidate_count:scan.candidate_count,transitions:scan.transitions,unavailable_vargas:scan.unavailable_vargas,pending_layers:scan.pending_layers,boundary:scan.boundary},null,2)}`;render(questionnaire.questions||[])}catch(err){fail(err)}};
+document.querySelector('#birth').onsubmit=async e=>{e.preventDefault();document.querySelector('#error').textContent='正在计算候选盘…';try{const p=asObject(e.target);for(const k of Object.keys(p))p[k]=Number(p[k]);const [q,scan]=await Promise.all([post('/api/rectification/questionnaire',p),post('/api/rectification/sensitivity_scan',p)]);questionnaire=q;document.querySelector('#error').textContent='';document.querySelector('#scan').innerHTML=`${JSON.stringify({candidate_count:scan.candidate_count,step_minutes:scan.step_minutes,transitions:scan.transitions,supported_vargas:scan.supported_vargas,unavailable_vargas:scan.unavailable_vargas,pending_layers:scan.pending_layers,boundary:scan.boundary},null,2)}`;render(questionnaire.questions||[])}catch(err){fail(err)}};
function render(qs){const root=document.querySelector('#questions');root.innerHTML=qs.map(q=>`${JSON.stringify({candidate_cluster_rankings:d.candidate_cluster_rankings,next_round:d.next_round,boundary:d.boundary},null,2)}`}catch(err){fail(err)}}