fix: harden dynamic rectification orchestration
This commit is contained in:
@@ -20,6 +20,14 @@ Terminal low, medium, confirmation, and ready turns are one-way: answer, generat
|
||||
pause, and finish mutations cannot restart them. New assessments reload and return their
|
||||
persisted v2 generation turn instead of projecting the former legacy baseline question.
|
||||
|
||||
The focused review follow-up closes three additional safety seams. Scoring now independently
|
||||
recomputes the deterministic confidence class from persisted effective evidence/domain counts,
|
||||
margin thresholds, and segment width; medium and high cannot be accepted below their gates.
|
||||
Winning segments must be a chronological subset of the persisted range, with exact inclusive
|
||||
width and midpoint representative time, including across midnight. Unmatched-context, pause,
|
||||
and finish retries now carry a private typed receipt and replay only the identical action,
|
||||
version, and payload; cross-action or changed-payload receipt reuse is stale.
|
||||
|
||||
## Persistence Amendment
|
||||
|
||||
Added service-role-only `create_birth_time_dynamic_scoring_job` and
|
||||
@@ -28,10 +36,15 @@ validates/persists the public turn, private state, canonical receipt, and pendin
|
||||
checks ownership, job identity, evidence fingerprint, algorithm version, current action, and
|
||||
lease; completed replay requires a coherent persisted result/action. Production store methods
|
||||
use these RPCs directly and do not call legacy scoring wrappers or expose the private row.
|
||||
Claim now locks job then case, matching completion/failure order and removing the prior lock
|
||||
cycle. The executable memory store also models the 60-second processing lease and reclaim.
|
||||
|
||||
## Main Files
|
||||
|
||||
- `frontend/src/lib/birth-time-dynamic-actions.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-special-actions.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-action-replay.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-result-validator.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-transitions.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-scoring-service.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-scoring-job-store.ts`
|
||||
@@ -55,13 +68,16 @@ use these RPCs directly and do not call legacy scoring wrappers or expose the pr
|
||||
- Persistence RED: all SQL contract cases failed before the ordered v2 job migration existed.
|
||||
- Assessment regression RED: a newly persisted v2 case returned a response with no
|
||||
`journeyProtocol`; GREEN now reloads and returns the stored dynamic generation turn.
|
||||
- Review RED: `medium + null segment + 1/1` was accepted, claim locked case before job, and
|
||||
unmatched/pause/finish lost-response retries failed before receipt replay. Dedicated tests
|
||||
reproduced each failure before the focused fix.
|
||||
|
||||
Final verification:
|
||||
|
||||
- Focused Task 6 TypeScript: **22/22 passed**.
|
||||
- Route/telemetry regression subset after v2 assessment routing: **28/28 passed**.
|
||||
- Full frontend TypeScript tests: **344/344 passed**.
|
||||
- Dynamic scoring-job SQL contracts: **3/3 passed**.
|
||||
- Full frontend TypeScript tests: **351/351 passed**.
|
||||
- Dynamic scoring-job SQL contracts: **4/4 passed**.
|
||||
- ESLint: **0 errors**, with the two pre-existing `page.tsx` hook warnings.
|
||||
- `git diff --check`: passed.
|
||||
- Every changed/new Task 6 TypeScript, test, and migration module is at most 250 pure LOC.
|
||||
@@ -71,7 +87,9 @@ Final verification:
|
||||
|
||||
Live PostgreSQL execution was unavailable from the inherited Task 5 environment because the
|
||||
Docker daemon socket was absent. The database claim is therefore limited to static SQL
|
||||
contracts plus executable TypeScript RPC fakes; no live-database pass is claimed.
|
||||
token/order contracts plus executable TypeScript RPC fakes; no live-database pass is claimed.
|
||||
The SQL tests do not simulate PostgreSQL locking; the lock-order assertion is structural, while
|
||||
lease/reclaim behavior is executed by the typed memory store.
|
||||
|
||||
## Handoff
|
||||
|
||||
@@ -81,3 +99,5 @@ browser coordination. In particular, v2 polling must call `pollDynamicScoringJob
|
||||
assessment response through the existing telemetry wrapper.
|
||||
|
||||
Commit message: `feat: orchestrate dynamic rectification turns`
|
||||
|
||||
Focused review fix commit message: `fix: harden dynamic rectification orchestration`
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { DynamicStoredRectificationCase } from "./birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts";
|
||||
|
||||
export function replayedDynamicAction(
|
||||
stored: DynamicStoredRectificationCase,
|
||||
actionId: string,
|
||||
expectedVersion: number,
|
||||
matches: () => boolean,
|
||||
): boolean {
|
||||
if (!stored.processedActionIds.includes(actionId.toLowerCase())) return false;
|
||||
if (stored.turnVersion === expectedVersion + 1 && matches()) return true;
|
||||
throw new StaleJourneyTurnError(stored.id, expectedVersion, stored.turnVersion);
|
||||
}
|
||||
|
||||
export function samePersistedDynamicReceipt(
|
||||
proposed: DynamicStoredRectificationCase,
|
||||
current: DynamicStoredRectificationCase,
|
||||
actionId: string,
|
||||
expectedVersion: number,
|
||||
): boolean {
|
||||
const expected = proposed.dynamicControl.lastActionReceipt;
|
||||
const actual = current.dynamicControl.lastActionReceipt;
|
||||
if (expected?.actionId !== actionId) return actual?.actionId !== actionId;
|
||||
return current.turnVersion === expectedVersion + 1
|
||||
&& actual?.actionId === expected.actionId
|
||||
&& actual.kind === expected.kind
|
||||
&& actual.turnVersion === expected.turnVersion
|
||||
&& actual.questionId === expected.questionId
|
||||
&& actual.note === expected.note;
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { replayedDynamicAction } from "./birth-time-dynamic-action-replay.ts";
|
||||
import type { PersistedDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts";
|
||||
import { dynamicDifferenceInput } from "./birth-time-dynamic-engine-input.ts";
|
||||
import { publicQuestionAction } from "./birth-time-dynamic-transitions.ts";
|
||||
import {
|
||||
answerTransition,
|
||||
isDynamicTerminal,
|
||||
toPausedDynamicAction,
|
||||
withDynamicAction,
|
||||
} from "./birth-time-dynamic-transitions.ts";
|
||||
import { answerTransition, isDynamicTerminal, withDynamicAction } from "./birth-time-dynamic-transitions.ts";
|
||||
import { createDynamicSpecialActions } from "./birth-time-dynamic-special-actions.ts";
|
||||
import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts";
|
||||
import type {
|
||||
BirthTimeJourneyEngine,
|
||||
@@ -27,7 +23,6 @@ type ChoiceCommand = {
|
||||
};
|
||||
type TurnCommand = Pick<ChoiceCommand, "caseId" | "actionId" | "turnVersion">;
|
||||
type QuestionCommand = TurnCommand & { readonly unmatchedNote?: string | null };
|
||||
type UnmatchedCommand = TurnCommand & { readonly questionId: string; readonly note: string };
|
||||
|
||||
export class BirthTimeDynamicActionError extends Error {
|
||||
readonly name = "BirthTimeDynamicActionError";
|
||||
@@ -88,14 +83,26 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
|
||||
) {
|
||||
return ports.store.saveDynamicTurn(updated, stored.turnVersion, actionId);
|
||||
}
|
||||
const specialActions = createDynamicSpecialActions(ports, load, requireMutable);
|
||||
|
||||
return {
|
||||
...specialActions,
|
||||
async answerDynamicChoice(userId: string, command: ChoiceCommand) {
|
||||
const stored = await load(userId, command.caseId);
|
||||
requireMutable(stored);
|
||||
if (stored.processedActionIds.includes(command.actionId.toLowerCase())) {
|
||||
const lastAnswer = stored.choiceAnswers.at(-1);
|
||||
const actionKind = stored.dynamicTurnState.nextAction.kind;
|
||||
if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => (
|
||||
lastAnswer?.questionId === command.questionId && lastAnswer.optionId === command.optionId
|
||||
&& stored.dynamicControl.lastActionReceipt?.actionId !== command.actionId.toLowerCase()
|
||||
&& (lastAnswer.kind === "primary"
|
||||
? actionKind === "score_pending"
|
||||
: lastAnswer.kind === "unknown"
|
||||
? actionKind === "generate_dynamic_question"
|
||||
: actionKind === "clarify_unmatched_answer")
|
||||
))) {
|
||||
return storedDynamicJourneyResponse(stored);
|
||||
}
|
||||
requireMutable(stored);
|
||||
const action = stored.dynamicTurnState.nextAction;
|
||||
const question = stored.currentChoiceQuestion;
|
||||
if (stored.turnVersion !== command.turnVersion
|
||||
@@ -135,33 +142,6 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
|
||||
return storedDynamicJourneyResponse(await save(stored, updated, command.actionId));
|
||||
},
|
||||
|
||||
async submitUnmatchedContext(userId: string, command: UnmatchedCommand) {
|
||||
const stored = await load(userId, command.caseId);
|
||||
requireMutable(stored);
|
||||
const question = stored.currentChoiceQuestion;
|
||||
if (stored.turnVersion !== command.turnVersion
|
||||
|| stored.dynamicTurnState.nextAction.kind !== "clarify_unmatched_answer"
|
||||
|| question?.questionId !== command.questionId) throw stale(stored, command.turnVersion);
|
||||
const note = z.string().trim().max(240).parse(command.note);
|
||||
const action = { kind: "generate_dynamic_question" as const };
|
||||
const updated = withDynamicAction(stored, action, stored.turnVersion + 1);
|
||||
const next = {
|
||||
...updated,
|
||||
currentChoiceQuestion: null,
|
||||
agentContext: note.length === 0
|
||||
? stored.agentContext
|
||||
: [...stored.agentContext.slice(-9), note],
|
||||
dynamicControl: {
|
||||
...stored.dynamicControl,
|
||||
dismissedOpportunityIds: [
|
||||
...stored.dynamicControl.dismissedOpportunityIds,
|
||||
question.opportunityId,
|
||||
],
|
||||
},
|
||||
};
|
||||
return storedDynamicJourneyResponse(await save(stored, next, command.actionId));
|
||||
},
|
||||
|
||||
async loadDynamicQuestionBuild(userId: string, command: QuestionCommand) {
|
||||
const stored = await load(userId, command.caseId);
|
||||
requireMutable(stored);
|
||||
@@ -179,10 +159,17 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
|
||||
question: PersistedDynamicChoiceQuestion | null,
|
||||
) {
|
||||
const stored = await load(userId, command.caseId);
|
||||
requireMutable(stored);
|
||||
if (stored.processedActionIds.includes(command.actionId.toLowerCase())) {
|
||||
if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => (
|
||||
question === null
|
||||
? stored.currentChoiceQuestion === null
|
||||
&& stored.dynamicTurnState.nextAction.kind === "present_low_result"
|
||||
: stored.currentChoiceQuestion?.questionId === question.questionId
|
||||
&& stored.currentChoiceQuestion.questionFingerprint === question.questionFingerprint
|
||||
&& stored.dynamicControl.lastActionReceipt?.actionId !== command.actionId.toLowerCase()
|
||||
))) {
|
||||
return { nextAction: stored.dynamicTurnState.nextAction };
|
||||
}
|
||||
requireMutable(stored);
|
||||
const kind = stored.dynamicTurnState.nextAction.kind;
|
||||
if (stored.turnVersion !== command.turnVersion
|
||||
|| (kind !== "generate_dynamic_question" && kind !== "retry_question_generation")) {
|
||||
@@ -210,20 +197,6 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
|
||||
return { nextAction: saved.dynamicTurnState.nextAction };
|
||||
},
|
||||
|
||||
async pauseDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) {
|
||||
const stored = await load(userId, caseId);
|
||||
requireMutable(stored);
|
||||
if (stored.turnVersion !== turnVersion || stored.dynamicTurnState.nextAction.kind === "paused") {
|
||||
throw stale(stored, turnVersion);
|
||||
}
|
||||
const pausedAction = toPausedDynamicAction(stored.dynamicTurnState.nextAction);
|
||||
const updated = withDynamicAction(stored, { kind: "paused" }, stored.turnVersion + 1);
|
||||
return storedDynamicJourneyResponse(await save(stored, {
|
||||
...updated,
|
||||
dynamicControl: { ...stored.dynamicControl, pausedAction },
|
||||
}, actionId));
|
||||
},
|
||||
|
||||
async resumeDynamic(userId: string, caseId: string) {
|
||||
const stored = await load(userId, caseId);
|
||||
if (isDynamicTerminal(stored) || stored.dynamicTurnState.nextAction.kind !== "paused") {
|
||||
@@ -245,20 +218,5 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
|
||||
return storedDynamicJourneyResponse(saved);
|
||||
},
|
||||
|
||||
async finishDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) {
|
||||
const stored = await load(userId, caseId);
|
||||
requireMutable(stored);
|
||||
if (stored.turnVersion !== turnVersion) throw stale(stored, turnVersion);
|
||||
const candidate = stored.candidateResult;
|
||||
const action = candidate?.confidence === "medium"
|
||||
? { kind: "present_medium_result" as const, resultId: candidate.resultId }
|
||||
: { kind: "present_low_result" as const, resultId: candidate?.resultId ?? null };
|
||||
const updated = withDynamicAction(stored, action, stored.turnVersion + 1);
|
||||
return storedDynamicJourneyResponse(await save(stored, {
|
||||
...updated,
|
||||
snapshot: terminalSnapshot(stored),
|
||||
currentChoiceQuestion: null,
|
||||
}, actionId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export type StoredChoiceAnswer = {
|
||||
readonly kind: PublicChoiceKind;
|
||||
readonly opportunityId: string;
|
||||
readonly answeredAt: string;
|
||||
readonly unmatchedContext?: string;
|
||||
};
|
||||
|
||||
export type ServerChoiceEvidence = {
|
||||
@@ -120,6 +121,13 @@ export type DynamicControlState = {
|
||||
readonly dismissedOpportunityIds: readonly string[];
|
||||
readonly recentRanges: readonly TimeRange[];
|
||||
readonly pausedAction: PausedDynamicAction | null;
|
||||
readonly lastActionReceipt?: {
|
||||
readonly actionId: string;
|
||||
readonly kind: "unmatched_context" | "pause" | "finish";
|
||||
readonly turnVersion: number;
|
||||
readonly questionId?: string;
|
||||
readonly note?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const evidencePartitionBaseSchema = z.object({
|
||||
@@ -199,6 +207,7 @@ export const storedChoiceAnswerSchema = z.object({
|
||||
kind: publicChoiceKindSchema,
|
||||
opportunityId: z.string().trim().min(1),
|
||||
answeredAt: z.string().datetime({ offset: true }),
|
||||
unmatchedContext: z.string().max(240).optional(),
|
||||
}).strict().readonly();
|
||||
|
||||
export const serverChoiceEvidenceSchema = z.object({
|
||||
@@ -236,6 +245,13 @@ export const dynamicControlStateSchema = z.object({
|
||||
dismissedOpportunityIds: z.array(z.string().trim().min(1)).readonly(),
|
||||
recentRanges: z.array(timeRangeSchema).readonly(),
|
||||
pausedAction: pausedDynamicActionSchema.nullable(),
|
||||
lastActionReceipt: z.object({
|
||||
actionId: z.string().uuid(),
|
||||
kind: z.enum(["unmatched_context", "pause", "finish"]),
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
questionId: z.string().trim().min(1).optional(),
|
||||
note: z.string().max(240).optional(),
|
||||
}).strict().readonly().nullable().optional(),
|
||||
}).strict().readonly();
|
||||
|
||||
export function toPublicDynamicChoiceQuestion(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { DynamicChoiceScoringResult } from "./birth-time-dynamic-choice-internal.ts";
|
||||
import type { TimeRange } from "./birth-time-dynamic-choice.ts";
|
||||
import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts";
|
||||
|
||||
function minute(value: string): number {
|
||||
const [hour, part] = value.split(":").map(Number);
|
||||
return hour * 60 + part;
|
||||
}
|
||||
|
||||
function rangeMinutes(range: TimeRange): readonly number[] {
|
||||
const start = minute(range.startTime);
|
||||
const end = minute(range.endTime);
|
||||
const values = [start];
|
||||
let current = start;
|
||||
while (current !== end) {
|
||||
current = (current + 1) % 1_440;
|
||||
values.push(current);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function segmentIsCoherent(
|
||||
result: DynamicChoiceScoringResult,
|
||||
currentRange: TimeRange,
|
||||
): boolean {
|
||||
const segment = result.candidate.winningSegment;
|
||||
if (segment === null) return true;
|
||||
const candidates = rangeMinutes(currentRange);
|
||||
const start = candidates.indexOf(minute(segment.startTime));
|
||||
const end = candidates.indexOf(minute(segment.endTime));
|
||||
if (start < 0 || end < start) return false;
|
||||
const width = end - start + 1;
|
||||
const representative = candidates[start + Math.floor((width - 1) / 2)];
|
||||
return segment.widthMinutes === width
|
||||
&& minute(segment.representativeTime) === representative;
|
||||
}
|
||||
|
||||
export function assertDynamicScoringResult(
|
||||
result: DynamicChoiceScoringResult,
|
||||
currentRange: TimeRange,
|
||||
): void {
|
||||
const candidate = result.candidate;
|
||||
const segment = candidate.winningSegment;
|
||||
const blocked = candidate.reasons.includes("missing_mandatory_layers");
|
||||
const high = !blocked
|
||||
&& result.effectiveAnswerCount >= 4
|
||||
&& result.dimensionCount >= 3
|
||||
&& segment !== null
|
||||
&& segment.widthMinutes <= 5
|
||||
&& candidate.marginPercent >= 20;
|
||||
const medium = !blocked
|
||||
&& result.effectiveAnswerCount >= 3
|
||||
&& result.dimensionCount >= 2
|
||||
&& segment !== null
|
||||
&& segment.widthMinutes <= 15
|
||||
&& candidate.marginPercent >= 10;
|
||||
const expected = high ? "high" : medium ? "medium" : "low";
|
||||
if (!segmentIsCoherent(result, currentRange)
|
||||
|| candidate.confidence !== expected
|
||||
|| candidate.canApply !== high
|
||||
|| candidate.evidence.length !== 0) {
|
||||
throw new BirthTimeScoringJobError("invalid_result");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
dynamicDifferenceInput,
|
||||
} from "./birth-time-dynamic-engine-input.ts";
|
||||
import { completeDynamicScoreTransition, isDynamicTerminal, withDynamicAction } from "./birth-time-dynamic-transitions.ts";
|
||||
import { assertDynamicScoringResult } from "./birth-time-dynamic-result-validator.ts";
|
||||
import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts";
|
||||
import type {
|
||||
BirthTimeJourneyEngine,
|
||||
@@ -94,6 +95,7 @@ export function createDynamicScoringService(ports: BirthTimeJourneyPorts) {
|
||||
const result = dynamicChoiceScoringResultSchema.parse(
|
||||
await engine.scoreChoices(dynamicChoiceScoreInput(stored)),
|
||||
);
|
||||
assertDynamicScoringResult(result, stored.dynamicTurnState.progress.currentRange);
|
||||
requireCounts(stored, result);
|
||||
const build = await engine.buildDifferencePacket(dynamicDifferenceInput(stored));
|
||||
const useful = build.packet.opportunities.filter((opportunity) => (
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { z } from "zod";
|
||||
import { replayedDynamicAction } from "./birth-time-dynamic-action-replay.ts";
|
||||
import { toPausedDynamicAction, withDynamicAction } from "./birth-time-dynamic-transitions.ts";
|
||||
import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts";
|
||||
import type { BirthTimeJourneyPorts, DynamicStoredRectificationCase } from "./birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts";
|
||||
|
||||
type UnmatchedCommand = {
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly turnVersion: number;
|
||||
readonly questionId: string;
|
||||
readonly note: string;
|
||||
};
|
||||
|
||||
function stale(stored: DynamicStoredRectificationCase, expected: number) {
|
||||
return new StaleJourneyTurnError(stored.id, expected, stored.turnVersion);
|
||||
}
|
||||
|
||||
function terminalSnapshot(stored: DynamicStoredRectificationCase) {
|
||||
return {
|
||||
...stored.snapshot,
|
||||
state: "candidate" as const,
|
||||
assistantIntent: "present_saved_candidate_range" as const,
|
||||
input: "candidate_actions" as const,
|
||||
confidence: stored.candidateResult?.confidence ?? "low" as const,
|
||||
canApply: false,
|
||||
activeTime: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDynamicSpecialActions(ports: BirthTimeJourneyPorts, load: (
|
||||
userId: string, caseId: string,
|
||||
) => Promise<DynamicStoredRectificationCase>, requireMutable: (
|
||||
stored: DynamicStoredRectificationCase,
|
||||
) => void) {
|
||||
const save = (stored: DynamicStoredRectificationCase, updated: DynamicStoredRectificationCase, actionId: string) => (
|
||||
ports.store.saveDynamicTurn(updated, stored.turnVersion, actionId)
|
||||
);
|
||||
return {
|
||||
async submitUnmatchedContext(userId: string, command: UnmatchedCommand) {
|
||||
const stored = await load(userId, command.caseId);
|
||||
const note = z.string().trim().max(240).parse(command.note);
|
||||
const priorAnswer = stored.choiceAnswers.at(-1);
|
||||
const receipt = stored.dynamicControl.lastActionReceipt;
|
||||
if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => (
|
||||
stored.dynamicTurnState.nextAction.kind === "generate_dynamic_question"
|
||||
&& priorAnswer?.kind === "unmatched" && priorAnswer.questionId === command.questionId
|
||||
&& priorAnswer.unmatchedContext === note
|
||||
&& receipt?.actionId === command.actionId.toLowerCase()
|
||||
&& receipt.kind === "unmatched_context" && receipt.turnVersion === command.turnVersion
|
||||
&& receipt.questionId === command.questionId && receipt.note === note
|
||||
))) return storedDynamicJourneyResponse(stored);
|
||||
requireMutable(stored);
|
||||
const question = stored.currentChoiceQuestion;
|
||||
if (stored.turnVersion !== command.turnVersion
|
||||
|| stored.dynamicTurnState.nextAction.kind !== "clarify_unmatched_answer"
|
||||
|| question?.questionId !== command.questionId
|
||||
|| priorAnswer?.kind !== "unmatched"
|
||||
|| priorAnswer.questionId !== command.questionId) throw stale(stored, command.turnVersion);
|
||||
const updated = withDynamicAction(stored, { kind: "generate_dynamic_question" }, stored.turnVersion + 1);
|
||||
const saved = await save(stored, {
|
||||
...updated,
|
||||
currentChoiceQuestion: null,
|
||||
choiceAnswers: [...stored.choiceAnswers.slice(0, -1), { ...priorAnswer, unmatchedContext: note }],
|
||||
agentContext: note.length === 0 ? stored.agentContext : [...stored.agentContext.slice(-9), note],
|
||||
dynamicControl: {
|
||||
...stored.dynamicControl,
|
||||
dismissedOpportunityIds: [...stored.dynamicControl.dismissedOpportunityIds, question.opportunityId],
|
||||
lastActionReceipt: {
|
||||
actionId: command.actionId.toLowerCase(), kind: "unmatched_context",
|
||||
turnVersion: command.turnVersion, questionId: command.questionId, note,
|
||||
},
|
||||
},
|
||||
}, command.actionId);
|
||||
return storedDynamicJourneyResponse(saved);
|
||||
},
|
||||
|
||||
async pauseDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) {
|
||||
const stored = await load(userId, caseId);
|
||||
const receipt = stored.dynamicControl.lastActionReceipt;
|
||||
if (replayedDynamicAction(stored, actionId, turnVersion, () => (
|
||||
stored.dynamicTurnState.nextAction.kind === "paused"
|
||||
&& stored.dynamicControl.pausedAction !== null
|
||||
&& receipt?.actionId === actionId.toLowerCase()
|
||||
&& receipt.kind === "pause" && receipt.turnVersion === turnVersion
|
||||
))) return storedDynamicJourneyResponse(stored);
|
||||
requireMutable(stored);
|
||||
if (stored.turnVersion !== turnVersion || stored.dynamicTurnState.nextAction.kind === "paused") {
|
||||
throw stale(stored, turnVersion);
|
||||
}
|
||||
const pausedAction = toPausedDynamicAction(stored.dynamicTurnState.nextAction);
|
||||
const updated = withDynamicAction(stored, { kind: "paused" }, stored.turnVersion + 1);
|
||||
const saved = await save(stored, {
|
||||
...updated,
|
||||
dynamicControl: {
|
||||
...stored.dynamicControl,
|
||||
pausedAction,
|
||||
lastActionReceipt: { actionId: actionId.toLowerCase(), kind: "pause", turnVersion },
|
||||
},
|
||||
}, actionId);
|
||||
return storedDynamicJourneyResponse(saved);
|
||||
},
|
||||
|
||||
async finishDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) {
|
||||
const stored = await load(userId, caseId);
|
||||
const receipt = stored.dynamicControl.lastActionReceipt;
|
||||
if (replayedDynamicAction(stored, actionId, turnVersion, () => (
|
||||
(stored.dynamicTurnState.nextAction.kind === "present_low_result"
|
||||
|| stored.dynamicTurnState.nextAction.kind === "present_medium_result")
|
||||
&& receipt?.actionId === actionId.toLowerCase()
|
||||
&& receipt.kind === "finish" && receipt.turnVersion === turnVersion
|
||||
))) return storedDynamicJourneyResponse(stored);
|
||||
requireMutable(stored);
|
||||
if (stored.turnVersion !== turnVersion) throw stale(stored, turnVersion);
|
||||
const candidate = stored.candidateResult;
|
||||
const action = candidate?.confidence === "medium"
|
||||
? { kind: "present_medium_result" as const, resultId: candidate.resultId }
|
||||
: { kind: "present_low_result" as const, resultId: candidate?.resultId ?? null };
|
||||
const updated = withDynamicAction(stored, action, stored.turnVersion + 1);
|
||||
const saved = await save(stored, {
|
||||
...updated,
|
||||
snapshot: terminalSnapshot(stored),
|
||||
currentChoiceQuestion: null,
|
||||
dynamicControl: {
|
||||
...stored.dynamicControl,
|
||||
lastActionReceipt: { actionId: actionId.toLowerCase(), kind: "finish", turnVersion },
|
||||
},
|
||||
}, actionId);
|
||||
return storedDynamicJourneyResponse(saved);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { samePersistedDynamicReceipt } from "./birth-time-dynamic-action-replay.ts";
|
||||
import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts";
|
||||
import {
|
||||
dynamicJourneyTurnStateSchema,
|
||||
@@ -143,8 +144,10 @@ export function createDynamicTurnPersistence(
|
||||
});
|
||||
if (result.error) {
|
||||
const current = await loadCase(value.userId, value.id);
|
||||
if (isStaleRpc(result.error) && current?.processedActionIds?.includes(receipt)) {
|
||||
return loadedDynamic(value.userId, value.id);
|
||||
if (isStaleRpc(result.error) && current?.journeyProtocol === "dynamic-choice-v2"
|
||||
&& current.processedActionIds.includes(receipt)) {
|
||||
if (samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) return current;
|
||||
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
|
||||
}
|
||||
if (isStaleRpc(result.error)) {
|
||||
throw new StaleJourneyTurnError(
|
||||
|
||||
@@ -138,6 +138,7 @@ export function createInitialDynamicState(
|
||||
dismissedOpportunityIds: [],
|
||||
recentRanges: [currentRange],
|
||||
pausedAction: null,
|
||||
lastActionReceipt: null,
|
||||
});
|
||||
const ready = snapshot.state === "ready";
|
||||
return {
|
||||
@@ -201,6 +202,7 @@ export function prepareLegacyDynamicUpgrade(
|
||||
dismissedOpportunityIds: [],
|
||||
recentRanges: [currentRange],
|
||||
pausedAction: null,
|
||||
lastActionReceipt: null,
|
||||
});
|
||||
const dynamicTurnState = dynamicJourneyTurnStateSchema.parse({
|
||||
journeyProtocol: "dynamic-choice-v2",
|
||||
|
||||
+4
-4
@@ -94,14 +94,14 @@ declare
|
||||
v_result_id text;
|
||||
v_updated_id uuid;
|
||||
begin
|
||||
select c.* into v_case from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
and c.journey_protocol = 'dynamic-choice-v2' for update;
|
||||
if not found then raise exception 'birth_time_dynamic_case_not_found'; end if;
|
||||
select j.* into v_job from public.birth_time_rectification_scoring_jobs j
|
||||
where j.id = p_job_id and j.case_id = p_case_id and j.user_id = p_user_id
|
||||
for update;
|
||||
if not found then raise exception 'birth_time_dynamic_scoring_job_not_found'; end if;
|
||||
select c.* into v_case from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
and c.journey_protocol = 'dynamic-choice-v2' for update;
|
||||
if not found then raise exception 'birth_time_dynamic_case_not_found'; end if;
|
||||
if v_job.evidence_fingerprint is distinct from p_evidence_fingerprint then
|
||||
raise exception 'birth_time_dynamic_scoring_fingerprint_mismatch';
|
||||
end if;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||||
import { dynamicCase, ownerId, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts";
|
||||
import { memoryStore } from "./birth-time-journey-memory-store.ts";
|
||||
|
||||
const answerId = "9a921af8-ddcc-4d20-b4c8-fbbb3e6a814d";
|
||||
const actionId = "700406ad-1ca6-437d-9f77-61354ba8e36a";
|
||||
|
||||
function flow() {
|
||||
const memory = memoryStore(dynamicCase());
|
||||
const jobs = dynamicJobStore(memory.store, () => {
|
||||
const current = memory.savedCase();
|
||||
return current?.journeyProtocol === "dynamic-choice-v2" ? current : null;
|
||||
});
|
||||
return createBirthTimeJourneyService({
|
||||
store: jobs.store,
|
||||
engine: {
|
||||
async scan() { throw new Error("unexpected scan"); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("unmatched context replays only the identical action and payload", async () => {
|
||||
const service = flow();
|
||||
const unmatched = persistedQuestion.options.find((option) => option.kind === "unmatched");
|
||||
if (!unmatched) throw new Error("missing unmatched option");
|
||||
const clarification = await service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId: answerId,
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: unmatched.optionId,
|
||||
});
|
||||
const command = {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: clarification.turnVersion,
|
||||
questionId: persistedQuestion.questionId,
|
||||
note: "更像是 2017 年",
|
||||
};
|
||||
const saved = await service.submitUnmatchedContext(ownerId, command);
|
||||
const replay = await service.submitUnmatchedContext(ownerId, command);
|
||||
assert.deepEqual(replay.nextAction, saved.nextAction);
|
||||
assert.equal(replay.turnVersion, saved.turnVersion);
|
||||
await assert.rejects(
|
||||
service.submitUnmatchedContext(ownerId, { ...command, note: "其实是 2018 年" }),
|
||||
StaleJourneyTurnError,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.pauseDynamic(ownerId, dynamicCase().id, actionId, command.turnVersion),
|
||||
StaleJourneyTurnError,
|
||||
);
|
||||
});
|
||||
|
||||
test("pause replays after a lost response but cannot impersonate finish", async () => {
|
||||
const service = flow();
|
||||
const saved = await service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7);
|
||||
const replay = await service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7);
|
||||
assert.deepEqual(replay.nextAction, saved.nextAction);
|
||||
assert.equal(replay.turnVersion, saved.turnVersion);
|
||||
await assert.rejects(
|
||||
service.finishDynamic(ownerId, dynamicCase().id, actionId, 7),
|
||||
StaleJourneyTurnError,
|
||||
);
|
||||
});
|
||||
|
||||
test("finish replays after a lost response but cannot impersonate pause", async () => {
|
||||
const service = flow();
|
||||
const saved = await service.finishDynamic(ownerId, dynamicCase().id, actionId, 7);
|
||||
const replay = await service.finishDynamic(ownerId, dynamicCase().id, actionId, 7);
|
||||
assert.deepEqual(replay.nextAction, saved.nextAction);
|
||||
assert.equal(replay.turnVersion, saved.turnVersion);
|
||||
await assert.rejects(
|
||||
service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7),
|
||||
StaleJourneyTurnError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import {
|
||||
dynamicChoiceScoringAlgorithmVersion,
|
||||
dynamicEvidenceFingerprint,
|
||||
} from "../src/lib/birth-time-scoring-job.ts";
|
||||
import { dynamicCase, ownerId, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts";
|
||||
import { memoryStore } from "./birth-time-journey-memory-store.ts";
|
||||
|
||||
test("dynamic processing claims are reclaimed only after the lease", async () => {
|
||||
const memory = memoryStore(dynamicCase());
|
||||
const jobs = dynamicJobStore(memory.store, () => {
|
||||
const current = memory.savedCase();
|
||||
return current?.journeyProtocol === "dynamic-choice-v2" ? current : null;
|
||||
});
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: jobs.store,
|
||||
now: () => new Date("2026-07-18T08:00:00.000Z"),
|
||||
engine: {
|
||||
async scan() { throw new Error("unexpected scan"); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
},
|
||||
});
|
||||
const pending = await service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId: "9a921af8-ddcc-4d20-b4c8-fbbb3e6a814d",
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: persistedQuestion.options[0].optionId,
|
||||
});
|
||||
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
|
||||
const stored = memory.savedCase();
|
||||
if (!stored || stored.journeyProtocol !== "dynamic-choice-v2") throw new Error("missing v2 case");
|
||||
const claim = jobs.store.claimDynamicScoringJob;
|
||||
if (!claim) throw new Error("missing dynamic claim");
|
||||
const identity = {
|
||||
userId: ownerId,
|
||||
caseId: stored.id,
|
||||
jobId: pending.nextAction.jobId,
|
||||
evidenceFingerprint: dynamicEvidenceFingerprint(stored.choiceEvidence),
|
||||
algorithmVersion: dynamicChoiceScoringAlgorithmVersion,
|
||||
} as const;
|
||||
|
||||
assert.equal((await claim({ ...identity, now: "2026-07-18T08:00:00.000Z" })).kind, "claimed");
|
||||
assert.equal((await claim({ ...identity, now: "2026-07-18T08:00:59.999Z" })).kind, "processing");
|
||||
assert.equal((await claim({ ...identity, now: "2026-07-18T08:01:00.000Z" })).kind, "claimed");
|
||||
});
|
||||
@@ -3,13 +3,14 @@ import type {
|
||||
DynamicStoredRectificationCase,
|
||||
} from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { DynamicScoringJobSpec } from "../src/lib/birth-time-scoring-job.ts";
|
||||
import { BirthTimeScoringJobError } from "../src/lib/birth-time-scoring-job.ts";
|
||||
import { BirthTimeScoringJobError, scoringJobDurationMs, scoringProcessingLeaseMs } from "../src/lib/birth-time-scoring-job.ts";
|
||||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||||
|
||||
type DynamicMemoryJob = DynamicScoringJobSpec & {
|
||||
readonly caseId: string;
|
||||
readonly userId: string;
|
||||
readonly status: "pending" | "processing" | "completed" | "failed";
|
||||
readonly updatedAt: string;
|
||||
};
|
||||
|
||||
export function dynamicJobStore(
|
||||
@@ -32,6 +33,7 @@ export function dynamicJobStore(
|
||||
caseId: value.id,
|
||||
userId: value.userId,
|
||||
status: "pending",
|
||||
updatedAt: new Date(Date.parse(spec.expiresAt) - scoringJobDurationMs).toISOString(),
|
||||
});
|
||||
return base.saveDynamicTurn(value, expectedVersion, receipt);
|
||||
},
|
||||
@@ -48,9 +50,12 @@ export function dynamicJobStore(
|
||||
return { kind: "completed", algorithmVersion: job.algorithmVersion };
|
||||
}
|
||||
if (job.status === "processing") {
|
||||
return { kind: "processing", algorithmVersion: job.algorithmVersion };
|
||||
const leaseEnds = Date.parse(job.updatedAt) + scoringProcessingLeaseMs;
|
||||
if (Date.parse(identity.now) < leaseEnds) {
|
||||
return { kind: "processing", algorithmVersion: job.algorithmVersion };
|
||||
}
|
||||
}
|
||||
jobs.set(job.jobId, { ...job, status: "processing" });
|
||||
jobs.set(job.jobId, { ...job, status: "processing", updatedAt: identity.now });
|
||||
return { kind: "claimed", algorithmVersion: job.algorithmVersion };
|
||||
},
|
||||
async completeDynamicScoringJob(value, command) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { DynamicChoiceScoringResult } from "../src/lib/birth-time-dynamic-choice-internal.ts";
|
||||
import { assertDynamicScoringResult } from "../src/lib/birth-time-dynamic-result-validator.ts";
|
||||
import { BirthTimeScoringJobError } from "../src/lib/birth-time-scoring-job.ts";
|
||||
|
||||
const result: DynamicChoiceScoringResult = {
|
||||
candidate: {
|
||||
resultId: "097b7b4c-60f3-4ed8-b290-64b2084182e7",
|
||||
confidence: "medium",
|
||||
canApply: false,
|
||||
winningSegment: {
|
||||
startTime: "05:10", endTime: "05:12", representativeTime: "05:11", widthMinutes: 3,
|
||||
},
|
||||
eventCount: 3,
|
||||
domainCount: 2,
|
||||
topScore: 10,
|
||||
secondScore: 9,
|
||||
marginPercent: 10,
|
||||
reasons: [],
|
||||
evidence: [],
|
||||
algorithmVersion: "birth-time-choice-scoring-v2",
|
||||
},
|
||||
evidenceMode: "dynamic_choice",
|
||||
effectiveAnswerCount: 3,
|
||||
dimensionCount: 2,
|
||||
};
|
||||
|
||||
const currentRange = { startTime: "05:00", endTime: "06:00" } as const;
|
||||
|
||||
test("medium and high results must satisfy deterministic confidence gates", () => {
|
||||
assert.doesNotThrow(() => assertDynamicScoringResult(result, currentRange));
|
||||
assert.doesNotThrow(() => assertDynamicScoringResult({
|
||||
...result,
|
||||
effectiveAnswerCount: 4,
|
||||
dimensionCount: 3,
|
||||
candidate: {
|
||||
...result.candidate,
|
||||
confidence: "high",
|
||||
canApply: true,
|
||||
eventCount: 4,
|
||||
domainCount: 3,
|
||||
marginPercent: 20,
|
||||
},
|
||||
}, currentRange));
|
||||
assert.throws(() => assertDynamicScoringResult({
|
||||
...result,
|
||||
effectiveAnswerCount: 1,
|
||||
dimensionCount: 1,
|
||||
candidate: {
|
||||
...result.candidate,
|
||||
winningSegment: null,
|
||||
eventCount: 1,
|
||||
domainCount: 1,
|
||||
},
|
||||
}, currentRange), BirthTimeScoringJobError);
|
||||
});
|
||||
|
||||
test("winning segments must be a coherent subset of the persisted range", () => {
|
||||
for (const winningSegment of [
|
||||
{ startTime: "04:59", endTime: "05:01", representativeTime: "05:00", widthMinutes: 3 },
|
||||
{ startTime: "05:10", endTime: "05:12", representativeTime: "05:10", widthMinutes: 3 },
|
||||
{ startTime: "05:10", endTime: "05:12", representativeTime: "05:11", widthMinutes: 2 },
|
||||
] as const) {
|
||||
assert.throws(() => assertDynamicScoringResult({
|
||||
...result,
|
||||
candidate: { ...result.candidate, winningSegment },
|
||||
}, currentRange), BirthTimeScoringJobError);
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-midnight ranges retain chronological segment validation", () => {
|
||||
assert.doesNotThrow(() => assertDynamicScoringResult({
|
||||
...result,
|
||||
candidate: {
|
||||
...result.candidate,
|
||||
winningSegment: {
|
||||
startTime: "23:59", endTime: "00:01", representativeTime: "00:00", widthMinutes: 3,
|
||||
},
|
||||
},
|
||||
}, { startTime: "23:58", endTime: "00:02" }));
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
completeDynamicScoreTransition,
|
||||
} from "../src/lib/birth-time-dynamic-transitions.ts";
|
||||
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
|
||||
import type { ServerChoiceEvidence } from "../src/lib/birth-time-dynamic-choice-internal.ts";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import {
|
||||
dynamicCase,
|
||||
@@ -30,8 +31,12 @@ const lowCandidate: CandidateResult = {
|
||||
|
||||
const actionId = "ab2d936b-5ce7-45d8-a0fb-33f48f960f36";
|
||||
|
||||
function freshDynamicCase(candidateResult: CandidateResult | null = null) {
|
||||
function freshDynamicCase(
|
||||
candidateResult: CandidateResult | null = null,
|
||||
priorEvidence: readonly ServerChoiceEvidence[] = [],
|
||||
) {
|
||||
const stored = dynamicCase();
|
||||
const effectiveAnswerCount = priorEvidence.length;
|
||||
return {
|
||||
...stored,
|
||||
eventContext: {
|
||||
@@ -41,18 +46,19 @@ function freshDynamicCase(candidateResult: CandidateResult | null = null) {
|
||||
tz: 8,
|
||||
},
|
||||
candidateResult,
|
||||
choiceEvidence: priorEvidence,
|
||||
dynamicControl: {
|
||||
...stored.dynamicControl,
|
||||
answeredCount: 0,
|
||||
effectiveAnswerCount: 0,
|
||||
answeredCount: effectiveAnswerCount,
|
||||
effectiveAnswerCount,
|
||||
plateauCount: candidateResult === null ? 0 : 1,
|
||||
},
|
||||
dynamicTurnState: {
|
||||
...stored.dynamicTurnState,
|
||||
progress: {
|
||||
...stored.dynamicTurnState.progress,
|
||||
answeredCount: 0,
|
||||
effectiveAnswerCount: 0,
|
||||
answeredCount: effectiveAnswerCount,
|
||||
effectiveAnswerCount,
|
||||
plateauCount: candidateResult === null ? 0 : 1,
|
||||
},
|
||||
},
|
||||
@@ -62,9 +68,10 @@ function freshDynamicCase(candidateResult: CandidateResult | null = null) {
|
||||
function scoringFlow(input: {
|
||||
readonly candidate?: CandidateResult;
|
||||
readonly initialCandidate?: CandidateResult | null;
|
||||
readonly priorEvidence?: readonly ServerChoiceEvidence[];
|
||||
readonly failOnce?: boolean;
|
||||
} = {}) {
|
||||
const initial = freshDynamicCase(input.initialCandidate ?? null);
|
||||
const initial = freshDynamicCase(input.initialCandidate ?? null, input.priorEvidence);
|
||||
const memory = memoryStore(initial);
|
||||
const jobs = dynamicJobStore(memory.store, () => {
|
||||
const value = memory.savedCase();
|
||||
@@ -88,8 +95,8 @@ function scoringFlow(input: {
|
||||
return {
|
||||
candidate,
|
||||
evidenceMode: "dynamic_choice" as const,
|
||||
effectiveAnswerCount: 1,
|
||||
dimensionCount: 1,
|
||||
effectiveAnswerCount: candidate.eventCount,
|
||||
dimensionCount: candidate.domainCount,
|
||||
};
|
||||
},
|
||||
async buildDifferencePacket(value) {
|
||||
@@ -150,6 +157,9 @@ test("high confidence requires explicit confirmation without applying a time", (
|
||||
representativeTime: "05:11",
|
||||
widthMinutes: 2,
|
||||
},
|
||||
eventCount: 4,
|
||||
domainCount: 3,
|
||||
marginPercent: 20,
|
||||
};
|
||||
const result = completeDynamicScoreTransition({
|
||||
stored: { ...stored, currentChoiceQuestion: null },
|
||||
@@ -210,8 +220,26 @@ test("dynamic scoring failure retries the same job without duplicating evidence"
|
||||
});
|
||||
|
||||
test("the second plateau is terminal and resume stays terminal", async () => {
|
||||
const medium = { ...lowCandidate, confidence: "medium" as const };
|
||||
const flow = scoringFlow({ initialCandidate: medium, candidate: medium });
|
||||
const medium = {
|
||||
...lowCandidate,
|
||||
confidence: "medium" as const,
|
||||
winningSegment: {
|
||||
startTime: "05:10", endTime: "05:12", representativeTime: "05:11", widthMinutes: 3,
|
||||
},
|
||||
eventCount: 3,
|
||||
domainCount: 3,
|
||||
marginPercent: 10,
|
||||
};
|
||||
const priorEvidence = ["education_change", "relationship_change"].map((dimensionCode, index) => ({
|
||||
questionId: `prior-${index}`,
|
||||
opportunityId: `opportunity-${index}`,
|
||||
partitionId: `partition-${index}`,
|
||||
dimensionCode,
|
||||
candidateScores: { "05:10": 1 },
|
||||
informationGain: 0.5,
|
||||
}));
|
||||
const previous = { ...medium, confidence: "low" as const, eventCount: 2, domainCount: 2 };
|
||||
const flow = scoringFlow({ initialCandidate: previous, candidate: medium, priorEvidence });
|
||||
const pending = await flow.service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
|
||||
@@ -67,6 +67,13 @@ def test_dynamic_job_claim_locks_v2_and_validates_completed_replay() -> None:
|
||||
assert "algorithm_version text" in body
|
||||
|
||||
|
||||
def test_dynamic_job_claim_uses_job_then_case_lock_order() -> None:
|
||||
body = _function(_sql(), "claim_birth_time_dynamic_scoring_job")
|
||||
job_lock = body.index("from public.birth_time_rectification_scoring_jobs j")
|
||||
case_lock = body.index("from public.birth_time_rectification_cases c")
|
||||
assert job_lock < case_lock
|
||||
|
||||
|
||||
def test_dynamic_job_functions_are_service_role_only_and_reviewable() -> None:
|
||||
sql = _sql()
|
||||
for name in (
|
||||
|
||||
Reference in New Issue
Block a user