The quiz where the answer was always C
Across a batch of auto-generated quizzes, the correct answer kept landing in the same slot. Prompting the model to vary it helped. It didn't fix it.
By Meet ModiSomeone on the team ran a quick check across a batch of auto-generated quizzes: about 4,000 multiple-choice questions, four options each. If placement were random, each option slot should hold the correct answer roughly 25% of the time. Option C held it 41% of the time.
A student who just picked the third option every time, without reading a single question, would have scored better than guessing should allow.
The bug: the model generating the questions had a habit of putting the correct answer in the same spot, regardless of what the prompt asked for. Prompting isn't code. It's a suggestion with a compliance rate.
Take 1: tell it to vary the position
The first fix was the obvious one: add explicit instructions to the prompt telling the model to randomize which option holds the correct answer.
const prompt = `Generate ${count} multiple-choice questions about ${topic}.
Each question must have exactly 4 options.
IMPORTANT: Randomize which option (A, B, C, or D) contains the
correct answer. Do not favor any position. Vary it across questions.
Return JSON: { question, options: string[], correctIndex: number }`;This helped. The skew dropped from 41% on option C down to around 33%. Still nowhere near the 25% you'd expect from real randomness, and still enough for a pattern-matching student to notice over a few weeks of daily quizzes.
The instruction competed with whatever positional habit the model picked up during training, and the habit kept winning a third of the time.
Take 2: shuffle it ourselves
The fix that actually worked didn't touch the prompt at all. After generation, shuffle the options server-side and remap the correct-answer index to match. The model's positional bias becomes irrelevant because the position it chose never reaches the student.
function shuffleAndRemap(question: {
options: string[];
correctIndex: number;
}): { options: string[]; correctIndex: number } {
const indices = question.options.map((_, i) => i);
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
const options = indices.map((i) => question.options[i]);
const correctIndex = indices.indexOf(question.correctIndex);
return { options, correctIndex };
}The shuffle is a standard Fisher-Yates on the option indices, not the strings, because we need to track where the original correct answer ended up. indices.indexOf(question.correctIndex) finds the new home of the option that used to live at the old correct index.
We verified it with a test that generates a large sample of questions, runs them through the shuffle, and asserts the distribution of correctIndex across all four slots stays within a few percentage points of 25% each. That test would have caught the original bug in about thirty seconds instead of however many weeks it took a human to notice a pattern by eye.
What I should have done first
I trusted the prompt because the model is good at language and this felt like a language problem. It isn't. Position bias is a distributional property of the model's training, and no amount of asking nicely rewires that. The moment something needs to be exactly uniform, it belongs in code that doesn't have opinions.
The tell, in hindsight, was that I measured the bias by eyeballing a spreadsheet before writing any test. If I'd written the distribution assertion first, take 1 would have failed it immediately and I'd have skipped straight to the shuffle.
Position is not the only thing the model repeats. Ask it for more practice on one topic a few times and the questions start coming back nearly identical, for a reason that is not quite the same one.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.