fix: the difficulty facet counts itself, and the adaptive item is closed

Two halves of one TODO, settled with a measurement rather than a guess.

*Shrunk readiness* was already done — `CandidateRanking.accuracy()` pulls a
topic towards NEUTRAL_RECALL by PRIOR_ANSWERS, so one miss does not read as 0%
— and there is now a test pinning it, because the note claiming otherwise
outlived the fix by weeks.

*Difficulty as a dimension the session moves along* cannot be built, and the
reason is a number: all 2,924 questions have a NULL `difficulty`, and the
empirical route is no better at 788 answers over 706 questions from 5 learners,
about one answer each. A ladder scored against that would be scoring noise
while looking as though it worked.

What is built instead is honesty in the control that already exists: the
Difficulty facet counts each level under the other filters and disables one
that would empty the bank, so nobody picks Hard and watches the count fall to
zero with no explanation. Reopen the ordering when something writes that
column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 19:57:21 +02:00
parent afc223d79e
commit 448bfdd71c
5 changed files with 110 additions and 21 deletions

View file

@ -425,6 +425,41 @@ class AdaptiveSelectionTests(unittest.TestCase):
# And the slot goes to the miss, not to the one that went well.
self.assertEqual(seen, [1])
def test_one_wrong_answer_does_not_settle_a_topic(self):
"""The shrinkage, pinned.
A topic with a single miss used to sort first: raw accuracy reads 0%,
and 0% is the strongest signal there is. It is also the least evidence
there is. `accuracy()` pulls a topic towards NEUTRAL_RECALL by
PRIOR_ANSWERS, so one answer moves it a little and twenty move it a
lot which is the whole reason the recommendations page shows
readiness rather than accuracy, and this is the same arithmetic.
"""
from app.services.quiz_builder import (
CandidateRanking, NEUTRAL_RECALL, PRIOR_ANSWERS)
# Read as of the day the answer was given, so recency decay is not part
# of what is being measured here — the fixture dates its attempts to
# 2026-01-01, and eight months of decay would flatten the answer to
# almost nothing before the shrinkage ever saw it.
as_of = datetime(2026, 1, 1)
ranking = lambda: CandidateRanking(self.db, self.user, now=as_of)
self.assertAlmostEqual(ranking().accuracy(1), NEUTRAL_RECALL, places=6)
self.bank.answer(1, correct=False)
after_one = ranking().accuracy(1)
# Moved, but nowhere near the 0.0 that raw accuracy would report.
self.assertLess(after_one, NEUTRAL_RECALL)
self.assertGreater(after_one, 0.25)
# And exactly where the formula says: (0 + prior × neutral) / (1 + prior).
self.assertAlmostEqual(
after_one, (PRIOR_ANSWERS * NEUTRAL_RECALL) / (1 + PRIOR_ANSWERS), places=6)
# Impact follows accuracy, so one miss cannot send a topic to the top of
# the queue the way a raw 0% would.
self.assertLess(ranking().impact(1), ranking().weight(1))
def test_it_can_see_the_whole_bank_not_the_first_page_of_it(self):
from app.services.quiz_builder import adaptive_select, bank_query
# The bank this learner may actually see — not every row in the table.

View file

@ -88,11 +88,20 @@ Captured so nothing is lost while the article writing runs.
that system exists to hold them, which is a classification gap rather
than a filing error. `Radial Head Subluxation` merged into
`Nursemaid's Elbow (Radial Head Subluxation)` — one idea under two names.
- [ ] **Adaptive session** — the design and its weaknesses are written up in
`docs/study-recommendations.md`; two defects fixed (a 2,000-row cap that
hid a third of the bank, and an O(n·m) category lookup). Still open:
ordering uses raw accuracy rather than shrunk readiness, and difficulty
is a filter rather than something the session moves along.
- [x] **Adaptive session** — closed 2026-09-12, both remaining halves settled.
*Shrunk readiness*: already done and now pinned by a test —
`CandidateRanking.accuracy()` pulls a topic towards `NEUTRAL_RECALL` by
`PRIOR_ANSWERS`, and the recommendations page sorts on `readiness` and
falls back to raw accuracy only before the unlock threshold, which is
deliberate. *Difficulty as a dimension*: **cannot be built, and the
reason is measured** — all 2,924 questions have a NULL `difficulty`, and
the empirical route is no better at 788 answers over 706 questions from
5 learners, about one answer each. A ladder scored against that column
would be scoring noise while looking as though it worked. What was built
instead is honesty in the control: the Difficulty facet now counts each
level under the other filters and disables one that would empty the
bank, so nobody picks Hard and watches the count fall to zero. Reopen
when something actually writes that column.
### Settings and access
- [x] **Settings revamp** — done 2026-09-11. A section list beside one panel,
@ -260,8 +269,13 @@ Captured so nothing is lost while the article writing runs.
took an article down for an afternoon. A rename keeps a note attached
(the id survives); a deleted section leaves the note marked orphaned,
under the heading it was written on, for the writer alone to remove.
- [ ] **High-yield / key-exam-info toggles** — mark spans and let the reader show
or hide them.
- [x] **High-yield spans** — done 2026-09-12, and half of it deliberately not
done. Marking is built: `==like this==` in the prose renders as a yellow
`<mark>`, there is a Key button in the editor toolbar, and the AI drafter
is asked to mark the high-yield view sparingly. The reader-facing show/
hide toggle is **not** built, against the instruction: "we dont need key
points on or off like amboss". The High yield control is a single lit-or-
not switch over the view; it no longer says "on"/"off" either.
## Asked for on 2026-09-11 (evening), not yet done

