Files
Jyotisha/tests/test_pancha_mahapurusha_main_detector.py
T
Jesse_ChenandClaude Opus 5.5 bc03bba26b fix(yoga): D9 debilitation only weakens Pancha Mahapurusha (BUG-1213)
Round-4 astrologer ruling 2026-10-03 Q1: the yoga stays formed with
d9_dignity_modifier='weakened' and the reason 'D9 落陷,兑现减弱'; only a failed
D1 condition is not_formed. The D9 sign is derived from the longitude when the
caller does not pass navamsa_sign, so the card, report and /api/pancha_mahapurusha
paths all check it. None of the nine public celebrity charts change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 16:18:06 +08:00

235 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pancha Mahapurusha: one main detector, ruling 甲5 (BUG-1207).
`scripts/pancha_mahapurusha.py` is the only detector: kendras from the Lagna,
retrograde does not cancel, combustion and malefic conjunction / aspect only
reduce the yoga, D9 debilitation only weakens it (round-4 ruling Q1, BUG-1213). The
data-driven `mahapurusha_*`, `*_own`, `moolatrikona_*`, `hamsa_trikona` and the
BVR Maalavya duplicate are `reference_only`, and `yogas_doshas` keeps its old
aggregate for reference only, so a yoga the main detector rejects cannot come
back on the card, the report or `chart.yogas` by another path. Placements are
fictional except the parametrised public AA figures.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from scripts.consultation_native_layers import build_traditional_yogas, merge_detected_yogas
from scripts.jyotish_api_server import JyotishAPIHandler, _compact_yoga_entry
from scripts.jyotish_engine import compute_chart_data
from scripts.main_yoga_detectors import pancha_mahapurusha_rows
from scripts.pancha_mahapurusha import detect_pancha_mahapurusha
from scripts.yoga_engine import YogaEngine
from scripts.yogas_doshas import calc_all_yogas_doshas
ROOT = Path(__file__).resolve().parents[1]
RULES = ROOT / "references" / "yoga_rules.json"
SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
REFERENCE_IDS = {
*(f"mahapurusha_{planet}{suffix}" for planet in ("mars", "mercury", "jupiter", "venus", "saturn") for suffix in ("", "_own")),
*(f"moolatrikona_{planet}" for planet in ("mars", "mercury", "jupiter", "venus", "saturn")),
"hamsa_trikona", "bvr_maalavya_yoga",
}
def _row(sign: str, lon_in_sign: float, asc: str = "Cancer", **extra) -> dict:
return {"sign": sign, "house": (SIGNS.index(sign) - SIGNS.index(asc)) % 12 + 1,
"lon": SIGNS.index(sign) * 30 + lon_in_sign, "degree": lon_in_sign, **extra}
def _one(planets: dict, name: str) -> dict:
return next(item for item in detect_pancha_mahapurusha(planets) if item["name"] == name)
def test_retrograde_does_not_cancel() -> None:
hamsa = _one({"Jupiter": _row("Cancer", 5.0, retrograde=True)}, "Hamsa Yoga")
assert (hamsa["is_valid"], hamsa["strength"], hamsa["reductions"]) == (True, "full", [])
def test_combustion_reduces_using_the_suns_longitude_from_the_chart() -> None:
# API rows carry `degree` in sign; the longitude decides combustion.
planets = {"Jupiter": _row("Cancer", 5.0), "Sun": _row("Cancer", 10.0)}
hamsa = _one(planets, "Hamsa Yoga")
assert hamsa["is_valid"] is True and hamsa["strength"] == "reduced"
assert hamsa["reductions"][0].startswith("木星燃烧")
far = _one({"Jupiter": _row("Cancer", 5.0), "Sun": _row("Leo", 20.0)}, "Hamsa Yoga")
assert far["strength"] == "full"
def test_malefic_aspect_reduces() -> None:
# Saturn in Capricorn casts its 7th on Cancer.
hamsa = _one({"Jupiter": _row("Cancer", 5.0), "Saturn": _row("Capricorn", 10.0)}, "Hamsa Yoga")
assert hamsa["strength"] == "reduced"
assert hamsa["reductions"] == ["受凶星照:土星"]
# Rahu / Ketu cast no aspect (repository drishti rule); Ketu opposite is no reduction.
assert _one({"Jupiter": _row("Cancer", 5.0), "Ketu": _row("Capricorn", 10.0)}, "Hamsa Yoga")["strength"] == "full"
def test_d9_debilitation_only_weakens() -> None:
# 原值:传 navamsa_sign 落陷 → is_valid False、strength cancelled(不传则不检查)
# 新值:formed 不变,d9_dignity_modifier = weakened,原因「D9 落陷,兑现减弱」;
# 不传 navamsa_sign 时由经度推出 D9(巨蟹 21° 的 D9 是摩羯)
# 原因:占星师第四轮裁定 2026-10-03 问 1(BUG-1213)
for row in (_row("Cancer", 21.0, navamsa_sign="Capricorn"), _row("Cancer", 21.0)):
hamsa = _one({"Jupiter": row}, "Hamsa Yoga")
assert (hamsa["status"], hamsa["is_valid"], hamsa["strength"]) == ("formed", True, "reduced")
assert hamsa["d9_dignity_modifier"] == "weakened" and hamsa["d9_sign"] == "Capricorn"
assert hamsa["reductions"] == ["D9 落陷,兑现减弱"]
strong = _one({"Jupiter": _row("Cancer", 5.0)}, "Hamsa Yoga")
assert strong["d9_dignity_modifier"] is None and strong["strength"] == "full"
def test_d1_condition_not_met_is_not_formed() -> None:
# Jupiter exalted but in the 9th from a Pisces Lagna (trikona, not kendra).
rows = detect_pancha_mahapurusha({"Jupiter": _row("Cancer", 5.0, asc="Pisces")}, include_not_formed=True)
hamsa = next(item for item in rows if item["planet"] == "Jupiter")
assert (hamsa["status"], hamsa["is_valid"]) == ("not_formed", False)
# Kendra but neither own nor exalted.
rows = detect_pancha_mahapurusha({"Jupiter": _row("Leo", 5.0, asc="Leo")}, include_not_formed=True)
assert next(item for item in rows if item["planet"] == "Jupiter")["status"] == "not_formed"
assert detect_pancha_mahapurusha({"Jupiter": _row("Leo", 5.0, asc="Leo")}) == []
def test_d9_weakened_yoga_reaches_the_card_and_the_report_rows_alike() -> None:
planets = {"Jupiter": _row("Cancer", 21.0), "Moon": _row("Taurus", 3.0)}
rows = pancha_mahapurusha_rows(planets)
assert rows[0]["d9_dignity_modifier"] == "weakened"
assert "D9 落陷,兑现减弱" in rows[0]["combination"]
engine_rows = [row for row in YogaEngine(str(RULES)).detect(planets, "Cancer") if row.get("category") == "mahapurusha"]
assert engine_rows == rows
chart_rows = JyotishAPIHandler._detect_yogas(None, planets, SIGNS.index("Cancer"))
entry = _compact_yoga_entry(next(row for row in chart_rows if row.get("category") == "PMC"))
layer = build_traditional_yogas({"raman_support": {"yogas": {"status": "executed", "hits": [], "ungoverned_detected": [entry]}}})
assert layer["names"] == ["Hamsa Yoga(Jupiter;部分减弱:D9 落陷,兑现减弱)"]
def test_reference_only_rules_never_run() -> None:
rules = {rule["id"]: rule for rule in json.loads(RULES.read_text(encoding="utf-8"))["rules"]}
for rule_id in REFERENCE_IDS:
assert rules[rule_id]["reference_only"] is True and "BUG-1207" in rules[rule_id]["reference_only_reason"], rule_id
assert not REFERENCE_IDS & {rule["id"] for rule in YogaEngine(str(RULES)).rules}
def test_a_rejected_yoga_does_not_come_back_by_another_path() -> None:
# 原值:木星在上升巨蟹入旺但 D9 落陷(被判不成立)作为「被拒格局」
# 新值:木星入旺巨蟹但在双鱼上升的第 9 宫(三方宫,非角宫),D1 条件不成立
# 原因:第四轮问 1(BUG-1213)D9 落陷不再拒绝格局;被拒样例改为 D1 不成立,
# 仍是已退役的 hamsa_trikona 规则曾接受的三方宫
asc = "Pisces"
planets = {"Jupiter": _row("Cancer", 5.0, asc=asc), "Moon": _row("Taurus", 3.0, asc=asc), "Sun": _row("Aries", 10.0, asc=asc),
"Mars": _row("Gemini", 1.0, asc=asc), "Mercury": _row("Aquarius", 2.0, asc=asc), "Venus": _row("Aries", 2.0, asc=asc),
"Saturn": _row("Leo", 2.0, asc=asc), "Rahu": _row("Virgo", 2.0, asc=asc), "Ketu": _row("Pisces", 2.0, asc=asc)}
engine_rows = YogaEngine(str(RULES)).detect(planets, asc)
assert not [row for row in engine_rows if "Hamsa" in row["name"] or "汉萨" in row.get("name_cn", "")]
chart_rows = JyotishAPIHandler._detect_yogas(None, planets, SIGNS.index(asc))
assert not [row for row in chart_rows if "Hamsa" in row["name"]]
merged = merge_detected_yogas(
[entry for entry in map(_compact_yoga_entry, chart_rows) if entry],
[entry for entry in map(_compact_yoga_entry, engine_rows) if entry],
)
layer = build_traditional_yogas({"raman_support": {"yogas": {"status": "executed", "hits": [], "ungoverned_detected": merged}}})
assert not [name for name in layer["names"] if "Hamsa" in name]
aggregate = calc_all_yogas_doshas(planets, {}, asc, "Moon", "Taurus", 11, 12, 10, "Gemini")
assert aggregate["pancha_mahapurusha"]["status"] == "reference_only"
assert aggregate["pancha_mahapurusha"]["yogas"] == []
def test_reduced_yoga_reaches_the_card_with_its_reasons() -> None:
# 原值:土星摩羯 10°(D9 白羊,落陷;旧规则只在给 navamsa_sign 时检查)
# 新值:土星摩羯 2°(D9 摩羯,本宫),断言不变
# 原因:BUG-1213 起 D9 由经度推出,摩羯 10° 的萨萨会多一条「D9 落陷」减弱;
# 本用例只测凶星照的原因上卡,换成 D9 不落陷的度数
planets = {"Jupiter": _row("Cancer", 5.0), "Saturn": _row("Capricorn", 2.0)}
all_rows = pancha_mahapurusha_rows(planets)
engine_rows = [row for row in YogaEngine(str(RULES)).detect(planets, "Cancer") if row.get("category") == "mahapurusha"]
assert engine_rows == all_rows
# Saturn in Capricorn is the 7th from a Cancer Lagna: Sasa, full.
assert [(row["name"], row["strength"]) for row in all_rows] == [("Hamsa Yoga", "部分减弱"), ("Sasa Yoga", "强")]
rows = all_rows[:1]
entry = _compact_yoga_entry(rows[0])
layer = build_traditional_yogas({"raman_support": {"yogas": {"status": "executed", "hits": [], "ungoverned_detected": [entry]}}})
assert layer["names"] == ["Hamsa Yoga(Jupiter;部分减弱:受凶星照:土星)"]
FIGURES = (
("minute_rectification_development_v1.json", "steve_jobs_1955_development"),
("minute_rectification_holdout_v4.json", "barack_obama_1961_aa_v4_holdout"),
("minute_rectification_holdout_v4.json", "elizabeth_taylor_1932_aa_v4_holdout"),
("minute_rectification_holdout_v5.json", "marilyn_monroe_1926_aa_v5_holdout"),
("minute_rectification_holdout_v5.json", "judy_garland_1922_aa_v5_holdout"),
("minute_rectification_holdout_v5.json", "edith_piaf_1915_aa_v5_holdout"),
("minute_rectification_holdout_v5.json", "frida_kahlo_1907_aa_v5_holdout"),
("minute_rectification_holdout_v5.json", "george_w_bush_1946_aa_v5_holdout"),
("minute_rectification_holdout_v5.json", "zinedine_zidane_1972_aa_v5_holdout"),
)
@pytest.mark.parametrize("library,case_id", FIGURES)
def test_public_figures_engine_and_chart_paths_agree_with_the_main_detector(library: str, case_id: str) -> None:
cases = json.loads((ROOT / "references/real_case_calibration" / library).read_text(encoding="utf-8"))["cases"]
birth = next(case for case in cases if case["case_id"] == case_id)["birth"]
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
chart, *_ = compute_chart_data(year, month, day, hour, minute, birth["latitude"], birth["longitude"],
birth["timezone_offset"], ayanamsa_name="raman")
asc = chart["ascendant"]["sign"]
# Report path shape (degree in sign + lon) and API path shape (degree = lon).
report_planets = {name: {"sign": row["sign"], "house": row["house"], "degree": row["degree_in_sign"],
"lon": row["degree_raw"], "retrograde": row["retrograde"]}
for name, row in chart["planets"].items()}
api_planets = {name: {**row, "degree": row["lon"]} for name, row in report_planets.items()}
expected = {item["planet"]: item["strength"] for item in detect_pancha_mahapurusha(api_planets) if item["is_valid"]}
engine = {row["planets"][0]: row for row in YogaEngine(str(RULES)).detect(report_planets, asc) if row.get("category") == "mahapurusha"}
assert {planet: ("reduced" if row["reductions"] else "full") for planet, row in engine.items()} == expected
chart_rows = JyotishAPIHandler._detect_yogas(None, api_planets, SIGNS.index(asc))
assert {row["planets"][0] for row in chart_rows if row.get("category") == "PMC"} == set(expected)
def test_report_yoga_rows_carry_retrograde_and_longitude() -> None:
"""cmd_yoga (report path) hands the detectors the same longitude and
retrograde flag as the consultation path, so combustion orbs agree."""
from scripts.jyotish_engine import cmd_yoga
cases = json.loads((ROOT / "references/real_case_calibration/minute_rectification_holdout_v5.json").read_text(encoding="utf-8"))["cases"]
birth = next(case for case in cases if case["case_id"] == "judy_garland_1922_aa_v5_holdout")["birth"]
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
args = type("Args", (), {"planets": None, "year": year, "month": month, "day": day, "hour": hour, "minute": minute,
"lat": birth["latitude"], "lon": birth["longitude"], "tz": birth["timezone_offset"],
"node_mode": "mean", "second": 0, "ascendant": "Aries"})()
report = [row for row in cmd_yoga(args)["yogas"] if row.get("category") == "mahapurusha"]
chart, *_ = compute_chart_data(year, month, day, hour, minute, birth["latitude"], birth["longitude"], birth["timezone_offset"])
api = {name: {"sign": row["sign"], "house": row["house"], "lon": row["degree_raw"], "degree": row["degree_raw"],
"retrograde": row["retrograde"]} for name, row in chart["planets"].items()}
assert [(row["name"], row["reductions"]) for row in report] == [
(row["name"], row["reductions"]) for row in pancha_mahapurusha_rows(api)]
def test_full_reading_yoga_module_matches_the_consultation_path() -> None:
"""The report's yoga table (cmd_full_reading modules.yoga, varga-context
re-run) gives the same reduced reasons as the consultation path; the
retrograde flag travels in the planets string (Judy Garland: retrograde
Mercury, combustion orb 12°)."""
from scripts.jyotish_engine import cmd_full_reading
cases = json.loads((ROOT / "references/real_case_calibration/minute_rectification_holdout_v5.json").read_text(encoding="utf-8"))["cases"]
birth = next(case for case in cases if case["case_id"] == "judy_garland_1922_aa_v5_holdout")["birth"]
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
args = {"year": year, "month": month, "day": day, "hour": hour, "minute": minute, "second": 0,
"lat": birth["latitude"], "lon": birth["longitude"], "tz": birth["timezone_offset"],
"node_mode": "mean", "ayanamsa": "raman", "today": "2026-10-03", "target_year": 2026, "age": 104}
reading = cmd_full_reading(type("Args", (), args)())
report = [row for row in reading["modules"]["yoga"]["yogas"] if row.get("category") == "mahapurusha"]
chart, *_ = compute_chart_data(year, month, day, hour, minute, birth["latitude"], birth["longitude"],
birth["timezone_offset"], ayanamsa_name="raman")
api = {name: {"sign": row["sign"], "house": row["house"], "lon": row["degree_raw"], "degree": row["degree_raw"],
"retrograde": row["retrograde"]} for name, row in chart["planets"].items()}
assert [(row["name"], row["reductions"]) for row in report] == [
(row["name"], row["reductions"]) for row in pancha_mahapurusha_rows(api)]
assert any("12°" in reason for row in report for reason in row["reductions"])