rankscope · learning edition

Look inside a retrieval ranking

Every idea in rankscope, from a rank with a level to a pre-registered verdict, as a number you can move with a slider. Each lesson explains one module, gives you an instrument that recomputes as you change its inputs, and ends with questions whose answers stay hidden until you have tried.

The arithmetic on this page is the same kernel the package ships (0.4.0); lesson 14 shows it producing the reference vectors line for line.

00Orientation: the questions after the metric

read · orientation, no instrument

A search system, a RAG retriever, or a "find the matching document" feature produces a ranked list. Teams summarise it with one number, say recall@10 = 0.53, and decide from that. rankscope exists because that number hides four things: whether it is stable, which class of query fails, which component found each answer, and whether the system should have said "not found". Everything below is an instrument for one of those.

lanesTREC runs, JSONL qrelsgrades, negatives, strata ranksdoc · group metrics + intervals miss attribution fusion (rrf, convex) gate: accept / none verdict

Vocabulary

lane
one retrieval system's ranked output for every query. A TREC run file is a lane.
qrels
graded relevance judgments. A query judged with no positive grade is a negative: the corpus holds no answer for it.
doc / group
two levels of "right": the exact passage, or any passage of the right document.
stratum
a label per query, so tables can show the class an average hides.
window
how many candidates the next stage sees. Attribution is measured inside it.
floor
a score below which the top hit is not evidence that the answer exists.
Check yourself
  1. Why is a query with all-zero grades treated as a negative rather than as "unjudged"?
    answer
    Because someone looked and found nothing relevant. That is information: the correct output is "not found". A query with no qrels rows at all is unjudged and is ignored, which is the TREC convention.
  2. Name a system where the window is 5 and one where it is 40.
    answer
    A verifier shown five crops, or a reranker fed five candidates, has window 5. A reader that stuffs forty passages into a prompt has window 40. Attribution must be measured at the window the next stage actually sees.

01Lanes and judgments

module · lanes.py, ranks.Qrels

A TREC run line is qid Q0 docid rank score tag. rankscope orders the hits of a query by score, descending, breaking ties on the doc id, exactly as trec_eval does; the rank column is not trusted. A qrels line is qid 0 docid grade. Edit either box and the parsed view updates.

Instrument · parse a run and its qrels
bm25.run
dense.run
qrels.txt
Check yourself
  1. In the bm25 box, move press-guide#p7 for q4 to the first line but keep its score 3.2. Where does it land?
    answer
    Still last: order comes from the score column, not from line order or the rank column.
  2. Add a line q9 Q0 acme-manual#p02 1 5.0 bm25. What happens to q9?
    answer
    It is counted as unjudged and ignored, because q9 has no qrels row. To make it a negative, give it a qrels row with grade 0.
  3. Delete every q3 line from the dense box. What does the parser say, and why does rankscope refuse to stay silent about it?
    answer
    "Judged but missing from a lane: dense 1." The query is scored as a miss for that lane, which is the right score, but a run file with a bug looks identical to a bad retriever unless the count is printed. Since 0.2.0 the report header carries it.

02A rank has a level

module · ranks.rank_of, group_by_separator

rank_of returns the 1-based position of the first relevant hit, and 0 for a miss. Zero is a sentinel: every consumer tests 0 < r <= k. With a group function, the same ranking has a second rank: the first hit whose group contains a relevant document. Group level answers "right document?"; doc level answers "right passage inside it?". They fail differently and want different fixes.

Instrument · doc rank and group rank, from the data above

Highlighted rows are found at group level but missed at doc level: the lane reached the right document and ranked the wrong part of it.

Check yourself
  1. Which fix does a group hit with a doc miss call for?
    answer
    Re-ranking inside the document the lane already found (or accepting at document level), not more retrieval. A group miss is retrieval, coverage or absence.
  2. Change the separator to -. What happens to the group column and why?
    answer
    Every doc becomes its own group (the ids have no dash before the separator would split), so the group rank equals the doc rank. The group function defines what "same document" means; choose it from your id convention.

03hit@k, recall@k, MRR and nDCG

module · metrics.py

Four metrics, four different questions. hit@k: did any relevant document make the top k. recall@k: what fraction of the relevant documents did. MRR: how deep is the first one. nDCG@k: how good is the whole top k, weighting graded relevance by position. hit@k is a proportion and gets a Wilson interval; the others are means, so the report gives them a percentile bootstrap interval (lesson 12 shows the machinery), and two systems are compared with a paired bootstrap.

