fix: harden dynamic rectification protocol

This commit is contained in:
Jesse_Chen
2026-07-19 00:16:14 +08:00
parent ab0351ad0f
commit 883d8dcba2
6 changed files with 147 additions and 4 deletions
@@ -4,6 +4,7 @@ import { join } from "node:path";
import test from "node:test";
import { publicDynamicChoiceQuestionSchema } from "../src/lib/birth-time-dynamic-choice.ts";
import { persistedDynamicChoiceQuestionSchema } from "../src/lib/birth-time-dynamic-choice-internal.ts";
import { dynamicJourneyTurnStateSchema, journeyTurnStateSchema } from "../src/lib/birth-time-journey-turn-protocol.ts";
const internalQuestion = {
questionId: "11111111-1111-4111-8111-111111111111",
@@ -133,3 +134,42 @@ test("public code never imports the internal dynamic choice contract", () => {
assert.equal(readFileSync(path, "utf8").includes("birth-time-dynamic-choice-internal"), false, path);
}
});
test("dynamic turn state is explicitly discriminated from the legacy protocol", () => {
const dynamicTurn = {
journeyProtocol: "dynamic-choice-v2",
turnVersion: 0,
nextAction: { kind: "generate_dynamic_question" },
progress: {
phase: "question",
answeredCount: 0,
effectiveAnswerCount: 0,
currentRange: { startTime: "09:00", endTime: "10:00" },
previousRange: null,
plateauCount: 0,
},
permissions: { canConfirmCandidate: false },
};
assert.equal(dynamicJourneyTurnStateSchema.safeParse(dynamicTurn).success, true);
assert.equal(journeyTurnStateSchema.safeParse(dynamicTurn).success, false);
assert.equal(dynamicJourneyTurnStateSchema.safeParse({
...dynamicTurn,
journeyProtocol: "legacy-guided-v1",
}).success, false);
assert.equal(dynamicJourneyTurnStateSchema.safeParse({
...dynamicTurn,
nextAction: {
kind: "ask_baseline_evidence",
question: {
questionId: "legacy",
phase: "baseline",
domain: "career",
requestedPrecision: ["year"],
allowUnknown: true,
purposeCode: "candidate_difference_career",
plannerVersion: "candidate-difference-v1",
},
},
}).success, false);
});
@@ -34,6 +34,7 @@ function decisionFor(overrides: Partial<Parameters<typeof decideDynamicStop>[0]>
usefulOpportunityCount: 1,
repeatedOnly: false,
effectiveAnswerCount: 1,
forcedReason: null,
...overrides,
});
}
@@ -53,6 +54,7 @@ test("two effective unchanged scores stop without starting another question", ()
usefulOpportunityCount: 3,
repeatedOnly: false,
effectiveAnswerCount: 6,
forcedReason: null,
});
assert.deepEqual(decision, { kind: "finish", reason: "plateau", plateauCount: 2 });
@@ -67,12 +69,15 @@ test("unknown answers do not advance plateau or the effective safety count", ()
usefulOpportunityCount: 2,
repeatedOnly: false,
effectiveAnswerCount: 4,
forcedReason: null,
});
assert.deepEqual(decision, { kind: "continue", plateauCount: 1 });
});
test("terminal conditions are deterministic", () => {
assert.equal(finishReason({ result: null, forcedReason: "user_finished" }), "user_finished");
assert.equal(finishReason({ result: null, forcedReason: "generation_unavailable" }), "generation_unavailable");
assert.equal(finishReason({ result: { ...lowCandidate, confidence: "high", canApply: true, winningSegment: {
startTime: "09:00", endTime: "09:05", representativeTime: "09:03", widthMinutes: 5,
}, eventCount: 4, domainCount: 3, marginPercent: 20 } }), "high_confidence");
@@ -81,6 +86,15 @@ test("terminal conditions are deterministic", () => {
assert.equal(finishReason({ effectiveAnswerCount: 10 }), "safety_cap");
});
test("forced terminal reasons win over a high-confidence score", () => {
assert.equal(finishReason({
result: { ...lowCandidate, confidence: "high", canApply: true, winningSegment: {
startTime: "09:00", endTime: "09:05", representativeTime: "09:03", widthMinutes: 5,
}, eventCount: 4, domainCount: 3, marginPercent: 20 },
forcedReason: "user_finished",
}), "user_finished");
});
test("a two point margin change resets the plateau", () => {
const decision = decisionFor({
result: { ...mediumCandidate, marginPercent: 17 },