diff --git a/scripts/narayana_rath.py b/scripts/narayana_rath.py index b33c90da..f098a4a0 100644 --- a/scripts/narayana_rath.py +++ b/scripts/narayana_rath.py @@ -52,8 +52,10 @@ upstream function). Tables 10 / 11 / 13 / 14, `_rasi_aspects`, the year and dual-lord functions are the same in both repositories and are reused from the frozen local `narayana_dasha.py`, which this module does not modify. -Not wired into scoring, consultation or reports (task red line 3: Table 18 is -not reproduced by the general rule, see tests/test_narayana_rath_book_tables.py). +Table 18 (Indira) is a book-internal contradiction (BOOK_EXAMPLES): the +general rule's Capricorn is output as is; the book's Cancer only with the +start sign given explicitly and flagged (product owner, 2026-10-03 evening). +Not wired into scoring. """ from __future__ import annotations @@ -72,6 +74,22 @@ DURATION_PROFILE = "jaimini_odd_footed_dignity_v2" DUAL_LORD_PROFILE = "jaimini_dual_lord_source_v1" ROLE_PROFILE = "rath_p36_role_conjunction_v1" INDIRA_EXCEPTION = "rath_p66_indira_ak" +# Book examples whose printed start sign the general rule does not give. +# Product owner ruling 2026-10-03 (evening, red line 3 revised): reproduce +# them only with the start sign given explicitly, flagged; never by code that +# bends the general rule. +BOOK_EXAMPLES = { + INDIRA_EXCEPTION: { + "book_table": "Table 18", + "book_start_sign": "Cancer", + "book_internal_contradiction": True, + "evidence": ("Hand example (PDF Page 62; astrologer cites p.65-66) concludes Cancer because " + "'Saturn is the Atmakaraka and has gained the highest longitude', but Saturn sits in " + "Cancer and rules Capricorn, so rules 5 and 6 read literally point to Capricorn; the " + "book's own software verdict for the same native (Table 27) also has Capricorn " + "stronger than Cancer. Audit docs/research/shared_engine_reuse_audit_2026_10_03.md N1."), + }, +} KETU_FIRST_NOTE = "PyJHora 兼容:计都优先" KETU_FIRST_NOTE_EN = "PyJHora-compatible: Ketu takes precedence" SOURCE = { @@ -339,13 +357,19 @@ def antardasha(md_row: Dict[str, Any], planet_lons: Dict[str, float]) -> Dict[st def calc_narayana_rath(lagna_sign_idx: int, planet_lons: Dict[str, float], *, seed_sign_idx: Optional[int] = None, cycle_count: int = 2, - allow_book_example: bool = False) -> Dict[str, Any]: + allow_book_example: bool = False, + book_example: Optional[str] = None) -> Dict[str, Any]: """Rath Narayana Dasha for one chart. - `seed_sign_idx` overrides the start sign (book-table replay only). + `seed_sign_idx` overrides the start sign (book-table replay only); with + `book_example` (a key of BOOK_EXAMPLES) the output is flagged + `book_example_exception` and still carries the general rule's own + verdict, unchanged, in `general_rule_selection`. `allow_book_example` lets the Indira AK example decide a full seven-level tie (flagged in the output). """ + if book_example is not None and (seed_sign_idx is None or book_example not in BOOK_EXAMPLES): + raise ValueError("book_example needs an explicit seed_sign_idx and a known BOOK_EXAMPLES key") base = { "profile": PROFILE, "duration_profile": DURATION_PROFILE, @@ -356,7 +380,14 @@ def calc_narayana_rath(lagna_sign_idx: int, planet_lons: Dict[str, float], } if seed_sign_idx is not None: selection = {"status": "explicit", "selected_sign_idx": seed_sign_idx, - "strength_level_decided": None, "book_example_exception": None} + "strength_level_decided": None, "book_example_exception": book_example} + if book_example is not None: + general = compare_signs(lagna_sign_idx, (lagna_sign_idx + 6) % 12, planet_lons) + base["general_rule_selection"] = { + key: general.get(key) for key in + ("status", "selected_sign", "strength_level_decided", "strength_level_name", "reason") + } + base["book_example"] = BOOK_EXAMPLES[book_example] else: selection = compare_signs(lagna_sign_idx, (lagna_sign_idx + 6) % 12, planet_lons, allow_book_example=allow_book_example) @@ -365,7 +396,9 @@ def calc_narayana_rath(lagna_sign_idx: int, planet_lons: Dict[str, float], base["book_example_exception"] = selection.get("book_example_exception") if selection["status"] in ("selected", "explicit"): seq = mahadasha_sequence(selection["selected_sign_idx"], planet_lons, cycle_count=cycle_count) - if selection["status"] == "explicit": + if selection["status"] == "explicit" and book_example is not None: + status, basis = "book_example_exception", f"explicit_seed:book_example:{book_example}" + elif selection["status"] == "explicit": status, basis = "explicit_seed", "explicit_seed" elif selection.get("book_example_exception"): status, basis = "book_example_exception", f"book_example:{selection['book_example_exception']}" diff --git a/tests/test_narayana_rath_book_tables.py b/tests/test_narayana_rath_book_tables.py index 14da9bdf..c6ec6317 100644 --- a/tests/test_narayana_rath_book_tables.py +++ b/tests/test_narayana_rath_book_tables.py @@ -13,9 +13,14 @@ 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), 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. +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 @@ -124,6 +129,99 @@ def test_antardasha_starts_from_the_lord_of_the_stronger_of_md_and_seventh() -> 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"]] + assert [row["years"] for row in result["mahadasha"]] == [row[1] for row in book["rows"]] + # 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). Open question to the astrologer. +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). +EXPECTED_CO_LORD_MISSES = {("Table 23", "Aquarius"), ("Table 25", "Scorpio")} + + +def test_book_software_verdicts_28_of_30_and_co_lords_8_of_10() -> None: + from scripts.narayana_rath import _lord, compare_signs + + sign_misses, co_lord_misses, pairs = set(), set(), 0 + 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): + if _lord(sign_idx, lons) != 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 + + +# 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_not_wired_into_any_product_path() -> None: pattern = re.compile(r"\bnarayana_rath\b") users = sorted(path.name for path in (ROOT / "scripts").rglob("*.py")