From cc74caf9dad81dd0e2f82c445f8c47f5ccddb02f Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 00:08:45 +0800 Subject: [PATCH] feat: define dynamic birth time choice protocol --- .../lib/birth-time-dynamic-choice-internal.ts | 235 ++++++++++++++++++ frontend/src/lib/birth-time-dynamic-choice.ts | 62 +++++ .../src/lib/birth-time-dynamic-stop-policy.ts | 46 ++++ .../lib/birth-time-journey-turn-protocol.ts | 31 +++ frontend/src/lib/birth-time-journey-turn.ts | 4 + .../tests/birth-time-dynamic-choice.test.ts | 135 ++++++++++ .../birth-time-dynamic-stop-policy.test.ts | 92 +++++++ 7 files changed, 605 insertions(+) create mode 100644 frontend/src/lib/birth-time-dynamic-choice-internal.ts create mode 100644 frontend/src/lib/birth-time-dynamic-choice.ts create mode 100644 frontend/src/lib/birth-time-dynamic-stop-policy.ts create mode 100644 frontend/tests/birth-time-dynamic-choice.test.ts create mode 100644 frontend/tests/birth-time-dynamic-stop-policy.test.ts diff --git a/frontend/src/lib/birth-time-dynamic-choice-internal.ts b/frontend/src/lib/birth-time-dynamic-choice-internal.ts new file mode 100644 index 00000000..4e5612b9 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-choice-internal.ts @@ -0,0 +1,235 @@ +import { z } from "zod"; +import { candidateResultSchema } from "./birth-time-evidence.ts"; +import { + publicChoiceKindSchema, + publicDynamicChoiceQuestionSchema, + timeRangeSchema, + validateOptionSet, +} from "./birth-time-dynamic-choice.ts"; +import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { PublicChoiceKind, PublicDynamicChoiceQuestion, TimeRange } from "./birth-time-dynamic-choice.ts"; + +const finiteScoresSchema = z.record(z.number().finite()); + +export type EvidencePartition = { + readonly partitionId: string; + readonly descriptor: string; + readonly fallbackLabel: string; +}; + +export type ScoredEvidencePartition = EvidencePartition & { + readonly candidateScores: Readonly>; +}; + +export type QuestionOpportunity = { + readonly opportunityId: string; + readonly dimensionCode: string; + readonly neutralContext: string; + readonly estimatedInformationGain: number; + readonly candidatePartitionFingerprint: string; + readonly fallbackPrompt: string; + readonly partitions: readonly EvidencePartition[]; +}; + +export type CandidateDifferencePacket = { + readonly caseId: string; + readonly scoringVersion: "birth-time-choice-scoring-v2"; + readonly currentRange: TimeRange; + readonly opportunities: readonly QuestionOpportunity[]; + readonly askedQuestionFingerprints: readonly string[]; + readonly candidatePartitionFingerprints: readonly string[]; + readonly recentRangeHistory: readonly TimeRange[]; +}; + +export type CandidateDifferenceBuild = { + readonly packet: CandidateDifferencePacket; + readonly candidateModel: Readonly>; + readonly scoringPartitions: Readonly>; +}; + +export type PersistedDynamicChoiceQuestion = PublicDynamicChoiceQuestion & { + readonly opportunityId: string; + readonly dimensionCode: string; + readonly estimatedInformationGain: number; + readonly scoringVersion: string; + readonly source: "agent" | "fallback"; + readonly questionFingerprint: string; + readonly candidatePartitionFingerprint: string; + readonly options: readonly { + readonly optionId: string; + readonly label: string; + readonly kind: PublicChoiceKind; + readonly partitionId: string | null; + readonly candidateScores: Readonly> | null; + }[]; +}; + +export type StoredChoiceAnswer = { + readonly questionId: string; + readonly optionId: string; + readonly kind: PublicChoiceKind; + readonly opportunityId: string; + readonly answeredAt: string; +}; + +export type ServerChoiceEvidence = { + readonly questionId: string; + readonly opportunityId: string; + readonly partitionId: string; + readonly dimensionCode: string; + readonly candidateScores: Readonly>; + readonly informationGain: number; +}; + +export type DynamicChoiceScoringResult = { + readonly candidate: CandidateResult; + readonly evidenceMode: "dynamic_choice"; + readonly effectiveAnswerCount: number; + readonly dimensionCount: number; +}; + +export type PausedDynamicAction = + | { readonly kind: "generate_dynamic_question" } + | { readonly kind: "ask_dynamic_choice"; readonly questionId: string } + | { readonly kind: "clarify_unmatched_answer"; readonly questionId: string } + | { readonly kind: "retry_question_generation" } + | { readonly kind: "score_pending"; readonly jobId: string } + | { readonly kind: "retry_scoring"; readonly jobId: string }; + +export type DynamicControlState = { + readonly asOfDate: string; + readonly answeredCount: number; + readonly effectiveAnswerCount: number; + readonly plateauCount: number; + readonly questionFingerprints: readonly string[]; + readonly partitionFingerprints: readonly string[]; + readonly dismissedOpportunityIds: readonly string[]; + readonly recentRanges: readonly TimeRange[]; + readonly pausedAction: PausedDynamicAction | null; +}; + +const evidencePartitionBaseSchema = z.object({ + partitionId: z.string().trim().min(1), + descriptor: z.string().trim().min(1), + fallbackLabel: z.string().trim().min(1).max(80), +}).strict(); + +export const evidencePartitionSchema = evidencePartitionBaseSchema.readonly(); + +export const scoredEvidencePartitionSchema = evidencePartitionBaseSchema.extend({ + candidateScores: finiteScoresSchema, +}).strict().readonly(); + +export const questionOpportunitySchema = z.object({ + opportunityId: z.string().trim().min(1), + dimensionCode: z.string().trim().min(1), + neutralContext: z.string().trim().min(1), + estimatedInformationGain: z.number().finite().nonnegative(), + candidatePartitionFingerprint: z.string().trim().min(1), + fallbackPrompt: z.string().trim().min(1).max(240), + partitions: z.array(evidencePartitionSchema).min(2).max(4).readonly(), +}).strict().readonly(); + +export const candidateDifferencePacketSchema = z.object({ + caseId: z.string().trim().min(1), + scoringVersion: z.literal("birth-time-choice-scoring-v2"), + currentRange: timeRangeSchema, + opportunities: z.array(questionOpportunitySchema).readonly(), + askedQuestionFingerprints: z.array(z.string().trim().min(1)).readonly(), + candidatePartitionFingerprints: z.array(z.string().trim().min(1)).readonly(), + recentRangeHistory: z.array(timeRangeSchema).readonly(), +}).strict().readonly(); + +export const candidateDifferenceBuildSchema = z.object({ + packet: candidateDifferencePacketSchema, + candidateModel: z.record(z.unknown()), + scoringPartitions: z.record(z.array(scoredEvidencePartitionSchema).readonly()), +}).strict().readonly(); + +const persistedPrimaryChoiceSchema = z.object({ + optionId: z.string().trim().min(1), + label: z.string().trim().min(1).max(80), + kind: z.literal("primary"), + partitionId: z.string().trim().min(1), + candidateScores: finiteScoresSchema, +}).strict().readonly(); + +const persistedSpecialChoiceSchema = (kind: "unknown" | "unmatched") => z.object({ + optionId: z.string().trim().min(1), + label: z.string().trim().min(1).max(80), + kind: z.literal(kind), + partitionId: z.null(), + candidateScores: z.null(), +}).strict().readonly(); + +export const persistedDynamicChoiceQuestionSchema = z.object({ + questionId: z.string().trim().min(1), + opportunityId: z.string().trim().min(1), + dimensionCode: z.string().trim().min(1), + estimatedInformationGain: z.number().finite().nonnegative(), + scoringVersion: z.string().trim().min(1), + source: z.enum(["agent", "fallback"]), + questionFingerprint: z.string().trim().min(1), + candidatePartitionFingerprint: z.string().trim().min(1), + prompt: z.string().trim().min(1).max(240), + options: z.array(z.union([ + persistedPrimaryChoiceSchema, + persistedSpecialChoiceSchema("unknown"), + persistedSpecialChoiceSchema("unmatched"), + ])).readonly(), +}).strict().superRefine(validateOptionSet).readonly(); + +export const storedChoiceAnswerSchema = z.object({ + questionId: z.string().trim().min(1), + optionId: z.string().trim().min(1), + kind: publicChoiceKindSchema, + opportunityId: z.string().trim().min(1), + answeredAt: z.string().datetime({ offset: true }), +}).strict().readonly(); + +export const serverChoiceEvidenceSchema = z.object({ + questionId: z.string().trim().min(1), + opportunityId: z.string().trim().min(1), + partitionId: z.string().trim().min(1), + dimensionCode: z.string().trim().min(1), + candidateScores: finiteScoresSchema, + informationGain: z.number().finite().nonnegative(), +}).strict().readonly(); + +export const dynamicChoiceScoringResultSchema = z.object({ + candidate: candidateResultSchema, + evidenceMode: z.literal("dynamic_choice"), + effectiveAnswerCount: z.number().int().min(0), + dimensionCount: z.number().int().min(0), +}).strict().readonly(); + +export const pausedDynamicActionSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("generate_dynamic_question") }).strict(), + z.object({ kind: z.literal("ask_dynamic_choice"), questionId: z.string().trim().min(1) }).strict(), + z.object({ kind: z.literal("clarify_unmatched_answer"), questionId: z.string().trim().min(1) }).strict(), + z.object({ kind: z.literal("retry_question_generation") }).strict(), + z.object({ kind: z.literal("score_pending"), jobId: z.string().trim().min(1) }).strict(), + z.object({ kind: z.literal("retry_scoring"), jobId: z.string().trim().min(1) }).strict(), +]).readonly(); + +export const dynamicControlStateSchema = z.object({ + asOfDate: z.string().date(), + answeredCount: z.number().int().min(0), + effectiveAnswerCount: z.number().int().min(0), + plateauCount: z.number().int().min(0), + questionFingerprints: z.array(z.string().trim().min(1)).readonly(), + partitionFingerprints: z.array(z.string().trim().min(1)).readonly(), + dismissedOpportunityIds: z.array(z.string().trim().min(1)).readonly(), + recentRanges: z.array(timeRangeSchema).readonly(), + pausedAction: pausedDynamicActionSchema.nullable(), +}).strict().readonly(); + +export function toPublicDynamicChoiceQuestion( + question: PersistedDynamicChoiceQuestion, +): PublicDynamicChoiceQuestion { + return publicDynamicChoiceQuestionSchema.parse({ + questionId: question.questionId, + prompt: question.prompt, + options: question.options.map(({ optionId, label, kind }) => ({ optionId, label, kind })), + }); +} diff --git a/frontend/src/lib/birth-time-dynamic-choice.ts b/frontend/src/lib/birth-time-dynamic-choice.ts new file mode 100644 index 00000000..b080f867 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-choice.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); + +export const publicChoiceKindSchema = z.enum(["primary", "unknown", "unmatched"]); + +export type PublicChoiceKind = z.infer; + +export const timeRangeSchema = z.object({ + startTime: timeSchema, + endTime: timeSchema, +}).strict().readonly(); + +export type TimeRange = { + readonly startTime: string; + readonly endTime: string; +}; + +export const publicDynamicChoiceOptionSchema = z.object({ + optionId: z.string().trim().min(1), + label: z.string().trim().min(1).max(80), + kind: publicChoiceKindSchema, +}).strict().readonly(); + +export type PublicDynamicChoiceQuestion = { + readonly questionId: string; + readonly prompt: string; + readonly options: readonly { + readonly optionId: string; + readonly label: string; + readonly kind: PublicChoiceKind; + }[]; +}; + +function validateOptionSet( + value: { readonly options: readonly { readonly optionId: string; readonly kind: PublicChoiceKind }[] }, + context: z.RefinementCtx, +): void { + const primaryCount = value.options.filter((option) => option.kind === "primary").length; + const unknownCount = value.options.filter((option) => option.kind === "unknown").length; + const unmatchedCount = value.options.filter((option) => option.kind === "unmatched").length; + if (primaryCount < 2 || primaryCount > 4) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: "questions require two to four primary options" }); + } + if (unknownCount !== 1) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: "questions require one unknown option" }); + } + if (unmatchedCount !== 1) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: "questions require one unmatched option" }); + } + if (new Set(value.options.map((option) => option.optionId)).size !== value.options.length) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: "question option ids must be unique" }); + } +} + +export const publicDynamicChoiceQuestionSchema = z.object({ + questionId: z.string().trim().min(1), + prompt: z.string().trim().min(1).max(240), + options: z.array(publicDynamicChoiceOptionSchema).readonly(), +}).strict().superRefine(validateOptionSet).readonly(); + +export { validateOptionSet }; diff --git a/frontend/src/lib/birth-time-dynamic-stop-policy.ts b/frontend/src/lib/birth-time-dynamic-stop-policy.ts new file mode 100644 index 00000000..62dd84fd --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-stop-policy.ts @@ -0,0 +1,46 @@ +import type { CandidateResult } from "./birth-time-evidence.ts"; + +export type DynamicStopInput = { + readonly result: CandidateResult; + readonly effectiveAnswer: boolean; + readonly previousResult: CandidateResult | null; + readonly priorPlateauCount: number; + readonly usefulOpportunityCount: number; + readonly repeatedOnly: boolean; + readonly effectiveAnswerCount: number; +}; + +export type DynamicStopDecision = + | { + readonly kind: "finish"; + readonly reason: "high_confidence" | "safety_cap" | "plateau" | "no_information_gain" | "repeated_partition"; + readonly plateauCount: number; + } + | { readonly kind: "continue"; readonly plateauCount: number }; + +export function materiallyChanged( + previousResult: CandidateResult | null, + result: CandidateResult, +): boolean { + if (previousResult === null) return true; + const previousRange = previousResult.winningSegment; + const nextRange = result.winningSegment; + const rangeChanged = previousRange === null || nextRange === null + ? previousRange !== nextRange + : previousRange.startTime !== nextRange.startTime + || previousRange.endTime !== nextRange.endTime + || previousRange.representativeTime !== nextRange.representativeTime; + return rangeChanged || Math.abs(previousResult.marginPercent - result.marginPercent) >= 2; +} + +export function decideDynamicStop(input: DynamicStopInput): DynamicStopDecision { + const plateauCount = input.effectiveAnswer + ? 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.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 }; + if (input.repeatedOnly) return { kind: "finish", reason: "repeated_partition", plateauCount }; + return { kind: "continue", plateauCount }; +} diff --git a/frontend/src/lib/birth-time-journey-turn-protocol.ts b/frontend/src/lib/birth-time-journey-turn-protocol.ts index 31ff3980..28677f53 100644 --- a/frontend/src/lib/birth-time-journey-turn-protocol.ts +++ b/frontend/src/lib/birth-time-journey-turn-protocol.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { evidenceDraftSchema } from "./birth-time-evidence.ts"; +import { publicDynamicChoiceQuestionSchema, timeRangeSchema } from "./birth-time-dynamic-choice.ts"; import type { EvidenceDraft } from "./birth-time-evidence.ts"; +import type { PublicDynamicChoiceQuestion, TimeRange } from "./birth-time-dynamic-choice.ts"; import type { QuestionSpec } from "./birth-time-question-planner.ts"; const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); @@ -50,6 +52,35 @@ export const nextActionSchema = z.discriminatedUnion("kind", [ export type NextAction = z.infer; +export const dynamicNextActionSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("generate_dynamic_question") }).strict(), + z.object({ kind: z.literal("ask_dynamic_choice"), question: publicDynamicChoiceQuestionSchema }).strict(), + z.object({ kind: z.literal("clarify_unmatched_answer"), questionId: z.string().trim().min(1) }).strict(), + z.object({ kind: z.literal("retry_question_generation") }).strict(), + z.object({ kind: z.literal("score_pending"), jobId: z.string().trim().min(1) }).strict(), + z.object({ kind: z.literal("retry_scoring"), jobId: z.string().trim().min(1) }).strict(), + z.object({ kind: z.literal("present_low_result"), resultId: z.string().trim().min(1).nullable() }).strict(), + z.object({ kind: z.literal("present_medium_result"), resultId: z.string().trim().min(1) }).strict(), + z.object({ kind: z.literal("request_candidate_confirmation"), resultId: z.string().trim().min(1) }).strict(), + z.object({ kind: z.literal("ready"), activeTime: timeSchema }).strict(), + z.object({ kind: z.literal("paused") }).strict(), +]).readonly(); + +export type DynamicNextAction = z.infer; + +export const dynamicJourneyProgressSchema = z.object({ + phase: z.enum(["question", "clarification", "scoring", "result", "ready", "paused"]), + answeredCount: z.number().int().min(0), + effectiveAnswerCount: z.number().int().min(0), + currentRange: timeRangeSchema, + previousRange: timeRangeSchema.nullable(), + plateauCount: z.number().int().min(0), +}).strict().readonly(); + +export type DynamicJourneyProgress = z.infer; + +export type { PublicDynamicChoiceQuestion, TimeRange }; + export const journeyTurnStateSchema = z.object({ turnVersion: z.number().int().nonnegative(), nextAction: nextActionSchema, diff --git a/frontend/src/lib/birth-time-journey-turn.ts b/frontend/src/lib/birth-time-journey-turn.ts index 70326461..ef1a64ed 100644 --- a/frontend/src/lib/birth-time-journey-turn.ts +++ b/frontend/src/lib/birth-time-journey-turn.ts @@ -16,8 +16,12 @@ export { journeyTurnStateSchema, nextActionSchema, questionSpecSchema, + dynamicJourneyProgressSchema, + dynamicNextActionSchema, } from "./birth-time-journey-turn-protocol.ts"; export type { + DynamicJourneyProgress, + DynamicNextAction, EvidenceDraft, JourneyPermissions, JourneyProgress, diff --git a/frontend/tests/birth-time-dynamic-choice.test.ts b/frontend/tests/birth-time-dynamic-choice.test.ts new file mode 100644 index 00000000..b10eedac --- /dev/null +++ b/frontend/tests/birth-time-dynamic-choice.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +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"; + +const internalQuestion = { + questionId: "11111111-1111-4111-8111-111111111111", + opportunityId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + dimensionCode: "career_change", + estimatedInformationGain: 0.7, + scoringVersion: "birth-time-choice-scoring-v2", + source: "fallback", + questionFingerprint: "question-fingerprint", + candidatePartitionFingerprint: "partition-fingerprint", + prompt: "哪一个时间段更接近这次工作变化?", + options: [ + { + optionId: "22222222-2222-4222-8222-222222222222", + label: "2018—2020 年", + kind: "primary", + partitionId: "career-2018-2020", + candidateScores: { "09:00": 0.8 }, + }, + { + optionId: "33333333-3333-4333-8333-333333333333", + label: "2021—2023 年", + kind: "primary", + partitionId: "career-2021-2023", + candidateScores: { "09:30": 0.6 }, + }, + { + optionId: "44444444-4444-4444-8444-444444444444", + label: "不确定 / 不记得", + kind: "unknown", + partitionId: null, + candidateScores: null, + }, + { + optionId: "55555555-5555-4555-8555-555555555555", + label: "都不符合", + kind: "unmatched", + partitionId: null, + candidateScores: null, + }, + ], +} as const; + +function sourceFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(path) : [path]; + }); +} + +test("public questions never expose partition ids", () => { + const parsed = publicDynamicChoiceQuestionSchema.parse({ + questionId: "11111111-1111-4111-8111-111111111111", + prompt: "哪一个时间段更接近这次工作变化?", + options: [ + { optionId: "22222222-2222-4222-8222-222222222222", label: "2018—2020 年", kind: "primary" }, + { optionId: "33333333-3333-4333-8333-333333333333", label: "2021—2023 年", kind: "primary" }, + { optionId: "44444444-4444-4444-8444-444444444444", label: "不确定 / 不记得", kind: "unknown" }, + { optionId: "55555555-5555-4555-8555-555555555555", label: "都不符合", kind: "unmatched" }, + ], + }); + + assert.equal("partitionId" in parsed.options[0], false); + assert.equal(publicDynamicChoiceQuestionSchema.safeParse({ + ...parsed, + options: [{ ...parsed.options[0], partitionId: "private" }, ...parsed.options.slice(1)], + }).success, false); +}); + +test("internal primary choices require a server partition", () => { + assert.equal(persistedDynamicChoiceQuestionSchema.safeParse(internalQuestion).success, true); + assert.equal(persistedDynamicChoiceQuestionSchema.safeParse({ + ...internalQuestion, + options: internalQuestion.options.map((option) => option.kind === "primary" + ? { optionId: option.optionId, label: option.label, kind: option.kind, partitionId: null } + : option), + }).success, false); +}); + +test("choice questions require a bounded complete option set", () => { + assert.equal(publicDynamicChoiceQuestionSchema.safeParse({ + questionId: "11111111-1111-4111-8111-111111111111", + prompt: "哪一个时间段更接近这次工作变化?", + options: [ + { optionId: "22222222-2222-4222-8222-222222222222", label: "2018—2020 年", kind: "primary" }, + { optionId: "44444444-4444-4444-8444-444444444444", label: "不确定 / 不记得", kind: "unknown" }, + { optionId: "55555555-5555-4555-8555-555555555555", label: "都不符合", kind: "unmatched" }, + ], + }).success, false); +}); + +test("dynamic schemas accept opaque server-issued identifiers", () => { + const publicQuestion = { + questionId: "question-career-window", + prompt: "哪一个时间段更接近这次工作变化?", + options: [ + { optionId: "window-a", label: "2018—2020 年", kind: "primary" }, + { optionId: "window-b", label: "2021—2023 年", kind: "primary" }, + { optionId: "unknown", label: "不确定 / 不记得", kind: "unknown" }, + { optionId: "unmatched", label: "都不符合", kind: "unmatched" }, + ], + }; + + assert.equal(publicDynamicChoiceQuestionSchema.safeParse(publicQuestion).success, true); + assert.equal(persistedDynamicChoiceQuestionSchema.safeParse({ + ...internalQuestion, + ...publicQuestion, + opportunityId: "career-window", + options: [ + { ...publicQuestion.options[0], partitionId: "window-a", candidateScores: { "09:00": 0.8 } }, + { ...publicQuestion.options[1], partitionId: "window-b", candidateScores: { "09:30": 0.6 } }, + { ...publicQuestion.options[2], partitionId: null, candidateScores: null }, + { ...publicQuestion.options[3], partitionId: null, candidateScores: null }, + ], + }).success, true); +}); + +test("public code never imports the internal dynamic choice contract", () => { + const publicSourceFiles = [ + ...sourceFiles(new URL("../src/components", import.meta.url).pathname), + ...sourceFiles(new URL("../src/hooks", import.meta.url).pathname), + ...sourceFiles(new URL("../src/lib", import.meta.url).pathname).filter((path) => + path.includes("client") || path.endsWith("response-schema.ts")), + ]; + + for (const path of publicSourceFiles) { + assert.equal(readFileSync(path, "utf8").includes("birth-time-dynamic-choice-internal"), false, path); + } +}); diff --git a/frontend/tests/birth-time-dynamic-stop-policy.test.ts b/frontend/tests/birth-time-dynamic-stop-policy.test.ts new file mode 100644 index 00000000..7e4794dc --- /dev/null +++ b/frontend/tests/birth-time-dynamic-stop-policy.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { decideDynamicStop } from "../src/lib/birth-time-dynamic-stop-policy.ts"; +import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; + +const lowCandidate: CandidateResult = { + resultId: "11111111-1111-4111-8111-111111111111", + confidence: "low", + canApply: false, + winningSegment: null, + eventCount: 1, + domainCount: 1, + topScore: 10, + secondScore: 9, + marginPercent: 10, + reasons: ["Candidate scores remain close."], + evidence: [], + algorithmVersion: "birth-time-choice-scoring-v2", +}; + +const mediumCandidate: CandidateResult = { + ...lowCandidate, + resultId: "22222222-2222-4222-8222-222222222222", + confidence: "medium", + marginPercent: 15, +}; + +function decisionFor(overrides: Partial[0]>) { + return decideDynamicStop({ + result: lowCandidate, + effectiveAnswer: true, + previousResult: null, + priorPlateauCount: 0, + usefulOpportunityCount: 1, + repeatedOnly: false, + effectiveAnswerCount: 1, + ...overrides, + }); +} + +function finishReason(overrides: Partial[0]>) { + const decision = decisionFor(overrides); + if (decision.kind !== "finish") assert.fail("expected a terminal decision"); + return decision.reason; +} + +test("two effective unchanged scores stop without starting another question", () => { + const decision = decideDynamicStop({ + result: mediumCandidate, + effectiveAnswer: true, + previousResult: mediumCandidate, + priorPlateauCount: 1, + usefulOpportunityCount: 3, + repeatedOnly: false, + effectiveAnswerCount: 6, + }); + + assert.deepEqual(decision, { kind: "finish", reason: "plateau", plateauCount: 2 }); +}); + +test("unknown answers do not advance plateau or the effective safety count", () => { + const decision = decideDynamicStop({ + result: lowCandidate, + effectiveAnswer: false, + previousResult: lowCandidate, + priorPlateauCount: 1, + usefulOpportunityCount: 2, + repeatedOnly: false, + effectiveAnswerCount: 4, + }); + + assert.deepEqual(decision, { kind: "continue", plateauCount: 1 }); +}); + +test("terminal conditions are deterministic", () => { + 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"); + assert.equal(finishReason({ usefulOpportunityCount: 0 }), "no_information_gain"); + assert.equal(finishReason({ repeatedOnly: true }), "repeated_partition"); + assert.equal(finishReason({ effectiveAnswerCount: 10 }), "safety_cap"); +}); + +test("a two point margin change resets the plateau", () => { + const decision = decisionFor({ + result: { ...mediumCandidate, marginPercent: 17 }, + previousResult: mediumCandidate, + priorPlateauCount: 1, + }); + + assert.deepEqual(decision, { kind: "continue", plateauCount: 0 }); +});