Feature detail
Workflow quiz engine / session / debug
The quiz engine runs a turn through six phases; session and debug helpers let consumer shells persist and force routes deterministically.
Worked example
Session + debug contract
Workflow helpers keep storage keys and deterministic debug routes stable across consumer apps.
Input
{
"identity": {
"sessionId": "demo-session",
"learnerId": "learner@example.edu",
"anonymousId": null
},
"namespace": "stats-exam-prep"
}Output
{
"storageKey": "stats-exam-prep:demo-session:learner@example.edu",
"snapshot": {
"version": 1,
"sessionId": "demo-session",
"learnerId": "learner@example.edu",
"anonymousId": null,
"createdAt": "2026-04-23T00:15:24.336Z",
"updatedAt": "2026-04-23T00:15:24.336Z",
"route": "/quiz?exam=exam3",
"currentConceptId": "factoring",
"currentQuestionId": "q-1",
"complete": false,
"state": {
"stage": "recognize",
"seen": 1
},
"metadata": {}
},
"wfRoute": "/quiz?wf=1&route=support&concept=factoring&question=q-1&learner=learner%40example.edu"
}Real source excerpt
Quiz engine phases and transitions
export type QuizEnginePhase =
| 'routing'
| 'question'
| 'staged-answer'
| 'support'
| 'recovery'
| 'complete';
export interface QuizEngineQuestion {
id: string;
concept: string;
type: string;
stageCount?: number;
supportsRecovery?: boolean;
}
export interface QuizEngineConfig<TQuestion extends QuizEngineQuestion = QuizEngineQuestion> {
questions: readonly TQuestion[];
defaultStageCount?: number;
allowSupport?: boolean;
allowRecovery?: boolean;
routeQuestionId?: (route: string) => string | null;
stageCountForQuestion?: (question: TQuestion) => number | undefined;
}
export interface QuizEngineState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion> {
phase: QuizEnginePhase;
route: string | null;
currentConcept: string | null;
currentQuestionId: string | null;
currentQuestion: TQuestion | null;
stageIndex: number;
stageCount: number;
stagedAnswers: string[];
supportCount: number;
recoveryCount: number;
completedQuestionIds: string[];
lastOutcome: 'idle' | 'routed' | 'answered' | 'supported' | 'recovered' | 'completed' | 'reset';
complete: boolean;
}
export interface QuizEngineAction<TQuestion extends QuizEngineQuestion = QuizEngineQuestion> {
type:
| 'route'
| 'select-question'
| 'sync-question-state'
| 'advance-stage'
| 'support'
| 'recovery'
| 'complete'
| 'reset';
route?: string;
question?: TQuestion;
questionId?: string;
conceptId?: string;
answer?: string;
stageCount?: number;
stageIndex?: number;
completedQuestionId?: string;
currentQuestion?: TQuestion | null;
stageAnswers?: readonly string[];
complete?: boolean;
supportActive?: boolean;
recoveryActive?: boolean;
outcome?: QuizEngineState<TQuestion>['lastOutcome'];
}
function readStageCount(question: QuizEngineQuestion | null, fallback = 1): number {
const stageCount = question?.stageCount ?? fallback;
return Number.isInteger(stageCount) && stageCount > 0 ? stageCount : fallback;
}
function deriveQuizEnginePhase(options: {
question: QuizEngineQuestion | null;
stageIndex: number;
stagedAnswers: readonly string[];
supportActive?: boolean;
recoveryActive?: boolean;
complete?: boolean;
}): QuizEnginePhase {
if (options.complete) return 'complete';
if (options.recoveryActive) return 'recovery';
if (options.supportActive) return 'support';
if (!options.question) return 'routing';
if (options.stageIndex > 0 || options.stagedAnswers.length > 0) return 'staged-answer';
return 'question';
}
export function createQuizEngineState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
snapshot: Partial<QuizEngineState<TQuestion>> = {}
): QuizEngineState<TQuestion> {
return {
phase: snapshot.phase ?? 'routing',
route: snapshot.route ?? null,
currentConcept: snapshot.currentConcept ?? null,
currentQuestionId: snapshot.currentQuestionId ?? null,
currentQuestion: snapshot.currentQuestion ?? null,
stageIndex: snapshot.stageIndex ?? 0,
stageCount: snapshot.stageCount ?? readStageCount(snapshot.currentQuestion ?? null, 1),
stagedAnswers: [...(snapshot.stagedAnswers ?? [])],
supportCount: snapshot.supportCount ?? 0,
recoveryCount: snapshot.recoveryCount ?? 0,
completedQuestionIds: [...(snapshot.completedQuestionIds ?? [])],
lastOutcome: snapshot.lastOutcome ?? 'idle',
complete: snapshot.complete ?? false,
};
}
export function routeQuizEngine<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
state: QuizEngineState<TQuestion>,
route: string,
config: Pick<QuizEngineConfig<TQuestion>, 'routeQuestionId' | 'questions' | 'stageCountForQuestion'> = {
questions: [],
}
): QuizEngineState<TQuestion> {
const currentQuestionId = config.routeQuestionId?.(route) ?? null;
const currentQuestion = config.questions.find(question => question.id === currentQuestionId) ?? null;
return {
...state,
phase: 'routing',
route,
currentQuestionId,
currentConcept: currentQuestion?.concept ?? null,
currentQuestion,
stageIndex: 0,
stageCount: config.stageCountForQuestion?.(currentQuestion as TQuestion) ?? readStageCount(currentQuestion, 1),
stagedAnswers: [],
lastOutcome: 'routed',
complete: false,
};
}
export function selectQuizQuestion<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
state: QuizEngineState<TQuestion>,
question: TQuestion,
config: Pick<QuizEngineConfig<TQuestion>, 'stageCountForQuestion' | 'defaultStageCount'> = {}
): QuizEngineState<TQuestion> {
return {
...state,
phase: 'question',
currentQuestionId: question.id,
currentConcept: question.concept,
currentQuestion: question,
stageIndex: 0,
stageCount:
config.stageCountForQuestion?.(question)
?? question.stageCount
?? config.defaultStageCount
?? 1,
stagedAnswers: [],
lastOutcome: 'routed',
complete: false,
};
}
export function advanceQuizStage<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
state: QuizEngineState<TQuestion>,
answer: string,
options: {
stageIndex?: number;
stageCount?: number;
completeWhenLastStage?: boolean;
} = {}
): QuizEngineState<TQuestion> {
const nextStageIndex = options.stageIndex ?? state.stageIndex + 1;
const stageCount = options.stageCount ?? state.stageCount;
const stagedAnswers = [...state.stagedAnswers, answer];
const isFinalStage = nextStageIndex >= Math.max(stageCount - 1, 0);
return {
...state,
phase: isFinalStage ? 'question' : 'staged-answer',
stageIndex: nextStageIndex,
stageCount,
stagedAnswers,
lastOutcome: 'answered',
complete: options.completeWhenLastStage ? isFinalStage : state.complete,
};
}
export function syncQuizEngineQuestionState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
state: QuizEngineState<TQuestion>,
options: {
route?: string | null;
question?: TQuestion | null;
stageIndex?: number;
stageCount?: number;
stagedAnswers?: readonly string[];
supportActive?: boolean;
recoveryActive?: boolean;
complete?: boolean;
outcome?: QuizEngineState<TQuestion>['lastOutcome'];
completedQuestionId?: string | null;
} = {}
): QuizEngineState<TQuestion> {
const question = options.question ?? state.currentQuestion ?? null;
const stageIndex = options.stageIndex ?? state.stageIndex;
const stageCount = options.stageCount ?? readStageCount(question, state.stageCount || 1);
const stagedAnswers = [...(options.stagedAnswers ?? state.stagedAnswers)];
const completedQuestionId = options.completedQuestionId ?? (options.complete ? question?.id ?? null : null);
const completedQuestionIds = completedQuestionId
? Array.from(new Set([...state.completedQuestionIds, completedQuestionId]))
: [...state.completedQuestionIds];
return {
...state,
route: options.route ?? state.route,
currentQuestion: question,
currentQuestionId: question?.id ?? null,
currentConcept: question?.concept ?? null,
stageIndex,
stageCount,
stagedAnswers,
phase: deriveQuizEnginePhase({
question,
stageIndex,
stagedAnswers,
supportActive: options.supportActive,
recoveryActive: options.recoveryActive,
complete: options.complete,
}),
completedQuestionIds,
lastOutcome: options.outcome ?? state.lastOutcome,
complete: options.complete ?? false,
};
}
export function enterSupportState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
state: QuizEngineState<TQuestion>
): QuizEngineState<TQuestion> {
return {
...state,
phase: 'support',
supportCount: state.supportCount + 1,
lastOutcome: 'supported',
};
}
export function enterRecoveryState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
state: QuizEngineState<TQuestion>
): QuizEngineState<TQuestion> {
return {
...state,
phase: 'recovery',
recoveryCount: state.recoveryCount + 1,
lastOutcome: 'recovered',
};
}
export function completeQuizQuestion<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
state: QuizEngineState<TQuestion>,
completedQuestionId: string = state.currentQuestionId ?? ''
): QuizEngineState<TQuestion> {
const completedQuestionIds = completedQuestionId
? Array.from(new Set([...state.completedQuestionIds, completedQuestionId]))
: [...state.completedQuestionIds];
return {
...state,
phase: 'complete',
completedQuestionIds,
lastOutcome: 'completed',
complete: true,
};
}