View file

@ -123,12 +123,21 @@ Four steps:
`next((r[1] for r in rows if r[0] == qid), None)` inside a loop over every
answer. That is O(answers × candidates); at 2,000 candidates and a few
thousand answers it is the slowest part of building a session.
- **Difficulty is not used in the ordering**, only as a filter. A genuine
adaptive scheme would move up and down the difficulty range as you do well
or badly; this one does not.
- **Step 2 sorts by raw category accuracy, not readiness.** The shrinkage that
the recommendations page applies precisely so small samples do not mislead is
not applied here, so a category with one wrong answer can sort first.
- **Difficulty is not used in the ordering**, only as a filter — and as of
2026-09-12 it cannot be. Every one of the 2,924 questions has a NULL
`difficulty`, and deriving it empirically is no better: 788 answers over 706
questions from 5 learners is about one answer each, so a measured difficulty
would be 0% or 100% per question. A ladder scored against either would be
scoring noise while appearing to work. The control now counts each level and
disables one that would empty the bank; the ordering waits for something that
actually writes that column.
- ~~**Step 2 sorts by raw category accuracy, not readiness.**~~ Fixed, and
pinned by a test: `CandidateRanking.accuracy()` is
`(correct + PRIOR_ANSWERS × NEUTRAL_RECALL) / (total + PRIOR_ANSWERS)`, so a
topic with one wrong answer sits near neutral rather than at the top of the
queue. The recommendations page sorts on `readiness` wherever it has one and
falls back to raw accuracy only below the unlock threshold, where there is no
overall accuracy stable enough to shrink towards.
The first two are defects and are worth fixing. The last two are honest
limitations of the current design and would be the substance of a better one.

View file

@ -320,3 +320,9 @@
.custom-test-refresh { grid-column: 1; white-space: nowrap; }
.custom-test-start { grid-column: 2; width: 100%; min-height: 44px; }
}
/* A facet option that would choose nothing says so, rather than letting the
available count fall to zero with no explanation. Nothing in the bank
carries a difficulty today, which is exactly the case this exists for. */
.facet-count { margin-left: auto; font-size: 0.76rem; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
.facet-panel-body label.is-empty { opacity: 0.55; cursor: not-allowed; }

View file

@ -32,6 +32,9 @@ export default function CustomQuizPage() {
return Number.isInteger(fromUrl) && fromUrl >= 1 && fromUrl <= 200 ? fromUrl : 20
})
const [difficulty, setDifficulty] = useState('')
//: How many questions carry each difficulty under the other filters. Null
//: until the first count comes back.
const [difficultyCounts, setDifficultyCounts] = useState(null)
const [adaptive, setAdaptive] = useState(() => searchParams.get('adaptive') === '1')
const [available, setAvailable] = useState(null)
const [countKey, setCountKey] = useState(null)
@ -57,6 +60,9 @@ export default function CustomQuizPage() {
const [globalSearch, setGlobalSearch] = useState('')
const [moreOpen, setMoreOpen] = useState(false)
//: Whether the bank has any difficulty on it at all.
const rated = !!difficultyCounts && Object.values(difficultyCounts).some(Boolean)
const filterKey = JSON.stringify([categoryIds, state, difficulty, articleIds, tagIds, systemIds, refresh])
useEffect(() => {
@ -83,7 +89,14 @@ export default function CustomQuizPage() {
tagIds.forEach(id => params.append('tag_ids', id))
if (systemIds.length) params.append('system_ids', systemIds.join(','))
api.get('/questions/builder/count', { params }).then(r => {
if (active) { setAvailable(r.data.count); setCountKey(filterKey) }
if (active) {
setAvailable(r.data.count)
// What each difficulty would actually give, under the other filters.
// Nothing in the bank carries a difficulty yet, and a control offering
// three choices that all empty the bank is worse than one that says so.
setDifficultyCounts(r.data.difficulties || null)
setCountKey(filterKey)
}
}).catch(() => { if (active) setCountError('Could not load available count. Try refreshing.') })
return () => { active = false }
}, [filterKey])
@ -467,13 +480,25 @@ export default function CustomQuizPage() {
</FacetPicker>
<FacetPicker title="Difficulty" open={openFacet === 'difficulty'} onClose={() => setOpenFacet(null)}
onReset={() => setDifficulty('')} helper="Applies to every question in the test.">
{() => ['', 'easy', 'medium', 'hard'].map(value => (
<label key={value || 'any'}>
<input type="radio" name="difficulty-facet" checked={difficulty === value} onChange={() => setDifficulty(value)} />
{DIFFICULTY_LABEL[value]}
</label>
))}
onReset={() => setDifficulty('')}
helper={difficultyCounts && !rated
? 'No question in the bank has been given a difficulty yet, so these choose nothing.'
: 'Applies to every question in the test.'}>
{() => ['', 'easy', 'medium', 'hard'].map(value => {
const n = value ? difficultyCounts?.[value] : null
// Offered, counted, and disabled when it would empty the bank
// rather than letting somebody pick Hard and watch the count fall to
// zero with no explanation.
const empty = !!value && difficultyCounts != null && !n
return (
<label key={value || 'any'} className={empty ? 'is-empty' : undefined}>
<input type="radio" name="difficulty-facet" checked={difficulty === value}
disabled={empty} onChange={() => setDifficulty(value)} />
{DIFFICULTY_LABEL[value]}
{value && difficultyCounts != null && <span className="facet-count">{n || 'none'}</span>}
</label>
)
})}
</FacetPicker>
<FacetPicker title="Status" open={openFacet === 'status'} onClose={() => setOpenFacet(null)}