diff --git a/frontend/src/lib/birth-time-dynamic-engine-input.ts b/frontend/src/lib/birth-time-dynamic-engine-input.ts index c4632181..d7984ec5 100644 --- a/frontend/src/lib/birth-time-dynamic-engine-input.ts +++ b/frontend/src/lib/birth-time-dynamic-engine-input.ts @@ -3,17 +3,78 @@ import type { DynamicChoiceScoreInput, DynamicStoredRectificationCase, } from "./birth-time-journey-service.ts"; +import type { ServerChoiceEvidence } from "./birth-time-dynamic-choice-internal.ts"; +import type { TimeRange } from "./birth-time-dynamic-choice.ts"; export class BirthTimeDynamicEngineInputError extends Error { readonly name = "BirthTimeDynamicEngineInputError"; } +function rangeCandidateTimes(range: TimeRange): readonly string[] { + const minute = (value: string) => { + const [hour, part] = value.split(":").map(Number); + return hour * 60 + part; + }; + const time = (value: number) => ( + `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}` + ); + const end = minute(range.endTime); + let current = minute(range.startTime); + const candidates = [time(current)]; + while (current !== end) { + current = (current + 1) % 1_440; + candidates.push(time(current)); + } + return candidates; +} + +function evidenceForRange( + evidence: readonly ServerChoiceEvidence[], + range: TimeRange, +): readonly ServerChoiceEvidence[] { + const candidates = rangeCandidateTimes(range); + return evidence.map((item) => { + if (candidates.some((candidate) => !Object.hasOwn(item.candidateScores, candidate))) { + return item; + } + return { + ...item, + candidateScores: Object.fromEntries( + candidates.map((candidate) => [candidate, item.candidateScores[candidate]]), + ), + }; + }); +} + +function candidateModelForRange( + model: Readonly> | null, + range: TimeRange, +): Readonly> | null { + if (model === null) return null; + if (model.opportunity_model_version !== "birth-time-opportunity-model-v2") return null; + const persistedRange = model.range; + if (typeof persistedRange !== "object" || persistedRange === null) return model; + const value = persistedRange as Readonly>; + return value.start_time === range.startTime && value.end_time === range.endTime + ? model + : null; +} + export function dynamicChoiceScoreInput( stored: DynamicStoredRectificationCase, +): DynamicChoiceScoreInput { + return dynamicChoiceScoreInputForRange( + stored, + stored.dynamicTurnState.progress.currentRange, + ); +} + +function dynamicChoiceScoreInputForRange( + stored: DynamicStoredRectificationCase, + range: TimeRange, ): DynamicChoiceScoreInput { const context = stored.eventContext; if (!context) throw new BirthTimeDynamicEngineInputError(); - const range = stored.dynamicTurnState.progress.currentRange; return { birthDate: context.birthDate, startTime: range.startTime, @@ -21,21 +82,22 @@ export function dynamicChoiceScoreInput( lat: context.lat, lon: context.lon, tz: context.tz, - evidence: stored.choiceEvidence, + evidence: evidenceForRange(stored.choiceEvidence, range), }; } export function dynamicDifferenceInput( stored: DynamicStoredRectificationCase, + range: TimeRange = stored.dynamicTurnState.progress.currentRange, ): DifferencePacketInput { return { caseId: stored.id, asOfDate: stored.dynamicControl.asOfDate, - ...dynamicChoiceScoreInput(stored), + ...dynamicChoiceScoreInputForRange(stored, range), dismissedOpportunityIds: stored.dynamicControl.dismissedOpportunityIds, questionFingerprints: stored.dynamicControl.questionFingerprints, partitionFingerprints: stored.dynamicControl.partitionFingerprints, recentRanges: stored.dynamicControl.recentRanges, - candidateModel: stored.candidateModel, + candidateModel: candidateModelForRange(stored.candidateModel, range), }; } diff --git a/frontend/src/lib/birth-time-dynamic-scoring-service.ts b/frontend/src/lib/birth-time-dynamic-scoring-service.ts index 3d497f5f..3dc4ca8e 100644 --- a/frontend/src/lib/birth-time-dynamic-scoring-service.ts +++ b/frontend/src/lib/birth-time-dynamic-scoring-service.ts @@ -62,6 +62,18 @@ function requireCounts(stored: DynamicStoredRectificationCase, result: ReturnTyp } } +function usefulOpportunities( + stored: DynamicStoredRectificationCase, + build: Awaited>, +) { + return build.packet.opportunities.filter((opportunity) => ( + opportunity.estimatedInformationGain > 0 + && !stored.dynamicControl.partitionFingerprints.includes( + opportunity.candidatePartitionFingerprint, + ) + )); +} + export function createDynamicScoringService(ports: BirthTimeJourneyPorts) { return { async poll(userId: string, caseId: string, jobId: string) { @@ -97,13 +109,23 @@ export function createDynamicScoringService(ports: BirthTimeJourneyPorts) { ); assertDynamicScoringResult(result, stored.dynamicTurnState.progress.currentRange); requireCounts(stored, result); - const build = await engine.buildDifferencePacket(dynamicDifferenceInput(stored)); - const useful = build.packet.opportunities.filter((opportunity) => ( - opportunity.estimatedInformationGain > 0 - && !stored.dynamicControl.partitionFingerprints.includes( - opportunity.candidatePartitionFingerprint, - ) - )); + const segment = result.candidate.winningSegment; + const nextRange = segment === null + ? stored.dynamicTurnState.progress.currentRange + : { startTime: segment.startTime, endTime: segment.endTime }; + let build = await engine.buildDifferencePacket(dynamicDifferenceInput(stored, nextRange)); + let useful = usefulOpportunities(stored, build); + const priorRange = stored.dynamicTurnState.progress.currentRange; + const narrowed = nextRange.startTime !== priorRange.startTime + || nextRange.endTime !== priorRange.endTime; + if (useful.length === 0 && narrowed) { + const broaderBuild = await engine.buildDifferencePacket(dynamicDifferenceInput(stored)); + const broaderUseful = usefulOpportunities(stored, broaderBuild); + if (broaderUseful.length > 0) { + build = broaderBuild; + useful = broaderUseful; + } + } updated = completeDynamicScoreTransition({ stored, candidate: result.candidate, @@ -111,6 +133,7 @@ export function createDynamicScoringService(ports: BirthTimeJourneyPorts) { repeatedOnly: build.packet.opportunities.length > 0 && useful.length === 0, nextVersion: stored.turnVersion + 1, candidateModel: build.candidateModel, + continuationRange: build.packet.currentRange, }); } catch (error) { if (!(error instanceof Error)) throw error; diff --git a/frontend/src/lib/birth-time-dynamic-transitions.ts b/frontend/src/lib/birth-time-dynamic-transitions.ts index 0a8a5261..af58c2e8 100644 --- a/frontend/src/lib/birth-time-dynamic-transitions.ts +++ b/frontend/src/lib/birth-time-dynamic-transitions.ts @@ -8,6 +8,7 @@ import type { } from "./birth-time-dynamic-choice-internal.ts"; import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts"; import type { DynamicStoredRectificationCase } from "./birth-time-journey-service.ts"; +import type { TimeRange } from "./birth-time-dynamic-choice.ts"; const terminalKinds = new Set([ "present_low_result", @@ -156,6 +157,7 @@ export function completeDynamicScoreTransition(input: { readonly repeatedOnly: boolean; readonly nextVersion: number; readonly candidateModel?: Readonly>; + readonly continuationRange?: TimeRange; }): DynamicStoredRectificationCase { const stored = input.stored; const decision = decideDynamicStop({ @@ -177,9 +179,17 @@ export function completeDynamicScoreTransition(input: { : { kind: "present_low_result", resultId: input.candidate.resultId }; const priorRange = stored.dynamicTurnState.progress.currentRange; const segment = input.candidate.winningSegment; - const currentRange = segment === null + const candidateRange = segment === null ? priorRange : { startTime: segment.startTime, endTime: segment.endTime }; + const currentRange = decision.kind === "continue" + ? input.continuationRange ?? candidateRange + : candidateRange; + const rangeChanged = currentRange.startTime !== priorRange.startTime + || currentRange.endTime !== priorRange.endTime; + const previousRange = rangeChanged + ? priorRange + : stored.dynamicTurnState.progress.previousRange; const updated = withDynamicAction(stored, action, input.nextVersion); return { ...updated, @@ -189,14 +199,14 @@ export function completeDynamicScoreTransition(input: { dynamicControl: { ...stored.dynamicControl, plateauCount: decision.plateauCount, - recentRanges: [...stored.dynamicControl.recentRanges, currentRange], + recentRanges: [...stored.dynamicControl.recentRanges, candidateRange], }, dynamicTurnState: { ...updated.dynamicTurnState, progress: { ...updated.dynamicTurnState.progress, currentRange, - previousRange: priorRange, + previousRange, plateauCount: decision.plateauCount, }, permissions: { canConfirmCandidate: input.candidate.confidence === "high" }, diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts index cdb90fee..17acda54 100644 --- a/frontend/src/lib/birth-time-journey-service.ts +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -224,6 +224,10 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) { if (stored.journeyProtocol === "dynamic-choice-v2") { return storedDynamicJourneyResponse(stored); } + const upgraded = await ports.store.upgradeLegacyActiveCase(stored); + if (upgraded.journeyProtocol === "dynamic-choice-v2") { + return storedDynamicJourneyResponse(upgraded); + } const completedLegacyQuestionnaire = stored.snapshot.input === "rectification_questions" && stored.scoring?.nextRound === null && stored.scoring.nextRoundQuestions.length === 0 diff --git a/frontend/tests/birth-time-agent-flow-test-support.ts b/frontend/tests/birth-time-agent-flow-test-support.ts index cf611a22..a69a9f0b 100644 --- a/frontend/tests/birth-time-agent-flow-test-support.ts +++ b/frontend/tests/birth-time-agent-flow-test-support.ts @@ -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, }; } diff --git a/frontend/tests/birth-time-dynamic-engine-input.test.ts b/frontend/tests/birth-time-dynamic-engine-input.test.ts new file mode 100644 index 00000000..ce1301f0 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-engine-input.test.ts @@ -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, + }); +}); diff --git a/frontend/tests/birth-time-dynamic-range-progression.test.ts b/frontend/tests/birth-time-dynamic-range-progression.test.ts new file mode 100644 index 00000000..dff54f0f --- /dev/null +++ b/frontend/tests/birth-time-dynamic-range-progression.test.ts @@ -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", + }); +}); diff --git a/frontend/tests/birth-time-dynamic-resume.test.ts b/frontend/tests/birth-time-dynamic-resume.test.ts index c825f7d8..957f1c28 100644 --- a/frontend/tests/birth-time-dynamic-resume.test.ts +++ b/frontend/tests/birth-time-dynamic-resume.test.ts @@ -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); +}); diff --git a/frontend/tests/birth-time-dynamic-scoring.test.ts b/frontend/tests/birth-time-dynamic-scoring.test.ts index 7c1386fa..ef749374 100644 --- a/frontend/tests/birth-time-dynamic-scoring.test.ts +++ b/frontend/tests/birth-time-dynamic-scoring.test.ts @@ -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, { diff --git a/frontend/tests/birth-time-journey-service.test.ts b/frontend/tests/birth-time-journey-service.test.ts index eda33b57..57ede3d2 100644 --- a/frontend/tests/birth-time-journey-service.test.ts +++ b/frontend/tests/birth-time-journey-service.test.ts @@ -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); }); diff --git a/frontend/tests/birth-time-journey-test-support.ts b/frontend/tests/birth-time-journey-test-support.ts index 3ebef5b7..1aebe1d1 100644 --- a/frontend/tests/birth-time-journey-test-support.ts +++ b/frontend/tests/birth-time-journey-test-support.ts @@ -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() { diff --git a/scripts/dynamic_rectification_opportunities.py b/scripts/dynamic_rectification_opportunities.py index b18f7586..04f6933d 100644 --- a/scripts/dynamic_rectification_opportunities.py +++ b/scripts/dynamic_rectification_opportunities.py @@ -24,6 +24,7 @@ from scripts.dynamic_rectification_copy import ( ) ALGORITHM_VERSION: Final = "birth-time-choice-scoring-v2" +OPPORTUNITY_MODEL_VERSION: Final = "birth-time-opportunity-model-v2" MIN_INFORMATION_GAIN: Final = 0.15 @@ -44,7 +45,9 @@ def candidate_times(birth_date: str, start_time: str, end_time: str) -> list[str return [(start + timedelta(minutes=offset)).strftime("%H:%M") for offset in range(count)] -def experience_windows(birth_date: str, as_of_date: str) -> list[tuple[date, date]]: +def experience_window_sets( + birth_date: str, as_of_date: str, +) -> list[tuple[str, list[tuple[date, date]]]]: born = date.fromisoformat(birth_date) as_of = date.fromisoformat(as_of_date) try: @@ -54,14 +57,29 @@ def experience_windows(birth_date: str, as_of_date: str) -> list[tuple[date, dat if as_of < first: return [] day_count = (as_of - first).days + 1 - count = min(4, day_count, max(2, math.ceil(day_count / (6 * 365)))) - boundaries = [first + timedelta(days=day_count * index // count) for index in range(count)] + counts = [1] if day_count == 1 else list(range(2, min(4, day_count) + 1)) return [ - (start, as_of if index == count - 1 else boundaries[index + 1] - timedelta(days=1)) - for index, start in enumerate(boundaries) + ( + f"periods-{count}", + [ + ( + first + timedelta(days=day_count * index // count), + as_of if index == count - 1 else ( + first + timedelta(days=day_count * (index + 1) // count - 1) + ), + ) + for index in range(count) + ], + ) + for count in counts ] +def experience_windows(birth_date: str, as_of_date: str) -> list[tuple[date, date]]: + sets = experience_window_sets(birth_date, as_of_date) + return sets[-1][1] if sets else [] + + def candidate_window_rows(request: dict) -> list[dict]: """Compute each candidate chart once and reuse it across every window.""" from scripts.active_rectification_event_engine import ( @@ -70,25 +88,28 @@ def candidate_window_rows(request: dict) -> list[dict]: _candidate_row, ) - windows = experience_windows(request["birth_date"], request["as_of_date"]) - if not windows: + window_sets = experience_window_sets(request["birth_date"], request["as_of_date"]) + if not window_sets: return [] events = [] - event_windows: dict[str, tuple[str, date, date]] = {} + event_windows: dict[str, tuple[str, str, date, date]] = {} for dimension in sorted(SUPPORTED_DIMENSIONS): - for window_start, window_end in windows: - event_id = str(uuid5( - NAMESPACE_URL, - f"{ALGORITHM_VERSION}:{dimension}:{window_start}:{window_end}", - )) - midpoint = window_start + (window_end - window_start) / 2 - events.append({ - "id": event_id, - "domain": dimension, - "date": midpoint.isoformat(), - "precision": "day", - }) - event_windows[event_id] = (dimension, window_start, window_end) + for window_group, windows in window_sets: + for window_start, window_end in windows: + event_id = str(uuid5( + NAMESPACE_URL, + f"{ALGORITHM_VERSION}:{window_group}:{dimension}:{window_start}:{window_end}", + )) + midpoint = window_start + (window_end - window_start) / 2 + events.append({ + "id": event_id, + "domain": dimension, + "date": midpoint.isoformat(), + "precision": "day", + }) + event_windows[event_id] = ( + window_group, dimension, window_start, window_end, + ) calculation_request = { "birth_date": request["birth_date"], "start_time": request["start_time"], @@ -109,6 +130,7 @@ def candidate_window_rows(request: dict) -> list[dict]: activations[evidence["event_id"]][row["time"]] = float(evidence["points"]) return [ { + "window_group": window_group, "dimension_code": dimension, "window_start": window_start.isoformat(), "window_end": window_end.isoformat(), @@ -116,13 +138,14 @@ def candidate_window_rows(request: dict) -> list[dict]: "missing_layers": [DOMAIN_CONFIG[dimension][0]] if DOMAIN_CONFIG[dimension][0] in missing else [], } - for event_id, (dimension, window_start, window_end) in event_windows.items() + for event_id, (window_group, dimension, window_start, window_end) in event_windows.items() ] def compute_candidate_model(request: dict, row_builder: Callable[[dict], list[dict]]) -> dict: return { "version": ALGORITHM_VERSION, + "opportunity_model_version": OPPORTUNITY_MODEL_VERSION, "birth_date": request["birth_date"], "as_of_date": request["as_of_date"], "range": {"start_time": request["start_time"], "end_time": request["end_time"]}, @@ -140,7 +163,7 @@ def compute_candidate_model(request: dict, row_builder: Callable[[dict], list[di def validate_candidate_model(model: dict, request: dict) -> dict: expected = { - "version", "birth_date", "as_of_date", "range", "location", + "version", "opportunity_model_version", "birth_date", "as_of_date", "range", "location", "candidate_times", "windows", } candidates = candidate_times(request["birth_date"], request["start_time"], request["end_time"]) @@ -148,6 +171,7 @@ def validate_candidate_model(model: dict, request: dict) -> dict: valid_header = ( set(model) == expected and model["version"] == ALGORITHM_VERSION + and model["opportunity_model_version"] == OPPORTUNITY_MODEL_VERSION and model["birth_date"] == request["birth_date"] and model["as_of_date"] == request["as_of_date"] and model["range"] == { @@ -168,18 +192,20 @@ def validate_candidate_model(model: dict, request: dict) -> dict: def _validate_windows(windows: list, request: dict, candidates: list[str]) -> bool: - generated = experience_windows(request["birth_date"], request["as_of_date"]) - minimum = generated[0][0] if generated else date.max + generated = experience_window_sets(request["birth_date"], request["as_of_date"]) + minimum = generated[0][1][0][0] if generated else date.max maximum = date.fromisoformat(request["as_of_date"]) + groups = {name for name, _windows in generated} keys = [ - (row.get("dimension_code"), row.get("window_start"), row.get("window_end")) + (row.get("window_group"), row.get("dimension_code"), row.get("window_start"), row.get("window_end")) for row in windows if isinstance(row, dict) ] return len(keys) == len(set(keys)) and all( isinstance(row, dict) and set(row) == { - "dimension_code", "window_start", "window_end", "activations", "missing_layers" + "window_group", "dimension_code", "window_start", "window_end", "activations", "missing_layers" } + and row["window_group"] in groups and row["dimension_code"] in SUPPORTED_DIMENSIONS and minimum <= date.fromisoformat(row["window_start"]) <= date.fromisoformat(row["window_end"]) <= maximum @@ -199,19 +225,27 @@ def _validate_windows(windows: list, request: dict, candidates: list[str]) -> bo def opportunities(model: dict) -> list[dict]: - grouped: dict[str, list[dict]] = defaultdict(list) + grouped: dict[tuple[str, str], list[dict]] = defaultdict(list) for row in model["windows"]: if not row["missing_layers"]: - grouped[row["dimension_code"]].append(row) - result = [] - for dimension, windows in sorted(grouped.items()): - opportunity = _dimension_opportunity(dimension, windows, model["candidate_times"]) + grouped[(row["dimension_code"], row["window_group"])].append(row) + variants: dict[str, list[dict]] = defaultdict(list) + for (dimension, window_group), windows in sorted(grouped.items()): + opportunity = _dimension_opportunity( + dimension, window_group, windows, model["candidate_times"], + ) if opportunity is not None: - result.append(opportunity) + variants[dimension].append(opportunity) + result = [ + sorted(items, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]))[0] + for items in variants.values() + ] return sorted(result, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"])) -def _dimension_opportunity(dimension: str, windows: list[dict], candidates: list[str]) -> dict | None: +def _dimension_opportunity( + dimension: str, window_group: str, windows: list[dict], candidates: list[str], +) -> dict | None: neutral_context = DIMENSION_CONTEXT[dimension] memberships: dict[int, list[str]] = defaultdict(list) for candidate in candidates: @@ -230,6 +264,7 @@ def _dimension_opportunity(dimension: str, windows: list[dict], candidates: list basis = [ { "version": ALGORITHM_VERSION, + "window_group": window_group, "dimension": dimension, "window_start": window["window_start"], "window_end": window["window_end"], diff --git a/tests/test_dynamic_rectification.py b/tests/test_dynamic_rectification.py index 553ff9bb..4ae778cf 100644 --- a/tests/test_dynamic_rectification.py +++ b/tests/test_dynamic_rectification.py @@ -28,6 +28,7 @@ def _base_request() -> dict: def _fake_rows(_request: dict) -> list[dict]: return [ { + "window_group": "periods-3", "dimension_code": "career", "window_start": "2014-01-01", "window_end": "2017-12-31", @@ -35,6 +36,7 @@ def _fake_rows(_request: dict) -> list[dict]: "missing_layers": [], }, { + "window_group": "periods-3", "dimension_code": "career", "window_start": "2018-01-01", "window_end": "2021-12-31", @@ -42,6 +44,7 @@ def _fake_rows(_request: dict) -> list[dict]: "missing_layers": [], }, { + "window_group": "periods-3", "dimension_code": "career", "window_start": "2022-01-01", "window_end": "2026-07-18", @@ -54,6 +57,7 @@ def _fake_rows(_request: dict) -> list[dict]: def _fake_model() -> dict: return { "version": "birth-time-choice-scoring-v2", + "opportunity_model_version": "birth-time-opportunity-model-v2", "birth_date": "1990-01-01", "as_of_date": "2026-07-18", "range": {"start_time": "05:30", "end_time": "05:33"}, @@ -83,6 +87,21 @@ def test_packet_contains_only_candidate_backed_high_gain_opportunities(monkeypat } +def test_period_range_offers_multiple_distinct_evidence_domains() -> None: + packet = dynamic_rectification.build_difference_packet({ + **_base_request(), + "birth_date": "1997-08-09", + "as_of_date": "2026-07-19", + "start_time": "04:00", + "end_time": "07:59", + "lat": 36.6, + "lon": 114.5, + }) + + dimensions = {item["dimension_code"] for item in packet["opportunities"]} + assert len(dimensions) >= 4 + + def test_packet_excludes_used_opportunity_and_partition_fingerprints(monkeypatch) -> None: monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", _fake_rows) first = dynamic_rectification.build_difference_packet(_base_request())