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
@@ -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() {
+69 -34
View File
@@ -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"],
+19
View File
@@ -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())