@molecule/api-quiz-grading
v1.0.1
Published
Multi-question-type grading engine: multi-choice, true-false, type-answer, fill-blank, numeric, matching with optional partial credit and speed bonus.
Readme
@molecule/api-quiz-grading
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Pure-function multi-question-type grading engine for molecule.dev.
Supports six question kinds: multi-choice (single + multi-correct,
optional partial credit), true-false, type-answer (exact + fuzzy
Levenshtein with case/whitespace/accent normalisation), fill-blank
(per-blank partial credit), numeric (with tolerance), and
matching pairs (with partial credit). A multiplicative speed bonus
is applied when the caller supplies elapsedMs and the question
defines a timeLimitMs.
No I/O, no DB, no clock reads — every grading decision is a pure
function of (question, answer, options). This keeps it equally
usable from API handlers, AI pipelines, mock-server fixtures, and
frontend test harnesses.
Used by lms, quiz-platform, language-learning, and any other app that needs question-grading.
Quick Start
import { gradeAnswer } from '@molecule/api-quiz-grading'
gradeAnswer(
{
kind: 'type-answer',
payload: { acceptedAnswers: ['Paris'], match: { maxEditDistance: 1 } },
points: 5,
},
'paris',
)
// → { is_correct: true, points_earned: 5, explanation: 'correct' }import { gradeAnswer } from '@molecule/api-quiz-grading'
gradeAnswer(
{
kind: 'multi-choice',
payload: { correctIndices: [0, 2], optionCount: 4, allowPartial: true },
points: 10,
},
[0],
)
// → { is_correct: false, points_earned: 5, explanation: 'partial' }Type
utility
Installation
npm install @molecule/api-quiz-grading zodAPI
Interfaces
AnswerMap
Maps a {@link QuestionKind} to its accepted answer shape.
multi-choice— array of selected indices.true-false— boolean.type-answer— string.fill-blank— string array (one entry per blank, in order).numeric— number.matching—Record<leftId, rightId>.
interface AnswerMap {
'multi-choice': number[]
'true-false': boolean
'type-answer': string
'fill-blank': string[]
numeric: number
matching: Record<string, string>
}FillBlankPayload
Payload for a fill-in-the-blank question (one or more blanks).
interface FillBlankPayload {
/** For each blank in order, the list of accepted answers for that blank. */
blanks: Array<{ acceptedAnswers: string[] }>
/** Normalisation options applied to every blank. */
match?: TextMatchOptions
/**
* When `true`, each correctly-filled blank earns proportional partial
* credit (1/N of the question's points). When `false`, all blanks must
* be correct for any credit. Default: `true`.
*/
allowPartial?: boolean
}FuzzyMatchOptions
Fuzzy-match options for type-answer questions.
When maxEditDistance > 0, the grader accepts answers within the given
Levenshtein edit distance of any accepted answer. When omitted, only
exact (post-normalisation) matches are accepted.
interface FuzzyMatchOptions extends TextMatchOptions {
/** Maximum Levenshtein distance for a fuzzy match. 0 disables fuzzy matching. */
maxEditDistance?: number
}GradedAnswer
Intermediate scoring result emitted by every per-kind grader.
fraction is in [0, 1] and represents the share of points earned
before any speed bonus.
interface GradedAnswer {
/** Whether the answer is fully correct. */
isCorrect: boolean
/** Fraction of base points earned, in `[0, 1]`. */
fraction: number
/** Locale-independent explanation key. */
explanation: string
}GradeOptions
Optional inputs to the grader.
interface GradeOptions {
/**
* Elapsed milliseconds spent on the question. When set together with
* `question.timeLimitMs`, a multiplicative speed bonus is applied to
* earned points using the formula:
*
* `speedFactor = 1 + speedBonusMaxFactor * max(0, 1 - elapsedMs/timeLimitMs)`
*
* Capped so `points_earned <= points * (1 + speedBonusMaxFactor)`.
*/
elapsedMs?: number
/**
* Maximum extra fraction of `points` awarded for instant answers.
* Default: 0.5 (i.e. up to +50% for a zero-elapsed answer).
*/
speedBonusMaxFactor?: number
}GradeResult
Result of grading a single question.
interface GradeResult {
/** `true` when the answer fully satisfies the question (no partial credit). */
is_correct: boolean
/**
* Points awarded. In `[0, points * (1 + speedBonusMaxFactor)]`.
* Partial-credit kinds may report a fractional value strictly between
* 0 and the max.
*/
points_earned: number
/**
* Stable, locale-independent explanation key (e.g. `'correct'`,
* `'incorrect'`, `'partial'`, `'numeric.outOfTolerance'`). Consumers
* who want a localised string should map this key through their own
* i18n layer.
*/
explanation?: string
}MatchingPayload
Payload for a matching-pairs question.
interface MatchingPayload {
/**
* The correct mapping. Keys are left-column ids, values are the
* right-column id that should be paired with them.
*/
pairs: Record<string, string>
/**
* When `true`, each correct pair earns proportional partial credit.
* When `false`, all pairs must be correct. Default: `true`.
*/
allowPartial?: boolean
}MultiChoicePayload
Payload for a multi-choice question.
interface MultiChoicePayload {
/** Indices (0-based) into the option list that are correct. */
correctIndices: number[]
/**
* When `true`, partial credit is awarded for selecting some-but-not-all
* correct options (with a penalty for incorrect selections). Default: `false`.
*/
allowPartial?: boolean
/** Total option count — used to bound partial-credit denominators. */
optionCount: number
}NumericPayload
Payload for a numeric question with optional tolerance.
interface NumericPayload {
/** Expected numeric value. */
correct: number
/** Absolute tolerance — `|submitted - correct| <= tolerance` is correct. Default: 0. */
tolerance?: number
}QuestionPayloadMap
Maps a {@link QuestionKind} to its payload type.
interface QuestionPayloadMap {
'multi-choice': MultiChoicePayload
'true-false': TrueFalsePayload
'type-answer': TypeAnswerPayload
'fill-blank': FillBlankPayload
numeric: NumericPayload
matching: MatchingPayload
}TextMatchOptions
String-comparison normalisation flags used by text-based graders.
All flags default to true (i.e. forgiving comparison) when omitted.
interface TextMatchOptions {
/** Lowercase both sides before comparison. Default: `true`. */
caseInsensitive?: boolean
/** Strip leading/trailing whitespace. Default: `true`. */
trim?: boolean
/** Collapse internal whitespace to a single space. Default: `true`. */
collapseWhitespace?: boolean
/** Strip diacritics (NFD + remove combining marks). Default: `true`. */
accentFold?: boolean
}TrueFalsePayload
Payload for a true-false question.
interface TrueFalsePayload {
/** The correct boolean. */
correct: boolean
}TypeAnswerPayload
Payload for a free-text "type-answer" question.
interface TypeAnswerPayload {
/** All accepted spellings of the correct answer. */
acceptedAnswers: string[]
/** Normalisation + fuzzy-match options. */
match?: FuzzyMatchOptions
}Types
AnswerFor
Per-kind answer type, keyed off the question's kind.
type AnswerFor<Q extends Question> = AnswerMap[Q['kind']]Question
A discriminated-union question shape. Use this as the canonical input type for {@link gradeAnswer}.
type Question = {
[K in QuestionKind]: {
/** Stable identifier, useful for caller-side bookkeeping. */
id?: string
/** Question kind discriminator. */
kind: K
/** Kind-specific payload. */
payload: QuestionPayloadMap[K]
/** Maximum points awardable. Default: 1. */
points?: number
/**
* Time limit in milliseconds. When provided alongside
* {@link GradeOptions.elapsedMs}, the speed-bonus formula is applied.
*/
timeLimitMs?: number
}
}[QuestionKind]QuestionKind
Discriminator for the supported question kinds.
multi-choice— pick one or more correct options from a fixed list.true-false— boolean answer.type-answer— free-text response matched against accepted answers.fill-blank— one or more blanks each with accepted answers.numeric— numeric answer with optional tolerance.matching— match items in column A to items in column B.
type QuestionKind =
'multi-choice' | 'true-false' | 'type-answer' | 'fill-blank' | 'numeric' | 'matching'Functions
computeSpeedFactor(elapsedMs, timeLimitMs, maxFactor)
Compute the multiplicative speed-bonus factor for an elapsed-time
answer. Returns 1 when no time data is provided.
Formula: 1 + maxFactor * max(0, 1 - elapsed/limit) clamped so very
slow answers receive no bonus and over-budget answers also get 1.
function computeSpeedFactor(
elapsedMs: number | undefined,
timeLimitMs: number | undefined,
maxFactor: number,
): numberelapsedMs— Time spent on the question in ms.timeLimitMs— Question's time limit in ms.maxFactor— Maximum extra fraction (e.g.0.5⇒ +50% bonus cap).
Returns: Multiplier in [1, 1 + maxFactor].
editDistance(a, b)
Levenshtein edit distance between two strings.
Used by the type-answer grader for fuzzy matching. Implements the
standard two-row dynamic-programming algorithm — O(n*m) time, O(min(n,m))
space.
function editDistance(a: string, b: string): numbera— First string.b— Second string.
Returns: Number of single-character insertions, deletions, or substitutions required to turn a into b.
gradeAnswer(question, answer, options)
Grade a submitted answer against its question.
Pure function: the same input always returns the same output. No I/O,
no clock reads — pass elapsedMs explicitly when you want a speed
bonus.
function gradeAnswer(question: Q, answer: AnswerFor<Q>, options?: GradeOptions): GradeResultquestion— The question + correct-answer payload.answer— The user's submitted answer (shape depends on kind).options— Optional speed-bonus inputs.
Returns: Grade result with is_correct, points_earned, explanation.
gradeFillBlank(payload, answer)
Grade a fill-in-the-blank question.
Each blank is graded independently. With allowPartial (default), the
earned fraction is correctBlanks / totalBlanks. Without it, the
grader returns full credit only when every blank matches.
function gradeFillBlank(payload: FillBlankPayload, answer: string[]): GradedAnswerpayload— Question payload.answer— Submitted strings, one per blank, in order.
Returns: Graded answer.
gradeMatching(payload, answer)
Grade a matching-pairs question.
Each correct pair earns 1/totalPairs. With allowPartial (default),
partial credit is reported. Without it, the grader returns full
credit only when every pair matches.
function gradeMatching(payload: MatchingPayload, answer: Record<string, string>): GradedAnswerpayload— Question payload.answer— Submitted mapping{ leftId: rightId }.
Returns: Graded answer.
gradeMultiChoice(payload, answer)
Grade a multi-choice question.
- Single-correct: the answer must contain exactly the one correct index.
- Multi-correct (no partial): the selected set must equal the correct set.
- Multi-correct (with
allowPartial): each correct selection earns1/|correct|and each incorrect selection deducts1/(optionCount - |correct|), floored at zero. Awarded fraction is in[0, 1].
function gradeMultiChoice(payload: MultiChoicePayload, answer: number[]): GradedAnswerpayload— Question payload.answer— Selected option indices (may contain duplicates / out-of-range — ignored).
Returns: Graded answer.
gradeNumeric(payload, answer)
Grade a numeric question with optional absolute tolerance.
function gradeNumeric(payload: NumericPayload, answer: number): GradedAnswerpayload— Question payload.answer— Submitted number.
Returns: Graded answer.
gradeTrueFalse(payload, answer)
Grade a true/false question.
function gradeTrueFalse(payload: TrueFalsePayload, answer: boolean): GradedAnswerpayload— Question payload.answer— Submitted boolean.
Returns: Graded answer.
gradeTypeAnswer(payload, answer)
Grade a free-text "type-answer" question.
The submitted string is normalised then compared (exactly or with Levenshtein fuzzy match) against every accepted answer.
function gradeTypeAnswer(payload: TypeAnswerPayload, answer: string): GradedAnswerpayload— Question payload.answer— Submitted string.
Returns: Graded answer.
normalizeText(value, options)
Apply the requested normalisation steps to value. All flags default
to true (forgiving comparison).
function normalizeText(value: string, options?: TextMatchOptions): stringvalue— Raw string from the user / question payload.options— Override flags. Omitted flags default totrue.
Returns: The normalised string.
Constants
fillBlankPayloadSchema
Schema for a fill-blank payload.
const fillBlankPayloadSchema: z.ZodObject<
{
blanks: z.ZodArray<z.ZodObject<{ acceptedAnswers: z.ZodArray<z.ZodString> }, z.core.$strict>>
match: z.ZodOptional<
z.ZodObject<
{
caseInsensitive: z.ZodOptional<z.ZodBoolean>
trim: z.ZodOptional<z.ZodBoolean>
collapseWhitespace: z.ZodOptional<z.ZodBoolean>
accentFold: z.ZodOptional<z.ZodBoolean>
},
z.core.$strict
>
>
allowPartial: z.ZodOptional<z.ZodBoolean>
},
z.core.$strict
>fuzzyMatchOptionsSchema
Schema for {@link FuzzyMatchOptions}.
const fuzzyMatchOptionsSchema: z.ZodObject<
{
caseInsensitive: z.ZodOptional<z.ZodBoolean>
trim: z.ZodOptional<z.ZodBoolean>
collapseWhitespace: z.ZodOptional<z.ZodBoolean>
accentFold: z.ZodOptional<z.ZodBoolean>
maxEditDistance: z.ZodOptional<z.ZodNumber>
},
z.core.$strict
>gradeOptionsSchema
Schema for {@link GradeOptions}.
const gradeOptionsSchema: z.ZodObject<
{ elapsedMs: z.ZodOptional<z.ZodNumber>; speedBonusMaxFactor: z.ZodOptional<z.ZodNumber> },
z.core.$strict
>gradeResultSchema
Schema for {@link GradeResult}.
const gradeResultSchema: z.ZodObject<
{ is_correct: z.ZodBoolean; points_earned: z.ZodNumber; explanation: z.ZodOptional<z.ZodString> },
z.core.$strict
>matchingPayloadSchema
Schema for a matching payload.
const matchingPayloadSchema: z.ZodObject<
{ pairs: z.ZodRecord<z.ZodString, z.ZodString>; allowPartial: z.ZodOptional<z.ZodBoolean> },
z.core.$strict
>multiChoicePayloadSchema
Schema for a multi-choice payload.
const multiChoicePayloadSchema: z.ZodObject<
{
correctIndices: z.ZodArray<z.ZodNumber>
allowPartial: z.ZodOptional<z.ZodBoolean>
optionCount: z.ZodNumber
},
z.core.$strict
>numericPayloadSchema
Schema for a numeric payload.
const numericPayloadSchema: z.ZodObject<
{ correct: z.ZodNumber; tolerance: z.ZodOptional<z.ZodNumber> },
z.core.$strict
>questionSchema
Discriminated-union schema for a {@link Question}. Use this to validate questions read from untrusted JSON.
const questionSchema: z.ZodDiscriminatedUnion<
[
z.ZodObject<
{
id: z.ZodOptional<z.ZodString>
kind: z.ZodLiteral<'multi-choice'>
payload: z.ZodObject<
{
correctIndices: z.ZodArray<z.ZodNumber>
allowPartial: z.ZodOptional<z.ZodBoolean>
optionCount: z.ZodNumber
},
z.core.$strict
>
points: z.ZodOptional<z.ZodNumber>
timeLimitMs: z.ZodOptional<z.ZodNumber>
},
z.core.$strict
>,
z.ZodObject<
{
id: z.ZodOptional<z.ZodString>
kind: z.ZodLiteral<'true-false'>
payload: z.ZodObject<{ correct: z.ZodBoolean }, z.core.$strict>
points: z.ZodOptional<z.ZodNumber>
timeLimitMs: z.ZodOptional<z.ZodNumber>
},
z.core.$strict
>,
z.ZodObject<
{
id: z.ZodOptional<z.ZodString>
kind: z.ZodLiteral<'type-answer'>
payload: z.ZodObject<
{
acceptedAnswers: z.ZodArray<z.ZodString>
match: z.ZodOptional<
z.ZodObject<
{
caseInsensitive: z.ZodOptional<z.ZodBoolean>
trim: z.ZodOptional<z.ZodBoolean>
collapseWhitespace: z.ZodOptional<z.ZodBoolean>
accentFold: z.ZodOptional<z.ZodBoolean>
maxEditDistance: z.ZodOptional<z.ZodNumber>
},
z.core.$strict
>
>
},
z.core.$strict
>
points: z.ZodOptional<z.ZodNumber>
timeLimitMs: z.ZodOptional<z.ZodNumber>
},
z.core.$strict
>,
z.ZodObject<
{
id: z.ZodOptional<z.ZodString>
kind: z.ZodLiteral<'fill-blank'>
payload: z.ZodObject<
{
blanks: z.ZodArray<
z.ZodObject<{ acceptedAnswers: z.ZodArray<z.ZodString> }, z.core.$strict>
>
match: z.ZodOptional<
z.ZodObject<
{
caseInsensitive: z.ZodOptional<z.ZodBoolean>
trim: z.ZodOptional<z.ZodBoolean>
collapseWhitespace: z.ZodOptional<z.ZodBoolean>
accentFold: z.ZodOptional<z.ZodBoolean>
},
z.core.$strict
>
>
allowPartial: z.ZodOptional<z.ZodBoolean>
},
z.core.$strict
>
points: z.ZodOptional<z.ZodNumber>
timeLimitMs: z.ZodOptional<z.ZodNumber>
},
z.core.$strict
>,
z.ZodObject<
{
id: z.ZodOptional<z.ZodString>
kind: z.ZodLiteral<'numeric'>
payload: z.ZodObject<
{ correct: z.ZodNumber; tolerance: z.ZodOptional<z.ZodNumber> },
z.core.$strict
>
points: z.ZodOptional<z.ZodNumber>
timeLimitMs: z.ZodOptional<z.ZodNumber>
},
z.core.$strict
>,
z.ZodObject<
{
id: z.ZodOptional<z.ZodString>
kind: z.ZodLiteral<'matching'>
payload: z.ZodObject<
{
pairs: z.ZodRecord<z.ZodString, z.ZodString>
allowPartial: z.ZodOptional<z.ZodBoolean>
},
z.core.$strict
>
points: z.ZodOptional<z.ZodNumber>
timeLimitMs: z.ZodOptional<z.ZodNumber>
},
z.core.$strict
>,
],
'kind'
>textMatchOptionsSchema
Schema for {@link TextMatchOptions}.
const textMatchOptionsSchema: z.ZodObject<
{
caseInsensitive: z.ZodOptional<z.ZodBoolean>
trim: z.ZodOptional<z.ZodBoolean>
collapseWhitespace: z.ZodOptional<z.ZodBoolean>
accentFold: z.ZodOptional<z.ZodBoolean>
},
z.core.$strict
>trueFalsePayloadSchema
Schema for a true-false payload.
const trueFalsePayloadSchema: z.ZodObject<{ correct: z.ZodBoolean }, z.core.$strict>typeAnswerPayloadSchema
Schema for a type-answer payload.
const typeAnswerPayloadSchema: z.ZodObject<
{
acceptedAnswers: z.ZodArray<z.ZodString>
match: z.ZodOptional<
z.ZodObject<
{
caseInsensitive: z.ZodOptional<z.ZodBoolean>
trim: z.ZodOptional<z.ZodBoolean>
collapseWhitespace: z.ZodOptional<z.ZodBoolean>
accentFold: z.ZodOptional<z.ZodBoolean>
maxEditDistance: z.ZodOptional<z.ZodNumber>
},
z.core.$strict
>
>
},
z.core.$strict
>Injection Notes
Runtime Dependencies
zod
