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
235 lines
15 KiB
Python
235 lines
15 KiB
Python
"""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"])
|