Files
Jyotisha/frontend/src/lib/report-evidence-bundle-v2.ts
T
2026-09-23 10:42:17 +08:00

787 lines
30 KiB
TypeScript

import { createHash } from "node:crypto";
import { z } from "zod";
import { reportFactTablesSchema } from "./report-fact-table-schema";
export type ClaimStatus =
| "multi_system_consensus"
| "single_system_inference"
| "parameter_sensitive"
| "unclosed_divisional_chart"
| "user_history_verification_required"
| "blocked";
export type EvidenceRefStatus = "verified" | "partial" | "blocked";
export type ReportEvidenceRef = Readonly<{
id: string;
technique: string;
status: EvidenceRefStatus;
}>;
export type ReportDashaPeriod = Readonly<{ lord: string; start: string; end: string }>;
export type ReportChartHouse = Readonly<{
number: number;
sign: string;
signDerived: boolean;
occupants: readonly string[];
}>;
export type ReportPlanetFact = Readonly<{
id: string;
sign: string;
degree: number;
house: number | null;
retrograde: boolean | null;
}>;
export type ReportChartFact = Readonly<{
id: string;
title: string;
ascendant?: Readonly<{ sign: string; degree: number }> | null;
houses: readonly ReportChartHouse[];
planets: readonly ReportPlanetFact[];
}>;
export type EvidenceFact = Readonly<{
id: string;
label: string;
value: string;
evidenceRef: string;
status: EvidenceRefStatus;
}>;
export type ReportClaimCard = Readonly<{
id: string;
theme: string;
section: string;
conclusion: string;
supportingFacts: readonly EvidenceFact[];
counterFacts: readonly EvidenceFact[];
executedTechniqueRefs: readonly string[];
assertionLevel: ClaimStatus;
timingBoundary: string | null;
verificationQuestions: readonly string[];
}>;
export type BlockedSection = Readonly<{
id: string;
theme: string;
section: string;
reason: string;
missingTechniqueRefs: readonly string[];
}>;
export type EvidenceConflict = Readonly<{
id: string;
techniqueRefs: readonly string[];
summary:
| "所列技法的结果存在未闭合冲突,报告不得据此作确定性提升。"
| "本次证据存在未闭合冲突,报告必须披露该边界。";
resolutionStatus: "unresolved" | "bounded";
}>;
export type TechniqueExecutionReceipt = Readonly<{
id: string;
technique: string;
status: EvidenceRefStatus;
executed: boolean;
note: string;
}>;
export type SafeReportSubject = Readonly<{
displayName: string;
birthTimeStatus: "reported" | "candidate" | "accepted" | "confirmed";
birthPlaceLabel: string;
}>;
export type CalculationProfileReceipt = Readonly<{
calculationHash: string;
calculationHashDerived: boolean;
birthTimeStatus: SafeReportSubject["birthTimeStatus"];
ayanamsa: string | null;
nodeMode: string | null;
houseSystem: string | null;
vimshottari: readonly ReportDashaPeriod[] | null;
narayana: readonly ReportDashaPeriod[] | null;
}>;
export type SkillPackageIdentity = Readonly<{
name: string;
version: string;
sha256: string;
sourceCommit: string | null;
}>;
export type ReportAnswerPolicy = Readonly<{
canAnswerPreciseTiming: boolean;
birthTimePolicy: "reported_directional_only" | "candidate_directional_only" | "accepted_directional_only" | "confirmed";
deterministicClaimsForbiddenFor: readonly (
| "timing"
| "medical"
| "investment"
| "exact_dates"
| "medical_diagnosis"
| "investment_guarantees"
| "kp_system"
| "muhurta"
| "gochara_event_timing"
| "sahams"
| "sphuta_trisphuta_family"
| "tajika_yogas"
| "conception_chart"
| "relationship_combinations"
)[];
}>;
/**
* Closed vocabulary for yoga families. The engine emits dozens of raw rule
* categories; the extraction layer folds them into this closed set and maps
* anything unrecognised to "other" so the bundle never carries a free string.
*/
export const REPORT_YOGA_CATEGORIES = [
"raja", "dhana", "mahapurusha", "nabhasa", "lunar", "solar",
"relationship", "progeny", "education", "health", "spiritual",
"affliction", "conjunction", "auspicious", "special", "extended", "other",
] as const;
export type ReportYogaCategory = (typeof REPORT_YOGA_CATEGORIES)[number];
export const REPORT_FUNCTIONAL_ROLES = ["benefic", "malefic", "neutral", "yogakaraka"] as const;
export type ReportFunctionalRole = (typeof REPORT_FUNCTIONAL_ROLES)[number];
export type ReportYogaFact = Readonly<{
name: string;
category: ReportYogaCategory;
planets: readonly string[];
evidenceRef: string;
}>;
export type ReportFunctionalRoleFact = Readonly<{
planet: string;
role: ReportFunctionalRole;
ownedHouses: readonly number[];
evidenceRef: string;
}>;
export type ReportShadbalaRankFact = Readonly<{
planet: string;
rank: number;
rupa: number | null;
}>;
export type ReportSavScoreFact = Readonly<{ house: number; score: number }>;
export type ReportCurrentDashaFact = Readonly<{
mahadasha: string;
antardasha: string | null;
start: string;
end: string;
}>;
export type ReportPlanetaryFriendshipFact = Readonly<{
planet: string;
greatFriends: readonly string[];
friends: readonly string[];
neutral: readonly string[];
enemies: readonly string[];
greatEnemies: readonly string[];
}>;
export type ReportPratyantarTimeline = Readonly<{
current: ReportDashaPeriod | null;
next: ReportDashaPeriod | null;
}>;
export type ReportBirthTimeSensitivityFact = Readonly<{
window: Readonly<{
startTime: string;
endTime: string;
representativeTime: string;
candidateCount: number;
}>;
themes: readonly Readonly<{
theme: string;
status: "stable" | "sensitive";
stableLayers: readonly string[];
sensitiveLayers: readonly string[];
minuteVariations: readonly Readonly<{
layer: string;
values: readonly Readonly<{ minute: string; value: string }>[];
}>[];
}>[];
claimBoundary: string;
}>;
/**
* Server-computed interpretive facts. Every entry is a closed vocabulary value
* or a bounded number projected deterministically from the engine response;
* no model output and no user text may ever reach these fields.
*/
export type ReportInterpretiveFacts = Readonly<{
yogas: readonly ReportYogaFact[];
functionalRoles: readonly ReportFunctionalRoleFact[];
shadbalaRanking: readonly ReportShadbalaRankFact[];
savScores: readonly ReportSavScoreFact[];
savTotal: number | null;
currentDasha: ReportCurrentDashaFact | null;
convergenceDomains: readonly string[];
planetaryFriendship: readonly ReportPlanetaryFriendshipFact[];
pratyantarTimeline: ReportPratyantarTimeline | null;
birthTimeSensitivity?: ReportBirthTimeSensitivityFact;
}>;
/**
* Per-theme narrative seed. Free text here is only ever copied from a
* server-side generator (thematic narrative payloads / guided-topic copy) or
* deterministically composed from the interpretive facts above. Chat history,
* the user question and model output are structurally excluded.
*/
export type ReportThemeNarrativeSeed = Readonly<{
theme: string;
headline: string;
strengths: readonly string[];
risks: readonly string[];
boundaries: readonly string[];
evidenceRefs: readonly string[];
}>;
export type ReportEvidenceBundleV2 = Readonly<{
schemaVersion: "report_evidence_bundle.v2";
bundleHash: string;
subject: SafeReportSubject;
requestedThemes: readonly string[];
reportType: "personal_full" | "personal_thematic";
presentationMode: "default" | "research";
calculationProfile: CalculationProfileReceipt;
skill: SkillPackageIdentity;
charts: readonly ReportChartFact[];
factTables?: import("./report-fact-table-schema").ReportFactTable[];
claimCards: readonly ReportClaimCard[];
interpretiveFacts: ReportInterpretiveFacts;
themeNarrativeSeeds: readonly ReportThemeNarrativeSeed[];
blockedSections: readonly BlockedSection[];
conflicts: readonly EvidenceConflict[];
executionLedger: readonly TechniqueExecutionReceipt[];
evidenceRefs: readonly ReportEvidenceRef[];
answerPolicy: ReportAnswerPolicy;
}>;
const idSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/);
const evidenceIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/);
const shaSchema = z.string().regex(/^[0-9a-f]{64}$/);
const statusSchema = z.enum(["verified", "partial", "blocked"]);
const signSchema = z.enum([
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]);
const celestialSchema = z.enum([
"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn",
"Rahu", "Ketu", "Uranus", "Neptune", "Pluto", "Ascendant", "Lagna",
]);
const calculationAyanamsaSchema = z.enum([
"Lahiri", "Raman", "Krishnamurti/KP", "Fagan-Bradley",
"Djwhal Khul", "Sassanian", "True Citra",
]);
const calculationNodeModeSchema = z.enum(["mean", "true"]);
const calculationHouseSystemSchema = z.enum([
"equal", "placidus", "porphyry", "sripati", "whole_sign", "koch",
]);
const conflictSummarySchema = z.enum([
"所列技法的结果存在未闭合冲突,报告不得据此作确定性提升。",
"本次证据存在未闭合冲突,报告必须披露该边界。",
]);
const deterministicClaimBoundarySchema = z.enum([
"timing",
"medical",
"investment",
"exact_dates",
"medical_diagnosis",
"investment_guarantees",
"kp_system",
"muhurta",
"gochara_event_timing",
"sahams",
"sphuta_trisphuta_family",
"tajika_yogas",
"conception_chart",
"relationship_combinations",
]);
const allowedTechniqueNames = new Set([
"D1", "D2", "D3", "D4", "D6", "D7", "D8", "D9", "D10", "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D60",
"A7", "A10", "UL", "DK", "AmK",
"Vimshottari", "Narayana", "Transit", "Yoga", "Ashtakavarga",
"Functional Benefic/Malefic", "Planet Degrees", "House Degrees",
"Planetary Friendship",
]);
const missingTechniquePattern = /^(?:career|marriage|wealth|education|migration_home|family|health_pressure|timing|general):(?:D1|D2|D4|D6|D6\/D30|D7\/D12|D8|D9|D10|D11|D12|D16|D20|D24|D27|D30|D40|D60|A7|A10|UL|DK|AmK|Vimshottari|Narayana|Transit|Yoga|Ashtakavarga)$/;
const techniqueSchema = z.string().max(80).refine(
(value) => allowedTechniqueNames.has(value) || missingTechniquePattern.test(value),
"report_bundle_technique_not_allowlisted",
);
function isValidDashaDate(value: string): boolean {
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (dateOnly) {
const year = Number(dateOnly[1]);
if (year < 1600 || year > 2400) return false;
const parsed = new Date(`${value}T00:00:00.000Z`);
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
return false;
}
const parsed = new Date(value);
const year = parsed.getUTCFullYear();
return Number.isFinite(parsed.getTime()) && year >= 1600 && year <= 2400;
}
const dashaDateSchema = z.string().refine(isValidDashaDate, "report_bundle_dasha_date_invalid");
const dashaPeriodSchema = z.object({
lord: z.union([celestialSchema, signSchema]),
start: dashaDateSchema,
end: dashaDateSchema,
}).strict().superRefine((period, context) => {
if (Date.parse(period.start) >= Date.parse(period.end)) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "report_bundle_dasha_period_invalid" });
}
});
const claimStatusSchema = z.enum([
"multi_system_consensus",
"single_system_inference",
"parameter_sensitive",
"unclosed_divisional_chart",
"user_history_verification_required",
"blocked",
]);
const houseSchema = z.object({
number: z.number().int().min(1).max(12),
sign: signSchema,
signDerived: z.boolean(),
occupants: z.array(celestialSchema).max(20),
}).strict();
const planetSchema = z.object({
id: celestialSchema,
sign: signSchema,
degree: z.number().finite(),
house: z.number().int().min(1).max(12).nullable(),
retrograde: z.boolean().nullable(),
}).strict();
/** Bounded, control-character-free interpretive prose. */
const seedTextSchema = (max: number) => z.string().min(1).max(max)
.refine((value) => !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(value), "report_bundle_seed_text_control_char");
const yogaNameSchema = z.string().min(1).max(80)
.regex(/^[\p{L}\p{N} ()/·,.'’++-]{1,80}$/u, "report_bundle_yoga_name_invalid");
const yogaCategorySchema = z.enum(REPORT_YOGA_CATEGORIES);
const functionalRoleSchema = z.enum(REPORT_FUNCTIONAL_ROLES);
const houseNumberSchema = z.number().int().min(1).max(12);
const interpretiveFactsSchema = z.object({
yogas: z.array(z.object({
name: yogaNameSchema,
category: yogaCategorySchema,
planets: z.array(celestialSchema).max(9),
evidenceRef: evidenceIdSchema,
}).strict()).max(40),
functionalRoles: z.array(z.object({
planet: celestialSchema,
role: functionalRoleSchema,
ownedHouses: z.array(houseNumberSchema).max(12),
evidenceRef: evidenceIdSchema,
}).strict()).max(12),
shadbalaRanking: z.array(z.object({
planet: celestialSchema,
rank: z.number().int().min(1).max(9),
rupa: z.number().finite().min(0).max(100).nullable(),
}).strict()).max(9),
savScores: z.array(z.object({
house: houseNumberSchema,
score: z.number().finite().min(0).max(100),
}).strict()).max(12),
savTotal: z.number().finite().min(0).max(1000).nullable(),
currentDasha: z.object({
mahadasha: celestialSchema,
antardasha: celestialSchema.nullable(),
start: dashaDateSchema,
end: dashaDateSchema,
}).strict().nullable(),
convergenceDomains: z.array(seedTextSchema(80)).max(6),
planetaryFriendship: z.array(z.object({
planet: celestialSchema,
greatFriends: z.array(celestialSchema).max(8),
friends: z.array(celestialSchema).max(8),
neutral: z.array(celestialSchema).max(8),
enemies: z.array(celestialSchema).max(8),
greatEnemies: z.array(celestialSchema).max(8),
}).strict()).max(9),
pratyantarTimeline: z.object({
current: dashaPeriodSchema.nullable(),
next: dashaPeriodSchema.nullable(),
}).strict().nullable(),
birthTimeSensitivity: z.object({
window: z.object({
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
representativeTime: z.string().regex(/^\d{2}:\d{2}$/),
candidateCount: z.number().int().min(2).max(15),
}).strict(),
themes: z.array(z.object({
theme: idSchema,
status: z.enum(["stable", "sensitive"]),
stableLayers: z.array(seedTextSchema(100)).max(24),
sensitiveLayers: z.array(seedTextSchema(100)).max(24),
minuteVariations: z.array(z.object({
layer: seedTextSchema(100),
values: z.array(z.object({
minute: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
value: seedTextSchema(300),
}).strict()).min(2).max(15),
}).strict()).max(24),
}).strict()).max(12),
claimBoundary: seedTextSchema(500),
}).strict().optional(),
}).strict();
const themeNarrativeSeedSchema = z.object({
theme: idSchema,
headline: seedTextSchema(300),
strengths: z.array(seedTextSchema(400)).max(8),
risks: z.array(seedTextSchema(400)).max(8),
boundaries: z.array(seedTextSchema(400)).max(8),
evidenceRefs: z.array(evidenceIdSchema).max(24),
}).strict();
const factSchema = z.object({
id: evidenceIdSchema,
label: z.string().min(1).max(160),
value: z.string().min(1).max(800),
evidenceRef: evidenceIdSchema,
status: statusSchema,
}).strict();
export const reportEvidenceBundleV2Schema = z.object({
schemaVersion: z.literal("report_evidence_bundle.v2"),
bundleHash: shaSchema,
subject: z.object({
displayName: z.string().min(1).max(160),
birthTimeStatus: z.enum(["reported", "candidate", "accepted", "confirmed"]),
birthPlaceLabel: z.string().min(1).max(200),
}).strict(),
requestedThemes: z.array(idSchema).min(1).max(12),
reportType: z.enum(["personal_full", "personal_thematic"]),
presentationMode: z.enum(["default", "research"]),
calculationProfile: z.object({
calculationHash: shaSchema,
calculationHashDerived: z.boolean(),
birthTimeStatus: z.enum(["reported", "candidate", "accepted", "confirmed"]),
ayanamsa: calculationAyanamsaSchema.nullable(),
nodeMode: calculationNodeModeSchema.nullable(),
houseSystem: calculationHouseSystemSchema.nullable(),
vimshottari: z.array(dashaPeriodSchema).nullable(),
narayana: z.array(dashaPeriodSchema).nullable(),
}).strict(),
skill: z.object({
name: z.string().min(1).max(120),
version: z.string().min(1).max(80),
sha256: shaSchema,
sourceCommit: z.string().regex(/^[0-9a-f]{40}$/).nullable(),
}).strict(),
charts: z.array(z.object({
id: z.string().regex(/^D[1-9][0-9]{0,2}$/),
title: z.string().min(1).max(160),
ascendant: z.object({
sign: signSchema,
degree: z.number().finite().min(0).max(360),
}).strict().nullable().optional(),
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(20),
}).strict()).min(1).max(24),
factTables: reportFactTablesSchema.optional(),
claimCards: z.array(z.object({
id: evidenceIdSchema,
theme: idSchema,
section: z.string().min(1).max(160),
conclusion: z.string().min(1).max(1200),
supportingFacts: z.array(factSchema).min(1).max(40),
counterFacts: z.array(factSchema).max(40),
executedTechniqueRefs: z.array(evidenceIdSchema).min(1).max(40),
assertionLevel: claimStatusSchema,
timingBoundary: z.string().max(200).nullable(),
verificationQuestions: z.array(z.string().min(1).max(300)).max(12),
}).strict()).max(48),
interpretiveFacts: interpretiveFactsSchema,
themeNarrativeSeeds: z.array(themeNarrativeSeedSchema).max(12),
blockedSections: z.array(z.object({
id: evidenceIdSchema,
theme: idSchema,
section: z.string().min(1).max(160),
reason: z.string().min(1).max(1000),
missingTechniqueRefs: z.array(z.string().min(1).max(120)).min(1).max(40),
}).strict()).max(24),
conflicts: z.array(z.object({
id: evidenceIdSchema,
techniqueRefs: z.array(evidenceIdSchema).max(40),
summary: conflictSummarySchema,
resolutionStatus: z.enum(["unresolved", "bounded"]),
}).strict()).max(100),
executionLedger: z.array(z.object({
id: evidenceIdSchema,
technique: techniqueSchema,
status: statusSchema,
executed: z.boolean(),
note: z.string().max(500),
}).strict()).min(1).max(200),
evidenceRefs: z.array(z.object({
id: evidenceIdSchema,
technique: techniqueSchema,
status: statusSchema,
}).strict()).min(1).max(300),
answerPolicy: z.object({
canAnswerPreciseTiming: z.boolean(),
birthTimePolicy: z.enum(["reported_directional_only", "candidate_directional_only", "accepted_directional_only", "confirmed"]),
deterministicClaimsForbiddenFor: z.array(deterministicClaimBoundarySchema).max(100),
}).strict(),
}).strict();
/**
* Empty interpretive-fact block. Used by callers that build a bundle without an
* engine snapshot (tests, legacy fixtures); a missing layer is represented as
* empty, never fabricated.
*/
export function emptyReportInterpretiveFacts(): ReportInterpretiveFacts {
return {
yogas: [],
functionalRoles: [],
shadbalaRanking: [],
savScores: [],
savTotal: null,
currentDasha: null,
convergenceDomains: [],
planetaryFriendship: [],
pratyantarTimeline: null,
};
}
function canonicalSerialize(value: unknown): string {
if (value === undefined) return "null";
if (Array.isArray(value)) return `[${value.map(canonicalSerialize).join(",")}]`;
if (value !== null && typeof value === "object") {
const source = value as Record<string, unknown>;
return `{${Object.keys(source).sort().map((key) => `${JSON.stringify(key)}:${canonicalSerialize(source[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function sortedUnique<T extends string>(values: readonly T[]): T[] {
return [...new Set(values)].sort();
}
function sortedFacts(facts: readonly EvidenceFact[]): EvidenceFact[] {
return [...facts].sort((a, b) => a.id.localeCompare(b.id));
}
function sortedBundleContent(bundle: Omit<ReportEvidenceBundleV2, "bundleHash">) {
return {
...bundle,
requestedThemes: sortedUnique(bundle.requestedThemes),
calculationProfile: {
...bundle.calculationProfile,
vimshottari: bundle.calculationProfile.vimshottari
? [...bundle.calculationProfile.vimshottari].sort((a, b) => `${a.start}:${a.end}:${a.lord}`.localeCompare(`${b.start}:${b.end}:${b.lord}`))
: null,
narayana: bundle.calculationProfile.narayana
? [...bundle.calculationProfile.narayana].sort((a, b) => `${a.start}:${a.end}:${a.lord}`.localeCompare(`${b.start}:${b.end}:${b.lord}`))
: null,
},
charts: [...bundle.charts].map((chart) => ({
...chart,
houses: [...chart.houses].map((house) => ({
...house,
occupants: sortedUnique(house.occupants),
})).sort((a, b) => a.number - b.number),
planets: [...chart.planets].sort((a, b) => a.id.localeCompare(b.id)),
})).sort((a, b) => a.id.localeCompare(b.id)),
claimCards: [...bundle.claimCards].map((card) => ({
...card,
supportingFacts: sortedFacts(card.supportingFacts),
counterFacts: sortedFacts(card.counterFacts),
executedTechniqueRefs: sortedUnique(card.executedTechniqueRefs),
verificationQuestions: sortedUnique(card.verificationQuestions),
})).sort((a, b) => a.id.localeCompare(b.id)),
interpretiveFacts: {
...bundle.interpretiveFacts,
yogas: [...bundle.interpretiveFacts.yogas]
.map((yoga) => ({ ...yoga, planets: sortedUnique(yoga.planets) }))
.sort((a, b) => `${a.name}:${a.category}`.localeCompare(`${b.name}:${b.category}`)),
functionalRoles: [...bundle.interpretiveFacts.functionalRoles]
.map((role) => ({ ...role, ownedHouses: [...new Set(role.ownedHouses)].sort((a, b) => a - b) }))
.sort((a, b) => a.planet.localeCompare(b.planet)),
shadbalaRanking: [...bundle.interpretiveFacts.shadbalaRanking].sort((a, b) => (
a.rank - b.rank || a.planet.localeCompare(b.planet)
)),
savScores: [...bundle.interpretiveFacts.savScores].sort((a, b) => a.house - b.house),
convergenceDomains: sortedUnique(bundle.interpretiveFacts.convergenceDomains),
planetaryFriendship: [...bundle.interpretiveFacts.planetaryFriendship]
.map((row) => ({
...row,
greatFriends: sortedUnique(row.greatFriends),
friends: sortedUnique(row.friends),
neutral: sortedUnique(row.neutral),
enemies: sortedUnique(row.enemies),
greatEnemies: sortedUnique(row.greatEnemies),
}))
.sort((a, b) => a.planet.localeCompare(b.planet)),
pratyantarTimeline: bundle.interpretiveFacts.pratyantarTimeline,
...(bundle.interpretiveFacts.birthTimeSensitivity ? {
birthTimeSensitivity: {
...bundle.interpretiveFacts.birthTimeSensitivity,
themes: [...bundle.interpretiveFacts.birthTimeSensitivity.themes]
.map((theme) => ({
...theme,
stableLayers: sortedUnique(theme.stableLayers),
sensitiveLayers: sortedUnique(theme.sensitiveLayers),
minuteVariations: [...theme.minuteVariations]
.map((variation) => ({
...variation,
values: [...variation.values].sort((a, b) => a.minute.localeCompare(b.minute)),
}))
.sort((a, b) => a.layer.localeCompare(b.layer)),
}))
.sort((a, b) => a.theme.localeCompare(b.theme)),
},
} : {}),
},
themeNarrativeSeeds: [...bundle.themeNarrativeSeeds]
.map((seed) => ({ ...seed, evidenceRefs: sortedUnique(seed.evidenceRefs) }))
.sort((a, b) => a.theme.localeCompare(b.theme)),
blockedSections: [...bundle.blockedSections].map((section) => ({
...section,
missingTechniqueRefs: sortedUnique(section.missingTechniqueRefs),
})).sort((a, b) => a.id.localeCompare(b.id)),
conflicts: [...bundle.conflicts].map((conflict) => ({
...conflict,
techniqueRefs: sortedUnique(conflict.techniqueRefs),
})).sort((a, b) => a.id.localeCompare(b.id)),
executionLedger: [...bundle.executionLedger].sort((a, b) => a.id.localeCompare(b.id)),
evidenceRefs: [...bundle.evidenceRefs].sort((a, b) => a.id.localeCompare(b.id)),
answerPolicy: {
...bundle.answerPolicy,
deterministicClaimsForbiddenFor: sortedUnique(bundle.answerPolicy.deterministicClaimsForbiddenFor),
},
};
}
export function computeReportEvidenceBundleHash(bundle: Omit<ReportEvidenceBundleV2, "bundleHash">): string {
return createHash("sha256").update(canonicalSerialize(sortedBundleContent(bundle))).digest("hex");
}
export function validateReportEvidenceBundleV2(bundle: ReportEvidenceBundleV2): ReportEvidenceBundleV2 {
const parsed = reportEvidenceBundleV2Schema.parse(bundle) as ReportEvidenceBundleV2;
if (parsed.calculationProfile.birthTimeStatus !== parsed.subject.birthTimeStatus) {
throw new Error("report_bundle_birth_time_status_mismatch");
}
const uniqueThemes = new Set(parsed.requestedThemes);
if (uniqueThemes.size !== parsed.requestedThemes.length) throw new Error("report_bundle_duplicate_theme");
const claimThemes = new Set(parsed.claimCards.map((card) => card.theme));
const blockedThemes = new Set(parsed.blockedSections.map((section) => section.theme));
if (claimThemes.size !== parsed.claimCards.length) throw new Error("report_bundle_duplicate_claim_theme");
if (blockedThemes.size !== parsed.blockedSections.length) throw new Error("report_bundle_duplicate_blocked_theme");
for (const theme of parsed.requestedThemes) {
const coverageCount = Number(claimThemes.has(theme)) + Number(blockedThemes.has(theme));
if (coverageCount !== 1) throw new Error(`report_bundle_theme_coverage_invalid:${theme}`);
}
const ledger = new Map(parsed.executionLedger.map((receipt) => [receipt.id, receipt]));
const evidenceRefs = new Map(parsed.evidenceRefs.map((ref) => [ref.id, ref]));
for (const receipt of parsed.executionLedger) {
if (receipt.status === "verified" && !receipt.executed) {
throw new Error(`report_bundle_verified_receipt_not_executed:${receipt.id}`);
}
const evidenceRef = evidenceRefs.get(receipt.id);
if (!evidenceRef || evidenceRef.status !== receipt.status || evidenceRef.technique !== receipt.technique) {
throw new Error(`report_bundle_receipt_evidence_mismatch:${receipt.id}`);
}
}
for (const section of parsed.blockedSections) {
for (const ref of section.missingTechniqueRefs) {
const receipt = ledger.get(ref);
if (!receipt || receipt.executed || receipt.status !== "blocked") {
throw new Error(`report_bundle_invalid_missing_technique_ref:${ref}`);
}
}
}
for (const card of parsed.claimCards) {
for (const ref of card.executedTechniqueRefs) {
const receipt = ledger.get(ref);
if (!receipt || !receipt.executed || receipt.status === "blocked") {
throw new Error(`report_bundle_invalid_technique_ref:${ref}`);
}
}
if (card.assertionLevel === "multi_system_consensus") {
const verified = card.executedTechniqueRefs.filter((ref) => ledger.get(ref)?.status === "verified");
if (verified.length < 2 || verified.length !== card.executedTechniqueRefs.length) {
throw new Error(`report_bundle_invalid_consensus:${card.id}`);
}
}
for (const fact of [...card.supportingFacts, ...card.counterFacts]) {
const evidenceRef = evidenceRefs.get(fact.evidenceRef);
if (!evidenceRef) throw new Error(`report_bundle_invalid_fact_ref:${fact.evidenceRef}`);
if (fact.status === "verified" && evidenceRef.status !== "verified") {
throw new Error(`report_bundle_fact_status_upgrade:${fact.id}`);
}
}
}
// Interpretive facts and narrative seeds never widen the evidence surface:
// every ref must already exist in evidenceRefs, and every seed must belong to
// a requested theme. Anything else fails closed.
const facts = parsed.interpretiveFacts;
for (const yoga of facts.yogas) {
if (!evidenceRefs.has(yoga.evidenceRef)) {
throw new Error(`report_bundle_invalid_interpretive_ref:${yoga.evidenceRef}`);
}
}
const seenRolePlanets = new Set<string>();
for (const role of facts.functionalRoles) {
if (!evidenceRefs.has(role.evidenceRef)) {
throw new Error(`report_bundle_invalid_interpretive_ref:${role.evidenceRef}`);
}
if (seenRolePlanets.has(role.planet)) throw new Error(`report_bundle_duplicate_functional_role:${role.planet}`);
seenRolePlanets.add(role.planet);
}
const seenRanks = new Set<number>();
const seenRankPlanets = new Set<string>();
for (const entry of facts.shadbalaRanking) {
if (seenRanks.has(entry.rank)) throw new Error(`report_bundle_duplicate_shadbala_rank:${entry.rank}`);
if (seenRankPlanets.has(entry.planet)) throw new Error(`report_bundle_duplicate_shadbala_planet:${entry.planet}`);
seenRanks.add(entry.rank);
seenRankPlanets.add(entry.planet);
}
const seenSavHouses = new Set<number>();
for (const entry of facts.savScores) {
if (seenSavHouses.has(entry.house)) throw new Error(`report_bundle_duplicate_sav_house:${entry.house}`);
seenSavHouses.add(entry.house);
}
if (facts.currentDasha && Date.parse(facts.currentDasha.start) >= Date.parse(facts.currentDasha.end)) {
throw new Error("report_bundle_current_dasha_invalid");
}
const seenFriendshipPlanets = new Set<string>();
for (const row of facts.planetaryFriendship) {
if (seenFriendshipPlanets.has(row.planet)) {
throw new Error(`report_bundle_duplicate_planetary_friendship:${row.planet}`);
}
seenFriendshipPlanets.add(row.planet);
}
if (facts.pratyantarTimeline) {
for (const period of [facts.pratyantarTimeline.current, facts.pratyantarTimeline.next]) {
if (period && Date.parse(period.start) >= Date.parse(period.end)) {
throw new Error("report_bundle_pratyantar_period_invalid");
}
}
}
const seenSeedThemes = new Set<string>();
for (const seed of parsed.themeNarrativeSeeds) {
if (seenSeedThemes.has(seed.theme)) throw new Error(`report_bundle_duplicate_seed_theme:${seed.theme}`);
seenSeedThemes.add(seed.theme);
if (!parsed.requestedThemes.includes(seed.theme)) {
throw new Error(`report_bundle_seed_theme_not_requested:${seed.theme}`);
}
for (const ref of seed.evidenceRefs) {
if (!evidenceRefs.has(ref)) throw new Error(`report_bundle_invalid_seed_ref:${ref}`);
}
}
const { bundleHash: _bundleHash, ...content } = parsed;
void _bundleHash;
const expectedHash = computeReportEvidenceBundleHash(content);
if (expectedHash !== parsed.bundleHash) throw new Error("report_bundle_hash_mismatch");
return parsed;
}
export function finalizeReportEvidenceBundleV2(
bundle: Omit<ReportEvidenceBundleV2, "bundleHash">,
): ReportEvidenceBundleV2 {
const normalized = sortedBundleContent(bundle);
return validateReportEvidenceBundleV2({
...normalized,
bundleHash: computeReportEvidenceBundleHash(normalized),
});
}