Files
Jyotisha/frontend/tests/report-interpretive-facts.test.ts
T
Jesse_Chen 7faf8555a5
Independent Staging Quality Gate / validate (push) Successful in 9m15s
Independent Staging Quality Gate / publish (push) Successful in 1m50s
fix(report): unblock personal-full themes with real varga, transit, and karaka evidence
The extractor only read an obsolete varga object shape, never issued Transit receipts, and the consultation response omitted Chara Karakas, so career/marriage/timing/wealth all blocked. Supply the live engine shape, fail-closed receipts, and jaimini.chara_karakas without relaxing the evidence plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 18:03:23 +08:00

571 lines
24 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildReportEvidenceBundleV2,
filterReportEvidenceBundleForSection,
} from "../src/lib/personal-report-generation.ts";
import { buildPersonalReportSectionPlan } from "../src/lib/personal-report-plan.ts";
import {
emptyReportInterpretiveFacts,
finalizeReportEvidenceBundleV2,
validateReportEvidenceBundleV2,
type ReportEvidenceBundleV2,
} from "../src/lib/report-evidence-bundle-v2.ts";
// ---------------------------------------------------------------------------
// Engine response contract fixture (2026-09-01 snapshot).
//
// Synthetic data only — no real birth details. The shapes below mirror what a
// live POST /api/consultation_workflow actually returns today:
// chart.ai_prompt_pack.evidence_snapshot.functional_benefic_malefic present
// chart.ai_prompt_pack.evidence_snapshot.strength.shadbala_ranking present
// ({planet, rupas, level})
// chart.modules.ashtakavarga.sav.{scores,total} present
// (scores keyed by SIGN, not house)
// chart.modules.dasha_sub_periods.current.{mahadasha,antardasha} present
// chart.yogas present
// chart.modules.yogas.yogas candidate
// rule table with hit:true/false
// chart.modules.guided_topics present
// evidence_snapshot.timing.vimshottari / .convergence_top_domains ABSENT
// evidence_snapshot.strength.sav_scores ABSENT
// evidence_snapshot.{career,relationship,finance}_narrative ABSENT
// The narrative payloads and the sign-keyed SAV fallback are still covered here
// because the engine's own `_build_ai_prompt_pack` emits them on other routes;
// the extraction layer must read either shape without inventing data.
// ---------------------------------------------------------------------------
const SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
] as const;
const ASC_INDEX = 3; // Cancer
type JsonRecord = Record<string, unknown>;
function basePlanets(): JsonRecord {
return {
Sun: { sign: "Cancer", degree_raw: 100.5, house: 1, retrograde: false },
Moon: { sign: "Leo", degree_raw: 130.5, house: 2, retrograde: false },
Mars: { sign: "Virgo", degree_raw: 160.5, house: 3, retrograde: false },
Mercury: { sign: "Libra", degree_raw: 190.5, house: 4, retrograde: true },
Jupiter: { sign: "Scorpio", degree_raw: 220.5, house: 5, retrograde: false },
Venus: { sign: "Sagittarius", degree_raw: 250.5, house: 6, retrograde: false },
Saturn: { sign: "Capricorn", degree_raw: 280.5, house: 7, retrograde: true },
Rahu: { sign: "Aquarius", degree_raw: 310.5, house: 8, retrograde: true },
Ketu: { sign: "Leo", degree_raw: 130.5, house: 2, retrograde: true },
};
}
function baseHouses(): JsonRecord {
const houses: JsonRecord = {};
for (let index = 0; index < 12; index += 1) {
houses[`house_${index + 1}`] = {
cusp_sign: SIGNS[(ASC_INDEX + index) % 12],
cusp_degree: 100.5 + index * 30,
};
}
return houses;
}
const AVAILABLE_LAYERS = [
"D1", "D2", "D11", "D24", "yogas", "ashtakavarga",
"functional_benefic_malefic", "vimshottari",
];
function machineSections(): JsonRecord {
return {
D1: { status: "used", source_path: "chart.planets+chart.ascendant" },
D2: { status: "used", source_path: "modules.varga_full.D2" },
D11: { status: "used", source_path: "modules.varga_full.D11" },
// Verified layers make the education theme reach multi_system_consensus so
// the seed-less fallback can be observed lowering it.
D24: { status: "verified", source_path: "modules.varga_full.D24" },
yogas: { status: "used", source_path: "modules.yogas" },
ashtakavarga: { status: "used", source_path: "modules.ashtakavarga" },
functional_benefic_malefic: { status: "used", source_path: "modules.functional_benefic_malefic" },
vimshottari: { status: "verified", source_path: "modules.dasha" },
planet_degrees: { status: "used", source_path: "chart.planets" },
house_degrees: { status: "used", source_path: "chart.houses" },
};
}
// Previous 643f6f7a fixture used the old engine-incompatible shape:
// `{ _meta, Ascendant: { sign, sign_idx, degree_in_sign }, Sun/Moon/Mars: { sign, sign_idx } }`.
// That locked the defect this round repairs (frontend only read Ascendant/sign_idx).
// Replaced with golden-derived live `modules.varga_full` values (lowercase
// ascendant, sign_index, planets dict, house_chart).
const GOLDEN_VARGA = (
JSON.parse(readFileSync(new URL("./fixtures/consultation-workflow-report-blocked-repairs-golden.json", import.meta.url), "utf8")) as {
themes: { career: { chart: { modules: { varga_full: Record<string, JsonRecord> } } } };
}
).themes.career.chart.modules.varga_full;
function engineVargaFromGolden(id: "D2" | "D11" | "D24"): JsonRecord {
const aliases: Record<typeof id, readonly string[]> = {
D2: ["D2_Hora", "D2"],
D11: ["D11_Rudramsa", "D11"],
D24: ["D24_Chaturvimsamsa", "D24_Siddhamsa", "D24"],
};
for (const key of aliases[id]) {
const value = GOLDEN_VARGA[key];
if (value) return value;
}
throw new Error(`golden varga ${id} missing`);
}
type FixtureOptions = Readonly<{
interpretive?: boolean;
financeNarrative?: boolean;
pollutedNames?: boolean;
structuredVargas?: boolean;
}>;
function workflowFixture(options: FixtureOptions = {}): JsonRecord {
const interpretive = options.interpretive !== false;
const chart: JsonRecord = {
ascendant: { sign: "Cancer", degree: 10.5, degree_in_sign: 10.5 },
planets: basePlanets(),
houses: baseHouses(),
dasha: {
mahadashas: [
{ lord: "Rahu", start: "2011-11-11", end: "2029-11-11" },
{ lord: "Jupiter", start: "2029-11-11", end: "2045-11-11" },
],
},
modules: {},
};
const modules = chart.modules as JsonRecord;
// Machine sections already mark D2/D11/D24 executed. Without the actual
// varga_full objects, demoteThemesMissingRequiredCharts strips wealth and
// education claim cards (Gitea run 2298).
if (options.structuredVargas !== false) {
modules.varga_full = {
D2: engineVargaFromGolden("D2"),
D11: engineVargaFromGolden("D11"),
D24: engineVargaFromGolden("D24"),
};
}
if (interpretive) {
chart.yogas = [
{ name: "Amala Yoga", planets: ["Mercury"], category: "extended" },
// Duplicate name from the second detection pass must collapse to one row.
{ name: "Amala Yoga", planets: [], cat: "extended", desc: "..." },
{ name: "Gaja Kesari Yoga", planets: ["Jupiter", "Moon"], category: "raja" },
// Unknown category folds into "other"; unknown planets are dropped.
{ name: "Budha Aditya Yoga", planets: ["Mercury", "Nibiru"], category: "wholly_invented" },
// No usable name: dropped rather than guessed.
{ name: " ", planets: ["Sun"], category: "raja" },
];
modules.yogas = {
status: "ok",
count: 3,
yogas: [
{ name: "Sunaphaa Yoga", rule_id: "bvr_002", hit: false },
{ name: "Chandra Mangala Yoga", rule_id: "bvr_010", hit: true, category: "dhana" },
],
};
modules.ashtakavarga = {
sav: {
// Real engine keys SAV by sign; the projection maps sign -> whole-sign house.
scores: {
Cancer: 24, Leo: 27, Virgo: 28, Libra: 25, Scorpio: 35, Sagittarius: 30,
Capricorn: 26, Aquarius: 28, Pisces: 32, Aries: 30, Taurus: 28, Gemini: 24,
},
total: 337,
},
};
modules.dasha_sub_periods = {
current: {
mahadasha: { lord: "Rahu", start: "2011-11-11", end: "2029-11-11" },
antardasha: { lord: "Sun", start: "2026-05-30", end: "2027-04-24" },
},
};
modules.guided_topics = [
{
id: "wealth_risk",
title: "财富、借贷和交易风险怎样用数据拆开",
reality_value: "帮助用户把收入、现金流、借贷、买卖和投资风险分开判断。",
why_worth_exploring: "财富主题必须同时看2宫、11宫、D2/D11 与时间层。",
evidence: [{ label: "required vargas", value: "D2 / D11" }],
confidence: "low",
},
// Product-flow topic: intentionally not mapped onto a report theme.
{ id: "birth_time_rectification", title: "出生时间是否需要微调", reality_value: "x", why_worth_exploring: "y" },
];
chart.ai_prompt_pack = {
schema_version: 1,
evidence_snapshot: {
functional_benefic_malefic: {
status: "used",
ascendant: "Cancer",
functional_benefics: options.pollutedNames
? ["Jupiter", "Moon", "Nibiru", "'; DROP TABLE --"]
: ["Jupiter", "Moon"],
functional_malefics: ["Mercury", "Saturn", "Venus"],
functional_neutrals: ["Sun"],
yogakarakas: ["Mars"],
owned_houses: {
Jupiter: [6, 9], Mars: [5, 10], Mercury: [3, 12], Moon: [1],
Saturn: [7, 8], Sun: [2], Venus: [4, 11], Nibiru: [13],
},
},
strength: {
shadbala_ranking: [
{ planet: "Venus", rupas: 8.35, level: "极强" },
{ planet: "Saturn", rupas: 7.56, level: "极强" },
{ planet: "Mercury", rupas: 7.03, level: "充足" },
{ planet: "Jupiter", rupas: 6.97, level: "充足" },
{ planet: "Sun", rupas: 6.42, level: "强" },
{ planet: "Moon", rupas: 6.42, level: "充足" },
{ planet: "Mars", rupas: 5.79, level: "充足" },
{ planet: "Nibiru", rupas: 99, level: "极强" },
],
},
...(options.financeNarrative
? {
finance_narrative: {
headline: `财富承诺已经成立,${"细".repeat(600)}`,
strengths: ["二宫与十一宫同时得到吉星支持,收入结构偏向可持续积累。"],
risks: [
"支出节奏比收入节奏更快,现金留存能力弱于账面收入。",
"VedAstro 官方财富日窗口尚未取得验证,不能作为时间依据。",
],
boundaries: ["未完成本命财富承诺与双重大运交叉前,不得写成高置信度结论。"],
},
}
: {}),
},
};
}
return {
success: true,
chart,
consumer_context: {
route: "general",
core_status: "ready",
available_layers: AVAILABLE_LAYERS,
missing_route_layers: [],
hard_blockers: [],
answer_policy: { can_answer_direction: true, can_answer_precise_timing: false },
},
machine_evidence_packet: { sections: machineSections(), conflicts: [] },
};
}
const THEMES = ["general", "wealth", "education"] as const;
function buildBundle(options: FixtureOptions = {}): ReportEvidenceBundleV2 {
const workflow = workflowFixture(options);
return buildReportEvidenceBundleV2({
workflows: THEMES.map((theme) => ({ theme, workflow })),
subject: { displayName: "冒烟用户", birthTimeStatus: "reported", birthPlaceLabel: "冒烟市" },
requestedThemes: [...THEMES],
reportType: "personal_full",
presentationMode: "default",
skillSnapshot: {
name: "jyotish-personal-report",
version: "1.0.0",
sha256: "a".repeat(64),
sourceCommit: null,
},
});
}
// ---------------------------------------------------------------------------
// Task 0/1: the extraction layer reads every field the contract snapshot has
// ---------------------------------------------------------------------------
test("bundle carries the interpretive layers the engine actually computes", () => {
const bundle = buildBundle();
const facts = bundle.interpretiveFacts;
// Yogas: chart.yogas detections + modules.yogas rows with hit:true only.
assert.deepEqual(
facts.yogas.map((yoga) => `${yoga.name}/${yoga.category}`).sort(),
[
"Amala Yoga/extended",
"Budha Aditya Yoga/other",
"Chandra Mangala Yoga/dhana",
"Gaja Kesari Yoga/raja",
],
);
assert.ok(!facts.yogas.some((yoga) => yoga.name === "Sunaphaa Yoga"), "hit:false rules are not detections");
const budha = facts.yogas.find((yoga) => yoga.name === "Budha Aditya Yoga")!;
assert.deepEqual([...budha.planets], ["Mercury"], "unknown celestial names are dropped");
assert.ok(facts.yogas.every((yoga) => yoga.evidenceRef === "ev-tech-yoga"));
// Functional roles: yogakaraka wins over the benefic/malefic lists.
assert.equal(facts.functionalRoles.length, 7);
const mars = facts.functionalRoles.find((role) => role.planet === "Mars")!;
assert.equal(mars.role, "yogakaraka");
assert.deepEqual([...mars.ownedHouses], [5, 10]);
assert.deepEqual(
facts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet),
["Mercury", "Saturn", "Venus"],
);
// Shadbala: ranks are re-derived from the ordering, unknown planets dropped.
assert.deepEqual(
facts.shadbalaRanking.map((row) => [row.planet, row.rank]),
// Sun and Moon tie at 6.42 rupas; the tie breaks alphabetically so the
// ranking (and therefore the bundle hash) is reproducible.
[["Venus", 1], ["Saturn", 2], ["Mercury", 3], ["Jupiter", 4], ["Moon", 5], ["Sun", 6], ["Mars", 7]],
);
assert.equal(facts.shadbalaRanking[0].rupa, 8.35);
// SAV: sign-keyed engine scores become whole-sign house scores.
assert.equal(facts.savScores.length, 12);
assert.equal(facts.savScores.find((row) => row.house === 1)!.score, 24, "house 1 == ascendant sign Cancer");
assert.equal(facts.savScores.find((row) => row.house === 2)!.score, 27, "house 2 == Leo");
assert.equal(facts.savTotal, 337);
assert.deepEqual(facts.currentDasha, {
mahadasha: "Rahu", antardasha: "Sun", start: "2011-11-11", end: "2029-11-11",
});
// Absent in the live consultation contract: reported as empty, never faked.
assert.deepEqual([...facts.convergenceDomains], []);
});
test("machine-section D2/D11/D24 receipts without varga_full demote wealth and education", () => {
const bundle = buildBundle({ structuredVargas: false });
assert.equal(bundle.claimCards.some((card) => card.theme === "wealth"), false);
assert.equal(bundle.claimCards.some((card) => card.theme === "education"), false);
assert.ok(bundle.blockedSections.some((section) => section.theme === "wealth"));
assert.ok(bundle.blockedSections.some((section) => section.theme === "education"));
assert.ok(bundle.claimCards.some((card) => card.theme === "general"));
assert.deepEqual(
bundle.themeNarrativeSeeds.map((seed) => seed.theme).filter((theme) => (
theme === "wealth" || theme === "education"
)),
[],
);
});
test("interpretive extraction rejects polluted planet names and over-long narrative text", () => {
const bundle = buildBundle({ pollutedNames: true, financeNarrative: true });
const benefics = bundle.interpretiveFacts.functionalRoles
.filter((role) => role.role === "benefic")
.map((role) => role.planet);
assert.deepEqual(benefics, ["Jupiter", "Moon"], "non-allowlisted names never enter the bundle");
const wealthSeed = bundle.themeNarrativeSeeds.find((seed) => seed.theme === "wealth")!;
assert.equal(wealthSeed.headline.length, 300, "headline is truncated to the schema bound");
assert.ok(wealthSeed.headline.startsWith("财富承诺已经成立"));
assert.deepEqual(
wealthSeed.risks,
["支出节奏比收入节奏更快,现金留存能力弱于账面收入。"],
"seed lines naming an external provider are dropped, not rewritten",
);
});
test("bundle hash is stable under interpretive field ordering", () => {
const bundle = buildBundle();
const { bundleHash: _hash, ...content } = bundle;
void _hash;
const shuffled = finalizeReportEvidenceBundleV2({
...content,
interpretiveFacts: {
...content.interpretiveFacts,
yogas: [...content.interpretiveFacts.yogas].reverse(),
functionalRoles: [...content.interpretiveFacts.functionalRoles].reverse(),
shadbalaRanking: [...content.interpretiveFacts.shadbalaRanking].reverse(),
savScores: [...content.interpretiveFacts.savScores].reverse(),
},
themeNarrativeSeeds: [...content.themeNarrativeSeeds].reverse().map((seed) => ({
...seed,
evidenceRefs: [...seed.evidenceRefs].reverse(),
})),
});
assert.equal(shuffled.bundleHash, bundle.bundleHash);
});
// ---------------------------------------------------------------------------
// Task 1: fail-closed validation of the new fields
// ---------------------------------------------------------------------------
test("validation fails closed on out-of-bounds interpretive facts and seeds", () => {
const bundle = buildBundle();
const mutate = (patch: Partial<ReportEvidenceBundleV2>) => (
{ ...bundle, ...patch } as ReportEvidenceBundleV2
);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: {
...bundle.interpretiveFacts,
yogas: [{ name: "Ghost Yoga", category: "raja", planets: [], evidenceRef: "ev-tech-not-real" }],
},
})), /report_bundle_invalid_interpretive_ref/);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: {
...bundle.interpretiveFacts,
shadbalaRanking: [
{ planet: "Venus", rank: 1, rupa: 1 },
{ planet: "Saturn", rank: 1, rupa: 1 },
],
},
})), /report_bundle_duplicate_shadbala_rank/);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: {
...bundle.interpretiveFacts,
functionalRoles: [{
planet: "Mars",
// @ts-expect-error unknown role must be rejected by the closed enum
role: "super_benefic",
ownedHouses: [1],
evidenceRef: "ev-tech-functional_benefic_malefic",
}],
},
})));
assert.throws(() => validateReportEvidenceBundleV2(mutate({
interpretiveFacts: { ...bundle.interpretiveFacts, savScores: [{ house: 13, score: 1 }] },
})));
assert.throws(() => validateReportEvidenceBundleV2(mutate({
themeNarrativeSeeds: [{
theme: "marriage",
headline: "未被请求的主题不得携带种子",
strengths: [], risks: [], boundaries: [], evidenceRefs: [],
}],
})), /report_bundle_seed_theme_not_requested/);
assert.throws(() => validateReportEvidenceBundleV2(mutate({
themeNarrativeSeeds: [{
theme: "general",
headline: "越界".repeat(200),
strengths: [], risks: [], boundaries: [], evidenceRefs: [],
}],
})));
assert.throws(() => validateReportEvidenceBundleV2(mutate({
themeNarrativeSeeds: [{
theme: "general",
headline: "引用不存在的证据",
strengths: [], risks: [], boundaries: [], evidenceRefs: ["ev-tech-not-real"],
}],
})), /report_bundle_invalid_seed_ref/);
});
// ---------------------------------------------------------------------------
// Task 2: claim cards carry conclusions, not receipts
// ---------------------------------------------------------------------------
test("claim card conclusions state real chart facts instead of an execution receipt", () => {
const bundle = buildBundle();
for (const card of bundle.claimCards) {
assert.ok(
!card.conclusion.includes("服务器已闭合"),
`${card.theme} still uses the receipt template`,
);
assert.ok(card.conclusion.includes("Cancer"), `${card.theme} conclusion names the ascendant`);
}
const wealth = bundle.claimCards.find((card) => card.theme === "wealth")!;
assert.ok(wealth.conclusion.includes("第 2 宫"), "wealth conclusion cites its own SAV houses");
assert.ok(wealth.conclusion.includes("第 11 宫"));
const health = bundle.claimCards.find((card) => card.theme === "general");
assert.ok(health && !health.conclusion.includes("第 2 宫"), "themes do not share one canned conclusion");
// Supporting facts are astrological statements bound to real receipts.
const values = wealth.supportingFacts.map((fact) => fact.value);
assert.ok(values.some((value) => value.includes("Rahu 主运")), "current dasha reaches the claim card");
assert.ok(values.some((value) => value.includes("Amala Yoga")), "detected yogas reach the claim card");
const refIds = new Set(bundle.evidenceRefs.map((ref) => ref.id));
for (const fact of [...wealth.supportingFacts, ...wealth.counterFacts]) {
assert.ok(refIds.has(fact.evidenceRef), `${fact.id} must bind a real evidence ref`);
}
});
test("engine risk lines become counter facts on the matching claim card", () => {
const bundle = buildBundle({ financeNarrative: true });
const wealth = bundle.claimCards.find((card) => card.theme === "wealth")!;
assert.deepEqual(
wealth.counterFacts.map((fact) => fact.value),
["支出节奏比收入节奏更快,现金留存能力弱于账面收入。"],
);
});
test("a theme without any narrative seed keeps the receipt wording and loses consensus", () => {
const seeded = buildBundle();
const bare = buildBundle({ interpretive: false });
const seededEducation = seeded.claimCards.find((card) => card.theme === "education")!;
assert.equal(seededEducation.assertionLevel, "multi_system_consensus");
const bareEducation = bare.claimCards.find((card) => card.theme === "education")!;
assert.equal(bare.themeNarrativeSeeds.length, 0, "no interpretive source means no seed");
assert.ok(bareEducation.conclusion.includes("服务器已闭合"));
assert.equal(
bareEducation.assertionLevel,
"single_system_inference",
"seed-less themes are capped below consensus",
);
});
test("consensus guard still rejects an upgraded assertion level", () => {
const bundle = buildBundle();
const card = bundle.claimCards.find((entry) => entry.theme === "wealth")!;
assert.notEqual(card.assertionLevel, "multi_system_consensus");
assert.throws(() => validateReportEvidenceBundleV2({
...bundle,
claimCards: bundle.claimCards.map((entry) => (
entry.theme === "wealth" ? { ...entry, assertionLevel: "multi_system_consensus" as const } : entry
)),
}), /report_bundle_invalid_consensus/);
});
// ---------------------------------------------------------------------------
// Task 1: per-section trimming
// ---------------------------------------------------------------------------
test("section filtering keeps the universal interpretive layer and trims the rest by theme", () => {
const bundle = buildBundle();
const plan = buildPersonalReportSectionPlan(bundle, "standard");
const wealthSection = plan.sections.find((section) => section.theme === "wealth")!;
const generalSection = plan.sections.find((section) => section.theme === "general")!;
const wealthBundle = filterReportEvidenceBundleForSection(bundle, wealthSection);
assert.deepEqual(wealthBundle.themeNarrativeSeeds.map((seed) => seed.theme), ["wealth"]);
assert.deepEqual(
wealthBundle.interpretiveFacts.savScores.map((row) => row.house),
[2, 11],
"only the theme's own SAV houses survive",
);
assert.equal(wealthBundle.interpretiveFacts.yogas.length, bundle.interpretiveFacts.yogas.length);
assert.equal(
wealthBundle.interpretiveFacts.functionalRoles.length,
bundle.interpretiveFacts.functionalRoles.length,
);
// general only requires D1, yet the chart-wide interpretive receipts ride along.
assert.ok(!generalSection.evidenceRefs.includes("ev-tech-functional_benefic_malefic"));
const generalBundle = filterReportEvidenceBundleForSection(bundle, generalSection);
const generalRefs = generalBundle.evidenceRefs.map((ref) => ref.id);
assert.ok(generalRefs.includes("ev-tech-functional_benefic_malefic"));
assert.ok(generalRefs.includes("ev-tech-yoga"));
assert.equal(generalBundle.interpretiveFacts.functionalRoles.length, 7);
assert.deepEqual(
generalBundle.interpretiveFacts.savScores.map((row) => row.house),
[1, 10],
);
assert.deepEqual(generalBundle.themeNarrativeSeeds.map((seed) => seed.theme), ["general"]);
});
test("an empty interpretive block still finalizes and hashes", () => {
const bundle = buildBundle();
const { bundleHash: _hash, ...content } = bundle;
void _hash;
const stripped = finalizeReportEvidenceBundleV2({
...content,
interpretiveFacts: emptyReportInterpretiveFacts(),
themeNarrativeSeeds: [],
});
assert.equal(stripped.interpretiveFacts.savTotal, null);
assert.notEqual(stripped.bundleHash, bundle.bundleHash, "the hash covers the new fields");
});