fix(rectification): keep clock-stamped events out of window intercept (BUG-631, BUG-632)
Independent Staging Quality Gate / validate (push) Successful in 12m36s
Independent Staging Quality Gate / publish (push) Successful in 2m11s

Dated life events with clock times were swallowed as birth-window replies, and compare columns still stopped at the first nine candidates after the hour-window cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 09:23:35 +08:00
co-authored by Cursor
parent 2de2aa0b71
commit 719ff55a09
19 changed files with 395 additions and 24 deletions
@@ -0,0 +1,30 @@
/**
* by_time ledgers default to the full engine candidate set (≤64).
* If a previous compare of that set exceeded the budget, the next score
* may pass only still-active inference minutes (BUG-632).
*/
export const COLUMN_COMPARE_BUDGET_MS = 3000;
export const MAX_COLUMN_COMPARE_TIMES = 64;
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
export function columnTimesForSlowCompare(input: {
previousColumnCompareMs: number | null | undefined;
activeTimes: readonly string[] | null | undefined;
}): string[] | undefined {
if (
input.previousColumnCompareMs == null
|| !Number.isFinite(input.previousColumnCompareMs)
|| input.previousColumnCompareMs <= COLUMN_COMPARE_BUDGET_MS
) {
return undefined;
}
const seen: string[] = [];
for (const raw of input.activeTimes ?? []) {
const clock = raw.slice(0, 5);
if (!CLOCK.test(clock) || seen.includes(clock)) continue;
seen.push(clock);
if (seen.length >= MAX_COLUMN_COMPARE_TIMES) break;
}
return seen.length > 0 ? seen : undefined;
}
@@ -1,13 +1,19 @@
/**
* Mid-session spoken birth-time windows are not a search-window change.
* Detect them so the route can answer with a fixed reply and skip the model.
*
* Clock-shaped text is not enough (BUG-631): dated life events and other
* non-birth sentences that happen to contain HH:MM must still reach the model.
*/
const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*[0-5]?\d(?:\s*分)?/;
const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*(?:半|[0-5]?\d(?:\s*分)?)?/;
const RANGE_SEP = /\s*(?:到|至|[-–—~])\s*/;
const AROUND = /\s*(?:左右|前后)/;
const RANGE_PATTERN = new RegExp(`(${CLOCK.source})${RANGE_SEP.source}(${CLOCK.source})`);
const AROUND_PATTERN = new RegExp(`(${CLOCK.source})${AROUND.source}`);
const BIRTH_CONTEXT = /出生时间|出生|生于|时辰|几点生|钟点是|(?<![发产陌])生的/;
const CALENDAR_DATE = /\d{4}\s*年|\d{1,2}\s*月|\d{1,2}\s*日/;
const BARE_FILLER = /上午|下午|傍晚|夜里|晚上|清晨|中午|我的|我|是|大概|大约|差不多|可能|就|在|的|了|吧|啊|呢|嗯|[,。,.、\s]|[-–—~]/g;
export type DeclaredBirthWindow =
| { kind: "range"; start: string; end: string }
@@ -20,21 +26,49 @@ export function parseDeclaredBirthWindow(message: string): DeclaredBirthWindow |
if (range) {
const start = normalizeClock(range[1] ?? "");
const end = normalizeClock(range[2] ?? "");
if (start && end && start !== end) return { kind: "range", start, end };
if (start && end && start !== end && shouldInterceptDeclaredWindow(text, range)) {
return { kind: "range", start, end };
}
if (start && end && start !== end) return null;
}
const around = AROUND_PATTERN.exec(text);
if (around) {
const time = normalizeClock(around[1] ?? "");
if (time) return { kind: "around", time };
if (time && shouldInterceptDeclaredWindow(text, around)) {
return { kind: "around", time };
}
}
return null;
}
function shouldInterceptDeclaredWindow(text: string, match: RegExpExecArray): boolean {
if (BIRTH_CONTEXT.test(text)) return true;
if (CALENDAR_DATE.test(text)) return false;
return isBareClockUtterance(text, match);
}
function isBareClockUtterance(text: string, match: RegExpExecArray): boolean {
const leftover = `${text.slice(0, match.index)}${text.slice(match.index + match[0].length)}`;
return leftover.replace(BARE_FILLER, "") === "";
}
function normalizeClock(raw: string): string | null {
const match = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || hour > 23 || !Number.isInteger(minute) || minute > 59) return null;
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
const half = /([01]?\d|2[0-3])\s*[::点]\s*/.exec(raw);
if (half) {
const hour = Number(half[1]);
if (!Number.isInteger(hour) || hour > 23) return null;
return `${String(hour).padStart(2, "0")}:30`;
}
const withMinutes = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
if (withMinutes) {
const hour = Number(withMinutes[1]);
const minute = Number(withMinutes[2]);
if (!Number.isInteger(hour) || hour > 23 || !Number.isInteger(minute) || minute > 59) return null;
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
const hourOnly = /([01]?\d|2[0-3])\s*点/.exec(raw);
if (!hourOnly) return null;
const hour = Number(hourOnly[1]);
if (!Number.isInteger(hour) || hour > 23) return null;
return `${String(hour).padStart(2, "0")}:00`;
}
@@ -518,6 +518,7 @@ export function engineRequestBody(input: {
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
askedProbeKeys?: readonly string[];
columnTimes?: readonly string[];
}): Record<string, unknown> {
const snapshot = input.baselineBirthSnapshot;
const birthDate = String(snapshot.birth_date ?? "");
@@ -531,6 +532,11 @@ export function engineRequestBody(input: {
throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine");
}
const askedProbeKeys = sanitizeAskedProbeKeysForEngine(input.askedProbeKeys);
const columnTimes = [...new Set(
(input.columnTimes ?? [])
.map((value) => value.slice(0, 5))
.filter((value) => timePattern.test(value)),
)].slice(0, 64);
return {
birth_date: birthDate,
start_time: input.candidateRange.start_time,
@@ -546,6 +552,7 @@ export function engineRequestBody(input: {
timezone_source: snapshot.timezone_source,
local_time_status: snapshot.local_time_status,
...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}),
...(columnTimes.length ? { column_times: columnTimes } : {}),
};
}
@@ -659,6 +666,7 @@ export async function runV9CandidateScore(input: {
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
askedProbeKeys?: readonly string[];
columnTimes?: readonly string[];
}): Promise<V9EngineScoreResult> {
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input));
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
@@ -22,6 +22,7 @@ import {
buildCaseInferenceState,
previousInferenceFromReceipt,
} from "./inference-adapter.ts";
import { columnTimesForSlowCompare } from "./column-times-for-compare.ts";
import { refinementFromDecisionReceipt } from "./refinement-packet.ts";
import { blockScanRequestExtras } from "./search-window.ts";
import {
@@ -360,11 +361,23 @@ export async function scoreAndPersistCurrentEvidence(input: {
};
}
const scoreStarted = Date.now();
const latestReceipt = dossier.latestResult?.decisionReceipt;
const previousCompareMs = typeof latestReceipt?.column_compare_ms === "number"
? latestReceipt.column_compare_ms
: null;
const previousInference = previousInferenceFromReceipt(latestReceipt);
const columnTimes = columnTimesForSlowCompare({
previousColumnCompareMs: previousCompareMs,
activeTimes: previousInference?.candidates
.filter((candidate) => candidate.status === "active")
.map((candidate) => candidate.time) ?? [],
});
const score = await runV9CandidateScore({
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange,
events,
askedProbeKeys,
columnTimes,
});
const engineCompareMs = Date.now() - scoreStarted;
const vedastroStarted = Date.now();