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-05-15T01:23:45.072Z",
"updatedAt": "2026-05-15T01:23:45.072Z",
"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 type QuizEngineLastOutcome = QuizEngineState['lastOutcome'];
export type QuizEngineSnapshot<TQuestion extends QuizEngineQuestion = QuizEngineQuestion> = Pick<
QuizEngineState<TQuestion>,
| 'phase'
| 'route'
| 'currentConcept'
| 'currentQuestionId'
| 'stageIndex'
| 'stageCount'
| 'stagedAnswers'
| 'supportCount'
| 'recoveryCount'
| 'completedQuestionIds'
| 'lastOutcome'
| 'complete'
>;
const QUIZ_ENGINE_PHASES = new Set<QuizEnginePhase>([
'routing',
'question',
'staged-answer',
'support',
'recovery',
'complete',
]);
const QUIZ_ENGINE_LAST_OUTCOMES = new Set<QuizEngineLastOutcome>([
'idle',
'routed',
'answered',
'supported',
'recovered',
'completed',
'reset',
]);
function readNonNegativeInteger(value: unknown, fallback: number): number {
return Number.isInteger(value) && Number(value) >= 0 ? Number(value) : fallback;
}
function readPositiveInteger(value: unknown, fallback: number): number {
return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
}
function readStringList(value: unknown, fallback: readonly string[] = []): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string')
: [...fallback];
}
function readNullableString(value: unknown, fallback: string | null = null): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : fallback;
}
function readQuizEnginePhase(value: unknown, fallback: QuizEnginePhase): QuizEnginePhase {
return typeof value === 'string' && QUIZ_ENGINE_PHASES.has(value as QuizEnginePhase)
? value as QuizEnginePhase
: fallback;
}
function readLastOutcome(value: unknown, fallback: QuizEngineLastOutcome): QuizEngineLastOutcome {
return typeof value === 'string' && QUIZ_ENGINE_LAST_OUTCOMES.has(value as QuizEngineLastOutcome)
? value as QuizEngineLastOutcome
: fallback;
}
export function createQuizEngineSnapshot<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
state: QuizEngineState<TQuestion>
): QuizEngineSnapshot<TQuestion> {
return {
phase: state.phase,
route: state.route,
currentConcept: state.currentConcept,
currentQuestionId: state.currentQuestionId,
stageIndex: state.stageIndex,
stageCount: state.stageCount,
stagedAnswers: [...state.stagedAnswers],
supportCount: state.supportCount,
recoveryCount: state.recoveryCount,
completedQuestionIds: [...state.completedQuestionIds],
lastOutcome: state.lastOutcome,
complete: state.complete,
};
}
export function restoreQuizEngineState<TQuestion extends QuizEngineQuestion = QuizEngineQuestion>(
snapshot: Partial<QuizEngineSnapshot<TQuestion>> | null | undefined,
options: {
question?: TQuestion | null;
fallback?: Partial<QuizEngineState<TQuestion>>;
route?: string | null;
} = {}
): QuizEngineState<TQuestion> {
const fallback = createQuizEngineState<TQuestion>(options.fallback ?? {});
const question = options.question ?? fallback.currentQuestion ?? null;
const fallbackStageCount = readStageCount(question, readPositiveInteger(fallback.stageCount, 1));
const stageCount = readPositiveInteger(snapshot?.stageCount, fallbackStageCount);
const maxStageIndex = Math.max(stageCount - 1, 0);
const fallbackStageIndex = readNonNegativeInteger(fallback.stageIndex, 0);
const stageIndex = Math.min(readNonNegativeInteger(snapshot?.stageIndex, fallbackStageIndex), maxStageIndex);
const stagedAnswers = readStringList(snapshot?.stagedAnswers, fallback.stagedAnswers);
const complete = typeof snapshot?.complete === 'boolean' ? snapshot.complete : fallback.complete;
const fallbackPhase = deriveQuizEnginePhase({
question,
stageIndex,
stagedAnswers,
supportActive: fallback.phase === 'support',
recoveryActive: fallback.phase === 'recovery',
complete,
});
const route = 'route' in options
? readNullableString(options.route, null)
: readNullableString(snapshot?.route, fallback.route);
return {
...fallback,
phase: complete ? 'complete' : readQuizEnginePhase(snapshot?.phase, fallbackPhase),
route,
currentConcept: question?.concept ?? readNullableString(snapshot?.currentConcept, fallback.currentConcept),
currentQuestionId: question?.id ?? readNullableString(snapshot?.currentQuestionId, fallback.currentQuestionId),
currentQuestion: question,
stageIndex,
stageCount,
stagedAnswers,
supportCount: readNonNegativeInteger(snapshot?.supportCount, readNonNegativeInteger(fallback.supportCount, 0)),
recoveryCount: readNonNegativeInteger(snapshot?.recoveryCount, readNonNegativeInteger(fallback.recoveryCount, 0)),
completedQuestionIds: Array.from(new Set(readStringList(snapshot?.completedQuestionIds, fallback.completedQuestionIds))),
lastOutcome: readLastOutcome(snapshot?.lastOutcome, fallback.lastOutcome),
complete,
};
}
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,
};
}