fix: continue narrowing birth time candidates

This commit is contained in:
Jesse_Chen
2026-07-19 14:48:02 +08:00
parent 293f6216e3
commit 05d2f3ca55
13 changed files with 464 additions and 57 deletions
@@ -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