Files
Jyotisha/frontend/tests/consult-evidence-card-20260927.test.ts
T
Jesse_ChenandClaude Opus 5.5 ec06d422d3 feat(consult): annual and timing turns get the shared reading and annual list (product budget decision 2026-10-02)
Annual / timing single-domain turns take the career / marriage / wealth budget:
contract + card <= 12,000, total with the checklist <= 18,000.
CONSULT_READING_BUDGET_BLOCKED removed; size assertions and the pinned
blocker test changed (原值/新值/原因 in the tests and brief 2's progress record);
BUG-1160/1161 resolved, BLOCKED.md entry struck through.

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

372 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TASK-consult-evidence-card-20260927 T3: the answer model reads the answer
// contract plus the evidence card instead of the whole projected result.
//
// Fixture: real engine consultation responses for three public AA charts
// (scripts/research/capture_consult_evidence_card_golden.py, AGENTS §7.4).
// Expected facts are read straight from the raw engine payload, so a card that
// rewrote, rounded 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,
citedEvidenceCardFields,
EVIDENCE_CARD_BACKSTAGE_LINE,
EVIDENCE_CARD_LABELS,
EVIDENCE_CARD_SPECS,
EVIDENCE_CARD_VERSION,
evidenceCardFieldValues,
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 { normalizeTechniqueAuditRows } from "../src/lib/consultation-technique-audit.ts";
import {
consultationWorkflowResponseSchema,
toAgentConsultationContext,
toModelOutput,
} from "../src/mastra/consultation-workflow.ts";
import {
CONSULTATION_MODEL_PACKET_VERSION,
createConsultationRuntimeState,
createConsultationTools,
} from "../src/mastra/consultation-tools.ts";
type Json = Record<string, unknown>;
const golden = JSON.parse(readFileSync(
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
"utf8",
)) as { charts: Array<{ id: string; workflow: Json }> };
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 cardFor(workflow: Json, domain: ConsultationDomain): EvidenceCard {
return buildEvidenceCard([sources(workflow, domain)]);
}
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" },
};
async function runTool(workflow: Json, domains: ConsultationDomain[]) {
const state = createConsultationRuntimeState();
const tools = createConsultationTools({
userId: "u", sessionId: "s", requestId: `card-${domains.join("-")}`, consultationMode: "verified_chart",
serverChart: serverChart as never, state,
runWorkflow: async () => structuredClone(workflow) as never,
});
const view = await tools["run-jyotish-consultation"].execute!(
{ question: "测试问题", domains } as never,
{ writer: { custom: async () => {} } } as never,
) as Json;
return { view, state };
}
function engineFacts(workflow: Json) {
const chart = dig(workflow, "chart") as Json;
const modules = dig(chart, "modules") as Json;
const formal = dig(modules, "varga_spectrum", "formal") as Json;
return {
ascendantSign: dig(chart, "ascendant", "sign"),
ascendantDegree: dig(chart, "ascendant", "degree_in_sign"),
moonSign: dig(chart, "planets", "Moon", "sign"),
moonHouse: dig(chart, "planets", "Moon", "house"),
moonDegree: dig(chart, "planets", "Moon", "degree_in_sign"),
mahadasha: dig(modules, "dasha_sub_periods", "current", "mahadasha"),
antardasha: dig(modules, "dasha_sub_periods", "current", "antardasha"),
pratyantar: dig(modules, "dasha_sub_periods", "pratyantar_dasha_timeline", "current"),
narayanaMd: dig(modules, "narayana_dasha", "current_dasha", "md"),
functional: dig(modules, "functional_benefic_malefic") as Json,
varga: (code: string) => formal[code] as Json,
};
}
test("the card copies the engine's own facts verbatim (red line 2)", () => {
for (const chart of golden.charts) {
const facts = engineFacts(chart.workflow);
for (const domain of ["parents", "children", "marriage", "career"] as const) {
const card = cardFor(chart.workflow, domain);
const natal = card.base.natal as Json;
const timing = card.base.timing as Json;
const where = `${chart.id}/${domain}`;
assert.equal(dig(natal, "ascendant", "sign"), facts.ascendantSign, `${where} ascendant sign`);
assert.equal(dig(natal, "ascendant", "degree_in_sign"), facts.ascendantDegree, `${where} ascendant degree`);
assert.equal(dig(natal, "planets", "Moon", "sign"), facts.moonSign, `${where} Moon sign`);
assert.equal(dig(natal, "planets", "Moon", "house"), facts.moonHouse, `${where} Moon house`);
assert.equal(dig(natal, "planets", "Moon", "degree_in_sign"), facts.moonDegree, `${where} Moon degree`);
for (const [cardKey, engine] of [
["mahadasha", facts.mahadasha],
["antardasha", facts.antardasha],
["pratyantardasha", facts.pratyantar],
] as const) {
for (const key of ["lord", "start", "end"] as const) {
assert.equal(dig(timing, "vimshottari", cardKey, key), dig(engine, key), `${where} ${cardKey}.${key}`);
}
}
assert.equal(dig(timing, "narayana", "md", "sign"), dig(facts.narayanaMd, "sign"), `${where} Narayana sign`);
assert.equal(dig(timing, "narayana", "md", "start_age"), dig(facts.narayanaMd, "start_age"), `${where} Narayana start age`);
assert.deepEqual(dig(natal, "functional_benefic_malefic", "functional_benefics"), facts.functional.functional_benefics, `${where} functional benefics`);
assert.deepEqual(dig(natal, "functional_benefic_malefic", "functional_malefics"), facts.functional.functional_malefics, `${where} functional malefics`);
}
const varga = (domain: ConsultationDomain, code: string) => dig(cardFor(chart.workflow, domain).domain_sections, domain, "vargas", code) as Json;
for (const [domain, code] of [["parents", "D12"], ["family", "D12"], ["migration", "D12"], ["children", "D7"], ["family", "D7"], ["marriage", "D9"], ["general", "D9"], ["career", "D10"], ["general", "D10"]] as const) {
const engine = facts.varga(code);
assert.equal(varga(domain, code)?.lagna, engine.lagna, `${chart.id}/${domain} ${code} lagna`);
assert.equal(dig(varga(domain, code), "planets", "Moon"), dig(engine, "planets", "Moon"), `${chart.id}/${domain} ${code} Moon`);
assert.deepEqual(varga(domain, code)?.planets, engine.planets, `${chart.id}/${domain} ${code} planets`);
}
}
});
test("every card value is a value the projection or the engine already holds (nothing rewritten)", () => {
function scalars(value: unknown, into = new Set<string>()) {
if (value === null || typeof value !== "object") {
into.add(JSON.stringify(value));
return into;
}
for (const [key, item] of Object.entries(value as object)) {
if (!Array.isArray(value)) into.add(JSON.stringify(key));
scalars(item, into);
}
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.
// 原值: 白名单是 EVIDENCE_CARD_LABELS 的两条(性别未知、年盘未接入)
// 新值: 同一来源多了 roleMother / roleFather(母亲、父亲),断言本身不变
// 原因: TASK-consult-plain-answer-20261001 D6(BUG-1133):父母卡标出每条依据属于谁,标签由卡自己写
// 原值: 白名单是 EVIDENCE_CARD_LABELS 的四条(性别未知、年盘未接入、母亲、父亲)
// 新值: 同一来源多了 functionalBenefic / functionalMalefic / functionalNeutral(benefic、malefic、neutral),断言本身不变
// 原因: TASK-consult-card-affliction-data-20261001 T3(BUG-1156):父母卡宫主与代表星行标出功能吉凶,
// 取值是「该星在引擎哪张功能列表里」的选择结果,标签由卡自己写
const cardLabels = new Set(Object.values(EVIDENCE_CARD_LABELS).map((label) => JSON.stringify(label)));
for (const chart of golden.charts) {
for (const domain of consultationDomainIds) {
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) {
assert.ok(known.has(JSON.stringify(value)), `${chart.id}/${domain} ${fieldId}=${JSON.stringify(value)} is not an engine value`);
}
}
assert.deepEqual(card.meta.gaps, [], `${chart.id}/${domain} gaps`);
}
}
});
test("every domain's card covers Part B: dual dasha, domain vargas, functional benefic/malefic, raw degrees and dates (red line 3)", () => {
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
for (const chart of golden.charts) {
for (const domain of consultationDomainIds) {
const card = cardFor(chart.workflow, domain);
const where = `${chart.id}/${domain}`;
const timing = card.base.timing as Json;
// Dual dasha: Vimshottari MD/AD/PD with dates and the running Narayana period.
for (const level of ["mahadasha", "antardasha", "pratyantardasha"]) {
assert.match(String(dig(timing, "vimshottari", level, "start")), isoDate, `${where} ${level} start`);
assert.match(String(dig(timing, "vimshottari", level, "end")), isoDate, `${where} ${level} end`);
}
assert.equal(typeof dig(timing, "narayana", "md", "sign"), "string", `${where} Narayana md`);
// Domain vargas: every varga the domain's spec names.
const section = card.domain_sections[domain];
assert.ok(section, `${where} domain section`);
assert.ok(EVIDENCE_CARD_SPECS[domain].vargas.length > 0, `${where} spec names a varga`);
for (const code of EVIDENCE_CARD_SPECS[domain].vargas) {
assert.equal(typeof dig(section.vargas, code, "lagna"), "string", `${where} ${code}`);
}
// Functional benefics / malefics with house lordship.
const functional = card.base.natal.functional_benefic_malefic;
assert.ok(Array.isArray(functional.functional_benefics) && functional.functional_benefics.length > 0, `${where} benefics`);
assert.ok(Array.isArray(functional.functional_malefics) && functional.functional_malefics.length > 0, `${where} malefics`);
assert.ok(Object.keys(functional.owned_houses as object).length > 0, `${where} lordship`);
// Raw degrees.
assert.equal(typeof dig(card.base.natal, "ascendant", "degree_in_sign"), "number", `${where} ascendant degree`);
assert.equal(typeof dig(card.base.natal, "planets", "Sun", "degree_in_sign"), "number", `${where} Sun degree`);
}
}
});
test("the model sees the answer contract and the card, not the full result (red line 1, D1, D3)", async () => {
const workflow = golden.charts[0]!.workflow;
const { view } = await runTool(workflow, ["parents"]);
const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow)));
const full = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: "parents" }), "parents");
// The keys the system prompt states its rules against, with the same values
// the full result carried.
assert.equal(view.packet_version, CONSULTATION_MODEL_PACKET_VERSION);
assert.equal(view.status, full.status);
assert.equal(view.route, full.route);
assert.deepEqual(dig(view, "evidence_contract", "answer_policy"), full.evidence_contract.answer_policy);
assert.equal(dig(view, "evidence_contract", "answer_policy", "can_answer_direction"), true);
assert.equal(typeof dig(view, "evidence_contract", "answer_policy", "can_answer_precise_timing"), "boolean");
assert.deepEqual(dig(view, "evidence_contract", "hard_blockers"), full.evidence_contract.hard_blockers);
assert.deepEqual(dig(view, "evidence_contract", "missing_route_layers"), full.evidence_contract.missing_route_layers);
assert.deepEqual(dig(view, "evidence_contract", "available_layers"), full.evidence_contract.available_layers);
assert.equal(dig(view, "evidence_contract", "user_facing_limitation"), full.evidence_contract.user_facing_limitation);
assert.deepEqual(view.rectification, full.rectification);
assert.equal("presentation" in view, false, "required_blocks names the audit table the body must not carry");
assert.deepEqual((view.claim_cards as Array<{ category: string }>).map((card) => card.category), ["natal_foundation", "timing", "domain"]);
assert.deepEqual(view.domains, ["parents"]);
assert.deepEqual(view.omitted_domains, []);
assert.equal(view.success, true);
assert.ok(dig(view, "methodology", "sections"), "the domain checklist still travels with the result");
assert.equal(dig(view, "evidence_card", "card_version"), EVIDENCE_CARD_VERSION);
assert.equal(dig(view, "evidence_card", "backstage"), EVIDENCE_CARD_BACKSTAGE_LINE);
// No duplicate package, no audit table, no spectra, no status layers.
assert.equal("consultations" in view, false);
const { methodology: _methodology, ...withoutMethod } = view;
void _methodology;
const text = JSON.stringify(withoutMethod);
// As keys: available_layers may still name a layer ("varga_spectrum") as available.
for (const absent of ["technique_audit_table", "varga_spectrum", "western_spectrum", "must_use_layers", "research_dn", "extended", "technique_truth", "reference_transparency", "vedastro_cross_check", "presentation"]) {
assert.equal(text.includes(`"${absent}":`), false, absent);
}
});
test("a two-domain turn carries one base, both domain sections and each domain's own contract", async () => {
const workflow = golden.charts[1]!.workflow;
const { view } = await runTool(workflow, ["children", "education"]);
const claimCards = view.claim_cards as Array<{ category: string; evidence: Json }>;
assert.deepEqual(claimCards.map((card) => card.category), ["natal_foundation", "timing", "domain"]);
assert.deepEqual(Object.keys(claimCards[2]!.evidence), ["children", "education"]);
const consultations = view.consultations as Array<Json>;
assert.deepEqual(consultations.map((item) => item.domain), ["children", "education"]);
for (const item of consultations) {
assert.deepEqual(Object.keys(item).sort(), ["domain", "evidence_contract", "rectification", "route", "status"]);
assert.equal(typeof dig(item, "evidence_contract", "answer_policy", "can_answer_direction"), "boolean");
}
assert.equal(typeof dig(view, "evidence_contract", "answer_policy", "can_answer_direction"), "boolean");
});
test("the 「本轮技法」 rows are exactly the rows the full result produced before the card", async () => {
for (const chart of golden.charts) {
for (const domains of [["parents"], ["career"], ["children", "education"]] as ConsultationDomain[][]) {
const { state } = await runTool(chart.workflow, domains);
// Before the card: single domain read the domain packet's audit table;
// several domains read the first packet's (mergeConsultationEvidencePackets).
const first = sources(chart.workflow, domains[0]!).packet;
const expected = normalizeTechniqueAuditRows(first.evidence_contract.technique_audit_table);
assert.ok((expected?.length ?? 0) > 30, "the audit table is the full 40-odd rows");
assert.deepEqual(state.techniqueAuditTable, expected, `${chart.id}/${domains.join("+")}`);
}
}
});
test("model-visible size falls to card scale; only the method checklist can push it past 12,000", async () => {
// 原值: new Set(["career", "marriage", "wealth"])
// 新值: 加 annual、timing(契约 + 卡 ≤ 12,000,含清单 ≤ 18,000)
// 原因: 产品 2026-10-02 决定年运 / 时运与事业 / 婚姻 / 财富同口径,通用读法与年运清单下发(BUG-1160/1161)
const methodHeavy = new Set(["career", "marriage", "wealth", "annual", "timing"]);
for (const chart of golden.charts) {
for (const domain of consultationDomainIds) {
const { view, state } = await runTool(chart.workflow, [domain]);
const total = JSON.stringify(view).length;
const { methodology, ...rest } = view;
const withoutMethod = JSON.stringify(rest).length;
assert.equal(state.modelVisibleChars, total);
assert.ok(withoutMethod <= 12_000, `${chart.id}/${domain}: contract + card ${withoutMethod}`);
if (!methodHeavy.has(domain)) assert.ok(total <= 12_000, `${chart.id}/${domain}: ${total}`);
assert.ok(total <= 18_000, `${chart.id}/${domain}: ${total} (methodology ${JSON.stringify(methodology ?? null).length})`);
}
}
});
test("the card names what else this calculation holds, without the sections it already carries", () => {
const card = cardFor(golden.charts[0]!.workflow, "parents");
assert.equal(card.meta.backstage, EVIDENCE_CARD_BACKSTAGE_LINE);
assert.ok(card.meta.supplementable_sections.includes("varga:D60"));
assert.ok(card.meta.supplementable_sections.includes("western:solar_return"));
assert.ok(card.meta.supplementable_sections.includes("yogas"));
assert.equal(card.meta.supplementable_sections.includes("varga:D12"), false);
const career = cardFor(golden.charts[0]!.workflow, "career");
// 原值: career 卡带 yogas,所以 supplementable_sections 不含 "yogas"(false)
// 新值: true
// 原因: TASK-consult-card-affliction-data-20261001 T4(BUG-1157):卡上只放本领域标签的瑜伽,
// 完整清单仍在服务端,只能靠一次查阅拿到,所以 "yogas" 仍是可补查的部分
assert.equal(career.meta.supplementable_sections.includes("yogas"), true);
assert.equal(career.meta.supplementable_sections.includes("varga:D10"), false);
});
test("citation counting keeps field ids only and needs a date, degree or planet-in-sign phrase", () => {
const card = cardFor(golden.charts[0]!.workflow, "parents");
const start = String(dig(card.base.timing, "vimshottari", "pratyantardasha", "start"));
const moonSign = String(dig(card.base.natal, "planets", "Moon", "sign"));
const answer = `子运从 ${start} 开始;Moon in ${moonSign}。月亮是 ${moonSign}。`;
const cited = citedEvidenceCardFields(card, answer);
assert.ok(cited.includes("base.timing.vimshottari.pratyantardasha.start"));
assert.ok(cited.includes("base.natal.planets.Moon.sign"));
for (const id of cited) assert.match(id, /^[A-Za-z][A-Za-z0-9._-]*$/);
// A bare sign name is not a citation.
assert.deepEqual(citedEvidenceCardFields(card, `月亮 ${moonSign}`), []);
assert.deepEqual(citedEvidenceCardFields(card, ""), []);
});
test("the parents section says which evidence is the mother's and which the father's (BUG-1133)", () => {
for (const chart of golden.charts) {
const card = cardFor(chart.workflow, "parents");
const section = card.domain_sections.parents!;
const roles = section.roles as Record<string, Json>;
const where = chart.id;
assert.deepEqual(Object.keys(roles), ["mother", "father"], where);
const mother = roles.mother!;
const father = roles.father!;
assert.equal(mother.label, EVIDENCE_CARD_LABELS.roleMother);
assert.equal(father.label, EVIDENCE_CARD_LABELS.roleFather);
assert.equal(mother.house, 4);
assert.equal(father.house, 9);
// House lords are the section's own 4H / 9H lords, copied, not re-derived.
assert.deepEqual(mother.house_lords, dig(section.houses, "4", "lords"), where);
assert.deepEqual(father.house_lords, dig(section.houses, "9", "lords"), where);
assert.deepEqual(mother.significators, ["Moon"]);
assert.deepEqual(father.significators, ["Sun"]);
// MK is on the card (7-karaka scheme); its value sits once in section.karakas.
assert.deepEqual(mother.karakas, ["MK"], where);
assert.equal(typeof dig(section.karakas, "MK", "planet"), "string", where);
// The engine's scheme has no PiK, so the father carries none and no gap is recorded.
assert.equal(father.karakas, undefined, where);
assert.equal(dig(section.karakas, "PiK"), undefined, where);
// D12 4th / 9th counted from the D12 lagna; occupants are D12 planets in that sign.
const d12 = section.vargas.D12 as { lagna: string; planets: Record<string, string> };
for (const [role, number] of [[mother, 4], [father, 9]] as const) {
const house = role.d12_house as { house: number; sign: string; occupants: string[] };
assert.equal(house.house, number);
const signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"];
assert.equal(house.sign, signs[(signs.indexOf(d12.lagna) + number - 1) % 12], where);
assert.deepEqual(house.occupants, Object.entries(d12.planets).filter(([, sign]) => sign === house.sign).map(([name]) => name), where);
}
assert.deepEqual(card.meta.gaps, [], where);
}
});
test("a parents card without MK records the gap and the mother names no karaka (BUG-1133)", () => {
const workflow = structuredClone(golden.charts[0]!.workflow) as Json;
const input = sources(workflow, "parents");
const table = dig(input.context, "local_layers", "jaimini", "chara_karakas") as Json;
delete table.MK;
delete table.Matrikaraka;
const card = buildEvidenceCard([input]);
assert.ok(card.meta.gaps.includes("parents.karaka.MK"));
assert.equal((card.domain_sections.parents!.roles as Record<string, Json>).mother!.karakas, undefined);
});