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
|
||||
|
||||
Reference in New Issue
Block a user