Files
Jyotisha/frontend/src/lib/report-evidence-bundle-v2.ts
T
2026-08-15 02:15:45 +08:00

468 lines
18 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"
)[];
}>;
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[];
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();
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),
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();
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)),
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}`);
}
}
}
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),
});
}