fix: continue narrowing birth time candidates
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
guidedCase,
|
||||
journeyCaseId,
|
||||
memoryStore,
|
||||
preserveLegacyResumeStore,
|
||||
unusedJourneyEngine,
|
||||
} from "./birth-time-journey-test-support.ts";
|
||||
|
||||
@@ -82,6 +83,7 @@ export function createHarness(input: {
|
||||
readonly failFirstScore?: boolean;
|
||||
}) {
|
||||
const memory = memoryStore(input.initial);
|
||||
const store = preserveLegacyResumeStore(memory.store);
|
||||
let scoreEventsCalls = 0;
|
||||
const engine: LegacyBirthTimeJourneyEngine = {
|
||||
...unusedJourneyEngine,
|
||||
@@ -93,17 +95,17 @@ export function createHarness(input: {
|
||||
: input.result;
|
||||
},
|
||||
};
|
||||
const service = createBirthTimeJourneyService({ store: memory.store, engine });
|
||||
const service = createBirthTimeJourneyService({ store, engine });
|
||||
const guide = createBirthTimeGuideService({
|
||||
generator: createFakeAgent(),
|
||||
loadCase: memory.store.loadCase,
|
||||
loadCase: store.loadCase,
|
||||
proposeEvidenceDraft: service.proposeEvidenceDraft,
|
||||
});
|
||||
return {
|
||||
memory,
|
||||
service,
|
||||
guide,
|
||||
candidateActions: createGuidedCandidateActions({ store: memory.store }),
|
||||
candidateActions: createGuidedCandidateActions({ store }),
|
||||
scoreEventsCalls: () => scoreEventsCalls,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
dynamicChoiceScoreInput,
|
||||
dynamicDifferenceInput,
|
||||
} from "../src/lib/birth-time-dynamic-engine-input.ts";
|
||||
import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
|
||||
function narrowedCase(startTime = "05:02", endTime = "05:03") {
|
||||
const stored = dynamicCase();
|
||||
return {
|
||||
...stored,
|
||||
eventContext: {
|
||||
birthDate: "1993-04-17",
|
||||
lat: 31.23,
|
||||
lon: 121.47,
|
||||
tz: 8,
|
||||
},
|
||||
choiceEvidence: [{
|
||||
questionId: "prior-question",
|
||||
opportunityId: "prior-opportunity",
|
||||
partitionId: "prior-partition",
|
||||
dimensionCode: "relocation_change",
|
||||
candidateScores: {
|
||||
"05:00": 0,
|
||||
"05:01": 0.25,
|
||||
"05:02": 0.5,
|
||||
"05:03": 0.75,
|
||||
"05:04": 1,
|
||||
},
|
||||
informationGain: 0.4,
|
||||
}],
|
||||
candidateModel: {
|
||||
version: "birth-time-choice-scoring-v2",
|
||||
range: { start_time: "05:00", end_time: "05:04" },
|
||||
},
|
||||
dynamicTurnState: {
|
||||
...stored.dynamicTurnState,
|
||||
progress: {
|
||||
...stored.dynamicTurnState.progress,
|
||||
currentRange: { startTime, endTime },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("narrowed scoring projects prior evidence onto the current candidate range", () => {
|
||||
const input = dynamicChoiceScoreInput(narrowedCase());
|
||||
|
||||
assert.deepEqual(input.evidence[0]?.candidateScores, {
|
||||
"05:02": 0.5,
|
||||
"05:03": 0.75,
|
||||
});
|
||||
});
|
||||
|
||||
test("narrowed question generation rebuilds a candidate model for the current range", () => {
|
||||
const input = dynamicDifferenceInput(narrowedCase());
|
||||
|
||||
assert.deepEqual(input.evidence[0]?.candidateScores, {
|
||||
"05:02": 0.5,
|
||||
"05:03": 0.75,
|
||||
});
|
||||
assert.equal(input.candidateModel, null);
|
||||
});
|
||||
|
||||
test("matching candidate models remain reusable", () => {
|
||||
const stored = narrowedCase();
|
||||
const matchingModel = {
|
||||
version: "birth-time-choice-scoring-v2",
|
||||
opportunity_model_version: "birth-time-opportunity-model-v2",
|
||||
range: { start_time: "05:02", end_time: "05:03" },
|
||||
};
|
||||
const input = dynamicDifferenceInput({ ...stored, candidateModel: matchingModel });
|
||||
|
||||
assert.equal(input.candidateModel, matchingModel);
|
||||
});
|
||||
|
||||
test("evidence projection preserves cross-midnight candidate chronology", () => {
|
||||
const stored = narrowedCase("23:59", "00:00");
|
||||
const input = dynamicChoiceScoreInput({
|
||||
...stored,
|
||||
choiceEvidence: [{
|
||||
...stored.choiceEvidence[0],
|
||||
candidateScores: { "23:58": 0, "23:59": 0.25, "00:00": 0.5, "00:01": 0.75 },
|
||||
}],
|
||||
});
|
||||
|
||||
assert.deepEqual(input.evidence[0]?.candidateScores, {
|
||||
"23:59": 0.25,
|
||||
"00:00": 0.5,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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 { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
|
||||
const narrowedLowCandidate: CandidateResult = {
|
||||
resultId: "615499c9-f4da-4da0-a8bd-da26b2b8477f",
|
||||
confidence: "low",
|
||||
canApply: false,
|
||||
winningSegment: {
|
||||
startTime: "05:10",
|
||||
endTime: "05:20",
|
||||
representativeTime: "05:15",
|
||||
widthMinutes: 11,
|
||||
},
|
||||
eventCount: 1,
|
||||
domainCount: 1,
|
||||
topScore: 1,
|
||||
secondScore: 0,
|
||||
marginPercent: 100,
|
||||
reasons: ["insufficient_effective_evidence"],
|
||||
evidence: [],
|
||||
algorithmVersion: "birth-time-choice-scoring-v2",
|
||||
};
|
||||
|
||||
test("low-confidence continuation narrows the candidate universe for the next question", () => {
|
||||
const stored = dynamicCase();
|
||||
const result = completeDynamicScoreTransition({
|
||||
stored: { ...stored, currentChoiceQuestion: null },
|
||||
candidate: narrowedLowCandidate,
|
||||
usefulOpportunityCount: 1,
|
||||
repeatedOnly: false,
|
||||
nextVersion: stored.turnVersion + 1,
|
||||
});
|
||||
|
||||
assert.equal(result.dynamicTurnState.nextAction.kind, "generate_dynamic_question");
|
||||
assert.deepEqual(
|
||||
result.dynamicTurnState.progress.currentRange,
|
||||
{ startTime: "05:10", endTime: "05:20" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.dynamicTurnState.progress.previousRange,
|
||||
stored.dynamicTurnState.progress.currentRange,
|
||||
);
|
||||
assert.deepEqual(result.dynamicControl.recentRanges.at(-1), {
|
||||
startTime: "05:10",
|
||||
endTime: "05:20",
|
||||
});
|
||||
});
|
||||
|
||||
test("terminal low confidence publishes its final candidate segment", () => {
|
||||
const stored = dynamicCase();
|
||||
const result = completeDynamicScoreTransition({
|
||||
stored: { ...stored, currentChoiceQuestion: null },
|
||||
candidate: narrowedLowCandidate,
|
||||
usefulOpportunityCount: 0,
|
||||
repeatedOnly: false,
|
||||
nextVersion: stored.turnVersion + 1,
|
||||
});
|
||||
|
||||
assert.equal(result.dynamicTurnState.nextAction.kind, "present_low_result");
|
||||
assert.deepEqual(result.dynamicTurnState.progress.currentRange, {
|
||||
startTime: "05:10",
|
||||
endTime: "05:20",
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-ser
|
||||
import {
|
||||
caseId,
|
||||
dynamicCase,
|
||||
legacyCase,
|
||||
ownerId,
|
||||
} from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { memoryStore } from "./birth-time-journey-memory-store.ts";
|
||||
@@ -35,3 +36,19 @@ test("v2 resume returns the stored dynamic turn without legacy scoring writes",
|
||||
}, stored.dynamicTurnState);
|
||||
assert.equal(memory.legacyWrites(), 0);
|
||||
});
|
||||
|
||||
test("resume upgrades an unfinished legacy case into the dynamic click-first flow", async () => {
|
||||
const stored = legacyCase(true);
|
||||
const memory = memoryStore(stored);
|
||||
const service = createBirthTimeJourneyService({ store: memory.store, engine: unusedJourneyEngine });
|
||||
|
||||
const resumed = await service.resume(ownerId, caseId);
|
||||
|
||||
assert.equal(resumed.journeyProtocol, "dynamic-choice-v2");
|
||||
assert.equal(resumed.nextAction.kind, "generate_dynamic_question");
|
||||
assert.equal(resumed.turnVersion, stored.turnVersion);
|
||||
assert.deepEqual(memory.savedCase()?.lifeEvents, stored.lifeEvents);
|
||||
assert.deepEqual(resumed.lifeEvents, []);
|
||||
assert.equal(memory.savedCase()?.journeyProtocol, "dynamic-choice-v2");
|
||||
assert.equal(memory.legacyWrites(), 0);
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ function scoringFlow(input: {
|
||||
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);
|
||||
@@ -78,6 +79,7 @@ function scoringFlow(input: {
|
||||
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({
|
||||
@@ -100,12 +102,13 @@ function scoringFlow(input: {
|
||||
};
|
||||
},
|
||||
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: [{
|
||||
opportunities: input.emptyNarrowedRange && value.startTime === "05:20" ? [] : [{
|
||||
opportunityId: "next-opportunity",
|
||||
dimensionCode: "relocation_change",
|
||||
neutralContext: "一次居住变化",
|
||||
@@ -127,7 +130,7 @@ function scoringFlow(input: {
|
||||
},
|
||||
},
|
||||
});
|
||||
return { memory, jobs, service, scoreCalls: () => scoreCalls };
|
||||
return { memory, jobs, service, scoreCalls: () => scoreCalls, differenceRanges: () => differenceRanges };
|
||||
}
|
||||
|
||||
test("score completion continues only when stop policy allows it", () => {
|
||||
@@ -197,6 +200,66 @@ test("dynamic scoring claims once, completes atomically, and replays", async ()
|
||||
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, {
|
||||
|
||||
@@ -118,7 +118,7 @@ test("journey service accumulates legacy answers while preserving the applicatio
|
||||
assert.deepEqual(memory.savedCase()?.answers, scoredAnswers);
|
||||
});
|
||||
|
||||
test("journey service resumes an owner-scoped unfinished legacy case", async () => {
|
||||
test("journey service upgrades an owner-scoped unfinished legacy case on resume", async () => {
|
||||
const storedCase: StoredRectificationCase = {
|
||||
id: journeyCaseId,
|
||||
userId: "user-1",
|
||||
@@ -136,15 +136,18 @@ test("journey service resumes an owner-scoped unfinished legacy case", async ()
|
||||
questionnaire: scanWithSigns(["Cancer", "Leo"]).questionnaire,
|
||||
answers: { education_environment_shift: "A" },
|
||||
};
|
||||
const memory = memoryStore(storedCase);
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: memoryStore(storedCase).store,
|
||||
store: memory.store,
|
||||
engine: unusedJourneyEngine,
|
||||
});
|
||||
|
||||
const result = await service.resume("user-1", journeyCaseId);
|
||||
|
||||
assert.equal(result.caseId, journeyCaseId);
|
||||
assert.deepEqual(result.answers, { education_environment_shift: "A" });
|
||||
assert.equal(result.journeyProtocol, "dynamic-choice-v2");
|
||||
assert.deepEqual(result.answers, {});
|
||||
assert.deepEqual(memory.savedCase()?.answers, { education_environment_shift: "A" });
|
||||
assert.equal(result.snapshot.canApply, false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { birthTimeAssessmentSchema, candidateResultSchema, lifeEventSchema } from "../src/lib/birth-time-journey.ts";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import type {
|
||||
BirthTimeJourneyStore,
|
||||
LegacyBirthTimeJourneyEngine,
|
||||
LegacyStoredRectificationCase,
|
||||
} from "../src/lib/birth-time-journey-service.ts";
|
||||
@@ -62,6 +63,15 @@ export const unusedJourneyEngine: LegacyBirthTimeJourneyEngine = {
|
||||
async scoreEvents() { throw new UnexpectedTestCallError(); },
|
||||
};
|
||||
|
||||
export function preserveLegacyResumeStore(store: BirthTimeJourneyStore): BirthTimeJourneyStore {
|
||||
return {
|
||||
...store,
|
||||
async upgradeLegacyActiveCase(value) {
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function evidenceQuestion(
|
||||
phase: "baseline" | "adaptive",
|
||||
domain: EvidenceDomain,
|
||||
@@ -172,7 +182,7 @@ export function progressionService(storedCase: LegacyStoredRectificationCase) {
|
||||
let scoreEventsCalls = 0;
|
||||
const memory = memoryStore(storedCase);
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: memory.store,
|
||||
store: preserveLegacyResumeStore(memory.store),
|
||||
engine: {
|
||||
...unusedJourneyEngine,
|
||||
async scoreEvents() {
|
||||
|
||||
Reference in New Issue
Block a user