All findings · Lucene finding · LFV-935 · qualitystats-average-reextrapolates-precision-at-n

QualityStats.average(...).getPrecisionAt(n) extrapolates an already averaged precision: mean P@10 0.4 instead of 0.6

Average a query with 10 relevant hits (P@10 = 1.0) and one with only 2 (P@10 = 0.2), and QualityStats.average(stats).getPrecisionAt(10) returns 0.4, not the mean 0.6. Above the mean result count, the average drops the mean it stored and extrapolates again; the printed SUMMARY hides those cutoffs.

Summary

Priority
Low, rank 584The wrong averaged P@n reaches only programs reading it through the API (the printed summary never shows it) and only when result counts vary and fall below n, which is the narrowest of the three benchmark-metric rows, so it closes that group.
Impact
minor
Since
2.3.0 (January 2008; the code came with LUCENE-836 in 2007)introduced 2007-07-27 · over 19 years
Affected
every release from 2.3.0 to 10.6.0, and main
Status
documented
Found by
bounded-exhaustive comparison with trec_eval's P@n, then a JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

QualityStats.average() stores the right mean P@n for every cutoff, but it also sets the result count to the mean result count, and getPrecisionAt(n) then extrapolates once more for every n above it.

Background: precision at n in Lucene's benchmark module

  1. QualityBenchmark.execute(...) runs each judged query and returns one QualityStats per query. getPrecisionAt(n) is the share of relevant hits among the top n results, for n = 1 to 20.
  2. If a query returned fewer than n results, the missing ranks count as non-relevant: 2 relevant hits out of 2 results give P@10 = 2/10 = 0.2. trec_eval's P_10 is defined the same way.
  3. QualityStats.average(stats) combines the queries into one object; its MAP, recall, MRR and P@n are meant to be the means over the judged queries (those with at least one relevant document).
Summary card for QualityStats.average: two judged queries with 10 and 2 relevant hits have per-query P@10 of 1.0 and 0.2; average() stores the mean 0.6 but sets the result count to 6, and getPrecisionAt(10) extrapolates from P@6 and returns 0.4.
It ends on the summary card, which is also the PNG. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You score a run with the benchmark module's QualityBenchmark and setMaxResults(10). Query "apple" returns 10 hits, all relevant: P@10 = 1.0. Query "banana" has only 2 hits, both relevant: P@10 = 2/10 = 0.2, because missing ranks count as non-relevant, as in trec_eval.
  2. You call QualityStats.average(stats).getPrecisionAt(10) and expect the mean, (1.0 + 0.2) / 2 = 0.6. average() does compute 0.6 and stores it.
  3. Instead you get 0.4. average() also sets the result count to the mean, (10 + 2) / 2 = 6, and for n above 6 getPrecisionAt(n) treats the average like one short query: it ignores the stored 0.6 and returns 6 x P@6 / 10 = 6 x 0.667 / 10.
  4. No error. Here P@7 to P@20 are all wrong (P@20 0.2 instead of 0.3). Other inputs give too-high values (P@6 0.458 instead of 0.417) or 0 at every cutoff when the mean result count is below 1. MAP, recall and MRR are right.
  5. The SUMMARY that log() prints shows only P@1 to P@5 here, the cutoffs below the mean result count, so it never shows a wrong number. Only code that reads the averaged P@n gets one.

Who is affected

Code that reads QualityStats.average(stats).getPrecisionAt(n) in the opt-in benchmark module, when the judged queries return different numbers of results and n (at most 20) is above their mean: for example setMaxResults below 20, a small collection, or queries with few or no hits. The printed SUMMARY is not affected.

Mechanism, terms and numbers793 words

