Files
Jyotisha/frontend/tests/rectification-block-scan-20260906.test.ts
T
Jesse_Chen 8e31680b45 fix(rectification): honor declared birth-time uncertainty and split windows over two hours (BUG-571–573)
Intake stores how sure the user is; rectification now searches that range, offers a one-click widen when event fit is low at the edge, and trisects windows longer than two hours before the minute grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 12:18:50 +08:00

472 lines
16 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { deriveRectificationOpenWindow } from "../src/lib/rectification-agentic/v9/case-service.ts";
import { decideAfterInferenceChange, decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { projectRectificationStepState } from "../src/lib/rectification-agentic/v9/step-state.ts";
import {
BLOCK_CHOICE_INTENT,
BLOCK_PERIOD_LABELS,
buildBlockChoiceFrame,
parseBlockScanPayload,
serializeBlockScanPayload,
windowFromBlockChoice,
} from "../src/lib/rectification-agentic/v9/block-scan.ts";
import { splitWindowIntoThirds } from "../src/lib/rectification-agentic/v9/search-window.ts";
import {
CASE_ID,
FOCUS_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const BLOCKS = [
{ period: "early_morning", start_time: "04:00", end_time: "07:59", relative_support: 12 },
{ period: "morning", start_time: "08:00", end_time: "11:59", relative_support: 41 },
{ period: "afternoon", start_time: "12:00", end_time: "17:59", relative_support: 28 },
{ period: "evening", start_time: "18:00", end_time: "22:59", relative_support: 11 },
{ period: "late_night", start_time: "23:00", end_time: "03:59", relative_support: 8 },
] as const;
const EVIDENCE = [
{
id: "e-edu",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "education_start",
domain: "education",
occurred_from: "2016-09-01",
occurred_to: "2016-09-30",
date_precision: "month",
summary: "education start",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-06T00:00:00.000Z",
},
{
id: "e-rel",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relationship_end",
domain: "relationship",
occurred_from: "2024-08-08",
occurred_to: "2024-08-08",
date_precision: "day",
summary: "relationship end",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-06T00:00:01.000Z",
},
{
id: "e-move",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relocation",
domain: "relocation",
occurred_from: "2023-07-01",
occurred_to: "2023-07-31",
date_precision: "month",
summary: "relocation",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-06T00:00:02.000Z",
},
];
function blockPayload(extra: Record<string, unknown> = {}) {
return {
evidence_ledger_fingerprint: "f".repeat(64),
algorithm_version: "test",
minute_step: 10,
blocks: [...BLOCKS],
...extra,
};
}
function blockSchema() {
const payload = parseBlockScanPayload(blockPayload());
assert.ok(payload);
const frame = buildBlockChoiceFrame(payload);
assert.ok(frame);
return {
choice: {
prompt: frame.prompt,
option_a: frame.option_a_hint,
option_b: frame.option_b_hint,
option_c: frame.neither_label,
option_d: frame.unsure_label,
options: [
{ key: "A", label: frame.option_a_hint, answer_class: frame.option_a_answer_class, role: "primary" },
{ key: "B", label: frame.option_b_hint, answer_class: frame.option_b_answer_class, role: "primary" },
{ key: "C", label: frame.neither_label, answer_class: frame.option_c_answer_class, role: "primary" },
{ key: "D", label: frame.unsure_label, answer_class: frame.option_d_answer_class, role: "primary" },
],
},
choice_kind: "block_choice",
scoring: false,
block_periods: {
A: BLOCKS[1],
B: BLOCKS[2],
C: BLOCKS[0],
},
};
}
function blockDossier(extra: {
declined?: boolean;
stage?: string;
range?: { start_time: string; end_time: string };
blockScan?: unknown;
} = {}) {
const schema = blockSchema();
return dossierFixture({
candidateRange: extra.range ?? { start_time: "00:00", end_time: "23:59" },
stage: extra.stage ?? "block_scan",
blockScan: extra.blockScan ?? (extra.declined
? blockPayload({ declined_at_fingerprint: "f".repeat(64), evidence_ledger_fingerprint: "f".repeat(64) })
: blockPayload()),
evidence: EVIDENCE,
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: extra.stage === "minute"
? null
: activeFocusFixture({
intent: BLOCK_CHOICE_INTENT,
targetDomain: null,
targetKind: null,
questionId: "block_scan:choose_birth_block:holdout",
expectedAnswerSchema: schema,
}),
declined_skipped_topics: [],
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-06T00:00:00.000Z",
},
});
}
test("unknown source opens a full-day block_scan window", () => {
assert.deepEqual(
deriveRectificationOpenWindow({
reportedTime: null,
source: "unknown",
period: null,
windowStart: null,
windowEnd: null,
}),
{
candidateRange: { start_time: "00:00", end_time: "23:59" },
stage: "block_scan",
},
);
assert.deepEqual(
deriveRectificationOpenWindow({
reportedTime: "05:00",
source: "unknown",
period: null,
windowStart: null,
windowEnd: null,
}).stage,
"minute",
);
assert.equal(
deriveRectificationOpenWindow({
reportedTime: "05:00",
source: "family_exact",
period: null,
windowStart: null,
windowEnd: null,
}).stage,
"minute",
);
});
test("three dated events in two domains ask a block choice and never adopt", () => {
const parsed = parseV9CaseDossier(blockDossier());
assert.ok(parsed);
const decision = decideFromDossier(parsed);
assert.equal(decision.nextAction, "ask_block_choice");
assert.equal(decision.canAdopt, false);
assert.equal(decision.selectionAllowed, false);
assert.equal(decision.representativeTime, null);
assert.equal(decision.separation.ranked.length, 0);
const step = projectRectificationStepState({ nextAction: decision.nextAction });
assert.equal(step.index, 1);
assert.equal(step.stage, "collect");
assert.match(step.headline, /比较时段/);
});
test("block choice copy names periods, not a winning minute", () => {
const payload = parseBlockScanPayload(blockPayload());
assert.ok(payload);
const frame = buildBlockChoiceFrame(payload);
assert.ok(frame);
assert.match(frame.prompt, /哪一段更像出生时段/);
assert.doesNotMatch(frame.prompt, /更像\s*\d/);
assert.match(frame.option_a_hint ?? "", new RegExp(BLOCK_PERIOD_LABELS.morning));
assert.equal(frame.scoring, false);
assert.equal(frame.choice_kind, "block_choice");
const schema = blockSchema();
assert.deepEqual(windowFromBlockChoice({ schema, optionId: "B" }), {
start_time: "12:00",
end_time: "17:59",
});
assert.equal(windowFromBlockChoice({ schema, optionId: "D" }), null);
});
test("declined block_scan fingerprint returns collect instead of another card", () => {
const parsed = parseV9CaseDossier(blockDossier());
assert.ok(parsed);
const fingerprint = evidenceLedgerFingerprint(parsed.evidence);
const declined = parseV9CaseDossier(blockDossier({
blockScan: {
...blockPayload(),
declined_at_fingerprint: fingerprint,
},
}));
assert.ok(declined);
assert.equal(declined.case.blockScan?.declinedAtFingerprint, fingerprint);
const decision = decideAfterInferenceChange({
dossier: declined,
state: null,
userStopped: false,
});
assert.equal(decision.nextAction, "ask_fact_collection");
assert.equal(decision.canAdopt, false);
});
test("choosing afternoon from a full-day scan stays in block_scan for a window wider than 120 minutes", async () => {
let current = blockDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => current,
get_agentic_rectification_case_compute: () => computeFixture({
baselineBirthSnapshot: {
birth_date: "1998-03-15",
latitude: 39.9042,
longitude: 116.4074,
timezone_id: "Asia/Shanghai",
timezone_offset: 8,
birth_time_source: "unknown",
},
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
revision: 1,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-06T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
write_agentic_rectification_block_scan: (_fn, args) => {
current = blockDossier({
stage: "block_scan",
range: current.case.candidate_range as { start_time: string; end_time: string },
blockScan: args.p_block_scan,
});
return { case_id: CASE_ID, stage: "block_scan", block_scan: args.p_block_scan };
},
advance_agentic_rectification_case_from_block_scan: (_fn, args) => {
current = blockDossier({
stage: "block_scan",
range: { start_time: String(args.p_start_time), end_time: String(args.p_end_time) },
blockScan: { rounds: 0, blocks: [] },
});
return {
case_id: CASE_ID,
stage: "block_scan",
candidate_range: { start_time: args.p_start_time, end_time: args.p_end_time },
};
},
persist_agentic_rectification_candidate_v2: () => {
throw new Error("must not persist the 24h minute snapshot");
},
});
const receipt = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
action: CHOICE_ACTION,
focusId: FOCUS_ID,
optionId: "B",
expectedRevision: 0,
});
assert.equal(receipt.snapshotCurrent, false);
assert.equal(receipt.nextAction.can_adopt, false);
const parsed = parseV9CaseDossier(current);
assert.equal(parsed?.case.stage, "block_scan");
assert.deepEqual(parsed?.case.candidateRange, { start_time: "12:00", end_time: "17:59" });
assert.ok(accounting.calls.some((item) => item.fn === "advance_agentic_rectification_case_from_block_scan"));
});
test("choosing D stays in block_scan and collects again", async () => {
let current = blockDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => current,
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
revision: 1,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-06T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
write_agentic_rectification_block_scan: (_fn, args) => {
current = blockDossier({ blockScan: args.p_block_scan });
return { case_id: CASE_ID, stage: "block_scan", block_scan: args.p_block_scan };
},
advance_agentic_rectification_case_from_block_scan: () => {
throw new Error("D must not advance the window");
},
});
const receipt = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
action: CHOICE_ACTION,
focusId: FOCUS_ID,
optionId: "D",
expectedRevision: 0,
});
const parsedAfter = parseV9CaseDossier(current);
assert.equal(parsedAfter?.case.stage, "block_scan");
assert.equal(
parsedAfter?.case.blockScan?.declinedAtFingerprint,
evidenceLedgerFingerprint(parsedAfter!.evidence),
);
assert.equal(receipt.nextAction.type, "ask_fact_collection");
assert.equal(receipt.nextAction.can_adopt, false);
assert.ok(accounting.calls.some((item) => item.fn === "write_agentic_rectification_block_scan"));
const payload = serializeBlockScanPayload(parseBlockScanPayload(blockPayload({
declined_at_fingerprint: "f".repeat(64),
}))!);
assert.equal(payload.declined_at_fingerprint, "f".repeat(64));
});
test("existing minute-stage cases keep the minute interview", () => {
const parsed = parseV9CaseDossier(dossierFixture({
evidence: EVIDENCE,
stage: "minute",
candidateRange: { start_time: "04:50", end_time: "05:10" },
}));
assert.ok(parsed);
const decision = decideFromDossier(parsed);
assert.notEqual(decision.nextAction, "ask_block_choice");
assert.notEqual(decision.sessionOutcome, "compare_blocks");
});
test("period_only evening and approximate ±120 open as block_scan, custom 30 minutes stay minute", () => {
assert.deepEqual(
deriveRectificationOpenWindow({
reportedTime: null,
source: "period_only",
period: "evening",
windowStart: null,
windowEnd: null,
}),
{
candidateRange: { start_time: "18:00", end_time: "22:59" },
stage: "block_scan",
},
);
assert.deepEqual(
deriveRectificationOpenWindow({
reportedTime: "05:00",
source: "approximate",
period: null,
windowStart: null,
windowEnd: null,
uncertaintyBefore: 120,
uncertaintyAfter: 120,
}),
{
candidateRange: { start_time: "03:00", end_time: "07:00" },
stage: "block_scan",
},
);
assert.equal(
deriveRectificationOpenWindow({
reportedTime: null,
source: "period_only",
period: null,
windowStart: "14:30",
windowEnd: "15:00",
}).stage,
"minute",
);
});
test("advance stubs with empty blocks still parse rounds", () => {
const parsed = parseBlockScanPayload({ rounds: 0, blocks: [] });
assert.equal(parsed?.rounds, 0);
assert.deepEqual(parsed?.blocks, []);
assert.equal(parseBlockScanPayload({ blocks: [] }), null);
});
test("evening thirds become a sub-block card with clock labels", () => {
const thirds = splitWindowIntoThirds({ start_time: "18:00", end_time: "22:59" });
const payload = parseBlockScanPayload({
rounds: 1,
blocks: thirds.map((block, index) => ({
period: block.period,
start_time: block.start_time,
end_time: block.end_time,
relative_support: [40, 35, 25][index],
})),
});
assert.ok(payload);
const frame = buildBlockChoiceFrame(payload);
assert.ok(frame);
assert.match(frame.prompt, /更像出生时间/);
assert.match(frame.option_a_hint ?? "", /18:00/);
assert.doesNotMatch(frame.option_a_hint ?? "", /傍晚到晚上/);
});