diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md new file mode 100644 index 00000000..c3e67caf --- /dev/null +++ b/.superpowers/sdd/task-3-report.md @@ -0,0 +1,68 @@ +# Task 3 — TypeScript Engine Adapter and Trust Boundary + +## Implementation + +- Added snake-case serializers for dynamic opportunity and deterministic choice-scoring requests. Only server-resolved `ServerChoiceEvidence` becomes `partition_id` / `candidate_scores`; client option IDs and confidence fields are never serialized. +- Added strict dynamic response adapters. Opportunity responses are versioned, endpoint-bound, exact-field parsed, and split into a model-safe `packet`, private `candidateModel`, and private `scoringPartitions`. +- Candidate score maps must contain finite nonnegative values keyed by exactly every minute in the submitted range, including cross-midnight ranges. Duplicate opportunity/partition IDs and unexpected response fields are rejected. +- Dynamic scoring responses require `birth-time-choice-scoring-v2`, `dynamic_choice` evidence mode, empty public evidence, matching compatibility/effective counts, and the existing deterministic candidate safety gates. +- Added authenticated server-only engine calls for both dynamic Python endpoints. They fail before fetch when `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` is absent and send the configured value only as a bearer header. Legacy scan, questionnaire score, and dated-event score calls remain unauthenticated. +- Extended the legacy-compatible engine contract with optional dynamic methods and added `DynamicBirthTimeJourneyEngine`, where both methods are required. The production factory returns the required dynamic subtype while existing legacy-only test doubles remain source-compatible. +- Raised only the shared candidate-result compatibility cap from 6 to 10 and changed the high-gate message to “effective evidence items.” The dated-event request contract remains capped at 6. +- Added source-contract coverage preventing snake-case or camel-case candidate model, partition, score, and token identifiers from entering client, request, response, component, or hook modules. + +## Files changed + +- `frontend/src/lib/birth-time-journey-service.ts` +- `frontend/src/lib/birth-time-journey-engine.ts` +- `frontend/src/lib/birth-time-journey-adapters.ts` +- `frontend/src/lib/birth-time-journey-engine-model.ts` +- `frontend/src/lib/birth-time-evidence.ts` +- `frontend/tests/birth-time-journey-engine.test.ts` +- `frontend/tests/birth-time-journey-adapters.test.ts` +- `.superpowers/sdd/task-3-report.md` + +## RED + +1. Bundled Node focused run: + - `/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` + - Failed during module instantiation because `parseCandidateDifferenceBuild` and `parseDynamicChoiceScoring` did not exist. Baseline result: 1 pass, 1 file-load failure. +2. After the first adapter implementation, the malformed score-key regression failed because a response containing `candidate_scores: { "not-a-time": 1 }` was accepted. +3. The exact-range regression then failed because an otherwise valid `05:34` score key could be added outside the `05:30—05:33` range. +4. The initial source-level legacy-auth assertion was too broad and matched the next dynamic method. It was narrowed to the exact legacy method section; production behavior did not change for this test-only correction. + +## GREEN + +1. Focused Task 3 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` + - 21 passed, 0 failed. +2. Complete birth-time frontend regression suite: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time*.test.ts` + - 207 passed, 0 failed. +3. Focused ESLint over all Task 3 source and test files: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node node_modules/eslint/bin/eslint.js ...` + - Passed with no diagnostics. +4. TypeScript diagnostic: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node node_modules/typescript/bin/tsc --noEmit --pretty false` + - No Task 3 diagnostics. It reproduces only the existing baseline `TS1501` in `tests/profile-persistence.test.ts:7` because the project targets ES2017 while that test uses an ES2018 regex flag. +5. `git diff --check` + - Passed with no whitespace errors. + +## Pre-work gate + +- Ran `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` after reading both required sweep documents and the error ledger. +- The gate remained red only on the unrelated known fragment-governance baseline: `candidate_count` expected `0`, observed `1`. Remote visibility was blocked, so no cloud-sync claim is made. + +## Self-review + +- Dynamic secrets and score vectors stay behind the existing server-only engine entrypoint. The public dynamic packet deliberately drops `candidateScores`, while the private partition map retains the exact server vector for later binding. +- Both response adapters are strict at their dynamic endpoint roots; model-controlled extra fields cannot be silently retained. +- The dynamic scorer cannot elevate confidence by mismatching effective counts, dimensions, evidence mode, evidence contents, algorithm version, or the existing high-confidence candidate gates. +- Cross-midnight ranges enumerate minutes modulo 24 hours, preventing a valid `23:59—00:00` result from being rejected or split. +- Legacy request payloads, endpoints, timeout, parsing behavior, and authentication remain unchanged. The event request schema remains at 6 even though the shared result compatibility schema can represent the v2 cap of 10. +- No dependency, logging, model prompt field, client response field, or persistence write was added. + +## Concerns + +- 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. diff --git a/frontend/src/lib/birth-time-evidence.ts b/frontend/src/lib/birth-time-evidence.ts index 3865d2d8..8af94d77 100644 --- a/frontend/src/lib/birth-time-evidence.ts +++ b/frontend/src/lib/birth-time-evidence.ts @@ -93,7 +93,7 @@ export const candidateResultSchema = z.object({ representativeTime: timeSchema, widthMinutes: z.number().int().min(1).max(1_440), }).strict().readonly().nullable(), - eventCount: z.number().int().min(0).max(6), + eventCount: z.number().int().min(0).max(10), domainCount: z.number().int().min(0).max(5), topScore: z.number(), secondScore: z.number(), @@ -107,7 +107,7 @@ export const candidateResultSchema = z.object({ context.addIssue({ code: z.ZodIssueCode.custom, path: ["eventCount"], - message: "high candidates require at least four events", + message: "high candidates require at least four effective evidence items", }); } if (value.confidence === "high" && value.domainCount < 3) { diff --git a/frontend/src/lib/birth-time-journey-adapters.ts b/frontend/src/lib/birth-time-journey-adapters.ts index d03be073..2c199374 100644 --- a/frontend/src/lib/birth-time-journey-adapters.ts +++ b/frontend/src/lib/birth-time-journey-adapters.ts @@ -5,6 +5,14 @@ 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, @@ -83,7 +91,15 @@ const eventDomainSchema = z.enum([ "career", "health_pressure", ]); -const candidateResultApiSchema = z.object({ +const candidateEvidenceApiSchema = z.object({ + event_id: z.string().uuid(), + domain: eventDomainSchema, + candidate_time: z.string(), + rule_ids: z.array(z.string()), + points: z.number(), +}).strict(); + +const candidateResultApiFields = { result_id: z.string().uuid(), confidence: z.enum(["low", "medium", "high"]), can_apply: z.boolean(), @@ -99,15 +115,129 @@ const candidateResultApiSchema = z.object({ second_score: z.number(), margin_percent: z.number(), reasons: z.array(z.string()), - evidence: z.array(z.object({ - event_id: z.string().uuid(), - domain: eventDomainSchema, - candidate_time: z.string(), - rule_ids: z.array(z.string()), - points: z.number(), - })), + evidence: z.array(candidateEvidenceApiSchema), algorithm_version: z.string(), -}).passthrough(); +} 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"; @@ -195,8 +325,64 @@ 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 { - const parsed = candidateResultApiSchema.parse(value); + return adaptCandidateResult(candidateResultApiSchema.parse(value)); +} + +function adaptCandidateResult(parsed: z.infer): CandidateResult { return candidateResultSchema.parse({ resultId: parsed.result_id, confidence: parsed.confidence, diff --git a/frontend/src/lib/birth-time-journey-engine-model.ts b/frontend/src/lib/birth-time-journey-engine-model.ts index c3769f2e..54a538ee 100644 --- a/frontend/src/lib/birth-time-journey-engine-model.ts +++ b/frontend/src/lib/birth-time-journey-engine-model.ts @@ -1,4 +1,8 @@ -import type { JourneyEventScoreInput } from "./birth-time-journey-service.ts"; +import type { + DifferencePacketInput, + DynamicChoiceScoreInput, + JourneyEventScoreInput, +} from "./birth-time-journey-service.ts"; export function eventScorePayload(input: JourneyEventScoreInput) { return { @@ -16,3 +20,48 @@ export function eventScorePayload(input: JourneyEventScoreInput) { })), } as const; } + +function choiceEvidencePayload(input: DifferencePacketInput["evidence"]) { + return input.map((item) => ({ + question_id: item.questionId, + opportunity_id: item.opportunityId, + partition_id: item.partitionId, + dimension_code: item.dimensionCode, + candidate_scores: item.candidateScores, + information_gain: item.informationGain, + })); +} + +export function differencePacketPayload(input: DifferencePacketInput) { + return { + case_id: input.caseId, + as_of_date: input.asOfDate, + birth_date: input.birthDate, + start_time: input.startTime, + end_time: input.endTime, + lat: input.lat, + lon: input.lon, + tz: input.tz, + evidence: choiceEvidencePayload(input.evidence), + dismissed_opportunity_ids: input.dismissedOpportunityIds, + question_fingerprints: input.questionFingerprints, + partition_fingerprints: input.partitionFingerprints, + recent_ranges: input.recentRanges.map((range) => ({ + start_time: range.startTime, + end_time: range.endTime, + })), + candidate_model: input.candidateModel, + } as const; +} + +export function dynamicChoiceScorePayload(input: DynamicChoiceScoreInput) { + return { + birth_date: input.birthDate, + start_time: input.startTime, + end_time: input.endTime, + lat: input.lat, + lon: input.lon, + tz: input.tz, + choice_evidence: choiceEvidencePayload(input.evidence), + } as const; +} diff --git a/frontend/src/lib/birth-time-journey-engine.ts b/frontend/src/lib/birth-time-journey-engine.ts index fed6f518..0798b95e 100644 --- a/frontend/src/lib/birth-time-journey-engine.ts +++ b/frontend/src/lib/birth-time-journey-engine.ts @@ -1,12 +1,18 @@ import "server-only"; import { + parseCandidateDifferenceBuild, + parseDynamicChoiceScoring, parseRectificationQuestionnaire, parseRectificationScoring, parseCandidateResult, } from "./birth-time-journey-adapters.ts"; -import { eventScorePayload } from "./birth-time-journey-engine-model.ts"; -import type { BirthTimeJourneyEngine } from "./birth-time-journey-service.ts"; +import { + differencePacketPayload, + dynamicChoiceScorePayload, + eventScorePayload, +} from "./birth-time-journey-engine-model.ts"; +import type { DynamicBirthTimeJourneyEngine } from "./birth-time-journey-service.ts"; export class BirthTimeJourneyEngineError extends Error { readonly name = "BirthTimeJourneyEngineError"; @@ -18,10 +24,26 @@ export class BirthTimeJourneyEngineError extends Error { } } -async function postJson(apiBase: string, path: string, body: unknown): Promise { +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" }, + headers: { + "content-type": "application/json", + ...(authorization ? { authorization } : {}), + }, body: JSON.stringify(body), signal: AbortSignal.timeout(45_000), }); @@ -30,9 +52,15 @@ async function postJson(apiBase: string, path: string, body: unknown): Promise> | null; +}; + +export type DynamicChoiceScoreInput = Pick; + 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 PersistedJourneyAssessment = { diff --git a/frontend/tests/birth-time-journey-adapters.test.ts b/frontend/tests/birth-time-journey-adapters.test.ts index c39e1f06..7d4b2fd5 100644 --- a/frontend/tests/birth-time-journey-adapters.test.ts +++ b/frontend/tests/birth-time-journey-adapters.test.ts @@ -2,7 +2,9 @@ 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"; @@ -13,6 +15,159 @@ 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", @@ -204,3 +359,23 @@ test("rectification adapter normalizes an event-scored candidate result", () => assert.equal(result.winningSegment?.representativeTime, "14:24"); assert.deepEqual(result.evidence[0]?.ruleIds, ["vim_md_domain_house"]); }); + +test("candidate compatibility result accepts ten effective items but not eleven", () => { + const lowCandidate = { + result_id: "1d8ee348-61a3-433d-8907-ff6d281b9992", + confidence: "low", + can_apply: false, + winning_segment: null, + event_count: 10, + domain_count: 5, + top_score: 0, + second_score: 0, + margin_percent: 0, + reasons: ["safety_cap"], + evidence: [], + algorithm_version: "birth-time-choice-scoring-v2", + } as const; + + assert.equal(parseCandidateResult(lowCandidate).eventCount, 10); + assert.throws(() => parseCandidateResult({ ...lowCandidate, event_count: 11 })); +}); diff --git a/frontend/tests/birth-time-journey-engine.test.ts b/frontend/tests/birth-time-journey-engine.test.ts index f3c26789..47e9cb5b 100644 --- a/frontend/tests/birth-time-journey-engine.test.ts +++ b/frontend/tests/birth-time-journey-engine.test.ts @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { basename, join } from "node:path"; import test from "node:test"; -import { eventScorePayload } from "../src/lib/birth-time-journey-engine-model.ts"; +import { + differencePacketPayload, + dynamicChoiceScorePayload, + eventScorePayload, +} from "../src/lib/birth-time-journey-engine-model.ts"; test("journey engine serializes only stored event-scoring inputs", () => { const payload = eventScorePayload({ @@ -33,3 +39,117 @@ test("journey engine serializes only stored event-scoring inputs", () => { assert.equal("confidence" in payload, false); assert.equal("can_apply" in payload, false); }); + +const dynamicInput = { + caseId: "case-1", + asOfDate: "2026-07-18", + birthDate: "1990-01-01", + startTime: "05:30", + endTime: "05:33", + lat: 31.23, + lon: 121.47, + tz: 8, + evidence: [{ + questionId: "question-1", + opportunityId: "career-window", + partitionId: "career-early", + dimensionCode: "career", + candidateScores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 }, + informationGain: 0.5, + }], + dismissedOpportunityIds: ["dismissed-1"], + questionFingerprints: ["question-fingerprint-1"], + partitionFingerprints: ["partition-fingerprint-1"], + recentRanges: [{ startTime: "05:30", endTime: "05:33" }], + candidateModel: { version: "birth-time-choice-scoring-v2" }, +} as const; + +test("dynamic opportunity payload owns private evidence and candidate model server-side", () => { + const payload = differencePacketPayload(dynamicInput); + + assert.deepEqual(payload, { + case_id: "case-1", + as_of_date: "2026-07-18", + birth_date: "1990-01-01", + start_time: "05:30", + end_time: "05:33", + lat: 31.23, + lon: 121.47, + tz: 8, + 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, + }], + dismissed_opportunity_ids: ["dismissed-1"], + question_fingerprints: ["question-fingerprint-1"], + partition_fingerprints: ["partition-fingerprint-1"], + 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); +}); + +test("dynamic score payload sends only server-resolved choice evidence", () => { + const payload = dynamicChoiceScorePayload(dynamicInput); + + assert.deepEqual(payload.choice_evidence, differencePacketPayload(dynamicInput).evidence); + 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"); + + 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 clientBoundaryFiles(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] + : []; + }); +} + +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", + ]; + + 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}`); + } + } +});