Files
Jyotisha/frontend/src/lib/personal-report-contract.ts
T
2026-08-15 05:10:37 +08:00

590 lines
26 KiB
TypeScript

/**
* ReportDocument v1/v2 contract (isomorphic Zod side).
*
* ReportDocument v2 is the canonical current contract. The v1 schema and
* compatibility type alias remain readable while the existing producer and
* renderer migrate independently. This file contains no node:crypto; evidence
* hashes are recomputed only by the server entry and the Python validator.
*
* JSON Schema draft-07 cannot express every semantic rule. The runtime guards
* below are mirrored by scripts/personal_report_contract.py: chart integrity,
* requested-theme coverage, evidence-reference closure, evidence-id
* uniqueness, blocked-claim safety, unsupported date rejection, content
* safety, and the serialization cap.
*/
import { z } from "zod";
export const LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v2" as const;
export const REPORT_DOCUMENT_V1_SCHEMA_VERSION = LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION;
export const REPORT_DOCUMENT_V2_SCHEMA_VERSION = REPORT_DOCUMENT_SCHEMA_VERSION;
export const CURRENT_REPORT_DOCUMENT_SCHEMA_VERSION = REPORT_DOCUMENT_SCHEMA_VERSION;
export const REPORT_CONTRACT_V1_VERSION = "1" as const;
export const REPORT_CONTRACT_V2_VERSION = "2" as const;
/** @deprecated Existing v1 producer compatibility. */
export const REPORT_CONTRACT_VERSION = REPORT_CONTRACT_V1_VERSION;
export const REPORT_DOCUMENT_MAX_BYTES = 1_572_864; // 1.5 MiB hard cap.
export const CLAIM_STATUSES = [
"multi_system_consensus",
"single_system_inference",
"parameter_sensitive",
"unclosed_divisional_chart",
"user_history_verification_required",
"blocked",
] as const;
export type ClaimStatus = (typeof CLAIM_STATUSES)[number];
export const REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
export const PRESENTATION_MODES = ["default", "research"] as const;
export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type ReportDepth = (typeof REPORT_DEPTHS)[number];
export const BIRTH_TIME_STATUSES = ["reported", "candidate", "accepted", "confirmed"] as const;
export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const;
export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
export const REPORT_ACTION_PRIORITIES = ["now", "next", "watch"] as const;
export const REPORT_DOCUMENT_V1_CHART_IDS = ["D1", "D9", "D10"] as const;
export const CHART_IDS = ["D1", "D2", "D9", "D10", "D11", "D24"] as const;
const claimStatusSchema = z.enum(CLAIM_STATUSES);
const themeIdSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/, "invalid theme id");
const sectionIdSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,95}$/, "invalid section id");
const evidenceIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id");
const sha256HexSchema = z.string().regex(/^[0-9a-f]{64}$/, "invalid sha256 hex");
const skillNameSchema = z.string()
.max(120)
.regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/, "invalid skill name");
const skillVersionSchema = z.string()
.max(80)
.regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, "invalid skill version");
const iso8601Schema = z.string()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/, "invalid ISO-8601 timestamp");
const text = (maxLength: number, minLength = 1) => z.string().min(minLength).max(maxLength);
const textArray = (maxItems: number, maxLength: number, minItems = 0) =>
z.array(text(maxLength)).min(minItems).max(maxItems);
const evidenceRefsSchema = (minItems = 0) => z.array(evidenceIdSchema).min(minItems).max(24);
const houseSchema = z.strictObject({
houseNumber: z.number().int().min(1).max(12),
sign: text(40),
occupants: textArray(12, 40),
});
const planetSchema = z.strictObject({
name: text(40),
sign: text(40),
longitudeDegrees: z.number().min(0).lt(360),
houseNumber: z.number().int().min(1).max(12),
retrograde: z.boolean(),
});
const reportDocumentV1ChartSchema = z.strictObject({
id: z.enum(REPORT_DOCUMENT_V1_CHART_IDS),
title: text(120),
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(12).optional(),
claimStatus: claimStatusSchema,
});
const reportDocumentV2ChartSchema = z.strictObject({
id: z.enum(CHART_IDS),
title: text(120),
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(12).optional(),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const thematicSectionV1Schema = z.strictObject({
id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id"),
title: text(160),
narrative: text(4000),
actions: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(),
});
const thematicSectionV2Schema = z.strictObject({
id: sectionIdSchema,
theme: themeIdSchema,
title: text(160),
narrative: text(4000),
actions: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const techniqueAuditRowSchema = z.strictObject({
id: evidenceIdSchema,
techniqueId: z.string().regex(/^[a-z0-9_.-]{1,80}$/, "invalid technique id"),
techniqueName: text(160),
status: z.enum(TECHNIQUE_STATUSES),
used: z.boolean(),
notes: z.string().max(500).optional(),
});
const conflictRowSchema = z.strictObject({
id: evidenceIdSchema,
description: text(1000),
impact: text(500),
status: z.enum(CONFLICT_STATUSES),
});
const calculationEvidenceRowSchema = z.strictObject({
id: evidenceIdSchema,
label: text(160),
value: text(500),
source: text(200),
});
const evidenceAppendixSchema = z.strictObject({
expandedByDefault: z.boolean(),
techniqueAudit: z.array(techniqueAuditRowSchema).max(100),
conflicts: z.array(conflictRowSchema).max(50),
calculationEvidence: z.array(calculationEvidenceRowSchema).max(100),
blockedTechniques: textArray(100, 120),
});
const subjectSchema = z.strictObject({
displayName: text(120),
birthTimeStatus: z.enum(BIRTH_TIME_STATUSES),
birthPlaceLabel: text(200),
});
const provenanceV1Schema = z.strictObject({
// Optional only so legacy v1 rows generated before named/versioned Skill
// provenance remain readable. Current v2 generation requires both.
skillName: skillNameSchema.optional(),
skillVersion: skillVersionSchema.optional(),
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
skillSnapshotSha256: sha256HexSchema,
calculationHash: sha256HexSchema,
evidenceHash: sha256HexSchema,
reportContractVersion: z.literal(REPORT_CONTRACT_V1_VERSION),
});
const provenanceV2Schema = z.strictObject({
skillName: skillNameSchema,
skillVersion: skillVersionSchema,
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
skillSnapshotSha256: sha256HexSchema,
calculationHash: sha256HexSchema,
evidenceHash: sha256HexSchema,
reportContractVersion: z.literal(REPORT_CONTRACT_V2_VERSION),
});
const executiveSummaryV1Schema = z.strictObject({
headline: text(200),
summary: text(2000),
priorities: textArray(8, 200),
overallClaimStatus: claimStatusSchema,
});
const executiveSummaryV2Schema = z.strictObject({
headline: text(200),
summary: text(2000),
priorities: textArray(8, 200),
overallClaimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const natalFoundationSchema = z.strictObject({
title: text(160),
narrative: text(4000),
keyFactors: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const currentPhaseSchema = z.strictObject({
title: text(160),
phaseLabel: text(200),
narrative: text(4000),
timingNotes: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const actionNoteSchema = z.strictObject({
id: sectionIdSchema,
title: text(160),
note: text(1000),
priority: z.enum(REPORT_ACTION_PRIORITIES),
evidenceRefs: evidenceRefsSchema(1),
});
const blockedConflictDisclosureSchema = z.strictObject({
theme: themeIdSchema,
title: text(160),
reason: text(2000),
missingEvidence: textArray(24, 400, 1),
conflictNotes: textArray(24, 500),
evidenceRefs: evidenceRefsSchema(),
claimStatus: z.literal("blocked"),
});
export const reportDocumentV1Schema = z.strictObject({
schemaVersion: z.literal(REPORT_DOCUMENT_V1_SCHEMA_VERSION),
reportId: z.string().uuid(),
reportType: z.enum(REPORT_TYPES),
presentationMode: z.enum(PRESENTATION_MODES),
generatedAt: iso8601Schema,
subject: subjectSchema,
provenance: provenanceV1Schema,
executiveSummary: executiveSummaryV1Schema,
charts: z.array(reportDocumentV1ChartSchema).min(1).max(3),
thematicNarrative: z.array(thematicSectionV1Schema).max(12),
evidenceAppendix: evidenceAppendixSchema,
disclaimer: text(2000),
});
export const reportDocumentV2Schema = z.strictObject({
schemaVersion: z.literal(REPORT_DOCUMENT_V2_SCHEMA_VERSION),
reportId: z.string().uuid(),
reportType: z.enum(REPORT_TYPES),
presentationMode: z.enum(PRESENTATION_MODES),
depth: z.enum(REPORT_DEPTHS),
requestedThemes: z.array(themeIdSchema).min(1).max(12),
generatedAt: iso8601Schema,
subject: subjectSchema,
provenance: provenanceV2Schema,
executiveSummary: executiveSummaryV2Schema,
natalFoundation: natalFoundationSchema,
currentPhase: currentPhaseSchema.nullable(),
actionNotes: z.array(actionNoteSchema).min(1).max(24),
charts: z.array(reportDocumentV2ChartSchema).min(1).max(6),
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
evidenceAppendix: evidenceAppendixSchema,
disclaimer: text(2000),
});
/** Canonical reader accepts current v2 plus stored v1 rows during migration. */
export const reportDocumentSchema = z.union([reportDocumentV2Schema, reportDocumentV1Schema]);
export type LegacyReportDocumentV1 = z.infer<typeof reportDocumentV1Schema>;
export type ReportDocumentV2 = z.infer<typeof reportDocumentV2Schema>;
export type ReportDocument = LegacyReportDocumentV1 | ReportDocumentV2;
/** @deprecated Compatibility name retained for existing producer/renderer imports. */
export type ReportDocumentV1 = LegacyReportDocumentV1;
export type EvidenceAppendix = ReportDocument["evidenceAppendix"];
export type ChartV1 = LegacyReportDocumentV1["charts"][number];
export type ChartV2 = ReportDocumentV2["charts"][number];
export type ThematicSectionV1 = LegacyReportDocumentV1["thematicNarrative"][number];
export type ThematicSectionV2 = ReportDocumentV2["thematicNarrative"][number];
export type NatalFoundationV2 = ReportDocumentV2["natalFoundation"];
export type CurrentPhaseV2 = ReportDocumentV2["currentPhase"];
export type ActionNoteV2 = ReportDocumentV2["actionNotes"][number];
export type BlockedConflictDisclosureV2 = ReportDocumentV2["blockedConflictDisclosure"][number];
export type ReportDocumentParseError = Readonly<{
path: string;
message: string;
}>;
export class ReportDocumentValidationError extends Error {
readonly errors: readonly ReportDocumentParseError[];
constructor(errors: readonly ReportDocumentParseError[]) {
super(errors.map((error) => `${error.path}: ${error.message}`).join("; "));
this.name = "ReportDocumentValidationError";
this.errors = errors;
}
}
export function isReportDocumentV2(document: ReportDocument): document is ReportDocumentV2 {
return document.schemaVersion === REPORT_DOCUMENT_V2_SCHEMA_VERSION;
}
/** Canonical evidence object used by the evidence hash on both language sides. */
export function canonicalEvidence(appendix: EvidenceAppendix): Record<string, unknown> {
return {
techniqueAudit: appendix.techniqueAudit.map((row) => ({
id: row.id,
techniqueId: row.techniqueId,
techniqueName: row.techniqueName,
status: row.status,
used: row.used,
...(row.notes !== undefined ? { notes: row.notes } : {}),
})),
conflicts: appendix.conflicts.map((row) => ({
id: row.id,
description: row.description,
impact: row.impact,
status: row.status,
})),
calculationEvidence: appendix.calculationEvidence.map((row) => ({
id: row.id,
label: row.label,
value: row.value,
source: row.source,
})),
};
}
export function serializedReportDocumentBytes(document: ReportDocument): number {
return new TextEncoder().encode(JSON.stringify(document)).length;
}
/** Keep regex semantics equivalent to FORBIDDEN_PATTERNS on the Python side. */
export const FORBIDDEN_CONTENT_PATTERNS: readonly Readonly<{ name: string; pattern: RegExp }>[] = [
{ name: "html_tag", pattern: /<\s*\/?\s*[a-z][^>]*>/i },
{ name: "event_handler", pattern: /\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*=/i },
{ name: "style_attribute", pattern: /\bstyle\s*=/i },
{ name: "css_at_rule", pattern: /@(?:import|media|supports|font-face|keyframes)\b/i },
{ name: "css_rule", pattern: /(?:^|[}\s])(?:[.#]?[a-z][a-z0-9_-]*)(?:\s+[.#]?[a-z][a-z0-9_-]*)*\s*\{[^{}]*\}/i },
{ name: "css_declaration", pattern: /(?:^|[;{\s])(?:color|background(?:-color)?|font(?:-family|-size|-weight)?|display|position|margin|padding|width|height|grid|flex|border|transform|animation)\s*:\s*[^;\n{}]+[;}]?/i },
{ name: "executable_url", pattern: /\b(?:javascript|vbscript|data:text\/html|data:text\/javascript|file):/i },
{ name: "processing_instruction", pattern: /<\?/i },
{ name: "template_literal", pattern: /\$\{/i },
{ name: "stack_trace", pattern: /(?:Traceback \(most recent call last\)|node:internal\/| at (?:Object|async|node)\.)/i },
{ name: "dunder_path", pattern: /__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__/i },
{ name: "process_env", pattern: /\bprocess\.env\b/i },
{ name: "unix_home_path", pattern: /(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]/i },
{ name: "windows_drive_path", pattern: /^[a-zA-Z]:[\\/]/i },
{ name: "jwt_token", pattern: /\beyJ[A-Za-z0-9_-]{20,}\b/i },
{ name: "secret_marker", pattern: /\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b/i },
{ name: "tool_trace", pattern: /\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b/i },
{ name: "chain_of_thought", pattern: /\bchain[\s_-]?of[\s_-]?thought\b/i },
{ name: "medical_diagnosis", pattern: /(?:你|命主)(?:已经|已|必将|一定会|确定)?(?:患有|罹患|得了)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|(?:确诊为|诊断为)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|\b(?:you|the native)\s+(?:definitely\s+)?(?:have|has|will develop)\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b|\bdiagnosed with\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b/i },
{ name: "deterministic_financial_promise", pattern: /(?:保证收益|保本保收益|稳赚不赔|稳赚|必赚|确定(?:盈利|获利|回报)|一定会(?:赚钱|盈利|获利))|\b(?:guaranteed|certain|risk-free)\s+(?:profit|return|gain)s?\b|\bwill definitely\s+(?:profit|earn|make money)\b/i },
];
export const BLOCKED_DETERMINISTIC_PHRASES: readonly string[] = [
"必然", "必定", "一定会", "肯定会", "绝对会", "保证会", "无疑将", "百分之百", "确定无疑",
"guaranteed", "definitely will", "certainly will", "will certainly", "is certain to",
];
const DATE_CLAIM_PATTERN = /(?:^|[^0-9])(?:19|20)\d{2}(?:年(?:0?[1-9]|1[0-2])月(?:(?:0?[1-9]|[12]\d|3[01])日)?|[-/.](?:0?[1-9]|1[0-2])(?:[-/.](?:0?[1-9]|[12]\d|3[01]))?)(?:[^0-9]|$)/i;
export function findForbiddenContent(value: string): readonly string[] {
return FORBIDDEN_CONTENT_PATTERNS
.filter(({ pattern }) => pattern.test(value))
.map(({ name }) => name);
}
function textLeaves(value: unknown, path = ""): readonly Readonly<{ path: string; text: string }>[] {
if (typeof value === "string") return [{ path: path || "(root)", text: value }];
if (Array.isArray(value)) return value.flatMap((entry, index) => textLeaves(entry, `${path}[${index}]`));
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, entry]) => textLeaves(entry, path ? `${path}.${key}` : key));
}
return [];
}
function blockedTexts(document: ReportDocument): readonly Readonly<{ path: string; text: string }>[] {
const entries: { path: string; text: string }[] = [];
if (document.executiveSummary.overallClaimStatus === "blocked") {
entries.push(...textLeaves(document.executiveSummary, "executiveSummary"));
}
document.charts.forEach((chart, index) => {
if (chart.claimStatus === "blocked") entries.push(...textLeaves(chart, `charts[${index}]`));
});
document.thematicNarrative.forEach((section, index) => {
if (section.claimStatus === "blocked") entries.push(...textLeaves(section, `thematicNarrative[${index}]`));
});
if (isReportDocumentV2(document)) {
if (document.natalFoundation.claimStatus === "blocked") {
entries.push(...textLeaves(document.natalFoundation, "natalFoundation"));
}
if (document.currentPhase?.claimStatus === "blocked") {
entries.push(...textLeaves(document.currentPhase, "currentPhase"));
}
document.blockedConflictDisclosure.forEach((section, index) => {
entries.push(...textLeaves(section, `blockedConflictDisclosure[${index}]`));
});
}
return entries;
}
export function findBlockedDeterministicClaims(document: ReportDocument): readonly string[] {
const phrases = BLOCKED_DETERMINISTIC_PHRASES.map((phrase) => new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"));
return blockedTexts(document)
.filter(({ text }) => phrases.some((pattern) => pattern.test(text)))
.map(({ path }) => path);
}
function evidenceRefEntries(document: ReportDocument): readonly Readonly<{ path: string; refs: readonly string[] }>[] {
const entries: { path: string; refs: readonly string[] }[] = document.thematicNarrative.map((section, index) => ({
path: document.schemaVersion === REPORT_DOCUMENT_V1_SCHEMA_VERSION ? section.id : `thematicNarrative[${index}].evidenceRefs`,
refs: section.evidenceRefs,
}));
if (!isReportDocumentV2(document)) return entries;
entries.push({ path: "executiveSummary.evidenceRefs", refs: document.executiveSummary.evidenceRefs });
entries.push({ path: "natalFoundation.evidenceRefs", refs: document.natalFoundation.evidenceRefs });
if (document.currentPhase) entries.push({ path: "currentPhase.evidenceRefs", refs: document.currentPhase.evidenceRefs });
document.actionNotes.forEach((note, index) => entries.push({ path: `actionNotes[${index}].evidenceRefs`, refs: note.evidenceRefs }));
document.charts.forEach((chart, index) => entries.push({ path: `charts[${index}].evidenceRefs`, refs: chart.evidenceRefs }));
document.blockedConflictDisclosure.forEach((section, index) => entries.push({ path: `blockedConflictDisclosure[${index}].evidenceRefs`, refs: section.evidenceRefs }));
return entries;
}
export function findDanglingEvidenceRefs(document: ReportDocument): readonly string[] {
const knownIds = new Set<string>([
...document.evidenceAppendix.techniqueAudit.map((row) => row.id),
...document.evidenceAppendix.conflicts.map((row) => row.id),
...document.evidenceAppendix.calculationEvidence.map((row) => row.id),
]);
return evidenceRefEntries(document).flatMap(({ path, refs }) =>
refs.filter((ref) => !knownIds.has(ref)).map((ref) => `${path}:${ref}`),
);
}
/** Evidence ids must be globally unique across the whole appendix. */
export function findDuplicateEvidenceIds(document: ReportDocument): readonly string[] {
const locations = new Map<string, string>();
const duplicates: string[] = [];
const rows: Readonly<{ key: string; index: number; id: string }>[] = [
...document.evidenceAppendix.techniqueAudit.map((row, index) => ({ key: "techniqueAudit", index, id: row.id })),
...document.evidenceAppendix.conflicts.map((row, index) => ({ key: "conflicts", index, id: row.id })),
...document.evidenceAppendix.calculationEvidence.map((row, index) => ({ key: "calculationEvidence", index, id: row.id })),
];
for (const { key, index, id } of rows) {
const location = `${key}[${index}]`;
const first = locations.get(id);
if (first !== undefined) duplicates.push(`evidence id ${id} used in both ${first} and ${location}`);
else locations.set(id, location);
}
return duplicates;
}
export function findChartSetViolations(document: ReportDocument): readonly string[] {
const violations: string[] = [];
const ids = document.charts.map((chart) => chart.id);
const d1Count = ids.filter((id) => id === "D1").length;
if (d1Count !== 1) violations.push(`charts must contain exactly one D1 chart, found ${d1Count}`);
const seen = new Set<string>();
for (const id of ids) {
if (seen.has(id)) violations.push(`duplicate chart id ${id}`);
seen.add(id);
}
document.charts.forEach((chart) => {
const numbers = chart.houses.map((house) => house.houseNumber);
if (new Set(numbers).size !== numbers.length) violations.push(`${chart.id} chart contains duplicate house numbers`);
});
const d1 = document.charts.find((chart) => chart.id === "D1");
if (d1) {
const unique = new Set(d1.houses.map((house) => house.houseNumber));
const expected = Array.from({ length: 12 }, (_, index) => index + 1);
if (d1.houses.length !== 12 || expected.some((number) => !unique.has(number))) {
violations.push("D1 chart must contain all twelve house numbers 1..12 exactly once");
}
}
return violations;
}
const REQUIRED_THEME_CHARTS: Readonly<Record<string, readonly (typeof CHART_IDS)[number][]>> = {
career: ["D10"],
wealth: ["D2", "D11"],
marriage: ["D9"],
education: ["D24"],
};
export function findThemeCoverageViolations(document: ReportDocumentV2): readonly string[] {
const violations: string[] = [];
const requestedCounts = new Map<string, number>();
document.requestedThemes.forEach((theme) => requestedCounts.set(theme, (requestedCounts.get(theme) ?? 0) + 1));
requestedCounts.forEach((count, theme) => {
if (count !== 1) violations.push(`requestedThemes must contain ${theme} exactly once, found ${count}`);
});
const coverageCounts = new Map<string, number>();
const requested = new Set(document.requestedThemes);
for (const section of document.thematicNarrative) {
coverageCounts.set(section.theme, (coverageCounts.get(section.theme) ?? 0) + 1);
if (!requested.has(section.theme)) violations.push(`thematic section covers unrequested theme ${section.theme}`);
}
for (const section of document.blockedConflictDisclosure) {
coverageCounts.set(section.theme, (coverageCounts.get(section.theme) ?? 0) + 1);
if (!requested.has(section.theme)) violations.push(`blocked disclosure covers unrequested theme ${section.theme}`);
}
for (const theme of requested) {
const count = coverageCounts.get(theme) ?? 0;
if (count !== 1) violations.push(`requested theme ${theme} must have exactly one thematic section or blocked disclosure, found ${count}`);
}
const chartIds = new Set(document.charts.map((chart) => chart.id));
for (const section of document.thematicNarrative) {
for (const requiredChart of REQUIRED_THEME_CHARTS[section.theme] ?? []) {
if (!chartIds.has(requiredChart)) {
violations.push(`thematic section ${section.theme} requires structured ${requiredChart} chart data or a blocked disclosure`);
}
}
}
return violations;
}
export function findUnsupportedDateClaims(document: ReportDocument): readonly string[] {
if (!isReportDocumentV2(document)) return [];
const sections: Readonly<{ path: string; value: unknown; refs: readonly string[] }>[] = [
{ path: "executiveSummary", value: document.executiveSummary, refs: document.executiveSummary.evidenceRefs },
{ path: "natalFoundation", value: document.natalFoundation, refs: document.natalFoundation.evidenceRefs },
...(document.currentPhase ? [{ path: "currentPhase", value: document.currentPhase, refs: document.currentPhase.evidenceRefs }] : []),
...document.thematicNarrative.map((section, index) => ({ path: `thematicNarrative[${index}]`, value: section, refs: section.evidenceRefs })),
...document.actionNotes.map((note, index) => ({ path: `actionNotes[${index}]`, value: note, refs: note.evidenceRefs })),
...document.blockedConflictDisclosure.map((section, index) => ({ path: `blockedConflictDisclosure[${index}]`, value: section, refs: section.evidenceRefs })),
];
return sections
.filter(({ value, refs }) => refs.length === 0 && textLeaves(value).some(({ text }) => DATE_CLAIM_PATTERN.test(text)))
.map(({ path }) => `${path}: date claim requires evidenceRefs`);
}
export function validateReportDocumentGuards(document: ReportDocument): readonly string[] {
const errors: string[] = [];
errors.push(...findChartSetViolations(document));
errors.push(...findDuplicateEvidenceIds(document));
errors.push(...findBlockedDeterministicClaims(document).map((path) => `${path}: blocked section contains deterministic prediction`));
errors.push(...findDanglingEvidenceRefs(document).map((ref) => `evidenceRefs: unknown evidence id ${ref}`));
errors.push(...findUnsupportedDateClaims(document));
if (isReportDocumentV2(document)) errors.push(...findThemeCoverageViolations(document));
for (const { path, text: value } of textLeaves(document)) {
const hits = findForbiddenContent(value);
if (hits.length > 0) errors.push(`${path}: forbidden content ${hits.join(",")}`);
}
const size = serializedReportDocumentBytes(document);
if (size > REPORT_DOCUMENT_MAX_BYTES) {
errors.push(`serialized document is ${size} bytes, exceeding ${REPORT_DOCUMENT_MAX_BYTES}`);
}
return errors;
}
export type ReportDocumentParseResult =
| Readonly<{ ok: true; document: ReportDocument }>
| Readonly<{ ok: false; errors: readonly ReportDocumentParseError[] }>;
export function safeParseReportDocument(input: unknown): ReportDocumentParseResult {
const parsed = reportDocumentSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
errors: parsed.error.issues.map((issue) => ({
path: issue.path.join(".") || "(root)",
message: issue.message,
})),
};
}
const document = parsed.data;
const guardErrors = validateReportDocumentGuards(document);
if (guardErrors.length > 0) {
return {
ok: false,
errors: guardErrors.map((message) => ({ path: "(guard)", message })),
};
}
return { ok: true, document };
}
export function parseReportDocument(input: unknown): ReportDocument {
const result = safeParseReportDocument(input);
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
return result.document;
}