diff --git a/backend/tests/test_quiz_builder.py b/backend/tests/test_quiz_builder.py index 844947e..cf407e8 100644 --- a/backend/tests/test_quiz_builder.py +++ b/backend/tests/test_quiz_builder.py @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index 2db5a36..5d4c33b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 + ``, 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 diff --git a/docs/study-recommendations.md b/docs/study-recommendations.md index f6367ad..b060122 100644 --- a/docs/study-recommendations.md +++ b/docs/study-recommendations.md @@ -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. diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index 3591aa2..be6ef17 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -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; } diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index f6ec403..3e59861 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -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() { setOpenFacet(null)} - onReset={() => setDifficulty('')} helper="Applies to every question in the test."> - {() => ['', 'easy', 'medium', 'hard'].map(value => ( - - ))} + 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 ( + + ) + })} setOpenFacet(null)}