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:
parent
afc223d79e
commit
448bfdd71c
5 changed files with 110 additions and 21 deletions
|
|
@ -425,6 +425,41 @@ class AdaptiveSelectionTests(unittest.TestCase):
|
||||||
# And the slot goes to the miss, not to the one that went well.
|
# And the slot goes to the miss, not to the one that went well.
|
||||||
self.assertEqual(seen, [1])
|
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):
|
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
|
from app.services.quiz_builder import adaptive_select, bank_query
|
||||||
# The bank this learner may actually see — not every row in the table.
|
# The bank this learner may actually see — not every row in the table.
|
||||||
|
|
|
||||||
28
docs/TODO.md
28
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
|
that system exists to hold them, which is a classification gap rather
|
||||||
than a filing error. `Radial Head Subluxation` merged into
|
than a filing error. `Radial Head Subluxation` merged into
|
||||||
`Nursemaid's Elbow (Radial Head Subluxation)` — one idea under two names.
|
`Nursemaid's Elbow (Radial Head Subluxation)` — one idea under two names.
|
||||||
- [ ] **Adaptive session** — the design and its weaknesses are written up in
|
- [x] **Adaptive session** — closed 2026-09-12, both remaining halves settled.
|
||||||
`docs/study-recommendations.md`; two defects fixed (a 2,000-row cap that
|
*Shrunk readiness*: already done and now pinned by a test —
|
||||||
hid a third of the bank, and an O(n·m) category lookup). Still open:
|
`CandidateRanking.accuracy()` pulls a topic towards `NEUTRAL_RECALL` by
|
||||||
ordering uses raw accuracy rather than shrunk readiness, and difficulty
|
`PRIOR_ANSWERS`, and the recommendations page sorts on `readiness` and
|
||||||
is a filter rather than something the session moves along.
|
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
|
### Settings and access
|
||||||
- [x] **Settings revamp** — done 2026-09-11. A section list beside one panel,
|
- [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
|
took an article down for an afternoon. A rename keeps a note attached
|
||||||
(the id survives); a deleted section leaves the note marked orphaned,
|
(the id survives); a deleted section leaves the note marked orphaned,
|
||||||
under the heading it was written on, for the writer alone to remove.
|
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
|
- [x] **High-yield spans** — done 2026-09-12, and half of it deliberately not
|
||||||
or hide them.
|
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
|
## Asked for on 2026-09-11 (evening), not yet done
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,12 +123,21 @@ Four steps:
|
||||||
`next((r[1] for r in rows if r[0] == qid), None)` inside a loop over every
|
`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
|
answer. That is O(answers × candidates); at 2,000 candidates and a few
|
||||||
thousand answers it is the slowest part of building a session.
|
thousand answers it is the slowest part of building a session.
|
||||||
- **Difficulty is not used in the ordering**, only as a filter. A genuine
|
- **Difficulty is not used in the ordering**, only as a filter — and as of
|
||||||
adaptive scheme would move up and down the difficulty range as you do well
|
2026-09-12 it cannot be. Every one of the 2,924 questions has a NULL
|
||||||
or badly; this one does not.
|
`difficulty`, and deriving it empirically is no better: 788 answers over 706
|
||||||
- **Step 2 sorts by raw category accuracy, not readiness.** The shrinkage that
|
questions from 5 learners is about one answer each, so a measured difficulty
|
||||||
the recommendations page applies precisely so small samples do not mislead is
|
would be 0% or 100% per question. A ladder scored against either would be
|
||||||
not applied here, so a category with one wrong answer can sort first.
|
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
|
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.
|
limitations of the current design and would be the substance of a better one.
|
||||||
|
|
|
||||||
|
|
@ -320,3 +320,9 @@
|
||||||
.custom-test-refresh { grid-column: 1; white-space: nowrap; }
|
.custom-test-refresh { grid-column: 1; white-space: nowrap; }
|
||||||
.custom-test-start { grid-column: 2; width: 100%; min-height: 44px; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ export default function CustomQuizPage() {
|
||||||
return Number.isInteger(fromUrl) && fromUrl >= 1 && fromUrl <= 200 ? fromUrl : 20
|
return Number.isInteger(fromUrl) && fromUrl >= 1 && fromUrl <= 200 ? fromUrl : 20
|
||||||
})
|
})
|
||||||
const [difficulty, setDifficulty] = useState('')
|
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 [adaptive, setAdaptive] = useState(() => searchParams.get('adaptive') === '1')
|
||||||
const [available, setAvailable] = useState(null)
|
const [available, setAvailable] = useState(null)
|
||||||
const [countKey, setCountKey] = useState(null)
|
const [countKey, setCountKey] = useState(null)
|
||||||
|
|
@ -57,6 +60,9 @@ export default function CustomQuizPage() {
|
||||||
const [globalSearch, setGlobalSearch] = useState('')
|
const [globalSearch, setGlobalSearch] = useState('')
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
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])
|
const filterKey = JSON.stringify([categoryIds, state, difficulty, articleIds, tagIds, systemIds, refresh])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -83,7 +89,14 @@ export default function CustomQuizPage() {
|
||||||
tagIds.forEach(id => params.append('tag_ids', id))
|
tagIds.forEach(id => params.append('tag_ids', id))
|
||||||
if (systemIds.length) params.append('system_ids', systemIds.join(','))
|
if (systemIds.length) params.append('system_ids', systemIds.join(','))
|
||||||
api.get('/questions/builder/count', { params }).then(r => {
|
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.') })
|
}).catch(() => { if (active) setCountError('Could not load available count. Try refreshing.') })
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [filterKey])
|
}, [filterKey])
|
||||||
|
|
@ -467,13 +480,25 @@ export default function CustomQuizPage() {
|
||||||
</FacetPicker>
|
</FacetPicker>
|
||||||
|
|
||||||
<FacetPicker title="Difficulty" open={openFacet === 'difficulty'} onClose={() => setOpenFacet(null)}
|
<FacetPicker title="Difficulty" open={openFacet === 'difficulty'} onClose={() => setOpenFacet(null)}
|
||||||
onReset={() => setDifficulty('')} helper="Applies to every question in the test.">
|
onReset={() => setDifficulty('')}
|
||||||
{() => ['', 'easy', 'medium', 'hard'].map(value => (
|
helper={difficultyCounts && !rated
|
||||||
<label key={value || 'any'}>
|
? 'No question in the bank has been given a difficulty yet, so these choose nothing.'
|
||||||
<input type="radio" name="difficulty-facet" checked={difficulty === value} onChange={() => setDifficulty(value)} />
|
: 'Applies to every question in the test.'}>
|
||||||
{DIFFICULTY_LABEL[value]}
|
{() => ['', 'easy', 'medium', 'hard'].map(value => {
|
||||||
</label>
|
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>
|
||||||
|
|
||||||
<FacetPicker title="Status" open={openFacet === 'status'} onClose={() => setOpenFacet(null)}
|
<FacetPicker title="Status" open={openFacet === 'status'} onClose={() => setOpenFacet(null)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue