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
490 lines
16 KiB
TypeScript
490 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",
|
||
},
|
||
// 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 ?? "", /傍晚到晚上/);
|
||
});
|