gain(rank) = 2^grade − 1 discount(rank) = 1 / log2(rank + 1) DCG@k = Σ gain · discount over the top k nDCG@k = DCG@k / DCG@k of the ideal ordering
Instrument · one query, every metric, every term shown
Check yourself
  1. Set the ranking to c, b, a with the same grades. Why is nDCG below 1 even though every relevant document is in the top 3?
    answer
    The ideal order puts the grade-2 document first. nDCG rewards ordering, not presence; recall@3 is 1.0 and hit@3 is 1.0, nDCG is not.
  2. When do hit@k and recall@k coincide?
    answer
    When a query has exactly one relevant document. Most single-answer benchmarks are in that case, which is why the two are often confused.

04The Wilson interval and the rule of three

module · stats.wilson, rule_of_three, negatives_for

A hit rate of 8 out of 12 is not "67 percent". It is "somewhere between 39 and 86 percent". The Wilson score interval says so without collapsing to zero width at 0 or n out of n, which the textbook normal approximation does. After zero events in n trials the upper limit is z²/(n+z²), which is where the table of "how many negatives do I need" comes from.

p = s/n, z = 1.96 centre = (p + z²/2n) / (1 + z²/n) half = z · √( p(1−p)/n + z²/4n² ) / (1 + z²/n) interval = [centre − half, centre + half], clipped to [0, 1]
Instrument · move s and n
Check yourself
  1. Set s = 0, n = 3. What is the most you can say about the false-accept rate?
    answer
    That it is at most about 56 percent. Zero of three is almost no evidence; the rule of three gives 3/3 = 100 percent, Wilson is a little tighter.
  2. How many negatives with zero false accepts do you need to claim a rate at or below 2 percent?
    answer
    189 by Wilson (z²·0.98/0.02), 150 by the rule of three. Type 0.02 in the bound box to see it computed.

05Strata: what the average hides

module · metrics.summarise, the --strata and --blind options

A stratum is a label per query. rankscope reports every table per stratum and overall, because a mean can be respectable while one class of query is at zero. The stratum you name with --blind is the one the attribution block singles out: how many of a lane's misses fall in it.

Instrument · hits per stratum, and the overall row that hides them
stratumquerieshits within khit rate95% interval
Check yourself
  1. With the default numbers the overall hit rate is above 0.5. Which stratum is at zero, and what decision would the overall number alone have led to?
    answer
    The hard stratum: 0 of 14. Reading only the overall row you would have called the lane acceptable and shipped a component that fails a third of the traffic.
  2. Why should strata come from labels rather than from the query text alone?
    answer
    A label-derived stratum (for example, queries sharing no term with their relevant document) is a measurement. A query-derived proxy has to be validated against labelled data before it is allowed to gate anything.

06Miss attribution: whose fault is a miss

module · metrics.miss_attribution, the --window and --corpus options

For each positive, given every lane's doc rank and the window: a relevant document absent from the corpus is not_indexed (coverage; no lane can help). No lane inside the window is not_surfaced (retrieval). Otherwise the label names the lanes that carried it. The share of positives a new lane surfaces that no other lane does is the number that says whether it earns its place.

Instrument · ranks per lane, a corpus flag, and a window
queryindexedlexical rankdense rankattribution
Check yourself
  1. Set the window to 20. Which labels change, and why is "not surfaced at 5, hit@20 0.8" not a contradiction?
    answer
    Rows with ranks between 6 and 20 move from not_surfaced to surfaced. They measure different windows: hit@20 asks whether the lane found it at all, attribution asks whether the next stage would have seen it.
  2. Why are not_indexed queries excluded from the not-surfaced share?
    answer
    Their fix is indexing, not ranking. Counting them as retrieval misses would blame the lanes for a coverage gap and hide the real not-surfaced rate.

07Reciprocal rank fusion, and what it dilutes

module · fusion.rrf
score(doc) = Σ over lanes where doc appears at rank r of 1 / (k + r) k = 60 by default

Presence in two lanes beats being first in one: rank 1 in a single lane scores 1/61 ≈ 0.0164, while ranks 3 and 5 in two lanes score 1/63 + 1/65 ≈ 0.0313. That is the point of RRF and also its cost: a lane's confident-wrong top hits can dilute another lane's correct top hit. Small k trusts the top of each lane; large k trusts presence. The instrument opens on exactly that case.

