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