feat(consult): evidence card v2 per the astrologer's review

TASK-consult-evidence-card-v2-20260927 T3. Card version evidence-card-v2.

- Base section (every card): the engine's D9 summary (D9 lagna, each
  planet's D9 sign and dignity, Vargottama, D1/D9 reversals); D9 houses and
  aspects stay out.
- Career: + AL (engine pada A1), 10H SAV, SAV of the Jupiter / Saturn
  transit signs.
- Marriage: + 5H / 5L, day / night, Punarphoo (observation_only), Double
  Transit on 7H / 7L (house-7 run) and DK / UL, Vivah Saham, gender unknown.
- Wealth: 8H / 12H named.
- Annual: the annual Tajika chart verbatim (parameter_sensitive), or
  年盘未接入 when the pack is blocked / not attached (never natal data);
  houses 1 + running / next AD lords' houses + the year's Jupiter / Saturn
  transit houses, with their basis; ingress / station dates only.
- Timing: Rahu / Ketu with ingress dates, Jupiter / Saturn SAV and BAV,
  Double Transit conclusions (no degrees); vargas follow the turn's other
  domain, D9 when alone.
- Chara Dasha leaves every card; KP is never on a card. The lookup enum adds
  karakamsha, dispositor_chains, inter_chart_linkage, argala, moon_transit;
  a KP lookup travels with its blocked note.
- System prompt and tool descriptions name the D9 summary, the
  parameter_sensitive / observation_only tags, 年盘未接入 and the lookups.