Instrument · two lanes, one k
Check yourself
  1. At k = 60, truth is rank 1 in dense and absent from lexical, yet x wins the fusion. Show the two numbers.
    answer
    truth: 1/61 = 0.01639. x: 1/62 + 1/62 = 0.03226. Two mediocre positions beat one first place.
  2. Cut the depth to 1. Which documents vanish from the fusion, and why does rankscope now print each lane's depth in the header and offer --depth?
    answer
    Everything below rank 1 in either lane. RRF over a 20-deep lane and a 100-deep lane is a different function from RRF over equal depths: a document below a lane's depth is absent from it and scores nothing there. Uniform depth makes lanes comparable; the header warns when they differ.
  3. Lower k until truth wins. Roughly where does it flip, and what does that say about k?
    answer
    Not until k is tiny: 1/(k+1) versus 2/(k+2) only favours truth when k < 0, so with x at rank 2 in both lanes truth never wins by k alone. Move x to rank 3 in one lane and the flip appears around small k. The lesson: k reshapes how much rank matters versus presence, but it cannot undo agreement between lanes.

08Convex fusion, and fitting weights without lying

module · fusion.minmax, convex, fit_weights · calibrate.py

Each lane's scores are min-max normalised over its own list (absent document = 0, constant list = all ones), then summed with weights on the simplex. calibrate walks a grid of weight vectors and keeps the one with the best mean objective over the calibration queries. The instrument fits on one synthetic query set and evaluates on another, then lets you cheat.

Instrument · grid fit on calibration queries, evaluation on held-out queries
Check yourself
  1. Tick the cheat box. Which number moves, and in which direction, and why is it always that direction?
    answer
    The held-out objective rises, because the weights were chosen to maximise it on those very queries. Selection on the reporting set can only flatter; that is why calibrate and evaluate read different files.
  2. Set calibration queries to 5. What happens to the fitted weight across seeds?
    answer
    It jumps around. A grid of eleven candidates fitted on five queries picks a winner by noise; the held-out number tells you how much.

09The null floor

module · stats.null_floor

Run the negatives, the queries whose answer is not in the corpus, and record the best score each one reaches. Over those n maxima, floor = mean + 3·std. A top score below the floor is relative evidence only: it orders candidates but says nothing about whether the answer exists. The floor is a property of the negatives you fed it, which is the whole lesson: negatives from another domain give a floor that looks safe and is not.

Instrument · sample negatives and true matches, then read the floor
negatives, best scorepositives, true-match scorefloor = mean + 3·std
Check yourself
  1. Press the hard preset. Where does the floor land relative to the true matches, and what does that mean for this score?
    answer
    Above most of them: FNIR is high while FAR is near zero. This score cannot serve as absolute evidence on these negatives; something else (text, a margin, a verifier) must carry "not found".
  2. Press the easy preset, then imagine deploying that floor against the hard negatives. What happens?
    answer
    The floor is lower, so a large share of hard negatives clear it: false accepts. Calibrate on negatives that resemble the positives you will face.
  3. Set n = 3. Why does rankscope print n beside every floor?
    answer
    Three maxima give a standard deviation with almost no information; the floor is arithmetic, not evidence. With n = 0 the floor is +inf: fail closed.

10The conformal floor, and how few is too few

module · stats.conformal_quantile, conformal_min_n, conformal_floor
nonconformity = −score of each calibration true match q = the ⌈(n + 1)(1 − α)⌉-th smallest nonconformity floor = −q P(new true match scores ≥ floor) ≥ 1 − α, for exchangeable data

A finite-sample guarantee on misses, with one catch: when the index exceeds n there is no finite threshold with that guarantee. rankscope returns +inf (a floor of −inf: accept everything, decide nothing) instead of quietly using the sample minimum, whose coverage is only n/(n+1). And a conformal floor bounds misses; it says nothing about false accepts.

Instrument · the index, the small-n regime, and a coverage simulation
Check yourself
  1. At α = 0.1, for which n is the floor exactly the smallest calibration score?
    answer
    n from 9 to 18: the index equals n. Below 9 there is no finite bound; from 19 the floor is the second smallest.
  2. Run the simulation at n = 8 and n = 40. What does each observed miss rate say?
    answer
    At n = 8 the floor is −inf, so the miss rate is 0: the guarantee holds trivially and uselessly. At n = 40 the miss rate sits near α, sometimes above, sometimes below: the guarantee is marginal over calibration sets.

11The gate: FNIR at a fixed FPIR

module · gate.decide, evaluate, sweep, operating_point
no candidates → abstain top score < floor → abstain top − second < margin → abstain else accept the top hit FAR (FPIR) = false accepts / negatives FNIR = (wrong accepts + abstains) / positives

