nDCG@k
# grade this stage (from your course directory)
npx theinference test --stage ndcg --watchPut it together: nDCG@k = DCG@k / IDCG@k, a 0–1 score where 1 = a perfect ranking. The "n" is normalized — dividing by IDCG makes scores comparable across queries with different numbers of relevant docs.
nDCG@k = DCG@k / IDCG@k (0 if IDCG@k is 0)
What to build
Implement ndcgAtK
Implement ndcgAtK(ranking, relevant, k) in src/metrics.ts — ranking is the full ranked list (top-100); relevant is a Map<docIndex, relevance>. Take the first k ranked docs, map each to its relevance (the Map value, or 0 if absent) to get gains, then return dcgAtK(gains, k) / idcgAtK([...relevant.values()], k) (return 0 if IDCG is 0).
export function ndcgAtK(ranking: number[], relevant: Map<number, number>, k: number): number {
// gains = ranking.slice(0,k).map(d => relevant.get(d) ?? 0)
// return dcgAtK(gains,k) / idcgAtK([...relevant.values()], k) (0 if idcg===0)
}
adapter — allclose vs reference on a hand-built (ranking, relevant) case, atol=1e-6. (The end-to-end mean nDCG@10 is graded two steps later, in ndcg-eval.)
A frequent slip: the gains list is indexed by rank position (the first k ranked docs, scored 1 if relevant else 0). The IDCG denominator uses all the relevant docs' values, regardless of where (or whether) they were retrieved. Mixing those two up tanks your nDCG.