feat: connect dynamic rectification engine

This commit is contained in:
Jesse_Chen
2026-07-19 01:24:16 +08:00
parent 49ab717752
commit 3cc748f329
8 changed files with 699 additions and 19 deletions
+68
View File
@@ -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.
+2 -2
View File
@@ -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) {
+196 -10
View File
@@ -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<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";
@@ -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<typeof candidateResultApiSchema>): CandidateResult {
return candidateResultSchema.parse({
resultId: parsed.result_id,
confidence: parsed.confidence,
@@ -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;
}
+53 -5
View File
@@ -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<unknown> {
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" },
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<u
return payload;
}
function dynamicAuthorization(): string {
const token = process.env.JYOTISH_DYNAMIC_RECTIFICATION_TOKEN?.trim();
if (!token) throw new BirthTimeJourneyEngineConfigurationError();
return `Bearer ${token}`;
}
export function createJyotishBirthTimeJourneyEngine(
apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200",
): BirthTimeJourneyEngine {
): DynamicBirthTimeJourneyEngine {
return {
async scan(input) {
const payload = await postJson(apiBase, "/api/active_rectification_questions", {
@@ -63,5 +91,25 @@ export function createJyotishBirthTimeJourneyEngine(
);
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);
},
};
}
@@ -15,6 +15,12 @@ import { scanAssessment } from "./birth-time-journey-assessment.ts";
import type { GuidedCandidateCommit } from "./birth-time-guided-candidate.ts";
import { createGuidedCandidateActions } from "./birth-time-guided-candidate.ts";
import { createGuidedDraftRevisionActions } from "./birth-time-guided-draft-revision.ts";
import type {
CandidateDifferenceBuild,
DynamicChoiceScoringResult,
ServerChoiceEvidence,
} from "./birth-time-dynamic-choice-internal.ts";
import type { TimeRange } from "./birth-time-dynamic-choice.ts";
export type RectificationAnswer = "A" | "B" | "C" | "D";
@@ -66,10 +72,38 @@ export type JourneyEventScoreInput = {
readonly events: readonly LifeEvent[];
};
export type DifferencePacketInput = {
readonly caseId: string;
readonly asOfDate: string;
readonly birthDate: string;
readonly startTime: string;
readonly endTime: string;
readonly lat: number;
readonly lon: number;
readonly tz: number;
readonly evidence: readonly ServerChoiceEvidence[];
readonly dismissedOpportunityIds: readonly string[];
readonly questionFingerprints: readonly string[];
readonly partitionFingerprints: readonly string[];
readonly recentRanges: readonly TimeRange[];
readonly candidateModel: Readonly<Record<string, unknown>> | null;
};
export type DynamicChoiceScoreInput = Pick<DifferencePacketInput,
"birthDate" | "startTime" | "endTime" | "lat" | "lon" | "tz" | "evidence"
>;
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 PersistedJourneyAssessment = {
@@ -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 }));
});
@@ -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}`);
}
}
});