Files
Jyotisha/tests/test_narayana_rath_book_tables.py
T
Jesse_ChenandCursor baf4ae8b34
Independent Staging Quality Gate / validate (push) Failing after 16m17s
Independent Staging Quality Gate / publish (push) Skipped
feat(narayana-rath): follow book p.43 for dual lords and node years (BUG-1227~1229)
Round-7 option A is text-first, so Scorpio and Aquarius lords, the Ketu-only degree reversal, and node exaltation years now live in the Rath display layer. Scoring files stay unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 18:37:34 +08:00

314 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Sanjay Rath Narayana Dasha profile vs the book tables (BUG-1215 / BUG-1218).
The rule ledger and the book rows are ported verbatim from upstream
yinduzhanxing c7117f92 (`references/oracle/sanjay_rath_narayana_book_ledger_2026_08_28.json`,
Tables 15 / 17 / 18). Birth data are the published charts the book tables are
computed for (public figures / the book's own example chart):
- Table 15: Sri Aurobindo, 1872-08-15 05:00 LMT, Calcutta 22N34 88E22;
- Table 17: the book's "standard nativity", 1963-08-07 21:15 IST, Sambalpur 21N28 84E01;
- Table 18: Indira Gandhi, 1917-11-19 23:11 IST, Allahabad 25N27 81E51.
Lahiri ayanamsa. With the book's start sign, all 36 MD signs and years match.
Start sign by the round-5 seven-level rule (astrologer 2026-10-03): Table 15
Cancer (level 1) and Table 17 Pisces (level 2) reproduce; Table 18 does not —
the general rule decides Capricorn at level 5 (Cancer lord Moon 5.6 deg vs
Capricorn lord Saturn 21.8 deg). Red line 3 as revised by the product owner
(2026-10-03 evening, audit docs/research/shared_engine_reuse_audit_2026_10_03.md):
Table 18 is a book-internal contradiction (the hand example's reasoning reads
literally as Capricorn and the book's Table 27 software verdict is Capricorn),
reproduced only with the Cancer seed given explicitly and flagged
`book_example_exception`; the general rule's Capricorn is output unchanged.
Further acceptance evidence below: the book's five D1 software-verdict tables
(30 sign pairs, 28 match) and Table 25's 36 sub-period rows (24 match).
"""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
import pytest
from scripts.jyotish_engine import compute_chart_data
from scripts.narayana_rath import SIGNS, antardasha, calc_narayana_rath, md_order
ROOT = Path(__file__).resolve().parents[1]
LEDGER = ROOT / "references/oracle/sanjay_rath_narayana_book_ledger_2026_08_28.json"
UPSTREAM_BLOBS = { # c7117f92
"references/oracle/sanjay_rath_narayana_book_ledger_2026_08_28.json": "a52f181c22ba1b1e6a330b73711eb0e791785e56",
"references/oracle/chara_narayana_book_golden_cases_2026_08_28.json": "552c40448502c15a47035da064914dd76a4d919b",
"docs/research/sanjay_rath_narayana_book_ledger_2026_08_28.md": "8d18c2320d32dcd364784351ac52dc8bcb4ef053",
}
CHARTS = {
"Table 15": (1872, 8, 15, 5, 0, 22.5667, 88.3667, 88.3667 / 15),
"Table 17": (1963, 8, 7, 21, 15, 21.4667, 84.0167, 5.5),
"Table 18": (1917, 11, 19, 23, 11, 25.45, 81.85, 5.5),
}
def _book(table: str) -> dict:
return next(case for case in json.loads(LEDGER.read_text(encoding="utf-8"))["golden_cases"] if case["source_table"] == table)
def _chart(table: str):
chart, asc, *_ = compute_chart_data(*CHARTS[table], ayanamsa_name="lahiri")
return asc, {name: row["degree"] for name, row in chart["planets"].items()}
@pytest.mark.parametrize("path,blob", sorted(UPSTREAM_BLOBS.items()))
def test_ported_files_are_the_upstream_blobs(path: str, blob: str) -> None:
data = (ROOT / path).read_bytes()
assert hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() == blob
@pytest.mark.parametrize("table", sorted(CHARTS))
def test_book_start_sign_reproduces_order_and_years(table: str) -> None:
book = _book(table)
asc, lons = _chart(table)
result = calc_narayana_rath(asc, lons, seed_sign_idx=SIGNS.index(book["start_sign"]), cycle_count=1)
assert [row["sign"] for row in result["mahadasha"]] == [row[0] for row in book["rows"]]
# 原值:年数逐行等于书印值
# 新值:Table 15 仍等于书印值;Table 17 / 18 见 NEW_EXPLICIT_YEARS(年数不再作通过线)
# 原因:BUG-1227 第七轮选 A,加上 BUG-1229 罗计旺陷 ±1
expected_years = NEW_EXPLICIT_YEARS.get(table, [row[1] for row in book["rows"]])
assert [row["years"] for row in result["mahadasha"]] == expected_years
def test_saturn_in_the_start_sign_gives_the_zodiacal_order() -> None:
asc, lons = _chart("Table 18") # Saturn in Cancer
assert md_order(SIGNS.index("Cancer"), lons)["direction"] == "table11_saturn_zodiacal"
asc, lons = _chart("Table 15")
assert md_order(SIGNS.index("Cancer"), lons)["direction"] == "table10_general"
def test_start_sign_by_the_seven_level_rule() -> None:
"""原值:现状钉住——Table 17 选出处女(原书双鱼),Table 18 平局(巨蟹 / 摩羯),只用前两级
新值:七级通则——Table 15 巨蟹(第 1 级)、Table 17 双鱼(第 2 级,三角色木星兼宫主记两次)、
Table 18 摩羯(第 5 级,宫主度数),与原书巨蟹不符;书内特例(AK)只在七级全平时可用,此盘够不着
原因:BUG-1218,占星师第五轮问 1、问 2"""
asc, lons = _chart("Table 15")
result = calc_narayana_rath(asc, lons, cycle_count=1)
assert (result["status"], result["seed_sign"], result["strength_level_decided"]) == ("partial_verified", "Cancer", 1)
assert [row["sign"] for row in result["mahadasha"]] == [row[0] for row in _book("Table 15")["rows"]]
asc, lons = _chart("Table 17")
result = calc_narayana_rath(asc, lons, cycle_count=1)
assert (result["status"], result["seed_sign"], result["strength_level_decided"]) == ("partial_verified", "Pisces", 2)
pisces = result["seed_selection"]["candidates"]["Pisces"]
assert [row["role"] for row in pisces["role_factors"]] == ["jupiter", "sign_lord"]
assert result["seed_selection"]["candidates"]["Virgo"]["aspect_factor_count"] == 1
assert [row["sign"] for row in result["mahadasha"]] == [row[0] for row in _book("Table 17")["rows"]]
# 原值:年数 12/12 等于 Table 17 书印值(天蝎 10、水瓶 8)
# 新值:10/12;天蝎计都 2(基本 1,入旺 +1),水瓶土星 1。Chart 3 水瓶印 9
# 原因:BUG-1227 第七轮选 A,年数不再作通过线;BUG-1229
assert [row["years"] for row in result["mahadasha"]] == NEW_EXPLICIT_YEARS["Table 17"]
asc, lons = _chart("Table 18")
for allow in (False, True):
result = calc_narayana_rath(asc, lons, cycle_count=1, allow_book_example=allow)
assert (result["seed_sign"], result["strength_level_decided"]) == ("Capricorn", 5)
assert result["book_example_exception"] is None
values = {sign: row["level_values"] for sign, row in result["seed_selection"]["candidates"].items()}
assert values["Cancer"][:4] == values["Capricorn"][:4] # occupants 1:1, roles 2:2, no dignity, both movable
assert values["Cancer"][4] < values["Capricorn"][4] # Moon 5.6 deg < Saturn 21.8 deg
assert _book("Table 18")["start_sign"] == "Cancer"
def test_antardasha_starts_from_the_lord_of_the_stronger_of_md_and_seventh() -> None:
"""原值:子运从大运星座本身起(Table 13),巨蟹大运 → 巨蟹、双子、金牛…
新值:比较大运星座与其第 7 宫,取较强者,从该宫宫主所在星座起,12 等分;Table 13 / 11 / 14 由大运星座里的土星 / 计都决定
原因:BUG-1218,占星师第五轮问 4(p.48 Arambha Rasi,p.50 说明 2)"""
asc, lons = _chart("Table 15")
md = calc_narayana_rath(asc, lons, cycle_count=1)["mahadasha"][0] # Cancer, 7 years; Mars and Jupiter there
ad = antardasha(md, lons)
rule = ad["antardasha_start_rule"]
assert (rule["stronger_sign"], rule["strength_level_decided"], rule["lord"], rule["start_sign"]) == ("Cancer", 1, "Moon", "Sagittarius")
assert rule["order_profile"] == "rath_table13_v1"
assert [row["sign"] for row in ad["rows"]][:3] == ["Sagittarius", "Capricorn", "Aquarius"] # Table 13, odd sign forward
assert all(abs(row["years"] - 7 / 12) < 1e-6 for row in ad["rows"])
asc, lons = _chart("Table 18")
md = calc_narayana_rath(asc, lons, seed_sign_idx=SIGNS.index("Cancer"), cycle_count=1)["mahadasha"][0]
ad = antardasha(md, lons)
rule = ad["antardasha_start_rule"]
# Cancer vs Capricorn: Capricorn at level 5; its lord Saturn sits in Cancer; Saturn in the MD sign -> Table 11.
assert (rule["stronger_sign"], rule["lord"], rule["start_sign"], rule["order_profile"]) == ("Capricorn", "Saturn", "Cancer", "rath_saturn_table11_v1")
assert [row["sign"] for row in ad["rows"]][:3] == ["Cancer", "Leo", "Virgo"]
def test_table18_only_with_the_explicit_cancer_seed_and_flagged() -> None:
asc, lons = _chart("Table 18")
book = _book("Table 18")
result = calc_narayana_rath(asc, lons, seed_sign_idx=SIGNS.index("Cancer"), cycle_count=1,
book_example="rath_p66_indira_ak")
assert result["status"] == "book_example_exception"
assert result["book_example_exception"] == "rath_p66_indira_ak"
assert result["book_example"]["book_internal_contradiction"] is True
assert [row["sign"] for row in result["mahadasha"]] == [row[0] for row in book["rows"]]
# 原值:显式巨蟹起运后年数等于书印值(水瓶 2)
# 新值:水瓶罗睺 1(基本 2,落陷 −1),其余 11 段不变
# 原因:BUG-1229,罗计按 Phalita 旺陷加减;顺序仍是 12/12
assert [row["years"] for row in result["mahadasha"]] == NEW_EXPLICIT_YEARS["Table 18"]
# The general rule's own verdict travels with it, unchanged.
assert (result["general_rule_selection"]["selected_sign"], result["general_rule_selection"]["strength_level_decided"]) == ("Capricorn", 5)
with pytest.raises(ValueError):
calc_narayana_rath(asc, lons, book_example="rath_p66_indira_ak")
# Book software verdicts ("Stronger planets and signs") for five D1 charts:
# Rath, Narayana Dasa, Tables 22 / 23 / 24 / 25 / 27 (book example charts
# 14, 15 = the standard nativity, 16, 17 = Krishnaraja Wadiyar IV, 22 = Indira
# Gandhi). Inputs and verdicts as transcribed in the audit (da95d92d).
def _dms(d: float, m: float) -> float:
return d + m / 60
AB = dict(Ar=0, Ta=1, Ge=2, Cn=3, Le=4, Vi=5, Li=6, Sc=7, Sg=8, Cp=9, Aq=10, Pi=11)
VERDICT_TABLES = {
"Table 22": ((1962, 3, 28, 6, 29, _dms(22, 6), _dms(85, 17), 5.5), ("Saturn", "Mars"), "Ar Sc Ge Cp Aq Pi"),
"Table 23": ((1963, 8, 7, 21, 15, _dms(21, 28), _dms(84, 1), 5.5), ("Saturn", "Mars"), "Li Ta Sg Cn Le Pi"),
"Table 24": ((1954, 12, 19, 0, 20, _dms(28, 36), _dms(77, 12), 5.5), ("Saturn", "Mars"), "Li Sc Sg Cn Aq Vi"),
"Table 25": ((1884, 6, 4, 10, 18, 12.0, _dms(76, 38), _dms(76, 38) / 15), ("Saturn", "Ketu"), "Li Ta Ge Cn Le Vi"),
"Table 27": ((1917, 11, 19, 23, 8, _dms(25, 28), _dms(81, 52), 5.5), ("Rahu", "Mars"), "Ar Sc Sg Cp Le Pi"),
}
# Expected misses, documented: Wadiyar Gemini/Sagittarius and Virgo/Pisces —
# the book takes Gemini and Virgo; the general rule decides at level 5
# (Mercury 1.3 deg vs Jupiter 10.0 deg) for Sagittarius and Pisces. Not
# sensitive to node mode or ayanamsa (audit). Round-6 ruling Q6b: the book's
# software used Rule 8 at the seventh level — a software special case; keep
# the general result, flagged `book_software_rule8_exception` (BUG-1226).
EXPECTED_SIGN_MISSES = {("Table 25", "Gemini", "Sagittarius"), ("Table 25", "Virgo", "Pisces")}
# Dual lords after round-7 option A (BUG-1227 resolved). 9/10: the one miss is
# Table 23 Scorpio (book Mars, p.43 (d) level 2 takes Ketu because Sagittarius
# has two Jupiter roles). Table 17 years are no longer a pass line:
# Scorpio book 10 -> Ketu 2 (base 1, exalted +1); Aquarius book 8 -> Saturn 1.
# Chart 3 prints Aquarius 9 (Rahu 8+1). Table 18 Aquarius book 2 -> Rahu 1
# (base 2, debilitated -1).
EXPECTED_CO_LORD_MISSES = {("Table 23", "Scorpio")}
# Explicit-seed years that no longer match the printed book rows. Order is unchanged.
NEW_EXPLICIT_YEARS = {
"Table 17": [12, 5, 2, 3, 5, 1, 1, 12, 2, 2, 9, 1],
"Table 18": [6, 9, 10, 2, 9, 5, 6, 1, 10, 4, 7, 5],
}
def test_book_software_verdicts_28_of_30_and_co_lords_9_of_10() -> None:
from scripts.narayana_rath import compare_signs, resolve_dual_lord
sign_misses, co_lord_misses, pairs = set(), set(), 0
decisions = []
for table, (birth, co_lords, stronger) in VERDICT_TABLES.items():
chart, _asc, *_ = compute_chart_data(*birth, ayanamsa_name="lahiri")
lons = {name: row["degree"] for name, row in chart["planets"].items()}
book = {AB[code] for code in stronger.split()}
for first in range(6):
second = first + 6
winner = first if first in book else second
result = compare_signs(first, second, lons)
pairs += 1
if result.get("selected_sign_idx") != winner:
assert result["strength_level_decided"] == 5
sign_misses.add((table, SIGNS[winner], SIGNS[second if winner == first else first]))
for sign_idx, want in zip((10, 7), co_lords):
decision = resolve_dual_lord(sign_idx, lons)
decisions.append((table, SIGNS[sign_idx], want, decision))
if decision.get("lord") != want:
co_lord_misses.add((table, SIGNS[sign_idx]))
assert pairs == 30
assert sign_misses == EXPECTED_SIGN_MISSES # 28/30, same two Wadiyar pairs
assert co_lord_misses == EXPECTED_CO_LORD_MISSES # 9/10
assert len(decisions) == 10
assert all(row[3]["rule"] == "d" and row[3]["status"] == "selected" for row in decisions)
scorpio = next(row[3] for row in decisions if row[0] == "Table 23" and row[1] == "Scorpio")
assert (scorpio["lord"], scorpio["strength_level_decided"]) == ("Ketu", 2)
# Table 25 (Wadiyar) sub-periods for the first three MDs, from upstream
# c7117f92 references/oracle/chara_narayana_rath_wadiyar_table25_replay_2026_09_07.json.
TABLE25_AD = {
"Cancer": ["Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo"],
"Gemini": ["Taurus", "Aries", "Pisces", "Aquarius", "Capricorn", "Sagittarius", "Scorpio", "Libra", "Virgo", "Leo", "Cancer", "Gemini"],
"Taurus": ["Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", "Aries", "Taurus", "Gemini"],
}
def test_table25_sub_periods_24_of_36() -> None:
"""Cancer and Taurus (Saturn inside, Table 11) MDs: 12/12. Gemini MD: 0/12 —
the Gemini/Sagittarius comparison picks Sagittarius (level 5, the same
Wadiyar miss as above), so the ADs start from Jupiter's sign Cancer
instead of Mercury's sign Taurus. Current state, pinned."""
chart, asc, *_ = compute_chart_data(*VERDICT_TABLES["Table 25"][0], ayanamsa_name="lahiri")
lons = {name: row["degree"] for name, row in chart["planets"].items()}
mds = calc_narayana_rath(asc, lons, seed_sign_idx=SIGNS.index("Cancer"), cycle_count=1)["mahadasha"][:3]
assert [row["sign"] for row in mds] == ["Cancer", "Gemini", "Taurus"]
matched = {}
for md in mds:
rows = [row["sign"] for row in antardasha(md, lons)["rows"]]
matched[md["sign"]] = sum(a == b for a, b in zip(rows, TABLE25_AD[md["sign"]]))
assert matched == {"Cancer": 12, "Gemini": 0, "Taurus": 12}
gemini = antardasha(mds[1], lons)["antardasha_start_rule"]
assert (gemini["stronger_sign"], gemini["strength_level_decided"], gemini["start_sign"]) == ("Sagittarius", 5, "Cancer")
def test_rath_profile_is_display_only() -> None:
"""原值:没有任何脚本导入 narayana_rath(`users == []`)
新值:只有展示层 narayana_legacy_label 导入它;冻结的打分文件与 rectification/ 都不导入展示层或 Rath 模块
原因:BUG-1218 T4(产品负责人 2026-10-03 晚恢复 T4):Rath 版与旧算法并列显示,打分与时间轴结论仍用旧算法"""
pattern = re.compile(r"\bnarayana_rath\b")
users = sorted(path.name for path in (ROOT / "scripts").rglob("*.py")
if path.name != "narayana_rath.py" and pattern.search(path.read_text(encoding="utf-8")))
assert users == ["narayana_legacy_label.py"]
frozen = json.loads((ROOT / "references/rectification_sealed_holdout.v1.json").read_text(encoding="utf-8"))
scoring = set(_production_files(frozen)) | {str(p.relative_to(ROOT)) for p in (ROOT / "scripts/rectification").rglob("*.py")}
display = re.compile(r"\b(narayana_rath|narayana_legacy_label)\b")
assert sorted(path for path in scoring if display.search((ROOT / path).read_text(encoding="utf-8"))) == []
def _production_files(record) -> list:
found = []
if isinstance(record, dict):
for key, value in record.items():
if key == "production_scoring_files" and isinstance(value, list):
found.extend(value)
else:
found.extend(_production_files(value))
elif isinstance(record, list):
for value in record:
found.extend(_production_files(value))
return found
def test_table25_taurus_md_is_the_saturn_exception_and_carries_the_book_divergence() -> None:
"""Round-6 ruling Q7 / BUG-1225: Saturn in the Taurus MD sign -> ADs always
zodiacal (p.51), 12/12 with the book; the p.47 / J.S. 2.4.31-32 statements
are listed as a book divergence, not applied."""
chart, asc, *_ = compute_chart_data(*VERDICT_TABLES["Table 25"][0], ayanamsa_name="lahiri")
lons = {name: row["degree"] for name, row in chart["planets"].items()}
taurus = calc_narayana_rath(asc, lons, seed_sign_idx=SIGNS.index("Cancer"), cycle_count=1)["mahadasha"][2]
ad = antardasha(taurus, lons)
rule = ad["antardasha_start_rule"]
assert taurus["sign"] == "Taurus" and rule["order_profile"] == "rath_saturn_table11_v1"
assert [row["sign"] for row in ad["rows"]] == TABLE25_AD["Taurus"]
assert rule["direction"]["direction"] == "zodiacal"
p47, js = rule["direction"]["book_divergence"]
assert (p47["direction"], p47["differs"]) == ("reverse", True) # Taurus is even: p.47 would run the ADs backwards
def test_wadiyar_pairs_are_the_flagged_book_software_rule8_exception() -> None:
"""Round-6 ruling Q6b (BUG-1226): the two Table 25 misses are exactly the
registered `book_software_rule8_exception` pairs; the general result is
kept and the note travels with every Rath output."""
from scripts.narayana_rath import BOOK_SOFTWARE_DIFFERENCES, BOOK_SOFTWARE_RULE8, compare_signs
entry = BOOK_SOFTWARE_DIFFERENCES[BOOK_SOFTWARE_RULE8]
assert BOOK_SOFTWARE_RULE8 == "book_software_rule8_exception"
flagged = {(entry["book_table"], pair["book"], pair["general_rule"]) for pair in entry["pairs"]}
assert flagged == EXPECTED_SIGN_MISSES
assert entry["kept"] == "general_rule" and entry["note"] and entry["note_en"]
chart, asc, *_ = compute_chart_data(*VERDICT_TABLES["Table 25"][0], ayanamsa_name="lahiri")
lons = {name: row["degree"] for name, row in chart["planets"].items()}
for pair in entry["pairs"]:
result = compare_signs(SIGNS.index(pair["book"]), SIGNS.index(pair["general_rule"]), lons)
assert (result["selected_sign"], result["strength_level_decided"]) == (pair["general_rule"], pair["general_level"])
assert calc_narayana_rath(asc, lons, cycle_count=1)["known_book_differences"][BOOK_SOFTWARE_RULE8] == entry