fix: harden dynamic rectification protocol
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
import type { CandidateResult } from "./birth-time-evidence.ts";
|
||||
|
||||
export type DynamicStopInput = {
|
||||
readonly result: CandidateResult;
|
||||
readonly result: CandidateResult | null;
|
||||
readonly effectiveAnswer: boolean;
|
||||
readonly previousResult: CandidateResult | null;
|
||||
readonly priorPlateauCount: number;
|
||||
readonly usefulOpportunityCount: number;
|
||||
readonly repeatedOnly: boolean;
|
||||
readonly effectiveAnswerCount: number;
|
||||
readonly forcedReason: "user_finished" | "generation_unavailable" | null;
|
||||
};
|
||||
|
||||
export type DynamicStopDecision =
|
||||
| {
|
||||
readonly kind: "finish";
|
||||
readonly reason: "high_confidence" | "safety_cap" | "plateau" | "no_information_gain" | "repeated_partition";
|
||||
readonly reason: "user_finished" | "generation_unavailable" | "high_confidence" | "safety_cap" | "plateau" | "no_information_gain" | "repeated_partition";
|
||||
readonly plateauCount: number;
|
||||
}
|
||||
| { readonly kind: "continue"; readonly plateauCount: number };
|
||||
@@ -34,10 +35,11 @@ export function materiallyChanged(
|
||||
}
|
||||
|
||||
export function decideDynamicStop(input: DynamicStopInput): DynamicStopDecision {
|
||||
const plateauCount = input.effectiveAnswer
|
||||
const plateauCount = input.effectiveAnswer && input.result !== null
|
||||
? materiallyChanged(input.previousResult, input.result) ? 0 : input.priorPlateauCount + 1
|
||||
: input.priorPlateauCount;
|
||||
if (input.result.confidence === "high") return { kind: "finish", reason: "high_confidence", plateauCount };
|
||||
if (input.forcedReason !== null) return { kind: "finish", reason: input.forcedReason, plateauCount };
|
||||
if (input.result?.confidence === "high") return { kind: "finish", reason: "high_confidence", plateauCount };
|
||||
if (input.effectiveAnswerCount >= 10) return { kind: "finish", reason: "safety_cap", plateauCount };
|
||||
if (plateauCount >= 2) return { kind: "finish", reason: "plateau", plateauCount };
|
||||
if (input.usefulOpportunityCount === 0) return { kind: "finish", reason: "no_information_gain", plateauCount };
|
||||
|
||||
@@ -81,6 +81,16 @@ export type DynamicJourneyProgress = z.infer<typeof dynamicJourneyProgressSchema
|
||||
|
||||
export type { PublicDynamicChoiceQuestion, TimeRange };
|
||||
|
||||
export const dynamicJourneyTurnStateSchema = z.object({
|
||||
journeyProtocol: z.literal("dynamic-choice-v2"),
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
nextAction: dynamicNextActionSchema,
|
||||
progress: dynamicJourneyProgressSchema,
|
||||
permissions: journeyPermissionsSchema,
|
||||
}).strict().readonly();
|
||||
|
||||
export type DynamicJourneyTurnState = z.infer<typeof dynamicJourneyTurnStateSchema>;
|
||||
|
||||
export const journeyTurnStateSchema = z.object({
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
nextAction: nextActionSchema,
|
||||
|
||||
@@ -18,10 +18,12 @@ export {
|
||||
questionSpecSchema,
|
||||
dynamicJourneyProgressSchema,
|
||||
dynamicNextActionSchema,
|
||||
dynamicJourneyTurnStateSchema,
|
||||
} from "./birth-time-journey-turn-protocol.ts";
|
||||
export type {
|
||||
DynamicJourneyProgress,
|
||||
DynamicNextAction,
|
||||
DynamicJourneyTurnState,
|
||||
EvidenceDraft,
|
||||
JourneyPermissions,
|
||||
JourneyProgress,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user