Files
Jyotisha/frontend/src/lib/birth-time-dynamic-transitions.ts
T
2026-07-19 14:48:02 +08:00

216 lines
7.8 KiB
TypeScript

import { withCandidateResult } from "./birth-time-evidence.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
import { decideDynamicStop } from "./birth-time-dynamic-stop-policy.ts";
import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts";
import type {
PausedDynamicAction,
PersistedDynamicChoiceQuestion,
} from "./birth-time-dynamic-choice-internal.ts";
import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts";
import type { DynamicStoredRectificationCase } from "./birth-time-journey-service.ts";
import type { TimeRange } from "./birth-time-dynamic-choice.ts";
const terminalKinds = new Set<DynamicNextAction["kind"]>([
"present_low_result",
"present_medium_result",
"request_candidate_confirmation",
"ready",
]);
export class DynamicPauseActionError extends Error {
readonly code = "invalid_dynamic_pause_action";
constructor(kind: DynamicNextAction["kind"]) {
super(`Cannot pause dynamic action ${kind}`);
this.name = "DynamicPauseActionError";
}
}
export function isDynamicTerminal(value: DynamicStoredRectificationCase): boolean {
return terminalKinds.has(value.dynamicTurnState.nextAction.kind);
}
export function publicQuestionAction(question: PersistedDynamicChoiceQuestion): DynamicNextAction {
return { kind: "ask_dynamic_choice", question: toPublicDynamicChoiceQuestion(question) };
}
export function toPausedDynamicAction(action: DynamicNextAction): PausedDynamicAction {
switch (action.kind) {
case "ask_dynamic_choice":
return { kind: action.kind, questionId: action.question.questionId };
case "generate_dynamic_question":
case "clarify_unmatched_answer":
case "retry_question_generation":
case "score_pending":
case "retry_scoring":
return action;
case "present_low_result":
case "present_medium_result":
case "request_candidate_confirmation":
case "ready":
case "paused":
throw new DynamicPauseActionError(action.kind);
}
}
export function progressPhase(action: DynamicNextAction): DynamicStoredRectificationCase["dynamicTurnState"]["progress"]["phase"] {
switch (action.kind) {
case "generate_dynamic_question":
case "ask_dynamic_choice":
case "retry_question_generation":
return "question";
case "clarify_unmatched_answer":
return "clarification";
case "score_pending":
case "retry_scoring":
return "scoring";
case "present_low_result":
case "present_medium_result":
case "request_candidate_confirmation":
return "result";
case "ready":
return "ready";
case "paused":
return "paused";
}
}
export function withDynamicAction(
stored: DynamicStoredRectificationCase,
action: DynamicNextAction,
nextVersion: number,
): DynamicStoredRectificationCase {
return {
...stored,
dynamicTurnState: {
...stored.dynamicTurnState,
turnVersion: nextVersion,
nextAction: action,
progress: { ...stored.dynamicTurnState.progress, phase: progressPhase(action) },
},
};
}
export function answerTransition(input: {
readonly stored: DynamicStoredRectificationCase;
readonly option: PersistedDynamicChoiceQuestion["options"][number];
readonly answeredAt: string;
readonly jobId: string;
readonly nextVersion: number;
}): DynamicStoredRectificationCase {
const { stored, option } = input;
const question = stored.currentChoiceQuestion;
if (question === null) return stored;
const answeredCount = stored.dynamicControl.answeredCount + 1;
const effective = option.kind === "primary";
const effectiveAnswerCount = stored.dynamicControl.effectiveAnswerCount + (effective ? 1 : 0);
const answer = {
questionId: question.questionId,
optionId: option.optionId,
kind: option.kind,
opportunityId: question.opportunityId,
answeredAt: input.answeredAt,
};
const evidence = option.kind === "primary" ? [{
questionId: question.questionId,
opportunityId: question.opportunityId,
partitionId: option.partitionId,
dimensionCode: question.dimensionCode,
candidateScores: option.candidateScores,
informationGain: question.estimatedInformationGain,
}] : [];
const nextAction: DynamicNextAction = option.kind === "primary"
? { kind: "score_pending", jobId: input.jobId }
: option.kind === "unknown"
? { kind: "generate_dynamic_question" }
: { kind: "clarify_unmatched_answer", questionId: question.questionId };
const cleared = option.kind === "unmatched" ? question : null;
const dismissed = option.kind === "primary" || option.kind === "unmatched"
? stored.dynamicControl.dismissedOpportunityIds
: [...stored.dynamicControl.dismissedOpportunityIds, question.opportunityId];
const updated = withDynamicAction(stored, nextAction, input.nextVersion);
return {
...updated,
currentChoiceQuestion: cleared,
choiceAnswers: [...stored.choiceAnswers, answer],
choiceEvidence: [...stored.choiceEvidence, ...evidence],
dynamicControl: {
...stored.dynamicControl,
answeredCount,
effectiveAnswerCount,
dismissedOpportunityIds: dismissed,
},
dynamicTurnState: {
...updated.dynamicTurnState,
progress: {
...updated.dynamicTurnState.progress,
answeredCount,
effectiveAnswerCount,
},
},
};
}
export function completeDynamicScoreTransition(input: {
readonly stored: DynamicStoredRectificationCase;
readonly candidate: CandidateResult;
readonly usefulOpportunityCount: number;
readonly repeatedOnly: boolean;
readonly nextVersion: number;
readonly candidateModel?: Readonly<Record<string, unknown>>;
readonly continuationRange?: TimeRange;
}): DynamicStoredRectificationCase {
const stored = input.stored;
const decision = decideDynamicStop({
result: input.candidate,
effectiveAnswer: true,
previousResult: stored.candidateResult ?? null,
priorPlateauCount: stored.dynamicControl.plateauCount,
usefulOpportunityCount: input.usefulOpportunityCount,
repeatedOnly: input.repeatedOnly,
effectiveAnswerCount: stored.dynamicControl.effectiveAnswerCount,
forcedReason: null,
});
const action: DynamicNextAction = input.candidate.confidence === "high"
? { kind: "request_candidate_confirmation", resultId: input.candidate.resultId }
: decision.kind === "continue"
? { kind: "generate_dynamic_question" }
: input.candidate.confidence === "medium"
? { kind: "present_medium_result", resultId: input.candidate.resultId }
: { kind: "present_low_result", resultId: input.candidate.resultId };
const priorRange = stored.dynamicTurnState.progress.currentRange;
const segment = input.candidate.winningSegment;
const candidateRange = segment === null
? priorRange
: { startTime: segment.startTime, endTime: segment.endTime };
const currentRange = decision.kind === "continue"
? input.continuationRange ?? candidateRange
: candidateRange;
const rangeChanged = currentRange.startTime !== priorRange.startTime
|| currentRange.endTime !== priorRange.endTime;
const previousRange = rangeChanged
? priorRange
: stored.dynamicTurnState.progress.previousRange;
const updated = withDynamicAction(stored, action, input.nextVersion);
return {
...updated,
snapshot: withCandidateResult(stored.snapshot, input.candidate),
candidateResult: input.candidate,
candidateModel: input.candidateModel ?? stored.candidateModel,
dynamicControl: {
...stored.dynamicControl,
plateauCount: decision.plateauCount,
recentRanges: [...stored.dynamicControl.recentRanges, candidateRange],
},
dynamicTurnState: {
...updated.dynamicTurnState,
progress: {
...updated.dynamicTurnState.progress,
currentRange,
previousRange,
plateauCount: decision.plateauCount,
},
permissions: { canConfirmCandidate: input.candidate.confidence === "high" },
},
};
}