Files
Jyotisha/frontend/src/lib/consultation-evidence-card.ts
T
Jesse_ChenandClaude Opus 5.5 50deecbf40 feat(consult): plain answer shape — conclusion first, one section per person, no word caps (BUG-1132~1134)
Retire the 09-17 opener shape (coined pattern name, push/tidy periods,
play-a-role) that produced riddles; one shape definition in
consultation-thinking-plan; parents card names mother/father roles with MK;
empty-house rule; thinking bar closes the opener at any H2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 09:42:33 +08:00

927 lines
39 KiB
TypeScript

import type { ConsultationDomain } from "./consultation-domain-registry.ts";
import { consultationDomainDefinition } from "./consultation-domain-registry.ts";
import type { ProfileGender } from "./profile-gender.ts";
/**
* The evidence card: what the answer-writing model reads about the chart
* (TASK-consult-evidence-card-20260927, D1; v2 per
* TASK-consult-evidence-card-v2-20260927, the astrologer's review).
*
* The consultation still calculates the full spectrum (D6); the full result
* stays on the server for receipts, the 「本轮技法」 panel, reports and the
* one-shot evidence lookup. The card is a base section (ascendant, twelve
* house signs, planet placements, functional benefics/malefics with house
* lordship, the running Vimshottari MD/AD/PD with dates, the running Narayana
* period) plus one section per domain, cut to that domain's vargas, houses,
* planets and layers.
*
* Every value is copied from the model projection (`toModelOutput`) or, for
* the three layers the projection does not carry (chart Shadbala summary,
* Ashtakavarga house scores, chara karakas), from the engine's own values in
* the agent context. Nothing is rewritten, rounded, converted or translated;
* what is missing is listed in `gaps`, never filled in. v2 adds the native
* technique layers the engine now attaches (scripts/consultation_native_layers.py):
* a D9 summary in every base section, the annual Tajika chart, Double
* Transit, Punarphoo, Vivah Saham and the slow-planet transits. The only
* card-authored strings are `EVIDENCE_CARD_LABELS` (and domain ids that point
* at another section); selections (which houses, which conclusions) are made
* here, values never.
*
* Pure functions, no React.
*/
export const EVIDENCE_CARD_VERSION = "evidence-card-v2" as const;
/** The only strings the card writes itself (decision table, 2026-09-27). */
export const EVIDENCE_CARD_LABELS = {
/**
* The current subject's optional gender (TASK-consult-gender-optional-20260927):
* 「性别未知」 when it is not filled, otherwise 「女」 / 「男」.
*/
genderUnknown: "性别未知",
genderFemale: "女",
genderMale: "男",
/** The annual pack could not be produced reliably; natal data is never put in its place. */
annualNotAttached: "年盘未接入",
/** Who a parents-section entry is about (TASK-consult-plain-answer-20261001 D6). */
roleMother: "母亲",
roleFather: "父亲",
} as const;
/** KP stays off every card: exact cusps are still blocked (decision table). */
export const EVIDENCE_LOOKUP_KP_NOTE = "KP 精确宫头仍 blocked:只作参考,不得作为结论依据";
/** D4: the one line the card carries instead of the long status layers. */
export const EVIDENCE_CARD_BACKSTAGE_LINE = "全网资料核对、真实案例校准:未做,置信度封顶";
/** chars / 3.5, the research's estimate (not a vendor tokenizer). */
export const EVIDENCE_CARD_CHARS_PER_TOKEN = 3.5;
export type EvidenceCardDomainSpec = Readonly<{
vargas: readonly string[];
houses: readonly number[];
arudha: readonly string[];
karakas: readonly string[];
planets: readonly string[];
yogas: boolean;
transits: boolean;
ashtakavarga: boolean;
shadbala: boolean;
/** SAV of these houses alone (career: 10H), without the full Ashtakavarga block. */
savHouses: readonly number[];
/**
* Slow-planet transits: `sav` Jupiter / Saturn sign, natal house and SAV
* (career); `nodes_sav_bav` Jupiter / Saturn with SAV and BAV plus Rahu /
* Ketu and their ingress dates (timing); `key_dates` the four bodies now and
* every ingress / station date of the next twelve months (annual).
*/
slowTransits: "none" | "sav" | "nodes_sav_bav" | "key_dates";
/** `seventh_and_karakas`: 7H / 7L (house 7 run) and DK / UL; `all_houses`: every conclusion. */
doubleTransit: "none" | "seventh_and_karakas" | "all_houses";
punarphoo: boolean;
dayNight: boolean;
vivahSaham: boolean;
/** The section carries the current subject's gender (「性别未知」 when not filled). */
gender: boolean;
/** The annual Tajika chart (annual route only). */
annualChart: boolean;
/** Houses of the running and next AD lords and of the year's Jupiter / Saturn transits. */
annualHouses: boolean;
/** Vargas follow the other domain of the turn; `vargas` is the default when alone. */
followVargas: boolean;
/**
* Who each piece of the section is about (parents: mother / father), so the
* answer can keep the people apart (TASK-consult-plain-answer-20261001 D6).
*/
roles: readonly EvidenceCardRole[];
}>;
export type EvidenceCardRole = Readonly<{
key: string;
label: string;
house: number;
planets: readonly string[];
/** Chara karakas that must be on the card; a missing one is a gap. */
karakas: readonly string[];
/** Chara karakas taken only when the engine's scheme has them (PiK is 8-karaka only). */
optionalKarakas: readonly string[];
/** The varga whose same-numbered house is named for this role. */
varga?: string;
}>;
const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSpec, "vargas">): EvidenceCardDomainSpec => ({
houses: [],
arudha: [],
karakas: [],
planets: [],
yogas: false,
transits: false,
ashtakavarga: false,
shadbala: false,
savHouses: [],
slowTransits: "none",
doubleTransit: "none",
punarphoo: false,
dayNight: false,
vivahSaham: false,
gender: false,
annualChart: false,
annualHouses: false,
followVargas: false,
roles: [],
...value,
});
/**
* Domain sections, ported from the research draft `CARD_SPECS`
* (scripts/research/consult_evidence_card_lib.py; docs/research/
* consult_evidence_card_draft_2026_09_27.json) as the single source, with the
* v2 decision table on top (TASK-consult-evidence-card-v2-20260927):
* - career: + AL, 10H SAV, SAV of the Jupiter / Saturn transit signs;
* - marriage: + 5H / 5L, day / night, Punarphoo (observation_only), Double
* Transit on 7H / 7L / DK / UL, Vivah Saham, the subject's gender (「性别未知」
* when not filled);
* - wealth: 8H / 12H named;
* - annual: the annual Tajika chart, houses of the running / next AD lords and
* of the year's Jupiter / Saturn transits, ingress and station dates only;
* - timing: Rahu / Ketu, Double Transit conclusions, Jupiter / Saturn SAV and
* 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.
*/
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,
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"] }),
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"],
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"] }),
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 }),
};
/** The formal vargas the engine materialises (varga_spectrum.formal). */
export const FORMAL_VARGA_CODES = [
"D1", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "D10",
"D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60",
] as const;
export const WESTERN_TECHNIQUE_IDS = [
"transits", "solar_return", "secondary_progressions", "solar_arc_directions",
"converse_secondary_progressions", "converse_solar_arc_directions", "midpoints",
"lunar_return", "transit_duration_scan", "parans",
] as const;
/**
* Sections outside the card that the one-shot lookup tool may return from the
* request's own calculation (D8). A closed list, so the model cannot ask for
* anything the server did not compute.
*/
export const EVIDENCE_LOOKUP_SECTIONS = [
"varga:D1", "varga:D2", "varga:D3", "varga:D4", "varga:D5", "varga:D6", "varga:D7",
"varga:D8", "varga:D9", "varga:D10", "varga:D11", "varga:D12", "varga:D16", "varga:D20",
"varga:D24", "varga:D27", "varga:D30", "varga:D40", "varga:D45", "varga:D60",
"varga:research_dn",
"varga:extended",
"western:natal",
"western:transits", "western:solar_return", "western:secondary_progressions",
"western:solar_arc_directions", "western:converse_secondary_progressions",
"western:converse_solar_arc_directions", "western:midpoints", "western:lunar_return",
"western:transit_duration_scan", "western:parans",
"yogas",
"ashtakavarga",
"shadbala",
"transits",
"chara_dasha",
"arudha_padas",
"chara_karakas",
"kp_cusps",
"gulika",
"kakshya",
"vimshottari_mahadashas",
"domain_thematic_evidence",
// Evidence card v2: optional per the astrologer's table, never on a card.
"karakamsha",
"dispositor_chains",
"inter_chart_linkage",
"argala",
"moon_transit",
] as const satisfies readonly (
| `varga:${typeof FORMAL_VARGA_CODES[number]}`
| "varga:research_dn"
| "varga:extended"
| "western:natal"
| `western:${typeof WESTERN_TECHNIQUE_IDS[number]}`
| "yogas" | "ashtakavarga" | "shadbala" | "transits" | "chara_dasha" | "arudha_padas"
| "chara_karakas" | "kp_cusps" | "gulika" | "kakshya" | "vimshottari_mahadashas"
| "domain_thematic_evidence"
| "karakamsha" | "dispositor_chains" | "inter_chart_linkage" | "argala" | "moon_transit"
)[];
export type EvidenceLookupSection = typeof EVIDENCE_LOOKUP_SECTIONS[number];
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
type JsonRecord = { [key: string]: Json };
export type EvidenceCardDomainSection = {
label: string;
vargas: JsonRecord;
houses: JsonRecord;
focus_planets?: string[];
arudha?: JsonRecord;
karakas?: JsonRecord;
yogas?: JsonRecord;
transits?: Json;
ashtakavarga?: JsonRecord;
shadbala?: Json;
sav_by_house?: JsonRecord;
slow_transits?: Json;
double_transit?: Json;
punarphoo?: Json;
day_night?: Json;
vivah_saham?: Json;
gender?: EvidenceCardGenderLabel;
annual_chart?: Json;
/** Why each annual house is on the card (AD lords' houses, the year's Jupiter / Saturn transits). */
house_basis?: JsonRecord;
/** The turn's other domain whose section already carries the vargas this section follows. */
vargas_follow?: ConsultationDomain;
/** Per-person index into this section (parents: mother / father). */
roles?: JsonRecord;
/**
* Present only when this domain's own base differs from the card's base
* (the first domain's). The same chart gives the same base; a difference is
* shown per domain instead of one side being presented as shared.
*/
base?: EvidenceCard["base"];
};
export type EvidenceCard = {
card_version: typeof EVIDENCE_CARD_VERSION;
domains: ConsultationDomain[];
base: {
natal: {
ascendant: Json;
houses: JsonRecord;
planets: JsonRecord;
functional_benefic_malefic: JsonRecord;
/** D9 lagna, each planet's D9 sign and dignity, Vargottama, D1↔D9 reversals. */
d9: JsonRecord;
};
timing: {
vimshottari: JsonRecord;
narayana: JsonRecord;
};
};
domain_sections: Partial<Record<ConsultationDomain, EvidenceCardDomainSection>>;
meta: {
backstage: typeof EVIDENCE_CARD_BACKSTAGE_LINE;
supplementable_sections: EvidenceLookupSection[];
gaps: string[];
};
};
export type EvidenceCardGenderLabel =
| typeof EVIDENCE_CARD_LABELS.genderUnknown
| typeof EVIDENCE_CARD_LABELS.genderFemale
| typeof EVIDENCE_CARD_LABELS.genderMale;
/** The card label for the current subject's gender; not filled → 「性别未知」. */
export function evidenceCardGenderLabel(gender: ProfileGender | null | undefined): EvidenceCardGenderLabel {
if (gender === "female") return EVIDENCE_CARD_LABELS.genderFemale;
if (gender === "male") return EVIDENCE_CARD_LABELS.genderMale;
return EVIDENCE_CARD_LABELS.genderUnknown;
}
/** Card-wide inputs that are not engine output (the bound subject's profile). */
export type EvidenceCardOptions = Readonly<{
/** The current subject's optional gender; null / undefined = not filled. */
gender?: ProfileGender | null;
}>;
/** One domain's inputs: the projected packet and the agent context behind it. */
export type EvidenceCardSource = Readonly<{
domain: ConsultationDomain;
/** `toModelOutput(...)` for this domain. */
packet: unknown;
/** `toAgentConsultationContext(...)` for this domain (engine values). */
context: unknown;
}>;
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function isJson(value: unknown): value is Json {
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJson);
if (typeof value === "object") return Object.values(value as object).every(isJson);
return false;
}
/** A JSON value copied as is, or undefined. Never reshaped. */
function copy(value: unknown): Json | undefined {
return value !== undefined && isJson(value) ? structuredClone(value) : undefined;
}
function pickKeys(source: unknown, keys: readonly string[]): JsonRecord {
const from = record(source);
const out: JsonRecord = {};
for (const key of keys) {
const value = copy(from[key]);
if (value !== undefined) out[key] = value;
}
return out;
}
function caseInsensitive(source: unknown, name: string): [string, unknown] | undefined {
const lowered = name.toLowerCase();
return Object.entries(record(source)).find(([key]) => key.toLowerCase() === lowered);
}
function claimEvidence(packet: unknown, category: string): Record<string, unknown> {
const cards = record(packet).claim_cards;
if (!Array.isArray(cards)) return {};
const card = cards.find((item) => record(item).category === category);
return record(record(card).evidence);
}
const PLANET_ROW_KEYS = ["sign", "house", "degree_in_sign", "nakshatra", "retrograde", "status"] as const;
const FUNCTIONAL_KEYS = [
"functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas", "owned_houses",
] as const;
const PERIOD_KEYS = ["lord", "start", "end"] as const;
const NARAYANA_PERIOD_KEYS = ["sign", "lord", "years", "start_age", "end_age"] as const;
const KARAKA_KEYS = ["planet", "degree_in_sign", "rank"] as const;
const ZODIAC_SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
] as const;
/** The sign and occupants of house `number` counted from a varga's own lagna. */
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))
.filter(([, value]) => value === sign)
.map(([name]) => name);
return { house: number, sign, occupants };
}
const ARUDHA_KEYS = ["sign", "lord"] as const;
/** The engine writes the Arudha Lagna as pada A1 (its own name: "Arudha Lagna (AL)"). */
const ARUDHA_ENGINE_KEY: Readonly<Record<string, string>> = { AL: "A1" };
const D9_ROW_KEYS = ["d9_sign", "d9_dignity", "vargottama"] as const;
const DOUBLE_TRANSIT_ROW_KEYS = ["layer", "target", "strength", "jupiter_pac", "saturn_pac"] as const;
const DOUBLE_TRANSIT_ALL_KEYS = ["house", "layer", "target", "strength"] as const;
const KARAKA_TARGET_KEYS = ["target", "jupiter_pac", "saturn_pac", "double_transit"] as const;
const ANNUAL_KEYS = [
"status", "varsha_year", "annual_lagna", "varshesha", "muntha", "mudda_dasha", "tajika_aspects", "boundary",
] as const;
function nonEmpty(value: JsonRecord) {
return Object.keys(value).length > 0;
}
function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
const ascendant = copy(natal.ascendant);
if (ascendant === undefined) gaps.push("base.ascendant");
const houses: JsonRecord = {};
for (const [number, row] of Object.entries(record(natal.houses))) {
const sign = record(row).sign;
if (typeof sign === "string") houses[number] = sign;
}
if (!nonEmpty(houses)) gaps.push("base.houses");
const planets: JsonRecord = {};
for (const [name, row] of Object.entries(record(natal.planets))) {
const picked = pickKeys(row, PLANET_ROW_KEYS);
if (nonEmpty(picked)) planets[name] = picked;
}
if (!nonEmpty(planets)) gaps.push("base.planets");
const functional = pickKeys(natal.functional_benefic_malefic, FUNCTIONAL_KEYS);
if (!nonEmpty(functional)) gaps.push("base.functional_benefic_malefic");
const summary = record(natal.d9_summary);
const d9: JsonRecord = {};
if (typeof summary.lagna === "string") {
d9.lagna = summary.lagna;
const lagnaVargottama = copy(summary.lagna_vargottama);
if (lagnaVargottama !== undefined) d9.lagna_vargottama = lagnaVargottama;
const rows: JsonRecord = {};
for (const [name, row] of Object.entries(record(summary.planets))) {
const picked = pickKeys(row, D9_ROW_KEYS);
if (nonEmpty(picked)) rows[name] = picked;
}
d9.planets = rows;
const reversals = copy(summary.reversals);
if (reversals !== undefined) d9.reversals = reversals;
} else {
gaps.push("base.d9");
}
return { ascendant: ascendant ?? null, houses, planets, functional_benefic_malefic: functional, d9 };
}
function baseTiming(timing: Record<string, unknown>, gaps: string[]) {
const sub = record(timing.dasha_sub_periods);
const current = record(sub.current);
const pratyantar = record(sub.pratyantar_dasha_timeline);
const vimshottari: JsonRecord = {};
const set = (key: string, value: unknown) => {
const picked = pickKeys(value, PERIOD_KEYS);
if (nonEmpty(picked)) vimshottari[key] = picked;
};
set("mahadasha", current.mahadasha);
set("antardasha", current.antardasha);
set("pratyantardasha", pratyantar.current);
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);
if (rows.length) vimshottari.antardashas_in_mahadasha = rows;
}
for (const key of ["mahadasha", "antardasha", "pratyantardasha"]) {
if (!vimshottari[key]) gaps.push(`base.vimshottari.${key}`);
}
const currentNarayana = record(record(timing.narayana_dasha).current_dasha);
const narayana: JsonRecord = {};
for (const level of ["md", "ad", "pd"] as const) {
const picked = pickKeys(currentNarayana[level], NARAYANA_PERIOD_KEYS);
if (nonEmpty(picked)) narayana[level] = picked;
}
const remaining = copy(currentNarayana.remaining_years);
if (remaining !== undefined) narayana.remaining_years = remaining;
if (!narayana.md) gaps.push("base.narayana.md");
return { vimshottari, narayana };
}
/** Vargas the timing section follows: the other domain's (D1 aside), else the spec default. */
export type EvidenceCardVargaPlan = Readonly<{ codes: readonly string[]; follow?: ConsultationDomain }>;
function uniqueNumbers(values: readonly unknown[]): number[] {
const out: number[] = [];
for (const value of values) if (typeof value === "number" && !out.includes(value)) out.push(value);
return out;
}
function ownedBy(base: EvidenceCard["base"], lord: unknown): number[] {
if (typeof lord !== "string") return [];
const houses = record(base.natal.functional_benefic_malefic.owned_houses)[lord];
return Array.isArray(houses) ? uniqueNumbers(houses) : [];
}
function domainSection(
domain: ConsultationDomain,
source: EvidenceCardSource,
base: EvidenceCard["base"],
gaps: string[],
vargaPlan?: EvidenceCardVargaPlan,
options: EvidenceCardOptions = {},
): EvidenceCardDomainSection {
const rule = EVIDENCE_CARD_SPECS[domain];
const natal = claimEvidence(source.packet, "natal_foundation");
const timing = claimEvidence(source.packet, "timing");
const context = record(source.context);
const localLayers = record(context.local_layers);
const chart = record(context.chart);
const formal = record(record(natal.varga_spectrum).formal);
const section: EvidenceCardDomainSection = {
label: consultationDomainDefinition(domain).label,
vargas: {},
houses: {},
};
for (const code of vargaPlan?.codes ?? rule.vargas) {
const chartRow = pickKeys(formal[code], ["lagna", "planets"]);
if (nonEmpty(chartRow)) section.vargas[code] = chartRow;
else gaps.push(`${domain}.varga.${code}`);
}
if (vargaPlan?.follow) section.vargas_follow = vargaPlan.follow;
const slow = record(timing.slow_transits);
let houses: number[] = [...rule.houses];
if (rule.annualHouses) {
const antardasha = record(base.timing.vimshottari.antardasha).lord;
const nextAntardasha = record(base.timing.vimshottari.next_antardasha).lord;
const inWindow = record(slow.houses_in_window);
const jupiter = Array.isArray(inWindow.Jupiter) ? uniqueNumbers(inWindow.Jupiter) : [];
const saturn = Array.isArray(inWindow.Saturn) ? uniqueNumbers(inWindow.Saturn) : [];
if (!Array.isArray(inWindow.Jupiter) || !Array.isArray(inWindow.Saturn)) gaps.push(`${domain}.transit_houses`);
const basis: JsonRecord = {};
if (typeof antardasha === "string") {
basis.antardasha_lord = antardasha;
basis.antardasha_houses = ownedBy(base, antardasha);
}
if (typeof nextAntardasha === "string") {
basis.next_antardasha_lord = nextAntardasha;
basis.next_antardasha_houses = ownedBy(base, nextAntardasha);
}
basis.jupiter_transit_houses = jupiter;
basis.saturn_transit_houses = saturn;
section.house_basis = basis;
houses = uniqueNumbers([...houses, ...ownedBy(base, antardasha), ...ownedBy(base, nextAntardasha), ...jupiter, ...saturn]);
}
const owned = record(base.natal.functional_benefic_malefic.owned_houses);
for (const number of houses) {
const sign = base.natal.houses[String(number)];
if (typeof sign !== "string") {
gaps.push(`${domain}.house.${number}`);
continue;
}
// Selection only: planets the projection places in this house, and the
// planets the functional layer lists as owning it.
const occupants = Object.entries(base.natal.planets)
.filter(([, row]) => record(row).house === number)
.map(([name]) => name);
const lords = Object.entries(owned)
.filter(([, houses]) => Array.isArray(houses) && houses.includes(number))
.map(([name]) => name);
section.houses[String(number)] = { sign, occupants, lords };
}
if (rule.planets.length) {
const focus: string[] = [];
for (const name of rule.planets) {
const hit = caseInsensitive(base.natal.planets, name);
if (hit) focus.push(hit[0]);
else gaps.push(`${domain}.planet.${name}`);
}
section.focus_planets = focus;
}
if (rule.arudha.length) {
const padas = record(record(natal.arudha_padas).padas);
const arudha: JsonRecord = {};
for (const code of rule.arudha) {
const picked = pickKeys(padas[ARUDHA_ENGINE_KEY[code] ?? code], ARUDHA_KEYS);
if (nonEmpty(picked)) arudha[code] = picked;
else gaps.push(`${domain}.arudha.${code}`);
}
section.arudha = arudha;
}
if (rule.karakas.length) {
const karakas: JsonRecord = {};
const table = record(record(localLayers.jaimini).chara_karakas);
for (const code of rule.karakas) {
const picked = pickKeys(table[code], KARAKA_KEYS);
if (nonEmpty(picked)) karakas[code] = picked;
else gaps.push(`${domain}.karaka.${code}`);
}
section.karakas = karakas;
}
if (rule.roles.length) {
const table = record(record(localLayers.jaimini).chara_karakas);
const roles: JsonRecord = {};
for (const role of rule.roles) {
const houseRow = record(section.houses[String(role.house)]);
// The role names its karakas; their values sit once in section.karakas.
// A required one missing there is already a gap; an optional one (PiK)
// joins section.karakas only when the engine's scheme has it.
const karakas = role.karakas.filter((code) => section.karakas?.[code] !== undefined);
for (const code of role.optionalKarakas) {
const picked = pickKeys(table[code], KARAKA_KEYS);
if (!nonEmpty(picked)) continue;
section.karakas = { ...(section.karakas ?? {}), [code]: picked };
karakas.push(code);
}
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)),
};
if (karakas.length) entry.karakas = karakas;
if (role.varga) {
const varga = vargaHouse(section.vargas[role.varga], role.house);
if (varga) entry[`${role.varga.toLowerCase()}_house`] = varga;
else gaps.push(`${domain}.role.${role.key}.${role.varga}`);
}
roles[role.key] = entry;
}
section.roles = roles;
}
if (rule.yogas) {
const yogas = record(natal.yogas);
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") {
section.yogas = {
...(typeof yogas.count === "number" ? { count: yogas.count } : {}),
names,
...(boundaries.length ? { claim_boundaries: boundaries } : {}),
};
} else {
gaps.push(`${domain}.yogas`);
}
}
if (rule.transits) {
const transits = copy(timing.transits);
if (transits !== undefined) section.transits = transits;
else gaps.push(`${domain}.transits`);
}
if (rule.ashtakavarga) {
const table = record(localLayers.ashtakavarga);
const houseScores = record(table.house_scores);
const byHouse: JsonRecord = {};
for (const number of rule.houses) {
const row = pickKeys(houseScores[`house_${number}`], ["sign", "sav_score", "level"]);
if (nonEmpty(row)) byHouse[String(number)] = row;
}
const strongest = copy(table.strongest_signs);
const weakest = copy(table.weakest_signs);
if (nonEmpty(byHouse) || strongest !== undefined) {
section.ashtakavarga = {
sav_by_house: byHouse,
...(strongest !== undefined ? { strongest_signs: strongest } : {}),
...(weakest !== undefined ? { weakest_signs: weakest } : {}),
};
} else {
gaps.push(`${domain}.ashtakavarga`);
}
}
if (rule.shadbala) {
const shadbala = copy(chart.shadbala);
if (shadbala !== undefined && shadbala !== null) section.shadbala = shadbala;
else gaps.push(`${domain}.shadbala`);
}
if (rule.savHouses.length) {
const houseScores = record(record(localLayers.ashtakavarga).house_scores);
const byHouse: JsonRecord = {};
for (const number of rule.savHouses) {
const row = pickKeys(houseScores[`house_${number}`], ["sign", "sav_score", "level"]);
if (nonEmpty(row)) byHouse[String(number)] = row;
else gaps.push(`${domain}.sav.${number}`);
}
section.sav_by_house = byHouse;
}
if (rule.slowTransits !== "none") {
const current = record(slow.current);
const rows: JsonRecord = {};
const bodies = rule.slowTransits === "sav" ? ["Jupiter", "Saturn"] : ["Jupiter", "Saturn", "Rahu", "Ketu"];
const keys = {
sav: ["sign", "natal_house", "sav"],
nodes_sav_bav: ["sign", "degree_in_sign", "retrograde", "natal_house", "sav", "bav"],
key_dates: ["sign", "natal_house"],
}[rule.slowTransits];
for (const body of bodies) {
const picked = pickKeys(current[body], keys);
if (nonEmpty(picked)) rows[body] = picked;
else gaps.push(`${domain}.slow_transits.${body}`);
}
const dates = Array.isArray(slow.key_dates) ? slow.key_dates.map(record) : [];
if (rule.slowTransits === "sav") {
section.slow_transits = rows;
} else if (rule.slowTransits === "nodes_sav_bav") {
section.slow_transits = {
current: rows,
node_ingresses: dates.filter((row) => row.body === "rahu" || row.body === "ketu")
.map((row) => copy(row))
.filter((row): row is Json => row !== undefined),
...(copy(slow.window) !== undefined ? { window: copy(slow.window)! } : {}),
};
} else {
section.slow_transits = {
current: rows,
key_dates: dates.map((row) => copy(row)).filter((row): row is Json => row !== undefined),
...(copy(slow.window) !== undefined ? { window: copy(slow.window)! } : {}),
};
}
}
if (rule.doubleTransit !== "none") {
const double = record(timing.double_transit);
const conclusions = Array.isArray(double.conclusions) ? double.conclusions.map(record) : [];
if (!Array.isArray(double.conclusions)) {
gaps.push(`${domain}.double_transit`);
} else if (rule.doubleTransit === "seventh_and_karakas") {
const karakas = record(double.karaka_targets);
const dk = pickKeys(karakas.DK, KARAKA_TARGET_KEYS);
const ul = pickKeys(karakas.UL, KARAKA_TARGET_KEYS);
section.double_transit = {
...(typeof double.date === "string" ? { date: double.date } : {}),
house_7: conclusions.filter((row) => row.house === 7).map((row) => pickKeys(row, DOUBLE_TRANSIT_ROW_KEYS)),
...(nonEmpty(dk) ? { DK: dk } : {}),
...(nonEmpty(ul) ? { UL: ul } : {}),
...(typeof record(karakas.DK).method === "string" ? { karaka_method: record(karakas.DK).method as string } : {}),
...(typeof double.boundary === "string" ? { boundary: double.boundary } : {}),
};
if (!nonEmpty(dk)) gaps.push(`${domain}.double_transit.DK`);
if (!nonEmpty(ul)) gaps.push(`${domain}.double_transit.UL`);
} else {
section.double_transit = {
...(typeof double.date === "string" ? { date: double.date } : {}),
...(copy(double.active_houses) !== undefined ? { active_houses: copy(double.active_houses)! } : {}),
conclusions: conclusions.map((row) => pickKeys(row, DOUBLE_TRANSIT_ALL_KEYS)),
...(typeof double.boundary === "string" ? { boundary: double.boundary } : {}),
};
}
}
if (rule.punarphoo) {
const punarphoo = record(natal.punarphoo);
if (typeof punarphoo.natal_present === "boolean") {
section.punarphoo = {
...pickKeys(punarphoo, ["status", "natal_present", "orb_deg"]),
saturn_return_to_natal_moon: pickKeys(punarphoo.saturn_return_to_natal_moon, ["active", "orb_deg"]),
...pickKeys(punarphoo, ["claim_boundary"]),
};
} else {
gaps.push(`${domain}.punarphoo`);
}
}
if (rule.dayNight) {
const dayNight = pickKeys(natal.day_night, ["is_day"]);
if (nonEmpty(dayNight)) section.day_night = dayNight;
else gaps.push(`${domain}.day_night`);
}
if (rule.vivahSaham) {
const vivah = pickKeys(natal.vivah_saham, ["longitude", "sign", "degree_in_sign", "house", "natal_conjuncts"]);
if (typeof vivah.house === "number") section.vivah_saham = vivah;
else gaps.push(`${domain}.vivah_saham`);
}
if (rule.gender) section.gender = evidenceCardGenderLabel(options.gender);
if (rule.annualChart) {
const annual = record(timing.annual_tajika);
if (annual.status === "parameter_sensitive") {
section.annual_chart = pickKeys(annual, ANNUAL_KEYS);
} else {
// Blocked or not attached: say so; never put natal data in its place.
if (!Object.keys(annual).length) gaps.push(`${domain}.annual_chart`);
section.annual_chart = {
status: EVIDENCE_CARD_LABELS.annualNotAttached,
...(typeof annual.reason === "string" ? { reason: annual.reason } : {}),
};
}
}
return section;
}
function onCard(card: Pick<EvidenceCard, "domain_sections" | "base">, section: EvidenceLookupSection) {
const sections = Object.values(card.domain_sections);
if (section.startsWith("varga:")) {
const code = section.slice("varga:".length);
// The base D9 summary already holds the D9 lagna and every planet's D9 sign.
if (code === "D9" && typeof card.base.natal.d9.lagna === "string") return true;
return sections.some((item) => item && code in item.vargas);
}
// Chara Dasha, KP and the v2 optional layers are never on a card.
const key = ({
yogas: "yogas",
ashtakavarga: "ashtakavarga",
shadbala: "shadbala",
transits: "transits",
} as const)[section as "yogas"];
return key ? sections.some((item) => item && item[key] !== undefined) : false;
}
/**
* Build the card for up to two domains (the existing per-turn cap). The base
* section is taken once, from the first domain's sources; it is the same chart.
*/
export function buildEvidenceCard(
sources: readonly EvidenceCardSource[],
options: EvidenceCardOptions = {},
): EvidenceCard {
if (sources.length === 0) throw new Error("evidence_card_needs_a_domain");
const gaps: string[] = [];
const first = sources[0]!;
const base = {
natal: baseNatal(claimEvidence(first.packet, "natal_foundation"), gaps),
timing: baseTiming(claimEvidence(first.packet, "timing"), gaps),
};
const domainSections: EvidenceCard["domain_sections"] = {};
const domains: ConsultationDomain[] = [];
const planFor = (domain: ConsultationDomain): EvidenceCardVargaPlan | undefined => {
if (!EVIDENCE_CARD_SPECS[domain].followVargas) return undefined;
const other = sources.map((item) => item.domain).find((item) => (
!EVIDENCE_CARD_SPECS[item].followVargas
&& EVIDENCE_CARD_SPECS[item].vargas.some((code) => code !== "D1")
));
if (!other) return undefined;
// D1 stays; the other section already carries the followed vargas.
return {
codes: EVIDENCE_CARD_SPECS[domain].vargas.filter((code) => code === "D1"),
follow: other,
};
};
for (const source of sources) {
if (domains.includes(source.domain)) continue;
domains.push(source.domain);
let own = base;
if (source !== first) {
const ownGaps: string[] = [];
const candidate = {
natal: baseNatal(claimEvidence(source.packet, "natal_foundation"), ownGaps),
timing: baseTiming(claimEvidence(source.packet, "timing"), ownGaps),
};
if (JSON.stringify(candidate) !== JSON.stringify(base)) {
own = candidate;
gaps.push(`${source.domain}.base_differs`, ...ownGaps.map((gap) => `${source.domain}.${gap}`));
}
}
const section = domainSection(source.domain, source, own, gaps, planFor(source.domain), options);
if (own !== base) section.base = own;
domainSections[source.domain] = section;
}
const card: EvidenceCard = {
card_version: EVIDENCE_CARD_VERSION,
domains,
base,
domain_sections: domainSections,
meta: {
backstage: EVIDENCE_CARD_BACKSTAGE_LINE,
supplementable_sections: [],
gaps,
},
};
card.meta.supplementable_sections = EVIDENCE_LOOKUP_SECTIONS.filter((section) => !onCard(card, section));
return card;
}
export function evidenceCardTokenEstimate(chars: number) {
return Math.round(chars / EVIDENCE_CARD_CHARS_PER_TOKEN);
}
type FieldValue = string | number | boolean | null;
/**
* The card's scalar leaves by field id. A field id is the card path with the
* domain name dropped (`base.planets.Moon.sign`, `domain.vargas.D12.lagna`),
* so ids aggregate across domains; array positions are dropped as well.
*/
export function evidenceCardFieldValues(card: EvidenceCard): Map<string, FieldValue[]> {
const out = new Map<string, FieldValue[]>();
const walk = (value: unknown, path: string) => {
if (value === null || typeof value !== "object") {
if (value === undefined) return;
const list = out.get(path) ?? [];
list.push(value as FieldValue);
out.set(path, list);
return;
}
if (Array.isArray(value)) {
for (const item of value) walk(item, path);
return;
}
for (const [key, item] of Object.entries(value)) walk(item, path ? `${path}.${key}` : key);
};
walk(card.base, "base");
for (const section of Object.values(card.domain_sections)) {
if (!section) continue;
const { label: _label, ...rest } = section;
void _label;
walk(rest, "domain");
}
return out;
}
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const PLANET_NAMES = new Set(["sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn", "rahu", "ketu"]);
/**
* Which card fields the answer quoted (research R5). A field counts only when
* its value is an ISO date, a degree (a number with a fractional part) or a
* planet-in-sign phrase of at least 8 characters (`Moon in Pisces`), and that
* text appears verbatim in the answer. Bare planet or sign names do not count.
* Only field ids are returned; the matched text is not kept.
*/
export function citedEvidenceCardFields(card: EvidenceCard, answer: string): string[] {
if (!answer) return [];
const cited = new Set<string>();
for (const [fieldId, values] of evidenceCardFieldValues(card)) {
for (const value of values) {
if (typeof value === "string" && ISO_DATE.test(value) && answer.includes(value)) {
cited.add(fieldId);
} else if (typeof value === "number" && !Number.isInteger(value) && answer.includes(String(value))) {
cited.add(fieldId);
} else if (typeof value === "string" && fieldId.endsWith(".sign")) {
const parts = fieldId.split(".");
const planet = parts[parts.length - 2] ?? "";
const phrase = `${planet} in ${value}`;
if (PLANET_NAMES.has(planet.toLowerCase()) && phrase.length >= 8 && answer.includes(phrase)) cited.add(fieldId);
} else if (typeof value === "string" && /\.planets\.[A-Za-z]+$/.test(fieldId)) {
// Varga planets map a planet straight to its sign.
const planet = fieldId.slice(fieldId.lastIndexOf(".") + 1);
const phrase = `${planet} in ${value}`;
if (PLANET_NAMES.has(planet.toLowerCase()) && phrase.length >= 8 && answer.includes(phrase)) cited.add(fieldId);
}
}
}
return [...cited].sort().slice(0, 64);
}