Lucene's benchmark module can score a search run the way TREC does. QualityBenchmark returns one QualityStats per judged query, and QualityStats.average(stats) combines them into one object whose getters are the means over the judged queries (those with at least one relevant document). For precision at n, average() computes and stores the correct mean for every cutoff 1 to 20, but it also sets the object's result count to the mean result count. getPrecisionAt(n) then treats the average like a single query that returned fewer than n hits: for every n above the mean result count it ignores the stored mean and extrapolates a second time. Two queries with 10 and 2 relevant hits give an averaged P@10 of 0.4 instead of 0.6. The value is silently wrong (too low, too high, or 0 when the mean result count is below 1); per-query values, MAP, recall and MRR are right, and the SUMMARY that log() prints never shows an affected cutoff. Only code that reads the averaged P@n through the API sees it, in the opt-in benchmark module. The code has been this way since the package was added (LUCENE-836, first released in 2.3.0).

The contract

  • Class javadoc: "Results of quality benchmark run for a single query or for a set of queries" (QualityStats.java:24).
  • average(): "Create a QualityStats object that is the average of the input QualityStats objects" (:189-194).
  • getPrecisionAt(n): "Return the precision at rank n", for any n from 1 to MAX_POINTS = 20 (:104-109), with no restriction to n <= getNumPoints().
  • getMRR(): "the Mean reciprocal rank over the queries or RR for a single query" (:267-268): the getters of an averaged object are means over the queries.

The mechanism

  1. One query. addResult(n, ...) sets numPoints = n and stores the precision at n in pAt[n] for n <= 20 (:92-99); pAt[0] is "unused" (:72). When the query returned fewer than n results, getPrecisionAt(n) returns (numPoints * pAt[(int) numPoints]) / n (:115-116), which is relevant hits found / n. Correct.
  2. The average. For each query with judgements (:206), average() adds stat.getPrecisionAt(j) to avg.pAt[j] for every j = 1..20 (:214-216), with that extrapolation already included, and adds stat.numPoints to avg.numPoints (:209). Then avg.numPoints /= m (:224) and avg.pAt[j] /= m (:228-230). Now avg.pAt[j] is the correct mean P@j for every j, and avg.numPoints is the mean result count M.
  3. The second extrapolation. avg.getPrecisionAt(n) with n > M takes the one-query branch and returns M * avg.pAt[floor(M)] / n (:115-116), discarding the stored mean avg.pAt[n].
  4. Not affected. log() prints P@i only for i < (int) numPoints (:162), so the SUMMARY shows only cutoffs below M, where the branch does not run. MAP, recall and MRR are averaged directly.

In the example

Query 1 returns 10 results, all relevant; query 2 returns 2, both relevant. M = (10 + 2) / 2 = 6 and avg.pAt[6] = (1.0 + 2/6) / 2 = 0.667, so for n > 6 the returned value is 6 x 0.667 / n = 4 / n.

cutoffquery 1query 2mean (stored)returned
P@51.0000.4000.7000.700
P@61.0000.3330.6670.667
P@71.0000.2860.6430.571
P@101.0000.2000.6000.400
P@150.6670.1330.4000.267
P@200.5000.1000.3000.200

14 of the 20 cutoffs are wrong; the SUMMARY prints P@1 to P@5 (Num Points 6.000), all right.

When the value is wrong

With f = floor(M) and R_i(k) the relevant hits of query i in its top min(k, length) results, the returned value is (M / f) x mean R_i(f) / n and the correct one is mean R_i(n) / n. They agree when every query returned the same number of results. With a whole-number M they agree exactly when no query has a relevant hit at ranks f + 1 to n; with a fractional M they differ except in special cases where the two sides happen to be equal (lists [relevant, relevant] and [relevant] give M = 1.5 and every cutoff right; [relevant, not relevant] and [relevant] give P@2 0.75 instead of 0.5 with no relevant hit after rank 1). The error goes both ways. When M < 1, f = 0 and the branch reads the never-filled avg.pAt[0], so every P@n is 0. (Earlier records said the value is right unless a query has a relevant hit between rank f + 1 and n; that holds only for a whole-number M or M < 1: with 4 and 7 results, M = 5.5, neither query has a relevant hit at rank 6 and P@6 is still 0.458 instead of 0.417.)

