Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/evidence-model.ts
T
Jesse_ChenandCursor 02d4c91626
Independent Staging Quality Gate / validate (push) Successful in 11m40s
Independent Staging Quality Gate / publish (push) Successful in 9m36s
fix(rectification): stop occupation coverage from locking questions and the range exit
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>
2026-08-30 03:05:28 +08:00

363 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};
});
}