Build a Quiz Answer Checker: Vetted Aliases or Human Review?
Build a Quiz Answer Checker: Vetted Aliases or Human Review?
A player types "Cafe du Port." The canonical answer is "Café du Port." A plain equality check returns false, the label turns red, and the format has just told someone they were wrong when they weren't.
The reflex is to reach for fuzzy matching. That reflex is the problem, because it hands a string-similarity score the authority to decide something no string-similarity score can decide: what the game accepts as an answer. Before you write the matcher, write the policy the matcher is supposed to enforce. Then the matcher becomes small, inspectable, and boring, which is what you want.
There are only three honest routes, and they cost different things. Restrict what players may submit. Accept a bounded, vetted set of equivalents. Or take open text and fund a human who decides. Everything below is about choosing among those deliberately instead of pretending a threshold discovered correctness.
Give each question an answer policy the code can inspect
A question owns a policy record, not a regex buried in a handler. At minimum: a prompt version, the canonical answer, the declared language policy, the alias set with provenance for each entry, the comparison steps in order, the rejection rule, who has review authority, what a finalized verdict does to the score, and a policy version number.
The version number is not bookkeeping. A verdict is only interpretable next to the rule that produced it, and rules change. If you bump the alias set mid-season, you need to be able to say that a stored "wrong" came from policy v3, not the current one.
Then separate four states that are tempting to collapse:
- Accepted, with a reason that names the exact rule that fired (
canonical, oralias:A-02) and the policy version. - Unmatched: no rule produced an acceptance. This is not a verdict. It is the absence of one.
- Pending review: an unresolved response waiting on a human decision.
- Incorrect: a conclusion. Either a declared rejection rule (closed input, submission outside the permitted set) or a person.
"Unmatched" is not "incorrect." Under open response, an unmatched string is a question, not an answer. Under a declared closed-input rule, some unmatched strings are rejected by a rule you wrote down, and that rejection is worth recording with its reason, because a player who types outside the permitted set has learned something different about the format than a player who typed a genuinely wrong answer.
One warning that has nothing to do with code: the checker demonstrates a policy its owner supplied. It does not verify the fact. If the canonical answer is wrong, this system will mark good answers incorrect with perfect confidence and a clean audit trail. Its authority ends exactly at the policy boundary.
Normalize representations without erasing distinctions
Unicode normalization is character-string reconciliation. A September 2026 check of UAX #15 (Sections 1.1–1.2, https://unicode.org/reports/tr15/) supports two claims that matter here and nothing more: NFC reconciles canonical representations, and compatibility normalization can remove distinctions a textual context cares about. Neither operation tells you whether an answer is right. That is your call, made in the policy record and signed by the person who owns the answers.
The canonical case is easy to miss and easy to fix. "é" can be one code point (U+00E9) or "e" followed by a combining acute accent (U+0301). They are canonically equivalent, byte-for-byte different, and both arrive from real keyboards depending on platform and input method. NFC maps them to the same composed string. Apply it to the alias list and to the submission, always both, or you build an asymmetry bug where an alias stored in composed form never matches a submission normalized to decomposed form.
Compatibility is where the damage happens. NFKC and NFKD fold away distinctions that a quiz may need: superscript digits fold to plain digits, the fi ligature expands to two letters, the micro sign folds to Greek mu, and the non-breaking space folds to an ordinary space. That last one is genuinely useful. The first one is a disaster for a question about notation, because a canonical answer of "10⁶" and a submission of "106" become the same string. A checker running NFKC would accept the wrong answer and log a confident, well-attributed match. A checker running NFC keeps them apart.
The consequence: do not reach for NFKC as a cleanup step. Declare a short fold list instead, one entry at a time, each with a reason someone can argue with. In the café example that list might be: NFC; fold non-breaking space to space; collapse runs of internal whitespace to one; trim both ends. That is four decisions, all inspectable, and it stops well short of superscript folding. Adopting one compatibility mapping deliberately is not the same act as switching compatibility normalization on.
Case deserves the same treatment. Case folding is not a normalization form, and it is not locale-neutral: lowercasing "I" under a Turkish locale gives a dotless ı, and a small set of letters have locale-specific mappings. If the policy declares a case fold, it should name the language environment. Order also matters, because a case fold can introduce a combining mark that your earlier normalization pass will never revisit. Fold-then-normalize, or normalize at both ends, but do it identically on both sides.
Accent removal is not normalization either. It is a semantic declaration that "cafe" and "café" are the same answer. For a venue-name round that is probably right. For a spelling round it is exactly wrong. If you strip accents, strip them from the alias list too, and write down the language policy that made it acceptable.
Units and qualifiers go in the same ledger. Is "10 cm" the same as "10cm"? Is "about 10" acceptable when the answer is "10"? Those are answer-policy questions, not string questions, and the place to settle them is the policy record, not a cleanup chain that silently accumulates.
Finally, store the submitted text. Keep the raw string beside the normalized comparison value, keep the list of steps that ran, and keep the reason the match was accepted. Never overwrite what the player typed with what your pipeline made of it. When a player disputes a result, the raw string is the only evidence you have.
Separate automated matching from adjudication
Once the policy exists, the decision path is small.
decide(submission, policy):
n = applyDeclaredFolds(policy, submission.raw)
if n == policy.canonicalNormalized:
return Accepted(reason="canonical", version=policy.version)
a = policy.aliases.find(x => x.normalized == n)
if a:
return Accepted(reason="alias:" + a.id, version=policy.version)
if policy.onUnmatched == "reject":
return Incorrect(reason="closed-input:" + policy.rejectionRule,
version=policy.version)
return Pending(reason="no rule produced a verdict", version=policy.version)
That is a design sketch of the policy and the branch, not output from anything running. The smaller checker this piece inherits from an earlier plan was never built or executed here, and the code above should be read as a specification someone still has to implement and run against the fixtures in the next section.
Two properties in that path matter more than its brevity. First, every acceptance is attributable: it names the canonical rule or a specific alias, plus the policy version. An acceptance that cannot say why it happened is indistinguishable from a bug that got lucky. Second, similarity scores do not appear. If you keep a similarity metric at all, its proper job is ordering the review queue, surfacing near-misses to the person who has to decide. It should never produce a verdict, and it should never emit a score event because a threshold was crossed.
Pending is not a verdict, and that has a serialization consequence. Do not store it as correct: false. Do not let it block the demo forever and then quietly expire to incorrect on a timeout. If a timeout resolves a pending answer, that is a rule, and someone has to own it.
Compare the interruption each route creates
The three routes differ less in accuracy than in what they interrupt.
Restricted choices. Input is constrained, matching is trivial, and the question silently changes shape. Recall becomes recognition. For a format where you want reliable, fast, unstaffed play, this is often the right trade, but it is a different game than the one you may think you are designing.
A bounded alias set. The player types freely; the format accepts a versioned list of equivalents, each with provenance. "The English map legend prints it without the accent" is a reason a person can audit. This preserves some flexibility and costs nothing at runtime. What it cannot do is cover every legitimate response, because "bounded" means there are legitimate answers outside it. The honest version of this route accepts that some correct answers land in the review queue.
Open text with funded review. The response space stays wide, and you pay for it in pauses, staffing, and the awkward question of override authority. Someone has to be allowed to say "I'll accept that," that authority has to be recorded, and there needs to be a rule for what happens when a reviewer's decision is challenged. A second reviewer for high-stakes items is a policy, not a courtesy.
The failure mode common to all three is hiding the interruption. If the host reads a pending answer and the graphics spin for forty seconds, that is a different show than one where the answer is decided in two. Choose the latency, budget the staffing, and name the overrides. Review time is a format decision, not a coding defect to be smoothed away with a more permissive threshold.
Test verdicts before connecting the score display
Fixtures come before the scoreboard, and they should include the ugly cases: a canonical equivalent, a deliberate alias, a near-spelling that names something else, a compatibility collision, and an answer the policy has not yet covered.
The fictional round below uses a map question: Name the waterfront café on the north quay, canonical answer "Café du Port," with one vetted alias "Cafe du Port" (the English map legend omits the accent). "Café du Pont" is the bridge café in the same fictional map and is a different venue. The second question uses a capacity figure whose canonical answer is "10⁶," with "10^6" and "1,000,000" as vetted aliases.
| Fixture | Submitted | Expected state | Expected reason | Score |
|---|---|---|---|---|
| F1 composed | Café du Port |
Accepted | canonical | +1 |
| F2 decomposed | Cafe\u0301 du Port |
Accepted | canonical | +1 |
| F3 vetted alias | Cafe du Port |
Accepted | alias:A-02 | +1 |
| F4 near-spelling | Café du Pont |
Pending → Incorrect | no rule matched; reviewer override | 0 |
| F5 compatibility collision | 106 vs canonical 10⁶ |
Pending | no rule matched | — |
| F6 vetted numeric alias | 1,000,000 |
Accepted | alias:A-11 | +1 |
| F7 unresolved phrasing | the one by the bridge |
Pending | no rule matched | — |
| F8 repeated submission, same submission id | Cafe du Port twice |
Accepted once | alias:A-02 | +1 total |
| F9 resolve twice | finalize F4, then finalize again | Incorrect once | override recorded once | 0 total |
These are the policy's declared expectations, not observed output. Nobody has run them. A dash in the Score column is not a zero. F5 and F7 are unresolved, so no score event exists for them yet, and putting a number there — even the number a wrong answer would earn — would make an undecided answer look decided. F4 earns its 0 from a reviewer's final call. F8 replays a single submission identifier, the way F9 replays a single finalization, so the key it produces is consumed once. F1 and F2 should produce the identical normalized value; if they don't, your pipeline is asymmetric. F4 sits one character away from the canonical answer, which is the whole point: a similarity threshold would accept it, and it names a different building. F5 is the case NFKC would have merged; under a canonical-only policy it stays unmatched, and the equivalents that are acceptable arrive as aliases instead. That is the thesis of this whole piece in one fixture: representation is normalization's job, meaning is the policy owner's job.
Each fixture should be inspectable down to the same fields: raw submitted text, normalized text, policy version, decision, reason, and the identity of whoever decided, where a person was involved. The scoreboard test is narrower than it sounds. Feed it finalized, attributable verdicts and confirm two things: only finalized verdicts move a score, and a verdict can be consumed exactly once. F8 and F9 are idempotency tests, not broadcast tests. Key the score event on something stable, like the submission identifier plus the rule that decided it, and treat a second consumption of the same key as a no-op or an error, never as another point.
F9 has a variant worth writing down rather than discovering. If an unresolved item is finalized, and then the policy moves to v4, does the v3 verdict stand? Either answer is defensible. Silent re-evaluation is not. Pick one, stamp the verdict with the rule version, and assert it.
What the policy actually says
Canonical normalization on both sides, declared in the policy record. A short fold list, each entry with a reason. A versioned alias set with provenance. Unmatched is not incorrect. Pending is not a verdict, and it is not correct: false. Only finalized, attributable verdicts carrying a rule version reach the separate scoreboard, and each one is consumed once. Near-spelling answers that name other things stay out, no matter how high the similarity score climbs.
Those fixtures stay red until someone implements the checker and runs them. The alias approvals, the rejection rule, and the review authority belong to the language and answer owner of the actual format, whose sign-off this piece cannot supply. Nothing here certifies fairness, linguistic coverage, the factual correctness of the canonical answers, or readiness for a real production. What it does is draw the boundary so that the three states stay visibly different on screen, in the data, and in the log, and so that no elegant interface ever gets to make "awaiting review" look like "wrong."
Frequently asked questions
Why isn't fuzzy matching the main answer for a quiz checker?
A string-similarity score cannot decide what the game accepts as an answer. Its proper job, if kept at all, is ordering a review queue and surfacing near-misses for a human. It should never produce a verdict or emit a score event because a threshold was crossed. The answer policy comes first.
What is the difference between Unmatched, Pending, and Incorrect?
Unmatched means no rule produced an acceptance; it is the absence of a verdict, not a verdict. Pending review means an unresolved response is waiting on a human. Incorrect is a conclusion, either from a declared rejection rule—such as closed input—or from a person. Pending should not be stored as correct:false or quietly expire to incorrect on a timeout unless that timeout is a rule someone owns.
Why avoid NFKC as a general cleanup step?
Compatibility normalization can fold away distinctions a quiz may need. Superscript digits fold to plain digits, the fi ligature expands, the micro sign folds to Greek mu, and non-breaking space folds to ordinary space. A canonical answer of "10⁶" and a submission of "106" become the same string, so a checker running NFKC could accept the wrong answer with a confident, well-attributed match. Use NFC and declare a short fold list instead, entry by entry, each with a reason.
What should the fixtures test before the score display is connected?
They should include a canonical equivalent, a deliberate alias, a near-spelling that names something else, a compatibility collision, and an answer the policy hasn't covered. Each should be inspectable by raw submitted text, normalized text, policy version, decision, reason, and the identity of any human decider. A dash in the score column is not a zero; unresolved fixtures have no score event yet. Only finalized, attributable verdicts should move a score, and each should be consumed once.
What does an answer policy not establish?
It does not verify the fact. If the canonical answer is wrong, the checker will mark good answers incorrect with confidence and a clean audit trail. It also doesn't certify fairness, linguistic coverage, factual correctness of canonical answers, or readiness for production. Alias approvals, the rejection rule, and review authority belong to the language and answer owner of the actual format, whose sign-off the piece cannot supply.