A wrong accept counts as a miss, the 1:N identification convention. sweep evaluates every floor the observed top scores suggest, in linear time: the scores a floor alone would decide are sorted once and each floor is a bisection, so a sweep over thousands of queries takes a fraction of a second rather than the twenty seconds a re-evaluation per floor did. operating_point picks the lowest floor whose FPIR is within a target, on the point estimate or, with --conservative, on the Wilson upper bound. Picked on calibration negatives it is a threshold; picked on the negatives you report it is tuning. The curve below uses the samples from lesson 9.

Two more conditions live in the same gate. The margin is the gap between the top score and the runner-up; margin_sweep does for that gap what the floor sweep does for the score, and calibrate writes an operating margin beside the operating floor. A lone candidate has an infinite gap and passes every margin, which is why a margin alone cannot drive FPIR to zero. Mutual nearest neighbour reads the ranking in the other direction: from the lanes, rankscope builds, for every document, the queries that retrieved it, best first; a gate with mutual = r accepts only when the query is within the top r of that list for its own top document. It is cheap, needs no scores to be comparable across queries, and is vacuous on a tiny query set.

Instrument · the FPIR / FNIR trade-off curve
sweep: FNIR against FPIR, one point per floorchosen floor
Check yourself
  1. Why is the curve read from calibration negatives and then applied to held-out negatives?
    answer
    Choosing the floor on the negatives you report lets the sample's noise pick the threshold and the reported FAR is then optimistic. The held-out FAR at a pre-chosen floor is the measurement.
  2. With 30 negatives, what is the smallest non-zero FPIR you can observe?
    answer
    1/30 ≈ 3.3 percent. Claiming FPIR ≤ 2 percent needs zero false accepts in at least 150 to 189 negatives (lesson 4).
  3. Why can a margin gate never reject a query whose lane returned a single candidate, and what does that imply for the sweep's last row?
    answer
    The gap to a non-existent runner-up is infinite, so it clears any finite margin. The last row of the margin sweep, at margin = infinity, still accepts those queries; if some are negatives, FPIR does not reach zero by margin alone and a floor is needed as well.
  4. Tick conservative with 60 negatives and a target of 0.05. Why is there no floor, and what changes if you set the negatives slider to 80?
    answer
    Zero false accepts in 60 only bounds FPIR at 6 percent on the Wilson upper limit, above the target, so no finite floor can honestly promise 5 percent. At 73 or more negatives the bound drops below 5 percent and a finite floor appears. The point estimate would have handed you a floor either way; the bound tells you what you can actually claim.

12Paired bootstrap: is B really better than A?

module · stats.paired_bootstrap · the compare command

Two systems scored on the same queries give one delta per query. Resample the queries with replacement, average the deltas each time, and read the 95 percent interval and P(delta > 0). rankscope calls it better only when P ≥ 0.95 and the mean delta clears a minimum effect size you choose, because with enough queries a delta of 0.001 becomes "significant" and still means nothing. Pairing removes query difficulty as a noise source; the toggle shows how much.

Instrument · simulate two systems and resample
bootstrap distribution of the mean deltazero
Check yourself
  1. Tick unpaired. What happens to the interval, and why?
    answer
    It widens: query difficulty now varies between the two resamples, adding variance the pairing had cancelled. The paired test is strictly more sensitive when both systems answer the same queries.
  2. Set true delta to 0.01, queries to 400, minimum effect to 0.02. P(better) is high; is B better?
    answer
    Statistically distinguishable, practically not: the delta is below the minimum effect you set. The verdict says not shown better, and that is the right reading.

13Pre-registered verdicts

module · verdict.py · the gates.json rules file

A rule is a dotted path into the report, >= or <=, and a threshold. It passes on the point when the value clears the threshold and on the interval when the conservative end of the Wilson interval does. A rule may instead name a second path with against: "fusion hit@5 must be at least the best single lane's hit@5", judged on the interval only when the two intervals do not overlap, so a new baseline never invalidates a hard-coded number. Write the rules before the run and commit them. The instrument judges a small report; edit the thresholds and watch the two columns disagree.

Instrument · rules against a report
rulepathopthresholdmeasured95% intervalpointinterval
Check yourself
  1. All rules pass on the point and one is open on the interval. When is deciding on points acceptable?
    answer
    When being wrong is cheap and reversible, and the direction is what you need. When a false pass costs a shipped failure, require the interval or collect more queries first.
  2. You saw the number and then lowered a threshold so it passes. What did you just do?
    answer
    Turned a measurement into a decoration. The file's history in version control is the only thing that makes a threshold a commitment.

14Goldens: parity across implementations

module · goldens.py · reference/js/rankscope.mjs

