fix: harden dynamic engine adapters
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { candidateResultSchema } from "../src/lib/birth-time-evidence.ts";
|
||||
import { createGuidedCandidateActions } from "../src/lib/birth-time-guided-candidate.ts";
|
||||
import {
|
||||
createBirthTimeJourneyService,
|
||||
type BirthTimeJourneyEngine,
|
||||
type LegacyBirthTimeJourneyEngine,
|
||||
type StoredRectificationCase,
|
||||
type VersionedJourneyResponse,
|
||||
} from "../src/lib/birth-time-journey-service.ts";
|
||||
@@ -82,7 +82,7 @@ export function createHarness(input: {
|
||||
}) {
|
||||
const memory = memoryStore(input.initial);
|
||||
let scoreEventsCalls = 0;
|
||||
const engine: BirthTimeJourneyEngine = {
|
||||
const engine: LegacyBirthTimeJourneyEngine = {
|
||||
...unusedJourneyEngine,
|
||||
async scoreEvents() {
|
||||
scoreEventsCalls += 1;
|
||||
|
||||
@@ -2,9 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
parseBirthTimeProfile,
|
||||
parseCandidateDifferenceBuild,
|
||||
parseCandidateResult,
|
||||
parseDynamicChoiceScoring,
|
||||
parseRectificationQuestionnaire,
|
||||
parseRectificationScoring,
|
||||
} from "../src/lib/birth-time-journey-adapters.ts";
|
||||
@@ -15,159 +13,6 @@ const coordinates = {
|
||||
timezone_offset: 8,
|
||||
} as const;
|
||||
|
||||
const apiPacket = {
|
||||
success: true,
|
||||
endpoint: "dynamic_rectification_opportunities",
|
||||
case_id: "case-1",
|
||||
scoring_version: "birth-time-choice-scoring-v2",
|
||||
current_range: { start_time: "05:30", end_time: "05:33" },
|
||||
opportunities: [{
|
||||
opportunity_id: "career-window",
|
||||
dimension_code: "career",
|
||||
neutral_context: "career",
|
||||
estimated_information_gain: 0.5,
|
||||
candidate_partition_fingerprint: "career-partitions-v2",
|
||||
fallback_prompt: "哪段经历更接近你的职业变化?",
|
||||
partitions: [{
|
||||
partition_id: "career-early",
|
||||
descriptor: "2014-01-01--2017-12-31",
|
||||
fallback_label: "2014—2017",
|
||||
candidate_scores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 },
|
||||
}, {
|
||||
partition_id: "career-late",
|
||||
descriptor: "2018-01-01--2021-12-31",
|
||||
fallback_label: "2018—2021",
|
||||
candidate_scores: { "05:30": 1, "05:31": 0, "05:32": 0, "05:33": 1 },
|
||||
}],
|
||||
}],
|
||||
asked_question_fingerprints: ["asked-1"],
|
||||
candidate_partition_fingerprints: ["partition-1"],
|
||||
recent_range_history: [{ start_time: "05:30", end_time: "05:33" }],
|
||||
candidate_model: {
|
||||
version: "birth-time-choice-scoring-v2",
|
||||
candidate_times: ["05:30", "05:31", "05:32", "05:33"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const apiScore = {
|
||||
success: true,
|
||||
endpoint: "dynamic_rectification_score",
|
||||
result_id: "1d8ee348-61a3-433d-8907-ff6d281b9992",
|
||||
confidence: "low",
|
||||
can_apply: false,
|
||||
winning_segment: {
|
||||
start_time: "05:31",
|
||||
end_time: "05:32",
|
||||
representative_time: "05:31",
|
||||
width_minutes: 2,
|
||||
},
|
||||
event_count: 1,
|
||||
domain_count: 1,
|
||||
top_score: 0.5,
|
||||
second_score: 0,
|
||||
margin_percent: 50,
|
||||
reasons: ["insufficient_effective_evidence"],
|
||||
evidence: [],
|
||||
algorithm_version: "birth-time-choice-scoring-v2",
|
||||
evidence_mode: "dynamic_choice",
|
||||
effective_answer_count: 1,
|
||||
dimension_count: 1,
|
||||
} as const;
|
||||
|
||||
test("difference packets keep candidate scores on the server-only internal shape", () => {
|
||||
const build = parseCandidateDifferenceBuild(apiPacket);
|
||||
|
||||
assert.equal(build.scoringPartitions["career-window"]?.[0]?.candidateScores["05:31"], 1);
|
||||
assert.equal(build.packet.opportunities[0]?.estimatedInformationGain, 0.5);
|
||||
assert.deepEqual(build.candidateModel, apiPacket.candidate_model);
|
||||
assert.equal("candidateScores" in build.packet.opportunities[0]!.partitions[0]!, false);
|
||||
});
|
||||
|
||||
test("difference packet parser rejects non-versioned or extra response fields", () => {
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
scoring_version: "birth-time-choice-scoring-v1",
|
||||
}));
|
||||
assert.throws(() => parseCandidateDifferenceBuild({ ...apiPacket, confidence: "high" }));
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
opportunities: [{
|
||||
...apiPacket.opportunities[0],
|
||||
partitions: [{
|
||||
...apiPacket.opportunities[0].partitions[0],
|
||||
candidate_scores: { "not-a-time": 1 },
|
||||
}, apiPacket.opportunities[0].partitions[1]],
|
||||
}],
|
||||
}));
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
opportunities: [{
|
||||
...apiPacket.opportunities[0],
|
||||
partitions: [{
|
||||
...apiPacket.opportunities[0].partitions[0],
|
||||
candidate_scores: {
|
||||
...apiPacket.opportunities[0].partitions[0].candidate_scores,
|
||||
"05:34": 1,
|
||||
},
|
||||
}, apiPacket.opportunities[0].partitions[1]],
|
||||
}],
|
||||
}));
|
||||
});
|
||||
|
||||
test("difference packet parser preserves an exact cross-midnight score range", () => {
|
||||
const parsed = parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
current_range: { start_time: "23:59", end_time: "00:00" },
|
||||
opportunities: [{
|
||||
...apiPacket.opportunities[0],
|
||||
partitions: apiPacket.opportunities[0].partitions.map((partition) => ({
|
||||
...partition,
|
||||
candidate_scores: { "23:59": 1, "00:00": 0 },
|
||||
})),
|
||||
}],
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed.packet.currentRange, { startTime: "23:59", endTime: "00:00" });
|
||||
});
|
||||
|
||||
test("choice score parser rejects model-controlled confidence fields", () => {
|
||||
assert.throws(() => parseDynamicChoiceScoring({
|
||||
...apiScore,
|
||||
confidence: "high",
|
||||
effective_answer_count: 1,
|
||||
can_apply: true,
|
||||
}));
|
||||
});
|
||||
|
||||
test("choice scores adapt into the existing guarded candidate shape", () => {
|
||||
const parsed = parseDynamicChoiceScoring(apiScore);
|
||||
|
||||
assert.equal(parsed.candidate.eventCount, parsed.effectiveAnswerCount);
|
||||
assert.equal(parsed.candidate.domainCount, parsed.dimensionCount);
|
||||
assert.deepEqual(parsed.candidate.evidence, []);
|
||||
assert.equal(parsed.candidate.algorithmVersion, "birth-time-choice-scoring-v2");
|
||||
});
|
||||
|
||||
test("choice score parser rejects count, evidence mode, evidence, and version mismatches", () => {
|
||||
assert.throws(() => parseDynamicChoiceScoring({ ...apiScore, event_count: 2 }));
|
||||
assert.throws(() => parseDynamicChoiceScoring({ ...apiScore, domain_count: 2 }));
|
||||
assert.throws(() => parseDynamicChoiceScoring({ ...apiScore, evidence_mode: "dated_event" }));
|
||||
assert.throws(() => parseDynamicChoiceScoring({
|
||||
...apiScore,
|
||||
evidence: [{
|
||||
event_id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5",
|
||||
domain: "career",
|
||||
candidate_time: "05:31",
|
||||
rule_ids: ["forged"],
|
||||
points: 1,
|
||||
}],
|
||||
}));
|
||||
assert.throws(() => parseDynamicChoiceScoring({
|
||||
...apiScore,
|
||||
algorithm_version: "birth-time-event-scoring-v1",
|
||||
}));
|
||||
});
|
||||
|
||||
test("birth time profile adapter parses an exact hospital declaration", () => {
|
||||
const assessment = parseBirthTimeProfile({
|
||||
birth_date: "1993-04-17",
|
||||
@@ -351,6 +196,7 @@ test("rectification adapter normalizes an event-scored candidate result", () =>
|
||||
candidate_time: "14:24",
|
||||
rule_ids: ["vim_md_domain_house"],
|
||||
points: 4,
|
||||
legacy_server_metadata: { source: "existing-engine" },
|
||||
}],
|
||||
algorithm_version: "birth-time-event-scoring-v1",
|
||||
});
|
||||
@@ -358,6 +204,7 @@ test("rectification adapter normalizes an event-scored candidate result", () =>
|
||||
assert.equal(result.resultId, "1d8ee348-61a3-433d-8907-ff6d281b9992");
|
||||
assert.equal(result.winningSegment?.representativeTime, "14:24");
|
||||
assert.deepEqual(result.evidence[0]?.ruleIds, ["vim_md_domain_house"]);
|
||||
assert.equal("legacy_server_metadata" in (result.evidence[0] ?? {}), false);
|
||||
});
|
||||
|
||||
test("candidate compatibility result accepts ten effective items but not eleven", () => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
parseCandidateDifferenceBuild,
|
||||
parseDynamicChoiceScoring,
|
||||
} from "../src/lib/birth-time-journey-dynamic-adapters.ts";
|
||||
|
||||
const scores = { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 } as const;
|
||||
const inverseScores = { "05:30": 1, "05:31": 0, "05:32": 0, "05:33": 1 } as const;
|
||||
const firstPartition = {
|
||||
partition_id: "career-early",
|
||||
descriptor: "2014-01-01--2017-12-31",
|
||||
fallback_label: "2014—2017",
|
||||
candidate_scores: scores,
|
||||
} as const;
|
||||
const secondPartition = {
|
||||
partition_id: "career-late",
|
||||
descriptor: "2018-01-01--2021-12-31",
|
||||
fallback_label: "2018—2021",
|
||||
candidate_scores: inverseScores,
|
||||
} as const;
|
||||
const opportunity = {
|
||||
opportunity_id: "career-window",
|
||||
dimension_code: "career",
|
||||
neutral_context: "career",
|
||||
estimated_information_gain: 0.5,
|
||||
candidate_partition_fingerprint: "career-partitions-v2",
|
||||
fallback_prompt: "哪段经历更接近你的职业变化?",
|
||||
partitions: [firstPartition, secondPartition],
|
||||
} as const;
|
||||
const apiPacket = {
|
||||
success: true,
|
||||
endpoint: "dynamic_rectification_opportunities",
|
||||
case_id: "case-1",
|
||||
scoring_version: "birth-time-choice-scoring-v2",
|
||||
current_range: { start_time: "05:30", end_time: "05:33" },
|
||||
opportunities: [opportunity],
|
||||
asked_question_fingerprints: ["asked-1"],
|
||||
candidate_partition_fingerprints: ["partition-1"],
|
||||
recent_range_history: [{ start_time: "05:30", end_time: "05:33" }],
|
||||
candidate_model: {
|
||||
version: "birth-time-choice-scoring-v2",
|
||||
candidate_times: ["05:30", "05:31", "05:32", "05:33"],
|
||||
},
|
||||
} as const;
|
||||
const apiScore = {
|
||||
success: true,
|
||||
endpoint: "dynamic_rectification_score",
|
||||
result_id: "1d8ee348-61a3-433d-8907-ff6d281b9992",
|
||||
confidence: "low",
|
||||
can_apply: false,
|
||||
winning_segment: {
|
||||
start_time: "05:31",
|
||||
end_time: "05:32",
|
||||
representative_time: "05:31",
|
||||
width_minutes: 2,
|
||||
},
|
||||
event_count: 1,
|
||||
domain_count: 1,
|
||||
top_score: 0.5,
|
||||
second_score: 0,
|
||||
margin_percent: 50,
|
||||
reasons: ["insufficient_effective_evidence"],
|
||||
evidence: [],
|
||||
algorithm_version: "birth-time-choice-scoring-v2",
|
||||
evidence_mode: "dynamic_choice",
|
||||
effective_answer_count: 1,
|
||||
dimension_count: 1,
|
||||
} as const;
|
||||
|
||||
test("difference packets separate public copy from private score vectors", () => {
|
||||
const build = parseCandidateDifferenceBuild(apiPacket);
|
||||
const mappedOpportunity = build.packet.opportunities[0];
|
||||
const mappedPartition = mappedOpportunity?.partitions[0];
|
||||
const privatePartition = build.scoringPartitions["career-window"]?.[0];
|
||||
|
||||
assert.equal(mappedOpportunity?.opportunityId, "career-window");
|
||||
assert.equal(mappedOpportunity?.estimatedInformationGain, 0.5);
|
||||
assert.equal(mappedPartition?.partitionId, "career-early");
|
||||
assert.equal(mappedPartition && "candidateScores" in mappedPartition, false);
|
||||
assert.equal(privatePartition?.candidateScores["05:31"], 1);
|
||||
assert.deepEqual(build.candidateModel, {
|
||||
version: "birth-time-choice-scoring-v2",
|
||||
candidate_times: ["05:30", "05:31", "05:32", "05:33"],
|
||||
});
|
||||
});
|
||||
|
||||
test("difference packets reject wrong versions, extra fields, and invalid score keys", () => {
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket, scoring_version: "birth-time-choice-scoring-v1",
|
||||
}));
|
||||
assert.throws(() => parseCandidateDifferenceBuild({ ...apiPacket, confidence: "high" }));
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket, opportunities: [{ ...opportunity, model_controlled: true }],
|
||||
}));
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
opportunities: [{ ...opportunity, partitions: [{ ...firstPartition, model_controlled: true }, secondPartition] }],
|
||||
}));
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
opportunities: [{
|
||||
...opportunity,
|
||||
partitions: [{ ...firstPartition, candidate_scores: { "not-a-time": 1 } }, secondPartition],
|
||||
}],
|
||||
}));
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
opportunities: [{
|
||||
...opportunity,
|
||||
partitions: [{ ...firstPartition, candidate_scores: { ...scores, "05:34": 1 } }, secondPartition],
|
||||
}],
|
||||
}));
|
||||
});
|
||||
|
||||
test("difference packets reject duplicate opportunity and partition identifiers", () => {
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
opportunities: [opportunity, opportunity],
|
||||
}));
|
||||
assert.throws(() => parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
opportunities: [{
|
||||
...opportunity,
|
||||
partitions: [firstPartition, { ...secondPartition, partition_id: firstPartition.partition_id }],
|
||||
}],
|
||||
}));
|
||||
});
|
||||
|
||||
test("difference packets preserve an exact cross-midnight score range", () => {
|
||||
const parsed = parseCandidateDifferenceBuild({
|
||||
...apiPacket,
|
||||
current_range: { start_time: "23:59", end_time: "00:00" },
|
||||
opportunities: [{
|
||||
...opportunity,
|
||||
partitions: opportunity.partitions.map((partition) => ({
|
||||
...partition,
|
||||
candidate_scores: { "23:59": 1, "00:00": 0 },
|
||||
})),
|
||||
}],
|
||||
});
|
||||
assert.deepEqual(parsed.packet.currentRange, { startTime: "23:59", endTime: "00:00" });
|
||||
});
|
||||
|
||||
test("dynamic scores reject model-controlled gates and all nested extra fields", () => {
|
||||
assert.throws(() => parseDynamicChoiceScoring({
|
||||
...apiScore, confidence: "high", can_apply: true, effective_answer_count: 1,
|
||||
}));
|
||||
assert.throws(() => parseDynamicChoiceScoring({
|
||||
...apiScore,
|
||||
winning_segment: { ...apiScore.winning_segment, model_controlled: "accepted" },
|
||||
}));
|
||||
});
|
||||
|
||||
test("dynamic scores require exact counts, mode, empty evidence, and v2", () => {
|
||||
assert.throws(() => parseDynamicChoiceScoring({ ...apiScore, event_count: 2 }));
|
||||
assert.throws(() => parseDynamicChoiceScoring({ ...apiScore, domain_count: 2 }));
|
||||
assert.throws(() => parseDynamicChoiceScoring({ ...apiScore, evidence_mode: "dated_event" }));
|
||||
assert.throws(() => parseDynamicChoiceScoring({
|
||||
...apiScore,
|
||||
evidence: [{
|
||||
event_id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5",
|
||||
domain: "career",
|
||||
candidate_time: "05:31",
|
||||
rule_ids: ["forged"],
|
||||
points: 1,
|
||||
}],
|
||||
}));
|
||||
assert.throws(() => parseDynamicChoiceScoring({
|
||||
...apiScore, algorithm_version: "birth-time-event-scoring-v1",
|
||||
}));
|
||||
});
|
||||
|
||||
test("dynamic scores map independent engine values into guarded candidates", () => {
|
||||
const parsed = parseDynamicChoiceScoring(apiScore);
|
||||
assert.equal(parsed.effectiveAnswerCount, 1);
|
||||
assert.equal(parsed.dimensionCount, 1);
|
||||
assert.equal(parsed.candidate.eventCount, 1);
|
||||
assert.equal(parsed.candidate.domainCount, 1);
|
||||
assert.equal(parsed.candidate.winningSegment?.representativeTime, "05:31");
|
||||
assert.deepEqual(parsed.candidate.evidence, []);
|
||||
assert.equal(parsed.candidate.algorithmVersion, "birth-time-choice-scoring-v2");
|
||||
});
|
||||
@@ -1,12 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
BirthTimeJourneyEngineConfigurationError,
|
||||
createJourneyEngineMethods,
|
||||
createJourneyEngineWire,
|
||||
differencePacketPayload,
|
||||
dynamicChoiceScorePayload,
|
||||
eventScorePayload,
|
||||
journeyEngineTimeoutMs,
|
||||
} from "../src/lib/birth-time-journey-engine-model.ts";
|
||||
import type { JourneyEngineFetch } from "../src/lib/birth-time-journey-engine-model.ts";
|
||||
|
||||
test("journey engine serializes only stored event-scoring inputs", () => {
|
||||
const payload = eventScorePayload({
|
||||
@@ -90,66 +95,161 @@ test("dynamic opportunity payload owns private evidence and candidate model serv
|
||||
recent_ranges: [{ start_time: "05:30", end_time: "05:33" }],
|
||||
candidate_model: { version: "birth-time-choice-scoring-v2" },
|
||||
});
|
||||
assert.equal("option_id" in payload.evidence[0]!, false);
|
||||
const evidence = payload.evidence[0];
|
||||
assert.ok(evidence);
|
||||
assert.equal("option_id" in evidence, false);
|
||||
});
|
||||
|
||||
test("dynamic score payload sends only server-resolved choice evidence", () => {
|
||||
const payload = dynamicChoiceScorePayload(dynamicInput);
|
||||
|
||||
assert.deepEqual(payload.choice_evidence, differencePacketPayload(dynamicInput).evidence);
|
||||
assert.deepEqual(payload.choice_evidence, [{
|
||||
question_id: "question-1",
|
||||
opportunity_id: "career-window",
|
||||
partition_id: "career-early",
|
||||
dimension_code: "career",
|
||||
candidate_scores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 },
|
||||
information_gain: 0.5,
|
||||
}]);
|
||||
assert.equal("case_id" in payload, false);
|
||||
assert.equal("candidate_model" in payload, false);
|
||||
assert.equal("confidence" in payload, false);
|
||||
assert.equal("can_apply" in payload, false);
|
||||
});
|
||||
|
||||
test("dynamic engine calls are bearer-authenticated while legacy calls stay unauthenticated", () => {
|
||||
const source = readFileSync(new URL("../src/lib/birth-time-journey-engine.ts", import.meta.url), "utf8");
|
||||
const dynamicResponses: Readonly<Record<string, unknown>> = {
|
||||
"/api/dynamic_rectification_opportunities": {
|
||||
success: true,
|
||||
endpoint: "dynamic_rectification_opportunities",
|
||||
case_id: "case-1",
|
||||
scoring_version: "birth-time-choice-scoring-v2",
|
||||
current_range: { start_time: "05:30", end_time: "05:33" },
|
||||
opportunities: [],
|
||||
asked_question_fingerprints: [],
|
||||
candidate_partition_fingerprints: [],
|
||||
recent_range_history: [],
|
||||
candidate_model: {},
|
||||
},
|
||||
"/api/dynamic_rectification_score": {
|
||||
success: true,
|
||||
endpoint: "dynamic_rectification_score",
|
||||
result_id: "1d8ee348-61a3-433d-8907-ff6d281b9992",
|
||||
confidence: "low",
|
||||
can_apply: false,
|
||||
winning_segment: null,
|
||||
event_count: 1,
|
||||
domain_count: 1,
|
||||
top_score: 0.5,
|
||||
second_score: 0,
|
||||
margin_percent: 50,
|
||||
reasons: ["insufficient_effective_evidence"],
|
||||
evidence: [],
|
||||
algorithm_version: "birth-time-choice-scoring-v2",
|
||||
evidence_mode: "dynamic_choice",
|
||||
effective_answer_count: 1,
|
||||
dimension_count: 1,
|
||||
},
|
||||
};
|
||||
|
||||
assert.match(source, /JYOTISH_DYNAMIC_RECTIFICATION_TOKEN/);
|
||||
assert.match(source, /if \(!token\) throw new BirthTimeJourneyEngineConfigurationError\(\)/);
|
||||
assert.match(source, /return `Bearer \$\{token\}`/);
|
||||
assert.match(source, /"\/api\/dynamic_rectification_opportunities"[\s\S]*dynamicAuthorization\(\)/);
|
||||
assert.match(source, /"\/api\/dynamic_rectification_score"[\s\S]*dynamicAuthorization\(\)/);
|
||||
const legacyMethods = source.slice(
|
||||
source.indexOf("async scan(input)"),
|
||||
source.indexOf("async buildDifferencePacket(input)"),
|
||||
);
|
||||
assert.match(legacyMethods, /\/api\/active_rectification_questions/);
|
||||
assert.match(legacyMethods, /\/api\/active_rectification_score/);
|
||||
assert.match(legacyMethods, /\/api\/active_rectification_events/);
|
||||
assert.equal(legacyMethods.includes("dynamicAuthorization()"), false);
|
||||
function engineHarness(dynamicToken: string | null) {
|
||||
const calls: { readonly url: string; readonly init: RequestInit }[] = [];
|
||||
const fetchImpl: JourneyEngineFetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
const payload = dynamicResponses[new URL(url).pathname];
|
||||
return { ok: true, status: 200, async json() { return payload; } };
|
||||
};
|
||||
const wire = createJourneyEngineWire({
|
||||
apiBase: "https://engine.invalid",
|
||||
dynamicToken,
|
||||
fetchImpl,
|
||||
});
|
||||
return { calls, engine: createJourneyEngineMethods(wire) };
|
||||
}
|
||||
|
||||
test("both dynamic endpoints send exact bodies with bearer auth and timeout signals", async () => {
|
||||
const harness = engineHarness("server-secret");
|
||||
await harness.engine.buildDifferencePacket(dynamicInput);
|
||||
await harness.engine.scoreChoices(dynamicInput);
|
||||
|
||||
assert.equal(harness.calls.length, 2);
|
||||
const opportunityCall = harness.calls[0];
|
||||
const scoreCall = harness.calls[1];
|
||||
assert.ok(opportunityCall);
|
||||
assert.ok(scoreCall);
|
||||
assert.equal(opportunityCall.url, "https://engine.invalid/api/dynamic_rectification_opportunities");
|
||||
assert.equal(scoreCall.url, "https://engine.invalid/api/dynamic_rectification_score");
|
||||
assert.equal(new Headers(opportunityCall.init.headers).get("authorization"), "Bearer server-secret");
|
||||
assert.equal(new Headers(scoreCall.init.headers).get("authorization"), "Bearer server-secret");
|
||||
assert.equal(opportunityCall.init.method, "POST");
|
||||
assert.equal(scoreCall.init.method, "POST");
|
||||
assert.deepEqual(JSON.parse(String(opportunityCall.init.body)), differencePacketPayload(dynamicInput));
|
||||
assert.deepEqual(JSON.parse(String(scoreCall.init.body)), dynamicChoiceScorePayload(dynamicInput));
|
||||
assert.ok(opportunityCall.init.signal instanceof AbortSignal);
|
||||
assert.ok(scoreCall.init.signal instanceof AbortSignal);
|
||||
assert.equal(journeyEngineTimeoutMs, 45_000);
|
||||
});
|
||||
|
||||
function clientBoundaryFiles(directory: string): string[] {
|
||||
test("missing dynamic token fails both endpoints before fetch", async () => {
|
||||
const harness = engineHarness(null);
|
||||
await assert.rejects(
|
||||
harness.engine.buildDifferencePacket(dynamicInput),
|
||||
BirthTimeJourneyEngineConfigurationError,
|
||||
);
|
||||
await assert.rejects(
|
||||
harness.engine.scoreChoices(dynamicInput),
|
||||
BirthTimeJourneyEngineConfigurationError,
|
||||
);
|
||||
assert.equal(harness.calls.length, 0);
|
||||
});
|
||||
|
||||
test("legacy wire calls never receive dynamic authorization", async () => {
|
||||
const calls: { readonly path: string; readonly init: RequestInit }[] = [];
|
||||
const engine = createJourneyEngineMethods(createJourneyEngineWire({
|
||||
apiBase: "https://engine.invalid",
|
||||
dynamicToken: "server-secret",
|
||||
fetchImpl: async (url, init) => {
|
||||
const path = new URL(url).pathname;
|
||||
calls.push({ path, init });
|
||||
const payload = path === "/api/active_rectification_questions"
|
||||
? { questions: [], candidate_scan: { samples: [] } }
|
||||
: path === "/api/active_rectification_score"
|
||||
? { answered_count: 0, candidate_cluster_rankings: [], next_round: null, next_round_questions: [] }
|
||||
: {
|
||||
result_id: "1d8ee348-61a3-433d-8907-ff6d281b9992", confidence: "low", can_apply: false,
|
||||
winning_segment: null, event_count: 0, domain_count: 0, top_score: 0, second_score: 0,
|
||||
margin_percent: 0, reasons: [], evidence: [], algorithm_version: "birth-time-event-scoring-v1",
|
||||
};
|
||||
return { ok: true, status: 200, async json() { return payload; } };
|
||||
},
|
||||
}));
|
||||
const scan = await engine.scan({ birthTime: "1990-01-01 05:30", uncertaintyMinutes: 3, lat: 31.23, lon: 121.47, tz: 8, ayanamsa: "lahiri" });
|
||||
await engine.score({ questionnaire: scan.questionnaire, answers: {} });
|
||||
await engine.scoreEvents({ birthDate: "1990-01-01", startTime: "05:30", endTime: "05:33", lat: 31.23, lon: 121.47, tz: 8, events: [] });
|
||||
assert.deepEqual(calls.map((call) => call.path), [
|
||||
"/api/active_rectification_questions", "/api/active_rectification_score", "/api/active_rectification_events",
|
||||
]);
|
||||
for (const call of calls) {
|
||||
assert.equal(new Headers(call.init.headers).has("authorization"), false);
|
||||
assert.ok(call.init.signal instanceof AbortSignal);
|
||||
}
|
||||
});
|
||||
|
||||
function componentAndHookFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) return clientBoundaryFiles(path);
|
||||
if (!/\.(ts|tsx)$/.test(entry.name)) return [];
|
||||
const normalized = path.replaceAll("\\", "/");
|
||||
return /\/(components|hooks)\//.test(normalized)
|
||||
|| /(?:client|request|response)(?:-schema)?\.(?:ts|tsx)$/.test(basename(path))
|
||||
? [path]
|
||||
: [];
|
||||
if (entry.isDirectory()) return componentAndHookFiles(path);
|
||||
return /\.(ts|tsx)$/.test(entry.name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
test("private dynamic scoring identifiers never enter client or response modules", () => {
|
||||
const sourceRoot = new URL("../src", import.meta.url).pathname;
|
||||
const forbidden = [
|
||||
"candidate_scores",
|
||||
"candidate_model",
|
||||
"partition_id",
|
||||
"candidateScores",
|
||||
"candidateModel",
|
||||
"partitionId",
|
||||
"JYOTISH_DYNAMIC_RECTIFICATION_TOKEN",
|
||||
test("candidate scores stay out of the specified client ownership boundary", () => {
|
||||
const files = [
|
||||
new URL("../src/lib/birth-time-journey-client.ts", import.meta.url).pathname,
|
||||
new URL("../src/lib/birth-time-journey-request.ts", import.meta.url).pathname,
|
||||
...componentAndHookFiles(new URL("../src/components", import.meta.url).pathname),
|
||||
...componentAndHookFiles(new URL("../src/hooks", import.meta.url).pathname),
|
||||
];
|
||||
|
||||
for (const path of clientBoundaryFiles(sourceRoot)) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
for (const identifier of forbidden) {
|
||||
assert.equal(source.includes(identifier), false, `${identifier} leaked into ${path}`);
|
||||
}
|
||||
for (const path of files) {
|
||||
assert.equal(readFileSync(path, "utf8").includes("candidate_scores"), false, path);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { BirthTimeJourneyEngine, StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { LegacyBirthTimeJourneyEngine, StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
|
||||
import {
|
||||
approximateAssessment,
|
||||
hospitalAssessment,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
test("journey service activates a stable hospital record and persists its scan", async () => {
|
||||
const memory = memoryStore();
|
||||
let receivedUncertainty = 0;
|
||||
const engine: BirthTimeJourneyEngine = {
|
||||
const engine: LegacyBirthTimeJourneyEngine = {
|
||||
...unusedJourneyEngine,
|
||||
async scan(input) {
|
||||
receivedUncertainty = input.uncertaintyMinutes;
|
||||
@@ -34,7 +34,7 @@ test("journey service activates a stable hospital record and persists its scan",
|
||||
|
||||
test("journey service fails a scanner error closed without activating the time", async () => {
|
||||
const memory = memoryStore();
|
||||
const engine: BirthTimeJourneyEngine = {
|
||||
const engine: LegacyBirthTimeJourneyEngine = {
|
||||
...unusedJourneyEngine,
|
||||
async scan() {
|
||||
throw new TypeError("scanner offline");
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { birthTimeAssessmentSchema, candidateResultSchema, lifeEventSchema } from "../src/lib/birth-time-journey.ts";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import type {
|
||||
BirthTimeJourneyEngine,
|
||||
BirthTimeJourneyStore,
|
||||
PersistedJourneyAssessment,
|
||||
StoredRectificationCase,
|
||||
} from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { BirthTimeJourneyStore, LegacyBirthTimeJourneyEngine, PersistedJourneyAssessment, StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||||
import type { EvidenceDomain } from "../src/lib/birth-time-question-planner.ts";
|
||||
import { createMemoryScoringJobs } from "./birth-time-scoring-memory-store.ts";
|
||||
|
||||
class UnexpectedTestCallError extends Error {
|
||||
readonly name = "UnexpectedTestCallError";
|
||||
}
|
||||
class UnexpectedTestCallError extends Error { readonly name = "UnexpectedTestCallError"; }
|
||||
|
||||
class MissingTestCaseError extends Error {
|
||||
readonly name = "MissingTestCaseError";
|
||||
@@ -143,7 +136,7 @@ export function memoryStore(initialCase?: StoredRectificationCase) {
|
||||
};
|
||||
}
|
||||
|
||||
export const unusedJourneyEngine: BirthTimeJourneyEngine = {
|
||||
export const unusedJourneyEngine: LegacyBirthTimeJourneyEngine = {
|
||||
async scan() { throw new UnexpectedTestCallError(); },
|
||||
async score() { throw new UnexpectedTestCallError(); },
|
||||
async scoreEvents() { throw new UnexpectedTestCallError(); },
|
||||
|
||||
Reference in New Issue
Block a user