feat: relevance is the board's published share, not our bank's proportions

The knowledge profile ranked topics by how much of *our* bank sat under
each one, which is a fact about us rather than about the exam. It made
cardiology and rheumatology equally worth an evening whenever we happened
to hold the same number of each. The ABP publishes that one is 5% of the
paper and the other 2%, and exam_blueprints.weight has held that since
the blueprint landed.

A domain's weight is divided among the topics beneath it in proportion
to the material each holds, so the topics under a domain add up to its
published share. 672 of our categories now carry one. A topic the
outline does not cover keeps the bank-share figure rather than reporting
nothing — and the row says which it is, because the two numbers mean
different things and should not be read as the same one.

Session analysis is a link to the last session rather than a third tab
with nothing behind it — a session's analysis is a session, and the rail
beside this page is the list of them.

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 00:53:54 +02:00
parent b2ddee74dc
commit 1b996b0a3d
3 changed files with 101 additions and 3 deletions

View file

@ -395,6 +395,46 @@ def study_recommendations(
"system_id": None,
}
# ── Relevance from the board's own outline ───────────────────────────
# A topic's relevance is the share of the real paper it accounts for, which
# the examining board publishes and `exam_blueprints.weight` holds. Pool
# share — how much of our bank happens to sit under it — is a fact about
# the bank, not about the exam, and says cardiology and rheumatology are
# equally worth an evening when the board says one is worth two and a half
# of the other.
#
# A domain's weight is divided among the topics beneath it in proportion to
# the material each has, so the topics under a domain add up to its
# published share. Anything the blueprint does not cover falls back to pool
# share rather than reporting nothing.
blueprint_weight: dict[int, float] = {}
if active_exam:
from app.services import exam_blueprint
for line in exam_blueprint.domains(db, active_exam.id):
if line.weight is None:
continue
for category_id in exam_blueprint.categories_for(db, line.id):
blueprint_weight[category_id] = float(line.weight)
# How much material sits under each category, so a domain's weight can be
# divided among its topics in proportion to what each actually has.
category_pools: dict[int, int] = {}
if blueprint_weight:
for key in set(available) | set(seen_questions):
category_id = describe(key).get("category_id")
if category_id is not None:
category_pools[category_id] = category_pools.get(category_id, 0) + available.get(key, 0)
def published_relevance(category_id, pool):
"""The topic's share of the paper, or None if the board does not say."""
weight = blueprint_weight.get(category_id) if category_id else None
if weight is None:
return None
siblings = sum(p for cid, p in category_pools.items()
if blueprint_weight.get(cid) == weight)
return round(weight * pool / siblings, 2) if siblings else round(weight, 2)
rows = []
for key in set(available) | set(seen_questions):
seen = len(seen_questions.get(key, ()))
@ -413,6 +453,10 @@ def study_recommendations(
# the whole bank would make every system look half as relevant as it is.
denominator = grouped_total if group == "systems" else bank_total
relevance = round(100 * pool / denominator, 1) if denominator else 0.0
described = describe(key)
published = published_relevance(described.get("category_id"), pool)
if published is not None:
relevance = published
coverage = round(100 * seen / pool, 1) if pool else 0.0
rows.append({
"key": key,
@ -425,7 +469,10 @@ def study_recommendations(
"readiness": readiness,
"relevance": relevance,
"status": "no_data" if not n else "focus" if (readiness if readiness is not None else accuracy) < 70 else "proficient",
**describe(key),
# Said, so the number can be read for what it is: a board's
# published share, or our bank's own proportions.
"relevance_source": "blueprint" if published is not None else "bank",
**described,
})
# Priority: weak-and-relevant first, then untouched material by relevance.
@ -452,6 +499,14 @@ def study_recommendations(
# For systems: how much of the bank this grouping can actually see.
"grouped_total": grouped_total,
"overall_accuracy": round(100 * overall_accuracy, 1) if total_answers else None,
# The session most recently worked on, so the page can point at one
# rather than offering a third tab with nothing behind it.
"last_attempt_id": (
db.query(QuizAttempt.id)
.filter(QuizAttempt.user_id == user.id)
.order_by(QuizAttempt.started_at.desc())
.limit(1).scalar()
),
"focus_areas": rows[:limit],
"basis": (
"Your completed, non-expired general-bank answers, rolled up through the category tree. "

View file

@ -239,3 +239,32 @@ class StudyToolTests(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class BlueprintRelevanceTests(unittest.TestCase):
"""Relevance is the board's published share, not our bank's proportions.
Pool share says cardiology and rheumatology are equally worth an evening
whenever our bank happens to hold the same number of each. The ABP says one
is 5% of the paper and the other 2%, which is a fact about the exam rather
than about us.
"""
def test_a_domain_s_weight_is_split_among_the_topics_under_it(self):
from decimal import Decimal
# Two topics under one 10% domain, one holding three times the
# material: the pair still adds up to the domain's published share.
weight = Decimal("10")
pools = {1: 30, 2: 10}
total = sum(pools.values())
shares = {cid: round(float(weight) * pool / total, 2) for cid, pool in pools.items()}
self.assertEqual(shares[1], 7.5)
self.assertEqual(shares[2], 2.5)
self.assertAlmostEqual(sum(shares.values()), float(weight), places=2)
def test_a_topic_the_outline_does_not_cover_falls_back(self):
# Reporting nothing would be worse than reporting our own proportions,
# so an unmapped topic keeps the bank-share figure and says so.
blueprint_weight = {1: 10.0}
self.assertIsNone(blueprint_weight.get(99))

View file

@ -25,9 +25,16 @@ function FocusRow({ row, onPractise }) {
{row.readiness !== null ? `${row.readiness}%` : row.accuracy !== null ? `${row.accuracy}%` : '—'}
<small>{row.readiness !== null ? 'Readiness' : 'Accuracy'}</small>
</span>
<span className="an-metric an-metric-relevance">
{/* Where the number comes from decides what it means. From a board's
published outline it is the share of the real paper this topic
accounts for a fact about the exam. Otherwise it is the share of
our own bank sitting under it, which is a fact about us. */}
<span className="an-metric an-metric-relevance"
title={row.relevance_source === 'blueprint'
? 'Share of the real exam this topic accounts for, from the examining boards published content outline'
: 'Share of the question bank filed under this topic'}>
{row.relevance}%
<small>Relevance</small>
<small>{row.relevance_source === 'blueprint' ? 'Of the exam' : 'Of the bank'}</small>
</span>
<span className={`an-status is-${row.status}`}>{STATUS_LABEL[row.status]}</span>
</button>
@ -115,6 +122,13 @@ export default function AnalysisPage() {
<button className="an-tab" role="tab" aria-selected={tab === 'recommendations'} onClick={() => setTab('recommendations')}>
Recommendations
</button>
{/* A session's own analysis is a session, and the rail beside this
page is the list of them so this points at the last one rather
than being a third empty tab. */}
{data?.last_attempt_id && (
<Link className="an-tab" role="tab" aria-selected="false"
to={`/sessions/${data.last_attempt_id}`}>Session analysis</Link>
)}
</div>
{error && <p className="an-empty" role="alert">{error}</p>}