feat: define dynamic birth time choice protocol
This commit is contained in:
@@ -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<Record<string, number>>;
|
||||
};
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
readonly scoringPartitions: Readonly<Record<string, readonly ScoredEvidencePartition[]>>;
|
||||
};
|
||||
|
||||
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<Record<string, number>> | 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<Record<string, number>>;
|
||||
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 })),
|
||||
});
|
||||
}
|
||||
@@ -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<typeof publicChoiceKindSchema>;
|
||||
|
||||
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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<typeof nextActionSchema>;
|
||||
|
||||
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<typeof dynamicNextActionSchema>;
|
||||
|
||||
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<typeof dynamicJourneyProgressSchema>;
|
||||
|
||||
export type { PublicDynamicChoiceQuestion, TimeRange };
|
||||
|
||||
export const journeyTurnStateSchema = z.object({
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
nextAction: nextActionSchema,
|
||||
|
||||
@@ -16,8 +16,12 @@ export {
|
||||
journeyTurnStateSchema,
|
||||
nextActionSchema,
|
||||
questionSpecSchema,
|
||||
dynamicJourneyProgressSchema,
|
||||
dynamicNextActionSchema,
|
||||
} from "./birth-time-journey-turn-protocol.ts";
|
||||
export type {
|
||||
DynamicJourneyProgress,
|
||||
DynamicNextAction,
|
||||
EvidenceDraft,
|
||||
JourneyPermissions,
|
||||
JourneyProgress,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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<Parameters<typeof decideDynamicStop>[0]>) {
|
||||
return decideDynamicStop({
|
||||
result: lowCandidate,
|
||||
effectiveAnswer: true,
|
||||
previousResult: null,
|
||||
priorPlateauCount: 0,
|
||||
usefulOpportunityCount: 1,
|
||||
repeatedOnly: false,
|
||||
effectiveAnswerCount: 1,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function finishReason(overrides: Partial<Parameters<typeof decideDynamicStop>[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 });
|
||||
});
|
||||
Reference in New Issue
Block a user