787 lines
30 KiB
TypeScript
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),
|
|
});
|
|
}
|