Files
Jyotisha/frontend/tests/rectification-block-scan-20260906.test.ts
T
Jesse_ChenandClaude Opus 5.5 758fee954b feat(rectification): range delivery needs 4 dated events across 3 domains (R1, BUG-1193)
Product decision 2026-10-02 (TASK-upstream-sync5 R1): a time range is offered
only with at least 4 dated, primary-scoreable events covering 3 domains,
counted on all of them (training + reserved holdout). Was 3 training events /
2 domains in three TS copies and the Python acceptance gate while the policy
file already said 4/3.

- One definition: references/rectification_policy.v1.json
  (minConfirmationEvents / minConfirmationDomains). TS core/types MIN_DATED_*,
  rectification-decision MIN_STANDALONE_*, evidence-model MIN_ACCEPTANCE_*,
  the convergence evaluator and the post-inference trainingGateOpen all read
  it; Python decision_policy MIN_ACCEPTANCE_* alias MIN_CONFIRMATION_*.
- Python receipt counts all scoreable events / domains for event_quality and
  domain_diversity; decision policy identity v3 -> v4 (candidate UUIDs carry
  it). Candidate scores unchanged (77 v5 cases A/B identical), so the
  algorithm stays rectification-v5-matrix-scoring-10.
- Memoization golden v3 written by write_golden; v2 frozen by sha256 with a
  test that its scores equal v3 and only the receipt policy moved.
- Collect gap copy names the exact gap ("再来两件……其中至少一件不是……")
  instead of always "再来一件"; VOICE.md updated. Legacy life-events form copy
  4/3 as well.
- 30 frontend test files, 4 Python tests: fixtures extended to the same
  scenario at 4/3, or assertions changed with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 00:08:00 +08:00

490 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
},
// R1(BUG-1193):补第 4 件,保持原场景(块扫描门槛 4 件 3 域)。
{
id: "e-career",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_entry",
domain: "career",
occurred_from: "2020-04-01",
occurred_to: "2020-04-30",
date_precision: "month",
summary: "career entry",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-06T00:00:03.000Z",
},
];
function blockPayload(extra: Record<string, unknown> = {}) {
return {
evidence_ledger_fingerprint: "f".repeat(64),
// Explicit fixture identity matches the test deployment; no unknown identity bypass.
algorithm_version: "rectification-v5",
decision_policy_version: "rectification-candidate-policy-v2",
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",
);
});
// 原值: 题名「three dated events in two domains」/ 新值: 「four dated events in three domains」/ 原因: R1 生时校正交付门槛 4 件 3 域(BUG-1193),块扫描门同口径
test("four dated events in three 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 ?? "", /傍晚到晚上/);
});