From c41555f69e8defdfcbe0fddbdf3840569529d846 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 3 Oct 2026 19:25:46 +0800 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- scripts/narayana_rath.py | 365 +++++++++++++++++++----- tests/test_narayana_rath_book_tables.py | 66 +++-- tests/test_narayana_rath_rules.py | 197 +++++++++++++ 3 files changed, 543 insertions(+), 85 deletions(-) create mode 100644 tests/test_narayana_rath_rules.py diff --git a/scripts/narayana_rath.py b/scripts/narayana_rath.py index 520dfff0..b33c90da 100644 --- a/scripts/narayana_rath.py +++ b/scripts/narayana_rath.py @@ -1,77 +1,270 @@ #!/usr/bin/env python3 -"""Narayana Dasha, Sanjay Rath profile (`sanjay_rath`) — research only (BUG-1215). +"""Narayana Dasha, Sanjay Rath profile (`sanjay_rath`) — research only (BUG-1215 / BUG-1218). -Astrologer round-4 ruling 2026-10-03, Q4: the second time axis should be Sanjay -Rath's Narayana Dasha (start from the stronger of Lagna and 7th, order by the -start sign, dual lords by the traditional profile). The current product axis -(`narayana_dasha.calc_narayana_mahadasha`) always starts from the Lagna and runs -zodiacally; it is labelled `legacy_reference_only` on user-visible surfaces. +Astrologer round-4 ruling (Q4): the second time axis should be Sanjay Rath's +Narayana Dasha. Round-5 ruling 2026-10-03 fixed the open points (book pages +in brackets; Sanjay Rath, *Narayana Dasa*): -This module builds the Rath sequence from the rule ledger ported from upstream -yinduzhanxing c7117f92 (`references/oracle/sanjay_rath_narayana_book_ledger_2026_08_28.json`, -`docs/research/sanjay_rath_narayana_book_ledger_2026_08_28.md`): +Start sign = the stronger of Lagna and 7th [p.48]. General rule, seven levels, +stop at the first level that separates the two signs, no weighting [p.35-36, +p.63]: -- start sign: stronger of Lagna / 7th (`rath_md_start_stronger_lagna_seventh`), - decided by the existing `narayana_dasha.select_narayana_seed_sign`; a tie - (`later_narayana_sign_strength_rules_required`) gives - `status: parameter_sensitive` and both sequences; -- MD order: Table 10 by the start sign (`rath_md_general_order_table10`); Saturn - in the start sign -> Table 11, zodiacal (`rath_saturn_start_exception`); Ketu - in the start sign (no Saturn) -> Table 10 direction reversed (the reversal - rule, `rath_ketu_reversal_exception`; the ledger tables only the AD form); -- years: `jaimini_odd_footed_dignity_v2` (Upadesa 1.1.25-27 odd/even-footed - count with the fixed-sign exception, exalted +1 / debilitated -1, ledger - `upadesa_1125_1126_odd_even_count`, `upadesa_1127_fixed_sign_exception`, - `upadesa_bphs_exalt_debil_adjustment`); Scorpio / Aquarius lords by - `jaimini_dual_lord_source_v1` (`upadesa_dual_lord_strength_chain`, partial); -- AD: twelve equal parts from the MD sign, Table 13 order; Saturn in the MD sign - -> Table 11, Ketu -> Table 14 (`rath_ad_equal_twelfths`, `rath_ad_order_table13`). +1. more occupants (actual bodies, Rahu/Ketu included); +2. more support from three roles — Jupiter, Mercury and the sign lord — each + role counted once when the planet is in the sign or casts Rasi Drishti on + it [p.48; no graha drishti]; roles are not de-duplicated by planet + (Jupiter as Jupiter and as lord counts twice) [p.36]; +3. occupant dignity: exalted > moolatrikona > own sign (whole sign) — best + rank, then the number of dignified occupants; +4. dual > fixed > movable; +5. the lord's degree inside its sign, Chara Karaka sense (higher is + stronger; Rahu / Ketu as 30 - degree); +6. the lord placed in a sign of the opposite parity to the sign it rules; +7. the sign giving the longer dasha. +"Contains the AK" / "lord is the AK" are not general rules [p.35]. The +Indira example (p.65-66) decides Cancer by the AK; it is kept as a flagged +book example (`book_example_exception: rath_p66_indira_ak`), used only when +all seven levels tie. -Not wired into scoring, consultation or reports: the three book tables -(Table 15 / 17 / 18) are not all reproduced (see -`docs/tasks/PROGRESS-astrologer-rulings-batch2-20261003.md`, task red line 2). +MD order: Table 10 by the start sign [p.51]; Saturn in the start sign -> zodiacal, +sign by sign (Table 11) [p.51-53]; Ketu in the start sign -> Table 10 direction +reversed, keeping the fixed / dual stepping (not the whole table read +backwards) [p.51, p.53 Table 12; p.54 Table 14 is the AD table]. Saturn and +Ketu both in the start sign: Ketu first (PyJHora's practice, product owner +ruling 2026-10-03, `method_note`). + +Years: `jaimini_odd_footed_dignity_v2` (odd/even-footed count, fixed-sign +exception, exalted +1 / debilitated -1); Scorpio / Aquarius lords by +`jaimini_dual_lord_source_v1`. Cycle 2 = 12 minus cycle 1. + +AD [p.48 Arambha Rasi, p.50 note 2]: compare the MD sign and its 7th with the +same seven levels, take the stronger, start from the sign holding that sign's +lord (dual lords by the same rule), twelve equal parts. Order: Table 13 from +the start sign; Saturn in the MD sign -> Table 11 (zodiacal); Ketu in the MD +sign -> Table 14 (Ketu first when both). + +Ported verbatim from upstream yinduzhanxing c7117f92 `scripts/narayana_dasha.py`: +`_rath_ketu_table12_sequence` (the `rath_ketu_table12_v1` branch of +`build_narayana_sign_sequence`), `_RATH_CLASSICAL_MOOLATRIKONA_SIGNS`, and the +level 1-2 counting of `select_narayana_seed_sign(strength_profile= +"rath_p36_role_conjunction_v1")` (`_role_candidate`; tests compare it with the +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). """ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple try: # scripts/ on sys.path import narayana_dasha as nd + from jaimini import calc_chara_karaka_8 except ImportError: # pragma: no cover - package import from scripts import narayana_dasha as nd + from scripts.jaimini import calc_chara_karaka_8 PROFILE = "sanjay_rath" DURATION_PROFILE = "jaimini_odd_footed_dignity_v2" DUAL_LORD_PROFILE = "jaimini_dual_lord_source_v1" -SOURCE = ("Sanjay Rath, Narayana Dasa — rule ledger and Tables 10/11/13/14 " - "(ported from upstream yinduzhanxing c7117f92)") +ROLE_PROFILE = "rath_p36_role_conjunction_v1" +INDIRA_EXCEPTION = "rath_p66_indira_ak" +KETU_FIRST_NOTE = "PyJHora 兼容:计都优先" +KETU_FIRST_NOTE_EN = "PyJHora-compatible: Ketu takes precedence" +SOURCE = { + "book": "Sanjay Rath, Narayana Dasa", + "start_sign": "p.48 (stronger of Lagna / 7th); levels p.35-36, p.63; Rasi Drishti p.48", + "md_order": "p.51 Table 10; Saturn p.51-53 Table 11; Ketu p.51, p.53 Table 12", + "ad": "p.48 Arambha Rasi, p.50 note 2; Tables 13 / 11 / 14 (p.54)", + "indira_example": "p.65-66 (AK), Table 18 p.67", + "ruling": "astrologer round 5, 2026-10-03, Q1-Q4; product owner: Ketu first", + "port": "upstream yinduzhanxing c7117f92 scripts/narayana_dasha.py", +} SIGNS = nd.SIGNS +SIGN_LORDS = nd.SIGN_LORDS +GRAHAS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") +LEVELS = ( + "occupant_count", + "role_conjunction_or_rasi_aspect", + "occupant_dignity", + "modality", + "lord_degree_in_sign", + "lord_opposite_parity", + "longer_dasha", +) + +# Ported from upstream c7117f92 (Rath p80, whole-sign scope). +_RATH_CLASSICAL_MOOLATRIKONA_SIGNS = dict(Sun=4, Moon=1, Mars=0, + Mercury=5, Jupiter=8, Venus=6, Saturn=10) # Rath p80, whole-sign scope. + + +def _rath_ketu_table12_sequence(seed_sign_idx: int) -> List[int]: + # Ported from upstream c7117f92 build_narayana_sign_sequence, + # profile "rath_ketu_table12_v1". + # Rath pp.53-54 Table12 note: reverse Table10 direction, retain motion. + return [(2 * seed_sign_idx - sign) % 12 + for sign in nd._NARAYANA_RATH_GENERAL_SEQUENCE[seed_sign_idx]] + + +def _grahas(planet_lons: Dict[str, float]) -> Dict[str, float]: + return {planet: float(planet_lons[planet]) for planet in GRAHAS if planet_lons.get(planet) is not None} + + +def _lord(sign_idx: int, planet_lons: Dict[str, float]) -> str: + return nd._resolve_narayana_lord(sign_idx, planet_lons, dual_lord_profile=DUAL_LORD_PROFILE) + + +def _role_candidate(sign_idx: int, planet_lons: Dict[str, float], lord: str) -> Dict[str, Any]: + """Levels 1-2 for one sign: upstream `select_narayana_seed_sign` with + strength_profile="rath_p36_role_conjunction_v1" (c7117f92), per sign.""" + occupants = sorted( + planet + for planet in planet_lons + if nd._get_planet_sign(planet, planet_lons) == sign_idx + ) + aspect_factors = [] + role_factors = [] + roles = (("jupiter", "Jupiter"), ("mercury", "Mercury"), ("sign_lord", lord)) + for role, planet in roles: + planet_sign_idx = nd._get_planet_sign(planet, planet_lons) + conjunction = planet_sign_idx == sign_idx + if planet_sign_idx is not None and (conjunction or nd._rasi_aspects(planet_sign_idx, sign_idx)): + aspect_factors.append(planet) + role_factors.append(dict(role=role, planet=planet, contact="conjunction" if conjunction else "rasi_aspect")) + return { + "sign": SIGNS[sign_idx], + "lord": lord, + "occupants": occupants, + "occupant_count": len(occupants), + "aspect_factors": aspect_factors, + "aspect_factor_count": len(aspect_factors), + "role_factors": role_factors, + } + + +def _occupant_dignity(planet: str, sign_idx: int) -> int: + table = nd._NARAYANA_DIGNITY.get(planet) + if table is None: # Rahu / Ketu: no ruling on node dignity; not counted. + return 0 + if sign_idx in table["exalted"]: + return 3 + if sign_idx == _RATH_CLASSICAL_MOOLATRIKONA_SIGNS[planet]: + return 2 + if SIGN_LORDS[SIGNS[sign_idx]] == planet: + return 1 + return 0 + + +def _ck_degree(planet: str, lon: float) -> float: + degree = float(lon) % 30.0 + return 30.0 - degree if planet in ("Rahu", "Ketu") else degree + + +def sign_strength(sign_idx: int, planet_lons: Dict[str, float]) -> Dict[str, Any]: + """All seven level values for one sign (see module docstring).""" + lons = _grahas(planet_lons) + lord = _lord(sign_idx, lons) + row = _role_candidate(sign_idx, lons, lord) + ranks = {planet: _occupant_dignity(planet, sign_idx) for planet in row["occupants"]} + best = max(ranks.values(), default=0) + dignified = sum(1 for value in ranks.values() if value > 0) + lord_sign_idx = nd._get_planet_sign(lord, lons) + if lord_sign_idx is None: + lord_sign_idx = sign_idx + years, _ = nd.calculate_narayana_period_years(sign_idx, lord, lord_sign_idx, profile=DURATION_PROFILE) + row.update({ + "occupant_dignity": {"ranks": ranks, "best_rank": best, "dignified_count": dignified}, + "modality": "dual" if sign_idx in (2, 5, 8, 11) else "fixed" if sign_idx in nd._FIXED_SIGNS else "movable", + "lord_sign": SIGNS[lord_sign_idx], + "lord_degree_in_sign": round(_ck_degree(lord, lons[lord]), 6) if lord in lons else None, + "lord_opposite_parity": lord_sign_idx % 2 != sign_idx % 2, + "dasha_years": years, + }) + row["level_values"] = [ + row["occupant_count"], + row["aspect_factor_count"], + (best, dignified), + {"movable": 0, "fixed": 1, "dual": 2}[row["modality"]], + row["lord_degree_in_sign"] if row["lord_degree_in_sign"] is not None else -1.0, + int(row["lord_opposite_parity"]), + years, + ] + return row + + +def _atmakaraka(planet_lons: Dict[str, float]) -> Optional[str]: + lons = {planet: lon for planet, lon in _grahas(planet_lons).items() if planet != "Ketu"} + table = calc_chara_karaka_8(lons).get("karaka_table_8") or {} + for row in table.values(): + if row.get("rank") == 1: + return row.get("planet") + return None + + +def compare_signs(first_idx: int, second_idx: int, planet_lons: Dict[str, float], + *, allow_book_example: bool = False) -> Dict[str, Any]: + """Stronger of two signs by the seven general levels. + + `allow_book_example`: only when all seven tie, the Indira example's AK + tie-break may decide; the result then carries `book_example_exception`. + """ + candidates = {first_idx: sign_strength(first_idx, planet_lons), second_idx: sign_strength(second_idx, planet_lons)} + base = {"candidates": {SIGNS[idx]: row for idx, row in candidates.items()}, + "book_example_exception": None} + for level, name in enumerate(LEVELS): + a = candidates[first_idx]["level_values"][level] + b = candidates[second_idx]["level_values"][level] + if a != b: + winner = first_idx if a > b else second_idx + return {**base, "status": "selected", "selected_sign_idx": winner, "selected_sign": SIGNS[winner], + "strength_level_decided": level + 1, "strength_level_name": name} + if allow_book_example: + ak = _atmakaraka(planet_lons) + holders = [idx for idx in (first_idx, second_idx) if ak and nd._get_planet_sign(ak, planet_lons) == idx] + if len(holders) == 1: + winner = holders[0] + return {**base, "status": "selected", "selected_sign_idx": winner, "selected_sign": SIGNS[winner], + "strength_level_decided": None, "strength_level_name": "book_example_atmakaraka", + "book_example_exception": INDIRA_EXCEPTION, "atmakaraka": ak} + return {**base, "status": "tie", "reason": "all_seven_general_levels_tie", + "strength_level_decided": None, "strength_level_name": None} def md_order(seed_sign_idx: int, planet_lons: Dict[str, float]) -> Dict[str, Any]: - """MD sign order for one start sign, with the rule that decided it.""" + """MD sign order for one start sign, with the exception that decided it.""" saturn = nd._get_planet_sign("Saturn", planet_lons) == seed_sign_idx ketu = nd._get_planet_sign("Ketu", planet_lons) == seed_sign_idx - if saturn: + method_note = None + if ketu: + order = _rath_ketu_table12_sequence(seed_sign_idx) + direction = "table12_ketu_reversed" + exception = "ketu_in_start_sign" + if saturn: + method_note = KETU_FIRST_NOTE + elif saturn: order = nd.build_narayana_sign_sequence(seed_sign_idx, profile="rath_saturn_table11_v1") direction = "table11_saturn_zodiacal" - elif ketu: - table10 = nd.build_narayana_sign_sequence(seed_sign_idx, profile="rath_general_table10_v1") - order = [(2 * seed_sign_idx - sign) % 12 for sign in table10] - direction = "table10_reversed_ketu" + exception = "saturn_in_start_sign" else: order = nd.build_narayana_sign_sequence(seed_sign_idx, profile="rath_general_table10_v1") direction = "table10_general" - return {"order": order, "direction": direction, "saturn_in_seed": saturn, "ketu_in_seed": ketu} + exception = None + return {"order": order, "direction": direction, "exception_applied": exception, + "method_note": method_note, "method_note_en": KETU_FIRST_NOTE_EN if method_note else None, + "saturn_in_seed": saturn, "ketu_in_seed": ketu} -def _ad_order_profile(md_sign_idx: int, planet_lons: Dict[str, float]) -> str: - if nd._get_planet_sign("Saturn", planet_lons) == md_sign_idx: - return "rath_saturn_table11_v1" - if nd._get_planet_sign("Ketu", planet_lons) == md_sign_idx: - return "rath_ketu_table14_v1" - return "rath_table13_v1" +def _ad_order_profile(md_sign_idx: int, planet_lons: Dict[str, float]) -> Tuple[str, Optional[str]]: + ketu = nd._get_planet_sign("Ketu", planet_lons) == md_sign_idx + saturn = nd._get_planet_sign("Saturn", planet_lons) == md_sign_idx + if ketu: + return "rath_ketu_table14_v1", (KETU_FIRST_NOTE if saturn else None) + if saturn: + return "rath_saturn_table11_v1", None + return "rath_table13_v1", None def mahadasha_sequence(seed_sign_idx: int, planet_lons: Dict[str, float], @@ -81,7 +274,7 @@ def mahadasha_sequence(seed_sign_idx: int, planet_lons: Dict[str, float], rows: List[Dict[str, Any]] = [] cursor = float(start_age) for position, sign_idx in enumerate(order["order"], start=1): - lord = nd._resolve_narayana_lord(sign_idx, planet_lons, dual_lord_profile=DUAL_LORD_PROFILE) + lord = _lord(sign_idx, planet_lons) lord_sign_idx = nd._get_planet_sign(lord, planet_lons) if lord_sign_idx is None: lord_sign_idx = sign_idx @@ -104,49 +297,93 @@ def mahadasha_sequence(seed_sign_idx: int, planet_lons: Dict[str, float], return {**order, "seed_sign": SIGNS[seed_sign_idx], "seed_sign_idx": seed_sign_idx, "mahadasha": rows} -def antardasha(md_row: Dict[str, Any], planet_lons: Dict[str, float]) -> List[Dict[str, Any]]: - """Twelve equal ADs from the MD sign (Table 13 / 11 / 14).""" - profile = _ad_order_profile(md_row["sign_idx"], planet_lons) +def antardasha(md_row: Dict[str, Any], planet_lons: Dict[str, float]) -> Dict[str, Any]: + """Twelve equal ADs: start from the sign of the lord of the stronger of + the MD sign and its 7th (round-5 Q4); order Table 13 / 11 / 14.""" + md_sign_idx = md_row["sign_idx"] + seventh = (md_sign_idx + 6) % 12 + comparison = compare_signs(md_sign_idx, seventh, planet_lons) + profile, note = _ad_order_profile(md_sign_idx, planet_lons) + if comparison["status"] != "selected": + return {"status": "blocked", "reason": comparison.get("reason"), "rows": [], + "antardasha_start_rule": {"rule": "stronger_of_md_sign_and_seventh_then_its_lord_sign", + "compared": [SIGNS[md_sign_idx], SIGNS[seventh]], + "strength_level_decided": None}} + stronger = comparison["selected_sign_idx"] + lord = _lord(stronger, planet_lons) + start = nd._get_planet_sign(lord, planet_lons) + if start is None: + return {"status": "blocked", "reason": "stronger_sign_lord_longitude_missing", "rows": []} rows = nd.subdivide_narayana_equal_order( - md_row, start_sign_idx=md_row["sign_idx"], order_profile=profile, level="AD", + md_row, start_sign_idx=start, order_profile=profile, level="AD", parent_key="parent_md", parent_name=md_row["sign"], ) - return rows + for row in rows: + row["start_sign_source"] = "lord_of_stronger_md_or_seventh_sign" + return { + "status": "selected", + "rows": rows, + "antardasha_start_rule": { + "rule": "stronger_of_md_sign_and_seventh_then_its_lord_sign", + "compared": [SIGNS[md_sign_idx], SIGNS[seventh]], + "stronger_sign": SIGNS[stronger], + "strength_level_decided": comparison["strength_level_decided"], + "lord": lord, + "dual_lord_rule": DUAL_LORD_PROFILE if stronger in (7, 10) else None, + "start_sign": SIGNS[start], + "order_profile": profile, + "method_note": note, + }, + } def calc_narayana_rath(lagna_sign_idx: int, planet_lons: Dict[str, float], - *, seed_sign_idx: Optional[int] = None, cycle_count: int = 2) -> Dict[str, Any]: + *, seed_sign_idx: Optional[int] = None, cycle_count: int = 2, + allow_book_example: bool = False) -> Dict[str, Any]: """Rath Narayana Dasha for one chart. `seed_sign_idx` overrides the start sign (book-table replay only). + `allow_book_example` lets the Indira AK example decide a full seven-level + tie (flagged in the output). """ - if seed_sign_idx is not None: - selection = {"status": "explicit", "selected_sign_idx": seed_sign_idx} - else: - selection = nd.select_narayana_seed_sign(lagna_sign_idx, planet_lons) base = { "profile": PROFILE, "duration_profile": DURATION_PROFILE, "dual_lord_rule": DUAL_LORD_PROFILE, "source": SOURCE, "lagna_sign": SIGNS[lagna_sign_idx], - "seed_selection": selection, + "antardasha_start_rule": "stronger_of_md_sign_and_seventh_then_its_lord_sign (p.48, p.50 note 2)", } + if seed_sign_idx is not None: + selection = {"status": "explicit", "selected_sign_idx": seed_sign_idx, + "strength_level_decided": None, "book_example_exception": None} + else: + selection = compare_signs(lagna_sign_idx, (lagna_sign_idx + 6) % 12, planet_lons, + allow_book_example=allow_book_example) + base["seed_selection"] = selection + base["strength_level_decided"] = selection.get("strength_level_decided") + 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) - return {**base, "status": "partial_verified" if selection["status"] == "selected" else "explicit_seed", - "seed_sign": seq["seed_sign"], - "seed_basis": ("explicit_seed" if selection["status"] == "explicit" - else "stronger_of_lagna_seventh:" + "+".join(selection.get("selection_levels", []))), - "direction": seq["direction"], "mahadasha": seq["mahadasha"]} + if 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']}" + else: + status = "partial_verified" + basis = f"stronger_of_lagna_seventh:level_{selection['strength_level_decided']}_{selection['strength_level_name']}" + return {**base, "status": status, "seed_sign": seq["seed_sign"], "seed_basis": basis, + "direction": seq["direction"], "exception_applied": seq["exception_applied"], + "method_note": seq["method_note"], "method_note_en": seq["method_note_en"], + "mahadasha": seq["mahadasha"]} alternatives = { SIGNS[idx]: mahadasha_sequence(idx, planet_lons, cycle_count=cycle_count) for idx in (lagna_sign_idx, (lagna_sign_idx + 6) % 12) } - lagna_seq = alternatives[SIGNS[lagna_sign_idx]] return {**base, "status": "parameter_sensitive", "reason": selection.get("reason"), "seed_sign": None, "seed_basis": "tie_lagna_seventh_both_listed", - "direction": None, "mahadasha": None, + "direction": None, "exception_applied": None, "method_note": None, "method_note_en": None, + "mahadasha": None, "alternatives": {sign: {"seed_sign": seq["seed_sign"], "direction": seq["direction"], - "mahadasha": seq["mahadasha"]} for sign, seq in alternatives.items()}, - "lagna_start_mahadasha": lagna_seq["mahadasha"]} + "exception_applied": seq["exception_applied"], + "mahadasha": seq["mahadasha"]} for sign, seq in alternatives.items()}} diff --git a/tests/test_narayana_rath_book_tables.py b/tests/test_narayana_rath_book_tables.py index be02bb9e..14da9bdf 100644 --- a/tests/test_narayana_rath_book_tables.py +++ b/tests/test_narayana_rath_book_tables.py @@ -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: diff --git a/tests/test_narayana_rath_rules.py b/tests/test_narayana_rath_rules.py new file mode 100644 index 00000000..dd841c54 --- /dev/null +++ b/tests/test_narayana_rath_rules.py @@ -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)