Files
Jyotisha/tests/test_double_transit_cross_layer_pairing.py
T
Jesse_ChenandClaude Opus 5.5 fb543d3849 fix(engine): pair double-transit D1+D9 targets by identity, not name digits (BUG-1061)
Every D9 target name contains the "9" of "D9", so the digit comparison never
paired D1 N宫 with D9_N宫 and, for house 9, paired D1 9宫 with any D9 planet
target. Targets now carry structured metadata (layer/kind/house/planet); the
cross-layer loop pairs only the same house or targets that both point at the
D1 house-N lord. Target names and D1/D9/CL layer results are unchanged
(D1/CL golden regenerated byte-identical); 48 samples: cross entries 31 -> 38.
Evidence-card golden regenerated with the capture script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 19:08:07 +08:00

206 lines
9.6 KiB
Python

"""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)