Files
Jyotisha/tests/test_consultation_native_layers.py
T
Jesse_ChenandClaude Opus 5.5 19802a4874 feat(consult): card carries aspects, co-tenants, combustion; yogas by domain
TASK-consult-card-affliction-data-20261001 T1-T4 (BUG-1154~1157, BUG-1159 mitigation).

- Engine layer graha_drishti (aspects.calc_house_aspects; combustion via the
  full reading's own rule, extracted as jyotish_engine.is_combust, orbs
  unchanged); yoga_domains (fixed Raman packet table by rule_id, with the
  sieve's hit); slow_transits.saturn_from_moon_house.
- Card: planets get aspected_by / conjunct_with / combust (true only);
  domain house rows get aspected_by; parents roles get lord_rows and
  significator_rows with functional class, D12 house lord and its sign;
  health houses [1,6,8,12], education [4,5,9]; annual copies
  saturn_from_moon_house; yogas filtered by domain tag with hits.
- Budget (D6): antardashas as [lord, start, end], D9 vargottama only when
  true; all 12,000-char assertions unchanged.
- Golden regenerated by the real engine; jyotish_api_server.py untouched.

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

332 lines
16 KiB
Python

"""Native technique layers in the consultation output (TASK-consult-evidence-card-v2-20260927 T1).
Real engine runs on a public AA chart (the evidence-card research charts).
Each layer is compared with its native producer called directly, the annual
pack with `build_annual_tajika_pack`, and the rest of the response with a run
where the new layer is switched off: only the new key may differ.
"""
from __future__ import annotations
import json
import re
import sys
from datetime import datetime
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 consult_evidence_card_run as runner # noqa: E402
from consult_evidence_card_lib import load_public_charts # noqa: E402
import scripts.consultation_native_layers as native # noqa: E402
QUESTIONS = {
"marriage": "我的关系模式是什么?",
"annual": "未来一年重点是什么?",
"timing": "接下来一年哪些时间点重要?",
}
@pytest.fixture(scope="module", autouse=True)
def _no_external_vedastro():
runner._block_external_vedastro()
def _chart(index: int = 0) -> dict:
return load_public_charts(ROOT)[index]
def _run(domain: str, index: int = 0) -> dict:
return runner._run_workflow(runner._workflow_body(_chart(index), {
"id": domain, "domain": domain, "question": QUESTIONS[domain],
}))
def _layers(workflow: dict) -> dict:
layers = workflow["chart"]["consultation_native_layers"]
assert layers["schema"] == native.SCHEMA
assert layers["status"] == "executed"
return layers
def _birth_args(workflow: dict, house: int) -> SimpleNamespace:
birth = workflow["chart"]["birth"]
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=birth["node_mode"],
ayanamsa=birth["ayanamsa_name"], date=body["today"], house=house,
)
def test_quick_gate_runs_this_file() -> None:
import run_quality_gate
assert "tests/test_consultation_native_layers.py" in run_quality_gate.CORE_PYTEST_TARGETS
def test_every_layer_is_the_native_producers_own_output() -> None:
workflow = _run("marriage")
layers = _layers(workflow)
chart = workflow["chart"]
modules = chart["modules"]
engine = native._load_engine()
d9 = modules["varga_full"]["D9_Navamsa"]
summary = layers["d9_summary"]
assert summary["lagna"] == d9["ascendant"]["sign"]
expected_vargottama = engine._calc_vargottama(chart["planets"], {"D9_Navamsa": d9["planets"]})
context = engine._build_dignity_context(d9["planets"])
for name, row in summary["planets"].items():
assert row["d9_sign"] == d9["planets"][name]["sign"]
assert row["vargottama"] is expected_vargottama[name]["is_vargottama"]
level = engine._get_dignity_level(name, d9["planets"][name]["sign"], d9["planets"][name]["degree"], context)
assert row["d9_dignity"] == engine.DIGNITY_LABELS[level]
assert summary["vargottama"] == [name for name, row in summary["planets"].items() if row["vargottama"]]
assert len(summary["planets"]) == 9
from punarphoo import detect_natal_punarphoo
natal = detect_natal_punarphoo(chart["planets"])
assert layers["punarphoo"]["status"] == "observation_only"
assert layers["punarphoo"]["natal_present"] == natal["natal_present"]
assert layers["punarphoo"]["orb_deg"] == natal["orb_deg"]
assert "not marriage" in layers["punarphoo"]["claim_boundary"]
vivah = engine._calc_vivah_saham(
{name: {"degree": row["lon"]} for name, row in chart["planets"].items()}, chart["ascendant"]["lon"],
)
assert layers["vivah_saham"]["longitude"] == vivah["saham_lon"]
assert layers["vivah_saham"]["house"] == vivah["saham_house"]
assert layers["vivah_saham"]["sign"] == vivah["saham_sign"]
assert layers["day_night"]["is_day"] is modules["gulika"]["daynight_evidence"]["is_daytime"]
seventh = engine.cmd_double_transit_pac(_birth_args(workflow, 7))
double = layers["double_transit"]
assert double["house_7_summary"] == seventh["summary"]
assert [row["target"] for row in double["conclusions"] if row["house"] == 7] == [
row["target"] for row in seventh["double_transit"]
]
assert set(double["karaka_targets"]) == {"DK", "UL"}
assert double["karaka_targets"]["DK"]["target"] == f"DK({modules['jaimini']['chara_karakas']['DK']['planet']})"
assert double["karaka_targets"]["UL"]["target"] == f"UL({modules['arudha_padas']['padas']['UL']['sign']})"
# Conclusions, not raw degrees.
assert not re.search(r"\d+\.\d+°", json.dumps(double["conclusions"], ensure_ascii=False))
slow = layers["slow_transits"]
assert set(slow["current"]) == {"Jupiter", "Saturn", "Rahu", "Ketu"}
sav = modules["ashtakavarga"]["sav"]["scores"]
for body in ("Jupiter", "Saturn"):
row = slow["current"][body]
assert row["sav"] == sav[row["sign"]]
assert row["bav"] == modules["ashtakavarga"]["bav"][body]["bindus"][native.SIGNS.index(row["sign"])]
from ephemeris_events import build_ephemeris_events
scan = build_ephemeris_events({
"start_date": slow["window"]["start"], "end_date": slow["window"]["end"],
"ayanamsa": chart["birth"]["ayanamsa_name"], "bodies": ["jupiter", "saturn"],
})
expected = [(row["kind"], row["date"], row["body"]) for row in scan["events"]]
got = [(row["kind"], row["date"], row["body"]) for row in slow["key_dates"] if row["body"] in ("jupiter", "saturn")]
assert got == expected
assert slow["houses_in_window"]["Jupiter"][0] == slow["current"]["Jupiter"]["natal_house"]
ak = modules["jaimini"]["chara_karakas"]["AK"]["planet"]
assert layers["karakamsha"]["atmakaraka"] == ak
assert layers["karakamsha"]["karakamsha_sign"] == d9["planets"][ak]["sign"]
assert layers["argala"]["summary"]["supported_count"] == len(layers["argala"]["summary"]["supported_houses"])
assert set(layers["dispositor_chains"]["chains"]) >= {"Sun", "Moon"}
assert layers["inter_chart_linkage"]["planets"]["Sun"]["D9"]["sign"] == d9["planets"]["Sun"]["sign"]
assert layers["moon_transit"]["sign"] in native.SIGNS
assert layers["annual_tajika"]["status"] == "not_applicable"
def test_annual_route_attaches_the_annual_pack_verbatim() -> None:
workflow = _run("annual")
annual = _layers(workflow)["annual_tajika"]
assert annual["status"] == "parameter_sensitive"
assert "parameter_sensitive" in annual["boundary"]
from annual_tajika_pack import build_annual_tajika_pack
body = _chart()["body"]
payload = native._annual_payload({**body, "tz": float(body["tz"]), "ayanamsa": "raman", "node_mode": "mean"}, annual["target_year"])
pack = build_annual_tajika_pack(payload)
assert annual["annual_lagna"] == pack["annual_chart"]["data"]["asc_sign"]
assert annual["varshesha"] == pack["year_lord"]["data"]["year_lord"]
assert annual["muntha"]["sign"] == pack["muntha"]["data"]["muntha_sign"]
assert annual["muntha"]["lord"] == pack["muntha"]["data"]["muntha_lord"]
periods = pack["mudda_dasha"]["periods"]
assert [(row["lord"], row["months"]) for row in annual["mudda_dasha"]] == [(row["lord"], row["months"]) for row in periods]
segments = annual["mudda_dasha"]
assert segments[0]["start"] == annual["varsha_year"]["start"]
assert segments[-1]["end"] == annual["varsha_year"]["end"]
for before, after in zip(segments, segments[1:]):
assert before["end"] == after["start"]
assert annual["varsha_year"]["start"] <= body["today"] < annual["varsha_year"]["end"]
focus = {"Sun", "Moon", annual["varshesha"]}
assert annual["tajika_aspects"], "the annual chart has Tajika aspects for the Sun, Moon or year lord"
for row in annual["tajika_aspects"]:
assert focus.intersection(row["planets"])
assert row["name"] in {"Ithasala_candidate", "Easarapha_candidate"}
def test_an_unreliable_annual_pack_is_blocked_and_never_filled_with_natal_data(monkeypatch) -> None:
import annual_tajika_pack
real = annual_tajika_pack.build_annual_tajika_pack
def conflicted(payload, **kwargs):
pack = real(payload, **kwargs)
pack["muntha"] = {"status": "conflict", "field": "muntha", "reason": "same_profile_annual_producers_disagree"}
return pack
monkeypatch.setattr(annual_tajika_pack, "build_annual_tajika_pack", conflicted)
annual = _layers(_run("annual"))["annual_tajika"]
assert annual["status"] == "blocked"
assert "muntha" in annual["reason"]
for key in ("annual_lagna", "varshesha", "muntha", "mudda_dasha", "tajika_aspects"):
assert key not in annual
def test_a_failing_producer_blocks_its_own_layer_only(monkeypatch) -> None:
engine = native._load_engine()
def broken(*args, **kwargs):
raise RuntimeError("fixture")
monkeypatch.setattr(engine, "cmd_double_transit_pac", broken)
workflow = _run("timing")
layers = _layers(workflow)
assert workflow["success"] is True
assert layers["double_transit"] == {"status": "blocked", "reason": "RuntimeError"}
assert layers["d9_summary"]["status"] == "executed"
assert layers["slow_transits"]["status"] == "executed"
def test_the_new_layer_leaves_every_existing_output_unchanged(monkeypatch) -> None:
# The first run of a chart fills the chart cache; a cache hit re-reads the
# stored chart, whose VedAstro request manifest and key order differ from a
# fresh compute. Compare two cache-hit runs.
_run("marriage", index=1)
with_layers = _run("marriage", index=1)
monkeypatch.setattr(native, "attach_native_consultation_layers", lambda chart, **_: chart)
without = _run("marriage", index=1)
assert "consultation_native_layers" not in without["chart"]
volatile = {"runtime_cache", "vedastro_gateway"}
def strip(value):
if isinstance(value, dict):
return {key: strip(item) for key, item in value.items() if key not in volatile and key != "consultation_native_layers"}
if isinstance(value, list):
return [strip(item) for item in value]
return value
assert json.dumps(strip(with_layers), sort_keys=True, default=str) == json.dumps(strip(without), sort_keys=True, default=str)
def test_mudda_segment_dates_split_the_solar_year_by_the_native_months() -> None:
start, end = datetime(2026, 2, 25), datetime(2027, 2, 25)
rows = native._mudda_segments([{"lord": "Sun", "months": 6.0}, {"lord": "Moon", "months": 6.0}], start, end)
assert rows == [
{"lord": "Sun", "months": 6.0, "start": "2026-02-25", "end": "2026-08-26"},
{"lord": "Moon", "months": 6.0, "start": "2026-08-26", "end": "2027-02-25"},
]
# ── TASK-consult-card-affliction-data-20261001 T1 / T3 / T4 ─────────────────
def _chart_index(chart_id: str) -> int:
return [chart["id"] for chart in load_public_charts(ROOT)].index(chart_id)
def test_graha_drishti_is_the_engine_aspect_and_combustion_rule() -> None:
"""BUG-1154: aspects from aspects.calc_house_aspects, combustion from the
full reading's own rule; Rahu / Ketu receive aspects but cast none."""
from aspects import calc_house_aspects
engine = native._load_engine()
for chart_id in ("steve_jobs", "barack_obama", "elizabeth_taylor"):
workflow = _run("marriage", index=_chart_index(chart_id))
drishti = _layers(workflow)["graha_drishti"]
assert drishti["status"] == "executed"
planets = workflow["chart"]["planets"]
asc_lon = workflow["chart"]["ascendant"]["lon"]
sun_lon = planets["Sun"]["lon"]
by_house: dict[int, list[str]] = {house: [] for house in range(1, 13)}
for name in native.DRISHTI_CASTERS:
expected = sorted(int(h) for h in calc_house_aspects(planets[name]["lon"], name, asc_lon)["aspects_to"])
assert drishti["planets"][name]["aspects_houses"] == expected, (chart_id, name)
assert drishti["planets"][name]["combust"] is engine.is_combust(name, planets[name]["lon"], sun_lon)
for house in expected:
by_house[house].append(name)
for house, casters in by_house.items():
assert drishti["houses"][str(house)]["aspected_by"] == casters, (chart_id, house)
for name, row in planets.items():
assert drishti["planets"][name]["aspected_by"] == by_house[row["house"]], (chart_id, name)
for node in ("Rahu", "Ketu"):
assert "aspects_houses" not in drishti["planets"][node]
assert "combust" not in drishti["planets"][node]
assert all(node not in row["aspected_by"] for row in drishti["houses"].values())
def test_obama_saturn_aspects_the_sun_and_mercury_is_combust() -> None:
drishti = _layers(_run("marriage", index=_chart_index("barack_obama")))["graha_drishti"]
assert {3, 7, 10} <= set(drishti["planets"]["Saturn"]["aspects_houses"])
assert "Saturn" in drishti["planets"]["Sun"]["aspected_by"]
assert drishti["planets"]["Mercury"]["combust"] is True
def test_combustion_rule_is_unchanged() -> None:
"""The full reading's orbs, moved into a named helper without change."""
engine = native._load_engine()
assert engine.COMBUSTION_ORBS == {"Mars": 17, "Mercury": 12, "Jupiter": 11, "Venus": 10, "Saturn": 15}
assert engine.is_combust("Mercury", 112.0, 100.0) is True
assert engine.is_combust("Mercury", 112.01, 100.0) is False
assert engine.is_combust("Venus", 355.0, 5.0) is True # across 0°
assert engine.is_combust("Sun", 100.0, 100.0) is False
assert engine.is_combust("Moon", 100.0, 100.0) is True # orb 0: only an exact conjunction
def test_saturn_from_moon_house_is_whole_sign_from_the_natal_moon() -> None:
"""BUG-1156: Jobs at the research reference date, Saturn in Pisces over the Pisces Moon."""
for chart_id in ("steve_jobs", "barack_obama", "elizabeth_taylor"):
workflow = _run("annual", index=_chart_index(chart_id))
slow = _layers(workflow)["slow_transits"]
moon_idx = native.SIGNS.index(workflow["chart"]["planets"]["Moon"]["sign"])
saturn_idx = native.SIGNS.index(slow["current"]["Saturn"]["sign"])
assert slow["saturn_from_moon_house"] == ((saturn_idx - moon_idx) % 12) + 1
jobs = _layers(_run("annual", index=_chart_index("steve_jobs")))["slow_transits"]
assert jobs["current"]["Saturn"]["sign"] == "Pisces"
assert jobs["saturn_from_moon_house"] == 1
def test_yoga_domains_tag_each_sieved_yoga_by_its_packet() -> None:
"""BUG-1157: the tag is the Raman packet of the rule id (fixed table); `hit` is the sieve's."""
from raman_support_observations import packet_members
workflow = _run("annual", index=_chart_index("barack_obama"))
rows = _layers(workflow)["yoga_domains"]["rows"]
sieved = workflow["chart"]["modules"]["yogas"]["yogas"]
assert [row["name"] for row in rows] == [row["name"] for row in sieved]
assert [row["hit"] for row in rows] == [row["hit"] for row in sieved]
expected = {
member["rule_id"]: domain
for filename, domain in native.YOGA_PACKET_DOMAINS.items()
for member in packet_members(filename)
}
for row, source in zip(rows, sieved):
assert row["domain"] == expected[source["rule_id"]]
by_name = {row["name"]: row["domain"] for row in rows}
assert by_name["Matrunasa Yoga"] == "parents"
assert by_name["Bahu Puthra Yoga"] == "children"
assert by_name["Dharidhra Yoga"] == "wealth"
assert by_name["Sunaphaa Yoga"] == "general"
assert set(native.YOGA_PACKET_DOMAINS.values()) == {"general", "wealth", "marriage", "children", "parents", "siblings"}