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

196 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_test``services/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.