ADR-0003: A hand-labeled judged-query set for the free-text lens¶
Date: 2026-06-03 Status: Accepted
Context¶
Through Phase 2 the leaderboard measured only item-to-item similarity on the
cross-listing lens. That lens is near-trivial for lexical methods (twins share
text) and could not separate any technique — every CI overlapped. But the whole
premise of topic and semantic methods is that they win on free-text queries
("practical deep learning"), via recommend_by_text — a mode with no automatic
ground truth (plan §3 lens 3, §6.3). Without a way to score it, adding more
powerful techniques means shipping them into a harness that cannot tell whether
they are actually better. Closing this gap was chosen ahead of, then alongside,
Phase 3.
Two design points were non-obvious:
- Where the ground truth comes from. There are no clicks or ratings, and the relevant set for a query is open-ended (any of 11k courses could be on-topic).
- How not to bias the labels toward the methods under test. If labels were generated by running a lexical recommender and keeping its top hits, the lens would reward lexical methods by construction.
Decision¶
- Hand-label a small natural-query set.
data/judged_queries.jsonholds 22 natural-language queries, each with 3–9course_ids a human judged clearly on-topic (125 labels total, spanning 34 subjects). Queries are deliberately phrased in words that differ from course titles (e.g. "building web applications" for courses titled "Front-End Web Architecture") so the lens rewards meaning, not surface overlap. - Treat the set as relative, not absolute, ground truth. It is curated and necessarily incomplete, so absence from a relevant set is not evidence of irrelevance. Recall is read as a comparison across techniques on the same set, never as an absolute hit rate.
- Score with the same metrics as the primary lens.
score_text_queries(eval.py) reuses the binary ranking metrics and the NDCG@10 bootstrap CI via the shared_aggregate_ranking_metricshelper.same_subject@10is undefined for a query with no seed subject and is reported as NaN (dropped from the text leaderboard). - Version-control the labels; commit them. The set is curated ground truth,
so it is force-committed via a
.gitignoreexception (!data/judged_queries.json) even though the rest ofdata/is ignored.scripts/build_judged_queries.pyvalidates every label resolves against the catalog (--validate, exit-coded for CI), summarizes the set (--stats), and can suggest lexical candidates to seed — never decide — hand labels (--suggest). - Skip + flag, never silently omit. Item-to-item-only techniques that raise
NotImplementedErrorfromrecommend_by_textare detected (recommender_supports_text) and listed as skipped in the text leaderboard.
Alternatives considered¶
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| LLM-as-judge to rate top-k relevance | Scales to many queries; no manual labeling | Needs a key (breaks local-only); must itself be validated against human labels before trusting | Deferred to Phase 7; a hand set is the validation baseline it would need anyway |
| Derive relevant sets from a lexical recommender's output | Fully automatic | Biases ground truth toward lexical methods — defeats the lens | A lens that rewards the method under test measures nothing |
| Use department/subject as the relevant set for a topic query | Automatic, large | Subject ≠ topic; rewards the useless same-subject-only model (plan §3 lens 2) | Already covered (and cautioned against) by the same-subject floor |
Store the set under data/ (gitignored) |
Consistent with other data | Ground truth would not be committed; eval not reproducible on a fresh clone | Curated labels must travel with the repo |
Consequences¶
Positive: Free-text mode is finally measurable, and the lens discriminates
where cross-listing could not — on this set NDCG@10 spreads from ~0.61 (lexical /
SBERT) down to ~0.07 (NMF/LDA at k=50), so it cleanly separates methods that
collapse on short queries from those that don't. Phase 3 lands in a harness that
can score it.
Negative: 22 queries / 125 labels is small, so text-lens CIs are wide
(~±0.11 on NDCG@10) — differences among the top methods are not significant.
Labels reflect one labeler's judgment and a single catalog snapshot.
Neutral: Maintenance cost — a future catalog revision can strand a label;
load_judged_queries drops stale ids with a warning and the validator catches
them, so the set degrades loudly, not silently.
Implementation notes¶
src/courserec/eval.py: JudgedQuery, load_judged_queries,
score_text_queries, recommender_supports_text, and the extracted
_aggregate_ranking_metrics (shared with score_crosslist).
scripts/build_judged_queries.py: validate / stats / suggest.
scripts/run_eval.py: writes a companion results/leaderboard_text.{md,csv}.
Tests in tests/test_eval.py cover stale-id dropping, the metrics, the NaN
subject, and the capability probe. See also ADR-0004.