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
341 lines
16 KiB
Python
341 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_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"}
|