The consultation workflow already returns a functional benefic/malefic table, a shadbala ranking, SAV scores, the current maha/antardasha, detected yogas and guided-topic copy. The report extraction layer threw all of it away, so claim cards could only say "the server closed the minimum evidence group" and the writer had no conclusions to work from. - ReportEvidenceBundleV2 gains interpretiveFacts (yogas, functionalRoles, shadbalaRanking, savScores/savTotal, currentDasha, convergenceDomains) and themeNarrativeSeeds. Both are required, allow empty, keep .strict(), are covered by the canonical sort + bundleHash, and fail closed on dangling refs, duplicate ranks/houses/themes and out-of-bound text. - Extraction is allowlist-style: closed enums for yoga category and functional role, safeCelestialName for planets, sign->whole-sign-house projection for SAV, and a forbidden-token scrub that drops any seed line naming an external provider or internal route. - Claim card conclusions and supportingFacts are now deterministic astrological statements built from those facts; risks become counterFacts. assertionLevel derivation is unchanged, and a theme with no seed keeps the old receipt wording with consensus capped down. - filterReportEvidenceBundleForSection trims seeds and SAV houses to the chapter's theme while letting the chart-wide interpretive receipts ride along, so every section can cite them. Contract snapshot taken from a real local /api/consultation_workflow call with fictional smoke birth data; the new fixture test locks the shapes that call actually returns, including the fields that are absent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
670 lines
26 KiB
TypeScript
670 lines
26 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { z } from "zod";
|
|
|
|
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;
|
|
}>;
|
|
|
|
/**
|
|
* 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[];
|
|
}>;
|
|
|
|
/**
|
|
* 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[];
|
|
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", "D4", "D6", "D7", "D9", "D10", "D11", "D12", "D24", "D30",
|
|
"A7", "A10", "UL", "DK", "AmK",
|
|
"Vimshottari", "Narayana", "Transit", "Yoga", "Ashtakavarga",
|
|
"Functional Benefic/Malefic", "Planet Degrees", "House Degrees",
|
|
]);
|
|
const missingTechniquePattern = /^(?:career|marriage|wealth|education|migration_home|family|health_pressure|timing|general):(?:D1|D2|D4|D6\/D30|D7\/D12|D9|D10|D11|D12|D24|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),
|
|
}).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),
|
|
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: [],
|
|
};
|
|
}
|
|
|
|
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),
|
|
},
|
|
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 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),
|
|
});
|
|
}
|