fix(benchmark): pass the ayanamsa to both engines and refuse a mismatched baseline

The local baseline still claimed "lahiri_assumed_by_skill" after the engine
default moved to Raman, so the PyJHora comparison reported 59% instead of
~95% (every longitude 1.446 deg off). The baseline now passes --ayanamsa and
--node-mode to the engine and records what the engine reports; the compare
takes --ayanamsa and stops when the baseline was built differently. The nine
public Rodden AA backtest figures join the fixed samples.

Raman run (19 samples): ascendant, planets, nakshatras, D2/D4/D9/D10 and
Ashtakavarga all 100%; Shadbala and three old-chart dashas differ (separate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
2026-11-04 11:55:22 +08:00
co-authored by Claude Opus 5.5
parent db2fc3fcfe
commit 74169e1bfc
4 changed files with 393 additions and 26 deletions
+298 -10
View File
@@ -3,7 +3,16 @@
"id": "smoke_beijing_1990_noon",
"label": "Smoke Beijing 1990 noon",
"category": "smoke",
"birth": {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 39.9042, "lon": 116.4074, "tz": 8},
"birth": {
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"lat": 39.9042,
"lon": 116.4074,
"tz": 8
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
@@ -11,7 +20,16 @@
"id": "smoke_newyork_1985_morning",
"label": "Smoke New York 1985 morning",
"category": "smoke",
"birth": {"year": 1985, "month": 7, "day": 13, "hour": 8, "minute": 30, "lat": 40.7128, "lon": -74.0060, "tz": -4},
"birth": {
"year": 1985,
"month": 7,
"day": 13,
"hour": 8,
"minute": 30,
"lat": 40.7128,
"lon": -74.006,
"tz": -4
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
@@ -19,7 +37,16 @@
"id": "smoke_london_1970_evening",
"label": "Smoke London 1970 evening",
"category": "smoke",
"birth": {"year": 1970, "month": 3, "day": 21, "hour": 18, "minute": 15, "lat": 51.5074, "lon": -0.1278, "tz": 0},
"birth": {
"year": 1970,
"month": 3,
"day": 21,
"hour": 18,
"minute": 15,
"lat": 51.5074,
"lon": -0.1278,
"tz": 0
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
@@ -27,7 +54,16 @@
"id": "smoke_delhi_2000_midnight",
"label": "Smoke Delhi 2000 midnight",
"category": "smoke",
"birth": {"year": 2000, "month": 12, "day": 25, "hour": 0, "minute": 5, "lat": 28.6139, "lon": 77.2090, "tz": 5.5},
"birth": {
"year": 2000,
"month": 12,
"day": 25,
"hour": 0,
"minute": 5,
"lat": 28.6139,
"lon": 77.209,
"tz": 5.5
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
@@ -35,7 +71,16 @@
"id": "smoke_sydney_1999_afternoon",
"label": "Smoke Sydney 1999 afternoon",
"category": "smoke",
"birth": {"year": 1999, "month": 9, "day": 9, "hour": 15, "minute": 45, "lat": -33.8688, "lon": 151.2093, "tz": 10},
"birth": {
"year": 1999,
"month": 9,
"day": 9,
"hour": 15,
"minute": 45,
"lat": -33.8688,
"lon": 151.2093,
"tz": 10
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
@@ -43,7 +88,16 @@
"id": "smoke_tokyo_1964_noon",
"label": "Smoke Tokyo 1964 noon",
"category": "smoke",
"birth": {"year": 1964, "month": 10, "day": 10, "hour": 12, "minute": 0, "lat": 35.6762, "lon": 139.6503, "tz": 9},
"birth": {
"year": 1964,
"month": 10,
"day": 10,
"hour": 12,
"minute": 0,
"lat": 35.6762,
"lon": 139.6503,
"tz": 9
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
@@ -51,7 +105,16 @@
"id": "smoke_cairo_1952_dawn",
"label": "Smoke Cairo 1952 dawn",
"category": "smoke",
"birth": {"year": 1952, "month": 7, "day": 23, "hour": 5, "minute": 20, "lat": 30.0444, "lon": 31.2357, "tz": 2},
"birth": {
"year": 1952,
"month": 7,
"day": 23,
"hour": 5,
"minute": 20,
"lat": 30.0444,
"lon": 31.2357,
"tz": 2
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
@@ -59,7 +122,16 @@
"id": "smoke_paris_1989_noon",
"label": "Smoke Paris 1989 noon",
"category": "smoke",
"birth": {"year": 1989, "month": 11, "day": 9, "hour": 12, "minute": 0, "lat": 48.8566, "lon": 2.3522, "tz": 1},
"birth": {
"year": 1989,
"month": 11,
"day": 9,
"hour": 12,
"minute": 0,
"lat": 48.8566,
"lon": 2.3522,
"tz": 1
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
@@ -67,7 +139,16 @@
"id": "smoke_losangeles_1995_night",
"label": "Smoke Los Angeles 1995 night",
"category": "smoke",
"birth": {"year": 1995, "month": 5, "day": 5, "hour": 23, "minute": 40, "lat": 34.0522, "lon": -118.2437, "tz": -7},
"birth": {
"year": 1995,
"month": 5,
"day": 5,
"hour": 23,
"minute": 40,
"lat": 34.0522,
"lon": -118.2437,
"tz": -7
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
@@ -75,8 +156,215 @@
"id": "smoke_sao_paulo_2004_morning",
"label": "Smoke Sao Paulo 2004 morning",
"category": "smoke",
"birth": {"year": 2004, "month": 2, "day": 29, "hour": 9, "minute": 10, "lat": -23.5558, "lon": -46.6396, "tz": -3},
"birth": {
"year": 2004,
"month": 2,
"day": 29,
"hour": 9,
"minute": 10,
"lat": -23.5558,
"lon": -46.6396,
"tz": -3
},
"today": "2026-06-03",
"privacy": "fictional_or_public_test"
},
{
"id": "steve_jobs",
"label": "Steve Jobs",
"category": "public_rodden_aa",
"birth": {
"year": 1955,
"month": 2,
"day": 24,
"hour": 19,
"minute": 15,
"lat": 37.7749,
"lon": -122.4194,
"tz": -8.0
},
"today": "2026-09-27",
"privacy": "public_rodden_aa",
"source": {
"url": "https://www.astro.com/adbvip/adbvip_02_24.htm?lang=r&nho2=250&nhor=1",
"rodden_rating": "AA",
"case_file": "references/real_case_calibration/minute_rectification_development_v1.json"
}
},
{
"id": "barack_obama",
"label": "Barack Obama",
"category": "public_rodden_aa",
"birth": {
"year": 1961,
"month": 8,
"day": 4,
"hour": 19,
"minute": 24,
"lat": 21.3069,
"lon": -157.8583,
"tz": -10.0
},
"today": "2026-09-27",
"privacy": "public_rodden_aa",
"source": {
"url": "https://www.astro.com/adbvip/adbvip_08_04.htm?lang=r&nho2=161&nhor=1",
"rodden_rating": "AA",
"case_file": "references/real_case_calibration/minute_rectification_holdout_v4.json"
}
},
{
"id": "elizabeth_taylor",
"label": "Elizabeth Taylor",
"category": "public_rodden_aa",
"birth": {
"year": 1932,
"month": 2,
"day": 27,
"hour": 2,
"minute": 30,
"lat": 51.5,
"lon": -0.1667,
"tz": 0.0
},
"today": "2026-09-27",
"privacy": "public_rodden_aa",
"source": {
"url": "https://www.astro.com/astro-databank/Taylor%2C_Elizabeth",
"rodden_rating": "AA",
"case_file": "references/real_case_calibration/minute_rectification_holdout_v4.json"
}
},
{
"id": "marilyn_monroe",
"label": "Marilyn Monroe",
"category": "public_rodden_aa",
"birth": {
"year": 1926,
"month": 6,
"day": 1,
"hour": 9,
"minute": 30,
"lat": 34.05,
"lon": -118.25,
"tz": -8.0
},
"today": "2026-09-27",
"privacy": "public_rodden_aa",
"source": {
"url": "https://www.astro.com/astro-databank/Monroe,_Marilyn",
"rodden_rating": "AA",
"case_file": "references/real_case_calibration/minute_rectification_holdout_v5.json"
}
},
{
"id": "judy_garland",
"label": "Judy Garland",
"category": "public_rodden_aa",
"birth": {
"year": 1922,
"month": 6,
"day": 10,
"hour": 6,
"minute": 0,
"lat": 47.2333,
"lon": -93.5333,
"tz": -6.0
},
"today": "2026-09-27",
"privacy": "public_rodden_aa",
"source": {
"url": "https://www.astro.com/astro-databank/Garland,_Judy",
"rodden_rating": "AA",
"case_file": "references/real_case_calibration/minute_rectification_holdout_v5.json"
}
},
{
"id": "edith_piaf",
"label": "Édith Piaf",
"category": "public_rodden_aa",
"birth": {
"year": 1915,
"month": 12,
"day": 19,
"hour": 5,
"minute": 0,
"lat": 48.8667,
"lon": 2.3333,
"tz": -0.0
},
"today": "2026-09-27",
"privacy": "public_rodden_aa",
"source": {
"url": "https://www.astro.com/astro-databank/Piaf,_Édith",
"rodden_rating": "AA",
"case_file": "references/real_case_calibration/minute_rectification_holdout_v5.json"
}
},
{
"id": "frida_kahlo",
"label": "Frida Kahlo",
"category": "public_rodden_aa",
"birth": {
"year": 1907,
"month": 7,
"day": 6,
"hour": 8,
"minute": 30,
"lat": 19.3333,
"lon": -99.1667,
"tz": -6.6167
},
"today": "2026-09-27",
"privacy": "public_rodden_aa",
"source": {
"url": "https://www.astro.com/astro-databank/Kahlo,_Frida",
"rodden_rating": "AA",
"case_file": "references/real_case_calibration/minute_rectification_holdout_v5.json"
}
},
{
"id": "george_w_bush",
"label": "George W. Bush",
"category": "public_rodden_aa",
"birth": {
"year": 1946,
"month": 7,
"day": 6,
"hour": 7,
"minute": 26,
"lat": 41.3,
"lon": -72.9333,
"tz": -4.0
},
"today": "2026-09-27",
"privacy": "public_rodden_aa",
"source": {
"url": "https://www.astro.com/astro-databank/Bush,_George_W.",
"rodden_rating": "AA",
"case_file": "references/real_case_calibration/minute_rectification_holdout_v5.json"
}
},
{
"id": "zinedine_zidane",
"label": "Zinedine Zidane",
"category": "public_rodden_aa",
"birth": {
"year": 1972,
"month": 6,
"day": 23,
"hour": 3,
"minute": 0,
"lat": 43.3,
"lon": 5.4,
"tz": 1.0
},
"today": "2026-09-27",
"privacy": "public_rodden_aa",
"source": {
"url": "https://www.astro.com/astro-databank/Zidane,_Zinedine",
"rodden_rating": "AA",
"case_file": "references/real_case_calibration/minute_rectification_holdout_v5.json"
}
}
]
@@ -101,16 +101,16 @@ def tuple_to_date(t):
return f'{int(y):04d}-{int(m):02d}-{int(d):02d}'
def build_pyjhora_sample(sample, *, node_mode='mean'):
def build_pyjhora_sample(sample, *, node_mode='mean', ayanamsa='lahiri'):
swe = patch_swisseph()
from jhora import utils, const
from jhora.panchanga import drik
from jhora.horoscope.chart import ashtakavarga, charts, strength
from jhora.horoscope.dhasa.graha import vimsottari
# Align benchmark口径: Lahiri + mean sidereal year. PyJHora default is TRUE_PUSHYA.
const._DEFAULT_AYANAMSA_MODE = 'LAHIRI'
drik.set_ayanamsa_mode('LAHIRI')
# Align benchmark口径: the requested ayanamsa + mean sidereal year. PyJHora default is TRUE_PUSHYA.
const._DEFAULT_AYANAMSA_MODE = ayanamsa.upper()
drik.set_ayanamsa_mode(ayanamsa.upper())
const.set_node_mode(node_mode == 'true')
drik.set_planet_list(set_rahu_ketu_as_true_nodes=(node_mode == 'true'))
try:
@@ -172,12 +172,12 @@ def build_pyjhora_sample(sample, *, node_mode='mean'):
dasha['error'] = f'{type(exc).__name__}: {exc}'
return {
'settings': {'ayanamsa': 'lahiri', 'node_mode': node_mode},
'settings': {'ayanamsa': ayanamsa, 'node_mode': node_mode},
'sample_id': sample['id'],
'engine': 'PyJHora_4_8_6_lahiri_patched',
'engine': f'PyJHora_4_8_6_{ayanamsa}_patched',
'parameters': {
'zodiac': 'sidereal',
'ayanamsa': 'LAHIRI',
'ayanamsa': ayanamsa.upper(),
'd9_method': 'PyJHora divisional_chart chart_method=1',
'd10_method': 'PyJHora divisional_chart chart_method=1',
'd2_method': 'PyJHora hora_chart chart_method=2 traditional_parasara',
@@ -301,7 +301,7 @@ def compare_one(sample_id, local, pyjhora):
return rows
def write_report(samples, rows, *, generated_at=None):
def write_report(samples, rows, *, generated_at=None, ayanamsa='lahiri'):
total = len(rows)
counts = {}
by_section = {}
@@ -327,8 +327,8 @@ def write_report(samples, rows, *, generated_at=None):
lines.append('')
lines.append('- 外部引擎:PyJHora 4.8.6。')
lines.append('- 用途:第二个独立 Jyotish 开源项目对标,重点验证 D1、D9、D10,并初探 Vimshottari。')
lines.append('- 样本:10个公开/虚构 smoke case,不包含用户个人资料。')
lines.append('- 口径:强制 Lahiri;PyJHora 默认 TRUE_PUSHYA,因此本轮显式切换到 LAHIRI。')
lines.append(f'- 样本:{len(samples)} 个公开 / 虚构样本,不包含用户个人资料。')
lines.append(f'- 口径:两边都用 {ayanamsa.capitalize()};PyJHora 默认 TRUE_PUSHYA,因此本轮显式切换到 {ayanamsa.upper()}。')
lines.append('- 兼容处理:PyJHora 4.8.6 与本机 pyswisseph API 存在关键字参数/常量兼容问题,本脚本只在 benchmark 进程内 monkeypatch,不改 PyJHora 源码,不把 AGPL 代码并入 skill。')
lines.append('')
lines.append('## 2. 总体结果')
@@ -373,6 +373,18 @@ def write_report(samples, rows, *, generated_at=None):
return '\n'.join(lines)
def baseline_settings_error(local, *, ayanamsa, node_mode):
"""Why a local baseline cannot be compared under these settings, or None."""
params = local.get('parameters') or {}
built = str(params.get('ayanamsa') or '').lower()
if built != ayanamsa:
return f'local baseline ayanamsa is {built or "unrecorded"}, comparison asked for {ayanamsa}'
built_node = str(params.get('node_mode') or '').lower()
if built_node and built_node != node_mode:
return f'local baseline node mode is {built_node}, comparison asked for {node_mode}'
return None
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.')
@@ -380,6 +392,7 @@ def main(argv=None):
parser.add_argument('--refresh-local', action='store_true', help='Explicitly rebuild selected local canonical baselines.')
parser.add_argument('--node-mode', choices=['mean', 'true'], default='mean', help='Match the node convention before comparing.')
parser.add_argument('--output-prefix', default='', help='Optional filename prefix for resumable batch artifacts.')
parser.add_argument('--ayanamsa', choices=['lahiri', 'raman'], default='lahiri', help='Ayanamsa for both engines; the local baseline must have been built with the same one.')
args = parser.parse_args(argv)
PYJHORA_OUT.mkdir(parents=True, exist_ok=True)
samples = json.loads(DATA.read_text())
@@ -394,7 +407,7 @@ def main(argv=None):
local_path = LOCAL_CANON / f"{sample['id']}.canonical.json"
if (not local_path.exists() and args.build_local) or args.refresh_local:
from run_skill_baseline import run_sample
baseline = run_sample(sample)
baseline = run_sample(sample, ayanamsa=args.ayanamsa, node_mode=args.node_mode)
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():
@@ -402,9 +415,12 @@ def main(argv=None):
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_path.read_text())
settings_error = baseline_settings_error(local, ayanamsa=args.ayanamsa, node_mode=args.node_mode)
if settings_error:
parser.error(f'{sample["id"]}: {settings_error}; rebuild with --refresh-local')
pyjhora = build_pyjhora_sample(sample, node_mode=args.node_mode, ayanamsa=args.ayanamsa)
(PYJHORA_OUT / f"{sample['id']}.pyjhora.json").write_text(json.dumps(pyjhora, ensure_ascii=False, indent=2))
all_rows.extend(compare_one(sample['id'], local, pyjhora))
prefix = f"{args.output_prefix}_" if args.output_prefix else ''
@@ -415,7 +431,7 @@ def main(argv=None):
writer.writerows(all_rows)
report = OUT / f'{prefix}jyotish_benchmark_round3_pyjhora_compare.md'
report.write_text(write_report(samples, all_rows))
report.write_text(write_report(samples, all_rows, ayanamsa=args.ayanamsa))
print(json.dumps({'report': str(report), 'matrix': str(matrix), 'samples': len(samples), 'fields': len(all_rows)}, ensure_ascii=False, indent=2))
@@ -30,7 +30,15 @@ def safe_get(obj, *keys, default=None):
return cur
def run_sample(sample):
# The engine's default ayanamsa changed from Lahiri to Raman after the June
# rounds; a baseline built on the default then compared against Lahiri PyJHora
# and every longitude was off by 1.446 degrees (2026-11-04). The settings are
# passed explicitly and recorded from the engine's own output.
DEFAULT_AYANAMSA = 'lahiri'
DEFAULT_NODE_MODE = 'mean'
def run_sample(sample, *, ayanamsa=DEFAULT_AYANAMSA, node_mode=DEFAULT_NODE_MODE):
RAW.mkdir(parents=True, exist_ok=True)
CANON.mkdir(parents=True, exist_ok=True)
birth = sample['birth']
@@ -45,6 +53,8 @@ def run_sample(sample):
'--lon', str(birth['lon']),
'--tz', str(birth['tz']),
'--today', sample.get('today', '2026-06-03'),
'--ayanamsa', ayanamsa,
'--node-mode', node_mode,
]
proc = subprocess.run(cmd, text=True, capture_output=True)
raw_path = RAW / f"{sample['id']}.json"
@@ -93,7 +103,8 @@ def canonicalize(sample, data):
'engine': 'local_jyotish_skill_v6_0_4',
'parameters': {
'zodiac': 'sidereal',
'ayanamsa': 'lahiri_assumed_by_skill',
'ayanamsa': safe_get(data, 'birth_info', 'ayanamsa_name') or safe_get(chart, 'birth_info', 'ayanamsa_name'),
'node_mode': safe_get(data, 'birth_info', 'node_mode') or safe_get(chart, 'birth_info', 'node_mode'),
'house': 'whole_sign_for_planet_house',
'today': sample.get('today'),
},
+52
View File
@@ -55,3 +55,55 @@ def test_pyjhora_comparison_includes_d2_d4_bav_sav_and_shadbala_rows():
sections = {row["section"] for row in compare_one("fixture", local, pyjhora)}
assert {"D2", "D4", "Ashtakavarga_BAV", "Ashtakavarga_SAV", "Shadbala"} <= sections
def test_baseline_records_the_engine_ayanamsa_and_the_compare_refuses_a_mismatch():
# 2026-11-04: the engine default moved from Lahiri to Raman while the
# baseline still claimed "lahiri_assumed_by_skill"; the comparison then
# reported 59% instead of ~95% because every longitude was 1.446 deg off.
from benchmarks.jyotish.scripts.run_pyjhora_compare import baseline_settings_error
from benchmarks.jyotish.scripts.run_skill_baseline import canonicalize
data = {"birth_info": {"ayanamsa_name": "raman", "node_mode": "mean"}, "modules": {"chart": {}}}
canon = canonicalize({"id": "fixture", "label": "x", "category": "smoke", "birth": {}}, data)
assert canon["parameters"]["ayanamsa"] == "raman"
assert canon["parameters"]["node_mode"] == "mean"
assert baseline_settings_error(canon, ayanamsa="raman", node_mode="mean") is None
assert "lahiri" in baseline_settings_error(canon, ayanamsa="lahiri", node_mode="mean")
assert "unrecorded" in baseline_settings_error({"parameters": {}}, ayanamsa="lahiri", node_mode="mean")
def test_baseline_passes_ayanamsa_and_node_mode_to_the_engine(monkeypatch, tmp_path):
from benchmarks.jyotish.scripts import run_skill_baseline as rsb
seen = {}
class Done:
returncode = 1
stdout = ""
stderr = "stub"
def fake_run(cmd, **kwargs):
seen["cmd"] = cmd
return Done()
monkeypatch.setattr(rsb, "RAW", tmp_path / "raw")
monkeypatch.setattr(rsb, "CANON", tmp_path / "canon")
monkeypatch.setattr(rsb.subprocess, "run", fake_run)
sample = {"id": "fixture", "birth": {"year": 2000, "month": 1, "day": 1, "hour": 0, "minute": 0, "lat": 0, "lon": 0, "tz": 0}}
rsb.run_sample(sample, ayanamsa="raman", node_mode="true")
cmd = seen["cmd"]
assert cmd[cmd.index("--ayanamsa") + 1] == "raman"
assert cmd[cmd.index("--node-mode") + 1] == "true"
rsb.run_sample(sample)
assert seen["cmd"][seen["cmd"].index("--ayanamsa") + 1] == "lahiri"
def test_fixed_samples_include_the_public_rodden_aa_backtest_figures():
import json
samples = json.loads((ROOT / "benchmarks" / "jyotish" / "data" / "benchmark_samples.json").read_text())
ids = {sample["id"] for sample in samples}
for figure in ["steve_jobs", "barack_obama", "elizabeth_taylor", "marilyn_monroe", "judy_garland", "edith_piaf", "frida_kahlo", "george_w_bush", "zinedine_zidane"]:
assert figure in ids
assert all(sample.get("privacy") in {"fictional_or_public_test", "public_rodden_aa"} for sample in samples)