feat(narayana): Rath start-sign seven-level rule, Ketu/Saturn exceptions, AD start rule; Table 18 not reproduced, not wired (BUG-1218)

Astrologer round-5 Q1-Q4 in scripts/narayana_rath.py (frozen narayana_dasha.py
untouched). Levels 1-2 are upstream c7117f92's rath_p36_role_conjunction_v1
counting (test compares with the upstream function); Ketu reversal is the
upstream rath_ketu_table12_v1 branch; Ketu first when Saturn shares the seed.
Book tables: 15 Cancer (level 1) and 17 Pisces (level 2) reproduce; 18 gives
Capricorn at level 5, so the Indira AK example (full tie only) is never
reached -> red line 3: no T4 wiring, no T5 trial.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-03 21:09:31 +08:00
co-authored by Claude Opus 5.5
parent 1c39988b3c
commit c41555f69e
3 changed files with 543 additions and 85 deletions
+45 -21
View File
@@ -1,4 +1,4 @@
"""Sanjay Rath Narayana Dasha profile vs the book tables (BUG-1215, round-4 Q4).
"""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`,
@@ -10,9 +10,12 @@ computed for (public figures / the book's own example chart):
- 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.
The start sign itself is not reproduced by the existing strength rule for
Tables 17 and 18 — pinned below as the current state (task red line 2: the
profile stays out of every user-visible surface and out of scoring).
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), so the Indira AK book example (allowed only
after all seven levels tie) is never reached. Task red line 3: the profile
stays out of every user-visible surface and out of scoring.
"""
from __future__ import annotations
@@ -72,32 +75,53 @@ def test_saturn_in_the_start_sign_gives_the_zodiacal_order() -> None:
assert md_order(SIGNS.index("Cancer"), lons)["direction"] == "table10_general"
def test_start_sign_selection_current_state() -> None:
"""Existing strength rule (occupants, then aspect by Jupiter / Mercury /
lord): Table 15 right; Table 17 picks Virgo (book Pisces: Pisces holds its
own lord Jupiter, which the aspect-only rule does not count); Table 18 ties
(book Cancer). Update this test together with the rule once the astrologer
answers; until then the profile is not wired anywhere."""
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")
assert (calc_narayana_rath(asc, lons)["status"], calc_narayana_rath(asc, lons)["seed_sign"]) == ("partial_verified", "Cancer")
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")
assert calc_narayana_rath(asc, lons)["seed_sign"] == "Virgo"
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"]]
assert [row["years"] for row in result["mahadasha"]] == [row[1] for row in _book("Table 17")["rows"]]
asc, lons = _chart("Table 18")
result = calc_narayana_rath(asc, lons)
assert result["status"] == "parameter_sensitive" and result["reason"] == "later_narayana_sign_strength_rules_required"
assert set(result["alternatives"]) == {"Cancer", "Capricorn"}
assert [row["sign"] for row in result["alternatives"]["Cancer"]["mahadasha"][:12]] == [row[0] for row in _book("Table 18")["rows"]]
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_is_twelve_equal_parts_from_the_md_sign() -> None:
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
rows = antardasha(md, lons)
assert [row["sign"] for row in rows][:3] == ["Cancer", "Gemini", "Taurus"] # Table 13, even sign reverse
assert all(abs(row["years"] - 7 / 12) < 1e-6 for row in rows)
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]
assert [row["sign"] for row in antardasha(md, lons)][:3] == ["Cancer", "Leo", "Virgo"] # Saturn in Cancer: Table 11
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_rath_profile_is_not_wired_into_any_product_path() -> None:
+197
View File
@@ -0,0 +1,197 @@
"""Sanjay Rath Narayana rules from the astrologer's round-5 ruling (BUG-1218).
Constructed charts (synthetic longitudes, no birth data) exercise each of the
seven start-sign levels, the Indira book-example path, the Saturn / Ketu
exceptions and the AD start rule. Two cases are ported from upstream
yinduzhanxing c7117f92: `tests/test_rath_role_strength.py::test_p36_two_roles_for_jupiter_and_one_opposite`
and the Saturn-seed case of `tests/test_varsha_narayana_native.py`
(`test_published_muntha_seed_saturn_exception_and_first_five_lengths`, MD part).
"""
from __future__ import annotations
import importlib.util
import random
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
for entry in (str(ROOT), str(ROOT / "scripts")):
if entry not in sys.path:
sys.path.insert(0, entry)
from scripts import narayana_dasha as nd # noqa: E402
from scripts.narayana_rath import ( # noqa: E402
GRAHAS, INDIRA_EXCEPTION, KETU_FIRST_NOTE, SIGN_LORDS, SIGNS, _role_candidate, antardasha,
calc_narayana_rath, compare_signs, md_order, mahadasha_sequence,
)
UPSTREAM = Path("/workspace/yinduzhanxing")
UPSTREAM_SHA = "c7117f92"
# (sign A, sign B, longitudes, expected winner, expected level). A/B are Lagna
# and 7th except level 4: opposite signs always share modality, so level 4 is
# shown on Aries vs Gemini (the comparator is generic).
LEVEL_CASES = {
1: (0, 6, {"Sun": 116.58, "Moon": 54.31, "Mars": 234.34, "Mercury": 26.08, "Jupiter": 192.92,
"Venus": 131.65, "Saturn": 20.88, "Rahu": 182.68, "Ketu": 2.68}, "Aries"),
2: (1, 7, {"Sun": 341.18, "Moon": 207.76, "Mars": 142.8, "Mercury": 351.45, "Jupiter": 16.77,
"Venus": 309.05, "Saturn": 104.26, "Rahu": 51.93, "Ketu": 231.93}, "Scorpio"),
3: (0, 6, {"Sun": 222.88, "Moon": 43.32, "Mars": 22.23, "Mercury": 245.64, "Jupiter": 191.06,
"Venus": 173.7, "Saturn": 279.54, "Rahu": 317.96, "Ketu": 137.96}, "Aries"),
4: (0, 2, {"Sun": 95.0, "Moon": 125.0, "Mars": 35.0, "Mercury": 45.0, "Jupiter": 55.0,
"Venus": 155.0, "Saturn": 275.0, "Rahu": 305.0, "Ketu": 125.0}, "Gemini"),
5: (9, 3, {"Sun": 30.94, "Moon": 150.54, "Mars": 86.64, "Mercury": 198.38, "Jupiter": 21.28,
"Venus": 203.56, "Saturn": 341.08, "Rahu": 227.03, "Ketu": 47.03}, "Capricorn"),
6: (3, 9, {"Sun": 136.2, "Moon": 178.62, "Mars": 80.38, "Mercury": 202.16, "Jupiter": 260.66,
"Venus": 355.0, "Saturn": 268.62, "Rahu": 0.8, "Ketu": 180.8}, "Capricorn"),
7: (6, 0, {"Sun": 195.37, "Moon": 223.57, "Mars": 268.44, "Mercury": 0.02, "Jupiter": 349.15,
"Venus": 328.44, "Saturn": 266.23, "Rahu": 223.1, "Ketu": 43.1}, "Aries"),
}
# Aries vs Libra tie on all seven levels: two occupants each (Venus + Moon in
# Aries, Mars + Sun in Libra), no role contact, no dignity, lords Mars / Venus
# both at 10 deg in each other's sign, six years each. Moon 29.5 deg is the AK.
TIE = {"Sun": 185.0, "Moon": 29.5, "Mars": 190.0, "Mercury": 335.0, "Jupiter": 65.0,
"Venus": 10.0, "Saturn": 275.0, "Rahu": 85.0, "Ketu": 265.0}
@pytest.mark.parametrize("level", sorted(LEVEL_CASES))
def test_each_level_decides_and_earlier_levels_tie(level: int) -> None:
first, second, lons, winner = LEVEL_CASES[level]
result = compare_signs(first, second, lons)
assert result["status"] == "selected"
assert (result["selected_sign"], result["strength_level_decided"]) == (winner, level)
a, b = (result["candidates"][SIGNS[idx]]["level_values"] for idx in (first, second))
assert a[:level - 1] == b[:level - 1]
assert a[level - 1] != b[level - 1]
def test_level_two_counts_roles_not_planets() -> None:
"""Pisces with Jupiter inside: Jupiter as Jupiter and as lord = two roles."""
lons = {"Sun": 0.0, "Moon": 30.0, "Mars": 165.0, "Mercury": 120.0, "Jupiter": 345.0,
"Venus": 60.0, "Saturn": 90.0, "Rahu": 180.0, "Ketu": 240.0}
result = compare_signs(11, 5, lons)
assert (result["selected_sign"], result["strength_level_decided"]) == ("Pisces", 2)
assert [row["role"] for row in result["candidates"]["Pisces"]["role_factors"]] == ["jupiter", "sign_lord"]
def test_ported_upstream_p36_two_roles_for_jupiter_and_one_opposite() -> None:
# Ported from upstream c7117f92 tests/test_rath_role_strength.py.
positions = dict(Jupiter=345., Mars=165., Mercury=120., Sun=0., Moon=30.,
Venus=60., Saturn=90., Rahu=180., Ketu=240.)
r = calc_narayana_rath(11, positions)
assert r["seed_sign"] == "Pisces"
candidates = r["seed_selection"]["candidates"]
assert candidates["Pisces"]["aspect_factor_count"] == 2
assert candidates["Virgo"]["aspect_factor_count"] == 1
assert candidates["Pisces"]["role_factors"] == [
dict(role="jupiter", planet="Jupiter", contact="conjunction"),
dict(role="sign_lord", planet="Jupiter", contact="conjunction")]
# The legacy aspect-only rule (frozen narayana_dasha) picks Virgo here.
assert nd.select_narayana_seed_sign(11, positions)["selected_sign_idx"] == 5
def _upstream_narayana():
if not (UPSTREAM / ".git").exists():
pytest.skip("upstream checkout not on this machine")
source = subprocess.run(["git", "show", f"{UPSTREAM_SHA}:scripts/narayana_dasha.py"], cwd=UPSTREAM,
capture_output=True, text=True, check=False)
if source.returncode != 0:
pytest.skip("upstream commit not available")
spec = importlib.util.spec_from_loader("upstream_narayana_c7117f92", loader=None)
module = importlib.util.module_from_spec(spec)
exec(compile(source.stdout, "upstream_narayana_dasha.py", "exec"), module.__dict__)
return module
def test_levels_one_two_equal_upstream_role_profile() -> None:
"""Levels 1-2 are upstream's rath_p36_role_conjunction_v1 counting."""
upstream = _upstream_narayana()
rng = random.Random(1003)
for _ in range(400):
lons = {planet: rng.uniform(0, 360) for planet in GRAHAS}
lagna = rng.randrange(12)
theirs = upstream.select_narayana_seed_sign(lagna, lons, strength_profile="rath_p36_role_conjunction_v1")
for idx in (lagna, (lagna + 6) % 12):
ours = _role_candidate(idx, lons, SIGN_LORDS[SIGNS[idx]])
for key in ("occupants", "occupant_count", "aspect_factors", "aspect_factor_count", "role_factors"):
assert ours[key] == theirs["candidates"][idx][key]
def test_full_tie_uses_the_indira_example_only_when_allowed() -> None:
plain = compare_signs(0, 6, TIE)
assert plain["status"] == "tie" and plain["strength_level_decided"] is None
flagged = compare_signs(0, 6, TIE, allow_book_example=True)
assert (flagged["selected_sign"], flagged["book_example_exception"], flagged["atmakaraka"]) == ("Aries", INDIRA_EXCEPTION, "Moon")
result = calc_narayana_rath(0, TIE, allow_book_example=True)
assert result["status"] == "book_example_exception"
assert result["book_example_exception"] == INDIRA_EXCEPTION
tied = calc_narayana_rath(0, TIE)
assert tied["status"] == "parameter_sensitive" and tied["mahadasha"] is None
assert set(tied["alternatives"]) == {"Aries", "Libra"}
def test_book_example_never_overrides_a_general_level() -> None:
first, second, lons, winner = LEVEL_CASES[5]
assert compare_signs(first, second, lons, allow_book_example=True)["book_example_exception"] is None
def _with(lons: dict, **moves: float) -> dict:
return {**lons, **moves}
BASE = {"Sun": 95.0, "Moon": 125.0, "Mars": 35.0, "Mercury": 45.0, "Jupiter": 55.0,
"Venus": 155.0, "Saturn": 275.0, "Rahu": 305.0, "Ketu": 125.0}
def test_ketu_reverses_table10_keeping_the_steps() -> None:
lons = _with(BASE, Rahu=245.0, Ketu=65.0) # Ketu in Gemini (dual: steps of 4/5/... differ from a read-back)
table10 = nd.build_narayana_sign_sequence(2, profile="rath_general_table10_v1")
order = md_order(2, lons)
assert order["direction"] == "table12_ketu_reversed" and order["exception_applied"] == "ketu_in_start_sign"
assert order["order"] == [2, 6, 10, 11, 3, 7, 8, 0, 4, 5, 9, 1]
assert [(b - a) % 12 for a, b in zip(order["order"], order["order"][1:])] == \
[(a - b) % 12 for a, b in zip(table10, table10[1:])]
assert order["order"] != [2] + table10[:0:-1] # not the table read backwards
def test_saturn_runs_zodiacal_and_ketu_wins_when_both() -> None:
saturn = md_order(1, _with(BASE, Saturn=40.0))
assert saturn["direction"] == "table11_saturn_zodiacal"
assert saturn["order"] == [(1 + i) % 12 for i in range(12)]
both = md_order(1, _with(BASE, Saturn=40.0, Rahu=215.0, Ketu=35.0))
assert both["direction"] == "table12_ketu_reversed"
assert both["method_note"] == KETU_FIRST_NOTE == "PyJHora 兼容:计都优先"
def test_ported_upstream_varsha_saturn_seed_order_and_lengths() -> None:
# Ported from upstream c7117f92 tests/test_varsha_narayana_native.py: PVR
# textbook Chart 67 D9 positions; seed Scorpio holds Saturn -> Table 11;
# first five MD [7, 4, 2, 3, 11] with Ketu as Scorpio's lord.
rasi = dict(Sun=47+4/60, Moon=184+48/60, Mars=113+53/60, Mercury=64+47/60, Jupiter=160+59/60,
Venus=1+37/60, Saturn=306+29/60, Rahu=228+38/60, Ketu=48+38/60)
d9 = {planet: lon * 9 % 360 for planet, lon in rasi.items()}
seq = mahadasha_sequence(7, d9, cycle_count=1)
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]
assert first[0]["lord"] == "Ketu"
def test_output_fields() -> None:
first, second, lons, _ = LEVEL_CASES[1]
result = calc_narayana_rath(first, lons)
for key in ("profile", "seed_sign", "seed_basis", "strength_level_decided", "direction", "exception_applied",
"dual_lord_rule", "mahadasha", "antardasha_start_rule", "source", "status"):
assert key in result, key
assert result["profile"] == "sanjay_rath"
assert result["seed_basis"] == "stronger_of_lagna_seventh:level_1_occupant_count"
assert len(result["mahadasha"]) == 24
ad = antardasha(result["mahadasha"][0], lons)
assert ad["status"] == "selected" and len(ad["rows"]) == 12
assert ad["antardasha_start_rule"]["strength_level_decided"] in range(1, 8)