/** * V9 evidence model contracts: event kinds, domains, date precision and the * append-only revision/status machine. IDs are always generated by the server. */ import { z } from "zod"; import { splitHoldoutEvents, type DatedEventInput } from "../core/split-holdout.ts"; export const EVIDENCE_KINDS = [ "education_start", "education_completion", "education_interruption", "education_change", "education_milestone", "career_entry", "career_change", "promotion", "career_pressure", "career_exit", "business_start", "relationship_start", "relationship_commitment", "relationship_separation", "relationship_end", "relationship_change", "relocation", "foreign_move", "return", "home_change", "finance_gain", "finance_loss", "income_change", "asset_change", "finance_change", "self_health_event", "pressure_period", "family_event", "appearance_note", "birthmark_or_scar", "occupation_note", "horary_query", "other", ] as const; export type EvidenceKind = (typeof EVIDENCE_KINDS)[number]; export const EVIDENCE_DOMAINS = [ "education", "career", "relationship", "relocation", "finance", "health", "health_pressure", "family", "appearance", "marks", "occupation", "horary", "other", ] as const; export type EvidenceDomain = (typeof EVIDENCE_DOMAINS)[number]; export const evidenceKindSchema = z.enum( EVIDENCE_KINDS as unknown as [EvidenceKind, ...EvidenceKind[]], ); export const evidenceDomainSchema = z.enum( EVIDENCE_DOMAINS as unknown as [EvidenceDomain, ...EvidenceDomain[]], ); export const DATE_PRECISIONS = [ "year", "month", "quarter", "day", "range", "unknown", ] as const; export type DatePrecision = (typeof DATE_PRECISIONS)[number]; export const EVIDENCE_STATUSES = [ "draft", "pending_confirmation", "confirmed", "superseded", "rejected", ] as const; export type EvidenceStatus = (typeof EVIDENCE_STATUSES)[number]; const KIND_SET = new Set(EVIDENCE_KINDS); const DOMAIN_SET = new Set(EVIDENCE_DOMAINS); const PRECISION_SET = new Set(DATE_PRECISIONS); const STATUS_SET = new Set(EVIDENCE_STATUSES); export function isEvidenceKind(value: unknown): value is EvidenceKind { return typeof value === "string" && KIND_SET.has(value); } export function isEvidenceDomain(value: unknown): value is EvidenceDomain { return typeof value === "string" && DOMAIN_SET.has(value); } export function isDatePrecision(value: unknown): value is DatePrecision { return typeof value === "string" && PRECISION_SET.has(value); } export function isEvidenceStatus(value: unknown): value is EvidenceStatus { return typeof value === "string" && STATUS_SET.has(value); } /** * Kinds that are semantically distinct and must never be folded together. * The scoring path keys on event_kind, so collapsing these would corrupt * both the ledger and the candidate contrast. */ export const DISTINCT_KIND_GROUPS: readonly (readonly EvidenceKind[])[] = [ ["career_entry", "career_pressure", "career_exit"], ["relationship_start", "relationship_commitment", "relationship_separation"], ]; /** * Legal evidence status transitions. Only the server confirmation path may * produce `confirmed`; a grounded draft may use that server path in the same run. */ export const EVIDENCE_STATUS_TRANSITIONS: Readonly< Record > = { draft: ["pending_confirmation", "confirmed", "rejected", "superseded"], pending_confirmation: ["confirmed", "rejected", "superseded"], confirmed: ["superseded"], superseded: [], rejected: [], }; export function canTransitEvidenceStatus( from: EvidenceStatus, to: EvidenceStatus, ): boolean { return EVIDENCE_STATUS_TRANSITIONS[from].includes(to); } /** * Normalized quote grounding contract: a user_quote is accepted only when it * is a substring match after whitespace/punctuation normalization of the * source turn's user message. */ const QUOTE_PUNCTUATION = /[\s\u3000,。!?、;:“”‘’()《》·—…,!.;:?]/g; export function normalizeQuote(value: string): string { return value.replace(QUOTE_PUNCTUATION, "").toLowerCase(); } const PRECISION_RANK: Readonly> = { day: 4, month: 3, quarter: 2, range: 2, year: 1, unknown: 0, }; export function datePrecisionRank(precision: string): number { return PRECISION_RANK[precision] ?? -1; } export function datedPrecision(value: string): DatedEventInput["precision"] { if (value === "day" || value === "month" || value === "year" || value === "unknown") return value; return "year"; } /** * Agent-facing date label. Day precision must never collapse to a year. */ export function displayDateLabel( precision: string, occurredFrom: string | null, occurredTo: string | null, ): string { const from = occurredFrom?.slice(0, 10) ?? ""; const to = occurredTo?.slice(0, 10) ?? ""; if (precision === "day" && from) return from; if (precision === "month" && from) return from.slice(0, 7); if (precision === "year" && from) return `${from.slice(0, 4)}年`; if (precision === "quarter" && from) return from.slice(0, 7); if (precision === "range") { if (from && to) return `${from}–${to}`; return from || to || "日期范围"; } if (precision === "unknown") return "日期不明"; return from || "日期不明"; } export function quoteIsGroundedInMessage( userMessage: string, quote: string, ): boolean { const normalizedMessage = normalizeQuote(userMessage); const normalizedQuote = normalizeQuote(quote); return ( normalizedQuote.length > 0 && normalizedMessage.includes(normalizedQuote) ); } /** Background kinds that never advance scoring coverage counts. */ export const BACKGROUND_ONLY_KINDS: ReadonlySet = new Set([ "other", "horary_query", ]); /** Notes that may cover a method layer but do not count as primary scoring events. */ export const AUXILIARY_EVIDENCE_KINDS: ReadonlySet = new Set([ "appearance_note", "birthmark_or_scar", "occupation_note", ]); export const NON_PRIMARY_SCORING_DOMAINS: ReadonlySet = new Set([ "appearance", "marks", "occupation", "horary", "other", ]); /** Same floors as `scripts/rectification/decision_policy.py`. Counted on training events only. */ export const MIN_ACCEPTANCE_EVENTS = 3; export const MIN_ACCEPTANCE_DOMAINS = 2; export function isBackgroundEvidenceKind(kind: EvidenceKind): boolean { return BACKGROUND_ONLY_KINDS.has(kind); } export function isPrimaryScoreableEvidence(item: { status: string; domain: string; datePrecision: string; occurredFrom: string | null; occurredTo: string | null; eventKind?: string | null; }): boolean { if (item.status !== "confirmed") return false; if (item.datePrecision === "unknown") return false; if (!item.occurredFrom && !item.occurredTo) return false; if (NON_PRIMARY_SCORING_DOMAINS.has(item.domain)) return false; const kind = item.eventKind; if ( kind === "other" || kind === "horary_query" || kind === "appearance_note" || kind === "birthmark_or_scar" || kind === "occupation_note" ) { return false; } return true; } type ScoreableEvidence = Readonly<{ id?: string; status: string; domain: string; datePrecision: string; occurredFrom: string | null; occurredTo: string | null; eventKind?: string | null; }>; function yearFromIso(value: string | null): number | null { if (!value || value.length < 4 || !/^\d{4}/.test(value)) return null; const year = Number(value.slice(0, 4)); return year >= 1900 && year <= 2100 ? year : null; } /** * Discrimination requires 3 training events / 2 training domains. * Holdout is reserved from the 2nd dated event but does not count. * 3 collected events with 1 holdout must keep collecting. */ export function trainingScoreableGate(evidence: readonly ScoreableEvidence[]): Readonly<{ trainingCount: number; trainingDomainCount: number; holdoutCount: number; open: boolean; }> { const scoreable = evidence.filter(isPrimaryScoreableEvidence); const dated: DatedEventInput[] = scoreable.map((item, index) => ({ id: item.id && item.id.trim() ? item.id : `scoreable:${index}:${item.domain}:${item.occurredFrom ?? ""}`, domain: item.domain, year: yearFromIso(item.occurredFrom) ?? yearFromIso(item.occurredTo), precision: datedPrecision(item.datePrecision), })); const split = splitHoldoutEvents(dated); const training = split.filter((item) => item.usage === "training"); const domains = new Set(training.map((item) => item.domain)); return { trainingCount: training.length, trainingDomainCount: domains.size, holdoutCount: split.filter((item) => item.usage === "holdout").length, open: training.length >= MIN_ACCEPTANCE_EVENTS && domains.size >= MIN_ACCEPTANCE_DOMAINS, }; } /** Reverse-inference / conflict probes wait until training coverage could accept. */ export function meetsAcceptanceEventQuality( evidence: readonly ScoreableEvidence[], ): boolean { return trainingScoreableGate(evidence).open; } export type CollectionProgress = Readonly<{ scoreable: number; minimum: number; missing: number; }>; export function collectionProgressFromReceipt( receipt: Readonly> | null | undefined, ): CollectionProgress | null { const gates = receipt && typeof receipt.gates === "object" && receipt.gates && !Array.isArray(receipt.gates) ? receipt.gates as Record : null; const quality = gates && typeof gates.event_quality === "object" && gates.event_quality && !Array.isArray(gates.event_quality) ? gates.event_quality as Record : null; if (!quality) return null; const scoreable = typeof quality.scoreable_event_count === "number" && Number.isFinite(quality.scoreable_event_count) ? quality.scoreable_event_count : null; const minimum = typeof quality.minimum === "number" && Number.isFinite(quality.minimum) ? quality.minimum : null; if (scoreable == null || minimum == null) return null; return { scoreable, minimum, missing: Math.max(0, minimum - scoreable), }; } /** Ledger/engine subject follows the domain. Family events must not stay on the tool default `self`. */ export function evidenceSubjectForDomain( domain: string, _subject?: string | null, ): "self" | "family" | "other" { if (domain === "family") return "family"; if (domain === "other") return "other"; return "self"; } export type OccupationCollectFocus = Readonly<{ intent?: string | null; targetDomain?: string | null; targetKind?: string | null; questionId?: string | null; }>; export function isOccupationCollectFocus( focus: OccupationCollectFocus | null | undefined, ): boolean { if (!focus) return false; const collectQuestion = typeof focus.questionId === "string" && focus.questionId.startsWith("collect:occupation:"); if (focus.intent && focus.intent !== "collect_method_evidence") return false; return focus.targetDomain === "occupation" || focus.targetKind === "occupation_note" || collectQuestion; } /** When the answered focus is occupation collect, do not trust the model domain. */ export function applyOccupationCollectLedgerNorm(focus: OccupationCollectFocus | null | undefined, items: readonly T[]): T[] { if (!isOccupationCollectFocus(focus)) return [...items]; return items.map((item) => { if (item.domain !== "career" && item.domain !== "occupation") return item; return { ...item, domain: "occupation", eventKind: "occupation_note" as EvidenceKind, datePrecision: "unknown", occurredFrom: null, occurredTo: null, }; }); }