Files
Jyotisha/tests/test_consultation_native_layers.py
T
Jesse_ChenandClaude Opus 5.5 cf78d4ca21 fix: align combustion thresholds across engine (upstream 080c286c, adapted)
One direct / retrograde orb table (Moon included) in the shared
COMBUSTION_ORBS / is_combust, used by the full reading and the consultation
graha-drishti layer. TASK-upstream-sync5-20261002 U3.

(cherry picked from commit 080c286c, resolved onto our shared helper)

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

341 lines
16 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.
"""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_follows_the_shared_orb_table() -> None:
"""Shared orbs for the full reading and the consultation layer.
原值: COMBUSTION_ORBS 只有顺行一档且不含月亮(水 12°、金 10°),月亮按 0° 只算精确合
新值: 顺行 / 逆行两档并含月亮(月 12/12、火 17/10、水 14/12、木 11/10、金 10/8、土 15/10)
原因: TASK-upstream-sync5-20261002 U3,上游 yinduzhanxing 080c286c 全引擎统一燃烧阈值
"""
engine = native._load_engine()
assert engine.COMBUSTION_ORBS["Mercury"] == {"direct": 14, "retrograde": 12}
assert engine.COMBUSTION_ORBS["Moon"] == {"direct": 12, "retrograde": 12}
assert engine.is_combust("Mercury", 114.0, 100.0) is True
assert engine.is_combust("Mercury", 114.01, 100.0) is False
assert engine.is_combust("Mercury", 113.0, 100.0, retrograde=True) is False
assert engine.is_combust("Venus", 355.0, 5.0) is True # across 0°
assert engine.is_combust("Venus", 351.0, 0.0, retrograde=True) is False
assert engine.is_combust("Sun", 100.0, 100.0) is False
assert engine.is_combust("Moon", 111.0, 100.0) is True
assert engine.is_combust("Rahu", 100.0, 100.0) is False
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"}