fix: continue narrowing birth time candidates
This commit is contained in:
@@ -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<Record<string, unknown>> | null,
|
||||
range: TimeRange,
|
||||
): Readonly<Record<string, unknown>> | 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<Record<string, unknown>>;
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@ function requireCounts(stored: DynamicStoredRectificationCase, result: ReturnTyp
|
||||
}
|
||||
}
|
||||
|
||||
function usefulOpportunities(
|
||||
stored: DynamicStoredRectificationCase,
|
||||
build: Awaited<ReturnType<BirthTimeJourneyEngine["buildDifferencePacket"]>>,
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -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<DynamicNextAction["kind"]>([
|
||||
"present_low_result",
|
||||
@@ -156,6 +157,7 @@ export function completeDynamicScoreTransition(input: {
|
||||
readonly repeatedOnly: boolean;
|
||||
readonly nextVersion: number;
|
||||
readonly candidateModel?: Readonly<Record<string, unknown>>;
|
||||
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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user