From 0299a1ea46135e1858ceb9a4b6fe9abe1e05cdaa Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 01:38:30 +0800 Subject: [PATCH] fix: rebuild incompatible birth-time candidate models --- .../lib/birth-time-dynamic-engine-input.ts | 18 +++++++++---- .../birth-time-dynamic-engine-input.test.ts | 25 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/birth-time-dynamic-engine-input.ts b/frontend/src/lib/birth-time-dynamic-engine-input.ts index d7984ec5..8e84d976 100644 --- a/frontend/src/lib/birth-time-dynamic-engine-input.ts +++ b/frontend/src/lib/birth-time-dynamic-engine-input.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { DifferencePacketInput, DynamicChoiceScoreInput, @@ -6,6 +7,14 @@ import type { import type { ServerChoiceEvidence } from "./birth-time-dynamic-choice-internal.ts"; import type { TimeRange } from "./birth-time-dynamic-choice.ts"; +const reusableCandidateModelSchema = z.object({ + opportunity_model_version: z.literal("birth-time-opportunity-model-v2"), + range: z.object({ start_time: z.string(), end_time: z.string() }), + windows: z.array(z.object({ + activations: z.record(z.string(), z.number().finite().nonnegative()), + }).passthrough()), +}).passthrough(); + export class BirthTimeDynamicEngineInputError extends Error { readonly name = "BirthTimeDynamicEngineInputError"; } @@ -51,11 +60,10 @@ function candidateModelForRange( 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 + const parsed = reusableCandidateModelSchema.safeParse(model); + if (!parsed.success) return null; + return parsed.data.range.start_time === range.startTime + && parsed.data.range.end_time === range.endTime ? model : null; } diff --git a/frontend/tests/birth-time-dynamic-engine-input.test.ts b/frontend/tests/birth-time-dynamic-engine-input.test.ts index ce1301f0..5dff61fb 100644 --- a/frontend/tests/birth-time-dynamic-engine-input.test.ts +++ b/frontend/tests/birth-time-dynamic-engine-input.test.ts @@ -6,6 +6,15 @@ import { } from "../src/lib/birth-time-dynamic-engine-input.ts"; import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts"; +function matchingCandidateModel(activation = 0) { + return { + version: "birth-time-choice-scoring-v2", + opportunity_model_version: "birth-time-opportunity-model-v2", + range: { start_time: "05:02", end_time: "05:03" }, + windows: [{ activations: { "05:02": activation, "05:03": 1 } }], + }; +} + function narrowedCase(startTime = "05:02", endTime = "05:03") { const stored = dynamicCase(); return { @@ -65,16 +74,22 @@ test("narrowed question generation rebuilds a candidate model for the current ra 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 matchingModel = matchingCandidateModel(); const input = dynamicDifferenceInput({ ...stored, candidateModel: matchingModel }); assert.equal(input.candidateModel, matchingModel); }); +test("persisted candidate models with legacy negative activations are rebuilt", () => { + const stored = narrowedCase(); + const input = dynamicDifferenceInput({ + ...stored, + candidateModel: matchingCandidateModel(-0.2), + }); + + assert.equal(input.candidateModel, null); +}); + test("evidence projection preserves cross-midnight candidate chronology", () => { const stored = narrowedCase("23:59", "00:00"); const input = dynamicChoiceScoreInput({