Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/evidence-model.ts
T
Jesse_ChenandCursor 3847e9c91d
Independent Staging Quality Gate / validate (push) Failing after 8m39s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(rectification): collect a dated third event before OOS and occupation
Holdout stays closed until the training gate opens and skips declined domains.
Dated collect order is shared with exhaustion; spoken prompts must name the domain; progress copy uses collection_progress only.

BUG-527 through BUG-530.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 18:17:17 +08:00

393 lines
12 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;
}
export type CollectionProgress = Readonly<{
scoreable: number;
minimum: number;
missing: number;
}>;
export function collectionProgressFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): CollectionProgress | null {
const gates = receipt && typeof receipt.gates === "object" && receipt.gates && !Array.isArray(receipt.gates)
? receipt.gates as Record<string, unknown>
: null;
const quality = gates && typeof gates.event_quality === "object" && gates.event_quality && !Array.isArray(gates.event_quality)
? gates.event_quality as Record<string, unknown>
: 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<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,
};
});
}