diff --git a/docs/research/ACTIVE_FRONTS.md b/docs/research/ACTIVE_FRONTS.md index 4e1771b3..553627ca 100644 --- a/docs/research/ACTIVE_FRONTS.md +++ b/docs/research/ACTIVE_FRONTS.md @@ -41,4 +41,9 @@ This file is the small index for the current engineering fronts that still drive - `/Users/wuyongnaren/Documents/印度占星/docs/superpowers/specs/2026-06-28-dignity-guardrail-v1-design.md` - `/Users/wuyongnaren/Documents/印度占星/docs/research/dignity_guardrail_v1_boundary_audit_2026_06_28.md` -- D1-only dignity guardrail is now landed; divisional dignity repair and a separate functional-role guardrail remain open fronts. +- `/Users/wuyongnaren/Documents/印度占星/docs/research/divisional_dignity_context_repair_audit_2026_06_28.md` +- D1-only dignity guardrail is landed. +- Divisional dignity context repair for D9/DK/Vimsopaka Navamsa is also landed. +- Still open: + - Vimsopaka semantic mapping for `NEECHA_BHANGA / GREAT_FRIEND / GREAT_ENEMY` + - a separate functional-role guardrail diff --git a/docs/research/divisional_dignity_context_repair_audit_2026_06_28.md b/docs/research/divisional_dignity_context_repair_audit_2026_06_28.md new file mode 100644 index 00000000..7b8ed03e --- /dev/null +++ b/docs/research/divisional_dignity_context_repair_audit_2026_06_28.md @@ -0,0 +1,121 @@ +# Divisional Dignity Context Repair Audit (2026-06-28) + +## What Landed + +This pass repairs a specific boundary bug in `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_engine.py`: + +- `full-reading.modules.d9_navamsa_expanded[*].dignity` +- `full-reading.modules.jaimini.darakaraka.d9_dignity` +- `full-reading.modules.vimsopaka[*].varga_scores.Navamsa.dignity` + +were previously calling `_get_dignity_level(...)` with natal `D1 planets` as `planets_data`, even when the dignity being judged belonged to `D9` or another varga. + +This pass adds `_build_dignity_context(chart_data)` and routes divisional dignity calls through the current chart context instead of borrowing `D1`. + +## Root Cause + +The helper `_get_dignity_level(planet, sign, deg_in_sign, planets_data)` was already context-sensitive: + +- temporary friendship (`Tatkalika Maitri`) +- compound friendship (`Panchadha Maitri`) +- `Neecha Bhanga` + +all depend on the `planets_data` passed in. + +The bug was not in the helper itself. The bug was in the callers that fed `D1 planets` into divisional dignity paths. + +## Regression Cases Locked + +### 1. D9 expanded dignity uses D9 context + +Case: + +- `1990-01-01 12:00` +- `lat=39.9` +- `lon=116.4` +- `tz=8` + +Expected: + +- `modules.d9_navamsa_expanded.Jupiter.sign == Capricorn` +- `modules.d9_navamsa_expanded.Jupiter.dignity == NEECHA_BHANGA` + +Before fix: + +- dignity incorrectly surfaced as `DEBILITATED` + +### 2. Darakaraka D9 dignity uses D9 context + +Case: + +- `1992-08-25 23:10` +- `lat=35.6895` +- `lon=139.6917` +- `tz=9` + +Expected: + +- `modules.jaimini.darakaraka.dk_planet == Sun` +- `modules.jaimini.darakaraka.d9_sign == Gemini` +- `modules.jaimini.darakaraka.d9_dignity == ENEMY` + +Before fix: + +- dignity incorrectly surfaced as `FRIEND` + +### 3. Vimsopaka Navamsa dignity uses D9 context + +Same `1992-08-25 23:10` case: + +Expected: + +- `modules.vimsopaka.Sun.varga_scores.Navamsa.dignity == Enemy` + +Before fix: + +- dignity incorrectly surfaced as `Friend` + +## Tests Added / Updated + +- `/Users/wuyongnaren/Documents/印度占星/tests/test_dasha.py` + - helper-level context-sensitive dignity regression tests +- `/Users/wuyongnaren/Documents/印度占星/tests/test_cli_smoke.py` + - D9 expanded dignity regression + - Darakaraka D9 dignity regression + - Vimsopaka Navamsa dignity regression + - updated D1 smoke expectation for richer `Great Enemy` output + +## Verification + +Ran: + +```bash +python3 -m pytest tests/test_dasha.py tests/test_cli_smoke.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_finance.py -q +``` + +Result: + +- `75 passed` + +## Boundary Notes + +This pass **does fix**: + +- divisional dignity borrowing `D1` context +- D9 exposed dignity fields in `full-reading` +- Navamsa dignity inside Vimsopaka when the dignity category already maps cleanly to the current `DignityLevel` enum + +This pass **does not fix**: + +- full semantic mapping of `NEECHA_BHANGA`, `GREAT_FRIEND`, `GREAT_ENEMY` into Vimsopaka virupa policy +- broader D10/D12/D60 dignity benchmark closure +- any adjudicator logic that consumes divisional dignity later +- historical P0/P1 items unrelated to this context bug + +## Fragment Reuse / Traceability + +This repair closes one concrete slice of the earlier audit trail in: + +- `/Users/wuyongnaren/Documents/印度占星/references/audit-deep-data-audit-2026-05-04.md` + +where Vimsopaka and divisional dignity context leakage had already been identified as a high-severity trust issue. diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 4c55708f..781b81ce 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -129,10 +129,13 @@ DIGNITY_LABELS = { 'EXALTED': '入旺(Exalted)', 'MOOLATRIKONA': '本垣(Moolatrikona)', 'OWN_SIGN': '入庙(Own Sign)', + 'GREAT_FRIEND': '极友(Great Friend)', 'FRIEND': '入友(Friendly Sign)', + 'NEUTRAL': '中性(Neutral)', 'ENEMY': '入敌(Enemy Sign)', + 'GREAT_ENEMY': '极敌(Great Enemy)', 'DEBILITATED': '落陷(Debilitated)', - 'NEUTRAL': '中性', + 'NEECHA_BHANGA': '落陷取消(Neecha Bhanga)', } PUSHKARA_NAVAMSA_RANGES = { 'fire': [(6 + 40/60, 10), (23 + 20/60, 26 + 40/60)], @@ -371,31 +374,125 @@ def calc_all_inter_chart_linkages(d1_data, d9_data, d10_data, d12_data=None, d1_ -def _get_dignity_level(planet, sign, deg_in_sign=None): - """判断行星在某星座的尊严等级 (用于 Vimsopaka 映射)""" +def _get_temporary_relationship(planet1, planet2, planets_data): + """Calculate Temporary Friendship (Tatkalika Maitri).""" + if not planets_data or planet1 not in planets_data or planet2 not in planets_data: + return 'NEUTRAL' + + p1_sign = planets_data[planet1].get('sign') + p2_sign = planets_data[planet2].get('sign') + if not p1_sign or not p2_sign: + return 'NEUTRAL' + + idx1 = SIGNS.index(p1_sign) + idx2 = SIGNS.index(p2_sign) + distance = (idx2 - idx1) % 12 + 1 + + # 2, 3, 4, 10, 11, 12 from planet are temporary friends + if distance in [2, 3, 4, 10, 11, 12]: + return 'FRIEND' + # 1 (conjunct), 5, 6, 7, 8, 9 are temporary enemies + else: + return 'ENEMY' + +def _check_neecha_bhanga(planet, sign, planets_data): + """Check for Cancellation of Debilitation.""" + if not planets_data: + return False + + sign_lord = SIGN_LORDS.get(sign) + + # Condition 1: Dispositor is exalted. + if sign_lord and sign_lord in planets_data: + lord_sign = planets_data[sign_lord].get('sign') + if lord_sign and EXALTATION.get(sign_lord) == lord_sign: + return True + + # Condition 2: Planet exalted in this sign is conjunct. + exalted_planet = None + for p, ex_sign in EXALTATION.items(): + if ex_sign == sign: + exalted_planet = p + break + + if exalted_planet and exalted_planet in planets_data: + ex_p_sign = planets_data[exalted_planet].get('sign') + if ex_p_sign == sign: + return True + + return False + + +def _build_dignity_context(chart_data): + """Normalize a chart/varga payload into the per-planet context used by dignity checks.""" + context = {} + if not isinstance(chart_data, dict): + return context + + for pn, pd in chart_data.items(): + if pn in ('_meta', 'Ascendant') or not isinstance(pd, dict) or 'sign' not in pd: + continue + context[pn] = { + 'sign': pd.get('sign'), + 'degree_in_sign': pd.get('degree_in_sign', pd.get('degree', 0) % 30), + } + return context + +def _get_dignity_level(planet, sign, deg_in_sign=None, planets_data=None): + """判断行星在某星座的尊严等级 (用于 Vimsopaka 映射), 包含五重敌友和落陷取消""" if EXALTATION.get(planet) == sign: return 'EXALTED' - # Moolatrikona: 需要检查度数范围 + mt = MOOLATRIKONA.get(planet) if mt and mt[0] == sign: if deg_in_sign is not None and mt[1] <= deg_in_sign < mt[2]: return 'MOOLATRIKONA' + if SIGN_LORDS.get(sign) == planet: return 'OWN_SIGN' + if DEBILITATION.get(planet) == sign: + if _check_neecha_bhanga(planet, sign, planets_data): + return 'NEECHA_BHANGA' return 'DEBILITATED' - # Natural dignity is judged by the planet's attitude toward the sign lord. + sign_lord = SIGN_LORDS.get(sign, '') + if not sign_lord: + return 'NEUTRAL' + + # Naisargika (Permanent) + perm = 'NEUTRAL' if sign_lord in PERMANENT_FRIENDS.get(planet, []): + perm = 'FRIEND' + elif sign_lord in PERMANENT_ENEMIES.get(planet, []): + perm = 'ENEMY' + + if not planets_data: + return perm + + # Tatkalika (Temporary) + temp = _get_temporary_relationship(planet, sign_lord, planets_data) + + # Panchadha Maitri (Compound) + if perm == 'FRIEND' and temp == 'FRIEND': + return 'GREAT_FRIEND' + elif perm == 'ENEMY' and temp == 'ENEMY': + return 'GREAT_ENEMY' + elif perm == 'FRIEND' and temp == 'ENEMY': + return 'NEUTRAL' + elif perm == 'NEUTRAL' and temp == 'FRIEND': return 'FRIEND' - if sign_lord in PERMANENT_ENEMIES.get(planet, []): + elif perm == 'NEUTRAL' and temp == 'ENEMY': return 'ENEMY' + elif perm == 'ENEMY' and temp == 'FRIEND': + return 'NEUTRAL' + return 'NEUTRAL' -def _get_planet_status_label(planet, sign, deg_in_sign=None): +def _get_planet_status_label(planet, sign, deg_in_sign=None, planets_data=None): """Return the user-facing D1 dignity label for chart output.""" - return DIGNITY_LABELS.get(_get_dignity_level(planet, sign, deg_in_sign), '中性') + return DIGNITY_LABELS.get(_get_dignity_level(planet, sign, deg_in_sign, planets_data), '中性') PLANET_CN = {"Ketu": "南交点Ketu", "Venus": "金星Venus", "Sun": "太阳Sun", "Moon": "月亮Moon", "Mars": "火星Mars", "Rahu": "北交点Rahu", "Jupiter": "木星Jupiter", "Saturn": "土星Saturn", "Mercury": "水星Mercury"} BASE_PLANETS_SWE = {'Sun': swe.SUN, 'Moon': swe.MOON, 'Mars': swe.MARS, 'Mercury': swe.MERCURY, 'Jupiter': swe.JUPITER, 'Venus': swe.VENUS, 'Saturn': swe.SATURN} if HAS_SWE else {} PLANETS_SWE = {**BASE_PLANETS_SWE, 'Rahu': swe.MEAN_NODE} if HAS_SWE else {} @@ -1033,14 +1130,13 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode=' si = int(lon_p / 30); d_in_s = lon_p - si * 30; sign = SIGNS[si] retro = spd < 0 house = ((si - asc_idx) % 12) + 1 - status = _get_planet_status_label(pname, sign, d_in_s) ni = int(lon_p / nak_span); pada = int((lon_p % nak_span) / (nak_span / 4)) + 1 nak_n, nak_l, _ = NAKSHATRA_LIST[ni % 27] result["planets"][pname] = { "sign": sign, "sign_cn": SIGNS_CN[sign], "degree": round(lon_p, 4), "degree_raw": lon_p, "degree_in_sign": round(d_in_s, 4), "degree_in_sign_raw": d_in_s, - "house": house, "status": status, + "house": house, "status": "", "retrograde": retro, "speed": round(spd, 6), "nakshatra": nak_n, "nakshatra_pada": pada, "nakshatra_lord": nak_l} if pname == 'Rahu': @@ -1051,11 +1147,16 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode=' "sign": SIGNS[ksi], "sign_cn": SIGNS_CN[SIGNS[ksi]], "degree": round(klon, 4), "degree_raw": klon, "degree_in_sign": round(kd, 4), "degree_in_sign_raw": kd, - "house": ((ksi - asc_idx) % 12) + 1, "status": "中性", + "house": ((ksi - asc_idx) % 12) + 1, "status": "", "retrograde": True, "speed": round(spd, 6), "nakshatra": kn, "nakshatra_pada": kp, "nakshatra_lord": kl} except Exception as e: result["planets"][pname] = {"error": str(e)} + + for pn, p_data in result["planets"].items(): + if "error" not in p_data and pn != "Lagna": + p_data["status"] = _get_planet_status_label(pn, p_data["sign"], p_data["degree_in_sign"], result["planets"]) + return result, asc_idx, jd, ayanamsa @@ -3861,14 +3962,16 @@ def cmd_full_reading(args): # 不使用 _dignity 字典(只有 Exalted/Debilitated/Own Sign) pinfo = vdata.get(pn, {}) if isinstance(pinfo, dict) and 'sign' in pinfo: + dignity_context = planets if div == 1 else _build_dignity_context(vdata) dl_key = _get_dignity_level(pn, pinfo['sign'], - pinfo.get('degree_in_sign', pinfo.get('degree', 0) % 30)) + pinfo.get('degree_in_sign', pinfo.get('degree', 0) % 30), + dignity_context) planet_vargas_input[pn][vt] = dignity_map.get(dl_key, DignityLevel.NEUTRAL) elif div == 1: # D1 直接用本命盘数据 p_sign = SIGNS[int(planet_lons.get(pn, 0) / 30) % 12] d_in_s = planet_lons.get(pn, 0) % 30 - dl_key = _get_dignity_level(pn, p_sign, d_in_s) + dl_key = _get_dignity_level(pn, p_sign, d_in_s, planets) planet_vargas_input[pn][vt] = dignity_map.get(dl_key, DignityLevel.NEUTRAL) else: planet_vargas_input[pn][vt] = DignityLevel.NEUTRAL @@ -3876,7 +3979,7 @@ def cmd_full_reading(args): # fallback: D1 从 planet_lons 计算 p_sign = SIGNS[int(planet_lons.get(pn, 0) / 30) % 12] d_in_s = planet_lons.get(pn, 0) % 30 - dl_key = _get_dignity_level(pn, p_sign, d_in_s) + dl_key = _get_dignity_level(pn, p_sign, d_in_s, planets) planet_vargas_input[pn][vt] = dignity_map.get(dl_key, DignityLevel.NEUTRAL) else: planet_vargas_input[pn][vt] = DignityLevel.NEUTRAL @@ -4260,6 +4363,7 @@ def cmd_full_reading(args): d9_data = varga_data.get('D9_Navamsa', {}) if isinstance(varga_data, dict) else {} d9_asc = d9_data.get('Ascendant', {}) if isinstance(d9_data, dict) else {} d9_asc_idx = SIGNS.index(d9_asc.get('sign', 'Aries')) if isinstance(d9_asc, dict) and d9_asc.get('sign') in SIGNS else 0 + d9_context = _build_dignity_context(d9_data) if isinstance(d9_data, dict): for d9_pn, d9_pd in d9_data.items(): if d9_pn == '_meta' or d9_pn == 'Ascendant' or not isinstance(d9_pd, dict) or 'sign' not in d9_pd: @@ -4271,7 +4375,7 @@ def cmd_full_reading(args): 'sign': d9_sign, 'house': ((d9_sign_idx - d9_asc_idx) % 12) + 1, 'degree_in_sign': d9_deg, - 'dignity': _get_dignity_level(d9_pn, d9_sign, d9_deg), + 'dignity': _get_dignity_level(d9_pn, d9_sign, d9_deg, d9_context), } jaimini_result['darakaraka'] = analyze_darakaraka({ 'ascendant': chart.get('ascendant', {}), @@ -4578,12 +4682,13 @@ def cmd_full_reading(args): d9_data = varga_data.get('D9_Navamsa', {}) if isinstance(varga_data, dict) else {} if d9_data: d9_expanded = {} + d9_context = _build_dignity_context(d9_data) for pn, pd in d9_data.items(): if pn == '_meta' or not isinstance(pd, dict) or 'sign' not in pd: continue d9_sign = pd['sign'] d9_deg = pd.get('degree_in_sign', pd.get('degree', 0) % 30) - dignity = _get_dignity_level(pn, d9_sign, d9_deg) + dignity = _get_dignity_level(pn, d9_sign, d9_deg, d9_context) # D9 宫位(从D9 Asc计算) d9_asc_data = d9_data.get('Ascendant', {}) d9_asc_sign_idx = SIGNS.index(d9_asc_data.get('sign', 'Aries')) if isinstance(d9_asc_data, dict) and d9_asc_data.get('sign') in SIGNS else 0 diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 897e4fd8..a5f0e437 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -126,7 +126,7 @@ def test_chart_accepts_second_and_preserves_birth_time_precision() -> None: assert with_seconds["birth_info"]["julian_day"] > without_seconds["birth_info"]["julian_day"] -def test_chart_reports_friend_and_enemy_sign_dignity_for_user_case() -> None: +def test_chart_reports_richer_d1_dignity_labels_for_user_case() -> None: result = run_engine( "chart", "--year", "REDACTED_YEAR", @@ -141,7 +141,7 @@ def test_chart_reports_friend_and_enemy_sign_dignity_for_user_case() -> None: ) assert result["planets"]["Jupiter"]["sign"] == "Virgo" - assert result["planets"]["Jupiter"]["status"] == "入敌(Enemy Sign)" + assert result["planets"]["Jupiter"]["status"] == "极敌(Great Enemy)" def test_chart_table_mode_prints_readable_ascii_table() -> None: @@ -330,6 +330,50 @@ def test_full_reading_exposes_sensitive_point_modules() -> None: assert payload["is_sarpa_drekkana"] is True +def test_full_reading_uses_d9_context_for_navamsa_dignity() -> None: + result = run_engine("full-reading", *BASE_BIRTH_ARGS) + + d9_jupiter = result["modules"]["d9_navamsa_expanded"]["Jupiter"] + assert d9_jupiter["sign"] == "Capricorn" + assert d9_jupiter["dignity"] == "NEECHA_BHANGA" + + +def test_full_reading_uses_d9_context_for_darakaraka_dignity() -> None: + result = run_engine( + "full-reading", + "--year", "1992", + "--month", "8", + "--day", "25", + "--hour", "23", + "--minute", "10", + "--lat", "35.6895", + "--lon", "139.6917", + "--tz", "9", + ) + + darakaraka = result["modules"]["jaimini"]["darakaraka"] + assert darakaraka["dk_planet"] == "Sun" + assert darakaraka["d9_sign"] == "Gemini" + assert darakaraka["d9_dignity"] == "ENEMY" + + +def test_full_reading_uses_d9_context_for_vimsopaka_navamsa_dignity() -> None: + result = run_engine( + "full-reading", + "--year", "1992", + "--month", "8", + "--day", "25", + "--hour", "23", + "--minute", "10", + "--lat", "35.6895", + "--lon", "139.6917", + "--tz", "9", + ) + + navamsa = result["modules"]["vimsopaka"]["Sun"]["varga_scores"]["Navamsa"] + assert navamsa["dignity"] == "Enemy" + + def test_varga_cli_outputs_d9_and_d10() -> None: result = run_engine("varga", *BASE_BIRTH_ARGS, "--d9", "--d10") charts = result.get("divisional_charts", {}) diff --git a/tests/test_dasha.py b/tests/test_dasha.py index 8fbbde5b..0cd11cb0 100644 --- a/tests/test_dasha.py +++ b/tests/test_dasha.py @@ -91,6 +91,43 @@ class TestDignityLevel(unittest.TestCase): result = _get_dignity_level(planet, sign) self.assertEqual(result, 'DEBILITATED', f"{planet} in {sign} should be DEBILITATED, got {result}") + def test_temporary_relationship_depends_on_passed_chart_context(self): + """同一落位若误用 D1 planets_data,会改变分盘 dignity 结果。""" + d1_like_context = { + 'Jupiter': {'sign': 'Virgo'}, + 'Mercury': {'sign': 'Libra'}, + } + d9_like_context = { + 'Jupiter': {'sign': 'Virgo'}, + 'Mercury': {'sign': 'Capricorn'}, + } + d1_result = _get_dignity_level('Jupiter', 'Virgo', 10.0, d1_like_context) + d9_result = _get_dignity_level('Jupiter', 'Virgo', 10.0, d9_like_context) + + self.assertEqual(d1_result, 'NEUTRAL') + self.assertEqual(d9_result, 'GREAT_ENEMY') + + def test_neecha_bhanga_depends_on_passed_chart_context(self): + """落陷取消必须依赖当前分盘上下文,而不是借用 D1 planets_data。""" + with_cancelling_dispositor = { + 'Mercury': {'sign': 'Pisces'}, + 'Jupiter': {'sign': 'Cancer'}, + 'Venus': {'sign': 'Aries'}, + } + without_cancelling_dispositor = { + 'Mercury': {'sign': 'Pisces'}, + 'Jupiter': {'sign': 'Capricorn'}, + 'Venus': {'sign': 'Aries'}, + } + self.assertEqual( + _get_dignity_level('Mercury', 'Pisces', 12.0, with_cancelling_dispositor), + 'NEECHA_BHANGA', + ) + self.assertEqual( + _get_dignity_level('Mercury', 'Pisces', 12.0, without_cancelling_dispositor), + 'DEBILITATED', + ) + class TestDashaCalculation(unittest.TestCase): """测试 Dasha 时间线计算"""