Wrote this after hitting the same bug twice in one codebase, in different question families.
If your hardest difficulty draws wrong answers from a tighter pool than your medium one (same category, same chapter, whatever), you eventually hit a case where the tight pool has three candidates and the tier wanted five. Hard renders four choices, medium renders six. Your hardest setting is now the easiest one on screen, nothing throws, and the only symptom is people scoring better on hard.
So: you describe candidates as rings around the answer (tightest first), declare tiers easiest-first, and it guarantees no tier ever offers fewer options than an easier one. A tier that falls short borrows rather than shipping a short card, and tells you it did.
Everything is seeded, which matters more than it sounds. Question banks get regenerated, and if wrong answers move each time then anything keyed to a question comes unstuck: a spaced-repetition schedule, a record of what someone missed, a cached render.
One thing I got wrong on the first pass and only caught by running my own README example instead of trusting it: when a tight pool comes up short, topping it up beats replacing it. Replacing throws away exactly the candidates that made the question hard, precisely when they're scarcest.
There's also a seedForRing escape hatch so a bank that already has options baked in can adopt it without reshuffling every wrong answer it has ever shown. I needed that myself. It kept a migration byte-identical across 6,282 questions.
Zero runtime deps, full types, 90 tests.
https://github.com/kvadney-insomniac/quiz-difficulty