Occupation answers were stored as career, so coverage never closed, yearless cards never fired, and the decision layer suppressed an engine-allowed range. Normalize occupation-collect writes, allow yearless cards once training is open, offer a range when no distinguish card remains, and align the public house table to the representative minute. Co-authored-by: Cursor <cursoragent@cursor.com>
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
/**
|
||
* 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<string>(EVIDENCE_KINDS);
|
||
const DOMAIN_SET = new Set<string>(EVIDENCE_DOMAINS);
|
||
const PRECISION_SET = new Set<string>(DATE_PRECISIONS);
|
||
const STATUS_SET = new Set<string>(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<EvidenceStatus, readonly EvidenceStatus[]>
|
||
> = {
|
||
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<Record<string, number>> = {
|
||
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<EvidenceKind> = 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<EvidenceKind> = new Set([
|
||
"appearance_note",
|
||
"birthmark_or_scar",
|
||
"occupation_note",
|
||
]);
|
||
|
||
export const NON_PRIMARY_SCORING_DOMAINS: ReadonlySet<string> = 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;
|
||
}
|
||
|
||
/** 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<T extends {
|
||
domain: string;
|
||
eventKind: EvidenceKind;
|
||
datePrecision: string;
|
||
occurredFrom: string | null;
|
||
occurredTo: string | null;
|
||
}>(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,
|
||
};
|
||
});
|
||
}
|