AdaptiveQuiz
A quiz whose next question depends on the last answer, with an estimated level and confidence, an explanation for each answer, and no repeats.
Demo
Mental arithmetic
Question 1 of up to 6No estimate yet
Difficulty 3. What is the square root of 169?
Stops when sure enough
Question 1 of up to 10No estimate yet
Difficulty 3. What is the square root of 169?
Running out of questions
- Question 3 has no text, so it is left out.
- Question 4 needs at least two choices, so it is left out.
- Question 5 has an answer that is not among its choices, so it is left out.
- Question 6 has no difficulty, so it is left out.
- Question 7 is a repeat, so it is left out.
Question 1 of up to 2No estimate yet
Difficulty 2. Which is a primary colour of light?
Answer to see on_answer, and finish to see on_finish.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/adaptive_quiz.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/adaptive_quiz.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/adaptive_quiz.qwe
let adaptive_quiz_demo_bank = [ { id: "a1", difficulty: 1, text: "What is 7 + 5?", choices: ["11", "12", "13", "14"], answer: 1, explain: "7 + 3 is 10, and 2 more is 12." }, { id: "b1", difficulty: 1, text: "What is half of 18?", choices: ["6", "8", "9", "12"], answer: "9", explain: "9 + 9 is 18." }, { id: "a2", difficulty: 2, text: "What is 12 × 12?", choices: ["124", "132", "144", "164"], answer: 2, explain: "12 × 10 is 120, and 12 × 2 is 24." }, { id: "b2", difficulty: 2, text: "What is 15% of 200?", choices: ["15", "30", "45", "60"], answer: 1, explain: "10% is 20 and 5% is 10." }, { id: "a3", difficulty: 3, text: "What is the square root of 169?", choices: ["11", "12", "13", "14"], answer: 2, explain: "13 × 13 is 169." }, { id: "b3", difficulty: 3, text: "What is 2 to the power 10?", choices: ["512", "1000", "1024", "2048"], answer: 2, explain: "Doubling ten times from 1 gives 1024." }, { id: "a4", difficulty: 4, text: "What is the sum of the angles of a hexagon?", choices: ["360°", "540°", "720°", "1080°"], answer: 2, explain: "(6 − 2) × 180° is 720°." }, { id: "b4", difficulty: 4, text: "What is 17 × 23?", choices: ["371", "381", "391", "401"], answer: 2, explain: "17 × 20 is 340, and 17 × 3 is 51." }, { id: "a5", difficulty: 5, text: "How many ways can 5 people stand in a line?", choices: ["25", "60", "120", "720"], answer: 2, explain: "5 × 4 × 3 × 2 × 1 is 120." }, { id: "b5", difficulty: 5, text: "What is the 10th prime number?", choices: ["23", "29", "31", "37"], answer: 1, explain: "2, 3, 5, 7, 11, 13, 17, 19, 23, 29." },]let adaptive_quiz_demo_short = [ { id: "s1", difficulty: 2, text: "Which is a primary colour of light?", choices: ["Green", "Yellow"], answer: 0, explain: "Red, green and blue." }, { id: "s2", difficulty: 2, text: "How many sides has a heptagon?", choices: ["6", "7", "8"], answer: "7" }, { text: "", difficulty: 1, choices: ["a", "b"], answer: 0 }, { text: "A question with one choice", difficulty: 1, choices: ["a"], answer: 0 }, { text: "An answer that is not there", difficulty: 1, choices: ["a", "b"], answer: 5 }, { text: "No difficulty", choices: ["a", "b"], answer: 0 }, { id: "s1", text: "A repeat", difficulty: 1, choices: ["a", "b"], answer: 0 },]component AdaptiveQuizDemoLive() { state note = "Answer to see on_answer, and finish to see on_finish." <div.demo-stack> <AdaptiveQuiz id="adaptive-quiz-demo-short" title="Running out of questions" name="short" questions={adaptive_quiz_demo_short} on_answer={(a) => { note = `on_answer: ${a.id} ${a.right ? "right" : "wrong"}, level ${a.level}.` }} on_finish={(r) => { note = `on_finish: ${r.right} of ${r.asked}, ${r.reason}.` }}/> <p.demo-note>{note}</p> </div>}view AdaptiveQuizDemo() = <div.demo-stack> <AdaptiveQuiz id="adaptive-quiz-demo" title="Mental arithmetic" questions={adaptive_quiz_demo_bank} limit={6}/> <AdaptiveQuiz id="adaptive-quiz-demo-early" title="Stops when sure enough" name="early" questions={adaptive_quiz_demo_bank} stop_at={30}/> <AdaptiveQuizDemoLive/> </div>Source
ui/adaptive_quiz.qwe
// AdaptiveQuiz: a quiz that finds someone's level. Each question has a difficulty;// after a right answer the next is harder and after a wrong one easier, the nearest// unused one to that level, so no question comes twice, and it says when it had to// go further than one step because a level ran out. Every answer is explained. The// level is estimated from all the answers so far, as the mean of a probability over// levels, with each answer weighed by how hard it was (a guess is allowed for: one// in the number of choices). Confidence is how much of that probability lies within half// a level of the estimate. It ends at `limit` questions, when it is sure enough, or// when the questions run out, and says which.//// <AdaptiveQuiz questions={bank} limit={8}/>// <AdaptiveQuiz questions={bank} stop_at={80} on_finish={(r) => { level = r.level }}/>//// questions: each { text, difficulty, choices, answer, explain?, id? }: difficulty a number// (1 to 5, say), choices a list of text, answer the index of the right one from 0, or its// text, explain why it is right. A question that cannot be asked (no text, fewer than two// choices, an answer not among them, a difficulty that is not a number, or asked// already under the same id or text) is left out and reported. A page sends the props// to the browser, answers included, so this is for practice, not an exam. limit: the most// questions asked (10). start: the difficulty to start at (the middle of those// there are). stop_at: a confidence in percent at which to stop early (none). title: the// heading ("Quiz"). id: a prefix for its ids ("adaptive-quiz"), unique on the page. The// choices are radio buttons named answer, and result has the outcome as JSON at the end;// name: a prefix for those. on_answer(a): inside one of your components, given { id,// right, level, confidence } after each answer. on_finish(r): given the result, { level,// confidence, word, right, asked, reason }, when it ends.//// adaptive_quiz_clean(questions), adaptive_quiz_next(items, asked, start) and// adaptive_quiz_estimate(items, asked) are the logic, for a program's own use: asked is a list// of { id, right }. A level is on the scale of the difficulties, shown to one place.import ui/theme.qwe use ui_clslet adaptive_quiz_css = css`.ui-adquiz { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; min-width: 0; max-width: 620px; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-adquiz .ui-adquiz-title { display: block; margin: 0; font-size: 16px; font-weight: 650; }.ui-adquiz-meter { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 14px; margin: 0; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-adquiz-why { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-adquiz-card { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 0; border: 0; }.ui-adquiz .ui-adquiz-question { margin: 0; padding: 0; font-size: 15px; font-weight: 600; outline: none; overflow-wrap: anywhere; }.ui-adquiz-question:focus-visible { box-shadow: var(--ui-focus); }.ui-adquiz-choices { display: grid; gap: 6px; }.ui-adquiz-choice { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 8px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); cursor: pointer; overflow-wrap: anywhere; }.ui-adquiz-choice:hover { border-color: var(--ui-strong); }.ui-adquiz-choice:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-adquiz-choice input { flex: none; margin: 0; accent-color: var(--ui-accent); }.ui-adquiz-is-right { border-color: var(--ui-success); background: var(--ui-success-soft); }.ui-adquiz-is-wrong { border-color: var(--ui-danger); background: var(--ui-danger-soft); }.ui-adquiz-bar { display: flex; flex-wrap: wrap; gap: 8px; }.ui-adquiz-go { height: 36px; padding: 0 16px; border: 1px solid var(--ui-accent); border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 13px var(--ui-font); cursor: pointer; }.ui-adquiz-go:hover { background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); }.ui-adquiz-go:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-adquiz-warn { margin: 0; color: var(--ui-danger); font-size: 13px; }.ui-adquiz-told { display: grid; gap: 4px; margin: 0; padding: 10px 14px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-adquiz-verdict { margin: 0; font-weight: 650; }.ui-adquiz-explain { margin: 0; }.ui-adquiz-problems { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; color: var(--ui-warning); font-size: 13px; }.ui-adquiz-result { display: grid; gap: 2px; margin: 0; padding: 12px 16px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-adquiz-level { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }.ui-adquiz-review { display: grid; gap: 8px; margin: 0; padding: 0 0 0 20px; }.ui-adquiz-review li { overflow-wrap: anywhere; }.ui-adquiz-review small { display: block; color: var(--ui-muted); font-size: 12px; }`// How steeply the chance of a right answer rises with level.let adaptive_quiz_slope = 1.5// The questions fit to ask, as { id, text, level, choices, answer, explain }, and what// was wrong with the others: { items, problems }.fn adaptive_quiz_clean(questions) { let items = [] let problems = [] let given = type_of(questions) == "list" ? questions : [] for i in 0..len(given) { let q = given[i] let text = type_of(q) == "map" && type_of(q.text) == "str" ? trim(q.text) : "" let choices = type_of(q) == "map" && type_of(q.choices) == "list" ? q.choices | map((c) => str(c)) : [] let right = type_of(q) == "map" ? q.answer : null let answer = type_of(right) == "int" ? (right >= 0 && right < len(choices) ? right : null) : index_of(choices, str(right)) let hard = type_of(q) == "map" ? q.difficulty : null let number = type_of(hard) == "int" || type_of(hard) == "float" let id_ = type_of(q) == "map" && q.id != null ? str(q.id) : `q${i + 1}` let why = text == "" ? "has no text" : len(choices) < 2 ? "needs at least two choices" : answer == null ? "has an answer that is not among its choices" : !number ? "has no difficulty" : any(items, (x) => x.id == id_ || lower(x.text) == lower(text)) ? "is a repeat" : null if why != null { problems = problems | push(`Question ${i + 1} ${why}, so it is left out.`) } else { items = items | push({ id: id_, text, level: hard, choices, answer, explain: filled(trim(str(q.explain ?? ""))) }) } } { items, problems }}fn adaptive_quiz_levels(items: list) = items | map((x) => x.level) | unique() | sort()fn adaptive_quiz_start(items: list, start) { let levels = adaptive_quiz_levels(items) if start != null { return start } len(levels) == 0 ? 0 : levels[int(floor((len(levels) - 1) / 2))]}// The next question: { item, why } or null when none is left. After a right answer the target// is one level up and after a wrong one down; of those left, the nearest to that, and// of two as near, the one the answer points to, and then the first listed.fn adaptive_quiz_next(items: list, asked: list, start = null) { let left = items | filter((x) => !any(asked, (a) => a.id == x.id)) if len(left) == 0 { return null } if len(asked) == 0 { let wanted = adaptive_quiz_start(items, start) let best = left | sort_by((x) => abs(x.level - wanted)) | first() return { item: best, why: "" } } let previous = last(asked) let was = (items | find((x) => x.id == previous.id)).level let up = previous.right let levels = adaptive_quiz_levels(items) let above = levels | filter((l) => l > was) | first() let below = levels | filter((l) => l < was) | last() let wanted = up ? (above ?? was) : (below ?? was) let side = (x) => (up && x.level >= wanted) || (!up && x.level <= wanted) ? 0 : 1 let best = left | sort_by((x) => abs(x.level - wanted) * 2 + side(x)) | first() let word = up ? "Right, so a harder one" : "Not that one, so an easier one" let why = best.level == wanted && wanted != was ? `${word}.` : best.level == was && wanted == was ? `Right or wrong, this is the ${up ? "hardest" : "easiest"} level there is.` : `${word} was due, but none is left at level ${wanted}, so this is level ${best.level}.` { item: best, why }}// { level, confidence, word, right, asked }: level null before the first answer. The chance of a// right answer at level t on a question of difficulty b, with g the chance of guessing,// is g + (1 - g) / (1 + e^(-1.5 (t - b))); levels every quarter step start equally likely.fn adaptive_quiz_estimate(items: list, asked: list) { let levels = adaptive_quiz_levels(items) let right = asked | filter((a) => a.right) | len if len(asked) == 0 || len(levels) == 0 { return { level: null, confidence: 0, word: "None yet", right, asked: len(asked) } } let flat = first(levels) == last(levels) let lo = float(first(levels)) - (flat ? 1.0 : 0.0) let hi = float(last(levels)) + (flat ? 1.0 : 0.0) let steps = int(floor((hi - lo) * 4)) + 1 let grid = for i in 0..steps => lo + i * 0.25 let weights = grid | map((t) => { let w = 1.0 for a in asked { let q = items | find((x) => x.id == a.id) let guess = 1.0 / len(q.choices) let chance = guess + (1.0 - guess) / (1.0 + math.exp(-adaptive_quiz_slope * (t - float(q.level)))) w = w * (a.right ? chance : 1.0 - chance) } w }) let total = sum(weights) let mean = sum(for i in 0..steps => grid[i] * weights[i]) / total let near = sum(for i in 0..steps => abs(grid[i] - mean) <= 0.5 ? weights[i] : 0.0) / total let confidence = round(near * 100) { level: round(mean, 1), confidence, word: confidence < 50 ? "Low" : confidence < 75 ? "Medium" : "High", right, asked: len(asked) }}// Why to stop now, or null: "out", "limit" or "sure".fn adaptive_quiz_stop(items: list, asked: list, limit: int, stop_at) { if len(asked) >= len(items) { return "out" } if len(asked) >= limit { return "limit" } if stop_at != null && adaptive_quiz_estimate(items, asked).confidence >= stop_at { return "sure" } null}fn adaptive_quiz_reason(reason: str, count: int) = match reason { "out" => "That was every question there is, so the estimate rests on these alone." "limit" => `That is the limit of ${count} questions.` "sure" => "It stopped early, sure enough of the level." _ => ""}fn adaptive_quiz_noun(n: int) = n == 1 ? "1 question" : `${n} questions`component AdaptiveQuiz(questions = [], limit: int = 10, start = null, stop_at = null, title: str = "Quiz", id: str = "adaptive-quiz", name: str? = null, on_answer = null, on_finish = null, class: str? = null) { style global adaptive_quiz_css let bank = adaptive_quiz_clean(questions) let items = bank.items let opening = adaptive_quiz_next(items, [], start) state asked = [] state current = opening == null ? null : opening.item.id state why = "" state picked = -1 state phase = opening == null ? "empty" : "ask" state warned = false state ending = "" state moved = false let named = (n: str) => name != null ? `${name}_${n}` : n let q = items | find((x) => x.id == current) let guess = adaptive_quiz_estimate(items, asked) let last_answer = phase == "told" ? last(asked) : null let stopping = phase == "told" ? adaptive_quiz_stop(items, asked, limit, stop_at) : null let level_text = guess.level == null ? "No estimate yet" : `Estimated level ${str(guess.level)}, confidence ${str(guess.confidence)}% (${guess.word})` let outcome = { level: guess.level, confidence: guess.confidence, word: guess.word, right: guess.right, asked: guess.asked, reason: ending } let shown_of = (a) => items | find((x) => x.id == a.id) let check = () => { if picked < 0 { warned = true } else { let good = picked == q.answer let now = asked | push({ id: q.id, chosen: picked, right: good }) asked = now warned = false phase = "told" moved = true let e = adaptive_quiz_estimate(items, now) if on_answer != null { on_answer({ id: q.id, right: good, level: e.level, confidence: e.confidence }) } } } let go_on = () => { let reason = adaptive_quiz_stop(items, asked, limit, stop_at) if reason != null { ending = reason phase = "done" let e = adaptive_quiz_estimate(items, asked) if on_finish != null { on_finish({ ...e, reason }) } } else { let n = adaptive_quiz_next(items, asked, start) current = n.item.id why = n.why picked = -1 phase = "ask" } } let again = () => { asked = [] why = "" picked = -1 warned = false ending = "" current = opening == null ? null : opening.item.id phase = opening == null ? "empty" : "ask" } <section class={ui_cls(["ui-adquiz", class])} aria-labelledby={`${id}-title`}> <h3.ui-adquiz-title id={`${id}-title`}>{title}</h3> {len(bank.problems) > 0 && <ul.ui-adquiz-problems> {for p in bank.problems | take(6) => <li>{p}</li>} {len(bank.problems) > 6 && <li>{`And ${len(bank.problems) - 6} more.`}</li>} </ul>} {phase == "empty" && <p.ui-adquiz-warn role="alert">There are no questions to ask.</p>} {phase != "empty" && <p.ui-adquiz-meter> <span>{phase == "done" ? `${adaptive_quiz_noun(len(asked))} asked` : `Question ${phase == "ask" ? len(asked) + 1 : len(asked)} of up to ${min([limit, len(items)])}`}</span> <span aria-live="polite">{level_text}</span> </p>} {phase == "ask" && why != "" && <p.ui-adquiz-why>{why}</p>} {(phase == "ask" || phase == "told") && q != null && <div.ui-adquiz-card key={q.id}> <p.ui-adquiz-question tabindex="-1" @mount={(el) => { if len(asked) > 0 { el.focus() } }}> <span class="ui-sr">{`Difficulty ${q.level}. `}</span>{q.text} </p> <div.ui-adquiz-choices role="radiogroup" aria-label="Choices"> {for i in 0..len(q.choices) { let told = phase == "told" let mark = told && i == q.answer ? "ui-adquiz-is-right" : told && i == picked ? "ui-adquiz-is-wrong" : null <label key={i} class={ui_cls(["ui-adquiz-choice", mark])}> <input type="radio" name={named("answer")} value={str(i)} checked={picked == i} disabled={told} @change={(e) => { picked = i; warned = false }} @keydown={(e) => { if e.key == "Enter" && phase == "ask" { e.preventDefault(); check() } }}/> <span>{q.choices[i]}</span> </label> }} </div> </div>} {warned && <p.ui-adquiz-warn role="alert">Pick an answer first.</p>} {phase == "ask" && <div.ui-adquiz-bar><button.ui-adquiz-go type="button" @click={check}>Check answer</button></div>} {phase == "told" && <div.ui-adquiz-told role="status"> <p.ui-adquiz-verdict>{last_answer.right ? "Right." : `Not quite: the answer is ${q.choices[q.answer]}.`}</p> {q.explain != null && <p.ui-adquiz-explain>{q.explain}</p>} </div>} {phase == "told" && <div.ui-adquiz-bar> <button.ui-adquiz-go type="button" @mount={(el) => { if moved { el.focus() } }} @click={go_on}>{stopping != null ? "See the result" : "Next question"}</button> </div>} {phase == "done" && <div.ui-adquiz-result role="status"> <span.ui-adquiz-level>{guess.level == null ? "—" : `Level ${str(guess.level)}`}</span> <span>{`${guess.right} of ${guess.asked} right. Confidence ${str(guess.confidence)}% (${guess.word}).`}</span> <span.ui-adquiz-why>{adaptive_quiz_reason(ending, limit)}</span> </div>} {phase == "done" && <input type="hidden" name={named("result")} value={json.stringify(outcome)}/>} {phase == "done" && <ol.ui-adquiz-review> {for a in asked { let item = shown_of(a) let aid = a.id <li key={aid}> {item.text} <small>{a.right ? `Right: ${item.choices[item.answer]}.` : `You said ${item.choices[a.chosen]}; the answer is ${item.choices[item.answer]}.`}</small> {item.explain != null && <small>{item.explain}</small>} </li> }} </ol>} {phase == "done" && <div.ui-adquiz-bar><button.ui-adquiz-go type="button" @click={again}>Start again</button></div>} </section>}Related
GradeCalculatorWorks out a weighted current grade from the marks so far, and the mark needed on what is left to reach each target, or that it is out of reach.2 views · 7 downloadsBranchingSurveyOne question at a time with jumps by answer: back keeps answers, progress is counted from the longest path left, and unreachable or looping questions are reported.3 views · 6 downloadsHttpRequestBuilderBuilds an HTTP request as curl, fetch and HTTPie with correct shell escaping, warns on a body on GET and a missing content type, and hides secrets until asked.3 views · 5 downloadsJsonFormatterFormats, minifies and sorts JSON, keeps numbers exactly, names the line and column of an error, and repairs trailing commas and single quotes.3 views · 5 downloadsJwtInspectorDecodes a JSON Web Token locally, reads its dates relative to now, warns on alg none and a missing exp, and says the signature was not verified.3 views · 7 downloadsRecipeScalerScales a recipe to any number of servings: reads fractions and ranges, moves to the next unit, and rounds to what a kitchen can measure, in metric or US.2 views · 7 downloads
Thread
Thread 0 posts
On AdaptiveQuiz: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.