fix(yoga): Neecha Bhanga lists the conditions that hold, never Raja Yoga (T8, BUG-1205)

Astrologer ruling 乙14. One condition source, yogas_doshas.calc_nicha_bhanga_raj_yoga
(conditions A-F, now also structured). New scripts/main_yoga_detectors.py turns it
into yoga_engine-shaped rows (zh + en twins) that YogaEngine.detect appends; the
four neechabhanga_* rules are reference_only (kept, renamed, not evaluated).
Card: traditional_yogas canonical 落陷取消(Neecha Bhanga) with 成立条件; compact
yoga entries keep conditions/reductions. Report: category 落陷取消, mixed group,
not raja; personal-report alias neecha_bhanga -> special.

Changed assertion (test_consultation_yoga_detection_merge): canonical name
'Neecha Bhanga Raja Yoga' -> '落陷取消(Neecha Bhanga)' (ruling 乙14).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-03 11:38:52 +08:00
co-authored by Claude Opus 5.5
parent 0cf2249e6b
commit df21e591e8
10 changed files with 342 additions and 26 deletions
@@ -83,7 +83,8 @@ def test_traditional_yogas_closed_list_only_and_fixed_label() -> None:
))
assert layer["status"] == "executed"
assert layer["label"] == TRADITIONAL_YOGA_LABEL == "传统格局,本站未验证,只作参考"
assert layer["names"] == ["Sasa Yoga", "Gajakesari Yoga", "Neecha Bhanga Raja Yoga", "Budha-Aditya Yoga"]
# BUG-1205: the Neecha Bhanga canonical is no longer called Raja Yoga.
assert layer["names"] == ["Sasa Yoga", "Gajakesari Yoga", "落陷取消(Neecha Bhanga)", "Budha-Aditya Yoga"]
def test_traditional_yogas_skip_misses_and_empty() -> None:
+109
View File
@@ -0,0 +1,109 @@
"""Neecha Bhanga lists the conditions that hold, never "Raja Yoga" (BUG-1205).
Astrologer rulings 2026-10-03 乙14 (TASK-astrologer-rulings-batch1-20261003 T8):
only the satisfied cancellation conditions are shown; the four data-driven
`neechabhanga_*` rules are `reference_only`; the one condition source is
`yogas_doshas.calc_nicha_bhanga_raj_yoga` (conditions A-F). Public chart:
Barack Obama, Astro-Databank AA (repository holdout v4), Raman, mean nodes.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from scripts.consultation_native_layers import build_traditional_yogas
from scripts.jyotish_engine import compute_chart_data
from scripts.main_yoga_detectors import neecha_bhanga_rows, with_navamsa
from scripts.yoga_engine import YogaEngine
from scripts.yogas_doshas import calc_nicha_bhanga_raj_yoga
ROOT = Path(__file__).resolve().parents[1]
RULES = ROOT / "references" / "yoga_rules.json"
RAJA = re.compile(r"(neecha|neech|nicha)[ -]?bhanga[ -]?raja?\b|neechabhanga raja", re.IGNORECASE)
def _case(case_id: str, library: str) -> dict:
cases = json.loads((ROOT / "references/real_case_calibration" / library).read_text(encoding="utf-8"))["cases"]
return next(case for case in cases if case["case_id"] == case_id)["birth"]
def _planets(birth: dict) -> tuple[dict, str]:
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")
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()}
return planets, chart["ascendant"]["sign"]
def test_reference_only_rules_are_not_evaluated_and_carry_no_raja_name() -> None:
rules = {rule["id"]: rule for rule in json.loads(RULES.read_text(encoding="utf-8"))["rules"]}
ids = {"neechabhanga_basic", "neechabhanga_kendra", "neechabhanga_lord_exalted", "neechabhanga_parivartana"}
for rule_id in ids:
assert rules[rule_id]["reference_only"] is True
assert "BUG-1205" in rules[rule_id]["reference_only_reason"]
for key in ("name", "name_cn", "dedup_key"):
assert not RAJA.search(str(rules[rule_id].get(key) or "")), (rule_id, key)
engine = YogaEngine(str(RULES))
assert not ids & {rule["id"] for rule in engine.rules}
assert ids <= set(engine.reference_only_rule_ids)
def test_public_chart_lists_the_conditions_from_the_one_source() -> None:
planets, asc = _planets(_case("barack_obama_1961_aa_v4_holdout", "minute_rectification_holdout_v4.json"))
source = calc_nicha_bhanga_raj_yoga(with_navamsa(planets), {})["yogas"]
rows = [row for row in YogaEngine(str(RULES)).detect(planets, asc) if row.get("category") == "neecha_bhanga"]
assert rows == neecha_bhanga_rows(planets)
assert [row["planets"] for row in rows] == [[item["planet"]] for item in source]
for row, item in zip(rows, source):
assert len(row["conditions"]) == len(item["cancellation_conditions"]) == len(item["cancellation_reasons"]) > 0
assert row["name"] == "Neecha Bhanga" and row["name_cn"] == "落陷取消"
assert "成立条件:" in row["combination"]
assert not RAJA.search(json.dumps(row, ensure_ascii=False))
assert "Raja" not in row["name"] and "王" not in row["name_cn"]
# This chart: Jupiter debilitated in Capricorn, dispositor Saturn in the 1st.
assert rows[0]["planets"] == ["Jupiter"]
assert rows[0]["conditions"][0] == "落陷星座主星土星在上升起第1宫(角宫)"
def test_card_label_lists_conditions() -> None:
row = {"name": "Neecha Bhanga", "planets": ["Jupiter"], "conditions": ["条件甲", "条件乙"]}
layer = build_traditional_yogas({"raman_support": {"yogas": {"status": "executed", "hits": [], "ungoverned_detected": [row]}}})
assert layer["names"] == ["落陷取消(Neecha Bhanga)(Jupiter;成立条件:条件甲;条件乙)"]
def test_no_raja_wording_on_user_visible_surfaces() -> None:
frontend = [path for path in (ROOT / "frontend" / "src").rglob("*") if path.suffix in {".ts", ".tsx"}]
for path in frontend + [RULES, ROOT / "scripts/consultation_native_layers.py", ROOT / "scripts/main_yoga_detectors.py"]:
text = path.read_text(encoding="utf-8")
assert not RAJA.search(text), path
personal = (ROOT / "frontend/src/lib/personal-report-generation.ts").read_text(encoding="utf-8")
assert 'neecha_bhanga: "special"' in personal
def test_reader_report_shows_conditions_not_raja() -> None:
import copy
from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
from scripts.professional_report_reference import _export_args
birth = _case("barack_obama_1961_aa_v4_holdout", "minute_rectification_holdout_v4.json")
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
case = {"name": "Barack Obama", "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": 65}
packet = dict(build_professional_report_reference_packet(cmd_full_reading(type("Args", (), dict(case))()), _export_args(dict(case)), []))
packet["report_version"] = "pl9_personal_long_report.v3"
markdown = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "reader_main")
rows = [line for line in markdown.splitlines() if line.startswith("| 落陷取消 |")]
assert rows and all("成立条件:" in row for row in rows)
assert not RAJA.search(markdown)
assert "坏座位消王" not in markdown and "王权/事业 | 成立条件" not in markdown
mixed = markdown[markdown.index("#### 可用混合型瑜伽"):]
assert "| 落陷取消 | 落陷取消 |" in mixed.split("#### 可用凶性瑜伽")[0]