fix(rectification): count timeline width inclusively and keep eliminated minutes hollow (BUG-602, BUG-603)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 13:58:55 +08:00
co-authored by Cursor
parent d925251654
commit 92e3d5e7a3
11 changed files with 338 additions and 25 deletions
@@ -307,4 +307,40 @@ test("session_outcome gates adopt cards independently of leaked can_adopt", () =
assert.equal(canShowRectificationReadonlyRange(adopted), false);
});
test("parses inference marks from decisionReceipt.inference_state.candidates", () => {
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const result = parseRectificationCandidateResult({
...camelCaseSnapshot,
candidates: [
{ candidateId: CANDIDATE_ID, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 },
{ candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 },
],
credible_range: ["04:51", "04:53"],
decisionReceipt: {
...camelCaseSnapshot.decisionReceipt,
inference_state: {
candidates: times.map((clock) => ({
time: clock,
status: clock === "04:51" || clock === "04:53" ? "active" : "eliminated",
})),
},
},
});
assert.ok(result);
assert.ok(result.inferenceMarks);
assert.equal(result.candidates.length, 2);
assert.deepEqual(result.inferenceMarks.map((mark) => mark.time), [...times]);
assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7);
assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:51")?.eliminated, false);
assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:45")?.eliminated, true);
});
test("inference marks are empty when inference_state is absent", () => {
const result = parseRectificationCandidateResult(camelCaseSnapshot);
assert.ok(result);
assert.deepEqual(result.inferenceMarks, []);
});
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import {
buildRectificationTimeline,
formatClockMinutes,
@@ -86,7 +87,8 @@ test("the band is placed against the axis and never escapes it", () => {
assert.equal(Math.round(view.bandWidthPercent), 7);
assert.ok(view.bandStartPercent + view.bandWidthPercent <= 100);
assert.equal(view.rangeLabel, "05:07–05:09");
assert.equal(view.widthLabel, "2 分钟");
// Inclusive of both ends: 05:07, 05:08, 05:09. Exclusive difference was "2 分钟".
assert.equal(view.widthLabel, "3 分钟");
// A range reported wider than the window is clamped rather than overflowing.
const overflowing = buildRectificationTimeline({
@@ -100,7 +102,33 @@ test("the band is placed against the axis and never escapes it", () => {
assert.equal(overflowing.bandWidthPercent, 100);
});
test("candidate marks are binary and the range boundary counts as inside", () => {
test("candidate marks follow inference status, not band position", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:07", "05:08", "05:09"],
inferenceMarks: [
{ time: "05:06", eliminated: true },
{ time: "05:07", eliminated: false },
{ time: "05:08", eliminated: true },
{ time: "05:09", eliminated: false },
{ time: "05:10", eliminated: true },
],
stage: "minute",
});
assert.ok(view);
// 05:08 sits inside the band but is eliminated → hollow. Position vs band is not the judge.
assert.deepEqual(view.marks.map((mark) => [mark.key, mark.state]), [
["m306", "out"],
["m307", "in"],
["m308", "out"],
["m309", "in"],
["m310", "out"],
]);
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
});
test("without inference marks every engine candidate is solid", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
@@ -108,12 +136,7 @@ test("candidate marks are binary and the range boundary counts as inside", () =>
stage: "minute",
});
assert.ok(view);
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "in", "in", "out"]);
// Closed interval: both ends of the range are in, not on the fence.
assert.equal(view.marks[1]?.state, "in");
assert.equal(view.marks[3]?.state, "in");
// No third state exists to grade confidence with.
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in", "in", "in"]);
});
test("marks outside the window are dropped and duplicates collapse", () => {
@@ -135,11 +158,11 @@ test("block scan draws the window as the range and no minute marks", () => {
stage: "block_scan",
});
assert.ok(view);
// Saying "23 小时 59 分" here is the truth: nothing has been ruled out yet.
// Inclusive 00:00–23:59 is a full day. Exclusive difference was "23 小时 59 分".
assert.equal(view.bandStartPercent, 0);
assert.equal(view.bandWidthPercent, 100);
assert.equal(view.rangeLabel, "00:00–23:59");
assert.equal(view.widthLabel, "23 小时 59 分");
assert.equal(view.widthLabel, "24 小时");
assert.deepEqual(view.marks, []);
});
@@ -176,7 +199,8 @@ test("widening the window rescales the axis and keeps the band inside it", () =>
// The widened range still fits: an axis pinned to the opening window would not hold it.
assert.ok(after.bandStartPercent >= 0);
assert.ok(after.bandStartPercent + after.bandWidthPercent <= 100);
assert.equal(after.widthLabel, "24 分钟");
// 04:56–05:20 inclusive is 25 minutes. Exclusive difference was "24 分钟".
assert.equal(after.widthLabel, "25 分钟");
});
test("a window that crosses midnight stays monotonic", () => {
@@ -191,11 +215,126 @@ test("a window that crosses midnight stays monotonic", () => {
assert.equal(view.axisStartLabel, "23:00");
assert.equal(view.axisEndLabel, "03:59");
assert.equal(view.rangeLabel, "00:30–01:30");
assert.equal(view.widthLabel, "1 小时");
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "out"]);
// Inclusive 00:30–01:30 is 61 minutes. Exclusive difference was "1 小时".
assert.equal(view.widthLabel, "1 小时 1 分");
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in"]);
assert.ok(view.marks.every((mark) => mark.percent >= 0 && mark.percent <= 100));
});
test("widthLabel counts both clock ends, matching the delivery-report inclusive minute shape", () => {
const label = (
searchWindow: readonly [string, string],
credibleRange: readonly [string, string] | null,
) => buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: [],
stage: credibleRange ? "minute" : "block_scan",
})?.widthLabel;
assert.equal(label(["04:45", "05:15"], ["04:51", "04:59"]), "9 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:09"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["04:51", "04:53"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:07"]), "1 分钟");
assert.equal(label(["00:00", "23:59"], null), "24 小时");
});
test("eliminated inference minutes stay as hollow dots and keep stable keys", () => {
const searchWindow = ["04:45", "05:15"] as const;
const credibleRange = ["04:51", "04:53"] as const;
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const marksFor = (active: ReadonlySet<string>) => times.map((time) => ({
time,
eliminated: !active.has(time),
}));
const first = buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: ["04:51", "04:53"],
inferenceMarks: marksFor(new Set(["04:51", "04:53"])),
stage: "minute",
});
assert.ok(first);
assert.equal(first.marks.length, 9);
assert.equal(first.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(first.marks.filter((mark) => mark.state === "in").length, 2);
const firstKeys = first.marks.map((mark) => mark.key);
const second = buildRectificationTimeline({
searchWindow,
credibleRange: ["04:53", "04:53"],
candidateTimes: ["04:53"],
inferenceMarks: marksFor(new Set(["04:53"])),
stage: "minute",
});
assert.ok(second);
assert.equal(second.marks.length, 9);
assert.equal(second.marks.filter((mark) => mark.state === "out").length, 8);
assert.equal(second.marks.filter((mark) => mark.state === "in").length, 1);
assert.deepEqual(second.marks.map((mark) => mark.key), firstKeys);
});
test("timeline chat wiring reads parsed inferenceMarks and never the raw receipt", () => {
const timelineBlock = chat.slice(
chat.indexOf("const timelineView = buildRectificationTimeline"),
chat.indexOf("const persistedOfferKey"),
);
assert.match(timelineBlock, /inferenceMarks:\s*candidateResult\?\.inferenceMarks/);
assert.doesNotMatch(timelineBlock, /decisionReceipt|inference_state/);
});
test("parsed inference_state marks reach the timeline even when engine candidates are only the active minutes", () => {
const activeId = "88888888-8888-4888-8888-888888888881";
const secondId = "88888888-8888-4888-8888-888888888882";
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const result = parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: activeId, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 },
{ candidateId: secondId, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 },
],
overallConfidence: "low",
selectionAllowed: false,
canAdopt: false,
confirmationAllowed: false,
representativeTime: "04:53",
selectedTime: null,
selectionKind: null,
credibleRange: ["04:51", "04:53"],
decisionReceipt: {
inference_state: {
candidates: times.map((time) => ({
time,
status: time === "04:51" || time === "04:53" ? "active" : "eliminated",
})),
},
},
});
assert.ok(result);
assert.ok(result.inferenceMarks);
assert.equal(result.candidates.length, 2);
assert.equal(result.inferenceMarks.length, 9);
assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7);
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: result.credibleRange,
candidateTimes: result.candidates.map((candidate) => candidate.time),
inferenceMarks: result.inferenceMarks,
stage: "minute",
});
assert.ok(view);
assert.equal(view.marks.length, 9);
assert.equal(view.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(view.marks.filter((mark) => mark.state === "in").length, 2);
assert.equal(view.widthLabel, "3 分钟");
});
test("duration reads as clock language, not raw minutes", () => {
assert.equal(timelineDurationLabel(2), "2 分钟");
assert.equal(timelineDurationLabel(59), "59 分钟");