rankscope goldens writes reference vectors, one line per case, in the form kind args -> answer. Another implementation prints the same lines with its own answers and rankscope goldens --check diffs them numerically with a tolerance of 1e-9. This page carries a JavaScript port of the kernel; the box below is that port computing the lines live. If the numbers here ever disagree with the file the package writes, one of the two is wrong.

Instrument · golden lines computed by this page

     
  
Check yourself
  1. Why is the tolerance 1e-9 and not exact string equality?
    answer
    Different languages format floats differently and may sum in a different order. A numeric tolerance below any value that could change a decision catches real drift without failing on formatting.
  2. What does the conformal line with eight values prove?
    answer
    That both implementations agree on the small-n rule: eight calibration scores at α = 0.1 give inf, not the minimum.

15Putting it together on the command line

read · the workflow
# 1. fit weights and floors on a development set
rankscope calibrate --lane bm25.run --lane dense.run --qrels qrels-dev.txt --gate-on dense --depth 100 --conservative --out thresholds.json

# 2. report on the test set, strata and the blind class named
rankscope evaluate --lane bm25.run --lane dense.run --qrels qrels-test.txt --thresholds thresholds.json \
    --strata strata.tsv --blind no-overlap --format markdown --out report.json

# 3. judge against rules you committed before running
rankscope verdict --report report.json --gates gates.json

# 4. is fusion better than the lexical lane alone?
rankscope compare --lane bm25.run --lane dense.run --qrels qrels-test.txt --candidate rrf --metric ndcg@10 --min-delta 0.02

# 5. in CI: did anything regress beyond noise since the stored baseline?
rankscope diff --before baseline.json --after report.json --fail-on-regression

# variants and conditions: a per-group cap, document-first ordering, a mutual-nearest-neighbour gate
rankscope evaluate --lane bm25.run --lane dense.run --qrels qrels-test.txt --group-sep '#' --cap 5 --document-first --mutual 3

# lanes and qrels may also be .json in the {query: {doc: score}} shape pytrec_eval and BEIR use;
# --format latex pastes into a paper, --per-query rows.csv opens in a spreadsheet

Order matters: calibrate on one file, evaluate on another, judge against a file that predates both. --depth cuts every lane to the same depth before anything is computed, and --conservative makes the FPIR floor a claim you can defend. The report's first number to read is not a metric. It is the share of positives your new lane surfaces that no existing lane does, and right after it, the count of judged queries missing from any lane.

Final check
  1. Your dense lane raises hit@10 from 0.44 to 0.52 on 27 queries. List what you need before believing it.
    answer
    The Wilson intervals on both (they overlap heavily at n = 27), a paired bootstrap on the same queries, the per-stratum rows, and attribution showing what the dense lane surfaced that BM25 did not. Then a larger query set.
  2. A pull request moves nDCG@10 from 0.337 to 0.331 on 46 queries. Is that a regression?
    answer
    Not on its own. diff calls a change regressed only when the two bootstrap intervals do not overlap; at n = 46 they overlap by a wide margin, so the verdict is "within noise" and the merge should not be blocked on it.
  3. Your product must answer "not in your documents". What do you need that most benchmarks do not have?
    answer
    Judged negatives that resemble the positives, enough of them to bound the false-accept rate you promise, and a floor calibrated on them rather than on foreign-domain queries.

16Caps and document-first re-ranking

module · fusion.cap_per_group, document_first · evaluate --cap, --document-first

A catalogue with sixty near-identical pages floods the top of a fused list, because its pages share vocabulary and style. A per-group cap keeps at most N hits per group and stops the flood. But a cap applied across groups also removes the right item when the right group is the flooder: a long manual ranks six of its own pages above its one right page, and a cap of five cuts the right page out. Document-first ordering decides the group first, keeps the top hit's whole group in its original order, and caps only the others. rankscope adds both as variants beside the ranking they came from, so the report shows what each rule costs and buys at doc and at group level.

Instrument · one ranking, a cap, and document-first
Check yourself
  1. With the default list, the cap of five removes the relevant item. Why, and which of the two rules keeps it?
    answer
    The right group M holds the six items above the truth, so a cap applied across groups drops M's seventh item, which is the truth. Document-first keeps all of M first and caps only X and Y, so the truth stays at rank 7 and the group is still decided correctly at rank 1.
  2. Move X#a to the front of the list. What does document-first do now, and is that a failure of the rule?
    answer
    It decides the group X first and pushes the whole of M behind X's items; the truth's doc rank gets worse. That is the rule working as designed: it trusts the top hit's group. When the top hit is wrong at group level, deciding the group first cannot help, which is why group-level recall at 1 is the number to check before turning it on.