"""BUG-1061: Double Transit PAC cross-layer (D1+D9) entries pair targets by identity, not name digits. Before the fix the pairing compared the digits in the target names; every D9 name contains the "9" of "D9", so D1 ``7宫`` never met ``D9_7宫`` ("7" vs "97") while D1 ``9宫`` met any ``{planet}_D9`` / ``D9_{planet}(宫主)`` target. Real engine runs on the three public AA charts of the evidence-card research (`consult_evidence_card_lib.load_public_charts`, raman ayanamsa, mean nodes, reference date fixed in the library). D1 / D9 / CL layer results are unchanged by this fix; `tests/test_double_transit_d9_targets.py` holds the D1 / CL golden. """ from __future__ import annotations import sys from pathlib import Path from types import SimpleNamespace import pytest ROOT = Path(__file__).resolve().parents[1] for path in (ROOT, ROOT / "scripts", ROOT / "scripts" / "research"): if str(path) not in sys.path: sys.path.insert(0, str(path)) import jyotish_engine as engine # noqa: E402 from consult_evidence_card_lib import load_public_charts # noqa: E402 HOUSES = range(1, 13) def _args(chart: dict, house: int) -> SimpleNamespace: body = chart["body"] return SimpleNamespace( year=body["year"], month=body["month"], day=body["day"], hour=body["hour"], minute=body["minute"], second=0, lat=body["lat"], lon=body["lon"], tz=float(body["tz"]), node_mode=body["node_mode"], ayanamsa=body["ayanamsa"], date=body["today"], house=house, ) def _context(chart: dict, house: int) -> dict: natal_chart, asc_idx, _, _ = engine._compute_chart_from_args(_args(chart, house)) ascendant = natal_chart["ascendant"] asc_deg = ascendant.get("lon", ascendant.get("degree", 0)) event_sign = engine.SIGNS[(asc_idx + house - 1) % 12] return { "natal": natal_chart["planets"], "asc_deg": asc_deg, "event_sign": event_sign, "event_lord": engine.SIGN_LORDS[event_sign], "ll_name": engine.SIGN_LORDS[natal_chart["ascendant"]["sign"]], "opposite_lord": engine.SIGN_LORDS[engine.SIGNS[(asc_idx + 6) % 12]], } def _meta(ctx: dict, house: int) -> dict: return engine._double_transit_target_meta( ctx["asc_deg"], ctx["natal"], house, ctx["event_sign"], ctx["event_lord"], ctx["ll_name"], ctx["opposite_lord"], ) def _cross(result: dict) -> list[tuple[str, str, str, str]]: """(D1 planet, D1 target, D9 planet, D9 target) for every D1+D9 entry (parsed in the test only).""" rows = [] for row in result["double_transit"]: if row["layer"] != "D1+D9": continue left, right = row["target"].split(" + ") d1_planet, d1_target = left.split("(D1)", 1) d9_planet, d9_target = right.split("(D9)", 1) rows.append((d1_planet, d1_target, d9_planet, d9_target)) return rows @pytest.fixture(scope="module") def charts() -> list[dict]: return load_public_charts(ROOT) @pytest.fixture(scope="module") def runs(charts) -> dict: out = {} for chart in charts: for house in HOUSES: ctx = _context(chart, house) out[(chart["id"], house)] = { "result": engine.cmd_double_transit_pac(_args(chart, house)), "ctx": ctx, "meta": _meta(ctx, house), } return out def test_quick_gate_runs_this_file() -> None: import run_quality_gate assert "tests/test_double_transit_cross_layer_pairing.py" in run_quality_gate.CORE_PYTEST_TARGETS def test_meta_names_are_the_command_target_names(runs) -> None: for (chart_id, house), run in runs.items(): ctx, meta = run["ctx"], run["meta"] d1_names = { f"{house}宫({ctx['event_sign']})", f"{ctx['event_lord']}(宫主)", f"{ctx['ll_name']}(LL)", f"{ctx['opposite_lord']}(对宫主)", } _, d9_targets = engine._double_transit_d9_targets( ctx["asc_deg"], ctx["natal"], house, ctx["event_lord"], ctx["ll_name"], ) assert set(meta["D1"]) == d1_names, (chart_id, house) assert set(meta["D9"]) == set(d9_targets), (chart_id, house) result = run["result"] for planet in ("jupiter", "saturn"): assert set(result["d1"][planet]) <= set(meta["D1"]), (chart_id, house) assert set(result["d9"][planet]) <= set(meta["D9"]), (chart_id, house) def test_identity_rules_on_structured_meta() -> None: def meta(layer, kind, house, planet=None): return [{"layer": layer, "kind": kind, "house": house, "planet": planet}] ids = engine._double_transit_target_identities # D1 9th house vs the lagna lord's D9 sign: the BUG-1061 false pair. assert not ids(meta("D1", "house", 9), 9, "Moon") & ids(meta("D9", "lagna_lord_d9_sign", 1, "Mars"), 9, "Moon") # D1 7th house vs D9 7th house: the pair the digit rule never made ("7" vs "97"). assert ids(meta("D1", "house", 7), 7, "Venus") & ids(meta("D9", "house", 7), 7, "Venus") # D1 house-N lord vs that lord's D9 sign (KN Rao) and vs the D9 house-N lord when it is the same planet. lord = ids(meta("D1", "house_lord", 7, "Venus"), 7, "Venus") assert lord & ids(meta("D9", "lord_d9_sign", 7, "Venus"), 7, "Venus") assert lord & ids(meta("D9", "d9_house_lord", 7, "Venus"), 7, "Venus") # ... but not when another planet rules D9 house N. assert not lord & ids(meta("D9", "d9_house_lord", 7, "Moon"), 7, "Venus") # Lagna lord that is not the event lord never pairs (it would repeat in all 12 house calls). assert not ids(meta("D1", "lagna_lord", 1, "Mars"), 7, "Venus") & ids(meta("D9", "lagna_lord_d9_sign", 1, "Mars"), 7, "Venus") # A house target never pairs with a planet target. assert not ids(meta("D1", "house", 7), 7, "Venus") & lord def _public(charts, chart_id: str, house: int) -> tuple[dict, dict]: chart = next(item for item in charts if item["id"] == chart_id) return engine.cmd_double_transit_pac(_args(chart, house)), _context(chart, house) def test_seventh_house_pairs_d1_house_with_d9_house(charts) -> None: """Public chart, house 7: Saturn on D1 7宫 and Jupiter on D9_7宫 are one cross-layer entry.""" result, _ = _public(charts, "steve_jobs", 7) assert "7宫(Pisces)" in result["d1"]["saturn"] assert "D9_7宫(Cancer)" in result["d9"]["jupiter"] assert ("Saturn", "7宫(Pisces)", "Jupiter", "D9_7宫(Cancer)") in _cross(result) assert result["summary"].startswith("⚠️ 跨层间接") def test_ninth_house_no_longer_pairs_with_an_unrelated_d9_target(charts) -> None: """Public chart, house 9: Mars_D9 is the lagna lord's D9 sign, not the 9th house.""" result, ctx = _public(charts, "elizabeth_taylor", 9) assert ctx["ll_name"] == "Mars" and ctx["event_lord"] == "Moon" # Both hits are still there; only the pairing changed. assert "9宫(Cancer)" in result["d1"]["jupiter"] assert "Mars_D9(Capricorn)" in result["d9"]["saturn"] assert all(d1_target != "9宫(Cancer)" for _, d1_target, _, _ in _cross(result)) assert all(not d9_target.startswith("Mars_D9") for _, _, _, d9_target in _cross(result)) def test_every_cross_entry_joins_the_same_event_significator(runs) -> None: for (chart_id, house), run in runs.items(): meta, event_lord = run["meta"], run["ctx"]["event_lord"] for _, d1_target, _, d9_target in _cross(run["result"]): d1_rows, d9_rows = meta["D1"][d1_target], meta["D9"][d9_target] d1_house = any(row["kind"] == "house" for row in d1_rows) d9_house = any(row["kind"] == "house" for row in d9_rows) assert d1_house == d9_house, (chart_id, house, d1_target, d9_target) if d1_house: assert d1_rows[0]["house"] == d9_rows[0]["house"] == house else: assert {row["planet"] for row in d1_rows} == {event_lord}, (chart_id, house, d1_target) assert event_lord in {row["planet"] for row in d9_rows}, (chart_id, house, d9_target) def test_every_same_house_hit_becomes_a_cross_entry(runs) -> None: same_house = set() for (chart_id, house), run in runs.items(): result, meta = run["result"], run["meta"] cross = _cross(result) d1_house = next(name for name, rows in meta["D1"].items() if rows[0]["kind"] == "house") d9_house = next(name for name, rows in meta["D9"].items() if rows[0]["kind"] == "house") for d1_planet, d9_planet in (("jupiter", "saturn"), ("saturn", "jupiter")): if d1_house in result["d1"][d1_planet] and d9_house in result["d9"][d9_planet]: assert (d1_planet.title(), d1_house, d9_planet.title(), d9_house) in cross, (chart_id, house) same_house.add((chart_id, house)) # On the library reference date these are the public-chart houses where both hits happen. assert same_house == {("elizabeth_taylor", 6), ("elizabeth_taylor", 12), ("steve_jobs", 2), ("steve_jobs", 6), ("steve_jobs", 7)} def test_event_lord_pairs_are_kept(runs) -> None: """The pre-fix rule (both targets name the D1 event lord) is expressed structurally and still pairs.""" for (chart_id, house), run in runs.items(): result, meta, event_lord = run["result"], run["meta"], run["ctx"]["event_lord"] cross = _cross(result) for d1_planet, d9_planet in (("jupiter", "saturn"), ("saturn", "jupiter")): for d1_target in result["d1"][d1_planet]: if meta["D1"][d1_target][0]["planet"] != event_lord: continue for d9_target in result["d9"][d9_planet]: if event_lord in {row["planet"] for row in meta["D9"][d9_target]}: assert (d1_planet.title(), d1_target, d9_planet.title(), d9_target) in cross, (chart_id, house)