"""Generate the fake learner responses in data/responses.csv. Nobody real took this quiz. Each simulated learner gets an ability score, and each item gets a difficulty and a discrimination value, using a standard three-parameter logistic (3PL) model. A few items have problems planted on purpose so the analysis has something to find: Q01 far too easy (almost everyone gets it right) Q05 ambiguous: stronger learners who know about mid-conversation system messages are drawn to option A, so the item barely separates learners Q08 miskeyed: the answer key in quiz.json says C, but B is the real answer Q12 option D is a distractor almost nobody picks Run it again with the same seed and you get the same file. """ import csv import json import math import random from pathlib import Path SEED = 2026 N_LEARNERS = 80 ROOT = Path(__file__).resolve().parent.parent # (true answer, difficulty b, discrimination a, distractor weights) # Distractor weights say how attractive each wrong option is. ITEM_PARAMS = { "Q01": ("B", -2.4, 1.0, {"A": 3, "C": 2, "D": 2}), "Q02": ("B", -0.6, 1.3, {"A": 3, "C": 3, "D": 3}), "Q03": ("B", 0.2, 1.4, {"A": 5, "C": 1, "D": 2}), "Q04": ("B", 0.4, 1.1, {"A": 3, "C": 3, "D": 1}), "Q05": None, # simulated separately below "Q06": ("B", 0.0, 1.5, {"A": 4, "C": 3, "D": 2}), "Q07": ("A", 0.8, 1.2, {"B": 5, "C": 2, "D": 1}), "Q08": ("B", 0.1, 1.3, {"A": 2, "C": 5, "D": 2}), "Q09": ("A", 1.0, 1.0, {"B": 5, "C": 2, "D": 3}), "Q10": ("B", -0.8, 1.2, {"A": 3, "C": 3, "D": 2}), "Q11": ("D", 0.3, 1.3, {"A": 2, "B": 4, "C": 3}), "Q12": ("B", -0.3, 1.2, {"A": 5, "C": 3, "D": 0.05}), } def p_correct(theta, a, b, guess=0.2): """3PL probability that a learner with ability theta answers correctly.""" return guess + (1 - guess) / (1 + math.exp(-1.7 * a * (theta - b))) def pick_weighted(rng, weights): options = list(weights) return rng.choices(options, weights=[weights[o] for o in options])[0] def answer_q05(rng, theta): # Learners who know the API well hesitate between A and B, because # role "system" messages are allowed later in the messages array. # Weaker learners mostly guess. pull_to_a = 1 / (1 + math.exp(-2.0 * (theta - 0.2))) if rng.random() < 0.55 * pull_to_a: return "A" if rng.random() < 0.5: return "B" return pick_weighted(rng, {"A": 2, "C": 2, "D": 2}) def main(): rng = random.Random(SEED) quiz = json.loads((ROOT / "data" / "quiz.json").read_text()) item_ids = [item["id"] for item in quiz["items"]] rows = [] for n in range(1, N_LEARNERS + 1): theta = rng.gauss(0, 1) row = {"learner_id": f"L{n:03d}"} for item_id in item_ids: if item_id == "Q05": row[item_id] = answer_q05(rng, theta) continue truth, b, a, distractors = ITEM_PARAMS[item_id] if rng.random() < p_correct(theta, a, b): row[item_id] = truth else: row[item_id] = pick_weighted(rng, distractors) rows.append(row) out = ROOT / "data" / "responses.csv" with out.open("w", newline="") as f: writer = csv.DictWriter(f, fieldnames=["learner_id", *item_ids]) writer.writeheader() writer.writerows(rows) print(f"Wrote {len(rows)} learners to {out.relative_to(ROOT)}") if __name__ == "__main__": main()