Files
Jyotisha/tests/test_consultation_native_layers.py
T
Jesse_ChenandClaude Opus 5.5 d5616c347e feat(engine): expose native technique layers in the consultation output
TASK-consult-evidence-card-v2-20260927 T1. New module
scripts/consultation_native_layers.py, thinly registered after the merged
engine fields in _attach_local_consultation_layers (no handler method, no new
forgery site). It writes one new top-level chart key,
chart.consultation_native_layers, kept out of chart.modules so the thematic
report's full_reading_module_count does not move:

- d9_summary: D9 lagna, each planet's D9 sign and dignity
  (jyotish_engine._get_dignity_level), Vargottama (_calc_vargottama),
  D1<->D9 reversals
- punarphoo (punarphoo.detect_punarphoo, observation_only)
- vivah_saham (jyotish_engine._calc_vivah_saham), day_night (gulika)
- slow_transits: Jupiter / Saturn / Rahu / Ketu now with natal house and the
  Jupiter / Saturn SAV / BAV, and the next twelve months' ingress / station
  dates (ephemeris_events; nodes by the same daily-noon sampling)
- double_transit: cmd_double_transit_pac for houses 1-12, conclusions only,
  plus DK / UL targets by the same PAC rule
- karakamsha, argala, dispositor_chains, inter_chart_linkage, moon_transit
  (lookup-only layers)
- annual_tajika on the annual route: build_annual_tajika_pack (annual lagna,
  Varshesha, Muntha, Mudda Dasha with dates, Sun / Moon / year-lord Tajika
  aspects), parameter_sensitive; blocked when the pack is not reliable,
  never natal data

A/B on 3 public AA charts x 4 routes: every existing output is identical,
only the new key is added; about 40 ms per workflow. Golden regenerated with
annual and timing routes (family workflow byte-identical apart from the new
key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 16:27:24 +08:00

241 lines
11 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"},
]