468 lines
18 KiB
TypeScript
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),
|
|
});
|
|
}
|