Choice C/D without new evidence never changed the candidate posterior until the next dated-event rescore, and persist-v2 would cache-hit on the same evidence fingerprint. Patch the latest decision_receipt.inference_state in place so the next follow-up sees the asked split immediately. Co-authored-by: Cursor <cursoragent@cursor.com>
82 lines
2.9 KiB
TypeScript
82 lines
2.9 KiB
TypeScript
export type TimeCandidate = Readonly<{
|
|
id: string;
|
|
time: string;
|
|
score: number;
|
|
}>;
|
|
|
|
export type CandidateCluster = Readonly<{
|
|
id: string;
|
|
range_start: string;
|
|
range_end: string;
|
|
representative_time: string;
|
|
member_ids: readonly string[];
|
|
}>;
|
|
|
|
function toMinutes(value: string): number | null {
|
|
const match = /^(?:[01]\d|2[0-3]):([0-5]\d)$/.exec(value);
|
|
if (!match) return null;
|
|
return Number(value.slice(0, 2)) * 60 + Number(match[1]);
|
|
}
|
|
|
|
function fromMinutes(value: number): string {
|
|
const wrapped = ((value % 1440) + 1440) % 1440;
|
|
return `${String(Math.floor(wrapped / 60)).padStart(2, "0")}:${String(wrapped % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
/**
|
|
* Collapse adjacent minutes that the current techniques cannot tell apart.
|
|
* Transition times from the window scan break a cluster; equal scores with a
|
|
* one-minute gap do not.
|
|
*/
|
|
export function clusterEquivalentCandidates(
|
|
candidates: readonly TimeCandidate[],
|
|
transitionTimes: readonly string[] = [],
|
|
): CandidateCluster[] {
|
|
const ranked = [...candidates]
|
|
.map((item) => ({ item, minutes: toMinutes(item.time) }))
|
|
.filter((row): row is { item: TimeCandidate; minutes: number } => row.minutes !== null)
|
|
.sort((left, right) => left.minutes - right.minutes);
|
|
if (ranked.length === 0) return [];
|
|
const breaks = new Set(
|
|
transitionTimes.map(toMinutes).filter((value): value is number => value !== null),
|
|
);
|
|
const groups: { item: TimeCandidate; minutes: number }[][] = [];
|
|
for (const row of ranked) {
|
|
const current = groups[groups.length - 1];
|
|
const previous = current?.[current.length - 1];
|
|
const peak = current
|
|
? Math.max(...current.map((member) => member.item.score))
|
|
: row.item.score;
|
|
const closeScore = peak === 0
|
|
? Math.abs(row.item.score) < 1e-9
|
|
: Math.abs(row.item.score - peak) / Math.max(Math.abs(peak), 1e-9) <= 0.05;
|
|
const adjacent = previous ? (row.minutes - previous.minutes + 1440) % 1440 <= 2 : false;
|
|
const broken = breaks.has(row.minutes);
|
|
if (!current || broken || !adjacent || !closeScore) groups.push([row]);
|
|
else current.push(row);
|
|
}
|
|
return groups.map((group) => {
|
|
const start = fromMinutes(group[0]!.minutes);
|
|
const end = fromMinutes(group[group.length - 1]!.minutes);
|
|
const representative = group.reduce((best, row) => (
|
|
row.item.score > best.item.score ? row : best
|
|
));
|
|
return {
|
|
id: `eq:${start}-${end}`,
|
|
range_start: start,
|
|
range_end: end,
|
|
representative_time: representative.item.time,
|
|
member_ids: group.map((row) => row.item.id),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function clusterRangeFor(
|
|
clusters: readonly CandidateCluster[],
|
|
candidateId: string,
|
|
fallbackTime: string,
|
|
): readonly [string, string] {
|
|
const cluster = clusters.find((item) => item.member_ids.includes(candidateId));
|
|
return cluster ? [cluster.range_start, cluster.range_end] : [fallbackTime, fallbackTime];
|
|
}
|