Files
Jyotisha/frontend/tests/birth-time-dynamic-scoring.test.ts
T
2026-07-19 14:48:02 +08:00

321 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
completeDynamicScoreTransition,
} from "../src/lib/birth-time-dynamic-transitions.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { ServerChoiceEvidence } from "../src/lib/birth-time-dynamic-choice-internal.ts";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import {
dynamicCase,
ownerId,
persistedQuestion,
} from "./birth-time-dynamic-persistence-fixture.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.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: ["close"],
evidence: [],
algorithmVersion: "birth-time-choice-scoring-v2",
};
const actionId = "ab2d936b-5ce7-45d8-a0fb-33f48f960f36";
function freshDynamicCase(
candidateResult: CandidateResult | null = null,
priorEvidence: readonly ServerChoiceEvidence[] = [],
) {
const stored = dynamicCase();
const effectiveAnswerCount = priorEvidence.length;
return {
...stored,
eventContext: {
birthDate: "1993-04-17",
lat: 31.23,
lon: 121.47,
tz: 8,
},
candidateResult,
choiceEvidence: priorEvidence,
dynamicControl: {
...stored.dynamicControl,
answeredCount: effectiveAnswerCount,
effectiveAnswerCount,
plateauCount: candidateResult === null ? 0 : 1,
},
dynamicTurnState: {
...stored.dynamicTurnState,
progress: {
...stored.dynamicTurnState.progress,
answeredCount: effectiveAnswerCount,
effectiveAnswerCount,
plateauCount: candidateResult === null ? 0 : 1,
},
},
};
}
function scoringFlow(input: {
readonly candidate?: CandidateResult;
readonly initialCandidate?: CandidateResult | null;
readonly priorEvidence?: readonly ServerChoiceEvidence[];
readonly failOnce?: boolean;
readonly emptyNarrowedRange?: boolean;
} = {}) {
const initial = freshDynamicCase(input.initialCandidate ?? null, input.priorEvidence);
const memory = memoryStore(initial);
const jobs = dynamicJobStore(memory.store, () => {
const value = memory.savedCase();
return value?.journeyProtocol === "dynamic-choice-v2" ? value : null;
});
let scoreCalls = 0;
const differenceRanges: Array<{ readonly startTime: string; readonly endTime: string }> = [];
let shouldFail = input.failOnce ?? false;
const candidate = input.candidate ?? lowCandidate;
const service = createBirthTimeJourneyService({
store: jobs.store,
engine: {
async scan() { throw new Error("unexpected scan"); },
async score() { throw new Error("unexpected score"); },
async scoreEvents() { throw new Error("unexpected event score"); },
async scoreChoices() {
scoreCalls += 1;
if (shouldFail) {
shouldFail = false;
throw new TypeError("offline");
}
return {
candidate,
evidenceMode: "dynamic_choice" as const,
effectiveAnswerCount: candidate.eventCount,
dimensionCount: candidate.domainCount,
};
},
async buildDifferencePacket(value) {
differenceRanges.push({ startTime: value.startTime, endTime: value.endTime });
return {
packet: {
caseId: value.caseId,
scoringVersion: "birth-time-choice-scoring-v2" as const,
currentRange: { startTime: value.startTime, endTime: value.endTime },
opportunities: input.emptyNarrowedRange && value.startTime === "05:20" ? [] : [{
opportunityId: "next-opportunity",
dimensionCode: "relocation_change",
neutralContext: "一次居住变化",
estimatedInformationGain: 0.5,
candidatePartitionFingerprint: "next-partition",
fallbackPrompt: "哪一段更接近一次居住变化?",
partitions: [
{ partitionId: "early", descriptor: "early", fallbackLabel: "较早" },
{ partitionId: "late", descriptor: "late", fallbackLabel: "较晚" },
],
}],
askedQuestionFingerprints: value.questionFingerprints,
candidatePartitionFingerprints: value.partitionFingerprints,
recentRangeHistory: value.recentRanges,
},
candidateModel: { version: "after-score" },
scoringPartitions: {},
};
},
},
});
return { memory, jobs, service, scoreCalls: () => scoreCalls, differenceRanges: () => differenceRanges };
}
test("score completion continues only when stop policy allows it", () => {
const stored = dynamicCase();
const result = completeDynamicScoreTransition({
stored: { ...stored, currentChoiceQuestion: null },
candidate: lowCandidate,
usefulOpportunityCount: 1,
repeatedOnly: false,
nextVersion: 8,
});
assert.equal(result.dynamicTurnState.nextAction.kind, "generate_dynamic_question");
assert.equal(result.dynamicControl.plateauCount, 0);
});
test("high confidence requires explicit confirmation without applying a time", () => {
const stored = dynamicCase();
const candidate = {
...lowCandidate,
resultId: "097b7b4c-60f3-4ed8-b290-64b2084182e7",
confidence: "high" as const,
canApply: true,
winningSegment: {
startTime: "05:10",
endTime: "05:12",
representativeTime: "05:11",
widthMinutes: 2,
},
eventCount: 4,
domainCount: 3,
marginPercent: 20,
};
const result = completeDynamicScoreTransition({
stored: { ...stored, currentChoiceQuestion: null },
candidate,
usefulOpportunityCount: 1,
repeatedOnly: false,
nextVersion: 8,
});
assert.deepEqual(result.dynamicTurnState.nextAction, {
kind: "request_candidate_confirmation",
resultId: candidate.resultId,
});
assert.equal(result.snapshot.activeTime, null);
assert.equal(result.dynamicTurnState.permissions.canConfirmCandidate, true);
});
test("dynamic scoring claims once, completes atomically, and replays", async () => {
const flow = scoringFlow();
const pending = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
});
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
const first = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
const replay = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
assert.equal(first.nextAction.kind, "generate_dynamic_question");
assert.deepEqual(replay.nextAction, first.nextAction);
assert.equal(flow.scoreCalls(), 1);
assert.deepEqual(flow.memory.savedCase()?.candidateModel, { version: "after-score" });
});
test("dynamic scoring generates the next question from the newly narrowed range", async () => {
const candidate = {
...lowCandidate,
winningSegment: {
startTime: "05:20",
endTime: "05:29",
representativeTime: "05:24",
widthMinutes: 10,
},
};
const flow = scoringFlow({ candidate });
const pending = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
});
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
const continued = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
assert.deepEqual(flow.differenceRanges(), [{ startTime: "05:20", endTime: "05:29" }]);
assert.equal(continued.nextAction.kind, "generate_dynamic_question");
assert.deepEqual(continued.progress.currentRange, { startTime: "05:20", endTime: "05:29" });
assert.deepEqual(continued.progress.previousRange, { startTime: "05:00", endTime: "06:00" });
});
test("dynamic scoring keeps asking from the broader competitive range when the winner cannot split", async () => {
const flow = scoringFlow({
emptyNarrowedRange: true,
candidate: {
...lowCandidate,
winningSegment: {
startTime: "05:20",
endTime: "05:29",
representativeTime: "05:24",
widthMinutes: 10,
},
},
});
const pending = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
});
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
const continued = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
assert.deepEqual(flow.differenceRanges(), [
{ startTime: "05:20", endTime: "05:29" },
{ startTime: "05:00", endTime: "06:00" },
]);
assert.equal(continued.nextAction.kind, "generate_dynamic_question");
assert.deepEqual(continued.progress.currentRange, { startTime: "05:00", endTime: "06:00" });
});
test("dynamic scoring failure retries the same job without duplicating evidence", async () => {
const flow = scoringFlow({ failOnce: true });
const pending = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
});
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
const jobId = pending.nextAction.jobId;
const failed = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, jobId);
const completed = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, jobId);
assert.deepEqual(failed.nextAction, { kind: "retry_scoring", jobId });
assert.equal(completed.nextAction.kind, "generate_dynamic_question");
assert.equal(flow.memory.savedCase()?.choiceEvidence?.length, 1);
assert.equal(flow.memory.savedCase()?.dynamicControl?.effectiveAnswerCount, 1);
assert.equal(flow.scoreCalls(), 2);
});
test("the second plateau is terminal and resume stays terminal", async () => {
const medium = {
...lowCandidate,
confidence: "medium" as const,
winningSegment: {
startTime: "05:10", endTime: "05:12", representativeTime: "05:11", widthMinutes: 3,
},
eventCount: 3,
domainCount: 3,
marginPercent: 10,
};
const priorEvidence = ["education_change", "relationship_change"].map((dimensionCode, index) => ({
questionId: `prior-${index}`,
opportunityId: `opportunity-${index}`,
partitionId: `partition-${index}`,
dimensionCode,
candidateScores: { "05:10": 1 },
informationGain: 0.5,
}));
const previous = { ...medium, confidence: "low" as const, eventCount: 2, domainCount: 2 };
const flow = scoringFlow({ initialCandidate: previous, candidate: medium, priorEvidence });
const pending = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
});
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
const terminal = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
const resumed = await flow.service.resumeDynamic(ownerId, dynamicCase().id);
assert.equal(terminal.nextAction.kind, "present_medium_result");
assert.deepEqual(resumed.nextAction, terminal.nextAction);
});