feat(consult): rule engine always runs and merges with chart yogas (BUG-1174); closed-list traditional_yogas on general/career/wealth cards (BUG-1175)

Goldens regenerated by the capture scripts (PYTHONHASHSEED=0, cache TTL 0; two runs byte-identical).

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-02 08:32:11 +08:00
co-authored by Claude Opus 5.5
parent 8e978cabc4
commit c74bfcc06b
7 changed files with 236 additions and 23 deletions
+21 -3
View File
@@ -53,6 +53,12 @@ export const EVIDENCE_CARD_LABELS = {
functionalBenefic: "benefic",
functionalMalefic: "malefic",
functionalNeutral: "neutral",
/**
* Closed-list classical yogas the engine found formed (TASK-consult-career-
* yoga-functional-20261002 D3, BUG-1175): strength only, never a stand-alone
* conclusion, never counted as affliction or as support against it.
*/
traditionalYogas: "传统格局,本站未验证,只作参考",
} as const;
/** KP stays off every card: exact cusps are still blocked (decision table). */
@@ -77,6 +83,8 @@ export type EvidenceCardDomainSpec = Readonly<{
* scripts/consultation_native_layers.py YOGA_PACKET_DOMAINS).
*/
yogaTags: readonly string[];
/** `traditional_yogas` (engine layer of the same name), only when one is formed (BUG-1175). */
traditionalYogas: boolean;
transits: boolean;
ashtakavarga: boolean;
shadbala: boolean;
@@ -129,6 +137,7 @@ const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSp
planets: [],
yogas: false,
yogaTags: [],
traditionalYogas: false,
transits: false,
ashtakavarga: false,
shadbala: false,
@@ -168,7 +177,7 @@ const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSp
*/
export const EVIDENCE_CARD_SPECS: Readonly<Record<ConsultationDomain, EvidenceCardDomainSpec>> = {
career: spec({
vargas: ["D10"], houses: [10], arudha: ["A10", "AL"], karakas: ["AmK"], yogas: true, yogaTags: ["career", "general"],
vargas: ["D10"], houses: [10], arudha: ["A10", "AL"], karakas: ["AmK"], yogas: true, yogaTags: ["career", "general"], traditionalYogas: true,
transits: true, shadbala: true,
savHouses: [10], slowTransits: "sav",
}),
@@ -177,7 +186,7 @@ export const EVIDENCE_CARD_SPECS: Readonly<Record<ConsultationDomain, EvidenceCa
punarphoo: true, dayNight: true, vivahSaham: true, doubleTransit: "seventh_and_karakas", gender: true,
}),
wealth: spec({
vargas: ["D2", "D11"], houses: [2, 11, 9, 5, 8, 12], yogas: true, yogaTags: ["wealth", "general"], ashtakavarga: true, shadbala: true,
vargas: ["D2", "D11"], houses: [2, 11, 9, 5, 8, 12], yogas: true, yogaTags: ["wealth", "general"], traditionalYogas: true, ashtakavarga: true, shadbala: true,
}),
health: spec({ vargas: ["D6", "D8", "D30"], houses: [1, 6, 8, 12], transits: true, shadbala: true }),
education: spec({ vargas: ["D5", "D24"], houses: [4, 5, 9], planets: ["Mercury", "Jupiter"] }),
@@ -197,7 +206,7 @@ export const EVIDENCE_CARD_SPECS: Readonly<Record<ConsultationDomain, EvidenceCa
slowTransits: "nodes_sav_bav", doubleTransit: "all_houses",
}),
general: spec({
vargas: ["D9", "D10", "D2"], houses: [1, 10, 7, 2], arudha: ["A10"], yogas: true, yogaTags: ["general"], transits: true,
vargas: ["D9", "D10", "D2"], houses: [1, 10, 7, 2], arudha: ["A10"], yogas: true, yogaTags: ["general"], traditionalYogas: true, transits: true,
ashtakavarga: true, shadbala: true,
}),
};
@@ -273,6 +282,8 @@ export type EvidenceCardDomainSection = {
arudha?: JsonRecord;
karakas?: JsonRecord;
yogas?: JsonRecord;
/** BUG-1175: `{ label, names }`, absent when no closed-list yoga is formed. */
traditional_yogas?: JsonRecord;
transits?: Json;
ashtakavarga?: JsonRecord;
shadbala?: Json;
@@ -752,6 +763,13 @@ function domainSection(
gaps.push(`${domain}.yogas`);
}
}
if (rule.traditionalYogas) {
const traditional = record(natal.traditional_yogas);
const names = Array.isArray(traditional.names)
? traditional.names.filter((name): name is string => typeof name === "string")
: [];
if (names.length) section.traditional_yogas = { label: EVIDENCE_CARD_LABELS.traditionalYogas, names };
}
if (rule.transits) {
const transits = copy(timing.transits);
if (transits !== undefined) section.transits = transits;
@@ -533,6 +533,8 @@ const nativeGrahaDrishtiKeys = new Set([
]);
// BUG-1157: each yoga's domain tag (the Raman packet that governs it) and the sieve's hit.
const nativeYogaDomainKeys = new Set(["status", "reason", "source", "rows", "name", "hit", "domain"]);
// BUG-1175: closed-list classical yogas found formed; the card writes the label itself.
const nativeTraditionalYogaKeys = new Set(["status", "reason", "source", "names"]);
const nativeSlowTransitKeys = new Set([
"status", "source", "referencedate", "window", "start", "end", "current", "sign", "degreeinsign",
"retrograde", "natalhouse", "sav", "bav", "keydates", "kind", "date", "body", "fromsign", "tosign",
@@ -593,6 +595,7 @@ function projectNatalFoundation(context: ReturnType<typeof toAgentConsultationCo
["day_night", nativeDayNightKeys],
["graha_drishti", nativeGrahaDrishtiKeys],
["yoga_domains", nativeYogaDomainKeys],
["traditional_yogas", nativeTraditionalYogaKeys],
]));
return Object.keys(output).length ? output : undefined;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+116
View File
@@ -696,6 +696,121 @@ def build_yoga_domains(modules: dict) -> dict:
}
def _yoga_key(entry: dict) -> str:
name = "".join(ch for ch in str(entry.get("name") or "").lower() if ch.isalnum())
return name.replace("yoga", "") or name
def merge_detected_yogas(*groups: list) -> list[dict]:
"""Merge yoga lists in order, dropping repeats by normalised name (BUG-1174)."""
merged: list[dict] = []
seen: set[str] = set()
for group in groups:
for entry in group or []:
if not isinstance(entry, dict) or not entry.get("name"):
continue
key = _yoga_key(entry)
if key in seen:
continue
seen.add(key)
merged.append(entry)
return merged
def collect_consultation_yogas(handler, chart: dict, birth_payload: dict, planets: dict,
ascendant: Any, compact_entry) -> list[dict]:
"""Every yoga the consultation sieve sees: the chart's own list plus the rule engine.
BUG-1174 (TASK-consult-career-yoga-functional-20261002 T2): the rule engine
used to run only when `chart.yogas` was empty, so a chart that already
carried one or two yogas never had the Raman packet members checked. It now
always runs; the two lists are merged and de-duplicated by name. No
detection rule or sieve boundary changes.
"""
raw = chart.get("yogas") if isinstance(chart.get("yogas"), list) else []
own = [entry for entry in (compact_entry(item) for item in raw) if entry]
try:
api = handler._compute_yogas_api({**birth_payload, "planets": planets, "ascendant": ascendant})
except Exception:
if not own:
raise
return merge_detected_yogas(own)
result = api.get("result") if isinstance(api, dict) else {}
result = result if isinstance(result, dict) else {}
engine = [
entry for entry in (
compact_entry(item)
for item in (result.get("extended_yogas") or []) + (result.get("rule_engine_yogas") or [])
)
if entry
]
return merge_detected_yogas(own, engine)
# Closed list for `traditional_yogas` (TASK-consult-career-yoga-functional-20261002
# D3, BUG-1175): canonical name -> name-token prefixes the detectors emit
# (yoga_expansion / yoga_engine / chart.yogas / Raman packet hits). Nothing
# outside this list is shown, and no detector is added here.
TRADITIONAL_YOGA_LABEL = "传统格局,本站未验证,只作参考"
TRADITIONAL_YOGAS = (
("Ruchaka Yoga", ("ruchaka",)),
("Bhadra Yoga", ("bhadra",)),
("Hamsa Yoga", ("hamsa",)),
("Malavya Yoga", ("malavya",)),
("Sasa Yoga", ("sasa", "shasha")),
("Gajakesari Yoga", ("gajakesari",)),
("Neecha Bhanga Raja Yoga", ("neechabhanga", "neechbhanga")),
# Budha-Aditya: the engine also emits the same Sun-Mercury conjunction as
# "Surya-Budha Yoga"; both map to one row.
("Budha-Aditya Yoga", ("budhaaditya", "budhaditya", "suryabudha")),
)
def _traditional_name(name: Any) -> str | None:
token = "".join(ch for ch in str(name or "").lower() if ch.isalnum())
for canonical, prefixes in TRADITIONAL_YOGAS:
if any(token.startswith(prefix) for prefix in prefixes):
return canonical
return None
def build_traditional_yogas(modules: dict) -> dict:
"""Closed-list classical yogas the detectors found formed (BUG-1175).
Reads what the consultation sieve was handed (`raman_support.yogas`: packet
hits plus `ungoverned_detected`). Only formed yogas are listed; the label is
fixed. Reading rule (card): strength only, no stand-alone conclusion, never
counted as affliction or support against affliction. Raman packets,
`modules.yogas`, confidence caps and claim boundaries are unchanged.
"""
sieve = _record(_record(modules.get("raman_support")).get("yogas"))
if sieve.get("status") != "executed":
return _blocked("raman_support_yogas_missing")
found: dict[str, list[str]] = {}
for entry in list(sieve.get("hits") or []) + list(sieve.get("ungoverned_detected") or []):
row = _record(entry)
if row.get("hit") is False:
continue
canonical = _traditional_name(row.get("name"))
if not canonical:
continue
planets = found.setdefault(canonical, [])
for planet in row.get("planets") or []:
if isinstance(planet, str) and planet not in planets:
planets.append(planet)
names = [
f"{canonical}({'、'.join(found[canonical])})" if found[canonical] else canonical
for canonical, _prefixes in TRADITIONAL_YOGAS
if canonical in found
]
return {
"status": "executed",
"source": "raman_support.yogas (hits + ungoverned_detected), closed list TRADITIONAL_YOGAS",
"label": TRADITIONAL_YOGA_LABEL,
"names": names,
}
# ── Annual Tajika / Varshaphala ─────────────────────────────────────────────
def _annual_payload(birth_payload: dict, target_year: int) -> dict:
@@ -889,6 +1004,7 @@ def attach_native_consultation_layers(chart: dict, *, birth_payload: dict, body:
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)
layers["traditional_yogas"] = _guard(build_traditional_yogas, modules)
if "annual" in routes:
layers["annual_tajika"] = _guard(build_annual_tajika, birth_payload, chart, reference=reference)
else:
+2 -18
View File
@@ -1705,24 +1705,8 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict,
if not _consultation_layer_present(modules.get('yogas')):
try:
raw_yogas = chart.get('yogas') if isinstance(chart.get('yogas'), list) else []
compact = [entry for entry in (_compact_yoga_entry(item) for item in raw_yogas) if entry]
if not compact:
api = handler._compute_yogas_api({
**birth_payload,
'planets': planets,
'ascendant': ascendant,
})
result = api.get('result') if isinstance(api, dict) else {}
extended = result.get('extended_yogas') if isinstance(result, dict) else []
engine = result.get('rule_engine_yogas') if isinstance(result, dict) else []
compact = [
entry for entry in (
_compact_yoga_entry(item)
for item in (extended or []) + (engine or [])
)
if entry
]
from scripts.consultation_native_layers import collect_consultation_yogas # BUG-1174
compact = collect_consultation_yogas(handler, chart, birth_payload, planets, ascendant, _compact_yoga_entry)
routes = _consultation_declared_routes(body)
raman = build_raman_support_observations(chart, compact, routes)
sieved = raman['yogas']['yogas']
@@ -0,0 +1,92 @@
"""T2 / T3 of TASK-consult-career-yoga-functional-20261002 (BUG-1174, BUG-1175)."""
from __future__ import annotations
from pathlib import Path
from scripts.consultation_native_layers import (
TRADITIONAL_YOGA_LABEL,
build_traditional_yogas,
collect_consultation_yogas,
merge_detected_yogas,
)
ROOT = Path(__file__).resolve().parents[1]
def _compact(item):
if isinstance(item, dict) and item.get("name"):
return {"name": item["name"]}
if isinstance(item, str) and item:
return {"name": item}
return None
class _Handler:
def __init__(self):
self.calls = 0
def _compute_yogas_api(self, body):
self.calls += 1
return {"result": {
"extended_yogas": [{"name": "Sasa Yoga"}, {"name": "Amala Yoga"}],
"rule_engine_yogas": [{"name": "Gajakesari Yoga"}],
}}
def test_rule_engine_runs_even_when_chart_already_has_yogas() -> None:
handler = _Handler()
chart = {"yogas": [{"name": "Amala Yoga"}, {"name": "Amala Yoga"}]}
merged = collect_consultation_yogas(handler, chart, {}, {"Sun": {}}, {"sign": "Aries"}, _compact)
assert handler.calls == 1
assert [row["name"] for row in merged] == ["Amala Yoga", "Sasa Yoga", "Gajakesari Yoga"]
def test_merge_dedupes_by_normalised_name() -> None:
merged = merge_detected_yogas([{"name": "Amala Yoga"}], [{"name": "amala yoga"}, {"name": "Amala"}, {"name": "Vosi Yoga"}])
assert [row["name"] for row in merged] == ["Amala Yoga", "Vosi Yoga"]
def test_engine_failure_keeps_the_chart_list() -> None:
class Broken:
def _compute_yogas_api(self, body):
raise RuntimeError("boom")
merged = collect_consultation_yogas(Broken(), {"yogas": ["Adhi Yoga"]}, {}, {}, None, _compact)
assert [row["name"] for row in merged] == ["Adhi Yoga"]
def test_server_no_longer_skips_the_rule_engine() -> None:
source = (ROOT / "scripts" / "jyotish_api_server.py").read_text(encoding="utf-8")
block = source[source.index("if not _consultation_layer_present(modules.get('yogas')):"):]
block = block[: block.index("routes = _consultation_declared_routes(body)")]
assert "collect_consultation_yogas" in block
assert "if not compact:" not in block
def _modules(hits, ungoverned):
return {"raman_support": {"yogas": {"status": "executed", "hits": hits, "ungoverned_detected": ungoverned}}}
def test_traditional_yogas_closed_list_only_and_fixed_label() -> None:
layer = build_traditional_yogas(_modules(
[{"name": "Gaja Kesari", "hit": True}],
[
{"name": "Sasa Yoga"},
{"name": "Neechabhanga Raja Yoga"},
{"name": "Neechabhanga Raja Yoga (Kendra)"},
{"name": "Budhaditya Yoga"},
{"name": "Surya-Budha Yoga"},
{"name": "Kemadruma Yoga"},
{"name": "Raja Yoga (Mutual Aspect)"},
],
))
assert layer["status"] == "executed"
assert layer["label"] == TRADITIONAL_YOGA_LABEL == "传统格局,本站未验证,只作参考"
assert layer["names"] == ["Sasa Yoga", "Gajakesari Yoga", "Neecha Bhanga Raja Yoga", "Budha-Aditya Yoga"]
def test_traditional_yogas_skip_misses_and_empty() -> None:
layer = build_traditional_yogas(_modules([{"name": "Gajakesari Yoga", "hit": False}], []))
assert layer["names"] == []
assert build_traditional_yogas({})["status"] == "blocked"