diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md index c3e67caf..a4e86e41 100644 --- a/.superpowers/sdd/task-3-report.md +++ b/.superpowers/sdd/task-3-report.md @@ -66,3 +66,38 @@ - The repository still lacks a standalone runtime `server-only` package for direct Node imports. Per the approved module-boundary decision, verification uses the production file's existing `import "server-only"` plus strict projection and source-contract tests rather than adding an unavailable dependency. - The complete TypeScript diagnostic remains blocked by the unrelated ES2017/ES2018 regex baseline described above. + +## Review fixes + +- Restored byte-compatible legacy candidate parsing. Unknown nested evidence metadata is accepted and stripped exactly as before Task 3; dynamic response parsing no longer changes the legacy schema. +- Moved every v2 response schema, invariant, and mapping into `birth-time-journey-dynamic-adapters.ts`. Root responses, ranges, opportunities, partitions, and winning segments are strict; empty dynamic evidence is enforced before candidate construction. +- Split dynamic adapter regressions into `birth-time-journey-dynamic-adapters.test.ts`. Added duplicate opportunity/partition attacks, nested extra-field attacks, exact-range keys, cross-midnight mapping, and independent expected-value assertions. +- Made `buildDifferencePacket` and `scoreChoices` required on the primary `BirthTimeJourneyEngine`. Existing services use the explicit `LegacyBirthTimeJourneyEngine` pick, and legacy-only test doubles were narrowed without runtime behavior changes. +- Refactored HTTP execution into `createJourneyEngineWire`, whose `post` accepts one typed request object. The server-only factory remains the environment owner; engine assembly is injectable for executable fake-fetch verification without adding the unavailable `server-only` runtime dependency. +- Replaced greedy source-regex authentication tests with executable coverage for both exact dynamic URLs, serialized bodies, POST method, bearer header, 45-second abort signal, and missing-token zero-fetch behavior. All three legacy engine endpoints execute without an Authorization header. +- Removed newly introduced non-null assertions and narrowed the ownership scan to the client/request/component/hook boundary named by the plan. +- Kept every modified production and test TypeScript module at or below 250 pure LOC. The largest is `birth-time-journey-test-support.ts` at 249; Task 3 production modules range from 19 to 239. + +### Review RED + +1. The legacy compatibility regression failed with `unrecognized_keys` when an existing engine evidence item contained extra server metadata. +2. The new dynamic adapter suite failed to load because the protocol-specific module did not yet exist. +3. Independent review probes had shown nested dynamic `winning_segment` extras were silently stripped, optional primary engine methods weakened consumers, and the source-regex auth proof could remain green after removing authentication from one endpoint. + +### Review GREEN + +1. Focused legacy/dynamic/wire suite: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts tests/birth-time-journey-dynamic-adapters.test.ts` + - 24 passed, 0 failed. +2. Complete birth-time suite: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time*.test.ts` + - 210 passed, 0 failed. +3. Full frontend suite: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts` + - 285 passed, 0 failed. +4. Focused ESLint across all changed production/test TypeScript modules: + - Passed with no diagnostics. +5. TypeScript diagnostic: + - No Task 3 diagnostics; only the known `tests/profile-persistence.test.ts:7 TS1501` baseline remains. +6. Pure-LOC audit and `git diff --check`: + - Every changed TypeScript file is at or below 250 pure LOC; no whitespace errors. diff --git a/frontend/src/lib/birth-time-journey-adapters.ts b/frontend/src/lib/birth-time-journey-adapters.ts index 2c199374..0d698e1b 100644 --- a/frontend/src/lib/birth-time-journey-adapters.ts +++ b/frontend/src/lib/birth-time-journey-adapters.ts @@ -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 { - 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([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)); } diff --git a/frontend/src/lib/birth-time-journey-assessment.ts b/frontend/src/lib/birth-time-journey-assessment.ts index 19506a76..105c8ba3 100644 --- a/frontend/src/lib/birth-time-journey-assessment.ts +++ b/frontend/src/lib/birth-time-journey-assessment.ts @@ -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, assessment: BirthTimeAssessment, ): Promise<{ readonly stability: ScanStability; diff --git a/frontend/src/lib/birth-time-journey-dynamic-adapters.ts b/frontend/src/lib/birth-time-journey-dynamic-adapters.ts new file mode 100644 index 00000000..de0b4a8b --- /dev/null +++ b/frontend/src/lib/birth-time-journey-dynamic-adapters.ts @@ -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 { + 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, + }); +} diff --git a/frontend/src/lib/birth-time-journey-engine-model.ts b/frontend/src/lib/birth-time-journey-engine-model.ts index 54a538ee..070c92e9 100644 --- a/frontend/src/lib/birth-time-journey-engine-model.ts +++ b/frontend/src/lib/birth-time-journey-engine-model.ts @@ -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; +}>; + +export type JourneyEngineWire = { + post(input: { + readonly path: string; + readonly body: unknown; + readonly authentication: "legacy" | "dynamic"; + }): Promise; +}; + +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); + }, + }; +} diff --git a/frontend/src/lib/birth-time-journey-engine.ts b/frontend/src/lib/birth-time-journey-engine.ts index 0798b95e..7ced28b5 100644 --- a/frontend/src/lib/birth-time-journey-engine.ts +++ b/frontend/src/lib/birth-time-journey-engine.ts @@ -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 { - 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, + })); } diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts index 453c9f17..02f3f6fa 100644 --- a/frontend/src/lib/birth-time-journey-service.ts +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -97,15 +97,14 @@ export interface BirthTimeJourneyEngine { scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>; score(input: JourneyScoreInput): Promise; scoreEvents(input: JourneyEventScoreInput): Promise; - buildDifferencePacket?(input: DifferencePacketInput): Promise; - scoreChoices?(input: DynamicChoiceScoreInput): Promise; -} - -export interface DynamicBirthTimeJourneyEngine extends BirthTimeJourneyEngine { buildDifferencePacket(input: DifferencePacketInput): Promise; scoreChoices(input: DynamicChoiceScoreInput): Promise; } +export type LegacyBirthTimeJourneyEngine = Pick; + 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; }; diff --git a/frontend/tests/birth-time-agent-flow-test-support.ts b/frontend/tests/birth-time-agent-flow-test-support.ts index 24a57206..005446e2 100644 --- a/frontend/tests/birth-time-agent-flow-test-support.ts +++ b/frontend/tests/birth-time-agent-flow-test-support.ts @@ -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; diff --git a/frontend/tests/birth-time-journey-adapters.test.ts b/frontend/tests/birth-time-journey-adapters.test.ts index 7d4b2fd5..171d60c2 100644 --- a/frontend/tests/birth-time-journey-adapters.test.ts +++ b/frontend/tests/birth-time-journey-adapters.test.ts @@ -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", () => { diff --git a/frontend/tests/birth-time-journey-dynamic-adapters.test.ts b/frontend/tests/birth-time-journey-dynamic-adapters.test.ts new file mode 100644 index 00000000..cc66d840 --- /dev/null +++ b/frontend/tests/birth-time-journey-dynamic-adapters.test.ts @@ -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"); +}); diff --git a/frontend/tests/birth-time-journey-engine.test.ts b/frontend/tests/birth-time-journey-engine.test.ts index 47e9cb5b..1a083103 100644 --- a/frontend/tests/birth-time-journey-engine.test.ts +++ b/frontend/tests/birth-time-journey-engine.test.ts @@ -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> = { + "/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); } }); diff --git a/frontend/tests/birth-time-journey-service.test.ts b/frontend/tests/birth-time-journey-service.test.ts index d38791fe..73ae0b8b 100644 --- a/frontend/tests/birth-time-journey-service.test.ts +++ b/frontend/tests/birth-time-journey-service.test.ts @@ -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"); diff --git a/frontend/tests/birth-time-journey-test-support.ts b/frontend/tests/birth-time-journey-test-support.ts index 5cacbf45..29259c59 100644 --- a/frontend/tests/birth-time-journey-test-support.ts +++ b/frontend/tests/birth-time-journey-test-support.ts @@ -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(); },