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:
co-authored by
Claude Opus 5.5
parent
8e978cabc4
commit
c74bfcc06b
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user