- Telemetry card version / agentVersion bumped to v2 (three-column notes in
  the touched tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-27 16:44:19 +08:00
co-authored by Claude Opus 5.5
parent 33739e3789
commit db17074430
11 changed files with 696 additions and 41 deletions
+1 -1
View File
@@ -958,7 +958,7 @@ export async function POST(request: Request) {
if (evidenceCard) {
logAgentObservability({
runId: requestId,
agentVersion: "consultation-evidence-card-v1",
agentVersion: "consultation-evidence-card-v2",
evidenceCard,
});
}
+1 -1
View File
@@ -129,7 +129,7 @@ export const EVIDENCE_CARD_TELEMETRY_FIELDS = [
export const evidenceCardTelemetrySchema = z.object({
domains: z.array(consultationDomainSchema).min(1).max(2),
cardVersion: z.literal("evidence-card-v1"),
cardVersion: z.literal("evidence-card-v2"),
cardChars: countSchema,
cardTokenEstimate: countSchema,
citedFieldIds: z.array(z.string().min(1).max(120).regex(/^[A-Za-z][A-Za-z0-9._-]*$/, "invalid card field id")).max(64),
@@ -44,6 +44,11 @@ const LOOKUP_SECTION_LABELS: Readonly<Record<string, string>> = {
kakshya: "Kakshya",
vimshottari_mahadashas: "全部大运起止",
domain_thematic_evidence: "这个主题的证据明细",
karakamsha: "灵魂星在九分盘的落点(Karakamsha)",
dispositor_chains: "宫主链",
inter_chart_linkage: "本命、九分盘、十分盘的联动",
argala: "门闩(Argala)",
moon_transit: "月亮行运",
};
/** Plain words for one evidence-lookup section (TASK-consult-evidence-card-20260927 T5). */
+294 -28
View File
@@ -3,7 +3,8 @@ import { consultationDomainDefinition } from "./consultation-domain-registry.ts"
/**
* The evidence card: what the answer-writing model reads about the chart
* (TASK-consult-evidence-card-20260927, D1).
* (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
@@ -17,12 +18,29 @@ import { consultationDomainDefinition } from "./consultation-domain-registry.ts"
* 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.
* 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-v1" as const;
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 product does not record gender yet (TASK-consult-gender-optional follows). */
genderUnknown: "性别未知",
/** The annual pack could not be produced reliably; natal data is never put in its place. */
annualNotAttached: "年盘未接入",
} 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 = "全网资料核对、真实案例校准:未做,置信度封顶";
@@ -40,7 +58,27 @@ export type EvidenceCardDomainSpec = Readonly<{
transits: boolean;
ashtakavarga: boolean;
shadbala: boolean;
charaDasha: 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;
genderUnknown: 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;
}>;
const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSpec, "vargas">): EvidenceCardDomainSpec => ({
@@ -52,31 +90,56 @@ const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSp
transits: false,
ashtakavarga: false,
shadbala: false,
charaDasha: false,
savHouses: [],
slowTransits: "none",
doubleTransit: "none",
punarphoo: false,
dayNight: false,
vivahSaham: false,
genderUnknown: false,
annualChart: false,
annualHouses: false,
followVargas: false,
...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. Health
* carries D30 and education D5 as in the Python focus techniques. The draft has
* no timing / annual card: those two follow the registry and the must-use
* layers (Vimshottari + Narayana are in every base section; D1 / D9 as the
* divisional check, transits, Chara Dasha).
* 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, gender unknown;
* - 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"], karakas: ["AmK"], yogas: true, transits: true, shadbala: true }),
marriage: spec({ vargas: ["D9"], houses: [7], arudha: ["UL", "A7"], karakas: ["DK"], planets: ["Venus", "Jupiter"], transits: true }),
wealth: spec({ vargas: ["D2", "D11"], houses: [2, 11, 9, 5], yogas: true, ashtakavarga: true, shadbala: true }),
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", genderUnknown: 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"] }),
children: spec({ vargas: ["D7"], houses: [5], karakas: ["PK"], planets: ["Jupiter"] }),
annual: spec({ vargas: ["D1"], houses: [1], transits: true, charaDasha: true }),
timing: spec({ vargas: ["D1", "D9"], planets: ["Saturn", "Jupiter"], transits: true, charaDasha: true }),
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 }),
};
@@ -120,6 +183,12 @@ export const EVIDENCE_LOOKUP_SECTIONS = [
"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"
@@ -129,6 +198,7 @@ export const EVIDENCE_LOOKUP_SECTIONS = [
| "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];
@@ -147,7 +217,18 @@ export type EvidenceCardDomainSection = {
transits?: Json;
ashtakavarga?: JsonRecord;
shadbala?: Json;
chara_dasha?: Json;
sav_by_house?: JsonRecord;
slow_transits?: Json;
double_transit?: Json;
punarphoo?: Json;
day_night?: Json;
vivah_saham?: Json;
gender?: typeof EVIDENCE_CARD_LABELS.genderUnknown;
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;
/**
* 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
@@ -165,6 +246,8 @@ export type EvidenceCard = {
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;
@@ -235,6 +318,15 @@ 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 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;
@@ -257,7 +349,24 @@ function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
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");
return { ascendant: ascendant ?? null, houses, planets, functional_benefic_malefic: functional };
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[]) {
@@ -293,11 +402,27 @@ function baseTiming(timing: Record<string, unknown>, gaps: string[]) {
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,
): EvidenceCardDomainSection {
const rule = EVIDENCE_CARD_SPECS[domain];
const natal = claimEvidence(source.packet, "natal_foundation");
@@ -311,13 +436,37 @@ function domainSection(
vargas: {},
houses: {},
};
for (const code of rule.vargas) {
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 rule.houses) {
for (const number of houses) {
const sign = base.natal.houses[String(number)];
if (typeof sign !== "string") {
gaps.push(`${domain}.house.${number}`);
@@ -346,7 +495,7 @@ function domainSection(
const padas = record(record(natal.arudha_padas).padas);
const arudha: JsonRecord = {};
for (const code of rule.arudha) {
const picked = pickKeys(padas[code], ARUDHA_KEYS);
const picked = pickKeys(padas[ARUDHA_ENGINE_KEY[code] ?? code], ARUDHA_KEYS);
if (nonEmpty(picked)) arudha[code] = picked;
else gaps.push(`${domain}.arudha.${code}`);
}
@@ -382,11 +531,6 @@ function domainSection(
if (transits !== undefined) section.transits = transits;
else gaps.push(`${domain}.transits`);
}
if (rule.charaDasha) {
const chara = copy(timing.chara_dasha);
if (chara !== undefined) section.chara_dasha = chara;
else gaps.push(`${domain}.chara_dasha`);
}
if (rule.ashtakavarga) {
const table = record(localLayers.ashtakavarga);
const houseScores = record(table.house_scores);
@@ -412,21 +556,130 @@ function domainSection(
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.genderUnknown) section.gender = EVIDENCE_CARD_LABELS.genderUnknown;
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">, section: EvidenceLookupSection) {
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",
chara_dasha: "chara_dasha",
} as const)[section as "yogas"];
return key ? sections.some((item) => item && item[key] !== undefined) : false;
}
@@ -445,6 +698,19 @@ export function buildEvidenceCard(sources: readonly EvidenceCardSource[]): Evide
};
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);
@@ -460,7 +726,7 @@ export function buildEvidenceCard(sources: readonly EvidenceCardSource[]): Evide
gaps.push(`${source.domain}.base_differs`, ...ownGaps.map((gap) => `${source.domain}.${gap}`));
}
}
const section = domainSection(source.domain, source, own, gaps);
const section = domainSection(source.domain, source, own, gaps, planFor(source.domain));
if (own !== base) section.base = own;
domainSections[source.domain] = section;
}
+26 -3
View File
@@ -22,6 +22,7 @@ import {
citedEvidenceCardFields,
EVIDENCE_LOOKUP_SECTIONS,
evidenceCardTokenEstimate,
EVIDENCE_LOOKUP_KP_NOTE,
type EvidenceCard,
type EvidenceLookupSection,
} from "../lib/consultation-evidence-card.ts";
@@ -923,13 +924,35 @@ export function readEvidenceLookupSection(
if (section === "varga:extended") return spectrum.extended;
if (section === "western:natal") return pickDefined(western, ["zodiac", "house_system", "natal", "boundary"]);
if (section.startsWith("western:")) return lookupRecord(western.techniques)[section.slice("western:".length)];
const native = lookupRecord(layers.native_layers);
const nativeLayer = (name: string) => {
const layer = lookupRecord(native[name]);
return Object.keys(layer).length && layer.status !== "blocked" ? layer : undefined;
};
switch (section) {
case "yogas":
case "arudha_padas":
case "kp_cusps":
case "gulika":
case "kakshya":
return natal[section];
case "kp_cusps":
// KP stays off every card; when looked up it travels with its boundary.
return natal.kp_cusps === undefined ? undefined : { kp_cusps: natal.kp_cusps, note: EVIDENCE_LOOKUP_KP_NOTE };
case "karakamsha":
case "moon_transit":
return nativeLayer(section);
case "dispositor_chains": {
const layer = nativeLayer(section);
return layer ? pickDefined(layer, ["chains", "boundary"]) : undefined;
}
case "inter_chart_linkage": {
const layer = nativeLayer(section);
return layer ? pickDefined(layer, ["planets", "boundary"]) : undefined;
}
case "argala": {
const layer = nativeLayer(section);
return layer ? pickDefined(layer, ["method", "summary", "houses"]) : undefined;
}
case "transits":
case "chara_dasha":
return timing[section];
@@ -970,7 +993,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
let lookupCache: EvidenceLookupCache | null = null;
const consultationTool = createTool({
id: "run-jyotish-consultation",
description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Questions about one's parents (父母) use parents and questions about one's children (子女) use children; family is only for the household as a whole (家里、家庭氛围). Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with each domain's own contract in consultations. claim_cards is the evidence card: the base chart (ascendant, house signs, placements, functional benefics/malefics, the running Vimshottari and Narayana periods) and a section per domain; evidence_card lists what else this calculation holds. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`,
description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Questions about one's parents (父母) use parents and questions about one's children (子女) use children; family is only for the household as a whole (家里、家庭氛围). Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with each domain's own contract in consultations. claim_cards is the evidence card: the base chart (ascendant, house signs, placements, functional benefics/malefics, the D9 summary with dignities, Vargottama and D1/D9 reversals, the running Vimshottari and Narayana periods) and a section per domain; evidence_card lists what else this calculation holds. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`,
inputSchema: consultationToolInputSchema,
execute: async (input, context) => {
const requestedDomains = canonicalDomainPlan(input, ctx);
@@ -1110,7 +1133,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
});
const lookupTool = createTool({
id: CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID,
description: `Read one section of this request's finished chart calculation that is not on the evidence card, for example a varga the card does not carry (varga:D60), a Western layer (western:solar_return), or the yoga details. It never calculates: it returns what run-jyotish-consultation already computed, or status unavailable. At most ${MAX_EVIDENCE_LOOKUPS_PER_TURN} call per turn; a second call is refused. Call it before writing any answer text, and only when the question needs that section; the answer contract and card come from run-jyotish-consultation.`,
description: `Read one section of this request's finished chart calculation that is not on the evidence card, for example a varga the card does not carry (varga:D60), a Western layer (western:solar_return), the yoga details, Chara Dasha, Karakamsha, Argala, the dispositor chain, the D1/D9/D10 linkage, the Moon transit, or KP (still blocked: reference only, never a basis for a conclusion). It never calculates: it returns what run-jyotish-consultation already computed, or status unavailable. At most ${MAX_EVIDENCE_LOOKUPS_PER_TURN} call per turn; a second call is refused. Call it before writing any answer text, and only when the question needs that section; the answer contract and card come from run-jyotish-consultation.`,
inputSchema: evidenceLookupInputSchema,
execute: async (input, context) => {
const startedAt = Date.now();
+1 -1
View File
@@ -25,7 +25,7 @@ Call run-jyotish-consultation before answering every turn, including short follo
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. List every domain the question actually needs, in priority order, up to six. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum. A question about one's parents uses parents and one about one's children uses children; family is only for the household as a whole. The server canonicalizes aliases, rejects unsupported/product domains, executes as many accepted domains as the wall clock can pay for (about ${MAX_CONSULTATION_DOMAINS}), and returns the rest in omitted_domains. The actual executed domains are in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
The tool result's methodology field is the domain checklist for the routes that actually ran, quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written; consultations carries each domain's own contract. Never treat an absent top-level field as permission to answer without a contract.
The evidence card is the chart evidence for this answer. claim_cards holds it: natal_foundation (ascendant, the twelve house signs, planet placements with degrees, functional benefics/malefics with the houses each planet rules), timing (the running Vimshottari mahadasha, antardasha and pratyantardasha with dates, the antardashas inside the mahadasha, the running Narayana period) and domain (one section per executed domain: its vargas, houses with occupants and lords, focus planets and the layers that domain uses). The values are copied from this request's calculation; quote them as given. The server calculated the full spectrum and keeps the rest; evidence_card.supplementable_sections names what else this calculation holds. When the question genuinely needs one of those sections (for example a varga the card does not carry, a Western layer, or the yoga details), call read-consultation-evidence once, before writing any answer text; it returns that section of this calculation or says it is unavailable, and it never calculates. Answer from the card, plus that one lookup when you used it.
The evidence card is the chart evidence for this answer. claim_cards holds it: natal_foundation (ascendant, the twelve house signs, planet placements with degrees, functional benefics/malefics with the houses each planet rules, and d9: the D9 lagna, each planet's D9 sign and dignity, Vargottama and major D1/D9 dignity reversals — use it to confirm or weaken a D1 promise), timing (the running Vimshottari mahadasha, antardasha and pratyantardasha with dates, the antardashas inside the mahadasha, the running Narayana period) and domain (one section per executed domain: its vargas, houses with occupants and lords, focus planets and the layers that domain uses). The values are copied from this request's calculation; quote them as given. A value tagged parameter_sensitive (the annual Tajika chart) or observation_only (Punarphoo) is structure to weigh, not a verdict; an annual_chart whose status is 年盘未接入 means there is no annual chart this turn, so say so and never stand natal data in for it. Chara Dasha and KP are never on the card; KP exact cusps are still blocked, so a KP lookup is reference only and never the basis of a conclusion. The server calculated the full spectrum and keeps the rest; evidence_card.supplementable_sections names what else this calculation holds. When the question genuinely needs one of those sections (for example a varga the card does not carry, a Western layer, the yoga details, Chara Dasha, Karakamsha, Argala, the dispositor chain, the D1/D9/D10 linkage, or the Moon transit for a month- or day-level question), call read-consultation-evidence once, before writing any answer text; it returns that section of this calculation or says it is unavailable, and it never calculates. Answer from the card, plus that one lookup when you used it.
evidence_card.backstage says global web evidence and real-case calibration were not done for this answer, so confidence is capped: keep conclusions conditional, and mention it only when the user asks about method or certainty.
When omitted_domains is non-empty, do not answer those domains and never present the reply as covering the whole plan. Stay with what was calculated. Do not announce a skipped-domain inventory or say this round was incomplete unless the user asked about coverage.
Activity, progress, tool status, and execution receipts are server-owned. Never imitate data-jyotish-activity, activity events, tool-started/tool-completed messages, or receipts in the answer text.
@@ -13,6 +13,7 @@ import {
buildEvidenceCard,
citedEvidenceCardFields,
EVIDENCE_CARD_BACKSTAGE_LINE,
EVIDENCE_CARD_LABELS,
EVIDENCE_CARD_SPECS,
EVIDENCE_CARD_VERSION,
evidenceCardFieldValues,
@@ -145,10 +146,16 @@ test("every card value is a value the projection or the engine already holds (no
}
return into;
}
// 原值: every leaf must be an engine / projection value.
// 新值: the same, except the two labels the v2 card writes itself
// (EVIDENCE_CARD_LABELS: 性别未知, 年盘未接入).
// 原因: TASK-consult-evidence-card-v2-20260927 decision table mandates both labels.
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) {
const input = sources(chart.workflow, domain);
const known = scalars({ packet: input.packet, context: input.context });
for (const label of cardLabels) known.add(label);
const card = buildEvidenceCard([input]);
for (const [fieldId, values] of evidenceCardFieldValues(card)) {
for (const value of values) {
@@ -44,7 +44,8 @@ test("the record has exactly the six approved fields", async () => {
assert.ok(record);
assert.deepEqual(Object.keys(record).sort(), [...EVIDENCE_CARD_TELEMETRY_FIELDS].sort());
assert.deepEqual(record.domains, ["parents"]);
assert.equal(record.cardVersion, "evidence-card-v1");
// 原值: "evidence-card-v1" / 新值: "evidence-card-v2" / 原因: TASK-consult-evidence-card-v2-20260927 T3 卡版本升级
assert.equal(record.cardVersion, "evidence-card-v2");
assert.equal(record.cardChars, state.evidenceCardChars);
assert.equal(record.cardTokenEstimate, Math.round(record.cardChars / 3.5));
assert.deepEqual(record.citedFieldIds, []);
@@ -52,7 +53,7 @@ test("the record has exactly the six approved fields", async () => {
});
test("the schema is strict: no extra field, no free text in field ids", () => {
const valid = { domains: ["parents"], cardVersion: "evidence-card-v1", cardChars: 10, cardTokenEstimate: 3, citedFieldIds: ["base.natal.ascendant.sign"], feedback: "up" };
const valid = { domains: ["parents"], cardVersion: "evidence-card-v2", cardChars: 10, cardTokenEstimate: 3, citedFieldIds: ["base.natal.ascendant.sign"], feedback: "up" };
assert.equal(evidenceCardTelemetrySchema.safeParse(valid).success, true);
for (const extra of ["question", "answer", "sessionId", "userId", "birthDate", "matched"]) {
assert.equal(evidenceCardTelemetrySchema.safeParse({ ...valid, [extra]: "x" }).success, false, extra);
@@ -70,7 +71,7 @@ test("the logged line keeps field ids and drops every word of the user's text",
const answer = `${secrets[0]}(${secrets[1]},生日 ${secrets[2]})问:${secrets[3]}。子运从 ${pdStart} 开始。`;
const lines: AgentObservabilityEvent[] = [];
const log = createAgentObservabilityLogger((event) => lines.push(event));
log({ runId: "req-telemetry", agentVersion: "consultation-evidence-card-v1", evidenceCard: consultationEvidenceCardTelemetry(state, answer) });
log({ runId: "req-telemetry", agentVersion: "consultation-evidence-card-v2", evidenceCard: consultationEvidenceCardTelemetry(state, answer) });
const text = JSON.stringify(lines);
for (const secret of secrets) assert.equal(text.includes(secret), false, secret);
assert.equal(text.includes(pdStart), false, "the matched date is not kept");
@@ -82,7 +83,8 @@ test("the logged line keeps field ids and drops every word of the user's text",
test("the consult route logs the record once per natal turn, without session or user id", () => {
assert.match(route, /const evidenceCard = consultationEvidenceCardTelemetry\(state, answerForTelemetry\);/);
const block = route.slice(route.indexOf("const evidenceCard = consultationEvidenceCardTelemetry"), route.indexOf("const settleRun = async"));
assert.match(block, /agentVersion: "consultation-evidence-card-v1"/);
// 原值: consultation-evidence-card-v1 / 新值: v2 / 原因: 卡 v2(TASK-consult-evidence-card-v2-20260927 T3)
assert.match(block, /agentVersion: "consultation-evidence-card-v2"/);
assert.doesNotMatch(block, /sessionId|userId/);
const natal = route.slice(route.indexOf("const agent = getJyotishAgent(selectedModel, agentContext);"));
assert.match(natal, /answerForTelemetry = output;[\s\S]*return settleRun\(\(\) => completeResponse\(/);
@@ -0,0 +1,346 @@
// TASK-consult-evidence-card-v2-20260927 T3: evidence card v2, card by card,
// item by item, against real engine responses (three public AA charts; family,
// annual and timing routes; scripts/research/capture_consult_evidence_card_golden.py,
// AGENTS §7.4). Expected values are read from the engine's own layers
// (chart.consultation_native_layers, chart.modules), so a card that rewrote,
// converted or re-derived a value fails here.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildEvidenceCard,
EVIDENCE_CARD_LABELS,
EVIDENCE_CARD_VERSION,
EVIDENCE_LOOKUP_KP_NOTE,
EVIDENCE_LOOKUP_SECTIONS,
type EvidenceCard,
} from "../src/lib/consultation-evidence-card.ts";
import { consultationDomainIds, type ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import { evidenceLookupActivityLabel } from "../src/lib/consultation-activity-labels.ts";
import {
consultationWorkflowResponseSchema,
toAgentConsultationContext,
toModelOutput,
} from "../src/mastra/consultation-workflow.ts";
import { createConsultationRuntimeState, createConsultationTools, readEvidenceLookupSection } from "../src/mastra/consultation-tools.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[] };
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, ...domains: ConsultationDomain[]): EvidenceCard {
return buildEvidenceCard(domains.map((domain) => sources(workflow, domain)));
}
function native(workflow: Json): Json {
return dig(workflow, "chart", "consultation_native_layers") as Json;
}
function modules(workflow: Json): Json {
return dig(workflow, "chart", "modules") as Json;
}
function pick(source: unknown, keys: readonly string[]) {
const out: Json = {};
for (const key of keys) {
const value = (source as Json)[key];
if (value !== undefined) out[key] = value;
}
return out;
}
test("the card version is v2", () => {
assert.equal(EVIDENCE_CARD_VERSION, "evidence-card-v2");
assert.equal(card(golden.charts[0]!.workflow, "career").card_version, "evidence-card-v2");
});
test("every base section carries the engine's D9 summary: lagna, D9 sign and dignity, Vargottama, reversals", () => {
let reversalsSeen = 0;
for (const chart of golden.charts) {
const summary = native(chart.workflow).d9_summary as Json;
for (const domain of consultationDomainIds) {
const d9 = card(chart.workflow, domain).base.natal.d9;
const where = `${chart.id}/${domain}`;
assert.equal(d9.lagna, summary.lagna, `${where} D9 lagna`);
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}`);
}
// The D9 houses and aspects stay out of the base section.
assert.equal(JSON.stringify(d9).includes("house"), false, `${where} no D9 houses`);
}
reversalsSeen += (summary.reversals as unknown[]).length;
}
assert.ok(reversalsSeen > 0, "a golden chart has a D1/D9 dignity reversal on the card");
});
test("no card carries Chara Dasha or KP; both are lookups", () => {
for (const chart of golden.charts) {
for (const [workflow, domains] of [
[chart.workflow, consultationDomainIds],
[chart.annual_workflow, ["annual"]],
[chart.timing_workflow, ["timing"]],
] as const) {
for (const domain of domains) {
const built = card(workflow, domain);
const text = JSON.stringify({ base: built.base, sections: built.domain_sections });
for (const absent of ["chara_dasha", "kp_cusps", "sublord", "sub_lord", "cusps"]) {
assert.equal(text.includes(`"${absent}"`), false, `${chart.id}/${domain} ${absent}`);
}
assert.ok(built.meta.supplementable_sections.includes("chara_dasha"), `${chart.id}/${domain} chara lookup`);
assert.ok(built.meta.supplementable_sections.includes("kp_cusps"), `${chart.id}/${domain} KP lookup`);
}
}
}
});
test("career: AL, 10H SAV and the SAV of the Jupiter / Saturn transit signs join v1's career section", () => {
for (const chart of golden.charts) {
const section = card(chart.workflow, "career").domain_sections.career!;
const padas = dig(modules(chart.workflow), "arudha_padas", "padas") as Json;
assert.deepEqual(section.arudha?.AL, pick(padas.A1, ["sign", "lord"]), `${chart.id} AL is pada A1`);
assert.deepEqual(section.arudha?.A10, pick(padas.A10, ["sign", "lord"]), `${chart.id} A10`);
const house10 = dig(modules(chart.workflow), "ashtakavarga", "house_scores", "house_10");
assert.deepEqual(section.sav_by_house?.["10"], pick(house10, ["sign", "sav_score", "level"]), `${chart.id} 10H SAV`);
const current = dig(native(chart.workflow), "slow_transits", "current") as Json;
for (const body of ["Jupiter", "Saturn"]) {
assert.deepEqual((section.slow_transits as Json)[body], pick(current[body], ["sign", "natal_house", "sav"]), `${chart.id} ${body}`);
}
assert.ok(section.vargas.D10 && section.karakas?.AmK && section.yogas && section.shadbala !== undefined, `${chart.id} v1 items kept`);
const supplementable = card(chart.workflow, "career").meta.supplementable_sections;
for (const lookup of ["karakamsha", "dispositor_chains", "inter_chart_linkage", "argala", "kp_cusps", "chara_dasha"]) {
assert.ok(supplementable.includes(lookup as never), `${chart.id} ${lookup} is a lookup`);
}
}
});
test("marriage: 5H / 5L, day / night, Punarphoo, Double Transit on 7H / 7L / DK / UL, Vivah Saham, gender unknown", () => {
for (const chart of golden.charts) {
const built = card(chart.workflow, "marriage");
const section = built.domain_sections.marriage!;
const layers = native(chart.workflow);
const where = chart.id;
assert.deepEqual(Object.keys(section.houses).sort(), ["5", "7"], `${where} houses`);
const owned = built.base.natal.functional_benefic_malefic.owned_houses as Json;
const fifthLords = Object.entries(owned).filter(([, houses]) => (houses as number[]).includes(5)).map(([name]) => name);
assert.deepEqual((section.houses["5"] as Json).lords, fifthLords, `${where} 5L`);
assert.deepEqual(section.day_night, { is_day: dig(modules(chart.workflow), "gulika", "daynight_evidence", "is_daytime") }, `${where} is_day`);
const punarphoo = layers.punarphoo as Json;
assert.deepEqual(section.punarphoo, {
status: "observation_only",
natal_present: punarphoo.natal_present,
orb_deg: punarphoo.orb_deg,
saturn_return_to_natal_moon: pick(punarphoo.saturn_return_to_natal_moon, ["active", "orb_deg"]),
claim_boundary: punarphoo.claim_boundary,
}, `${where} Punarphoo`);
const double = layers.double_transit as Json;
const seventh = (double.conclusions as Json[]).filter((row) => row.house === 7)
.map((row) => pick(row, ["layer", "target", "strength", "jupiter_pac", "saturn_pac"]));
assert.deepEqual(dig(section.double_transit, "house_7"), seventh, `${where} 7H / 7L double transit`);
for (const key of ["DK", "UL"]) {
assert.deepEqual(
dig(section.double_transit, key),
pick((double.karaka_targets as Json)[key], ["target", "jupiter_pac", "saturn_pac", "double_transit"]),
`${where} ${key} double transit`,
);
}
const vivah = layers.vivah_saham as Json;
assert.deepEqual(section.vivah_saham, pick(vivah, ["longitude", "sign", "degree_in_sign", "house", "natal_conjuncts"]), `${where} Vivah`);
assert.equal(typeof (section.vivah_saham as Json).house, "number");
assert.equal(section.gender, EVIDENCE_CARD_LABELS.genderUnknown);
assert.ok(section.vargas.D9 && section.arudha?.UL && section.karakas?.DK, `${where} v1 items kept`);
}
});
test("wealth names 8H and 12H beside 2 / 11 / 9 / 5", () => {
for (const chart of golden.charts) {
const section = card(chart.workflow, "wealth").domain_sections.wealth!;
assert.deepEqual(Object.keys(section.houses).map(Number).sort((a, b) => a - b), [2, 5, 8, 9, 11, 12]);
for (const house of ["8", "12"]) {
assert.equal((section.houses[house] as Json).sign, card(chart.workflow, "wealth").base.natal.houses[house], `${chart.id} ${house}H sign`);
}
}
});
test("annual: the annual chart verbatim, AD-lord and transit houses, key dates only", () => {
for (const chart of golden.charts) {
const built = card(chart.annual_workflow, "annual");
const section = built.domain_sections.annual!;
const layers = native(chart.annual_workflow);
const annual = layers.annual_tajika as Json;
const where = chart.id;
assert.deepEqual(section.annual_chart, pick(annual, [
"status", "varsha_year", "annual_lagna", "varshesha", "muntha", "mudda_dasha", "tajika_aspects", "boundary",
]), `${where} annual chart`);
assert.equal(dig(section.annual_chart, "status"), "parameter_sensitive");
// Houses: 1, the running and next AD lords' houses, the year's Jupiter / Saturn transit houses.
const vim = built.base.timing.vimshottari as Json;
const owned = built.base.natal.functional_benefic_malefic.owned_houses as Json;
const lordHouses = (lord: unknown) => ((owned[String(lord)] as number[] | undefined) ?? []);
const inWindow = dig(layers, "slow_transits", "houses_in_window") as Json;
const expected = [...new Set([
1,
...lordHouses(dig(vim, "antardasha", "lord")),
...lordHouses(dig(vim, "next_antardasha", "lord")),
...(inWindow.Jupiter as number[]),
...(inWindow.Saturn as number[]),
])];
assert.deepEqual(Object.keys(section.houses).map(Number).sort((a, b) => a - b), [...expected].sort((a, b) => a - b), `${where} annual houses`);
assert.deepEqual(section.house_basis, {
antardasha_lord: dig(vim, "antardasha", "lord"),
antardasha_houses: lordHouses(dig(vim, "antardasha", "lord")),
next_antardasha_lord: dig(vim, "next_antardasha", "lord"),
next_antardasha_houses: lordHouses(dig(vim, "next_antardasha", "lord")),
jupiter_transit_houses: inWindow.Jupiter,
saturn_transit_houses: inWindow.Saturn,
}, `${where} house basis`);
// Key dates: Jupiter / Saturn ingress and stations, Rahu / Ketu ingress; no month-by-month rows.
assert.deepEqual(dig(section.slow_transits, "key_dates"), dig(layers, "slow_transits", "key_dates"), `${where} key dates`);
assert.ok(((dig(section.slow_transits, "key_dates") as Json[]).length) <= 24);
for (const row of dig(section.slow_transits, "key_dates") as Json[]) {
assert.ok(["ingress", "station"].includes(String(row.kind)));
assert.ok(["jupiter", "saturn", "rahu", "ketu"].includes(String(row.body)));
}
assert.equal(section.transits, undefined, `${where} no generic transit layer on the annual card`);
}
});
test("annual: a missing or blocked annual pack says 年盘未接入 and never shows natal data in its place", () => {
// Family route: the engine attaches the pack on the annual route only.
const notAttached = card(golden.charts[0]!.workflow, "annual").domain_sections.annual!;
assert.deepEqual(notAttached.annual_chart, {
status: EVIDENCE_CARD_LABELS.annualNotAttached,
reason: "annual pack is attached for the annual route only",
});
const blocked = structuredClone(golden.charts[1]!.annual_workflow);
((blocked.chart as Json).consultation_native_layers as Json).annual_tajika = {
status: "blocked",
reason: "annual_pack_not_reliable:mudda_dasha_periods",
};
const section = card(blocked, "annual").domain_sections.annual!;
assert.deepEqual(section.annual_chart, { status: "年盘未接入", reason: "annual_pack_not_reliable:mudda_dasha_periods" });
assert.deepEqual(Object.keys(section.annual_chart as Json), ["status", "reason"], "no annual field is filled from natal data");
assert.deepEqual(Object.keys(notAttached.annual_chart as Json), ["status", "reason"]);
});
test("timing: Rahu / Ketu, Jupiter / Saturn SAV and BAV, Double Transit conclusions without degrees", () => {
for (const chart of golden.charts) {
const section = card(chart.timing_workflow, "timing").domain_sections.timing!;
const layers = native(chart.timing_workflow);
const current = dig(layers, "slow_transits", "current") as Json;
const where = chart.id;
for (const body of ["Jupiter", "Saturn"]) {
assert.deepEqual(dig(section.slow_transits, "current", body), pick(current[body], ["sign", "degree_in_sign", "retrograde", "natal_house", "sav", "bav"]), `${where} ${body}`);
}
for (const body of ["Rahu", "Ketu"]) {
assert.deepEqual(dig(section.slow_transits, "current", body), pick(current[body], ["sign", "degree_in_sign", "retrograde", "natal_house"]), `${where} ${body}`);
}
const nodeDates = (dig(layers, "slow_transits", "key_dates") as Json[]).filter((row) => row.body === "rahu" || row.body === "ketu");
assert.deepEqual(dig(section.slow_transits, "node_ingresses"), nodeDates, `${where} node ingresses`);
const double = layers.double_transit as Json;
assert.deepEqual(dig(section.double_transit, "conclusions"), (double.conclusions as Json[]).map((row) => pick(row, ["house", "layer", "target", "strength"])), `${where} conclusions`);
assert.deepEqual(dig(section.double_transit, "active_houses"), double.active_houses, `${where} active houses`);
assert.equal(/\d+\.\d+°/.test(JSON.stringify(section.double_transit)), false, `${where} no raw degrees`);
// Moon transit is a lookup, not on the card.
assert.equal(JSON.stringify(section).includes("moon_transit"), false);
}
});
test("timing vargas follow the turn's other domain; D9 when timing is alone", () => {
const workflow = golden.charts[1]!.timing_workflow;
assert.deepEqual(Object.keys(card(workflow, "timing").domain_sections.timing!.vargas), ["D1", "D9"]);
for (const [other, code] of [["career", "D10"], ["marriage", "D9"], ["wealth", "D2"], ["health", "D30"]] as const) {
for (const order of [["timing", other], [other, "timing"]] as ConsultationDomain[][]) {
const built = card(workflow, ...order);
const timing = built.domain_sections.timing!;
assert.deepEqual(Object.keys(timing.vargas), ["D1"], `${order.join("+")} timing keeps D1 only`);
assert.equal(timing.vargas_follow, other, `${order.join("+")} follows ${other}`);
assert.ok(built.domain_sections[other]!.vargas[code], `${order.join("+")} ${code} is in the ${other} section`);
}
}
// Annual carries only D1, so timing falls back to D9.
const withAnnual = card(workflow, "timing", "annual").domain_sections.timing!;
assert.deepEqual(Object.keys(withAnnual.vargas), ["D1", "D9"]);
assert.equal(withAnnual.vargas_follow, undefined);
});
test("the lookup enum carries the optional layers and returns the engine's own values", async () => {
for (const section of ["karakamsha", "dispositor_chains", "inter_chart_linkage", "argala", "moon_transit", "chara_dasha", "kp_cusps", "varga:D10"] as const) {
assert.ok(EVIDENCE_LOOKUP_SECTIONS.includes(section), section);
assert.equal(evidenceLookupActivityLabel(section).includes("一段盘面数据"), false, `${section} has a plain label`);
}
const workflow = golden.charts[0]!.workflow;
const career = sources(workflow, "career");
const execution = { ...career, modelOutput: career.packet };
const cache = { executions: [execution] } as never;
const layers = native(workflow);
assert.deepEqual(readEvidenceLookupSection(cache, "karakamsha"), layers.karakamsha);
assert.deepEqual(readEvidenceLookupSection(cache, "moon_transit"), layers.moon_transit);
assert.deepEqual(readEvidenceLookupSection(cache, "dispositor_chains"), pick(layers.dispositor_chains, ["chains", "boundary"]));
assert.deepEqual(readEvidenceLookupSection(cache, "inter_chart_linkage"), pick(layers.inter_chart_linkage, ["planets", "boundary"]));
assert.deepEqual(readEvidenceLookupSection(cache, "argala"), pick(layers.argala, ["method", "summary", "houses"]));
const kp = readEvidenceLookupSection(cache, "kp_cusps") as Json;
assert.equal(kp.note, EVIDENCE_LOOKUP_KP_NOTE);
assert.ok(kp.kp_cusps, "KP data travels with its blocked note");
assert.ok(readEvidenceLookupSection(cache, "varga:D10"), "D10 for income from work");
// Through the tool: a KP lookup answers with the note.
const state = createConsultationRuntimeState();
const tools = createConsultationTools({
userId: "u", sessionId: "s", requestId: "kp-lookup", consultationMode: "verified_chart",
serverChart: { name: "public", toolInput: { year: 2000, month: 1, day: 1, hour: 0, minute: 0, city: "fixture", lat: 0, lon: 0, tz: 0, ayanamsa: "raman" }, truth: { birthTimeSource: "reported" } } as never,
state,
runWorkflow: async () => structuredClone(workflow) as never,
});
await tools["run-jyotish-consultation"].execute!({ question: "q", domains: ["wealth"] } as never, { writer: { custom: async () => {} } } as never);
const result = await tools["read-consultation-evidence"].execute!({ section: "kp_cusps" } as never, { writer: { custom: async () => {} } } as never) as Json;
assert.equal(dig(result, "data", "note"), EVIDENCE_LOOKUP_KP_NOTE);
});
test("size: contract + card stay under 12,000 characters without the checklist, annual and timing included", async () => {
const serverChart = {
name: "public",
toolInput: { year: 2000, month: 1, day: 1, hour: 0, minute: 0, city: "fixture", lat: 0, lon: 0, tz: 0, ayanamsa: "raman" },
truth: { birthTimeSource: "reported" },
};
for (const chart of golden.charts) {
for (const [workflow, domain] of [
...consultationDomainIds.map((domain) => [chart.workflow, domain] as const),
[chart.annual_workflow, "annual"] as const,
[chart.timing_workflow, "timing"] as const,
]) {
const state = createConsultationRuntimeState();
const tools = createConsultationTools({
userId: "u", sessionId: "s", requestId: `size-${domain}`, consultationMode: "verified_chart",
serverChart: serverChart as never, state,
runWorkflow: async () => structuredClone(workflow) as never,
});
const view = await tools["run-jyotish-consultation"].execute!({ question: "q", domains: [domain] } as never, { writer: { custom: async () => {} } } as never) as Json;
const { methodology: _methodology, ...rest } = view;
void _methodology;
assert.ok(JSON.stringify(rest).length <= 12_000, `${chart.id}/${domain} contract + card ${JSON.stringify(rest).length}`);
assert.deepEqual(dig(view, "evidence_card", "gaps") ?? [], [], `${chart.id}/${domain} gaps`);
}
}
});
@@ -107,7 +107,8 @@ test("the answer-writing call sees the evidence card, not the full result (red l
const writer = firstPromptWithToolResult(result.prompts, "run-jyotish-consultation");
assert.equal(writer, 1);
const card = toolResultText(result.prompts[writer], "run-jyotish-consultation");
assert.ok(card.includes("evidence-card-v1"), "the card reaches the writer");
// 原值: "evidence-card-v1" / 新值: "evidence-card-v2" / 原因: 卡 v2(TASK-consult-evidence-card-v2-20260927 T3)
assert.ok(card.includes("evidence-card-v2"), "the card reaches the writer");
assert.ok(card.includes(PD_START), "with the engine's pratyantar start");
assert.ok(card.includes(NARAYANA_SIGN), "and the running Narayana sign");
assert.ok(card.includes("can_answer_direction"), "and the answer contract");
@@ -135,7 +136,8 @@ test("a length continuation carries the card (red line 4)", async () => {
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
assert.equal(result.continuations.length, 1);
const continuation = JSON.stringify(result.prompts[2]);
assert.ok(continuation.includes("evidence-card-v1"));
// 原值: "evidence-card-v1" / 新值: "evidence-card-v2" / 原因: 同上
assert.ok(continuation.includes("evidence-card-v2"));
assert.ok(continuation.includes(PD_START));
assert.equal(result.completed, ANSWER);
});
@@ -2,7 +2,9 @@
* Measure what the answer model sees after the evidence card
* (TASK-consult-evidence-card-20260927 T3), on the research's own cached
* engine responses (3 public AA charts x 10 questions, produced by
* consult_evidence_card_run.py --cache <dir>). Read-only; runs the production
* consult_evidence_card_run.py --cache <dir>; evidence card v2 adds the annual
* and timing questions, files `<chart>__annual.workflow.json` /
* `<chart>__timing.workflow.json`). Read-only; runs the production
* calculation tool with the cached workflow standing in for the engine.
*
* node frontend/node_modules/tsx/dist/cli.mjs scripts/research/measure_consult_evidence_card.ts <cache-dir> [out.json]
@@ -26,6 +28,8 @@ const QUESTIONS: Record<string, { domain: ConsultationDomain; question: string }
general: { domain: "general", question: "请综合说明我当前最值得关注的主题。" },
parents: { domain: "parents", question: "我和父母关系如何" },
children: { domain: "children", question: "我和子女的关系如何" },
annual: { domain: "annual", question: "未来一年重点是什么?" },
timing: { domain: "timing", question: "接下来一年哪些时间点重要?" },
};
const [cacheDir, outPath] = process.argv.slice(2);