Exposure and likelihood239 words
  • Opt-in tool. lucene/benchmark's search-quality package is a developer evaluation tool; nothing in indexing or search uses it.
  • Only programmatic reads. QueryDriver (trec/QueryDriver.java:101-102) and the package's documented sample (package-info.java:68-69) call only average() and log(), and log() prints only the cutoffs below the mean result count, so the printed SUMMARY never shows an affected cutoff. A program that reads avg.getPrecisionAt(n) (as upstream TestQualityRun does for n = 1..20) gets the wrong value silently.
  • When. The judged queries return different numbers of results and n is above their mean. The default maxResults is 1000 (QualityBenchmark.java:64; QueryDriver and the sample also use 1000), which keeps the mean above 20 whenever the judged queries return more than 20 hits on average (broad queries on a larger collection; in upstream TestQualityRun it is 247.6), but not with setMaxResults below 20, a small collection, very selective queries, or queries with no hits.
  • Size of the error. 0.4 instead of 0.6 (P@10, the example), 0.4125 instead of 0.5625 (P@8, an end-to-end run with setMaxResults(8)), 0.458 instead of 0.417 (P@6), and 0 at every cutoff when the mean result count is below 1. Per-query values, MAP, recall and MRR are right.
  • How likely: rare. A maintainer who runs relevance experiments, Robert Muir (@rmuir), has said they use the module's QueryDriver to run the queries and send its output to trec_eval (#9048, 2017; also #3263, 2010), which bypasses this path.

Minimal repro

One JUnit test in lucene/benchmark, public API, no index: two QualityStats built with addResult (10 relevant results; 2 relevant results) are averaged, and the test asserts that the averaged P@10 is 0.6. Deterministic; the seed only satisfies the test framework.

TestQualityStatsAveragePrecisionAt.java (lucene/benchmark, one test)36 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.benchmark.quality;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestQualityStatsAveragePrecisionAt extends LuceneTestCase {

  private static QualityStats allRelevant(int hits) {
    QualityStats stats = new QualityStats(hits, 0);
    for (int rank = 1; rank <= hits; rank++) {
      stats.addResult(rank, true, 0);
    }
    return stats;
  }

  public void testAveragePrecisionAtIsMeanOfPerQueryValues() {
    QualityStats avg = QualityStats.average(new QualityStats[] {allRelevant(10), allRelevant(2)});
    // expected the mean of the per-query P@10 values (1.0 + 0.2) / 2 = 0.6; actual 0.4
    assertEquals(0.6, avg.getPrecisionAt(10), 1e-9);
  }
}
Command
cp $REPRO/TestQualityStatsAveragePrecisionAt.java $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/
cd $LUCENE && ./gradlew :lucene:benchmark:test --tests org.apache.lucene.benchmark.quality.TestQualityStatsAveragePrecisionAt -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
Expected
QualityStats.average(stats).getPrecisionAt(10) is the mean of the per-query P@10 values, (1.0 + 0.2) / 2 = 0.6.
Observed
0.4 (expected:<0.6> but was:<0.4>), with assertions on and off, on main, 10.6.0, 10.5.2 and 9.12.3.

All files of this repro: repro/LFV-935/

More files, environment, full output192 words

allRelevant(hits) builds one query's stats the way QualityBenchmark does: new QualityStats(hits, 0) (hits relevant documents exist) and addResult(rank, true, 0) for ranks 1..hits. The second query's own getPrecisionAt(10) is 2/10 = 0.2, so the mean P@10 is 0.6, which average() stores; the averaged object returns 0.4.

