import { z } from "zod"; import { RECTIFICATION_POLICY } from "./rectification-policy.ts"; import type { JourneySnapshot } from "./birth-time-journey.ts"; const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); export const lifeEventDomainSchema = z.enum([ "education", "relocation", "relationship", "career", "finance", "health_pressure", ]); export const lifeEventPrecisionSchema = z.enum(["year", "month", "day"]); const lifeEventBase = { id: z.string().uuid(), domain: lifeEventDomainSchema, summary: z.string().trim().min(1).max(1_000).optional(), } as const; const supportedYearSchema = z.string().regex(/^(19|20)\d{2}$/).refine( (value) => Number(value) <= new Date().getFullYear(), "event year cannot be in the future", ); const monthDateSchema = z.string().regex(/^(19|20)\d{2}-(0[1-9]|1[0-2])$/).refine( (value) => value <= new Date().toISOString().slice(0, 7), "event month cannot be in the future", ); const dayDateSchema = z.string().regex(/^(19|20)\d{2}-(0[1-9]|1[0-2])-([0-2]\d|3[01])$/).refine( (value) => { const [year, month, day] = value.split("-").map(Number); const parsed = new Date(Date.UTC(year, month - 1, day)); return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day && value <= new Date().toISOString().slice(0, 10); }, "event day must be a real past or present calendar date", ); export const lifeEventSchema = z.discriminatedUnion("precision", [ z.object({ ...lifeEventBase, precision: z.literal("year"), date: supportedYearSchema }).strict(), z.object({ ...lifeEventBase, precision: z.literal("month"), date: monthDateSchema }).strict(), z.object({ ...lifeEventBase, precision: z.literal("day"), date: dayDateSchema }).strict(), ]).readonly(); export type LifeEvent = z.infer; export const evidenceDraftProposalSchema = z.object({ domain: lifeEventDomainSchema, precision: lifeEventPrecisionSchema.nullable(), date: z.string().trim().min(1).max(10).nullable(), }).strict().readonly(); export type EvidenceDraftProposal = z.infer; export const evidenceDraftSchema = z.object({ draftId: z.string().uuid(), questionId: z.string().trim().min(1).max(120), domain: lifeEventDomainSchema, precision: lifeEventPrecisionSchema.nullable(), date: z.string().trim().min(1).max(10).nullable(), status: z.literal("draft"), needsReview: z.boolean(), }).strict().readonly().superRefine((value, context) => { if ((value.precision === null || value.date === null) && !value.needsReview) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["needsReview"], message: "incomplete evidence drafts require review", }); } }); export type EvidenceDraft = z.infer; const candidateEvidenceSchema = z.object({ eventId: z.string().uuid(), domain: lifeEventDomainSchema, candidateTime: timeSchema, ruleIds: z.array(z.string().trim().min(1)), points: z.number(), }).strict().readonly(); const rectificationTechniqueReceiptSchema = z.object({ calculationStatus: z.enum(["not_started", "evaluated"]), usedDivisionalCharts: z.array(z.string()), usedArudha: z.array(z.string()), dashaTracks: z.array(z.string()), missingLayers: z.array(z.string()), auxiliaryLayers: z.array(z.string()).default([]), hardBlockers: z.array(z.string()), externalEngines: z.object({ status: z.string(), providers: z.array(z.string()), validation: z.record(z.string(), z.unknown()).optional(), }).strict().optional(), canonicalInputHash: z.string().optional(), confirmationAllowed: z.boolean().optional(), decision: z.enum(["continue_rectification", "confirm_minute"]).optional(), gates: z.record(z.string(), z.object({ status: z.enum(["pass", "fail", "blocked", "not_evaluated"]), reason: z.string(), }).strict()).optional(), }).strict().readonly(); export const candidateResultSchema = z.object({ resultId: z.string().uuid(), confidence: z.enum(["low", "medium", "high"]), canApply: z.boolean(), winningSegment: z.object({ startTime: timeSchema, endTime: timeSchema, representativeTime: timeSchema, widthMinutes: z.number().int().min(1).max(1_440), }).strict().readonly().nullable(), eventCount: z.number().int().min(0), domainCount: z.number().int().min(0).max(6), topScore: z.number(), secondScore: z.number(), marginPercent: z.number().min(0), reasons: z.array(z.string().trim().min(1)), evidence: z.array(candidateEvidenceSchema), algorithmVersion: z.string().trim().min(1), techniqueReceipt: rectificationTechniqueReceiptSchema.optional(), }).strict().readonly().superRefine((value, context) => { const eligible = value.confidence === "high" && highCandidateMeetsSafetyGates(value); if (value.confidence === "high" && value.eventCount < RECTIFICATION_POLICY.minConfirmationEvents) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["eventCount"], message: "high candidates require at least four effective evidence items", }); } if (value.confidence === "high" && value.domainCount < RECTIFICATION_POLICY.minConfirmationDomains) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["domainCount"], message: "high candidates require at least three domains", }); } if (value.confidence === "high" && value.winningSegment === null) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["winningSegment"], message: "high candidates require a winning segment", }); } if (value.confidence === "high" && value.winningSegment !== null && value.winningSegment.widthMinutes > RECTIFICATION_POLICY.maxConfirmationWidthMinutes) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["winningSegment", "widthMinutes"], message: "high candidate segments cannot exceed five minutes", }); } if (value.confidence === "high" && value.marginPercent < RECTIFICATION_POLICY.minConfirmationMarginPercent) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["marginPercent"], message: "high candidates require at least twenty percent margin", }); } if (value.canApply && !eligible) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["canApply"], message: "only a high candidate with a winning segment may enter confirmation", }); } }); export type CandidateResult = z.infer; function highCandidateMeetsSafetyGates(value: { readonly confidence: CandidateResult["confidence"]; readonly winningSegment: CandidateResult["winningSegment"]; readonly eventCount: number; readonly domainCount: number; readonly marginPercent: number; }): boolean { return value.confidence === "high" && value.winningSegment !== null && value.eventCount >= RECTIFICATION_POLICY.minConfirmationEvents && value.domainCount >= RECTIFICATION_POLICY.minConfirmationDomains && value.winningSegment.widthMinutes <= RECTIFICATION_POLICY.maxConfirmationWidthMinutes && value.marginPercent >= RECTIFICATION_POLICY.minConfirmationMarginPercent; } export class CandidateConfirmationError extends Error { readonly name = "CandidateConfirmationError"; } export function withCandidateResult( snapshot: JourneySnapshot, result: CandidateResult, ): JourneySnapshot { switch (result.confidence) { case "low": return { ...snapshot, state: "rectifying", assistantIntent: "explain_event_evidence_insufficient", input: "life_events", confidence: "low", canApply: false, activeTime: null, }; case "medium": return { ...snapshot, state: "candidate", assistantIntent: "present_candidate_result", input: "candidate_actions", confidence: "medium", canApply: false, activeTime: null, }; case "high": if (!result.canApply) { return { ...snapshot, state: "candidate", assistantIntent: "present_candidate_result", input: "candidate_actions", confidence: "high", canApply: false, activeTime: null, }; } return { ...snapshot, state: "confirming", assistantIntent: "confirm_candidate_time", input: "candidate_confirmation", confidence: "high", canApply: true, activeTime: null, }; } } export function withConfirmedCandidate( snapshot: JourneySnapshot, result: CandidateResult, confirmedTime: string, ): JourneySnapshot { if ( snapshot.state !== "confirming" || !snapshot.canApply || !highCandidateMeetsSafetyGates(result) || !result.canApply || result.winningSegment?.representativeTime !== confirmedTime ) { throw new CandidateConfirmationError(); } return { ...snapshot, state: "ready", assistantIntent: "confirmed_candidate_time", input: "none", route: "direct_chart", confidence: "high", canApply: false, activeTime: confirmedTime, }; }