merge: data audit wave 2 H (Chara co-lord/antardasha, gulika night, natal vara, BUG-1565..1569)

Freeze contract, FREEZE/REPORT constants and gate tests keep the G state
here; the merged tree is re-frozen once as audit_wave2_2026_11_04 at the end.
H's audit_h records are kept byte-for-byte as history.

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 17:05:15 +08:00
co-authored by Claude Opus 5.5
22 changed files with 45771 additions and 603 deletions
+8
View File
@@ -6,6 +6,14 @@
- 星盘页与对话分盘谱的 D30、D40,以及完整解读里的 D30 专项(`trimshamsa_d30`)也按 BPHS 改正(BUG-1561、BUG-1562)。完整解读分盘表里的 D30 偶数星座仍是旧表:它进生时校正打分,改了会动校正分数,待产品决定(BUG-1560)。
- 这几张分盘里行星和上升的星座、宫位会变;D3、D7、D9、D10、D12、D24、D60 不变;对话数据卡上的 D9 / D10 / D12、Chara Karaka 与 Arudha 不变;生时校正 20 条密封案例和 900 个报时窗口回放逐条不变。
## 2026-11-04 — 数据核对 H:Chara Dasha 双主星与小运、时段点夜段、日出前出生的星期
- K.N. Rao Chara Dasha 的天蝎 / 水瓶宫主以前与盘面无关(天蝎总是计都、水瓶总是土星),现在一主在本宫取另一主,两主都在外按所在星座比强弱;天蝎、水瓶两段的年数与其后各段起止可能变化(例:布什公开盘天蝎 13 → 9 年,乔布斯水瓶 5 → 1 年),与 PyJHora 的年数一致率 93% → 99%(BUG-1566)。
- K.N. Rao Chara 的小运按 K.N. Rao 原书:每个大运按自己的顺逆,从下一星座起,本运星座排最后;以前除第一运外都不对(BUG-1567)。
- 星盘页特殊点层的 Maandi、Kaala、Mrityu、Artha Praharaka、Yama Ghantaka:周三到周六夜里出生的盘以前落在错的时段,已改正;Gulika 不变(BUG-1565)。
- 完整解读 / 报告里的出生星期(vara)和时主:日出前出生按前一天算(BUG-1568)。
- 对照脚本两边都显式传岁差(BUG-1569)。Narayana、行运、日出与外部引擎核对后未发现缺陷,流派差异列给占星师。生时校正结果不变。Skill 版本不变;没有数据库变更。
## 2026-11-04 — Shadbala:月亮的盈亏力不再重复计入
- 六力里月亮的盈亏力(Paksha)以前被算了四倍(时力里乘 2,动力又取乘 2 后的值),现按 BPHS(Santhanam 译本)27.10–11 + 27.18:时力里不乘 2,动力等于盈亏力,合计两倍。月亮的六力总分、Ishta / Kashta 和「最强星」可能变化(例:奥巴马公开盘最强星由月亮变为太阳),其他六颗星不变;结果里 `method_variants.moon_paksha` 写明口径(BUG-1557)。生时校正结果不变。Skill 版本不变;没有数据库变更。
@@ -24,7 +24,7 @@ OUT = ROOT / 'outputs'
CANON = OUT / 'canonical'
REPORT = OUT / 'jyotish_benchmark_round7_chara_dasha_compare.md'
MATRIX = OUT / 'chara_dasha_comparison_matrix.csv'
SKILL_SCRIPTS = Path(__file__).resolve().parents[3] / 'scripts' # repo scripts/ (parents[2] was benchmarks/)
SKILL_SCRIPTS = Path(__file__).resolve().parents[3] / 'scripts'
PYJHORA_SITE = Path(__import__('os').environ.get('PYJHORA_SITE', ''))
PYJHORA_COMPAT = ROOT / 'scripts/pyjhora_compat'
@@ -81,7 +81,8 @@ def pyjhora_chara(sample, *, ayanamsa='lahiri', node_mode='mean'):
from jhora import const
from jhora.panchanga import drik
from jhora.horoscope.dhasa.raasi import chara
# Same ayanamsa and node mode as the local baseline (pre_work_error_ledger 2026-11-04).
# Both sides use the requested ayanamsa and node mode; the engine default
# is Raman and PyJHora's is TRUE_PUSHYA (2026-11-04 audit H).
const._DEFAULT_AYANAMSA_MODE = ayanamsa.upper()
drik.set_ayanamsa_mode(ayanamsa.upper())
const.set_node_mode(node_mode == 'true')
@@ -124,7 +125,7 @@ def summarize(rows, prefix=None):
return total, match, total - match, match / total if total else 0.0
def write_report(rows, per_sample):
def write_report(rows, per_sample, *, ayanamsa='lahiri', sample_count=None):
lines = []
lines.append('# Jyotish benchmark 第七轮:Chara Dasha / Jaimini 时间线对标')
lines.append('')
@@ -134,7 +135,8 @@ def write_report(rows, per_sample):
lines.append('')
lines.append('- 验证当前 skill `scripts/jaimini.py` 的 Chara Dasha 是否可作为正式计算模块使用。')
lines.append('- 对标对象:PyJHora `raasi/chara.py` 的 KN Rao method(PyJHora 默认 `CHARA_TYPE_DEFAULT = KN_RAO`)。')
lines.append('- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。')
lines.append(f'- 样本:{sample_count if sample_count is not None else len(per_sample)} 个公开 / 虚构样本,不包含用户个人资料。')
lines.append(f'- 口径:两边都用 {ayanamsa.capitalize()}、平均交点;本地行星位置取自同口径的 canonical 基线。')
lines.append('')
lines.append('## 2. 总体结果')
lines.append('')
@@ -166,9 +168,10 @@ def write_report(rows, per_sample):
def main(argv=None):
parser = argparse.ArgumentParser(description='Compare the local Chara Dasha against PyJHora KN Rao.')
parser.add_argument('--ayanamsa', choices=['lahiri', 'raman'], default='lahiri', help='Ayanamsa for PyJHora; the local baseline must have been built with the same one.')
parser.add_argument('--node-mode', choices=['mean', 'true'], default='mean', help='Node mode for PyJHora; must match the local baseline.')
parser = argparse.ArgumentParser(description='Compare the local K.N. Rao Chara Dasha against PyJHora.')
parser.add_argument('--ayanamsa', choices=['lahiri', 'raman'], default='lahiri',
help='Ayanamsa for both sides; the canonical baseline must have been built with the same one.')
parser.add_argument('--node-mode', choices=['mean', 'true'], default='mean')
args = parser.parse_args(argv)
sys.path.insert(0, str(Path(__file__).resolve().parent))
from run_pyjhora_compare import baseline_settings_error
@@ -180,7 +183,7 @@ def main(argv=None):
c = canon(sid)
settings_error = baseline_settings_error(c, ayanamsa=args.ayanamsa, node_mode=args.node_mode)
if settings_error:
parser.error(f'{sid}: {settings_error}; rebuild with run_skill_baseline.py')
parser.error(f'{sid}: {settings_error}; rebuild with run_skill_baseline.py --ayanamsa {args.ayanamsa}')
local = local_chara(sample, c)
pyj = pyjhora_chara(sample, ayanamsa=args.ayanamsa, node_mode=args.node_mode)
sign_match = 0
@@ -204,7 +207,7 @@ def main(argv=None):
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
REPORT.write_text(write_report(rows, per_sample))
REPORT.write_text(write_report(rows, per_sample, ayanamsa=args.ayanamsa, sample_count=len(samples)))
print(json.dumps({'report': str(REPORT), 'matrix': str(MATRIX), 'rows': len(rows)}, ensure_ascii=False, indent=2))
@@ -8,6 +8,7 @@ Chara Dasha KN Rao Benchmark v1.1
依赖: pip install jhora numpy
"""
import argparse
import sys, json, os
# 添加scripts路径
@@ -44,7 +45,15 @@ PLANET_NAMES = {
}
def run_benchmark():
def run_benchmark(ayanamsa='lahiri', node_mode='mean'):
# Shared positions come from PyJHora, so the ayanamsa does not change the
# algorithm comparison, but it decides which sign each planet is in; set
# it explicitly instead of inheriting PyJHora's TRUE_PUSHYA default
# (2026-11-04 audit H).
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'))
total_sign = 0; sign_match = 0
total_dur = 0; dur_match = 0
detail_lines = []
@@ -154,6 +163,8 @@ def run_benchmark():
result = {
"version": "v6.1.11",
"benchmark": "chara_dasha_kn_rao",
"ayanamsa": ayanamsa,
"node_mode": node_mode,
"total_sign_match": sign_match,
"total_dur_match": dur_match,
"total_sign": total_sign,
@@ -169,4 +180,8 @@ def run_benchmark():
if __name__ == "__main__":
run_benchmark()
_parser = argparse.ArgumentParser(description="K.N. Rao Chara Dasha: local helpers vs PyJHora on shared PyJHora positions.")
_parser.add_argument("--ayanamsa", choices=["lahiri", "raman"], default="lahiri")
_parser.add_argument("--node-mode", choices=["mean", "true"], default="mean")
_args = _parser.parse_args()
run_benchmark(_args.ayanamsa, _args.node_mode)
@@ -8,6 +8,7 @@
Compares local Jyotish skill canonical output against Swiss Ephemeris Mean/True Node
and PyJHora's default rasi_chart node mode. Samples are fictional/public smoke cases.
"""
import argparse
import csv
import json
import math
@@ -33,6 +34,9 @@ NAKSHATRAS = [
'Mula','Purva Ashadha','Uttara Ashadha','Shravana','Dhanishta','Shatabhisha','Purva Bhadrapada','Uttara Bhadrapada','Revati'
]
PYJHORA_PLANET_ID = {7: 'Rahu', 8: 'Ketu'}
# Both sides get the ayanamsa explicitly; the canonical baseline must have been
# built with the same one (2026-11-04 audit H).
SIDEREAL_MODES = {'lahiri': swe.SIDM_LAHIRI, 'raman': swe.SIDM_RAMAN}
def norm(deg):
@@ -73,9 +77,9 @@ def point_from_lon(lon):
}
def swiss_nodes(sample, node_pid):
def swiss_nodes(sample, node_pid, ayanamsa='lahiri'):
jd = julian_day_utc(sample['birth'])
swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0)
swe.set_sid_mode(SIDEREAL_MODES[ayanamsa], 0, 0)
flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_SPEED
res, _ = swe.calc_ut(jd, node_pid, flags)
rahu = point_from_lon(res[0])
@@ -104,7 +108,7 @@ def patch_swisseph_for_pyjhora():
return swe
def pyjhora_default_nodes(sample):
def pyjhora_default_nodes(sample, ayanamsa='lahiri'):
# PyJHora is used only as an external benchmark. Do not vendor/copy its code into the skill.
if str(PYJHORA_COMPAT) not in sys.path:
sys.path.insert(0, str(PYJHORA_COMPAT))
@@ -114,8 +118,8 @@ def pyjhora_default_nodes(sample):
from jhora import utils, const
from jhora.panchanga import drik
from jhora.horoscope.chart import charts
const._DEFAULT_AYANAMSA_MODE = 'LAHIRI'
drik.set_ayanamsa_mode('LAHIRI')
const._DEFAULT_AYANAMSA_MODE = ayanamsa.upper()
drik.set_ayanamsa_mode(ayanamsa.upper())
# Note: charts.rasi_chart -> drik.dhasavarga() defaults to set_rahu_ketu_as_true_nodes=True.
b = sample['birth']
jd = utils.julian_day_number((b['year'], b['month'], b['day']), (b['hour'], b['minute'], 0))
@@ -186,7 +190,7 @@ def write_report(rows):
lines.append('')
lines.append('- 解释第三轮 PyJHora 对比中 Rahu/Ketu 大量差异的根因。')
lines.append('- 对比当前 skill canonical baseline 与 Swiss Ephemeris Mean Node、Swiss Ephemeris True Node、PyJHora rasi_chart 默认输出。')
lines.append('- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。')
lines.append('- 样本:公开 / 虚构样本,不包含用户个人资料;岁差两边显式传入。')
lines.append('')
lines.append('## 2. 总体结果')
lines.append('')
@@ -223,16 +227,26 @@ def write_report(rows):
return '\n'.join(lines)
def main():
def main(argv=None):
parser = argparse.ArgumentParser(description='Arbitrate the local Rahu / Ketu against Swiss mean / true node and PyJHora.')
parser.add_argument('--ayanamsa', choices=sorted(SIDEREAL_MODES), default='lahiri',
help='Ayanamsa for every side; the canonical baseline must have been built with the same one.')
args = parser.parse_args(argv)
sys.path.insert(0, str(Path(__file__).resolve().parent))
from run_pyjhora_compare import baseline_settings_error
samples = json.loads(DATA.read_text())
rows = []
for sample in samples:
sample_id = sample['id']
canonical = json.loads((CANON / f'{sample_id}.canonical.json').read_text())
settings_error = baseline_settings_error(canonical, ayanamsa=args.ayanamsa, node_mode='mean')
if settings_error:
parser.error(f'{sample_id}: {settings_error}; rebuild with run_skill_baseline.py --ayanamsa {args.ayanamsa}')
local = local_nodes(sample_id)
targets = {
'swiss_mean_node': swiss_nodes(sample, swe.MEAN_NODE),
'swiss_true_node': swiss_nodes(sample, swe.TRUE_NODE),
'pyjhora_default_rasi': pyjhora_default_nodes(sample),
'swiss_mean_node': swiss_nodes(sample, swe.MEAN_NODE, args.ayanamsa),
'swiss_true_node': swiss_nodes(sample, swe.TRUE_NODE, args.ayanamsa),
'pyjhora_default_rasi': pyjhora_default_nodes(sample, args.ayanamsa),
}
for target_name, target_nodes in targets.items():
for body in ['Rahu', 'Ketu']:
@@ -235,12 +235,17 @@ def write_report(samples, results):
return report
def main():
def main(argv=None):
import argparse
parser = argparse.ArgumentParser(description='Build the local canonical baselines for the benchmark samples.')
parser.add_argument('--ayanamsa', choices=['lahiri', 'raman'], default=DEFAULT_AYANAMSA)
parser.add_argument('--node-mode', choices=['mean', 'true'], default=DEFAULT_NODE_MODE)
args = parser.parse_args(argv)
OUT.mkdir(parents=True, exist_ok=True)
RAW.mkdir(parents=True, exist_ok=True)
CANON.mkdir(parents=True, exist_ok=True)
samples = json.loads(DATA.read_text())
results = [run_sample(sample) for sample in samples]
results = [run_sample(sample, ayanamsa=args.ayanamsa, node_mode=args.node_mode) for sample in samples]
summary = {'total': len(results), 'ok': sum(1 for r in results if r.get('ok')), 'results': [{'id': r['id'], 'ok': r.get('ok'), 'error': r.get('error')} for r in results]}
(OUT / 'run_summary.json').write_text(json.dumps(summary, ensure_ascii=False, indent=2))
rows = flatten_rows(results)
@@ -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 subprocess
@@ -13,7 +14,7 @@ from pathlib import Path
import swisseph as swe
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(sys.executable)
DATA = ROOT / 'data/benchmark_samples.json'
OUT = ROOT / 'outputs'
@@ -31,6 +32,10 @@ PLANETS = {
'Rahu': swe.MEAN_NODE,
}
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']
# Both sides get the ayanamsa explicitly: the engine default is Raman, and this
# script used to compare an engine-default full reading with Lahiri Swiss
# positions (2026-11-04 audit H).
SIDEREAL_MODES = {'lahiri': swe.SIDM_LAHIRI, 'raman': swe.SIDM_RAMAN}
def normalize(deg):
@@ -53,9 +58,9 @@ def julian_day_for_transit(date_str, tz):
return swe.julday(utc_dt.year, utc_dt.month, utc_dt.day, hour, swe.GREG_CAL)
def calc_swiss_transit(date_str, tz):
def calc_swiss_transit(date_str, tz, ayanamsa='lahiri'):
jd = julian_day_for_transit(date_str, tz)
swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0)
swe.set_sid_mode(SIDEREAL_MODES[ayanamsa], 0, 0)
flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_SPEED
planets = {}
for name, pid in PLANETS.items():
@@ -75,10 +80,10 @@ def calc_swiss_transit(date_str, tz):
'degree_in_sign': round(degree_in_sign(ketu_lon), 6),
'retrograde': planets['Rahu']['retrograde'],
}
return {'julian_day_ut': jd, 'planets': planets, 'parameters': {'ayanamsa': 'Lahiri', 'node': 'Mean Node'}}
return {'julian_day_ut': jd, 'planets': planets, 'parameters': {'ayanamsa': ayanamsa, 'node': 'Mean Node'}}
def run_full_reading(sample):
def run_full_reading(sample, ayanamsa='lahiri'):
birth = sample['birth']
transit_date = sample.get('today', '2026-06-03')
cmd = [
@@ -94,6 +99,7 @@ def run_full_reading(sample):
'--today', transit_date,
'--transit-date', transit_date,
'--node-mode', 'mean',
'--ayanamsa', ayanamsa,
]
proc = subprocess.run(cmd, text=True, capture_output=True)
raw_path = RAW / f"{sample['id']}.transit_full_reading.json"
@@ -105,13 +111,16 @@ def run_full_reading(sample):
return data
def compare_sample(sample):
def compare_sample(sample, ayanamsa='lahiri'):
transit_date = sample.get('today', '2026-06-03')
data = run_full_reading(sample)
data = run_full_reading(sample, ayanamsa)
reported = str((data.get('birth_info') or {}).get('ayanamsa_name') or '').lower()
if reported and reported != ayanamsa:
raise RuntimeError(f"{sample['id']}: engine reported ayanamsa {reported}, comparison asked for {ayanamsa}")
modules = data.get('modules', {})
local_transit = modules.get('transit_positions', {})
local_multi = modules.get('transit_multi_reference', {})
swiss = calc_swiss_transit(transit_date, sample['birth']['tz'])
swiss = calc_swiss_transit(transit_date, sample['birth']['tz'], ayanamsa)
(TRANSIT_OUT / f"{sample['id']}.swiss_transit.json").write_text(json.dumps(swiss, ensure_ascii=False, indent=2))
rows = []
@@ -184,7 +193,7 @@ def compare_sample(sample):
return rows
def write_report(rows):
def write_report(rows, *, ayanamsa='lahiri', sample_count=None):
total = len(rows)
matches = sum(1 for r in rows if r['status'] == 'match')
mismatches = [r for r in rows if r['status'] == 'mismatch']
@@ -202,8 +211,8 @@ def write_report(rows):
lines.append('## 1. 范围')
lines.append('')
lines.append('- 对比对象:full-reading.modules.transit_positions / transit_multi_reference vs 直接调用 Swiss Ephemeris。')
lines.append('- 样本:10个公开/虚构 smoke case,不包含真实用户个人资料。')
lines.append('- 配置:Sidereal Lahiri,Mean Node,transit date 使用样本 today 字段。')
lines.append(f'- 样本:{sample_count} 个公开 / 虚构样本,不包含真实用户个人资料。')
lines.append(f'- 配置:Sidereal {ayanamsa.capitalize()}(两边显式传入),Mean Node,transit date 使用样本 today 字段。')
lines.append('- 重点:确认 full-reading 的多参考点 Transit 不再使用 natal positions fallback,而是使用真实过境行星位置。')
lines.append('')
lines.append('## 2. 总体结果')
@@ -240,20 +249,24 @@ def write_report(rows):
return report
def main():
def main(argv=None):
parser = argparse.ArgumentParser(description='Compare full-reading transit positions with Swiss Ephemeris.')
parser.add_argument('--ayanamsa', choices=sorted(SIDEREAL_MODES), default='lahiri',
help='Ayanamsa passed to both the engine and the Swiss Ephemeris side.')
args = parser.parse_args(argv)
OUT.mkdir(parents=True, exist_ok=True)
RAW.mkdir(parents=True, exist_ok=True)
TRANSIT_OUT.mkdir(parents=True, exist_ok=True)
samples = json.loads(DATA.read_text())
all_rows = []
for sample in samples:
all_rows.extend(compare_sample(sample))
all_rows.extend(compare_sample(sample, args.ayanamsa))
csv_path = OUT / 'transit_true_comparison_matrix.csv'
with csv_path.open('w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=['sample_id','body','field','local_skill','swiss_direct','delta','status'])
writer.writeheader()
writer.writerows(all_rows)
report = write_report(all_rows)
report = write_report(all_rows, ayanamsa=args.ayanamsa, sample_count=len(samples))
print(json.dumps({
'rows': len(all_rows),
'matches': sum(1 for r in all_rows if r['status'] == 'match'),
+77
View File
@@ -21410,3 +21410,80 @@
- 关联记录:BUG-1560、BUG-1561。
- 复发自:无
- 修复版本:分支 `codex/audit-vargas-20261104`(未推送)。
## BUG-1565 | 时段点夜段分配表周三到周六整体错一格:Maandi 和 Gulika 不在同一个土星段,Kaala 等四点落在别的星段
- 状态:resolved(引擎 + 回归测试;未部署)
- 首次发现 / 最近更新:2026-11-04(数据核对 H:19 张对照盘逐张用 Swiss Ephemeris 独立重算八分段)/ 2026-11-04
- 影响面:`scripts/gulika.py` `NIGHT_RULERS`,经 `_temporal_upagraha` 影响 `calculate_upagrahas` 的 Maandi、Kaala、Mrityu、Artha_Praharaka、Yama_Ghantaka:星盘页特殊点层(`chart_view_points/api.py`)、完整解读的 upagraha 包(`_build_natal_foundation_modules`)。Gulika 本身走 `SATURN_PART_START`,本来就对,不受影响。
- 现象:19 张盘里 7 张夜生盘(乔布斯周四夜、奥巴马 / 泰勒 / 洛杉矶周五夜、琵雅芙 / 伦敦周六夜、齐达内周四夜)这五个点全部偏一段(几度到几十度);例:乔布斯(Raman)Maandi 射手 26.93°,应为射手 3.35°,与 Gulika(天蝎 22.80°,同一土星段的起点)不在同一段。
- 触发条件:出生在周三、周四、周五、周六的夜段(日落到次日日出;按开启该段的日落所在日期算星期)。
- 根因:夜段八分的第一段应由「当天星期主数起第五位」的行星主管(周六夜从水星起),表里周三到周六四行都从前一位起(周六夜从火星起)。白天表与 `SATURN_PART_START` 是对的,夜段表手写时错位,两张表没有互相校验。
- 修复:改正 `NIGHT_RULERS` 的周三到周六四行,与 PyJHora `const.night_rulers` 同表;注释写明规则。
- 验证:`tests/test_audit_h_dasha_sky_20261104.py`:七天白天 / 夜段第一段与「星期主 / 第五位」一致、土星所在段与 `SATURN_PART_START` 一致;乔布斯、泰勒、伦敦三张周四到周六夜生盘五个时段点与独立 Swiss 计算差 ≤ 0.01°,Maandi 的段位 = Gulika 段位 + 1/16。修复后 19 张盘 × 2 种岁差 114/114 与独立计算一致(修复前 79/114)。`test_gulika*.py` 通过。
- 未改(口径,列给占星师):PyJHora 的八段循环把「无主段」放在土星段之后,BPHS 读法是第八段无主;Gulika / Maandi 两种读法相同,Kaala 等四点在 114 项里有 42 项不同。PyJHora 4.8.6 `upagraha_longitude` 夜段用次日日出作起点、以「日落与次日日出钟点差」作夜长,夜生盘 Gulika 与本仓差 128–177°,夜段不能作对照。
- 防复发:上述测试锁两张表互相一致。
- 关联记录:BUG-1388(夜段终点用真实次日日出)、BUG-513。
- 复发自:无
- 修复版本:分支 `codex/audit-dasha-sky-20261104`(未推送)。
## BUG-1566 | K.N. Rao Chara Dasha 天蝎 / 水瓶的宫主一律取计都 / 土星:按大运星座本身比尊贵,没有「一主在本宫取另一主」
- 状态:resolved(引擎 + 回归测试;未部署)
- 首次发现 / 最近更新:2026-11-04(数据核对 H:19 张盘 × Raman / Lahiri 与 PyJHora 4.8.6 `chara` KN_RAO 逐段对照)/ 2026-11-04
- 影响面:`scripts/jaimini.py` `_resolve_chara_dasha_lord`、`_jaimini_stronger_planet`(新增 `_co_lord_strength`、`_rasi_drishti`);连带 `calc_chara_dasha` / `calc_chara_dasha_with_antardasha` 天蝎、水瓶两段的宫主、年数与其后各段起止:咨询 `modules.chara_dasha`(数据卡 Chara 当前运)、`/api/dasha/chara` 与星盘页 Chara 宫主标注(issue #163)、完整解读 Jaimini、报告 Rashi 大运章 Chara 表。Narayana(两条时间轴)不用这两个函数,不受影响。
- 现象:19 张盘每种岁差 228 段里 16 段年数与 PyJHora 不同,全部在天蝎、水瓶。布什公开盘计都在天蝎、火星在狮子,天蝎给 13 年(PyJHora 9);泰勒水瓶 1 年(PyJHora 11);乔布斯水瓶 5 年(PyJHora 1)。
- 触发条件:盘中有天蝎或水瓶大运(每张盘都有),且计都 / 土星不是按规则该取的那一颗。
- 根因:`_jaimini_stronger_planet` 用大运星座(天蝎 / 水瓶)本身判断两颗主星的尊贵,与它们实际所在星座无关:天蝎里计都「入旺」永远胜火星,水瓶里土星「本宫」永远胜罗睺,结果与盘面无关;也没有「一主在本宫、另一主在外,取在外者」这条(K.N. Rao《Predicting through Jaimini's Chara Dasha》;Rath《Narayana Dasa》p.43 (c);PyJHora basic rule;`references/prashna-complete-guide.md` 双主星第 1 条)。函数注释说「基于 PyJHora _stronger_planet_new」,实际没有照它的次序。
- 修复:一主在本宫、另一主在外,取在外者;两主都在外,按各自所在星座比:① 同宫星数多者;② 木星 / 水星 / 定位星同宫或星座相照者多;③ 所在星座尊贵(旺 > 本宫 > 其他 > 落);④ 双体 > 固定 > 活动;⑤ 星座内度数大者;最后天然力量。次序与 PyJHora、Rath p.43 (d)、prashna 指南一致。两主都在本宫仍按比较结果(年数都是 12),未改。
- 验证:`tests/test_audit_h_dasha_sky_20261104.py`(布什、泰勒、乔布斯三张公开盘两种岁差的天蝎 / 水瓶年数等于 PyJHora 参照值,只存数字;一主在本宫取另一主;两主都在外时随盘面翻转);`tests/test_chara_dasha_board_lord.py` 一条按「原值 / 新值 / 原因」改写(计都在天蝎、火星在天秤:原值 计都 / 天蝎,新值 火星 / 天秤),另加一例火星在天蝎时取计都。修复后与 PyJHora 年数 225/228(Raman)、226/228(Lahiri),剩余差异均为 PyJHora 自己的取法:把上升算作同宫星(东京盘天蝎、齐达内 Raman 水瓶),尊贵一步只认入旺、第二颗星的入旺判断写成和自己比(伦敦盘天蝎)。K.N. Rao 书中越南女士例(1958,Lahiri)前 5 段星座与年数(含水瓶 3 年)与原书一致。前端金样 `chart-view-golden.json`、`chara-zero-year-1985-08-03-beijing.golden.json` 的 Chara 段按同一张盘的经度用修复后的引擎重采(前者天蝎宫主计都 8 年 → 火星 4 年,两份的小运次序随 BUG-1567 变),先用改前代码重算与原金样逐字节相同,确认输入一致;读它们的前端测试 256 条通过。咨询数据卡两份大金样(`consult-evidence-card-golden.json`、`consult-biography-backtest-golden.json`)里的 `chara_dasha` 仍是旧引擎采样(乔布斯天蝎宫主计都、水瓶 5 年),未重采,下次整体重采时更新。`tests/test_rashi_dashas_native.py` 的 PyJHora 4.8.7 对照(乔布斯、奥巴马、爱因斯坦)三盘 Chara 年数改前各差一段,改后与 PyJHora 逐段相同,fixture 的 `native.chara.years` 与 `diff_steps` 按「原值 / 新值 / 原因」更新(PyJHora 行未动)。
- 冻结身份(ERR-110):`jaimini.py` 在生时校正冻结身份里(校正只用 `calc_arudha_padas`,未改)。与 BUG-1567 一起按 ERR-110 改 FREEZE / REPORT 常量后用各脚本 `--freeze` 新建记录并回放,标签 `audit_h_2026_11_04`(`docs/research/sealed_holdout_rerun_audit_h_2026_11_04.{freeze.json,json}`、`docs/research/reported_offset_audit_h_2026_11_04.{freeze.json,json}`),合同 `references/rectification_sealed_holdout.v1.json` 按 moon_paksha 先例轮换,旧记录逐字节保留。与 `moon_paksha_2026_11_04` 逐条逐字段比:密封 20 条全部相同、`metrics` 不变;报时窗口 900 条全部相同、`summary` 不变(只有冻结身份哈希与路径不同);相对 09-20 历史报告的 changed_trial_count 仍为 8 / 581。符合预期:校正打分只调 `jaimini.calc_arudha_padas`,Chara Dasha 不进打分。
- 防复发:上述测试;同名「动态判定」函数要用盘面上的位置,不能用被判定的星座代入。
- 关联记录:BUG-1567(同轮 Chara 小运)、BUG-1218 / BUG-1227(Narayana Rath 版双主星,p.43)、issue #163。
- 复发自:无
- 修复版本:分支 `codex/audit-dasha-sky-20261104`(未推送)。
## BUG-1567 | K.N. Rao Chara Dasha 小运:每个大运都沿用上升序右移一位,只有第一运对
- 状态:resolved(引擎 + 回归测试;未部署)
- 首次发现 / 最近更新:2026-10-08 已在 `docs/tasks/PROGRESS-rashi-dashas-native-20261008.md` 记为与书不符、未改 `jaimini.py`;2026-11-04 数据核对 H 按书逐运核对后修复。
- 影响面:`scripts/jaimini.py` `calc_chara_dasha_with_antardasha`(`profile="kn_rao"`)的 `antardashas` 与其下 `pratyantar_dashas` 次序:报告 Rashi 大运章 Chara 表的子运(`rashi_dashas_native._chara`)、`/api/dasha/chara`、完整解读 Jaimini 的 Chara 子运。Goel 三个档案不变。
- 现象:19 张盘 × 12 运,只有每张盘的第一运(19/228)符合书中规则。K.N. Rao 书中越南女士例(pdf pp.51–52)摩羯大运第一小运是射手,本仓给白羊。
- 触发条件:任何盘的第二运起。
- 根因:子运序列写成「全盘大运序右移一位」(注释称 PyJHora method=2;PyJHora 实为不移位、并注明「逻辑未实现」)。K.N. Rao《Predicting through Jaimini's Chara Dasha》印页 35–36:每个大运按它自己的顺逆(其第 9 宫若为偶足则逆行),从下一星座起,本运星座排最后。
- 修复:`kn_rao` 档案每运单独起序:第 9 宫偶足则逆行,否则顺行,从下一星座起,第 12 段为本运星座;`antardasha_method` 写明口径。其余档案保持原序并注明「未找到出处,沿用」。
- 验证:`tests/test_audit_h_dasha_sky_20261104.py`:每运末段为本运星座、方向按其第 9 宫;天蝎大运次序等于原书 pdf pp.49–50 金样(天秤 … 天蝎);越南女士例 5 段大运与原书一致、摩羯首个小运为射手;Goel 档案次序不变。修复后 228/228 符合书中规则(修复前 19/228)。PyJHora 的 KN_RAO 子运是全盘大运序不移位(0/57),不作参照。
- 冻结身份(ERR-110):与 BUG-1566 同一次重冻,见 BUG-1566。
- 防复发:上述测试与书中金样。
- 关联记录:BUG-1566。
- 复发自:无
- 修复版本:分支 `codex/audit-dasha-sky-20261104`(未推送)。
## BUG-1568 | 出生 Panchanga 的星期(vara)和时主(hora)按公历日:日出前出生算成当天
- 状态:resolved(引擎 + 回归测试;未部署)
- 首次发现 / 最近更新:2026-11-04(数据核对 H:19 张盘与 PyJHora `drik.vaara`、本仓 Shadbala 的星期对照)/ 2026-11-04
- 影响面:`scripts/jyotish_engine.py` `_build_natal_foundation_modules` 的 `panchanga.raw.vara` 与 `hora`:完整解读 `modules.panchanga`、报告「基础资料表」与 parity 章出生 Panchanga、读这两项的地方。Shadbala 的星期 / 时主本来就按日出算,不受影响;星盘页出生历法的「星期」是公历星期,未改(列给产品)。
- 现象:19 张盘 4 张日出前出生(德里测试盘、泰勒、琵雅芙、齐达内)vara 写成公历当天,时主随之错:泰勒(1932-02-27 周六 02:30 伦敦)写周六,应为周五,时主应为火星。
- 触发条件:出生在午夜后、当天日出前。
- 根因:时主小时数从最近一次日出算(前一天的日出),星期却取出生的公历日期。吠陀日从日出到日出(PyJHora `vedic_weekday`、本仓 `shadbala._calendar_lords` 都是这样),两者混用。
- 修复:星期取「开始计时的那次日出」所在的公历日期。日出口径(apparent)不变。
- 验证:`tests/test_audit_h_dasha_sky_20261104.py`:泰勒 vara 周五、第 20 个时、时主火星;布什(日出后)仍为周六。19 张盘 vara 与 PyJHora 吠陀星期 19/19(修复前 15/19);与 Shadbala 时主一致 16/19,余 3 张(卡罗、布什、齐达内)差在日出口径(Panchanga 用 apparent、Shadbala 用 Hindu,差 4–6 分钟,恰好跨整点),列给占星师。
- 防复发:上述测试。
- 关联记录:BUG-1388(日出口径分用)、BUG-1546(PyJHora 时主公式错,不作参照)。
- 复发自:无
- 修复版本:分支 `codex/audit-dasha-sky-20261104`(未推送)。
## BUG-1569 | 四个对照脚本一边写死 Lahiri、一边吃引擎默认(Raman)或不查基线口径;两个脚本引擎路径指错
- 状态:resolved(工具 + 回归测试;不涉及线上)
- 首次发现 / 最近更新:2026-11-04(数据核对 H,按同日 74169e1b 的做法逐个检查)/ 2026-11-04
- 影响面:`benchmarks/jyotish/scripts/run_chara_dasha_compare.py`、`run_chara_dasha_knrao.py`、`run_transit_true_compare.py`、`run_node_mode_compare.py`、`run_skill_baseline.py`(`main`)。只影响对照报告,不影响产品输出。
- 现象:引擎默认岁差改为 Raman 后,这几份对照会把 1.446° 的岁差差当成算法差;`run_chara_dasha_compare.py` 与 `run_transit_true_compare.py` 的引擎路径指到不存在的 `benchmarks/scripts/`,直接跑不起来。
- 根因:PyJHora / Swiss 一侧写死 `LAHIRI` / `SIDM_LAHIRI`,引擎一侧不传 `--ayanamsa`,canonical 基线的口径也不核对;`parents[2]` 应为 `parents[3]`。
- 修复:四个脚本都加 `--ayanamsa`(Chara 两个另加 `--node-mode`),两边显式传入;读 canonical 基线的两个脚本用 `run_pyjhora_compare.baseline_settings_error` 拒绝口径不符的基线;Transit 脚本核对引擎回报的岁差;路径改正;`run_skill_baseline.py` 命令行加 `--ayanamsa / --node-mode`。
- 验证:`tests/test_benchmark_ayanamsa_plumbing_20261104.py`;用 Raman 重建 19 张 canonical 基线后重跑:Chara 对照 MD 星座 228/228、年数 225/228(BUG-1566 后);K.N. Rao 共享位置对照 Raman 年数 117/120、Lahiri 118/120;Transit 646/646(含 19 张盘 171 项度数、逆行、多参考点星座);节点对照 Swiss 平均交点 152/152、真交点与 PyJHora 默认各 100/152(口径差,预期)。用 Lahiri 对 Raman 基线跑 Chara 对照,脚本拒绝并提示重建。
- 防复发:上述测试;新写对照脚本两边都要显式传岁差与交点,并核对基线口径。
- 关联记录:74169e1b(`run_skill_baseline.py`、`run_pyjhora_compare.py` 同类修正)。
- 复发自:无
- 修复版本:分支 `codex/audit-dasha-sky-20261104`(未推送)。
+1
View File
@@ -19,6 +19,7 @@ Purpose: read this file before substantial project work. It exists to stop repea
- 数据卡宫行加 Ashtakavarga 强 / 中 / 弱(worktree `/workspace/wt-card-av`,`codex/card-ashtakavarga-20261104`):`pre_work_check.py` 第一次 exit 1(`fragment_scan` 90 s、`focused_tests` 45 s 超时,当时同机在建 worktree、跑卡片导出),机器空下来重跑 pass、六项全部 ok。分档沿用星盘页 `savBand`(≥ 28 强、25–27 中、≤ 24 弱),来源写在 `SAV_BAND_SOURCE`。注意:事业卡的 `sav_by_house`、财富 / 综合卡的 `ashtakavarga` 块本来就把原始与净化后的 SAV 点数给了模型,本单没动。
- BUG-1557 月亮 Paksha 口径(worktree `/workspace/wt-moon`,`codex/moon-paksha-bphs-20261104`):`pre_work_check.py` exit 0、六项全部 ok。精确路径 Kala 里月亮 ×2(Raman)加 Chesta = Paksha(BPHS 27.18)同时用、Chesta 又取 ×2 后的值,总分 4P;改为 BPHS-Santhanam(不乘 2,Chesta = Paksha),`method_variants.moon_paksha` 标口径。`shadbala.py` 在冻结身份里,按 ERR-110 重冻为 `moon_paksha_2026_11_04`:密封 20 条、报时 900 条与 `data_audit_2026_11_04` 全同。教训:两家来源各自「×2」「Chesta = Paksha」都对,拼在一起就错;引进一条古典规则时先看同一来源怎么处理相邻分量。Shadbala 相关测试改前改后失败清单一致(同上 10 条 + 其他既有 12 条);Python quick 门一次通过。
- 数据核对第二轮 G(worktree `/workspace/wt-g`,`codex/audit-vargas-20261104`):分盘 D3–D60、Arudha、Chara Karaka 对 PyJHora 与 jyotishganit,19 张盘、Raman / Lahiri、两边显式传岁差与交点。BUG-1560~1562:`varga.py` 的 D16/D20/D27/D45 起点、`divisional_charts_extended.py` 的 D16/D20/D27/D30/D40/D45、`trimshamsa_d30.py` 的偶数星座 D30 不符 BPHS,已改,同经度 190 / 190 与 PyJHora 一致。`varga.py` 的偶数星座 D30 同样错,但它进生时校正打分:先改了并按 ERR-110 重冻,回放变了(密封头名率 0.50 → 0.45,报时 166 / 900 条名次或分钟误差变),快速门 memoization / dated-transitions golden 9 条红,按先例要升 scoring-13,属产品决定,撤回,留 KNOWN DEFECT 注释与 strict xfail。最终重冻 `audit_g_2026_11_04`:密封 20 条、报时 900 条与 `moon_paksha_2026_11_04` 全同。教训:改分盘表前先查它是否进校正打分;进的话,除了 ERR-110 重冻,还要看 `tests/test_rectification_engine_memoization.py` 的 golden 与算法身份。`run_arudha_compare.py`、`run_chara_dasha_compare.py` 原来 PyJHora 那边写死 Lahiri,已改为 `--ayanamsa` / `--node-mode` 并拒绝口径不符的基线;`run_chara_dasha_compare.py` 还从 `benchmarks/scripts` 导入 jaimini(路径错),已改。`run_node_mode_compare.py`、`run_swiss_*_compare.py`、`run_transit_true_compare.py` 仍写死 Lahiri,未动。jyotishganit 的偶数星座 D30 表本身不符 BPHS,不能当 D30 对照;`tests/test_varga_vedastro_mode.py` 9 条在 origin/staging 上就失败(D2 上升),与本单无关。回放脚本不加 `--freeze` 时只把报告打到 stdout,要自己重定向成 REPORT;临时冻结记录要放在仓库里并传绝对路径(`reported_offset_sweep.py` 对冻结路径取 `relative_to(ROOT)`,放 scratchpad 或传相对路径会在跑完 900 条后才报错)。
- 数据核对 H(worktree `/workspace/wt-h`,`codex/audit-dasha-sky-20261104`):`pre_work_check.py` exit 0、六项全部 ok。Narayana / Chara / 行运 / 日出 / Gulika 两种岁差各 19 张盘对 PyJHora 与 Swiss 直算:行运 190/190、Hindu 日出与 PyJHora 0 秒、Narayana Rath 版差异全是流派(起运宫、双主星、水星处女),第二轮规则与 PyJHora 同;修 BUG-1565(夜段时段点表错一格)、BUG-1566 / 1567(K.N. Rao Chara 双主星、小运次序)、BUG-1568(日出前出生的星期)、BUG-1569(四个对照脚本岁差)。`jaimini.py` 在冻结身份里,按 ERR-110 重冻为 `audit_h_2026_11_04`:密封 20 条、报时 900 条与 `moon_paksha_2026_11_04` 全同。教训:① PyJHora 4.8.6 夜生的 Gulika / 时段点算法本身错(以次日日出为起点、钟点差作夜长),夜生盘不能拿它作对照,要用 Swiss 独立重算;② 一个函数名叫「动态判定」不等于真按盘面判定,对照时先看结果是否随盘面变;③ 同机多会话同时跑报时回放与全量测试,相关测试 91 个文件跑了一个多小时,比对改前改后要用同一时段的两个 worktree 并行跑。`test_narayana_rath_book_tables.py::test_rath_profile_is_display_only` 在未改动的基线上就失败(`scripts/annual_service/package.py` 导入 `narayana_rath`)。
## 2026-11-03 · 输入框单行起步(方案 2,worktree `/workspace/wt-composer`)
@@ -0,0 +1,118 @@
{
"record_version": "reported-offset-native-candidate-date-v3",
"frozen_at_utc": "2026-11-04T08:34:36.741288+00:00",
"ayanamsa": "raman",
"node_mode": "mean",
"radii_minutes": [
15,
30,
60
],
"offsets_minutes": [
-30,
-20,
-15,
-10,
-8,
-5,
-3,
0,
3,
5,
8,
10,
15,
20,
30
],
"minute_step": 1,
"dataset": "references/real_case_calibration/minute_rectification_holdout_v3.json",
"dataset_sha256": "45aa76ab79dfc6c6404b6252083e1aa5c7e2e2b7de811fc11042a8ad2da3f0ea",
"dataset_benchmark_id": "minute_rectification_fact_ranker_v4_holdout_v3",
"implementation_sha256": "a6a60f59f7405496453000abef6f9f3074aa8d31c2a4dc9a8f18c6d812837168",
"implementation_sha256_prefix": "a6a60f59f7405496",
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
"scripts/active_rectification_event_engine.py",
"scripts/active_rectification_events.py",
"scripts/ashtakavarga.py",
"scripts/dasha_analyzer.py",
"scripts/divisional_charts_extended.py",
"scripts/domain_calculation_service.py",
"scripts/functional_benefics.py",
"scripts/jaimini.py",
"scripts/minute_rectification_fact_ranker_v4.py",
"scripts/minute_rectification_feature_facts_v4.py",
"scripts/narayana_dasha.py",
"scripts/rectification/api_service.py",
"scripts/rectification/candidate_contrast.py",
"scripts/rectification/candidate_window.py",
"scripts/rectification/case_holdout.py",
"scripts/rectification/contracts.py",
"scripts/rectification/dasha_transition_proximity.py",
"scripts/rectification/decision_policy.py",
"scripts/rectification/event_probes.py",
"scripts/rectification/refinement_packet.py",
"scripts/rectification/scoring_service.py",
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "e20ff44f33b57db60bb6661ac235bd9f81425ced3fa632e7ea4b31f48b41237e",
"research_files": [
"scripts/research/reported_offset_sweep.py",
"scripts/research/cluster_width_lib.py",
"scripts/research/probe_supply_after_six.py",
"scripts/research/sealed_holdout_rerun.py",
"scripts/minute_rectification_blind_eval.py",
"scripts/minute_rectification_fact_blind_eval_v4.py",
"scripts/minute_rectification_holdout_validator.py"
],
"research_implementation_sha256": "25d970c09cb4d6a71e90c05d9580be5b81a63cbc338cc7cd4bf768a8aa45efd9",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "dced0b398ad90bdec59c743a55ffe01613306127c17a1e44c8c9cfb569c57841",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "706d20dcff89079063fd1857a36f19419604ef81c80227921c7a2ced60011ac9",
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "9a847d3b8d05f90d4f275079c97af1f71e83a74f930e14bd8f3f37b293d91d1e",
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
"scripts/rectification/dasha_transition_proximity.py": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "13c29205f5315383a4d4af04b4c69a244d8f175158beb20f3d9e47b62fd2683f",
"scripts/research/sealed_holdout_rerun.py": "2a4dfd27bb8b78d90d81f2b534091a1f82f748bc873e9943609256774a4313bc",
"scripts/shadbala.py": "352a9dd91c9be6dd7a294b1dc24c62173e8f08db5fc36c453dc5dcd7e0fd62d7",
"scripts/varga.py": "1bca389e4f36d293a755de175163c3f39b42bab5831cb6f7828830899f9f177e"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock",
"scorer": "native_event_contribution_matrix_not_shadow_fact_ranker",
"delivery": "initial_signature_clusters_peak_gap_lt_8_envelope_no_answers_no_elimination",
"rank": "score_desc_then_sha256(benchmark_id:case_id:candidate_time)",
"grid": "every_minute_inclusive_not_production_two_minute_sampling",
"cross_midnight": "date_aware_candidates_distance_and_native_single_matrix",
"evaluator_sha256": "13c29205f5315383a4d4af04b4c69a244d8f175158beb20f3d9e47b62fd2683f",
"replay_revision": "native_candidate_date_v3",
"supersedes": "candidate_date_grouped_v2_identity_and_path_not_assumed_numerically_wrong",
"is_blind_evaluation": false,
"truth_hidden_from_ranker": true,
"official_valid_independent_blind": false,
"official_blind_trial_count": 0,
"results_previously_seen": true,
"must_not_use_for_tuning": true
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
{
"record_version": "exposed-v3-fixed-protocol-cross-midnight-rerun-v2",
"extended_identity": {
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
"scripts/active_rectification_event_engine.py",
"scripts/active_rectification_events.py",
"scripts/ashtakavarga.py",
"scripts/dasha_analyzer.py",
"scripts/divisional_charts_extended.py",
"scripts/domain_calculation_service.py",
"scripts/functional_benefics.py",
"scripts/jaimini.py",
"scripts/minute_rectification_fact_ranker_v4.py",
"scripts/minute_rectification_feature_facts_v4.py",
"scripts/narayana_dasha.py",
"scripts/rectification/api_service.py",
"scripts/rectification/candidate_contrast.py",
"scripts/rectification/candidate_window.py",
"scripts/rectification/case_holdout.py",
"scripts/rectification/contracts.py",
"scripts/rectification/dasha_transition_proximity.py",
"scripts/rectification/decision_policy.py",
"scripts/rectification/event_probes.py",
"scripts/rectification/refinement_packet.py",
"scripts/rectification/scoring_service.py",
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "e20ff44f33b57db60bb6661ac235bd9f81425ced3fa632e7ea4b31f48b41237e",
"research_files": [
"scripts/research/reported_offset_sweep.py",
"scripts/research/cluster_width_lib.py",
"scripts/research/probe_supply_after_six.py",
"scripts/research/sealed_holdout_rerun.py",
"scripts/minute_rectification_blind_eval.py",
"scripts/minute_rectification_fact_blind_eval_v4.py",
"scripts/minute_rectification_holdout_validator.py"
],
"research_implementation_sha256": "25d970c09cb4d6a71e90c05d9580be5b81a63cbc338cc7cd4bf768a8aa45efd9",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "dced0b398ad90bdec59c743a55ffe01613306127c17a1e44c8c9cfb569c57841",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "706d20dcff89079063fd1857a36f19419604ef81c80227921c7a2ced60011ac9",
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "9a847d3b8d05f90d4f275079c97af1f71e83a74f930e14bd8f3f37b293d91d1e",
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
"scripts/rectification/dasha_transition_proximity.py": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "13c29205f5315383a4d4af04b4c69a244d8f175158beb20f3d9e47b62fd2683f",
"scripts/research/sealed_holdout_rerun.py": "2a4dfd27bb8b78d90d81f2b534091a1f82f748bc873e9943609256774a4313bc",
"scripts/shadbala.py": "352a9dd91c9be6dd7a294b1dc24c62173e8f08db5fc36c453dc5dcd7e0fd62d7",
"scripts/varga.py": "1bca389e4f36d293a755de175163c3f39b42bab5831cb6f7828830899f9f177e"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
},
"production_identity_scope": "context_only_shadow_scorer_does_not_call_transition_proximity",
"frozen_at_utc": "2026-11-04T08:34:34.223675+00:00",
"dataset_path": "references/real_case_calibration/minute_rectification_holdout_v3.json",
"dataset_sha256": "45aa76ab79dfc6c6404b6252083e1aa5c7e2e2b7de811fc11042a8ad2da3f0ea",
"algorithm_version": "birth-time-event-fact-ranker-v4-shadow",
"implementation_sha256": "a6a60f59f7405496453000abef6f9f3074aa8d31c2a4dc9a8f18c6d812837168",
"files": [
"scripts/active_rectification_event_engine.py",
"scripts/active_rectification_events.py",
"scripts/ashtakavarga.py",
"scripts/dasha_analyzer.py",
"scripts/divisional_charts_extended.py",
"scripts/domain_calculation_service.py",
"scripts/jaimini.py",
"scripts/minute_rectification_fact_ranker_v4.py",
"scripts/minute_rectification_feature_facts_v4.py",
"scripts/narayana_dasha.py",
"scripts/shadbala.py",
"scripts/varga.py"
],
"historical_frozen_sha256": "f41c298dd6cdcebe7a93e632f7191954be7987f6012ca2a34cecb6e447fbf196",
"evaluator_sha256": "2a4dfd27bb8b78d90d81f2b534091a1f82f748bc873e9943609256774a4313bc",
"ayanamsa": "raman",
"node_mode": "mean",
"candidate_radius_minutes": [
10
],
"minute_step": 1,
"release_metrics": {
"top_1_rate_minimum": 0.6,
"top_3_rate_minimum": 0.85,
"mean_absolute_minute_error_maximum": 2.0,
"false_confirmation_rate_maximum": 0.05,
"correct_insufficient_evidence_rejection_rate_minimum": 0.9
},
"results_previously_seen": true,
"official_valid_independent_blind": false,
"must_not_use_for_tuning": true,
"tie_breaker": "sha256(benchmark_id:case_id:candidate_time); published minute is never passed to the ranker",
"metric_rank_definition": "competition_rank_1_plus_strictly_higher_scores_legacy_protocol",
"extra_metric_rank_definition": "score_desc_then_existing_opaque_sha256_total_order"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -43,7 +43,13 @@ SATURN_PART_START = {
# Planet indices follow the local seven-graha ordering: Sun through Saturn.
# Each row assigns the eight daylight/nighttime shares for a weekday where
# Python's datetime.weekday() is Monday=0.
# Python's datetime.weekday() is Monday=0. The day starts with the weekday
# lord, the night with the lord of the fifth weekday counted from it; the
# shares then run round the eight-slot cycle Sun..Saturn, lordless (PyJHora
# const.day_rulers / night_rulers). Saturn's share in every row equals
# SATURN_PART_START. The Wednesday-Saturday night rows used to start one lord
# early (Saturday night with Mars instead of Mercury), so Maandi and the four
# other time-segment points sat in the wrong share on those nights (BUG-1565).
DAY_RULERS = (
(1, 2, 3, 4, 5, 6, -1, 0),
(2, 3, 4, 5, 6, -1, 0, 1),
@@ -56,10 +62,10 @@ DAY_RULERS = (
NIGHT_RULERS = (
(5, 6, -1, 0, 1, 2, 3, 4),
(6, -1, 0, 1, 2, 3, 4, 5),
(-1, 0, 1, 2, 3, 4, 5, 6),
(0, 1, 2, 3, 4, 5, 6, -1),
(1, 2, 3, 4, 5, 6, -1, 0),
(2, 3, 4, 5, 6, -1, 0, 1),
(3, 4, 5, 6, -1, 0, 1, 2),
(4, 5, 6, -1, 0, 1, 2, 3),
)
TEMPORAL_UPAGRAHAS = {
+96 -48
View File
@@ -361,38 +361,66 @@ def _jaimini_planet_dignity_level(planet: str, sign_idx: int) -> int:
return 3
_CO_LORD_GRAHAS = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu')
def _rasi_drishti(source_sign_idx: int, target_sign_idx: int) -> bool:
"""Jaimini sign aspect: movable -> fixed (not the next), fixed -> movable
(not the previous), dual -> the other duals."""
if source_sign_idx in (0, 3, 6, 9):
return target_sign_idx in (1, 4, 7, 10) and target_sign_idx != (source_sign_idx + 1) % 12
if source_sign_idx in (1, 4, 7, 10):
return target_sign_idx in (0, 3, 6, 9) and target_sign_idx != (source_sign_idx - 1) % 12
return target_sign_idx in (2, 5, 8, 11) and target_sign_idx != source_sign_idx
def _co_lord_strength(longitudes: Dict[str, float], planet: str) -> Tuple[int, int, int, int, float]:
"""Strength key of one Scorpio / Aquarius co-lord where it stands (BUG-1566).
Same order as PyJHora `house._stronger_planet_new`, Rath (Narayana Dasa
p.43 (d)) and references/prashna-complete-guide.md (dual lords):
1. more other bodies in its sign;
2. more of Jupiter, Mercury and its dispositor in or aspecting (Rasi
Drishti) its sign;
3. dignity in the sign it occupies (exalted > own > other > debilitated);
4. dual > fixed > movable sign;
5. more degrees in the sign.
"""
sign_idx = _get_planet_house(longitudes, planet)
companions = sum(
1 for other in _CO_LORD_GRAHAS
if other != planet and other in longitudes and _get_planet_house(longitudes, other) == sign_idx
)
dispositor = SIGN_LORDS[SIGNS[sign_idx]]
support = 0
for role in ('Jupiter', 'Mercury', dispositor):
if role not in longitudes:
continue
role_sign = _get_planet_house(longitudes, role)
if role_sign == sign_idx or _rasi_drishti(role_sign, sign_idx):
support += 1
dignity = _jaimini_planet_dignity_level(planet, sign_idx)
modality = 2 if sign_idx % 3 == 2 else 1 if sign_idx % 3 == 1 else 0
return companions, support, dignity, modality, float(longitudes[planet]) % 30
def _jaimini_stronger_planet(longitudes: Dict[str, float], planet_a: str,
planet_b: str, sign_idx: int) -> str:
"""
动态比较两颗行星在指定星座的力量,返回较强者。
基于PyJHora _stronger_planet_new 算法翻译。
Stronger of two co-lords of ``sign_idx``, judged where each one stands.
比较层次:
1. 尊严层级: exalted(6) > own(5) > friendly(4) > neutral(3) > enemy(2) > debilitated(1)
2. 同层级:比较行星经度位置(度数更靠近星座中心更强)
3. 仍相同:比较Naisargika Bala(天然力量)
Before BUG-1566 this compared both planets' dignity *in ``sign_idx``*
(the dasha sign) rather than in the signs they occupy, so Scorpio always
went to Ketu (exalted there) and Aquarius always to Saturn (own sign),
whatever the chart. Now: `_co_lord_strength` levels 1-5, then the
natural-strength order as a last tie-break.
"""
level_a = _jaimini_planet_dignity_level(planet_a, sign_idx)
level_b = _jaimini_planet_dignity_level(planet_b, sign_idx)
if level_a > level_b:
key_a = _co_lord_strength(longitudes, planet_a)
key_b = _co_lord_strength(longitudes, planet_b)
if key_a > key_b:
return planet_a
if level_b > level_a:
if key_b > key_a:
return planet_b
# 同层级:比较行星在星座中的位置(更靠近星座中心度数=15°更强)
deg_a = longitudes.get(planet_a, 0) % 30
deg_b = longitudes.get(planet_b, 0) % 30
center_diff_a = abs(deg_a - 15)
center_diff_b = abs(deg_b - 15)
if center_diff_a < center_diff_b:
return planet_a
if center_diff_b < center_diff_a:
return planet_b
# 完全平局:使用Naisargika Bala决断
naisargika_order = ['Sun', 'Moon', 'Venus', 'Jupiter', 'Mercury', 'Mars', 'Saturn']
idx_a = naisargika_order.index(planet_a) if planet_a in naisargika_order else 99
idx_b = naisargika_order.index(planet_b) if planet_b in naisargika_order else 99
@@ -401,32 +429,33 @@ def _jaimini_stronger_planet(longitudes: Dict[str, float], planet_a: str,
def _resolve_chara_dasha_lord(longitudes, sign_idx):
"""
解析Chara Dasha宫主。v6.1.13: 实现动态共主判定。
解析Chara Dasha宫主(天蝎:火星 / 计都;水瓶:土星 / 罗睺)。
Aquarius (sign 10): 比较 Saturn vs Rahu
Scorpio (sign 7): 比较 Mars vs Ketu
BUG-1566: one co-lord in the sign itself and the other elsewhere -> the
one elsewhere (K.N. Rao, Predicting through Jaimini's Chara Dasha; Rath,
Narayana Dasa p.43 (c); PyJHora basic rule; prashna guide rule 1).
Otherwise the stronger by `_jaimini_stronger_planet`, judged where each
planet stands. Both in the sign keep the stronger-planet result (the
count is 12 either way).
其他星座: 使用传统宫主
"""
sign_name = SIGNS[sign_idx]
traditional_lord = SIGN_LORDS.get(sign_name, '')
# Aquarius: Saturn vs Rahu 共主判定
if sign_idx == 10: # Aquarius
saturn_present = 'Saturn' in longitudes and longitudes['Saturn'] >= 0
rahu_present = 'Rahu' in longitudes and longitudes['Rahu'] >= 0
if saturn_present and rahu_present:
return _jaimini_stronger_planet(longitudes, 'Saturn', 'Rahu', sign_idx)
return 'Saturn' if saturn_present else 'Rahu'
# Scorpio: Mars vs Ketu 共主判定
if sign_idx == 7: # Scorpio
mars_present = 'Mars' in longitudes and longitudes['Mars'] >= 0
ketu_present = 'Ketu' in longitudes and longitudes['Ketu'] >= 0
if mars_present and ketu_present:
return _jaimini_stronger_planet(longitudes, 'Mars', 'Ketu', sign_idx)
return 'Mars' if mars_present else 'Ketu'
return traditional_lord
pair = {10: ('Saturn', 'Rahu'), 7: ('Mars', 'Ketu')}.get(sign_idx)
if pair is None:
return traditional_lord
first, second = pair
first_present = first in longitudes and longitudes[first] >= 0
second_present = second in longitudes and longitudes[second] >= 0
if not (first_present and second_present):
return first if first_present else second
first_in = _get_planet_house(longitudes, first) == sign_idx
second_in = _get_planet_house(longitudes, second) == sign_idx
if first_in and not second_in:
return second
if second_in and not first_in:
return first
return _jaimini_stronger_planet(longitudes, first, second, sign_idx)
def _sign_is_even_footed(sign_idx: int) -> bool:
@@ -707,8 +736,21 @@ def calc_chara_dasha_with_antardasha(asc_sign_idx: int,
antar_sequence = [(md_sign_idx + 12 - i) % 12 for i in range(12)]
else:
antar_sequence = [(md_sign_idx + i) % 12 for i in range(12)]
elif profile == "kn_rao":
# K.N. Rao, Predicting through Jaimini's Chara Dasha, printed
# pp.35-36: each mahadasha sign runs its own direction (reverse
# when the 9th from it is even-footed) and its own sign comes
# last. The book's Scorpio MD (pp.49-50) runs Libra .. Scorpio and
# its Capricorn MD (pp.51-52) opens with Sagittarius (golden rows
# in references/oracle/chara_narayana_book_golden_cases_2026_08_28.json).
# Before BUG-1567 every MD reused the lagna order shifted by one,
# which matches the book only for the first MD.
if _sign_is_even_footed((md_sign_idx + 8) % 12):
antar_sequence = [(md_sign_idx - i) % 12 for i in range(1, 13)]
else:
antar_sequence = [(md_sign_idx + i) % 12 for i in range(1, 13)]
else:
# Antardasha序列:Mahadasha序列偏移1(PyJHora method=2)
# Antardasha序列:Mahadasha序列偏移1(Goel 两档未找到出处,沿用旧序)
antar_sequence = progression[1:] + progression[:1]
antardasha_list = []
@@ -744,7 +786,13 @@ def calc_chara_dasha_with_antardasha(asc_sign_idx: int,
base['has_antardasha'] = True
base['has_pratyantar'] = True
base['antardasha_method'] = 'equal_division_12 (PyJHora method=2)'
base['antardasha_method'] = (
'equal_division_12; kn_rao: own direction per mahadasha sign, mahadasha sign last (K.N. Rao pp.35-36)'
if profile == 'kn_rao' else
'equal_division_12; goel_shastri_method2: mahadasha sign first, its own direction'
if profile == 'goel_shastri_method2' else
'equal_division_12; lagna order shifted by one (unsourced, unchanged)'
)
return base
+6 -1
View File
@@ -15283,8 +15283,13 @@ def _build_natal_foundation_modules(args, planet_lons, *, asc_lon=None, jd=None)
hours_from_sunrise = (daynight["julian_day_ut"] - daynight["sunrise_jd_ut"]) * 24
if hours_from_sunrise < 0:
hours_from_sunrise += 24
# The Vedic day (vara) runs sunrise to sunrise: a birth before sunrise
# belongs to the previous weekday, and its hora counts from that
# day's sunrise (BUG-1568). The weekday is the civil date of the
# sunrise the hours are counted from, not the birth's civil date.
# muhurta.py uses Sunday=0 while datetime.weekday() uses Monday=0.
weekday = (birth_dt.weekday() + 1) % 7
sy, sm, sd, _ = swe.revjul(daynight["sunrise_jd_ut"] + float(args.tz) / 24.0)
weekday = (datetime(int(sy), int(sm), int(sd)).weekday() + 1) % 7
panchanga = calc_panchanga(
float(planet_lons["Sun"]),
float(planet_lons["Moon"]),
+3 -6
View File
@@ -619,7 +619,7 @@
6,
6,
4,
5,
1,
9,
12,
7,
@@ -658,7 +658,6 @@
"subperiod"
],
"chara": [
"years",
"subperiod"
]
}
@@ -1274,7 +1273,7 @@
"years": [
12,
0,
3,
9,
8,
2,
1,
@@ -1318,7 +1317,6 @@
"subperiod"
],
"chara": [
"years",
"subperiod"
]
}
@@ -1963,7 +1961,7 @@
11,
10,
2,
8,
3,
6,
5,
5,
@@ -2001,7 +1999,6 @@
"subperiod"
],
"chara": [
"years",
"subperiod"
]
}
+199
View File
@@ -0,0 +1,199 @@
"""Data audit wave 2 (H), 2026-11-04: Chara Dasha, time-segment points and the
birth weekday, checked against PyJHora 4.8.6, Swiss Ephemeris and the books.
External reference values are stored as plain numbers (PyJHora is AGPL and is
not imported here). Public charts are Rodden AA figures already in
benchmarks/jyotish/data/benchmark_samples.json.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
import pytest
import swisseph as swe
from scripts import gulika, jaimini
from scripts.jyotish_engine import _build_natal_foundation_modules, compute_chart_data
ROOT = Path(__file__).resolve().parents[1]
GRAHAS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
def _chart(year, month, day, hour, minute, lat, lon, tz, ayanamsa="raman"):
chart, asc_idx, jd, _ = compute_chart_data(year, month, day, hour, minute, lat, lon, tz,
node_mode="mean", ayanamsa_name=ayanamsa)
return asc_idx, {name: chart["planets"][name]["degree_raw"] for name in GRAHAS}, jd
BUSH = (1946, 7, 6, 7, 26, 41.3, -72.9333, -4.0)
TAYLOR = (1932, 2, 27, 2, 30, 51.5, -0.1667, 0.0)
JOBS = (1955, 2, 24, 19, 15, 37.7749, -122.4194, -8.0)
TOKYO = (1964, 10, 10, 12, 0, 35.6762, 139.6503, 9.0)
# ── BUG-1566: Scorpio / Aquarius co-lord in K.N. Rao Chara Dasha ─────────────
@pytest.mark.parametrize("ayanamsa", ["raman", "lahiri"])
@pytest.mark.parametrize("birth, scorpio, aquarius", [
# PyJHora 4.8.6 chara KN_RAO, same ayanamsa, mean node (numbers only).
(BUSH, 9, 7), # Ketu in Scorpio, Mars in Leo -> Mars (was Ketu, 13 years)
(TAYLOR, 3, 11), # both outside: Mars wins Scorpio, Rahu wins Aquarius (were 10 / 1)
(JOBS, 6, 1), # Aquarius goes to Rahu (was Saturn, 5 years)
])
def test_co_lord_years_match_pyjhora_kn_rao(ayanamsa, birth, scorpio, aquarius):
asc_idx, lons, _ = _chart(*birth, ayanamsa=ayanamsa)
years = {row["sign"]: row["duration_years"] for row in
jaimini.calc_chara_dasha(asc_idx, lons, birth[0], birth[1], birth[2])["dasha_sequence"]}
assert years["Scorpio"] == scorpio
assert years["Aquarius"] == aquarius
def test_a_co_lord_sitting_in_the_sign_gives_way_to_the_one_outside():
# K.N. Rao; Rath, Narayana Dasa p.43 (c); references/prashna-complete-guide.md.
base = {"Sun": 15.0, "Moon": 75.0, "Mercury": 40.0, "Jupiter": 100.0, "Venus": 160.0}
assert jaimini._resolve_chara_dasha_lord({**base, "Mars": 215.0, "Ketu": 45.0, "Rahu": 225.0, "Saturn": 5.0}, 7) == "Ketu"
assert jaimini._resolve_chara_dasha_lord({**base, "Mars": 190.0, "Ketu": 225.0, "Rahu": 45.0, "Saturn": 5.0}, 7) == "Mars"
assert jaimini._resolve_chara_dasha_lord({**base, "Mars": 0.0, "Ketu": 100.0, "Rahu": 280.0, "Saturn": 310.0}, 10) == "Rahu"
asc_idx, lons, _ = _chart(*TOKYO)
assert int(lons["Saturn"] // 30) == 10 # Saturn in Aquarius
assert jaimini._resolve_chara_dasha_lord(lons, 10) == "Rahu"
def test_co_lord_choice_depends_on_where_the_planets_stand():
# Before BUG-1566 dignity was read in Scorpio itself, so Ketu won every
# chart; with both lords outside, moving them must be able to flip it.
base = {"Sun": 15.0, "Moon": 75.0, "Mercury": 40.0, "Jupiter": 100.0, "Venus": 160.0, "Saturn": 5.0}
mars_exalted = {**base, "Mars": 285.0, "Ketu": 130.0, "Rahu": 310.0} # Mars in Capricorn, alone
ketu_crowded = {**base, "Mars": 250.0, "Ketu": 105.0, "Rahu": 285.0} # Ketu in Cancer with Jupiter
assert jaimini._resolve_chara_dasha_lord(mars_exalted, 7) == "Mars"
assert jaimini._resolve_chara_dasha_lord(ketu_crowded, 7) == "Ketu"
# ── BUG-1567: K.N. Rao Chara antardasha order ───────────────────────────────
def _golden(case_id):
data = json.loads((ROOT / "references/oracle/chara_narayana_book_golden_cases_2026_08_28.json").read_text())
return next(case for case in data["golden_cases"] if case["case_id"] == case_id)
def test_every_mahadasha_runs_its_own_direction_and_ends_on_itself():
asc_idx, lons, _ = _chart(*JOBS)
result = jaimini.calc_chara_dasha_with_antardasha(asc_idx, lons, 1955, 2, 24)
for md in result["dasha_sequence"]:
order = [ad["sign_idx"] for ad in md["antardashas"]]
assert order[-1] == md["sign_idx"]
step = -1 if ((md["sign_idx"] + 8) % 12) in jaimini._EVEN_FOOTED_SIGNS else 1
assert order == [(md["sign_idx"] + step * i) % 12 for i in range(1, 13)]
def test_scorpio_mahadasha_matches_the_book_order():
# K.N. Rao, Predicting through Jaimini's Chara Dasha, pdf pp.49-50.
expected = _golden("rao_chara_male_1949_scorpio_ad")["profile_assertions"]["subperiod_order"]
asc_idx, lons, _ = _chart(*JOBS)
result = jaimini.calc_chara_dasha_with_antardasha(asc_idx, lons, 1955, 2, 24)
scorpio = next(md for md in result["dasha_sequence"] if md["sign"] == "Scorpio")
assert [ad["sign"] for ad in scorpio["antardashas"]] == expected
def test_vietnamese_lady_replays_the_book_mahadashas_and_first_capricorn_antardasha():
# K.N. Rao, pdf pp.51-52: 1958-01-19 14:45 Hanoi; Lahiri as in the book.
asc_idx, lons, _ = _chart(1958, 1, 19, 14, 45, 21.03, 105.85, 7.0, ayanamsa="lahiri")
result = jaimini.calc_chara_dasha_with_antardasha(asc_idx, lons, 1958, 1, 19)
rows = [(md["sign"], md["duration_years"]) for md in result["dasha_sequence"][:5]]
book = [(row["sign"], row["duration_years"]) for row in _golden("rao_chara_vietnamese_lady_1958_capricorn_ad")["period_rows"] if row["level"] == "MD"]
assert rows == book
capricorn = next(md for md in result["dasha_sequence"] if md["sign"] == "Capricorn")
assert capricorn["antardashas"][0]["sign"] == "Sagittarius"
def test_goel_profiles_keep_their_antardasha_order():
asc_idx, lons, _ = _chart(*JOBS)
progression = jaimini._chara_progression_profile(asc_idx, lons, profile="goel_lagna_start")
result = jaimini.calc_chara_dasha_with_antardasha(asc_idx, lons, 1955, 2, 24, profile="goel_lagna_start")
for md in result["dasha_sequence"]:
assert [ad["sign_idx"] for ad in md["antardashas"]] == progression[1:] + progression[:1]
# ── BUG-1565: night shares of the time-segment points ───────────────────────
def test_night_and_day_share_tables_follow_the_weekday_rule():
for weekday in range(7): # Monday=0
sunday_based = (weekday + 1) % 7
assert gulika.DAY_RULERS[weekday][0] == sunday_based
assert gulika.NIGHT_RULERS[weekday][0] == (sunday_based + 4) % 7 # lord of the 5th weekday
assert gulika.DAY_RULERS[weekday].index(6) == gulika.SATURN_PART_START[weekday]["day"]
assert gulika.NIGHT_RULERS[weekday].index(6) == gulika.SATURN_PART_START[weekday]["night"]
def _independent_point(moment, lat, lon, tz, planet, middle, ayanamsa="raman"):
flags = swe.FLG_SWIEPH | swe.FLG_TRUEPOS
geo = (lon, lat, 0.0)
def event(after, kind):
return swe.rise_trans(after, swe.SUN, kind | swe.BIT_HINDU_RISING, geo, flags=flags)[1][0]
jd = swe.julday(moment.year, moment.month, moment.day, moment.hour + moment.minute / 60 - tz)
midnight = swe.julday(moment.year, moment.month, moment.day, -tz)
rise = event(midnight, swe.CALC_RISE)
if rise > jd:
midnight -= 1.0
rise = event(midnight, swe.CALC_RISE)
sunset, next_rise = event(midnight, swe.CALC_SET), event(midnight + 1.0, swe.CALC_RISE)
day = rise <= jd < sunset
start, end = (rise, sunset) if day else (sunset, next_rise)
y, m, d, _ = swe.revjul(start + tz / 24)
sunday_based = (datetime(y, m, d).weekday() + 1) % 7
first = sunday_based if day else (sunday_based + 4) % 7
cycle = [0, 1, 2, 3, 4, 5, 6, -1]
order = [cycle[(cycle.index(first) + i) % 8] for i in range(8)]
fraction = (order.index(planet) + (0.5 if middle else 0.0)) / 8
from scripts.ayanamsa_utils import temporary_ayanamsa
with temporary_ayanamsa(ayanamsa, swe):
return swe.houses_ex(start + (end - start) * fraction, lat, lon, b"P", swe.FLG_SWIEPH | swe.FLG_SIDEREAL)[1][0] % 360
@pytest.mark.parametrize("birth", [JOBS, TAYLOR, (1970, 3, 21, 18, 15, 51.5074, -0.1278, 0.0)])
def test_thursday_to_saturday_night_points_sit_in_the_right_share(birth):
# Jobs: Thursday night; Taylor: Friday night (before Saturday's sunrise);
# London smoke chart: Saturday night.
moment = datetime(*birth[:5])
lat, lon, tz = birth[5], birth[6], birth[7]
sun = _chart(*birth)[1]["Sun"]
raw = gulika.calculate_upagrahas(moment, lat=lat, lon=lon, tz=tz, sun_longitude=sun, ayanamsa="raman")["raw"]
assert raw["Maandi"]["period"] == "night"
for name, (planet, part) in gulika.TEMPORAL_UPAGRAHAS.items():
expected = _independent_point(moment, lat, lon, tz, planet, part == "middle")
assert raw[name]["longitude"] == pytest.approx(expected, abs=0.01), name
# Maandi is the middle of the same Saturn share whose start is Gulika.
single = gulika.calculate_gulika(moment, lat=lat, lon=lon, tz=tz, ayanamsa="raman")
assert raw["Maandi"]["segment_fraction"] == pytest.approx(single["segment_fraction"] + 1 / 16)
# ── BUG-1568: birth weekday (vara) and hora before sunrise ──────────────────
def _panchanga(birth):
year, month, day, hour, minute, lat, lon, tz = birth
_, lons, jd = _chart(*birth)
args = SimpleNamespace(year=year, month=month, day=day, hour=hour, minute=minute, second=0,
lat=lat, lon=lon, tz=tz, ayanamsa="raman", node_mode="mean", position_mode="legacy")
return _build_natal_foundation_modules(args, lons, jd=jd)["panchanga"]["raw"]
def test_a_birth_before_sunrise_belongs_to_the_previous_weekday():
# Taylor: Saturday 1932-02-27 02:30 civil, before sunrise -> Friday
# (PyJHora drik.vaara, the Shadbala path in this repo, Panchanga practice).
raw = _panchanga(TAYLOR)
assert raw["vara"]["vara"] == "Friday"
# Hora counts from Friday's sunrise: about 19.5 hours in -> the 20th hora,
# Friday's sequence Venus, Mercury, Moon, Saturn, Jupiter, Mars, Sun, ... -> Mars.
assert raw["hora"]["hora_num"] == 20
assert raw["hora"]["hora_lord"] == "Mars"
def test_a_daytime_birth_keeps_its_civil_weekday():
raw = _panchanga(BUSH) # Saturday morning after sunrise
assert raw["vara"]["vara"] == "Saturday"
@@ -0,0 +1,79 @@
"""BUG-1569: the Chara, transit and node-mode benchmark scripts pass the ayanamsa
to both sides and refuse a canonical baseline built with another one.
Same failure as 74169e1b: one side hard-coded Lahiri while the engine default
is Raman, so a 1.446 degree ayanamsa gap read as an algorithm difference.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import swisseph as swe
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "benchmarks" / "jyotish" / "scripts"
@pytest.mark.parametrize("name", [
"run_chara_dasha_compare.py", "run_chara_dasha_knrao.py",
"run_transit_true_compare.py", "run_node_mode_compare.py",
])
def test_no_script_hard_codes_lahiri_and_each_takes_the_ayanamsa(name):
source = (SCRIPTS / name).read_text(encoding="utf-8")
assert "set_ayanamsa_mode('LAHIRI')" not in source
assert "SIDM_LAHIRI, 0, 0" not in source
assert "--ayanamsa" in source
def test_engine_paths_point_at_the_repository_scripts():
from benchmarks.jyotish.scripts import run_chara_dasha_compare, run_transit_true_compare
assert (run_chara_dasha_compare.SKILL_SCRIPTS / "jaimini.py").is_file()
assert run_transit_true_compare.SKILL_SCRIPT.is_file()
def test_transit_compare_passes_the_ayanamsa_to_engine_and_swiss(monkeypatch, tmp_path):
from benchmarks.jyotish.scripts import run_transit_true_compare as rtc
seen = {}
class Done:
returncode = 0
stdout = json.dumps({"birth_info": {"ayanamsa_name": "raman"}, "modules": {}})
stderr = ""
def fake_run(cmd, **kwargs):
seen["cmd"] = cmd
return Done()
monkeypatch.setattr(rtc, "RAW", tmp_path)
monkeypatch.setattr(rtc.subprocess, "run", fake_run)
sample = {"id": "fixture", "today": "2026-11-04",
"birth": {"year": 2000, "month": 1, "day": 1, "hour": 0, "minute": 0, "lat": 0, "lon": 0, "tz": 0}}
rtc.run_full_reading(sample, "raman")
assert seen["cmd"][seen["cmd"].index("--ayanamsa") + 1] == "raman"
raman = rtc.calc_swiss_transit("2026-11-04", 0, "raman")["planets"]["Saturn"]["longitude"]
lahiri = rtc.calc_swiss_transit("2026-11-04", 0, "lahiri")["planets"]["Saturn"]["longitude"]
assert 1.3 < (raman - lahiri) % 360 < 1.6
monkeypatch.setattr(rtc, "TRANSIT_OUT", tmp_path)
with pytest.raises(RuntimeError, match="ayanamsa"):
rtc.compare_sample(sample, "lahiri")
@pytest.mark.parametrize("module_name", ["run_chara_dasha_compare", "run_node_mode_compare"])
def test_canonical_baseline_from_another_ayanamsa_is_refused(module_name, monkeypatch, tmp_path):
import importlib
module = importlib.import_module(f"benchmarks.jyotish.scripts.{module_name}")
canon = tmp_path / "canonical"
canon.mkdir()
(canon / "fixture.canonical.json").write_text(json.dumps({"parameters": {"ayanamsa": "raman", "node_mode": "mean"}}))
data = tmp_path / "samples.json"
data.write_text(json.dumps([{"id": "fixture", "label": "x", "birth": {}}]))
monkeypatch.setattr(module, "CANON", canon)
monkeypatch.setattr(module, "DATA", data)
with pytest.raises(SystemExit):
module.main(["--ayanamsa", "lahiri"])
+14 -4
View File
@@ -17,8 +17,9 @@ from jaimini import ( # noqa: E402
calc_chara_dasha_with_antardasha,
)
# Scorpio: Ketu (exalted there in this table) outranks Mars, so the engine picks Ketu.
# Aquarius: Saturn owns it and outranks Rahu, so the engine keeps Saturn.
# Scorpio: Ketu sits in Scorpio and Mars in Libra, so the lord outside, Mars,
# counts (BUG-1566; before, Ketu always won Scorpio because its dignity was
# read in Scorpio itself). Aquarius: Rahu sits in Aquarius, so Saturn counts.
LONGITUDES = {
"Sun": 15.0,
"Moon": 75.0,
@@ -47,8 +48,17 @@ def test_every_mahadasha_row_names_its_lord_and_the_sign_it_sits_in():
def test_co_lord_signs_carry_the_engine_choice_not_the_traditional_lord():
# Was Scorpio -> Ketu / Scorpio (原值); now Mars / Libra (新值): the co-lord
# sitting in the sign gives way to the one outside it (BUG-1566).
rows = _rows()
assert rows["Scorpio"]["lord"] == "Ketu"
assert rows["Scorpio"]["lord_in_sign"] == "Scorpio"
assert rows["Scorpio"]["lord"] == "Mars"
assert rows["Scorpio"]["lord_in_sign"] == "Libra"
assert rows["Aquarius"]["lord"] == "Saturn"
assert rows["Aquarius"]["lord_in_sign"] == "Aries"
# Mars in Scorpio, Ketu in Taurus: the row carries Ketu, not the
# traditional lord.
moved = {**LONGITUDES, "Mars": 215.0, "Ketu": 45.0, "Rahu": 225.0}
result = calc_chara_dasha_with_antardasha(0, moved, 1990, 6)
scorpio = next(row for row in result["dasha_sequence"] if row["sign"] == "Scorpio")
assert scorpio["lord"] == "Ketu"
assert scorpio["lord_in_sign"] == "Taurus"
+3
View File
@@ -285,6 +285,9 @@ def test_pyjhora_fixture_diffs_stay_on_the_recorded_steps() -> None:
# 原值:niryaana_shoola 的 native 行与 Shoola 一样,从上升或第七宫起、每段 9 年,并带子运差异。
# 新值:同一键改为 PVR 主运的星座和年数;diff_steps 按新表相对 PyJHora 重记。
# 原因:产品 2026-10-18 改口径。PyJHora 对照行没有重跑,只改我们自己的期望。
# 原值:乔布斯 / 奥巴马 / 爱因斯坦三盘 native.chara 年数各有一段(水瓶或天蝎)与 PyJHora 不同,diff_steps 含 years。
# 新值:三盘 Chara 年数与 PyJHora 4.8.7 对照行逐段相同,diff_steps 只剩 subperiod。
# 原因:BUG-1566 天蝎 / 水瓶双主星按盘面判定(一主在本宫取另一主)。PyJHora 对照行未重跑。
payload = json.loads(FIXTURE.read_text(encoding="utf-8"))
assert payload["pyjhora_version"] == "4.8.7"
assert payload["generator_python_has_pyjhora"] is True