pdf-quiz-generator/docs/adaptive-sessions.md
Daniel ddd8c4c4e3 feat: the session moves along the difficulty range, per topic
The half of "adaptive" that was never built. Difficulty was a filter a learner
could set and nothing the session did on its own, so somebody at 30% on a topic
and somebody at 90% were asked the same questions in the same order.

A question's value is now scaled by how far its level is from the one the
learner's readiness on *that topic* calls for: under 0.45 wants easy, up to
0.78 medium, above that hard. Per topic because strong on growth and weak on
arrhythmias is the normal case and one number across the two describes nobody.

A multiplier and not a filter, for the same reason the reranker is a
permutation: thinning the bank to one level makes it three times smaller, and
on a narrow topic that is the same eight questions every time. One step away
keeps 0.72 of its value, two steps 0.5, and an unlabelled question 0.92 —
unknown is not wrong.

The first band's edge is 0.45 rather than 0.55 so that a topic with no evidence
— which sits at exactly NEUTRAL_RECALL — gets medium questions. Handing
somebody the easiest questions in a subject nobody has measured them on is a
poor way to find out what they know.

Written up in docs/adaptive-sessions.md, which now says five rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:39:23 +02:00

10 KiB
Raw Permalink Blame History

Adaptive sessions — what "prioritised by impact" means here

Code: backend/app/services/quiz_builder.py, class CandidateRanking (reached by adaptive_select, the algorithm: "adaptive" branch of generate_test), and backend/app/services/prepared_session.py. Reached from: the Ready for you card on the dashboard (frontend/src/components/PreparedSession.jsx), the Adaptive toggle on the custom-session builder (frontend/src/pages/CustomQuizPage.jsx), and the Next step: adaptive session card on Analysis (frontend/src/pages/AnalysisPage.jsx). Tests: backend/tests/test_prepared_session.py, backend/tests/test_quiz_builder.py (AdaptiveSelectionTests).

There is no language model anywhere in this. It is arithmetic over the learner's own answers, and it has to stay that way: the whole product here is that the session can be explained before it is sat, and a model that cannot show its working could not do that.

What decides which questions

Five rules, then one multiplier.

1. Unseen material is most of the session. A question never met teaches more than one already answered, so it takes every slot review is not holding.

2. Review holds up to 40% of the session, and only what is due. MAX_REVIEW_SHARE. Due means recall has decayed below DUE_RECALL — which is everything ever answered wrongly, and everything answered correctly more than about three and a half weeks ago. This used to be "recycle when the unanswered run out", which on a bank of nearly three thousand questions meant a learner never saw a repeat: no spaced repetition at all. The cap is the other half of it — somebody handed twenty questions they have already answered does not come back.

3. Within either pool, highest value first. For unseen material that is the topic's impact, (1 accuracy) × blueprint weight. For review it is (1 recall) × blueprint weight.

4. Each pick halves its topic's priority. CATEGORY_DAMPING. Without it a session of twenty becomes twenty questions from the single worst subject — and a learner with no history at all, whose topics are all equally unknown, gets handed the heaviest domain entire rather than a spread.

5. And how hard the question is follows how the learner is doing on that topic. difficulty_fit(). Readiness under 0.45 wants easy, up to 0.78 wants medium, above that hard; a question at the target level scores in full, one step away keeps 0.72 of its value, two steps 0.5. An unlabelled question keeps 0.92 — unknown is not wrong.

Three things about this are deliberate.

Per topic, not per learner. Somebody strong on growth and weak on arrhythmias is the normal case, and one number across the two describes nobody. The target is computed from the same shrunk accuracy that ranks the topic.

A multiplier, not a filter. Thinning the bank to one level makes it three times smaller, and on a narrow topic that is the same eight questions every time. Nothing is ever excluded for being the wrong level; it is preferred against — the same reasoning as the reranker being a permutation.

The middle is the default. A topic with no evidence sits at exactly NEUTRAL_RECALL, and the first band's edge is 0.45 rather than 0.55 precisely so that untouched topics get medium questions. Handing somebody the easiest questions in a subject nobody has measured them on is a poor way to find out what they know and a slow way to start.

This could not exist until 2026-09-12, when classify_question_difficulty labelled the bank: the column had been NULL on all 2,924 rows since it was added, so difficulty was a filter a learner could set and nothing the session ever did on its own.

And all of it is scaled by the topic's share of the real paper. Weakness alone said that being weak at something worth 5% of the exam and something worth 1% were the same problem. They are not. exam_blueprints.weight holds what the examining board publishes and blueprint_weights() loads it. A topic the blueprint does not cover takes the median of the published weights: a zero would make unmapped material unreachable, the highest would make it the priority, and neither is a claim the blueprint supports.

With no study objective set, or an objective carrying no weights, the multiplier is absent and selection is about weakness alone.

Only completed, non-expired, non-course attempts count. The pool is scoped to the learner's active exam, the same scope the bank and search use.

