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
This commit is contained in:
Jesse_Chen
2026-10-01 23:56:18 +08:00
co-authored by Claude Opus 5.5
parent 9b937c4adf
commit 19802a4874
9 changed files with 643 additions and 21 deletions
+131
View File
@@ -20,6 +20,12 @@ existing reader consumes. It is kept out of `chart.modules` on purpose: the
thematic report counts the modules it was handed (`full_reading_module_count`)
and that existing value must not move.
TASK-consult-card-affliction-data-20261001 adds three outputs: the whole-sign
graha drishti and combustion (`graha_drishti`, from `aspects.calc_house_aspects`
and the full reading's `jyotish_engine.is_combust`), the domain tag of each
sieved yoga (`yoga_domains`, a fixed packet table) and transit Saturn's house
from the natal Moon (`slow_transits.saturn_from_moon_house`).
The annual pack is attached only when the declared route is `annual`. When the
pack cannot be produced reliably it is reported as blocked with the reason;
natal data is never substituted for the annual chart.
@@ -45,6 +51,26 @@ REVERSAL_RULE = (
"D1 in EXALTED / MOOLATRIKONA / OWN_SIGN and D9 in DEBILITATED / NEECHA_BHANGA, or the reverse; "
"levels from jyotish_engine._get_dignity_level"
)
# Graha drishti casters (TASK-consult-card-affliction-data-20261001 D2): the
# seven grahas; Rahu / Ketu receive aspects but cast none here (schools
# differ), their influence reaches the card through house co-tenancy.
DRISHTI_CASTERS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
DRISHTI_RULE = (
"Parashari whole-sign graha drishti (aspects.calc_house_aspects): every graha the 7th; "
"Mars also 4 / 8, Jupiter 5 / 9, Saturn 3 / 10; Rahu / Ketu cast none"
)
# Domain tag of each Raman support packet (scripts/raman_support_observations.py).
# A fixed table: a yoga's domain is the packet that governs it, never a guess
# from its name (BUG-1157). The Nabhasa / Chandra batch describes the whole
# chart, so it is `general`.
YOGA_PACKET_DOMAINS = {
"raman_bvr_nabhasa_chandra_sunapha_batch_closure_packet_2026_08_20.json": "general",
"raman_bvr_wealth_core_batch_closure_packet_2026_08_20.json": "wealth",
"raman_bvr_relationship_spouse_batch_closure_packet_2026_08_20.json": "marriage",
"raman_bvr_relationship_children_batch_closure_packet_2026_08_20.json": "children",
"raman_bvr_relationship_mother_batch_closure_packet_2026_08_20.json": "parents",
"raman_bvr_relationship_sibling_core_batch_closure_packet_2026_08_20.json": "siblings",
}
TAJIKA_BOUNDARY = (
"parameter_sensitive: Tajika / Varshaphala is verified on the first target set only, "
"not a global closure; annual structure, not an event verdict"
@@ -302,12 +328,18 @@ def build_slow_transits(engine, chart: dict, modules: dict, *, reference: dateti
if isinstance(house, int) and house not in houses:
houses.append(house)
houses_in_window[body] = houses
natal_moon_sign = _record(_record(chart.get("planets")).get("Moon")).get("sign")
return {
"status": "executed",
"source": "jyotish_engine._calc_sidereal_planets_for_jd + ephemeris_events.build_ephemeris_events",
"reference_date": reference.strftime("%Y-%m-%d"),
"window": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")},
"current": current,
# BUG-1156: the house of transit Saturn's sign counted from the natal
# Moon's sign (1 = Saturn over the natal Moon), whole-sign.
"saturn_from_moon_house": _whole_sign_house(
_record(current.get("Saturn")).get("sign"), _sign_index(natal_moon_sign),
),
"key_dates": events,
"houses_in_window": houses_in_window,
"boundary": scan.get("boundary") or "sign ingress and station scan, not a natal trigger",
@@ -567,6 +599,103 @@ def build_inter_chart_linkage(engine, chart: dict, modules: dict) -> dict:
}
# ── Graha drishti and combustion ────────────────────────────────────────────
def build_graha_drishti(engine, chart: dict) -> dict:
"""Who aspects whom (whole-sign) and which planets are combust.
Aspected houses: `aspects.calc_house_aspects` for the seven grahas (the
full reading's own Step 5 producer). `aspected_by` of a house is every
caster whose aspected houses include it; `aspected_by` of a planet is that
of the house it occupies. Combustion: `jyotish_engine.is_combust`, the
full reading's rule and orbs; written only for the seven grahas.
"""
try:
from aspects import calc_house_aspects
except ModuleNotFoundError: # pragma: no cover
from scripts.aspects import calc_house_aspects
planets = _record(chart.get("planets"))
asc_lon = _planet_lon(chart.get("ascendant"))
lagna_idx = _lagna_idx(chart)
if asc_lon is None or lagna_idx is None or not planets:
return _blocked("natal_longitudes_missing")
rows: dict[str, dict] = {}
houses: dict[str, dict] = {str(number): {"aspected_by": []} for number in range(1, 13)}
for name in DRISHTI_CASTERS:
lon = _planet_lon(planets.get(name))
if lon is None:
continue
result = calc_house_aspects(lon, name, asc_lon)
targets = sorted(int(house) for house in _record(result.get("aspects_to")))
rows[name] = {"house": result.get("house"), "aspects_houses": targets}
for house in targets:
houses[str(house)]["aspected_by"].append(name)
for name, pdata in planets.items():
if not isinstance(pdata, dict):
continue
house = _record(rows.get(name)).get("house") or _whole_sign_house(pdata.get("sign"), lagna_idx)
if not isinstance(house, int):
continue
row = rows.setdefault(name, {"house": house})
row["aspected_by"] = list(houses[str(house)]["aspected_by"])
sun_lon = _planet_lon(planets.get("Sun"))
if sun_lon is not None:
for name in DRISHTI_CASTERS:
lon = _planet_lon(planets.get(name))
if lon is not None and name in rows:
rows[name]["combust"] = bool(engine.is_combust(name, lon, sun_lon))
for row in rows.values():
row.pop("house", None)
return {
"status": "executed",
"source": "aspects.calc_house_aspects + jyotish_engine.is_combust",
"rule": DRISHTI_RULE,
"planets": rows,
"houses": houses,
}
def _yoga_packet_tags() -> dict[str, str]:
try:
from raman_support_observations import packet_members
except ModuleNotFoundError: # pragma: no cover
from scripts.raman_support_observations import packet_members
tags: dict[str, str] = {}
for filename, domain in YOGA_PACKET_DOMAINS.items():
for member in packet_members(filename):
if member.get("rule_id"):
tags.setdefault(member["rule_id"], domain)
return tags
def build_yoga_domains(modules: dict) -> dict:
"""The domain tag of each yoga in `modules.yogas` (BUG-1157).
The tag is the Raman packet that governs the yoga's rule id
(`YOGA_PACKET_DOMAINS`); `hit` is the sieve's own value. A yoga whose rule
id is in no packet carries no tag.
"""
entries = _record(modules.get("yogas")).get("yogas")
if not isinstance(entries, list):
return _blocked("yogas_missing")
tags = _yoga_packet_tags()
rows: list[dict] = []
for entry in entries:
row = _record(entry)
if not isinstance(row.get("name"), str):
continue
out: dict[str, Any] = {"name": row["name"], "hit": bool(row.get("hit"))}
tag = tags.get(str(row.get("rule_id") or ""))
if tag:
out["domain"] = tag
rows.append(out)
return {
"status": "executed",
"source": "modules.yogas (Raman sieve) + YOGA_PACKET_DOMAINS",
"rows": rows,
}
# ── Annual Tajika / Varshaphala ─────────────────────────────────────────────
def _annual_payload(birth_payload: dict, target_year: int) -> dict:
@@ -758,6 +887,8 @@ def attach_native_consultation_layers(chart: dict, *, birth_payload: dict, body:
layers["argala"] = _guard(build_argala, chart)
layers["dispositor_chains"] = _guard(build_dispositor_chains, engine, chart)
layers["inter_chart_linkage"] = _guard(build_inter_chart_linkage, engine, chart, modules)
layers["graha_drishti"] = _guard(build_graha_drishti, engine, chart)
layers["yoga_domains"] = _guard(build_yoga_domains, modules)
if "annual" in routes:
layers["annual_tajika"] = _guard(build_annual_tajika, birth_payload, chart, reference=reference)
else:
+14 -3
View File
@@ -10703,6 +10703,19 @@ def _oracle_progress_snapshot():
}
# Combustion orbs of the full reading (Step 5 shadbala block). The Moon, Rahu
# and Ketu have no orb here; the Sun is never combust. Shared with the web
# consultation layer (consultation_native_layers.build_graha_drishti) so both
# read the same rule (BUG-1154).
COMBUSTION_ORBS = {'Mars': 17, 'Mercury': 12, 'Jupiter': 11, 'Venus': 10, 'Saturn': 15}
def is_combust(planet_name, longitude, sun_longitude):
"""True when `planet_name` is within its combustion orb of the Sun."""
separation = abs(((float(longitude) - float(sun_longitude) + 180) % 360) - 180)
return planet_name != 'Sun' and separation <= COMBUSTION_ORBS.get(planet_name, 0)
def _functional_benefic_malefic_snapshot(planets, ascendant):
try:
from functional_benefics import derive_functional_benefic_malefic
@@ -16327,7 +16340,6 @@ def cmd_full_reading(args):
shadbala_result['weak_planets'] = len(sb_planets) - strong_count
shadbala_result['status'] = '优秀' if strong_count >= 5 else '良好' if strong_count >= 3 else '一般'
sun_longitude = float(planet_lons.get('Sun', 0.0))
combustion_orbs = {'Mars': 17, 'Mercury': 12, 'Jupiter': 11, 'Venus': 10, 'Saturn': 15}
for planet_name, planet in planets.items():
if not isinstance(planet, dict) or planet_name in {'Rahu', 'Ketu'}:
continue
@@ -16335,8 +16347,7 @@ def cmd_full_reading(args):
if longitude is None:
continue
planet.update(get_kp_lords(float(longitude)))
separation = abs(((float(longitude) - sun_longitude + 180) % 360) - 180)
planet['combust'] = planet_name != 'Sun' and separation <= combustion_orbs.get(planet_name, 0)
planet['combust'] = is_combust(planet_name, float(longitude), sun_longitude)
planet['r_c'] = 'R' if planet.get('retrograde') else 'C' if planet['combust'] else ''
strength = sb_planets.get(planet_name) if isinstance(sb_planets.get(planet_name), dict) else {}
if strength: