Files
Jyotisha/frontend/src/lib/rectification-agentic/core/split-holdout.ts
T
Jesse_ChenandCursor dd8f35f7ba fix(rectification): invite-first collect, holdout at 4 events, Skill 10.0.23 (BUG-646–648)
Stop domain-wheel collecting and age-band years in prompts. Ask until the training gate, then discriminate until convergence, then deliver a range plus a concrete follow-up. Reserve holdout only with four dated events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 01:43:02 +08:00

106 lines
4.1 KiB
TypeScript

import type { EventUsage, InferenceEvent } from "./types.ts";
export type DatedEventInput = Readonly<{
id: string;
domain: string;
year: number | null;
precision: InferenceEvent["precision"];
}>;
/** Reserve a holdout only when collection already has four dated events.
* Discrimination still waits until 3 training events remain.
*/
export const MIN_EVENTS_TO_RESERVE_HOLDOUT = 4;
export type HoldoutReservationStatus = "reserved" | "not_reserved_min_events";
export function holdoutReservationStatus(events: readonly DatedEventInput[]): HoldoutReservationStatus {
const dated = events.filter((item) => item.year !== null && item.precision !== "unknown");
if (dated.length < MIN_EVENTS_TO_RESERVE_HOLDOUT) return "not_reserved_min_events";
return splitHoldoutEvents(events).some((item) => item.usage === "holdout")
? "reserved"
: "not_reserved_min_events";
}
/**
* Domain-stratified holdout: keep at least one dated event out of training
* so the winner is not certified by the same fact that selected it.
*/
export function splitHoldoutEvents(events: readonly DatedEventInput[]): InferenceEvent[] {
const dated = events.filter((item) => item.year !== null && item.precision !== "unknown");
if (dated.length < MIN_EVENTS_TO_RESERVE_HOLDOUT) {
return events.map((item) => ({
...item,
usage: dated.some((row) => row.id === item.id) ? "training" as const : "unused" as const,
}));
}
const byDomain = new Map<string, DatedEventInput[]>();
for (const item of dated) {
const rows = byDomain.get(item.domain) ?? [];
rows.push(item);
byDomain.set(item.domain, rows);
}
const holdoutId = pickHoldoutId(byDomain, dated);
return events.map((item) => ({
...item,
usage: usageFor(item, holdoutId, dated),
}));
}
function usageFor(item: DatedEventInput, holdoutId: string | null, dated: readonly DatedEventInput[]): EventUsage {
if (item.year === null || item.precision === "unknown") return "unused";
if (item.id === holdoutId) return "holdout";
return dated.some((row) => row.id === item.id) ? "training" : "unused";
}
function pickHoldoutId(
byDomain: Map<string, DatedEventInput[]>,
dated: readonly DatedEventInput[],
): string | null {
const monthOrBetter = dated.filter((item) => item.precision === "day" || item.precision === "month");
const singletonDomain = [...byDomain.entries()].find(([, rows]) => rows.length === 1);
if (singletonDomain && dated.length - 1 >= 3) {
const preferred = monthOrBetter.find((item) => item.domain === singletonDomain[0]);
return (preferred ?? singletonDomain[1][0])?.id ?? null;
}
return (monthOrBetter.at(-1) ?? dated.at(-1))?.id ?? null;
}
export function trainingEventIds(events: readonly InferenceEvent[]): ReadonlySet<string> {
return new Set(events.filter((item) => item.usage === "training").map((item) => item.id));
}
export function holdoutEventIds(events: readonly InferenceEvent[]): ReadonlySet<string> {
return new Set(events.filter((item) => item.usage === "holdout").map((item) => item.id));
}
export function holdoutDomainYears(events: readonly InferenceEvent[]): ReadonlySet<string> {
return new Set(
events
.filter((item) => item.usage === "holdout" && item.year !== null)
.map((item) => `${item.domain}:${item.year}`),
);
}
/** Keep a previously reserved holdout sticky as more evidence arrives. */
export function stickyHoldoutEvents(
events: readonly DatedEventInput[],
previous?: readonly InferenceEvent[] | null,
): InferenceEvent[] {
const previousHoldout = previous?.find((item) => item.usage === "holdout");
if (previousHoldout && events.some((item) => item.id === previousHoldout.id)) {
const dated = events.filter((item) => item.year !== null && item.precision !== "unknown");
if (dated.length >= MIN_EVENTS_TO_RESERVE_HOLDOUT) {
return events.map((item) => ({
...item,
usage: item.id === previousHoldout.id
? "holdout" as const
: dated.some((row) => row.id === item.id)
? "training" as const
: "unused" as const,
}));
}
}
return splitHoldoutEvents(events);
}