How time enters it

EVIDENCE_HALF_LIFE_DAYS = 30. Every answer's weight is 0.5 ** (age_days / 30).

Why exponential. A fixed window was the obvious thing and is wrong in a way that shows: it makes an answer twenty-nine days old count in full and one thirty-one days old count for nothing, so a topic crosses a cliff overnight and the ranking lurches without the learner having done anything. Exponential decay is also memoryless — an answer's weight depends only on its own age, not on what has been answered since — which is what keeps two consecutive sessions consistent with each other. A power law fits very long retention slightly better, but it needs an arbitrary offset to avoid a singularity at age zero and a second parameter nothing here could justify. One named half-life describes the whole curve.

Why thirty days. About the turn of a revision cycle. A ninety-day-old answer keeps an eighth of the weight of a fresh one, so what was missed last week clearly outranks what was missed in spring, while a topic revised last month is not written off as forgotten.

Two things decay:

  • A question's recall. NEUTRAL_RECALL + (settled NEUTRAL_RECALL) × decay, where settled is 0.85 after a correct answer and 0.25 after a wrong one. Both decay towards a coin flip, not towards zero: forgetting a right answer does not turn it into a wrong one, and time does not turn a wrong answer into a right one. Both end up saying nothing, which is exactly when the question is worth asking again.
  • A topic's accuracy. Each answer counts for decay(age) of an answer, and PRIOR_ANSWERS = 2 answers' worth of "no idea" is mixed in. Without that prior a single correct answer made a topic 100% known and it never came back, which is the one thing a ranking claiming to decay must not do.

The prepared session

prepared_session.prepare_session — one action that produces a session tuned to this learner and a plain statement of why, before anything is written.

  • GET /questions/builder/prepared?count= returns the plan. Nothing is written, and the question ids are withheld: the plan is for reading, and handing out stem identifiers is how a preview becomes a way to enumerate the bank.
  • POST /questions/builder/prepared recomputes the plan, builds the test from that plan's own question ids, and returns the plan alongside the created quiz. So the account handed back describes the session by construction, not because two calls happened to agree. A learner who changed the length gets the plan for the length they chose.

The preview is a forecast, and it is accurate because the ranking is deterministic: candidates are scanned in question-id order and every tie resolves to the lowest id. The cold-start blueprint draw, which is random, is seeded on (user, length, date) for the same reason.

What the plan says. A one-line summary, a length and why that length, and one row per discipline — count, how much of it is new versus review, the learner's accuracy there, the topic's share of the paper, and one line of reasoning. Rows are grouped by top-level category because that is the vocabulary Analysis already reports weaknesses in; leaf topics would be twenty rows of one question each. Every count in the plan is tallied from the questions actually chosen, never predicted from the ranking — see test_the_plan_describes_the_questions_actually_put_in_the_session.

The reason is the strongest true fact, in this order: the blueprint share (cold start), a weak area, due for review, not attempted yet, the blueprint share. A weak topic is usually also due, and the accuracy is the more useful of the two. "Weak area" needs WEAK_EVIDENCE_ANSWERS = 3 decayed answers behind it — below that the prior is most of the number, and a topic last answered correctly in the spring would otherwise be reported as a weakness on no evidence.

Length is the learner's own median finished session, clamped to 560, counted as answers recorded rather than questions offered. The median rather than the mean: one abandoned session and one marathon pull a mean somewhere neither of them is. Until SESSIONS_BEFORE_LENGTH_IS_PERSONAL = 3 sessions are finished it is 20, and the plan says so.

No history degrades honestly. A learner who has finished nothing gets a paper drawn to the exam blueprint — exam_blueprint.sample, the same code behind algorithm: "blueprint" — and is told in as many words that this is a spread across the exam rather than a personalisation. No exam or no blueprint falls back to the damped impact spread over the whole bank, and says that instead.

What it is still not

Weight scales weakness; it does not replace it. A topic you are certain of does not surface because it is worth 5% of the paper — (1 accuracy) is near zero and no multiplier rescues it. That is deliberate: this picks what to study, not what the paper is made of.

Dealing a paper shaped like the real exam is a different algorithm — algorithm: "blueprint", _blueprint_testservices/exam_blueprint.py.

Refinements still open

In the order they are worth doing:

  • Adapt the difficulty, not only the count and the mix. Question.difficulty is on every row and nothing in the ranking reads it. A learner at 40% on a topic and one at 80% get the same questions from it.
  • Use every category a question is filed under, not only its primary. The ranking reads question_category_id alone, so a question's extra links do not influence which topic it counts as. The plan's grouping has the same limit.
  • Let time answer. A question answered correctly in ten seconds is not the same as one answered correctly in four minutes, and seconds_spent is recorded on every answer already.
  • Let the half-life be per learner. Thirty days is a defensible constant and a worse fit than a rate fitted to somebody's own forgetting, which their answer history could estimate once there is enough of it.