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:
co-authored by
Claude Opus 5.5
parent
9b937c4adf
commit
19802a4874
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// TASK-consult-card-affliction-data-20261001 T2–T4 (BUG-1155~1157): the card
|
||||
// carries who aspects whom, co-tenants and combustion; the parents roles carry
|
||||
// full rows for their house lords and significators; health / education take
|
||||
// the houses they were missing; the annual card names transit Saturn's house
|
||||
// from the natal Moon; yogas are taken by domain tag. Expected values are read
|
||||
// from the real engine's own layers in the golden fixture
|
||||
// (scripts/research/capture_consult_evidence_card_golden.py, AGENTS §7.4).
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildEvidenceCard,
|
||||
EVIDENCE_CARD_SPECS,
|
||||
type EvidenceCard,
|
||||
} from "../src/lib/consultation-evidence-card.ts";
|
||||
import type { ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
|
||||
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
|
||||
import {
|
||||
consultationWorkflowResponseSchema,
|
||||
toAgentConsultationContext,
|
||||
toModelOutput,
|
||||
} from "../src/mastra/consultation-workflow.ts";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
type GoldenChart = { id: string; workflow: Json; annual_workflow: Json; timing_workflow: Json };
|
||||
const golden = JSON.parse(readFileSync(
|
||||
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
|
||||
"utf8",
|
||||
)) as { charts: GoldenChart[] };
|
||||
|
||||
const chartById = (id: string) => golden.charts.find((chart) => chart.id === id)!;
|
||||
const jobs = chartById("steve_jobs");
|
||||
const obama = chartById("barack_obama");
|
||||
const taylor = chartById("elizabeth_taylor");
|
||||
|
||||
function dig(value: unknown, ...keys: string[]): unknown {
|
||||
let current = value;
|
||||
for (const key of keys) {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
|
||||
current = (current as Json)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function sources(workflow: Json, domain: ConsultationDomain) {
|
||||
const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow)));
|
||||
const packet = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: domain }), domain);
|
||||
return { domain, packet, context };
|
||||
}
|
||||
|
||||
function card(workflow: Json, domain: ConsultationDomain): EvidenceCard {
|
||||
return buildEvidenceCard([sources(workflow, domain)]);
|
||||
}
|
||||
|
||||
function layer(workflow: Json, name: string): Json {
|
||||
return dig(workflow, "chart", "consultation_native_layers", name) as Json;
|
||||
}
|
||||
|
||||
function enginePlanets(workflow: Json): Record<string, Json> {
|
||||
return dig(workflow, "chart", "planets") as Record<string, Json>;
|
||||
}
|
||||
|
||||
test("base planets carry the engine's aspected_by, combustion (true only) and co-tenants (selection)", () => {
|
||||
for (const chart of golden.charts) {
|
||||
const built = card(chart.workflow, "general");
|
||||
const drishti = layer(chart.workflow, "graha_drishti");
|
||||
assert.equal(drishti.status, "executed", chart.id);
|
||||
const planets = built.base.natal.planets as Record<string, Json>;
|
||||
const engine = enginePlanets(chart.workflow);
|
||||
for (const [name, row] of Object.entries(planets)) {
|
||||
const source = dig(drishti, "planets", name) as Json;
|
||||
const by = source.aspected_by as string[];
|
||||
if (by.length) assert.deepEqual(row.aspected_by, by, `${chart.id} ${name} aspected_by`);
|
||||
else assert.equal("aspected_by" in row, false, `${chart.id} ${name} empty aspected_by is left out`);
|
||||
if (source.combust === true) assert.equal(row.combust, true, `${chart.id} ${name} combust`);
|
||||
else assert.equal("combust" in row, false, `${chart.id} ${name} combust only when true`);
|
||||
const coTenants = Object.entries(engine)
|
||||
.filter(([other, value]) => other !== name && value.house === engine[name]!.house)
|
||||
.map(([other]) => other);
|
||||
if (coTenants.length) assert.deepEqual(row.conjunct_with, coTenants, `${chart.id} ${name} conjunct_with`);
|
||||
else assert.equal("conjunct_with" in row, false, `${chart.id} ${name} alone`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Obama: Saturn in the 1st aspects the Sun in the 7th; Mercury (9th lord) is combust", () => {
|
||||
const planets = card(obama.workflow, "parents").base.natal.planets as Record<string, Json>;
|
||||
assert.equal(planets.Saturn!.house, 1);
|
||||
assert.equal(planets.Sun!.house, 7);
|
||||
assert.ok((planets.Sun!.aspected_by as string[]).includes("Saturn"));
|
||||
assert.ok((planets.Mercury!.aspected_by as string[]).includes("Saturn"));
|
||||
assert.equal(planets.Mercury!.combust, true);
|
||||
assert.deepEqual(dig(layer(obama.workflow, "graha_drishti"), "planets", "Saturn", "aspects_houses"), [3, 7, 10]);
|
||||
});
|
||||
|
||||
test("a house lord sharing a house with Rahu shows it (Jobs: 9th lord Venus with Rahu in the 4th)", () => {
|
||||
const planets = card(jobs.workflow, "general").base.natal.planets as Record<string, Json>;
|
||||
assert.equal(planets.Venus!.house, 4);
|
||||
assert.ok((planets.Venus!.conjunct_with as string[]).includes("Rahu"));
|
||||
assert.ok((planets.Rahu!.conjunct_with as string[]).includes("Venus"));
|
||||
});
|
||||
|
||||
test("domain house rows carry the engine's aspected_by for each selected house", () => {
|
||||
for (const chart of golden.charts) {
|
||||
for (const domain of ["marriage", "parents", "health"] as const) {
|
||||
const section = card(chart.workflow, domain).domain_sections[domain]!;
|
||||
for (const [number, row] of Object.entries(section.houses)) {
|
||||
assert.deepEqual(
|
||||
(row as Json).aspected_by,
|
||||
dig(layer(chart.workflow, "graha_drishti"), "houses", number, "aspected_by"),
|
||||
`${chart.id}/${domain} house ${number}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const seventh = card(obama.workflow, "marriage").domain_sections.marriage!.houses["7"] as Json;
|
||||
assert.deepEqual(seventh.aspected_by, ["Jupiter", "Saturn"]);
|
||||
});
|
||||
|
||||
test("parents roles carry full rows for the house lords and significators, functional class selected", () => {
|
||||
for (const chart of golden.charts) {
|
||||
const built = card(chart.workflow, "parents");
|
||||
const functional = built.base.natal.functional_benefic_malefic;
|
||||
const roles = built.domain_sections.parents!.roles as Record<string, Json>;
|
||||
for (const [key, role] of Object.entries(roles)) {
|
||||
const lordRows = role.lord_rows as Record<string, Json>;
|
||||
const significatorRows = role.significator_rows as Record<string, Json>;
|
||||
assert.deepEqual(Object.keys(lordRows), role.house_lords, `${chart.id} ${key} lords`);
|
||||
assert.deepEqual(Object.keys(significatorRows), role.significators, `${chart.id} ${key} significators`);
|
||||
for (const [name, row] of [...Object.entries(lordRows), ...Object.entries(significatorRows)]) {
|
||||
const base = (built.base.natal.planets as Record<string, Json>)[name]!;
|
||||
for (const field of ["house", "sign", "status", "conjunct_with", "aspected_by", "combust"]) {
|
||||
assert.deepEqual(row[field], base[field], `${chart.id} ${key} ${name}.${field}`);
|
||||
}
|
||||
const expected = (functional.functional_benefics as string[]).includes(name) ? "benefic"
|
||||
: (functional.functional_malefics as string[]).includes(name) ? "malefic"
|
||||
: (functional.functional_neutrals as string[]).includes(name) ? "neutral" : undefined;
|
||||
assert.equal(row.functional, expected, `${chart.id} ${key} ${name}.functional`);
|
||||
}
|
||||
const d12 = role.d12_house as Json;
|
||||
const d12Planets = dig(built.domain_sections.parents!.vargas, "D12", "planets") as Json;
|
||||
assert.equal(typeof d12.lord, "string", `${chart.id} ${key} D12 lord`);
|
||||
assert.equal(d12.lord_sign, d12Planets[d12.lord as string], `${chart.id} ${key} D12 lord sign`);
|
||||
}
|
||||
}
|
||||
const father = dig(card(jobs.workflow, "parents").domain_sections.parents, "roles", "father") as Json;
|
||||
assert.ok((dig(father, "lord_rows", "Venus", "conjunct_with") as string[]).includes("Rahu"));
|
||||
assert.equal(dig(father, "significator_rows", "Sun", "functional"), "malefic");
|
||||
});
|
||||
|
||||
test("health takes the 1st and 12th houses, education the 4th", () => {
|
||||
assert.deepEqual(EVIDENCE_CARD_SPECS.health.houses, [1, 6, 8, 12]);
|
||||
assert.deepEqual(EVIDENCE_CARD_SPECS.education.houses, [4, 5, 9]);
|
||||
const health = card(taylor.workflow, "health").domain_sections.health!;
|
||||
assert.deepEqual(Object.keys(health.houses).sort((a, b) => Number(a) - Number(b)), ["1", "6", "8", "12"]);
|
||||
const education = card(jobs.workflow, "education").domain_sections.education!;
|
||||
assert.ok((dig(education.houses, "4", "occupants") as string[]).includes("Rahu"), "Jobs: Rahu in the 4th is now on the card");
|
||||
});
|
||||
|
||||
test("the annual card copies transit Saturn's house from the natal Moon (Jobs: Saturn over the Pisces Moon)", () => {
|
||||
for (const chart of golden.charts) {
|
||||
const section = card(chart.annual_workflow, "annual").domain_sections.annual!;
|
||||
assert.equal(
|
||||
dig(section.slow_transits, "saturn_from_moon_house"),
|
||||
dig(layer(chart.annual_workflow, "slow_transits"), "saturn_from_moon_house"),
|
||||
chart.id,
|
||||
);
|
||||
}
|
||||
const section = card(jobs.annual_workflow, "annual").domain_sections.annual!;
|
||||
assert.equal(dig(section.slow_transits, "current", "Saturn", "sign"), "Pisces");
|
||||
assert.equal(dig(enginePlanets(jobs.annual_workflow), "Moon", "sign"), "Pisces");
|
||||
assert.equal(dig(section.slow_transits, "saturn_from_moon_house"), 1);
|
||||
});
|
||||
|
||||
test("yogas are taken by domain tag; hits are the sieve's own matches", () => {
|
||||
const tagged = (workflow: Json) => (layer(workflow, "yoga_domains").rows as Json[]);
|
||||
// The annual route sieves every packet (20 rules), so every tag is present.
|
||||
for (const chart of golden.charts) {
|
||||
for (const domain of ["career", "wealth", "general", "parents", "children"] as const) {
|
||||
const yogas = card(chart.annual_workflow, domain).domain_sections[domain]!.yogas as Json;
|
||||
const wanted = EVIDENCE_CARD_SPECS[domain].yogaTags;
|
||||
const own = tagged(chart.annual_workflow).filter((row) => wanted.includes(row.domain as string));
|
||||
assert.deepEqual(yogas.names, own.map((row) => row.name), `${chart.id}/${domain} names`);
|
||||
assert.deepEqual(yogas.hits, own.filter((row) => row.hit === true).map((row) => row.name), `${chart.id}/${domain} hits`);
|
||||
}
|
||||
}
|
||||
const names = (domain: ConsultationDomain) => (
|
||||
card(obama.annual_workflow, domain).domain_sections[domain]!.yogas as Json
|
||||
).names as string[];
|
||||
for (const domain of ["wealth", "general"] as const) {
|
||||
assert.equal(names(domain).some((name) => /^Matru(nasa|\s|deer|S)/.test(name) || /Puthra/.test(name)), false, `Obama ${domain}`);
|
||||
}
|
||||
assert.ok(names("parents").includes("Matrunasa Yoga"));
|
||||
assert.ok(names("parents").every((name) => name.startsWith("Matru")));
|
||||
assert.ok(names("children").every((name) => name.includes("Puthra")) && names("children").length > 0);
|
||||
assert.deepEqual(
|
||||
(card(obama.annual_workflow, "parents").domain_sections.parents!.yogas as Json).hits,
|
||||
["Matrunasa Yoga"],
|
||||
);
|
||||
});
|
||||
|
||||
test("missing engine layers are gaps, never filled in", () => {
|
||||
const workflow = structuredClone(obama.workflow);
|
||||
const layers = dig(workflow, "chart", "consultation_native_layers") as Json;
|
||||
layers.graha_drishti = { status: "blocked", reason: "fixture" };
|
||||
layers.yoga_domains = { status: "blocked", reason: "fixture" };
|
||||
const built = card(workflow, "parents");
|
||||
assert.ok(built.meta.gaps.includes("base.aspects"));
|
||||
assert.ok(built.meta.gaps.includes("parents.yoga_domains"));
|
||||
for (const row of Object.values(built.base.natal.planets as Record<string, Json>)) {
|
||||
assert.equal("aspected_by" in row, false);
|
||||
assert.equal("combust" in row, false);
|
||||
}
|
||||
for (const row of Object.values(built.domain_sections.parents!.houses)) {
|
||||
assert.equal("aspected_by" in (row as Json), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("budget compression keeps every value: antardashas as [lord, start, end], vargottama only when true", () => {
|
||||
for (const chart of golden.charts) {
|
||||
const built = card(chart.annual_workflow, "annual");
|
||||
const boundaries = dig(chart.annual_workflow, "chart", "modules", "dasha_sub_periods", "boundaries") as Json[];
|
||||
assert.deepEqual(
|
||||
built.base.timing.vimshottari.antardashas_in_mahadasha,
|
||||
boundaries.map((row) => [row.lord, row.start, row.end]),
|
||||
chart.id,
|
||||
);
|
||||
const summary = layer(chart.annual_workflow, "d9_summary").planets as Record<string, Json>;
|
||||
for (const [name, row] of Object.entries(built.base.natal.d9.planets as Record<string, Json>)) {
|
||||
assert.equal(row.vargottama === true, summary[name]!.vargottama === true, `${chart.id} ${name}`);
|
||||
assert.notEqual(row.vargottama, false, `${chart.id} ${name}: false is left out`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -153,6 +153,10 @@ test("every card value is a value the projection or the engine already holds (no
|
||||
// 原值: 白名单是 EVIDENCE_CARD_LABELS 的两条(性别未知、年盘未接入)
|
||||
// 新值: 同一来源多了 roleMother / roleFather(母亲、父亲),断言本身不变
|
||||
// 原因: TASK-consult-plain-answer-20261001 D6(BUG-1133):父母卡标出每条依据属于谁,标签由卡自己写
|
||||
// 原值: 白名单是 EVIDENCE_CARD_LABELS 的四条(性别未知、年盘未接入、母亲、父亲)
|
||||
// 新值: 同一来源多了 functionalBenefic / functionalMalefic / functionalNeutral(benefic、malefic、neutral),断言本身不变
|
||||
// 原因: TASK-consult-card-affliction-data-20261001 T3(BUG-1156):父母卡宫主与代表星行标出功能吉凶,
|
||||
// 取值是「该星在引擎哪张功能列表里」的选择结果,标签由卡自己写
|
||||
const cardLabels = new Set(Object.values(EVIDENCE_CARD_LABELS).map((label) => JSON.stringify(label)));
|
||||
for (const chart of golden.charts) {
|
||||
for (const domain of consultationDomainIds) {
|
||||
@@ -293,7 +297,11 @@ test("the card names what else this calculation holds, without the sections it a
|
||||
assert.ok(card.meta.supplementable_sections.includes("yogas"));
|
||||
assert.equal(card.meta.supplementable_sections.includes("varga:D12"), false);
|
||||
const career = cardFor(golden.charts[0]!.workflow, "career");
|
||||
assert.equal(career.meta.supplementable_sections.includes("yogas"), false);
|
||||
// 原值: career 卡带 yogas,所以 supplementable_sections 不含 "yogas"(false)
|
||||
// 新值: true
|
||||
// 原因: TASK-consult-card-affliction-data-20261001 T4(BUG-1157):卡上只放本领域标签的瑜伽,
|
||||
// 完整清单仍在服务端,只能靠一次查阅拿到,所以 "yogas" 仍是可补查的部分
|
||||
assert.equal(career.meta.supplementable_sections.includes("yogas"), true);
|
||||
assert.equal(career.meta.supplementable_sections.includes("varga:D10"), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -85,7 +85,14 @@ test("every base section carries the engine's D9 summary: lagna, D9 sign and dig
|
||||
assert.equal(d9.lagna_vargottama, summary.lagna_vargottama, `${where} lagna vargottama`);
|
||||
assert.deepEqual(d9.reversals, summary.reversals, `${where} reversals`);
|
||||
for (const [planet, row] of Object.entries(summary.planets as Json)) {
|
||||
assert.deepEqual((d9.planets as Json)[planet], pick(row, ["d9_sign", "d9_dignity", "vargottama"]), `${where} ${planet}`);
|
||||
// 原值: 每行 deepEqual pick(row, ["d9_sign", "d9_dignity", "vargottama"]),vargottama 为 false 时也在卡上
|
||||
// 新值: vargottama 只在 true 时出现;false 时卡上没有这个键,其余两项不变,true 时三项全等
|
||||
// 原因: TASK-consult-card-affliction-data-20261001 D6:加相位、同宫、燃烧后年运卡超 12,000 字符,
|
||||
// 按任务书先压缩格式(同 combust 只在 true 时出现),不删字段、不改体量断言
|
||||
const expected = pick(row, ["d9_sign", "d9_dignity", "vargottama"]);
|
||||
if (expected.vargottama === false) delete expected.vargottama;
|
||||
assert.deepEqual((d9.planets as Json)[planet], expected, `${where} ${planet}`);
|
||||
assert.equal(((d9.planets as Json)[planet] as Json).vargottama === true, (row as Json).vargottama === true, `${where} ${planet} vargottama`);
|
||||
}
|
||||
// The D9 houses and aspects stay out of the base section.
|
||||
assert.equal(JSON.stringify(d9).includes("house"), false, `${where} no D9 houses`);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -238,3 +238,94 @@ def test_mudda_segment_dates_split_the_solar_year_by_the_native_months() -> None
|
||||
{"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"},
|
||||
]
|
||||
|
||||
|
||||
# ── TASK-consult-card-affliction-data-20261001 T1 / T3 / T4 ─────────────────
|
||||
|
||||
def _chart_index(chart_id: str) -> int:
|
||||
return [chart["id"] for chart in load_public_charts(ROOT)].index(chart_id)
|
||||
|
||||
|
||||
def test_graha_drishti_is_the_engine_aspect_and_combustion_rule() -> None:
|
||||
"""BUG-1154: aspects from aspects.calc_house_aspects, combustion from the
|
||||
full reading's own rule; Rahu / Ketu receive aspects but cast none."""
|
||||
from aspects import calc_house_aspects
|
||||
|
||||
engine = native._load_engine()
|
||||
for chart_id in ("steve_jobs", "barack_obama", "elizabeth_taylor"):
|
||||
workflow = _run("marriage", index=_chart_index(chart_id))
|
||||
drishti = _layers(workflow)["graha_drishti"]
|
||||
assert drishti["status"] == "executed"
|
||||
planets = workflow["chart"]["planets"]
|
||||
asc_lon = workflow["chart"]["ascendant"]["lon"]
|
||||
sun_lon = planets["Sun"]["lon"]
|
||||
by_house: dict[int, list[str]] = {house: [] for house in range(1, 13)}
|
||||
for name in native.DRISHTI_CASTERS:
|
||||
expected = sorted(int(h) for h in calc_house_aspects(planets[name]["lon"], name, asc_lon)["aspects_to"])
|
||||
assert drishti["planets"][name]["aspects_houses"] == expected, (chart_id, name)
|
||||
assert drishti["planets"][name]["combust"] is engine.is_combust(name, planets[name]["lon"], sun_lon)
|
||||
for house in expected:
|
||||
by_house[house].append(name)
|
||||
for house, casters in by_house.items():
|
||||
assert drishti["houses"][str(house)]["aspected_by"] == casters, (chart_id, house)
|
||||
for name, row in planets.items():
|
||||
assert drishti["planets"][name]["aspected_by"] == by_house[row["house"]], (chart_id, name)
|
||||
for node in ("Rahu", "Ketu"):
|
||||
assert "aspects_houses" not in drishti["planets"][node]
|
||||
assert "combust" not in drishti["planets"][node]
|
||||
assert all(node not in row["aspected_by"] for row in drishti["houses"].values())
|
||||
|
||||
|
||||
def test_obama_saturn_aspects_the_sun_and_mercury_is_combust() -> None:
|
||||
drishti = _layers(_run("marriage", index=_chart_index("barack_obama")))["graha_drishti"]
|
||||
assert {3, 7, 10} <= set(drishti["planets"]["Saturn"]["aspects_houses"])
|
||||
assert "Saturn" in drishti["planets"]["Sun"]["aspected_by"]
|
||||
assert drishti["planets"]["Mercury"]["combust"] is True
|
||||
|
||||
|
||||
def test_combustion_rule_is_unchanged() -> None:
|
||||
"""The full reading's orbs, moved into a named helper without change."""
|
||||
engine = native._load_engine()
|
||||
assert engine.COMBUSTION_ORBS == {"Mars": 17, "Mercury": 12, "Jupiter": 11, "Venus": 10, "Saturn": 15}
|
||||
assert engine.is_combust("Mercury", 112.0, 100.0) is True
|
||||
assert engine.is_combust("Mercury", 112.01, 100.0) is False
|
||||
assert engine.is_combust("Venus", 355.0, 5.0) is True # across 0°
|
||||
assert engine.is_combust("Sun", 100.0, 100.0) is False
|
||||
assert engine.is_combust("Moon", 100.0, 100.0) is True # orb 0: only an exact conjunction
|
||||
|
||||
|
||||
def test_saturn_from_moon_house_is_whole_sign_from_the_natal_moon() -> None:
|
||||
"""BUG-1156: Jobs at the research reference date, Saturn in Pisces over the Pisces Moon."""
|
||||
for chart_id in ("steve_jobs", "barack_obama", "elizabeth_taylor"):
|
||||
workflow = _run("annual", index=_chart_index(chart_id))
|
||||
slow = _layers(workflow)["slow_transits"]
|
||||
moon_idx = native.SIGNS.index(workflow["chart"]["planets"]["Moon"]["sign"])
|
||||
saturn_idx = native.SIGNS.index(slow["current"]["Saturn"]["sign"])
|
||||
assert slow["saturn_from_moon_house"] == ((saturn_idx - moon_idx) % 12) + 1
|
||||
jobs = _layers(_run("annual", index=_chart_index("steve_jobs")))["slow_transits"]
|
||||
assert jobs["current"]["Saturn"]["sign"] == "Pisces"
|
||||
assert jobs["saturn_from_moon_house"] == 1
|
||||
|
||||
|
||||
def test_yoga_domains_tag_each_sieved_yoga_by_its_packet() -> None:
|
||||
"""BUG-1157: the tag is the Raman packet of the rule id (fixed table); `hit` is the sieve's."""
|
||||
from raman_support_observations import packet_members
|
||||
|
||||
workflow = _run("annual", index=_chart_index("barack_obama"))
|
||||
rows = _layers(workflow)["yoga_domains"]["rows"]
|
||||
sieved = workflow["chart"]["modules"]["yogas"]["yogas"]
|
||||
assert [row["name"] for row in rows] == [row["name"] for row in sieved]
|
||||
assert [row["hit"] for row in rows] == [row["hit"] for row in sieved]
|
||||
expected = {
|
||||
member["rule_id"]: domain
|
||||
for filename, domain in native.YOGA_PACKET_DOMAINS.items()
|
||||
for member in packet_members(filename)
|
||||
}
|
||||
for row, source in zip(rows, sieved):
|
||||
assert row["domain"] == expected[source["rule_id"]]
|
||||
by_name = {row["name"]: row["domain"] for row in rows}
|
||||
assert by_name["Matrunasa Yoga"] == "parents"
|
||||
assert by_name["Bahu Puthra Yoga"] == "children"
|
||||
assert by_name["Dharidhra Yoga"] == "wealth"
|
||||
assert by_name["Sunaphaa Yoga"] == "general"
|
||||
assert set(native.YOGA_PACKET_DOMAINS.values()) == {"general", "wealth", "marriage", "children", "parents", "siblings"}
|
||||
|
||||
Reference in New Issue
Block a user