feat(narayana-rath): follow book p.43 for dual lords and node years (BUG-1227~1229)
Independent Staging Quality Gate / validate (push) Failing after 16m17s
Independent Staging Quality Gate / publish (push) Skipped

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>
This commit is contained in:
Jesse_Chen
2026-10-04 18:37:34 +08:00
co-authored by Cursor
parent 64c652eea2
commit baf4ae8b34
15 changed files with 604 additions and 84 deletions
+2 -2
View File
@@ -94,9 +94,9 @@ def test_reference_edition_shows_both_and_english_terms_cover_the_rath_lines() -
packet = _packet(CASES["day"])
zh = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "reference")
legacy = zh.index(f"- 算法:{LEGACY_NOTE_ZH}")
rath = zh.index("- 并列:Sanjay Rath 版(并列参考,未进打分与时间轴结论):起运宫 ")
rath = zh.index("- 并列:Sanjay Rath 版(并列参考,未进打分与时间轴结论;按原书 p.43 正文(第七轮裁定)):起运宫 ")
assert legacy < rath
assert "| Rath MD | 星座 | 宫主 | 起始年龄 | 结束年龄 | 年数 |" in zh and "| Rath AD | 星座 | 起始年龄 | 结束年龄 |" in zh
assert "| Rath MD | 星座 | 宫主 | 起始年龄 | 结束年龄 | 基本年数 | 修正 | 最终 |" in zh and "| Rath AD | 星座 | 起始年龄 | 结束年龄 |" in zh
view = packet["worksheets"]["timing_and_predictive_systems"]["narayana_dasha"]["rath"]
for line in rath_report_lines_zh(view):
text = line
+37 -16
View File
@@ -70,7 +70,11 @@ def test_book_start_sign_reproduces_order_and_years(table: str) -> None:
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"]]
assert [row["years"] for row in result["mahadasha"]] == [row[1] 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:
@@ -96,7 +100,10 @@ def test_start_sign_by_the_seven_level_rule() -> None:
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"]]
assert [row["years"] for row in result["mahadasha"]] == [row[1] 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)
@@ -138,7 +145,10 @@ def test_table18_only_with_the_explicit_cancer_seed_and_flagged() -> None:
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"]]
assert [row["years"] for row in result["mahadasha"]] == [row[1] 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):
@@ -168,20 +178,25 @@ VERDICT_TABLES = {
# 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 (Aquarius, Scorpio): book Table 23 Aquarius = Saturn (ours Rahu),
# Table 25 Scorpio = Ketu (ours Mars). Round-6 ruling Q6a (p.43 (d): compare
# the signs the two lords occupy) was tried in batch 5 (BUG-1227) and NOT
# landed: it gives 9/10 here (new miss Table 23 Scorpio) but drops the Table 17
# years from 12/12 to 10/12 (Scorpio 10 -> 1 by Ketu, Aquarius 8 -> 1 by
# Saturn) — the book's standard nativity (Table 17 / Chart 3, and the p.43 (e)
# example) conflicts with its own Table 23 verdict. Kept at 8/10 (red line).
EXPECTED_CO_LORD_MISSES = {("Table 23", "Aquarius"), ("Table 25", "Scorpio")}
# 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_8_of_10() -> None:
from scripts.narayana_rath import _lord, compare_signs
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()}
@@ -195,11 +210,17 @@ def test_book_software_verdicts_28_of_30_and_co_lords_8_of_10() -> None:
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):
if _lord(sign_idx, lons) != want:
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
assert co_lord_misses == EXPECTED_CO_LORD_MISSES # 8/10
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
+77 -1
View File
@@ -183,8 +183,12 @@ def test_ported_upstream_varsha_saturn_seed_order_and_lengths() -> None:
first = seq["mahadasha"][:5]
assert seq["direction"] == "table11_saturn_zodiacal"
assert [row["sign_idx"] for row in first] == [7, 8, 9, 10, 11]
assert [row["years"] for row in first] == [7, 4, 2, 3, 11]
# 原值:第一段 7 年(计都在双子,年数不减落陷)
# 新值:第一段 6 年(基本 7,计都在双子落陷 −1);主星仍是计都,第 1 级
# 原因:BUG-1229,第七轮 p.40 (a),罗计按 Phalita 旺陷加减
assert [row["years"] for row in first] == [6, 4, 2, 3, 11]
assert first[0]["lord"] == "Ketu"
assert (first[0]["base_years"], first[0]["dignity_adjustment"], first[0]["adjustment_source"]) == (7, -1, "p40a_phalita_nodes")
def test_output_fields() -> None:
@@ -295,3 +299,75 @@ def test_book_divergence_lists_the_two_other_statements() -> None:
p47 = direction["book_divergence"][0]
assert p47["direction"] == "reverse" # Taurus is an even sign
assert p47["differs"] == (p47["direction"] != direction["direction"])
# --- BUG-1227 / BUG-1228 / BUG-1229: round-7 ruling (p.43 option A, only Ketu
# reversed, node exaltation ±1). Synthetic longitudes, no birth data.
def _nine(**placed: float) -> dict:
chart = {"Sun": 5.0, "Moon": 6.0, "Mars": 7.0, "Mercury": 8.0, "Jupiter": 9.0,
"Venus": 11.0, "Saturn": 12.0, "Rahu": 13.0, "Ketu": 193.0}
chart.update(placed)
return chart
def test_mars_in_aries_and_saturn_in_capricorn_do_not_trigger_rule_c() -> None:
"""附问:火星在白羊、土星在摩羯不是 p.43 (c) 的「在本宫」。若误触发,会改取外面那颗。"""
from scripts.narayana_rath import resolve_dual_lord
scorpio = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(
Mars=10.0, Sun=11.0, Moon=12.0, Ketu=65.0, Rahu=245.0))
assert (scorpio["rule"], scorpio["lord"]) == ("d", "Mars")
aquarius = resolve_dual_lord(SIGNS.index("Aquarius"), _nine(
Saturn=280.0, Sun=281.0, Moon=282.0, Rahu=70.0, Ketu=250.0))
assert (aquarius["rule"], aquarius["lord"]) == ("d", "Saturn")
def test_same_sign_degree_reverses_only_ketu_and_ties_stay_undecided() -> None:
from scripts.narayana_rath import _strength_degree, resolve_dual_lord
assert _strength_degree("Ketu", 0.0) == 30.0
assert _strength_degree("Ketu", 27.0) == 3.0
assert _strength_degree("Rahu", 27.0) == 27.0
assert _strength_degree("Saturn", 14.0) == 14.0
rahu = resolve_dual_lord(SIGNS.index("Aquarius"), _nine(Saturn=44.0, Rahu=57.0, Ketu=237.0))
assert (rahu["status"], rahu["rule"], rahu["identity_rule"], rahu["lord"]) == ("selected", "b", "p116_longitude", "Rahu")
mars = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(Mars=134.0, Ketu=147.0, Rahu=327.0))
assert (mars["status"], mars["rule"], mars["lord"]) == ("selected", "b", "Mars")
ketu_zero = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(Mars=29.0, Ketu=0.0, Rahu=180.0))
assert (ketu_zero["rule"], ketu_zero["lord"]) == ("b", "Ketu")
tied = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(Mars=10.0, Ketu=20.0, Rahu=200.0))
assert (tied["status"], tied["lord"], tied["reason"]) == ("undecided", None, "equal_degree")
def test_rahu_degree_at_level_five_is_not_reversed() -> None:
from scripts.narayana_rath import sign_strength
row = sign_strength(SIGNS.index("Aquarius"), _nine(Saturn=44.0, Rahu=57.0, Ketu=237.0))
assert row["lord"] == "Rahu"
assert row["lord_degree_in_sign"] == 27.0
def test_node_dignity_adjusts_the_period_and_clamps() -> None:
from scripts.narayana_rath import rath_period_years
ketu = rath_period_years(SIGNS.index("Scorpio"), "Ketu", SIGNS.index("Sagittarius"))
assert (ketu["base_years"], ketu["dignity_adjustment"], ketu["years"], ketu["adjustment_source"]) == (1, 1, 2, "p40a_phalita_nodes")
rahu = rath_period_years(SIGNS.index("Aquarius"), "Rahu", SIGNS.index("Gemini"))
assert (rahu["base_years"], rahu["dignity_adjustment"], rahu["years"]) == (8, 1, 9)
mars = rath_period_years(SIGNS.index("Aries"), "Mars", SIGNS.index("Capricorn"))
assert mars["adjustment_source"] == "p40a_grahas" and mars["dignity_adjustment"] == 1
def test_dual_lord_cycle_stays_undecided() -> None:
"""Scorpio's lords sit in Aquarius and Taurus (tied). Aquarius's lords then
sit in Leo and Scorpio (tied), which asks for Scorpio again."""
from scripts.narayana_rath import resolve_dual_lord
decision = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(
Mars=305.0, Ketu=35.0, Rahu=215.0, Saturn=125.0,
Sun=5.0, Moon=6.0, Mercury=7.0, Jupiter=8.0, Venus=9.0))
assert decision["status"] == "undecided"
assert decision["lord"] is None
assert decision["rule"] == "d"