Files
Jyotisha/frontend/tests/consult-evidence-card-20260927.test.ts
T
Jesse_ChenandClaude Opus 5.5 db17074430 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
2026-09-27 16:44:19 +08:00

310 lines
17 KiB
TypeScript

// 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.
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 () => {
const methodHeavy = new Set(["career", "marriage", "wealth"]);
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");
assert.equal(career.meta.supplementable_sections.includes("yogas"), false);
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, ""), []);
});