fix: harden dynamic engine adapters

This commit is contained in:
Jesse_Chen
2026-07-19 01:49:09 +08:00
parent fb947f25a6
commit 42df227feb
13 changed files with 723 additions and 507 deletions
+1 -181
View File
@@ -5,14 +5,6 @@ import {
type BirthTimeAssessment,
} from "./birth-time-journey.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
import {
candidateDifferenceBuildSchema,
dynamicChoiceScoringResultSchema,
} from "./birth-time-dynamic-choice-internal.ts";
import type {
CandidateDifferenceBuild,
DynamicChoiceScoringResult,
} from "./birth-time-dynamic-choice-internal.ts";
import type {
RectificationAnswer,
RectificationQuestion,
@@ -97,7 +89,7 @@ const candidateEvidenceApiSchema = z.object({
candidate_time: z.string(),
rule_ids: z.array(z.string()),
points: z.number(),
}).strict();
});
const candidateResultApiFields = {
result_id: z.string().uuid(),
@@ -120,125 +112,6 @@ const candidateResultApiFields = {
} as const;
const candidateResultApiSchema = z.object(candidateResultApiFields).passthrough();
const apiCandidateTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const apiTimeRangeSchema = z.object({
start_time: apiCandidateTimeSchema,
end_time: apiCandidateTimeSchema,
}).strict();
const scoredPartitionApiSchema = z.object({
partition_id: z.string().trim().min(1),
descriptor: z.string().trim().min(1),
fallback_label: z.string().trim().min(1).max(80),
candidate_scores: z.record(apiCandidateTimeSchema, z.number().finite().nonnegative()),
}).strict();
function candidateTimes(startTime: string, endTime: string): Set<string> {
const toMinute = (value: string) => {
const [hour, minute] = value.split(":").map(Number);
return hour * 60 + minute;
};
const toTime = (value: number) => `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`;
const end = toMinute(endTime);
let current = toMinute(startTime);
const result = new Set<string>([toTime(current)]);
while (current !== end) {
current = (current + 1) % 1_440;
result.add(toTime(current));
}
return result;
}
const opportunityApiSchema = z.object({
opportunity_id: z.string().trim().min(1),
dimension_code: z.string().trim().min(1),
neutral_context: z.string().trim().min(1),
estimated_information_gain: z.number().finite().nonnegative(),
candidate_partition_fingerprint: z.string().trim().min(1),
fallback_prompt: z.string().trim().min(1).max(240),
partitions: z.array(scoredPartitionApiSchema).min(2).max(4),
}).strict().superRefine((value, context) => {
if (new Set(value.partitions.map((partition) => partition.partition_id)).size !== value.partitions.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["partitions"],
message: "opportunity partition ids must be unique",
});
}
});
const candidateDifferenceApiSchema = z.object({
success: z.literal(true),
endpoint: z.literal("dynamic_rectification_opportunities"),
case_id: z.string().trim().min(1),
scoring_version: z.literal("birth-time-choice-scoring-v2"),
current_range: apiTimeRangeSchema,
opportunities: z.array(opportunityApiSchema),
asked_question_fingerprints: z.array(z.string().trim().min(1)),
candidate_partition_fingerprints: z.array(z.string().trim().min(1)),
recent_range_history: z.array(apiTimeRangeSchema),
candidate_model: z.record(z.unknown()),
}).strict().superRefine((value, context) => {
if (new Set(value.opportunities.map((opportunity) => opportunity.opportunity_id)).size !== value.opportunities.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["opportunities"],
message: "opportunity ids must be unique",
});
}
const expectedCandidates = candidateTimes(
value.current_range.start_time,
value.current_range.end_time,
);
value.opportunities.forEach((opportunity, opportunityIndex) => {
opportunity.partitions.forEach((partition, partitionIndex) => {
const actualCandidates = Object.keys(partition.candidate_scores);
if (
actualCandidates.length !== expectedCandidates.size
|| actualCandidates.some((candidate) => !expectedCandidates.has(candidate))
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["opportunities", opportunityIndex, "partitions", partitionIndex, "candidate_scores"],
message: "candidate scores must exactly match the current range",
});
}
});
});
});
const dynamicChoiceScoringApiSchema = z.object({
success: z.literal(true),
endpoint: z.literal("dynamic_rectification_score"),
...candidateResultApiFields,
algorithm_version: z.literal("birth-time-choice-scoring-v2"),
evidence_mode: z.literal("dynamic_choice"),
effective_answer_count: z.number().int().min(0).max(10),
dimension_count: z.number().int().min(0).max(5),
}).strict().superRefine((value, context) => {
if (value.event_count !== value.effective_answer_count) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["event_count"],
message: "event count must equal effective answer count",
});
}
if (value.domain_count !== value.dimension_count) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["domain_count"],
message: "domain count must equal dimension count",
});
}
if (value.evidence.length !== 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["evidence"],
message: "dynamic choice results cannot contain public dated-event evidence",
});
}
});
class UnexpectedProfileSourceError extends Error {
readonly name = "UnexpectedProfileSourceError";
@@ -325,59 +198,6 @@ export function parseRectificationAnswer(value: unknown): RectificationAnswer {
return z.enum(["A", "B", "C", "D"]).parse(value);
}
export function parseCandidateDifferenceBuild(value: unknown): CandidateDifferenceBuild {
const parsed = candidateDifferenceApiSchema.parse(value);
return candidateDifferenceBuildSchema.parse({
packet: {
caseId: parsed.case_id,
scoringVersion: parsed.scoring_version,
currentRange: {
startTime: parsed.current_range.start_time,
endTime: parsed.current_range.end_time,
},
opportunities: parsed.opportunities.map((opportunity) => ({
opportunityId: opportunity.opportunity_id,
dimensionCode: opportunity.dimension_code,
neutralContext: opportunity.neutral_context,
estimatedInformationGain: opportunity.estimated_information_gain,
candidatePartitionFingerprint: opportunity.candidate_partition_fingerprint,
fallbackPrompt: opportunity.fallback_prompt,
partitions: opportunity.partitions.map((partition) => ({
partitionId: partition.partition_id,
descriptor: partition.descriptor,
fallbackLabel: partition.fallback_label,
})),
})),
askedQuestionFingerprints: parsed.asked_question_fingerprints,
candidatePartitionFingerprints: parsed.candidate_partition_fingerprints,
recentRangeHistory: parsed.recent_range_history.map((range) => ({
startTime: range.start_time,
endTime: range.end_time,
})),
},
candidateModel: parsed.candidate_model,
scoringPartitions: Object.fromEntries(parsed.opportunities.map((opportunity) => [
opportunity.opportunity_id,
opportunity.partitions.map((partition) => ({
partitionId: partition.partition_id,
descriptor: partition.descriptor,
fallbackLabel: partition.fallback_label,
candidateScores: partition.candidate_scores,
})),
])),
});
}
export function parseDynamicChoiceScoring(value: unknown): DynamicChoiceScoringResult {
const parsed = dynamicChoiceScoringApiSchema.parse(value);
return dynamicChoiceScoringResultSchema.parse({
candidate: adaptCandidateResult(parsed),
evidenceMode: parsed.evidence_mode,
effectiveAnswerCount: parsed.effective_answer_count,
dimensionCount: parsed.dimension_count,
});
}
export function parseCandidateResult(value: unknown): CandidateResult {
return adaptCandidateResult(candidateResultApiSchema.parse(value));
}
@@ -1,6 +1,6 @@
import type {
BirthTimeJourneyEngine,
JourneyScanInput,
LegacyBirthTimeJourneyEngine,
RectificationQuestionnaire,
} from "./birth-time-journey-service.ts";
import type {
@@ -54,7 +54,7 @@ function questionnaireStability(
}
export async function scanAssessment(
engine: BirthTimeJourneyEngine,
engine: Pick<LegacyBirthTimeJourneyEngine, "scan">,
assessment: BirthTimeAssessment,
): Promise<{
readonly stability: ScanStability;
@@ -0,0 +1,205 @@
import { z } from "zod";
import {
candidateDifferenceBuildSchema,
dynamicChoiceScoringResultSchema,
} from "./birth-time-dynamic-choice-internal.ts";
import { candidateResultSchema } from "./birth-time-evidence.ts";
import type {
CandidateDifferenceBuild,
DynamicChoiceScoringResult,
} from "./birth-time-dynamic-choice-internal.ts";
const candidateTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const apiRangeSchema = z.object({
start_time: candidateTimeSchema,
end_time: candidateTimeSchema,
}).strict();
const partitionSchema = z.object({
partition_id: z.string().trim().min(1),
descriptor: z.string().trim().min(1),
fallback_label: z.string().trim().min(1).max(80),
candidate_scores: z.record(candidateTimeSchema, z.number().finite().nonnegative()),
}).strict();
const opportunitySchema = z.object({
opportunity_id: z.string().trim().min(1),
dimension_code: z.string().trim().min(1),
neutral_context: z.string().trim().min(1),
estimated_information_gain: z.number().finite().nonnegative(),
candidate_partition_fingerprint: z.string().trim().min(1),
fallback_prompt: z.string().trim().min(1).max(240),
partitions: z.array(partitionSchema).min(2).max(4),
}).strict().superRefine((value, context) => {
if (new Set(value.partitions.map((item) => item.partition_id)).size !== value.partitions.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["partitions"],
message: "opportunity partition ids must be unique",
});
}
});
function candidateTimes(startTime: string, endTime: string): Set<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(endTime);
let current = minute(startTime);
const result = new Set([time(current)]);
while (current !== end) {
current = (current + 1) % 1_440;
result.add(time(current));
}
return result;
}
const differenceApiSchema = z.object({
success: z.literal(true),
endpoint: z.literal("dynamic_rectification_opportunities"),
case_id: z.string().trim().min(1),
scoring_version: z.literal("birth-time-choice-scoring-v2"),
current_range: apiRangeSchema,
opportunities: z.array(opportunitySchema),
asked_question_fingerprints: z.array(z.string().trim().min(1)),
candidate_partition_fingerprints: z.array(z.string().trim().min(1)),
recent_range_history: z.array(apiRangeSchema),
candidate_model: z.record(z.unknown()),
}).strict().superRefine((value, context) => {
if (new Set(value.opportunities.map((item) => item.opportunity_id)).size !== value.opportunities.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["opportunities"],
message: "opportunity ids must be unique",
});
}
const expected = candidateTimes(value.current_range.start_time, value.current_range.end_time);
value.opportunities.forEach((opportunity, opportunityIndex) => {
opportunity.partitions.forEach((partition, partitionIndex) => {
const actual = Object.keys(partition.candidate_scores);
if (actual.length !== expected.size || actual.some((item) => !expected.has(item))) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["opportunities", opportunityIndex, "partitions", partitionIndex, "candidate_scores"],
message: "candidate scores must exactly match the current range",
});
}
});
});
});
const winningSegmentSchema = z.object({
start_time: candidateTimeSchema,
end_time: candidateTimeSchema,
representative_time: candidateTimeSchema,
width_minutes: z.number().int(),
}).strict();
const dynamicScoreApiSchema = z.object({
success: z.literal(true),
endpoint: z.literal("dynamic_rectification_score"),
result_id: z.string().uuid(),
confidence: z.enum(["low", "medium", "high"]),
can_apply: z.boolean(),
winning_segment: winningSegmentSchema.nullable(),
event_count: z.number().int(),
domain_count: z.number().int(),
top_score: z.number().finite(),
second_score: z.number().finite(),
margin_percent: z.number().finite(),
reasons: z.array(z.string()),
evidence: z.array(z.never()).max(0),
algorithm_version: z.literal("birth-time-choice-scoring-v2"),
evidence_mode: z.literal("dynamic_choice"),
effective_answer_count: z.number().int().min(0).max(10),
dimension_count: z.number().int().min(0).max(5),
}).strict().superRefine((value, context) => {
if (value.event_count !== value.effective_answer_count) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["event_count"],
message: "event count must equal effective answer count",
});
}
if (value.domain_count !== value.dimension_count) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["domain_count"],
message: "domain count must equal dimension count",
});
}
});
export function parseCandidateDifferenceBuild(value: unknown): CandidateDifferenceBuild {
const parsed = differenceApiSchema.parse(value);
return candidateDifferenceBuildSchema.parse({
packet: {
caseId: parsed.case_id,
scoringVersion: parsed.scoring_version,
currentRange: {
startTime: parsed.current_range.start_time,
endTime: parsed.current_range.end_time,
},
opportunities: parsed.opportunities.map((opportunity) => ({
opportunityId: opportunity.opportunity_id,
dimensionCode: opportunity.dimension_code,
neutralContext: opportunity.neutral_context,
estimatedInformationGain: opportunity.estimated_information_gain,
candidatePartitionFingerprint: opportunity.candidate_partition_fingerprint,
fallbackPrompt: opportunity.fallback_prompt,
partitions: opportunity.partitions.map((partition) => ({
partitionId: partition.partition_id,
descriptor: partition.descriptor,
fallbackLabel: partition.fallback_label,
})),
})),
askedQuestionFingerprints: parsed.asked_question_fingerprints,
candidatePartitionFingerprints: parsed.candidate_partition_fingerprints,
recentRangeHistory: parsed.recent_range_history.map((range) => ({
startTime: range.start_time,
endTime: range.end_time,
})),
},
candidateModel: parsed.candidate_model,
scoringPartitions: Object.fromEntries(parsed.opportunities.map((opportunity) => [
opportunity.opportunity_id,
opportunity.partitions.map((partition) => ({
partitionId: partition.partition_id,
descriptor: partition.descriptor,
fallbackLabel: partition.fallback_label,
candidateScores: partition.candidate_scores,
})),
])),
});
}
export function parseDynamicChoiceScoring(value: unknown): DynamicChoiceScoringResult {
const parsed = dynamicScoreApiSchema.parse(value);
const segment = parsed.winning_segment;
const candidate = candidateResultSchema.parse({
resultId: parsed.result_id,
confidence: parsed.confidence,
canApply: parsed.can_apply,
winningSegment: segment && {
startTime: segment.start_time,
endTime: segment.end_time,
representativeTime: segment.representative_time,
widthMinutes: segment.width_minutes,
},
eventCount: parsed.event_count,
domainCount: parsed.domain_count,
topScore: parsed.top_score,
secondScore: parsed.second_score,
marginPercent: parsed.margin_percent,
reasons: parsed.reasons,
evidence: [],
algorithmVersion: parsed.algorithm_version,
});
return dynamicChoiceScoringResultSchema.parse({
candidate,
evidenceMode: parsed.evidence_mode,
effectiveAnswerCount: parsed.effective_answer_count,
dimensionCount: parsed.dimension_count,
});
}
@@ -1,9 +1,83 @@
import {
parseCandidateResult,
parseRectificationQuestionnaire,
parseRectificationScoring,
} from "./birth-time-journey-adapters.ts";
import {
parseCandidateDifferenceBuild,
parseDynamicChoiceScoring,
} from "./birth-time-journey-dynamic-adapters.ts";
import type {
BirthTimeJourneyEngine,
DifferencePacketInput,
DynamicChoiceScoreInput,
JourneyEventScoreInput,
} from "./birth-time-journey-service.ts";
export const journeyEngineTimeoutMs = 45_000;
export class BirthTimeJourneyEngineError extends Error {
readonly name = "BirthTimeJourneyEngineError";
readonly status: number;
constructor(status: number) {
super(`Jyotish birth-time engine returned ${status}`);
this.status = status;
}
}
export class BirthTimeJourneyEngineConfigurationError extends Error {
readonly name = "BirthTimeJourneyEngineConfigurationError";
constructor() {
super("Dynamic Jyotish rectification is not configured");
}
}
export type JourneyEngineFetch = (
url: string,
init: RequestInit,
) => Promise<{
readonly ok: boolean;
readonly status: number;
json(): Promise<unknown>;
}>;
export type JourneyEngineWire = {
post(input: {
readonly path: string;
readonly body: unknown;
readonly authentication: "legacy" | "dynamic";
}): Promise<unknown>;
};
export function createJourneyEngineWire(options: {
readonly apiBase: string;
readonly dynamicToken: string | null;
readonly fetchImpl: JourneyEngineFetch;
}): JourneyEngineWire {
return {
async post(input) {
const token = options.dynamicToken?.trim();
if (input.authentication === "dynamic" && !token) {
throw new BirthTimeJourneyEngineConfigurationError();
}
const response = await options.fetchImpl(`${options.apiBase}${input.path}`, {
method: "POST",
headers: {
"content-type": "application/json",
...(input.authentication === "dynamic" ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(input.body),
signal: AbortSignal.timeout(journeyEngineTimeoutMs),
});
const payload = await response.json();
if (!response.ok) throw new BirthTimeJourneyEngineError(response.status);
return payload;
},
};
}
export function eventScorePayload(input: JourneyEventScoreInput) {
return {
birth_date: input.birthDate,
@@ -65,3 +139,56 @@ export function dynamicChoiceScorePayload(input: DynamicChoiceScoreInput) {
choice_evidence: choiceEvidencePayload(input.evidence),
} as const;
}
export function createJourneyEngineMethods(wire: JourneyEngineWire): BirthTimeJourneyEngine {
return {
async scan(input) {
const payload = await wire.post({
path: "/api/active_rectification_questions",
authentication: "legacy",
body: {
birth_time: input.birthTime,
uncertainty_minutes: input.uncertaintyMinutes,
step_minutes: 1,
lat: input.lat,
lon: input.lon,
tz: input.tz,
ayanamsa: input.ayanamsa,
},
});
return { questionnaire: parseRectificationQuestionnaire(payload) };
},
async score(input) {
const payload = await wire.post({
path: "/api/active_rectification_score",
authentication: "legacy",
body: { questionnaire: input.questionnaire.raw, answers: input.answers },
});
return parseRectificationScoring(payload);
},
async scoreEvents(input) {
const payload = await wire.post({
path: "/api/active_rectification_events",
authentication: "legacy",
body: eventScorePayload(input),
});
return parseCandidateResult(payload);
},
async buildDifferencePacket(input) {
const payload = await wire.post({
path: "/api/dynamic_rectification_opportunities",
authentication: "dynamic",
body: differencePacketPayload(input),
});
return parseCandidateDifferenceBuild(payload);
},
async scoreChoices(input) {
const payload = await wire.post({
path: "/api/dynamic_rectification_score",
authentication: "dynamic",
body: dynamicChoiceScorePayload(input),
});
return parseDynamicChoiceScoring(payload);
},
};
}
+13 -106
View File
@@ -1,115 +1,22 @@
import "server-only";
import {
parseCandidateDifferenceBuild,
parseDynamicChoiceScoring,
parseRectificationQuestionnaire,
parseRectificationScoring,
parseCandidateResult,
} from "./birth-time-journey-adapters.ts";
import {
differencePacketPayload,
dynamicChoiceScorePayload,
eventScorePayload,
createJourneyEngineMethods,
createJourneyEngineWire,
} from "./birth-time-journey-engine-model.ts";
import type { DynamicBirthTimeJourneyEngine } from "./birth-time-journey-service.ts";
import type { BirthTimeJourneyEngine } from "./birth-time-journey-service.ts";
export class BirthTimeJourneyEngineError extends Error {
readonly name = "BirthTimeJourneyEngineError";
readonly status: number;
constructor(status: number) {
super(`Jyotish birth-time engine returned ${status}`);
this.status = status;
}
}
export class BirthTimeJourneyEngineConfigurationError extends Error {
readonly name = "BirthTimeJourneyEngineConfigurationError";
constructor() {
super("Dynamic Jyotish rectification is not configured");
}
}
async function postJson(
apiBase: string,
path: string,
body: unknown,
authorization?: string,
): Promise<unknown> {
const response = await fetch(`${apiBase}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
...(authorization ? { authorization } : {}),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(45_000),
});
const payload: unknown = await response.json();
if (!response.ok) throw new BirthTimeJourneyEngineError(response.status);
return payload;
}
function dynamicAuthorization(): string {
const token = process.env.JYOTISH_DYNAMIC_RECTIFICATION_TOKEN?.trim();
if (!token) throw new BirthTimeJourneyEngineConfigurationError();
return `Bearer ${token}`;
}
export {
BirthTimeJourneyEngineConfigurationError,
BirthTimeJourneyEngineError,
} from "./birth-time-journey-engine-model.ts";
export function createJyotishBirthTimeJourneyEngine(
apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200",
): DynamicBirthTimeJourneyEngine {
return {
async scan(input) {
const payload = await postJson(apiBase, "/api/active_rectification_questions", {
birth_time: input.birthTime,
uncertainty_minutes: input.uncertaintyMinutes,
step_minutes: 1,
lat: input.lat,
lon: input.lon,
tz: input.tz,
ayanamsa: input.ayanamsa,
});
return { questionnaire: parseRectificationQuestionnaire(payload) };
},
async score(input) {
const payload = await postJson(apiBase, "/api/active_rectification_score", {
questionnaire: input.questionnaire.raw,
answers: input.answers,
});
return parseRectificationScoring(payload);
},
async scoreEvents(input) {
const payload = await postJson(
apiBase,
"/api/active_rectification_events",
eventScorePayload(input),
);
return parseCandidateResult(payload);
},
async buildDifferencePacket(input) {
const payload = await postJson(
apiBase,
"/api/dynamic_rectification_opportunities",
differencePacketPayload(input),
dynamicAuthorization(),
);
return parseCandidateDifferenceBuild(payload);
},
async scoreChoices(input) {
const payload = await postJson(
apiBase,
"/api/dynamic_rectification_score",
dynamicChoiceScorePayload(input),
dynamicAuthorization(),
);
return parseDynamicChoiceScoring(payload);
},
};
): BirthTimeJourneyEngine {
return createJourneyEngineMethods(createJourneyEngineWire({
apiBase,
dynamicToken: process.env.JYOTISH_DYNAMIC_RECTIFICATION_TOKEN ?? null,
fetchImpl: fetch,
}));
}
@@ -97,15 +97,14 @@ export interface BirthTimeJourneyEngine {
scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>;
score(input: JourneyScoreInput): Promise<RectificationScoringResult>;
scoreEvents(input: JourneyEventScoreInput): Promise<CandidateResult>;
buildDifferencePacket?(input: DifferencePacketInput): Promise<CandidateDifferenceBuild>;
scoreChoices?(input: DynamicChoiceScoreInput): Promise<DynamicChoiceScoringResult>;
}
export interface DynamicBirthTimeJourneyEngine extends BirthTimeJourneyEngine {
buildDifferencePacket(input: DifferencePacketInput): Promise<CandidateDifferenceBuild>;
scoreChoices(input: DynamicChoiceScoreInput): Promise<DynamicChoiceScoringResult>;
}
export type LegacyBirthTimeJourneyEngine = Pick<BirthTimeJourneyEngine,
"scan" | "score" | "scoreEvents"
>;
export type PersistedJourneyAssessment = {
readonly userId: string;
readonly assessment: BirthTimeAssessment;
@@ -148,7 +147,7 @@ export interface BirthTimeJourneyStore {
export type BirthTimeJourneyPorts = {
readonly store: BirthTimeJourneyStore;
readonly engine: BirthTimeJourneyEngine;
readonly engine: LegacyBirthTimeJourneyEngine;
readonly now?: () => Date;
};