strengthen external parity diagnostics
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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())
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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=`<h2>实际候选盘差异</h2><pre>${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)}</pre>`;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=`<h2>实际候选盘差异</h2><pre>${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)}</pre>`;render(questionnaire.questions||[])}catch(err){fail(err)}};
|
||||
function render(qs){const root=document.querySelector('#questions');root.innerHTML=qs.map(q=>`<section class="question"><strong>${q.prompt}</strong>${q.options.map(o=>`<label><input type="radio" name="${q.id}" value="${o.key}"> ${o.key}. ${o.label}</label>`).join('')}</section>`).join('')+'<button id="score">提交本轮答案</button>';document.querySelector('#score').onclick=score}
|
||||
async function score(){try{const answers={};document.querySelectorAll('#questions input:checked').forEach(e=>answers[e.name]=e.value);const d=await post('/api/rectification/answers',{questionnaire,answers});document.querySelector('#result').innerHTML=`<h2>候选簇排序</h2><pre>${JSON.stringify({candidate_cluster_rankings:d.candidate_cluster_rankings,next_round:d.next_round,boundary:d.boundary},null,2)}</pre>`}catch(err){fail(err)}}
|
||||
</script></html>
|
||||
|
||||
Reference in New Issue
Block a user