Files
Jyotisha/frontend/src/lib/rectification-agentic/core/cluster-candidates.ts
T
Jesse_ChenandCursor 29750d3835
Independent Staging Quality Gate / validate (push) Failing after 9m53s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(web): persist C/D rectification answers without waiting for rescore
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>
2026-08-23 22:41:01 +08:00

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];
}