The same numbers come out end to end: index 10 documents "apple" and 2 "banana", all judged relevant, and run QualityBenchmark with setMaxResults(10) for the two queries (the finder's longer test). The minimal test is also the regression test for the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0, 10.5.2 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code is plain double arithmetic)
Command (environment)
cp $REPRO/TestQualityStatsAveragePrecisionAt.java $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/
cd $LUCENE && ./gradlew :lucene:benchmark:test --tests org.apache.lucene.benchmark.quality.TestQualityStatsAveragePrecisionAt -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false

Observed output

TestQualityStatsAveragePrecisionAt > testAveragePrecisionAtIsMeanOfPerQueryValues FAILED
    java.lang.AssertionError: expected:<0.6> but was:<0.4>
:lucene:benchmark:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false, through the standalone runner on main (-ea, -da), on the 10.6.0 release jars (-ea, -da) and on the 10.5.2 and 9.12.3 release jars (-da)
# with the recommended fix: :lucene:benchmark:test (SUCCESS): 1 test

Why it looks unintended

  • average() is documented as "the average of the input QualityStats objects" and stores the correct mean P@n for every cutoff; the extra extrapolation discards it.
  • For a fractional result count the formula is no query's precision; below 1 it returns 0.
  • Upstream TestQualityRun reads the averaged P@n at all 20 cutoffs (with 1000 results, so the branch never runs).
The full argument190 words
  • Contract. The class holds results "for a single query or for a set of queries" (QualityStats.java:24); average() creates "a QualityStats object that is the average of the input QualityStats objects" (:189-194); getPrecisionAt(n) accepts any 1 <= n <= 20 (:104-109); getMRR() is "the Mean reciprocal rank over the queries" (:267-268).
  • average() computes the right answer. It fills avg.pAt[j] with the mean P@j for every j = 1..20 (:214-216, :228-230); the stored means above the mean result count are never returned, and nothing documents why they should be discarded.
  • trec_eval. Its P_n counts missing ranks as non-relevant per query and reports the mean over queries; the per-query getPrecisionAt follows it, and the exhaustive check found 0 per-query mismatches.
  • Upstream test. TestQualityRun asserts 1.0 > avg.getPrecisionAt(j) for j = 1..20 (TestQualityRun.java:149-153), with maxResults = 1000 (:54, :85), so the branch never runs there and the assertion holds either way.
  • History. The branch and average() came together in LUCENE-836 (98fa2d898d1, 2007); no javadoc, CHANGES entry, test or comment on LUCENE-836 (#1911) describes extrapolating an average.
The devil's-advocate check135 words

The strongest case (the cross-model review, the intended-behaviour check and the refute-lens cross-model review): the averaged object is a synthetic query whose length is the mean result count, so cutoffs above it are extrapolated like a short query's, and log() agrees by printing only cutoffs below numPoints. The reading is coherent but undocumented and weak.

Why it fails: average() computes and stores the true mean for every cutoff, which would be pointless under that reading; a fractional length is not a query, and for a mean below 1 the formula returns 0 at every cutoff; the javadoc calls the object an average of the inputs; nothing documents synthetic-query semantics. log() hiding the cutoffs explains why nobody noticed, not that it is intended. The cross-model reviews rated the argument moderate, and every reviewer rejected it.

Suggested fix

Recommended Do not extrapolate the averaged object

Mark the object that average() returns and skip the one-query extrapolation for it, since its pAt[] already holds the mean for every cutoff (1 file, +4/-1). With it the repro and the quality tests pass with assertions on and off, and the whole benchmark module passes; the SUMMARY output is unchanged.

   private long docNamesExtractTime;
+  // true for the result of average(): pAt[] then already holds the mean P@n for every n
+  private boolean isAverage = false;
@@ getPrecisionAt(int n)
-    if (n > numPoints) {
+    if (!isAverage && n > numPoints) {
       return (numPoints * pAt[(int) numPoints]) / n;
@@ average(QualityStats[] stats)
     QualityStats avg = new QualityStats(0, 0);
+    avg.isAverage = true;
All options and trade-offs3 options ยท 398 words

Validated in a scratch worktree of 645d4fe14a20 with gradle (seed DEADBEEF), with the repro test added to lucene/benchmark. The hunt's checks had already validated the same change against its longer tests, the exhaustive harness and upstream TestQualityRun.

Do not extrapolate the averaged object (validated by the checks) (recommended)

In QualityStats.java (1 file, +4/-1): a private flag that average() sets on its result, checked in getPrecisionAt.

   private long docNamesExtractTime;
+  // true for the result of average(): pAt[] then already holds the mean P@n for every n
+  private boolean isAverage = false;
@@ getPrecisionAt(int n)
-    if (n > numPoints) {
+    if (!isAverage && n > numPoints) {
       return (numPoints * pAt[(int) numPoints]) / n;
@@ average(QualityStats[] stats)
     QualityStats avg = new QualityStats(0, 0);
+    avg.isAverage = true;

Trade-offs

  • Smallest change; per-query objects behave exactly as before.
  • getNumPoints() of the average still returns the mean result count, and log() is unchanged, so the SUMMARY output is identical.
  • Edge cases unchanged: with no input stats every P@n stays 0; with no judged query (possible only with assertions off) the values stay NaN.

What the checks validated

  • Stock + test: fails with assertions on and off (expected:<0.6> but was:<0.4>).
  • + fix: the test passes with assertions on and off.
  • + fix: org.apache.lucene.benchmark.quality.* (upstream TestQualityRun's 2 tests and the new test) passes with assertions on and off; the whole lucene/benchmark module (19 suites, 91 tests, 0 skipped) passes with assertions on.
  • :lucene:benchmark:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) passes.
  • Earlier, in the hunt's checks: the finder's 2 tests, the independent reproduction's 4 tests, the refute reviewer's own test and the exhaustive harness (0 of 1,181,520 averaged P@n values wrong) pass with the same change.
  • Not run: the module with assertions off, the full Lucene suite, nightly tests.

Track how many precision slots are filled (not built)

Keep the number of filled pAt slots, min(numPoints, 20) for a query and 20 for an average, and extrapolate only past it.

Trade-offs

Same behaviour as the flag, slightly more general, a few more lines.

Document the extrapolation instead (not recommended, not built)

Say in the javadoc that, for an averaged object, getPrecisionAt(n) with n > getNumPoints() is extrapolated from the mean result count.

Trade-offs

No behaviour change, but it documents a value that is not a mean and is 0 when the mean result count is below 1.

Downstream note

The Lucene.NET port of the benchmark module has the same code in QualityStats.cs, so its averaged P@n has the same error.

How it was found

A bounded-exhaustive differential check ran the stock QualityStats against an independent implementation of trec_eval's P@n, average precision, recall and reciprocal rank over every small set of relevance lists. Per-query values all matched; 341,559 of 1,181,520 averaged P@n values did not, all at cutoffs above the mean result count.

The method in full143 words
  • Method (semi-formal). Translation validation of the stock jar: every ordered pair of relevance lists of length 0..5 (15,840 sets), every ordered triple of length 0..3 (3,375) and 39,861 random sets of 1-8 queries with 0-30 results (59,076 sets with a judged query in all), each compared at all 20 cutoffs with a reference written from trec_eval's definitions. Per query: 0 mismatches in 2,950,908 checks. Averaged: 341,559 of 1,181,520 P@n values (59,076 sets x 20 cutoffs) wrong, none when n <= the mean result count or when all lists have the same length; MAP, recall and MRR 0 mismatches; with the fix, 0 wrong. A mutant reference was detected (vacuity check).
  • First signal. A planning probe of the benchmark quality metrics against trec_eval found 6 of 20 averaged cutoffs wrong; the hunt unit re-ran and characterised it, then reduced it to a public-API JUnit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-28: 102 queries in all, of them 55 automated queries over GitHub, JIRA, the Lucene lists, downstream trackers and the web, 35 manual GitHub searches including the files of all 413 open PRs, and 12 other lookups), refreshed 2026-10-30. Closest: #2193 and #3923, other items about the same class.

Searches4 searches

Nobody has reported that the averaged getPrecisionAt(n) is extrapolated a second time. #2193 (LUCENE-1116, 2008) changed average() only for empty input and added MRR; open #3923 (LUCENE-2849) is a locale bug in QualityStats.log; #1911 (LUCENE-836), the introducing issue, does not discuss the averaged P@n. The Lucene.NET port has the same code and no report.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, Lucene.NET, Elasticsearch, OpenSearch, Solr), JIRA archive2026-10-28a scripted prior-report search (55 queries), 35 manual searches and 12 JIRA, list, Stack Exchange and port lookups (102 in all): class and method names, "precision at", P@10, trec_eval, the package path; JQLno report; closest #2193, #3923, #1911
Open apache/lucene PRs2026-10-28changed files of all 413 open PRsnone touches QualityStats.java; #16847 touches quality/utils only
dev@, java-user@, issues@, commits@, Stack Overflow, the web2026-10-28list searches, 8 java-user threads and 8 Stack Overflow questions readusage questions only; a maintainer, Robert Muir, has said they run the queries with QueryDriver and evaluate with trec_eval (#9048, #3263)
GitHub refresh2026-10-30git ls-remote; issue and PR searches for QualityStats, getPrecisionAt, "QualityStats average"; commits to QualityStats.java on main; changed files of the 33 open PRs updated since 2026-10-28nothing new; main at 716d6f216f52, newest commit to the file still #16255; no open PR touches benchmark/quality/

History

Wrong since the package was added in LUCENE-836 (98fa2d898d1, 2007), first released in 2.3.0 (January 2008); it fails on every release executed, 2.3.0 to 10.6.0, and on main. Not a regression.

Versions tested7 rows

LUCENE-836 added the per-query branch and average() together (in the 2007 file the branch is at :107-108, the per-query sum at :202 and avg.numPoints /= m at :211). Later commits (LUCENE-1116 in 2008, the 2021 reformat, #14228, #16056, #16255) did not change the meaning. 2.2.0 has no search-quality package. 10.6.0 (released 2026-10-29, release commit 4ae946e5a9b) differs from main only by #16255's renames, and the one-method test fails on its release jars with assertions on and off. The history repro is a main() with 2007-era API only; its output is byte-identical on every release run.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-method test fails, 0.4 instead of 0.6, assertions on and off (gradle and standalone)
main a9779ef3c193, ea57360912ed affectedthe finder's, the independent reproduction's and the reviewers' tests and the history repro fail, assertions on and off
10.6.0 (release commit 4ae946e5a9b) latest releasethe one-method test fails, 0.4 instead of 0.6, assertions on and off
10.5.2, 9.12.3 affectedthe one-method test fails (assertions off); the earlier JUnits fail with assertions on and off
10.0.0, 9.0.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4, 4.0.0, 3.6.2, 3.0.3, 2.9.4, 2.4.1 affectedthe history repro fails with the same output
2.3.0 first releasethe history repro fails, assertions on and off
2.2.0 no such packagecompile error: package org.apache.lucene.benchmark.quality does not exist

Independent checks

The finder, an executed cross-model review, the independent reproduction, the cross-model review and the intended-behaviour check and an adjudication agree on the mechanism (adjudication: confirmed, high confidence). This doc's test fails on main, 10.5.2 and 9.12.3, and passes with the fix.

Checks and verdicts319 words

The cross-model reviews used an independent reviewer from another model family (OpenAI GPT-5.6) on main ea57360912ed, where QualityStats.java is identical to 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateExhaustive differential check, then a public-API JUnit: 2 of 2 fail on main (-ea, -da), ea57360912ed, 10.5.2 and 9.12.3; the fix passes.
cross-model review, refute lens real (high)Own test fails with -ea, -da and over 3 seeds; the control passes; the fix passes its test and upstream TestQualityRun. Two earlier attempts were reading-only (procedural).
Independent reproducer realOwn 4-test class fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3; every wrong value equals numPoints * pAt[(int) numPoints] / n; 4 of 4 pass with the fix.
Cross-model review, intended lens real (medium)Read-only; intended argument moderate and rejected; a re-run of the repro failed (2 of 2).
Devil's advocate realSynthetic-query reading rejected; probe with a mean result count of 0.5: P@1 0 instead of 0.5.
adjudication confirmed (high)Re-ran the independent reproduction's test (3 of 4 fail, -ea, -da) and the finder's (2 of 2).
credibility and noise audits keep; claim-lowRe-ran the finder's test (-da): fails. The noise audit kept it as a low claim and cut the repro to the one-method test.
this doc as aboveThe one-method test on 645d4fe14a20 (gradle and standalone, -ea, -da), 10.5.2 and 9.12.3; the fix and the benchmark module's tests in a scratch worktree.
fact checks substance confirmedA separate AI tool with execution and an independent reviewer from another model family (OpenAI GPT-5.6, read-only) confirmed the mechanism, the numbers and the fix; the one-method test also fails on the 10.6.0 release jars (-ea, -da). Their wording corrections are applied.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,219 words

Finding record

LFV-935, snapshot of 2026-11-06

Registry title
QualityStats.average(stats).getPrecisionAt(n) is not the mean per-query precision at n when n is above the mean result count: the mean that average() stored is replaced by a second extrapolation (mean P@10 0.4 instead of 0.6)
Area
benchmark / search-quality evaluation (QualityStats)
Priority
Low, rank 584: The wrong averaged P@n reaches only programs reading it through the API (the printed summary never shows it) and only when result counts vary and fall below n, which is the narrowest of the three benchmark-metric rows, so it closes that group.
Impact class
minor
Impact note
Only programs that read QualityStats.average(stats).getPrecisionAt(n) through the API, in the opt-in lucene/benchmark search-quality package (a developer evaluation tool), get a wrong mean P@n with no error, and only when the judged queries return different numbers of results, n (at most 20) is above their mean result count and some query has a relevant hit between rank floor(mean)+1 and n; the value can be too low or too high (mean P@10 0.4 instead of 0.6; P@6 0.458 instead of 0.417) and is 0 for every n when the mean result count is below 1; the SUMMARY that QueryDriver and the package's documented sample print is not affected, because log() prints P@i only for i < (int) numPoints; MAP, recall and MRR are correct, no index or search is touched, and it is in every release since 2.3.0.
Summary
You evaluate search quality with QualityBenchmark, average the per-query results with QualityStats.average(stats) and ask the average for its precision at 10, expecting the mean of the per-query P@10 values (which, like trec_eval's P_10, already count missing ranks as non-relevant). If the queries returned different numbers of results and their mean is below 10, getPrecisionAt(10) ignores the correct mean that average() stored and extrapolates again from the precision at the mean result count: with setMaxResults(10), a query whose 10 hits are all relevant (P@10 = 1.0) and a query whose only 2 hits are relevant (P@10 = 0.2) give 0.4 instead of 0.6. There is no error, and the printed SUMMARY never shows the affected cutoffs.
Introduced
98fa2d898d1882f0bee2ccb7401e1135efd05520, LUCENE-836 'Add support for search quality benchmarking' (SVN r560372; review venue JIRA LUCENE-836, migrated as apache/lucene#1911; no PR), which added the per-query n > numPoints extrapolation in getPrecisionAt and average() together [read: git blame -w -M -C and git log -S / -L in the full-history clone; bracketed by execution: 2.2.0 has no quality package, 2.3.0 fails]. Not a regression: later commits (LUCENE-1116 MRR and empty-input guard, 2021 reformat, #14228 for-each rewrite, #16255 renames) did not change the meaning (2007-07-27 (author date = committer date))
First affected version
2.3.0 [exec]; 2.2.0 has no search-quality package [exec: compile error]
Affected versions
Every release from 2.3.0 to 10.6.0 (10.6.0 [exec] on its release jars by the bug doc's test, -ea and -da), plus main a9779ef3c193 and branch_10_6 [read: QualityStats.java differs from main only by renames] [exec: the 2007-era main() history repro fails with byte-identical output on 2.3.0 (-ea and -da), 2.4.1, 2.9.4, 3.0.3, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the finder's JUnit fails 2 of 2 on main a9779ef3c193 and ea57360912ed, 10.5.2 and 9.12.3 (-ea and -da); the independent reproduction's JUnit fails 3 of 4 on main (-ea and -da), 10.5.2 and 9.12.3]. Not fixed on upstream main 645d4fe14a20 (2026-10-28): QualityStats.java last changed by #16255 (author date 2026-06-14, renames only). All runs on Linux aarch64 with JDK 25.0.3
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-28, read-only; the prior-report record, verdict new): 102 follow-up queries (a scripted prior-report search over GitHub issues/PRs/commits, downstream trackers, dev@, java-user@, issues@, commits@, the JIRA archive and the web; 35 manual GitHub searches over apache/lucene, Lucene.NET, Elasticsearch, OpenSearch and Solr; JQL searches; java-user threads and Stack Overflow questions read; file lists of all 413 open apache/lucene PRs), plus the finder's 51-query run and the intended-behaviour check's own leg. Closest items, none this bug: #2193 (LUCENE-1116, 2008: MRR and an empty-input guard in average()); open #3923 (LUCENE-2849: locale decimal separator in QualityStats.log, another defect in the same class); #1911 (LUCENE-836, the introducing issue, no discussion of averaged P@n). The Lucene.NET port (QualityStats.cs) carries the same code with no report or fix. Not fixed upstream: QualityStats.java unchanged on upstream main 645d4fe14a20 since #16255 (author date 2026-06-14, renames only). Refresh (2026-10-28): search 'repo:apache/lucene QualityStats' still 2 items (#3923, #2193), 'getPrecisionAt' 0; none of the 19 apache/lucene PRs created since 2026-10-26 touches lucene/benchmark/.../quality/ (first 100 files each; #16911 deletes branch_10x); no issue created since 2026-10-26 mentions it.
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): the one-method repro test through gradle in a scratch worktree and through the standalone runner, assertions on and off, stock and with the recommended fix, plus the benchmark module's tests with the fix; the 10.5.2 and 9.12.3 release jars (assertions off); the 10.6.0 release jars (assertions on and off, run by the fact check). The hunt's checks ran on main a9779ef3c193 (the refute review on ea57360912ed) and the history repro on releases 2.3.0 to 10.5.2. QualityStats.java is identical at the three main commits and unchanged on upstream main 716d6f216f52 (2026-10-30).

Lucene and upstream sources

Finding record
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityStats.java:24 (class javadoc 'for a single query or for a set of queries'), 72 (pAt[0] unused), 104-119 (getPrecisionAt; branch 115-116), 162 (log loop i < (int) numPoints), 189-235 (average(): pAt[j] += stat.getPrecisionAt(j) at 215, numPoints /= m at 224, pAt[j] /= m at 229); trec/QueryDriver.java:101-102 and package-info.java:67-69 (average() then log() only) [read at the registration check]
  • GitHub, read-only, registration check 2026-10-28: search/issues 'repo:apache/lucene QualityStats' (2: #3923, #2193), 'repo:apache/lucene getPrecisionAt' (0), 'repo:apache/lucene benchmark quality updated:>=2026-10-26' (1: #16794, HNSW merges, unrelated), 'repo:apache/lucene "precision at" average quality' (4, unrelated vector/ranking items); commits?path=.../quality/QualityStats.java on main (latest 54283150a81, #16255, author date 2026-06-14); pulls created since 2026-10-26 (19, none with files under benchmark/quality/); issues created since 2026-10-26 (10, none about it); upstream main head 645d4fe14a20
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityStats.java:24, :72, :92-99, :104-119, :162, :189-235 (:206, :209, :214-216, :224, :228-230), :267-268
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:70, :97, :101-102; package-info.java:44, :65, :68-69; QualityBenchmark.java:64, :166-168
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/TestQualityRun.java:54, :85, :142, :149-153
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityStats.java:24, :72, :104-109, :189-194, :214-216, :228-230, :267-268
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/TestQualityRun.java:54, :85, :142, :149-153

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

The card's condition line, "possible only when result counts differ and n > their mean; here (mean 6) exactly when some query has a relevant hit at rank 7 to n", gives the general necessary condition and the exact rule for the example's whole-number mean (see "When the value is wrong" in the explanation).

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.