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
+144 -15
View File
@@ -46,6 +46,13 @@ export const EVIDENCE_CARD_LABELS = {
/** Who a parents-section entry is about (TASK-consult-plain-answer-20261001 D6). */
roleMother: "母亲",
roleFather: "父亲",
/**
* A role planet's functional class (TASK-consult-card-affliction-data-20261001
* D4, BUG-1156): which of the engine's functional lists names the planet.
*/
functionalBenefic: "benefic",
functionalMalefic: "malefic",
functionalNeutral: "neutral",
} as const;
/** KP stays off every card: exact cusps are still blocked (decision table). */
@@ -64,6 +71,12 @@ export type EvidenceCardDomainSpec = Readonly<{
karakas: readonly string[];
planets: readonly string[];
yogas: boolean;
/**
* Domain tags of the yogas this section takes (BUG-1157): the tag is the
* Raman packet that governs the yoga (engine layer `yoga_domains`,
* scripts/consultation_native_layers.py YOGA_PACKET_DOMAINS).
*/
yogaTags: readonly string[];
transits: boolean;
ashtakavarga: boolean;
shadbala: boolean;
@@ -115,6 +128,7 @@ const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSp
karakas: [],
planets: [],
yogas: false,
yogaTags: [],
transits: false,
ashtakavarga: false,
shadbala: false,
@@ -148,35 +162,44 @@ const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSp
* BAV; vargas follow the turn's other domain (D9 when alone);
* - Chara Dasha leaves every card (lookup); KP is never on a card.
* Health carries D30 and education D5 as in the Python focus techniques.
* TASK-consult-card-affliction-data-20261001: health adds the 1st and 12th
* houses, education the 4th (BUG-1156); yogas are taken by domain tag
* (BUG-1157), and parents / children now carry their own.
*/
export const EVIDENCE_CARD_SPECS: Readonly<Record<ConsultationDomain, EvidenceCardDomainSpec>> = {
career: spec({
vargas: ["D10"], houses: [10], arudha: ["A10", "AL"], karakas: ["AmK"], yogas: true, transits: true, shadbala: true,
vargas: ["D10"], houses: [10], arudha: ["A10", "AL"], karakas: ["AmK"], yogas: true, yogaTags: ["career", "general"],
transits: true, shadbala: true,
savHouses: [10], slowTransits: "sav",
}),
marriage: spec({
vargas: ["D9"], houses: [7, 5], arudha: ["UL", "A7"], karakas: ["DK"], planets: ["Venus", "Jupiter"], transits: true,
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, ashtakavarga: true, shadbala: true }),
health: spec({ vargas: ["D6", "D8", "D30"], houses: [6, 8], transits: true, shadbala: true }),
education: spec({ vargas: ["D5", "D24"], houses: [5, 9], planets: ["Mercury", "Jupiter"] }),
wealth: spec({
vargas: ["D2", "D11"], houses: [2, 11, 9, 5, 8, 12], yogas: true, yogaTags: ["wealth", "general"], 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"] }),
migration: spec({ vargas: ["D4", "D12"], houses: [4, 12] }),
family: spec({ vargas: ["D7", "D12"], houses: [4, 5, 9] }),
parents: spec({
vargas: ["D12"], houses: [4, 9], planets: ["Sun", "Moon"], karakas: ["MK"],
vargas: ["D12"], houses: [4, 9], planets: ["Sun", "Moon"], karakas: ["MK"], yogas: true, yogaTags: ["parents"],
roles: [
{ key: "mother", label: EVIDENCE_CARD_LABELS.roleMother, house: 4, planets: ["Moon"], karakas: ["MK"], optionalKarakas: [], varga: "D12" },
{ key: "father", label: EVIDENCE_CARD_LABELS.roleFather, house: 9, planets: ["Sun"], karakas: [], optionalKarakas: ["PiK"], varga: "D12" },
],
}),
children: spec({ vargas: ["D7"], houses: [5], karakas: ["PK"], planets: ["Jupiter"] }),
children: spec({ vargas: ["D7"], houses: [5], karakas: ["PK"], planets: ["Jupiter"], yogas: true, yogaTags: ["children"] }),
annual: spec({ vargas: ["D1"], houses: [1], annualChart: true, annualHouses: true, slowTransits: "key_dates" }),
timing: spec({
vargas: ["D1", "D9"], planets: ["Saturn", "Jupiter"], transits: true, followVargas: true,
slowTransits: "nodes_sav_bav", doubleTransit: "all_houses",
}),
general: spec({ vargas: ["D9", "D10", "D2"], houses: [1, 10, 7, 2], arudha: ["A10"], yogas: true, transits: true, ashtakavarga: true, shadbala: true }),
general: spec({
vargas: ["D9", "D10", "D2"], houses: [1, 10, 7, 2], arudha: ["A10"], yogas: true, yogaTags: ["general"], transits: true,
ashtakavarga: true, shadbala: true,
}),
};
/** The formal vargas the engine materialises (varga_spectrum.formal). */
@@ -379,16 +402,52 @@ const ZODIAC_SIGNS = [
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
] as const;
/** The sign and occupants of house `number` counted from a varga's own lagna. */
/**
* Sign rulership, the table the engine's functional layer uses
* (scripts/functional_benefics.py SIGN_LORDS). Used only to name a varga
* house's lord; the lord's varga sign is then read from the varga row.
*/
const SIGN_LORDS: Readonly<Record<(typeof ZODIAC_SIGNS)[number], string>> = {
Aries: "Mars", Taurus: "Venus", Gemini: "Mercury", Cancer: "Moon", Leo: "Sun", Virgo: "Mercury",
Libra: "Venus", Scorpio: "Mars", Sagittarius: "Jupiter", Capricorn: "Saturn", Aquarius: "Saturn", Pisces: "Jupiter",
};
/**
* The sign and occupants of house `number` counted from a varga's own lagna,
* plus that sign's lord and the lord's sign in the same varga (BUG-1156).
*/
function vargaHouse(row: unknown, number: number): JsonRecord | undefined {
const lagna = record(row).lagna;
const start = typeof lagna === "string" ? ZODIAC_SIGNS.indexOf(lagna as (typeof ZODIAC_SIGNS)[number]) : -1;
if (start < 0) return undefined;
const sign = ZODIAC_SIGNS[(start + number - 1) % 12]!;
const occupants = Object.entries(record(record(row).planets))
const vargaPlanets = record(record(row).planets);
const occupants = Object.entries(vargaPlanets)
.filter(([, value]) => value === sign)
.map(([name]) => name);
return { house: number, sign, occupants };
const lord = SIGN_LORDS[sign];
const lordSign = vargaPlanets[lord];
return { house: number, sign, occupants, lord, ...(typeof lordSign === "string" ? { lord_sign: lordSign } : {}) };
}
const ROLE_PLANET_KEYS = ["house", "sign", "status", "conjunct_with", "aspected_by", "combust"] as const;
/**
* A planet's full row for a role entry (BUG-1156): the base row's placement,
* co-tenants, aspects and combustion, and its functional class selected from
* the functional layer's own lists.
*/
function rolePlanetRow(base: EvidenceCard["base"], name: string): JsonRecord | undefined {
const hit = caseInsensitive(base.natal.planets, name);
if (!hit) return undefined;
const row = pickKeys(hit[1], ROLE_PLANET_KEYS);
const functional = base.natal.functional_benefic_malefic;
const listed = (key: string) => Array.isArray(functional[key]) && (functional[key] as Json[]).includes(hit[0]);
const klass = listed("functional_benefics") ? EVIDENCE_CARD_LABELS.functionalBenefic
: listed("functional_malefics") ? EVIDENCE_CARD_LABELS.functionalMalefic
: listed("functional_neutrals") ? EVIDENCE_CARD_LABELS.functionalNeutral
: undefined;
return klass ? { ...row, functional: klass } : row;
}
const ARUDHA_KEYS = ["sign", "lord"] as const;
/** The engine writes the Arudha Lagna as pada A1 (its own name: "Arudha Lagna (AL)"). */
@@ -420,6 +479,29 @@ function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
if (nonEmpty(picked)) planets[name] = picked;
}
if (!nonEmpty(planets)) gaps.push("base.planets");
// BUG-1154 / BUG-1155: who aspects each planet, and combustion, copied from
// the engine's graha_drishti layer; co-tenants are selected from the
// planets' own `house`. Nothing is derived when the layer is missing. The
// houses' own aspects sit on the domain house rows (domainSection).
// Budget (D6): like `combust` (only when true), both lists appear only when
// non-empty.
const drishti = record(natal.graha_drishti);
const drishtiPlanets = record(drishti.planets);
const listed = (value: unknown) => (Array.isArray(value) && value.length ? copy(value) : undefined);
if (drishti.status !== "executed") gaps.push("base.aspects");
for (const [name, row] of Object.entries(planets)) {
const out = row as JsonRecord;
const house = out.house;
const coTenants = typeof house === "number"
? Object.entries(planets).filter(([other, value]) => other !== name && record(value).house === house).map(([other]) => other)
: [];
if (coTenants.length) out.conjunct_with = coTenants;
if (drishti.status !== "executed") continue;
const source = record(drishtiPlanets[name]);
const by = listed(source.aspected_by);
if (by !== undefined) out.aspected_by = by;
if (source.combust === true) out.combust = true;
}
const functional = pickKeys(natal.functional_benefic_malefic, FUNCTIONAL_KEYS);
if (!nonEmpty(functional)) gaps.push("base.functional_benefic_malefic");
const summary = record(natal.d9_summary);
@@ -431,6 +513,9 @@ function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
const rows: JsonRecord = {};
for (const [name, row] of Object.entries(record(summary.planets))) {
const picked = pickKeys(row, D9_ROW_KEYS);
// Budget (TASK-consult-card-affliction-data-20261001 D6): like `combust`,
// `vargottama` appears only when true.
if (picked.vargottama === false) delete picked.vargottama;
if (nonEmpty(picked)) rows[name] = picked;
}
d9.planets = rows;
@@ -457,7 +542,14 @@ function baseTiming(timing: Record<string, unknown>, gaps: string[]) {
set("next_antardasha", sub.next);
set("next_pratyantardasha", pratyantar.next);
if (Array.isArray(sub.boundaries)) {
const rows = sub.boundaries.map((row) => pickKeys(row, PERIOD_KEYS)).filter(nonEmpty);
// Budget (TASK-consult-card-affliction-data-20261001 D6, short arrays):
// each antardasha is [lord, start, end] when the engine gave all three,
// otherwise the picked object as before. Values are unchanged.
const rows = sub.boundaries.map((row): Json => {
const picked = pickKeys(row, PERIOD_KEYS);
const tuple = PERIOD_KEYS.map((key) => picked[key]);
return tuple.every((value) => typeof value === "string") ? tuple as Json[] : picked;
}).filter((row) => Array.isArray(row) || nonEmpty(row as JsonRecord));
if (rows.length) vimshottari.antardashas_in_mahadasha = rows;
}
for (const key of ["mahadasha", "antardasha", "pratyantardasha"]) {
@@ -540,6 +632,9 @@ function domainSection(
houses = uniqueNumbers([...houses, ...ownedBy(base, antardasha), ...ownedBy(base, nextAntardasha), ...jupiter, ...saturn]);
}
const owned = record(base.natal.functional_benefic_malefic.owned_houses);
// BUG-1155: who aspects each selected house (engine graha_drishti layer).
const drishti = record(natal.graha_drishti);
const houseAspects = drishti.status === "executed" ? record(drishti.houses) : undefined;
for (const number of houses) {
const sign = base.natal.houses[String(number)];
if (typeof sign !== "string") {
@@ -554,7 +649,8 @@ function domainSection(
const lords = Object.entries(owned)
.filter(([, houses]) => Array.isArray(houses) && houses.includes(number))
.map(([name]) => name);
section.houses[String(number)] = { sign, occupants, lords };
const aspectedBy = houseAspects ? copy(record(houseAspects[String(number)]).aspected_by) : undefined;
section.houses[String(number)] = { sign, occupants, lords, ...(aspectedBy !== undefined ? { aspected_by: aspectedBy } : {}) };
}
if (rule.planets.length) {
const focus: string[] = [];
@@ -600,11 +696,23 @@ function domainSection(
section.karakas = { ...(section.karakas ?? {}), [code]: picked };
karakas.push(code);
}
const houseLords = Array.isArray(houseRow.lords) ? houseRow.lords.filter((name): name is string => typeof name === "string") : [];
const significators = role.planets.filter((name) => caseInsensitive(base.natal.planets, name));
const rowsFor = (names: readonly string[]) => {
const rows: JsonRecord = {};
for (const name of names) {
const row = rolePlanetRow(base, name);
if (row) rows[name] = row;
}
return rows;
};
const entry: JsonRecord = {
label: role.label,
house: role.house,
house_lords: Array.isArray(houseRow.lords) ? houseRow.lords : [],
significators: role.planets.filter((name) => caseInsensitive(base.natal.planets, name)),
house_lords: houseLords,
significators,
lord_rows: rowsFor(houseLords),
significator_rows: rowsFor(significators),
};
if (karakas.length) entry.karakas = karakas;
if (role.varga) {
@@ -621,7 +729,20 @@ function domainSection(
const rows = Array.isArray(yogas.yogas) ? yogas.yogas.map(record) : [];
const names = rows.map((row) => row.name).filter((name): name is string => typeof name === "string");
const boundaries = [...new Set(rows.map((row) => row.claim_boundary).filter((value): value is string => typeof value === "string"))];
if (names.length || typeof yogas.count === "number") {
const tagged = record(natal.yoga_domains);
if (tagged.status === "executed" && Array.isArray(tagged.rows)) {
// BUG-1157: only this domain's tags; `hits` are the names the engine's
// sieve matched in the chart (the rest are the packet's other rules).
const own = tagged.rows.map(record).filter((row) => (
typeof row.name === "string" && typeof row.domain === "string" && rule.yogaTags.includes(row.domain)
));
section.yogas = {
names: own.map((row) => row.name as string),
hits: own.filter((row) => row.hit === true).map((row) => row.name as string),
...(boundaries.length ? { claim_boundaries: boundaries } : {}),
};
} else if (names.length || typeof yogas.count === "number") {
gaps.push(`${domain}.yoga_domains`);
section.yogas = {
...(typeof yogas.count === "number" ? { count: yogas.count } : {}),
names,
@@ -699,9 +820,12 @@ function domainSection(
} else {
section.slow_transits = {
current: rows,
// BUG-1156: transit Saturn's house from the natal Moon (engine value).
...(typeof slow.saturn_from_moon_house === "number" ? { saturn_from_moon_house: slow.saturn_from_moon_house } : {}),
key_dates: dates.map((row) => copy(row)).filter((row): row is Json => row !== undefined),
...(copy(slow.window) !== undefined ? { window: copy(slow.window)! } : {}),
};
if (typeof slow.saturn_from_moon_house !== "number") gaps.push(`${domain}.saturn_from_moon_house`);
}
}
if (rule.doubleTransit !== "none") {
@@ -779,6 +903,11 @@ function onCard(card: Pick<EvidenceCard, "domain_sections" | "base">, section: E
if (code === "D9" && typeof card.base.natal.d9.lagna === "string") return true;
return sections.some((item) => item && code in item.vargas);
}
// Yogas are on a card only by domain tag (BUG-1157); the full list stays
// one lookup away unless a section carries it untagged.
if (section === "yogas") {
return sections.some((item) => item?.yogas !== undefined && !Array.isArray(item.yogas.hits));
}
// Chara Dasha, KP and the v2 optional layers are never on a card.
const key = ({
yogas: "yogas",
@@ -527,10 +527,18 @@ const nativeVivahSahamKeys = new Set([
"status", "source", "longitude", "sign", "degreeinsign", "house", "natalconjuncts", "planet", "diffdeg", "boundary",
]);
const nativeDayNightKeys = new Set(["status", "source", "isday"]);
// BUG-1154: whole-sign graha drishti and combustion (build_graha_drishti).
const nativeGrahaDrishtiKeys = new Set([
"status", "reason", "source", "rule", "planets", "houses", "aspectshouses", "aspectedby", "combust",
]);
// 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"]);
const nativeSlowTransitKeys = new Set([
"status", "source", "referencedate", "window", "start", "end", "current", "sign", "degreeinsign",
"retrograde", "natalhouse", "sav", "bav", "keydates", "kind", "date", "body", "fromsign", "tosign",
"tonatalhouse", "motion", "housesinwindow", "boundary",
// BUG-1156: transit Saturn's house counted from the natal Moon.
"saturnfrommoonhouse",
]);
const nativeDoubleTransitKeys = new Set([
"status", "source", "date", "activehouses", "conclusions", "house", "layer", "target", "strength",
@@ -583,6 +591,8 @@ function projectNatalFoundation(context: ReturnType<typeof toAgentConsultationCo
["punarphoo", nativePunarphooKeys],
["vivah_saham", nativeVivahSahamKeys],
["day_night", nativeDayNightKeys],
["graha_drishti", nativeGrahaDrishtiKeys],
["yoga_domains", nativeYogaDomainKeys],
]));
return Object.keys(output).length ? output : undefined;
}