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.
QualityBenchmark.execute(...)runs each judged query and returns oneQualityStatsper query.getPrecisionAt(n)is the share of relevant hits among the top n results, for n = 1 to 20.- 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.
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).
- You score a run with the benchmark module's
QualityBenchmarkandsetMaxResults(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. - 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. - Instead you get 0.4.
average()also sets the result count to the mean, (10 + 2) / 2 = 6, and for n above 6getPrecisionAt(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. - 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.
- 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.
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).
- 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 toMAX_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.
- One query.
addResult(n, ...)setsnumPoints = nand stores the precision at n inpAt[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. - The average. For each query with judgements (
:206),average()addsstat.getPrecisionAt(j)toavg.pAt[j]for every j = 1..20 (:214-216), with that extrapolation already included, and addsstat.numPointstoavg.numPoints(:209). Thenavg.numPoints /= m(:224) andavg.pAt[j] /= m(:228-230). Nowavg.pAt[j]is the correct mean P@j for every j, andavg.numPointsis the mean result count M. - The second extrapolation.
avg.getPrecisionAt(n)with n > M takes the one-query branch and returnsM * avg.pAt[floor(M)] / n(:115-116), discarding the stored meanavg.pAt[n]. - Not affected.
log()prints P@i only fori < (int) numPoints(:162), so the SUMMARY shows only cutoffs below M, where the branch does not run. MAP, recall and MRR are averaged directly.
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.
| cutoff | query 1 | query 2 | mean (stored) | returned |
|---|---|---|---|---|
| P@5 | 1.000 | 0.400 | 0.700 | 0.700 |
| P@6 | 1.000 | 0.333 | 0.667 | 0.667 |
| P@7 | 1.000 | 0.286 | 0.643 | 0.571 |
| P@10 | 1.000 | 0.200 | 0.600 | 0.400 |
| P@15 | 0.667 | 0.133 | 0.400 | 0.267 |
| P@20 | 0.500 | 0.100 | 0.300 | 0.200 |
14 of the 20 cutoffs are wrong; the SUMMARY prints P@1 to P@5 (Num Points 6.000), all right.
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 onlyaverage()andlog(), andlog()prints only the cutoffs below the mean result count, so the printed SUMMARY never shows an affected cutoff. A program that readsavg.getPrecisionAt(n)(as upstreamTestQualityRundoes 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
maxResultsis 1000 (QualityBenchmark.java:64;QueryDriverand 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 upstreamTestQualityRunit is 247.6), but not withsetMaxResultsbelow 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
QueryDriverto run the queries and send its output to trec_eval (#9048, 2017; also #3263, 2010), which bypasses this path.
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.
/*
* 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);
}
}
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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0, 10.5.2 and 9.12.3 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code is plain double arithmetic) |
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
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 testaverage()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
TestQualityRunreads 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 fillsavg.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
getPrecisionAtfollows it, and the exhaustive check found 0 per-query mismatches. - Upstream test.
TestQualityRunasserts1.0 > avg.getPrecisionAt(j)for j = 1..20 (TestQualityRun.java:149-153), withmaxResults = 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.
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.
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, andlog()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.*(upstreamTestQualityRun's 2 tests and the new test) passes with assertions on and off; the wholelucene/benchmarkmodule (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.
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.
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.
The Lucene.NET port of the benchmark module has the same code in QualityStats.cs, so its averaged P@n has the same error.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, Lucene.NET, Elasticsearch, OpenSearch, Solr), JIRA archive | 2026-10-28 | a 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; JQL | no report; closest #2193, #3923, #1911 |
| Open apache/lucene PRs | 2026-10-28 | changed files of all 413 open PRs | none touches QualityStats.java; #16847 touches quality/utils only |
| dev@, java-user@, issues@, commits@, Stack Overflow, the web | 2026-10-28 | list searches, 8 java-user threads and 8 Stack Overflow questions read | usage questions only; a maintainer, Robert Muir, has said they run the queries with QueryDriver and evaluate with trec_eval (#9048, #3263) |
| GitHub refresh | 2026-10-30 | git 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-28 | nothing new; main at 716d6f216f52, newest commit to the file still #16255; no open PR touches benchmark/quality/ |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one-method test fails, 0.4 instead of 0.6, assertions on and off (gradle and standalone) |
main a9779ef3c193, ea57360912ed | affected | the 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 release | the one-method test fails, 0.4 instead of 0.6, assertions on and off |
| 10.5.2, 9.12.3 | affected | the 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 | affected | the history repro fails with the same output |
| 2.3.0 | first release | the history repro fails, assertions on and off |
| 2.2.0 | no such package | compile error: package org.apache.lucene.benchmark.quality does not exist |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (a hunt unit) | candidate | Exhaustive 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 | real | Own 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 | real | Synthetic-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-low | Re-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 above | The 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 confirmed | A 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. |
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.
- PNG 461 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,219 words
- 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 maina9779ef3c193(the refute review onea57360912ed) and the history repro on releases 2.3.0 to 10.5.2.QualityStats.javais identical at the three main commits and unchanged on upstream main716d6f216f52(2026-10-30).
- 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-168a 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-268a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/TestQualityRun.java:54, :85, :142, :149-153
- TestQualityStatsAveragePrecisionAt.java (lucene/benchmark, one test):
repro/LFV-935/src/org/apache/lucene/benchmark/quality/TestQualityStatsAveragePrecisionAt.java(36 lines)
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).
assets/LFV-935/bug.png(461 KB)
