Rescored candidate sets were widening because new minutes stayed neutral. Derive support/conflict from varga signs and dasha segments, and announce range changes from facts rather than tool names. Co-authored-by: Cursor <cursoragent@cursor.com>
544 lines
19 KiB
TypeScript
544 lines
19 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
buildCandidateContrastPacket,
|
||
vargaSignPartitionKey,
|
||
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
||
import {
|
||
buildInferenceState,
|
||
nextProbe,
|
||
replayInferenceState,
|
||
} from "../src/lib/rectification-agentic/core/build-state.ts";
|
||
import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-probe-outcome.ts";
|
||
import type { TransitionSignLookup } from "../src/lib/rectification-agentic/core/sign-from-transitions.ts";
|
||
import { selectHighestGainProbe } from "../src/lib/rectification-agentic/core/select-probe.ts";
|
||
import type { ConflictProbe, ProbeAnswer } from "../src/lib/rectification-agentic/core/types.ts";
|
||
import {
|
||
rangeChangedAfterEvidence,
|
||
rangeWidthMinutes,
|
||
withRangeChangedAfterEvidence,
|
||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||
|
||
const S1_TIMES = [
|
||
"04:50", "04:51", "04:52", "04:53", "04:54", "04:55", "04:56", "04:57", "05:13",
|
||
] as const;
|
||
const KEPT_TIMES = S1_TIMES.slice(0, 7);
|
||
const REPLACED = ["04:57", "05:13"] as const;
|
||
const NEW_TIMES = ["04:59", "05:14"] as const;
|
||
const S2_TIMES = [...KEPT_TIMES, ...NEW_TIMES] as const;
|
||
|
||
const EVENTS = [
|
||
{ id: "e-edu-2010", domain: "education", year: 2010, precision: "year" as const },
|
||
{ id: "e-family-2011", domain: "family", year: 2011, precision: "year" as const },
|
||
{ id: "e-finance-2012", domain: "finance", year: 2012, precision: "year" as const },
|
||
{ id: "e-health-2013", domain: "health_pressure", year: 2013, precision: "year" as const },
|
||
];
|
||
|
||
function clockProbe(input: {
|
||
id: string;
|
||
semanticKey: string;
|
||
domain: string;
|
||
year: number;
|
||
source: string;
|
||
choiceKind?: ConflictProbe["choice_kind"];
|
||
yesSupports: readonly string[];
|
||
yesConflicts: readonly string[];
|
||
}): ConflictProbe {
|
||
return {
|
||
id: input.id,
|
||
semantic_key: input.semanticKey,
|
||
candidate_split_hash: `${input.semanticKey}:${input.yesSupports.join(",")}`,
|
||
domain: input.domain,
|
||
year: input.year,
|
||
question: input.semanticKey,
|
||
candidate_ids: [...input.yesSupports, ...input.yesConflicts],
|
||
expected_outcomes: [
|
||
{ answer_class: "yes", supports: input.yesSupports, conflicts: input.yesConflicts },
|
||
{ answer_class: "weak_yes", supports: input.yesSupports, conflicts: input.yesConflicts },
|
||
{ answer_class: "no", supports: input.yesConflicts, conflicts: input.yesSupports },
|
||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||
],
|
||
information_gain: 0.4,
|
||
source: input.source,
|
||
...(input.choiceKind ? { choice_kind: input.choiceKind } : {}),
|
||
};
|
||
}
|
||
|
||
function answeredProbes(): ConflictProbe[] {
|
||
const yesSupports = [...KEPT_TIMES];
|
||
const yesConflicts = [...REPLACED];
|
||
return [
|
||
clockProbe({
|
||
id: "probe:varga.d9.old",
|
||
semanticKey: "varga.d9.04:50|04:51|04:52|04:53|04:54|04:55|04:56/04:57|05:13",
|
||
domain: "relationship",
|
||
year: 0,
|
||
source: "varga_contrast",
|
||
choiceKind: "varga_style",
|
||
yesSupports,
|
||
yesConflicts,
|
||
}),
|
||
clockProbe({
|
||
id: "probe:varga.d10.old",
|
||
semanticKey: "varga.d10.04:50|04:51|04:52|04:53|04:54|04:55|04:56/04:57|05:13",
|
||
domain: "career",
|
||
year: 0,
|
||
source: "varga_contrast",
|
||
choiceKind: "varga_style",
|
||
yesSupports,
|
||
yesConflicts,
|
||
}),
|
||
clockProbe({
|
||
id: "probe:career.2023",
|
||
semanticKey: "career.2023",
|
||
domain: "career",
|
||
year: 2023,
|
||
source: "dasha_boundary",
|
||
yesSupports,
|
||
yesConflicts,
|
||
}),
|
||
clockProbe({
|
||
id: "probe:career.2024",
|
||
semanticKey: "career.2024",
|
||
domain: "career",
|
||
year: 2024,
|
||
source: "dasha_boundary",
|
||
yesSupports,
|
||
yesConflicts,
|
||
}),
|
||
clockProbe({
|
||
id: "probe:relocation.2015",
|
||
semanticKey: "relocation.2015",
|
||
domain: "relocation",
|
||
year: 2015,
|
||
source: "dasha_boundary",
|
||
yesSupports,
|
||
yesConflicts,
|
||
}),
|
||
];
|
||
}
|
||
|
||
function engineCandidates(times: readonly string[]) {
|
||
return times.map((time) => ({ id: time, time, relative_support: 20 }));
|
||
}
|
||
|
||
function choiceAnswers(probes: readonly ConflictProbe[]): ProbeAnswer[] {
|
||
return probes.map((probe) => ({
|
||
probe_id: probe.id,
|
||
semantic_key: probe.semantic_key,
|
||
candidate_split_hash: probe.candidate_split_hash,
|
||
answer_class: "yes" as const,
|
||
classified_from: "choice" as const,
|
||
}));
|
||
}
|
||
|
||
function deltaFor(state: ReturnType<typeof buildInferenceState>, time: string): number {
|
||
const row = state.candidates.find((item) => item.time === time);
|
||
assert.ok(row, time);
|
||
return row.posterior_score - row.prior_score;
|
||
}
|
||
|
||
function width(range: readonly [string, string] | null | undefined): number {
|
||
if (!range) return Number.POSITIVE_INFINITY;
|
||
return rangeWidthMinutes(range[0], range[1]) ?? Number.POSITIVE_INFINITY;
|
||
}
|
||
|
||
function buildS1() {
|
||
const probes = answeredProbes();
|
||
return buildInferenceState({
|
||
range_start: "04:45",
|
||
range_end: "05:15",
|
||
candidates: engineCandidates(S1_TIMES),
|
||
events: EVENTS,
|
||
probes,
|
||
answered_probes: choiceAnswers(probes),
|
||
});
|
||
}
|
||
|
||
function rescoreWithoutLiveProbes(
|
||
previous: ReturnType<typeof buildInferenceState>,
|
||
times: readonly string[],
|
||
liveProbes: readonly ConflictProbe[] = [],
|
||
) {
|
||
return buildInferenceState({
|
||
range_start: "04:45",
|
||
range_end: "05:15",
|
||
candidates: engineCandidates(times),
|
||
events: EVENTS,
|
||
probes: liveProbes,
|
||
previous,
|
||
});
|
||
}
|
||
|
||
test("BUG-587 (a) a changed candidate set still replays five answered probes", () => {
|
||
const s1 = buildS1();
|
||
assert.equal(s1.answered_probes.length, 5);
|
||
assert.equal(s1.rounds.length, 5);
|
||
assert.ok(width(s1.credible_range) <= 8);
|
||
|
||
const s2 = rescoreWithoutLiveProbes(s1, S2_TIMES);
|
||
assert.equal(s2.rounds.length, 5);
|
||
assert.equal(s2.answered_probes.length, 5);
|
||
assert.equal(s2.probes.filter((item) => item.carried).length, 5);
|
||
|
||
for (const time of KEPT_TIMES) {
|
||
assert.equal(deltaFor(s2, time), deltaFor(s1, time), time);
|
||
}
|
||
// 04:59 sits between conflict minutes 04:57 and 05:13, so it inherits conflict.
|
||
assert.ok(deltaFor(s2, "04:59") < 0);
|
||
// 05:14 is past the last known minute and this fixture has no transitions.
|
||
assert.equal(deltaFor(s2, "05:14"), 0);
|
||
assert.ok(width(s2.credible_range) <= width(s1.credible_range) + NEW_TIMES.length);
|
||
});
|
||
|
||
test("BUG-587 (b) a second rescore still replays every answer", () => {
|
||
const s1 = buildS1();
|
||
const afterFinance = rescoreWithoutLiveProbes(s1, S2_TIMES);
|
||
const afterRelocation = rescoreWithoutLiveProbes(afterFinance, S2_TIMES);
|
||
assert.equal(afterRelocation.rounds.length, 5);
|
||
for (const time of KEPT_TIMES) {
|
||
assert.equal(deltaFor(afterRelocation, time), deltaFor(s1, time), time);
|
||
}
|
||
assert.ok(deltaFor(afterRelocation, "04:59") < 0);
|
||
assert.equal(deltaFor(afterRelocation, "05:14"), 0);
|
||
assert.equal(
|
||
afterRelocation.candidates.every((item) => item.posterior_score === item.prior_score),
|
||
false,
|
||
);
|
||
});
|
||
|
||
test("BUG-587 (c) varga semantic keys use the sign partition, not the minute list", () => {
|
||
const transitions = [
|
||
{ layer: "d9", at: "04:53", from_sign: "天秤座", to_sign: "天蝎座" },
|
||
{ layer: "d9", at: "04:57", from_sign: "天蝎座", to_sign: "射手座" },
|
||
];
|
||
const first = buildCandidateContrastPacket({
|
||
candidateSetVersion: "set-a",
|
||
candidateTimes: ["04:50", "04:53", "04:57"],
|
||
transitions,
|
||
});
|
||
const second = buildCandidateContrastPacket({
|
||
candidateSetVersion: "set-b",
|
||
candidateTimes: ["04:50", "04:52", "04:53", "04:57"],
|
||
transitions,
|
||
});
|
||
const firstKey = first.probes.find((item) => item.semanticKey.startsWith("varga.d9."))?.semanticKey;
|
||
const secondKey = second.probes.find((item) => item.semanticKey.startsWith("varga.d9."))?.semanticKey;
|
||
assert.equal(firstKey, "varga.d9.天秤座|天蝎座|射手座");
|
||
assert.equal(secondKey, firstKey);
|
||
assert.doesNotMatch(firstKey ?? "", /\d\d:\d\d/);
|
||
assert.equal(vargaSignPartitionKey("d9", ["天秤座", "天蝎座", "射手座"]), firstKey);
|
||
|
||
const s1 = buildS1();
|
||
const s2 = rescoreWithoutLiveProbes(s1, S2_TIMES);
|
||
const carried = s2.probes.find((item) => item.semantic_key.includes("varga.d9."));
|
||
assert.ok(carried?.carried);
|
||
assert.match(carried.semantic_key, /\d\d:\d\d/);
|
||
assert.equal(
|
||
s2.answered_probes.some((item) => item.semantic_key === carried.semantic_key),
|
||
true,
|
||
);
|
||
});
|
||
|
||
test("BUG-587 (d) nextProbe does not re-ask a carried probe", () => {
|
||
const s1 = buildS1();
|
||
const fresh: ConflictProbe = clockProbe({
|
||
id: "probe:education.2016",
|
||
semanticKey: "education.2016",
|
||
domain: "education",
|
||
year: 2016,
|
||
source: "dasha_boundary",
|
||
yesSupports: ["04:50", "04:51"],
|
||
yesConflicts: ["05:14"],
|
||
});
|
||
const s2 = rescoreWithoutLiveProbes(s1, S2_TIMES, [fresh]);
|
||
const selected = nextProbe(s2);
|
||
assert.equal(selected?.id, fresh.id);
|
||
assert.equal(s2.probes.some((item) => item.carried && item.id === selected?.id), false);
|
||
assert.equal(
|
||
selectHighestGainProbe(s2.probes, s2.answered_probes)?.id,
|
||
fresh.id,
|
||
);
|
||
for (const probe of s2.probes.filter((item) => item.carried)) {
|
||
assert.equal(selectHighestGainProbe([probe], s2.answered_probes), null);
|
||
}
|
||
});
|
||
|
||
test("BUG-588 evidence narration names a widened range after compare", () => {
|
||
const changed = withRangeChangedAfterEvidence(
|
||
"记下了。",
|
||
["04:50", "04:57"],
|
||
["04:47", "05:15"],
|
||
);
|
||
assert.match(changed, /范围从 04:50–04:57 变为 04:47–05:15。/);
|
||
assert.equal(
|
||
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]),
|
||
"范围从 04:50–04:57 变为 04:47–05:15。",
|
||
);
|
||
assert.equal(
|
||
withRangeChangedAfterEvidence("记下了。", ["04:50", "04:57"], ["04:50", "04:57"]),
|
||
"记下了。",
|
||
);
|
||
assert.equal(rangeChangedAfterEvidence(["04:50", "04:57"], ["04:50", "04:57"]), null);
|
||
|
||
const agentRun = readFileSync(
|
||
new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const askedFocusAt = agentRun.indexOf("const askedFocus = latestDossier.conversationSummary.activeFocus");
|
||
const settlement = askedFocusAt >= 0 ? agentRun.slice(askedFocusAt) : "";
|
||
const stripAt = settlement.indexOf("stripQuestionSentences");
|
||
const rangeAt = settlement.indexOf("withRangeChangedAfterEvidence");
|
||
assert.ok(stripAt >= 0 && rangeAt > stripAt);
|
||
assert.doesNotMatch(settlement, /toolsUsed\.has\("rectification-compare-candidates"\)/);
|
||
assert.match(settlement, /rangeAfterEvidence/);
|
||
assert.match(
|
||
withRangeChangedAfterEvidence("接下来我们继续。", ["04:47", "04:59"], ["04:47", "05:14"]),
|
||
/范围从 04:47–04:59 变为 04:47–05:14。/,
|
||
);
|
||
});
|
||
|
||
test("P3 selection cards stay gated by busy and a loaded snapshot", () => {
|
||
const chat = readFileSync(
|
||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const block = chat.slice(
|
||
chat.indexOf("const showSelectionCards"),
|
||
chat.indexOf("const showReadonlyRange"),
|
||
);
|
||
assert.match(block, /!busy/);
|
||
assert.match(block, /caseSnapshotLoaded/);
|
||
assert.match(chat, /startTransition\(/);
|
||
});
|
||
|
||
const ACCIDENT_EARLY = ["04:47", "04:51", "04:53", "04:55", "04:57", "04:59"] as const;
|
||
const ACCIDENT_LATE = ["05:00", "05:06", "05:15"] as const;
|
||
const ACCIDENT_OLD = [...ACCIDENT_EARLY, ...ACCIDENT_LATE] as const;
|
||
const ACCIDENT_NEW = [...ACCIDENT_EARLY, "04:48", "04:50", "05:14"] as const;
|
||
const ACCIDENT_TRANSITIONS: readonly TransitionSignLookup[] = [
|
||
{ layer: "d9", at: "05:00", from_sign: "天蝎座", to_sign: "射手座" },
|
||
{ layer: "d10", at: "05:00", from_sign: "巨蟹座", to_sign: "狮子座" },
|
||
];
|
||
|
||
function vargaStyleProbe(input: {
|
||
id: string;
|
||
layer: "d9" | "d10";
|
||
yesSign: string;
|
||
otherSign: string;
|
||
domain: string;
|
||
}): ConflictProbe {
|
||
const semanticKey = `varga.${input.layer}.${input.yesSign}|${input.otherSign}`;
|
||
return {
|
||
id: input.id,
|
||
semantic_key: semanticKey,
|
||
candidate_split_hash: `${semanticKey}:${ACCIDENT_EARLY.join(",")}`,
|
||
domain: input.domain,
|
||
year: 0,
|
||
question: semanticKey,
|
||
candidate_ids: [...ACCIDENT_OLD],
|
||
expected_outcomes: [
|
||
{ answer_class: "yes", supports: ACCIDENT_EARLY, conflicts: ACCIDENT_LATE },
|
||
{ answer_class: "weak_yes", supports: ACCIDENT_LATE, conflicts: ACCIDENT_EARLY },
|
||
{ answer_class: "no", supports: [], conflicts: [] },
|
||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||
],
|
||
information_gain: 0.4,
|
||
source: "varga_contrast",
|
||
choice_kind: "varga_style",
|
||
style_options: [
|
||
{ label: input.yesSign, answer_class: "yes", sign: input.yesSign },
|
||
{ label: input.otherSign, answer_class: "weak_yes", sign: input.otherSign },
|
||
],
|
||
};
|
||
}
|
||
|
||
function segmentProbe(input: {
|
||
id: string;
|
||
semanticKey: string;
|
||
domain: string;
|
||
year: number;
|
||
yesSupportsLate: boolean;
|
||
}): ConflictProbe {
|
||
const yesSupports = input.yesSupportsLate ? ACCIDENT_LATE : ACCIDENT_EARLY;
|
||
const yesConflicts = input.yesSupportsLate ? ACCIDENT_EARLY : ACCIDENT_LATE;
|
||
return clockProbe({
|
||
id: input.id,
|
||
semanticKey: input.semanticKey,
|
||
domain: input.domain,
|
||
year: input.year,
|
||
source: "dasha_boundary",
|
||
yesSupports,
|
||
yesConflicts,
|
||
});
|
||
}
|
||
|
||
function accidentProbes(): ConflictProbe[] {
|
||
return [
|
||
vargaStyleProbe({
|
||
id: "probe:varga.d9.scorpio",
|
||
layer: "d9",
|
||
yesSign: "天蝎座",
|
||
otherSign: "射手座",
|
||
domain: "relationship",
|
||
}),
|
||
vargaStyleProbe({
|
||
id: "probe:varga.d10.cancer",
|
||
layer: "d10",
|
||
yesSign: "巨蟹座",
|
||
otherSign: "狮子座",
|
||
domain: "career",
|
||
}),
|
||
segmentProbe({
|
||
id: "probe:career.2023.05",
|
||
semanticKey: "career.2023.05",
|
||
domain: "career",
|
||
year: 2023,
|
||
yesSupportsLate: true,
|
||
}),
|
||
segmentProbe({
|
||
id: "probe:career.2024.04",
|
||
semanticKey: "career.2024.04",
|
||
domain: "career",
|
||
year: 2024,
|
||
yesSupportsLate: true,
|
||
}),
|
||
segmentProbe({
|
||
id: "probe:relocation.2015.05",
|
||
semanticKey: "relocation.2015.05",
|
||
domain: "relocation",
|
||
year: 2015,
|
||
yesSupportsLate: false,
|
||
}),
|
||
segmentProbe({
|
||
id: "probe:family.2011",
|
||
semanticKey: "family.2011",
|
||
domain: "family",
|
||
year: 2011,
|
||
yesSupportsLate: false,
|
||
}),
|
||
];
|
||
}
|
||
|
||
function accidentAnswers(probes: readonly ConflictProbe[]): ProbeAnswer[] {
|
||
return probes.map((probe) => ({
|
||
probe_id: probe.id,
|
||
semantic_key: probe.semantic_key,
|
||
candidate_split_hash: probe.candidate_split_hash,
|
||
answer_class: probe.semantic_key === "career.2023.05" ? "no" as const : "yes" as const,
|
||
classified_from: "choice" as const,
|
||
}));
|
||
}
|
||
|
||
function buildAccidentState(
|
||
times: readonly string[],
|
||
previous?: ReturnType<typeof buildInferenceState> | null,
|
||
) {
|
||
const probes = previous ? [] : accidentProbes();
|
||
return buildInferenceState({
|
||
range_start: "04:45",
|
||
range_end: "05:15",
|
||
candidates: engineCandidates(times),
|
||
events: EVENTS,
|
||
probes,
|
||
previous,
|
||
answered_probes: previous ? [] : accidentAnswers(accidentProbes()),
|
||
transitions: ACCIDENT_TRANSITIONS,
|
||
transition_times: ACCIDENT_TRANSITIONS.map((item) => item.at),
|
||
});
|
||
}
|
||
|
||
test("BUG-594 (a) a new minute inherits varga signs and segment outcomes and is eliminated", () => {
|
||
const s1 = buildAccidentState(ACCIDENT_OLD);
|
||
assert.equal(s1.answered_probes.length, 6);
|
||
for (const time of ACCIDENT_LATE) {
|
||
const row = s1.candidates.find((item) => item.time === time);
|
||
assert.ok(row, time);
|
||
assert.ok(row.strong_conflict_count >= 3, time);
|
||
assert.equal(row.status, "eliminated", time);
|
||
}
|
||
assert.deepEqual(s1.credible_range, ["04:47", "04:59"]);
|
||
|
||
const s2 = buildAccidentState(ACCIDENT_NEW, s1);
|
||
const born = s2.candidates.find((item) => item.time === "05:14");
|
||
assert.ok(born);
|
||
assert.ok(born.strong_conflict_count >= 3);
|
||
assert.equal(born.status, "eliminated");
|
||
assert.deepEqual(s2.credible_range, ["04:47", "04:59"]);
|
||
assert.ok(s2.rounds.some((round) => (round.score_deltas?.["05:14"] ?? 0) !== 0));
|
||
const d9 = s2.probes.find((item) => item.semantic_key.startsWith("varga.d9."));
|
||
assert.equal(d9?.outcome_by_minute?.["05:14"], "conflict");
|
||
const career2024 = s2.probes.find((item) => item.semantic_key === "career.2024.04");
|
||
assert.equal(career2024?.outcome_by_minute?.["05:14"], "support");
|
||
const career2023 = s2.probes.find((item) => item.semantic_key === "career.2023.05");
|
||
assert.equal(career2023?.outcome_by_minute?.["05:14"], "conflict");
|
||
const replayed = replayInferenceState(s2, s2.answered_probes);
|
||
assert.equal(replayed.candidates.find((item) => item.time === "05:14")?.status, "eliminated");
|
||
});
|
||
|
||
test("BUG-594 (b) a new minute between two outcome sets stays neutral on that probe", () => {
|
||
const probe = clockProbe({
|
||
id: "probe:career.2023.05",
|
||
semanticKey: "career.2023.05",
|
||
domain: "career",
|
||
year: 2023,
|
||
source: "dasha_boundary",
|
||
yesSupports: ["04:47", "04:51", "04:53"],
|
||
yesConflicts: ["05:06", "05:15"],
|
||
});
|
||
const scores = Object.fromEntries(
|
||
["04:47", "04:51", "04:53", "05:00", "05:06", "05:15"].map((time) => [time, 20]),
|
||
);
|
||
const applied = applyProbeOutcome(scores, probe, "yes");
|
||
assert.equal(applied.deltas["05:00"], 0);
|
||
assert.ok((applied.deltas["04:47"] ?? 0) > 0);
|
||
assert.ok((applied.deltas["05:06"] ?? 0) < 0);
|
||
});
|
||
|
||
test("BUG-594 (c) a varga probe without transitions leaves a new minute neutral", () => {
|
||
const probe = vargaStyleProbe({
|
||
id: "probe:varga.d9.scorpio",
|
||
layer: "d9",
|
||
yesSign: "天蝎座",
|
||
otherSign: "射手座",
|
||
domain: "relationship",
|
||
});
|
||
const scores = Object.fromEntries([...ACCIDENT_OLD, "05:14"].map((time) => [time, 20]));
|
||
const applied = applyProbeOutcome(scores, probe, "yes");
|
||
assert.equal(applied.deltas["05:14"], 0);
|
||
const withSigns = applyProbeOutcome(scores, probe, "yes", {
|
||
transitions: ACCIDENT_TRANSITIONS,
|
||
});
|
||
assert.ok((withSigns.deltas["05:14"] ?? 0) < 0);
|
||
});
|
||
|
||
test("BUG-594 (d) successive covered new minutes do not widen the 13-minute accident range", () => {
|
||
let state = buildAccidentState(ACCIDENT_OLD);
|
||
assert.deepEqual(state.credible_range, ["04:47", "04:59"]);
|
||
assert.equal(width(state.credible_range), 12);
|
||
const rounds: readonly (readonly string[])[] = [
|
||
ACCIDENT_NEW,
|
||
[...ACCIDENT_EARLY, "04:49", "04:52", "05:14"],
|
||
[...ACCIDENT_EARLY, "04:48", "04:54", "05:13"],
|
||
];
|
||
for (const times of rounds) {
|
||
const previousWidth = width(state.credible_range);
|
||
const previousIds = new Set(state.candidates.map((item) => item.id));
|
||
state = buildAccidentState(times, state);
|
||
const uncovered = times.filter((time) => {
|
||
if (previousIds.has(time)) return false;
|
||
return state.probes.every((probe) => (
|
||
!probe.outcome_by_minute || probe.outcome_by_minute[time] === "neutral"
|
||
));
|
||
});
|
||
assert.ok(
|
||
width(state.credible_range) <= previousWidth + uncovered.length,
|
||
`${times.join(",")}: ${width(state.credible_range)} > ${previousWidth}+${uncovered.length}`,
|
||
);
|
||
}
|
||
assert.equal(width(state.credible_range), 12);
|
||
assert.deepEqual(state.credible_range, ["04:47", "04:59"]);
|
||
});
|