All findings · Lucene finding · LFV-940 · trecjudge-negative-qrels-grade-counted-relevant

TrecJudge counts negative qrels grades (such as -2 for spam) as relevant

TrecJudge treats every qrels grade except the text "0" as relevant, so pages graded -2 (spam in TREC Web track qrels) count as relevant: junk ranked above the only relevant page gives P@1 1.0 and AP 1.0; trec_eval's rule gives 0 and 0.5. Every release since 2.3.0 is affected.

Summary

Priority
Low, rank 582It silently gives wrong precision, AP, MRR and recall in every release since 2.3.0 when qrels have negative grades, as TREC Web track qrels do, but only in the opt-in benchmark evaluation tool and with no effect on any index or search, so it is Low.
Impact
silent wrong results
Since
2.3.0 (LUCENE-836, 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
a bounded-exhaustive check of TrecJudge against trec_eval's relevance rule, 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

TrecJudge turns a qrels file into the per-query set of relevant documents that QualityBenchmark scores every hit against. It keeps a line as relevant whenever its grade is not the exact text "0".

Background: TREC qrels in three lines

  1. A qrels file lists judged documents, one per line: query id, an unused column (0), document name, grade. Example: 1 0 good 1.
  2. The grade is an integer: 0 is not relevant, 1 or more is relevant (2 = highly relevant). TREC Web track qrels (2010-2014) also use -2 for "spam or otherwise seems useless".
  3. trec_eval, the standard scorer, counts a document as relevant only when its grade is at least 1; a negative grade is never relevant (it reads it as "in pool, but unjudged").
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 search run with Lucene's quality benchmark (QueryDriver, or QualityBenchmark with TrecJudge). The qrels for query 1 say 1 0 junk -2 (spam) and 1 0 good 1 (relevant).
  2. Your search ranks junk first and good second. You expect what trec_eval's rule (grade 1 or more is relevant) gives: 1 relevant document, P@1 0, AP 0.5, MRR 0.5.
  3. Instead Lucene reports 2 relevant documents, P@1 1.0, AP 1.0 and MRR 1.0, with no warning.
  4. Why: TrecJudge never reads the grade as a number. It marks a document relevant when the grade is not the string "0" (TrecJudge.java:70), so -2 and -1 count as relevant. Binary 0/1 qrels and grades 0 to 4 are judged correctly.

Who is affected

Users of the opt-in lucene/benchmark quality package (QueryDriver, QualityBenchmark or TrecJudge) whose qrels contain negative grades, such as TREC Web track 2010-2014 qrels. Every negative line counts, every run, with no warning. Binary qrels, the trec_eval submission file, indexes and search results are unaffected, so the priority is low.

Mechanism, terms and numbers544 words

TrecJudge (lucene/benchmark, org.apache.lucene.benchmark.quality.trec) reads TREC relevance judgements (qrels) for QualityBenchmark and QueryDriver. Each line is qnum 0 doc-name grade, and the grade is an integer: trec_eval counts a document as relevant only when its grade is at least 1, and TREC Web track qrels (2010-2014) use -2 for spam or useless pages. TrecJudge instead decides relevance with !"0".equals(grade), so every token except the exact text "0" is relevant, -2 and -1 included. The relevant-document count and the P@n, AP, MRR and recall that Lucene prints are then wrong, silently: a junk page graded -2 ranked above the only relevant page gives 2 relevant documents, P@1 1.0, AP 1.0 and MRR 1.0, where trec_eval's rule gives 1, 0, 0.5 and 0.5. Binary and non-negative graded qrels are unaffected, as are the trec_eval submission file, the index and search results. The test dates from LUCENE-836 (2007): every release from 2.3.0 to 10.6.0 is affected.

The mechanism (main 645d4fe14a20)

  1. Parsing. The constructor (TrecJudge.java:54-86) splits each line into query id, an ignored column, document name and grade (:66-70):
   String zero = "0";                                    // :57
   ...
   boolean relevant = !zero.equals(st.nextToken());      // :70

A string comparison, not a number test: -2, -1, 00, 0.0, -0 and any word are "not the string 0", so they are relevant. The assert on :71 only checks for extra tokens, so assertions change nothing.

  1. Only relevant lines are kept (:72-81), in a per-query set; isRelevant (:89-93) and maxRecall (:153-161, the set size) read it.
  2. The metrics. QualityBenchmark creates new QualityStats(judge.maxRecall(qq), ...) (QualityBenchmark.java:126) and calls judge.isRelevant for every hit (:137-138). A relevant hit raises the good-hit count, sets MRR on the first one in the top 5 and adds its precision to the AP sum (QualityStats.java:85-97); AP is that sum divided by maxGoodPoints (:122-124), recall is good hits divided by maxGoodPoints (:100).
  3. Worked steps. qrels junk -2, good 1; hits: rank 1 junk, rank 2 good. Stock: both relevant, maxGoodPoints 2; rank 1 precision 1/1 (MRR 1), rank 2 precision 2/2; AP = (1 + 1) / 2 = 1.0, P@1 = 1.0. trec_eval's rule: 1 relevant document; rank 1 is not relevant (P@1 0); rank 2 precision 1/2, reciprocal rank 1/2; AP = 0.5 / 1 = 0.5.
  4. Where it shows. The per-query stats that QualityBenchmark logs ("Max Good Points", "Average Precision", "MRR", "Recall", "Precision At n"; QualityBenchmark.java:140-143, QualityStats.java:156-166) and the SUMMARY that QueryDriver prints (QueryDriver.java:101-102).

More inputs (executed)

  • qrels d1:1, d2:-2, d3:0, d4:-1: maxRecall 3 instead of 1.
  • 00, -0 and +0 are judged relevant too (secondary: no real qrels file uses them); so are words such as x, no and false.
  • Controls: grades 1 and 2 relevant, 0 not relevant, as in trec_eval; the in-tree TestQualityRun (binary qrels) passes.

Scope

  • Not affected: binary 0/1 qrels and graded qrels with grades 0..4, the trec_eval submission file QueryDriver writes, the index and search results.
  • Related, not part of this bug: queries whose judgements are all 0 get maxRecall 0 and are left out of QualityStats.average (by a commented design choice), where trec_eval averages them with AP 0.
Exposure and likelihood155 words
  • Who is exposed. Anyone who evaluates relevance with the lucene/benchmark search-quality package on qrels that use negative grades: the TREC Web track 2010-2014 qrels (-2 for spam), or custom qrels that use -1 for "bad". TrecJudge is the adjudication the package documents for TREC qrels and the only one QueryDriver uses.
  • Effect. Silent wrong evaluation numbers: the relevant-document count is too high, and P@n, AP, MRR and recall can all be wrong; in the example P@1 1.0 instead of 0 and AP 1.0 instead of 0.5. A system that ranks spam high can look better than one that does not. validateData only checks that queries and judgements match.
  • Not affected. Binary 0/1 and non-negative graded qrels; the trec_eval submission file; indexes and search results.
  • Workaround. Score the submission file with trec_eval, as experienced users do, or drop grades below 1 from the qrels first.
  • Releases. Every release from 2.3.0 to 10.6.0 and main. Priority low.

Minimal repro

One JUnit test method in lucene/benchmark, public API only: a TrecJudge built from the single qrels line 1 0 d -2, then isRelevant("d", query 1). No index, no randomness, one thread; the same result with assertions on and off.

TestTrecJudgeNegativeGrade.java (lucene/benchmark, one test)34 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.trec;

import java.io.BufferedReader;
import java.io.StringReader;
import java.util.Map;
import org.apache.lucene.benchmark.quality.QualityQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTrecJudgeNegativeGrade extends LuceneTestCase {

  public void testNegativeGradeIsNotRelevant() throws Exception {
    TrecJudge judge = new TrecJudge(new BufferedReader(new StringReader("1 0 d -2\n")));
    // expected grade -2 non-relevant (trec_eval: relevant only if grade >= 1); actual relevant
    assertFalse(
        "expected isRelevant=false for qrels grade -2, got true",
        judge.isRelevant("d", new QualityQuery("1", Map.of())));
  }
}
Command
mkdir -p $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/trec
cp $REPRO/TestTrecJudgeNegativeGrade.java $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/trec/
cd $LUCENE && ./gradlew :lucene:benchmark:test --tests org.apache.lucene.benchmark.quality.trec.TestTrecJudgeNegativeGrade -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
Expected
isRelevant("d", query 1) is false for the qrels line 1 0 d -2: only grades of 1 or more are relevant.
Observed
isRelevant is true: AssertionError: expected isRelevant=false for qrels grade -2, got true, with assertions on and off.

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

More files, environment, full output320 words

The trec test directory does not exist on main (the module's quality tests sit one package up), hence the mkdir. The seed only satisfies the test framework. As a regression test, reword the comment so it states only the expectation, for example "TREC qrels grade -2 (junk) is not relevant: trec_eval counts a document only if grade >= 1". The end-to-end effect on P@n, AP and MRR follows from isRelevant and maxRecall. The numbers in the steps come from the hunt's end-to-end test (two documents, QualityBenchmark: relevant documents, P@1, AP) and, for MRR, from the diagram's probe (QualityBenchmark and QueryDriver on the same two documents), both run on the same commit. The expected numbers apply trec_eval's relevance rule, read from its source, by hand and through Lucene's own QualityStats; the trec_eval binary was not run.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 and 10.5.2 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; pure Java string handling)
Command (environment)
mkdir -p $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/trec
cp $REPRO/TestTrecJudgeNegativeGrade.java $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/trec/
cd $LUCENE && ./gradlew :lucene:benchmark:test --tests org.apache.lucene.benchmark.quality.trec.TestTrecJudgeNegativeGrade -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false

Observed output

TestTrecJudgeNegativeGrade > testNegativeGradeIsNotRelevant FAILED
    java.lang.AssertionError: expected isRelevant=false for qrels grade -2, got true
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:3055D722D8068F5F]:0)
        ...
        at org.apache.lucene.benchmark.quality.trec.TestTrecJudgeNegativeGrade.testNegativeGradeIsNotRelevant(TestTrecJudgeNegativeGrade.java:30)
:lucene:benchmark:test (FAILURE): 1 test, 1  failure

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

# end to end: QueryDriver on the two-document example (junk -2 ranked 1st, good 1 ranked 2nd), same commit, -da;
# after each '#': the same query through QualityBenchmark with a judge that applies the grade >= 1 rule
SUMMARY
  Num Points:             2.000      # 2.000
  Num Good Points:        2.000      # 1.000
  Max Good Points:        2.000      # 1.000
  Average Precision:      1.000      # 0.500
  MRR:                    1.000      # 0.500
  Recall:                 1.000      # 1.000
  Precision At 1:         1.000      # 0.000

Why it looks unintended

  • The class judges "based on Trec format for judgements", and the package offers it "For benchmarking TREC collections with TREC QRels", with no 0/1-only precondition.
  • The package's own example runs on graded TREC qrels (grades 0, 1, 2), and grade 2 already counts as relevant.
  • trec_eval, which QueryDriver writes its submission file for, never counts a negative grade as relevant.
The full argument246 words
  • Contract. TrecJudge "Judge[s] if given document is relevant to given quality query, based on Trec format for judgements" (TrecJudge.java:28-30). The package: "For benchmarking TREC collections with TREC QRels, take a look at the trec package" (package-info.java:33-34); the trec package is "Utilities for Trec related quality benchmarking, feeding from Trec Topics and QRels inputs" (trec/package-info.java:18).
  • Graded example. package-info.java:36-69 runs "the TREC 2006 queries 701-850 on the .Gov2 collection" through TrecJudge; those qrels (the TREC 2004-2006 Terabyte ad hoc qrels for topics 701-850) use only grades 0, 1 and 2: 108,435 / 22,566 / 4,351 lines over the three files.
  • Reference scorer. QueryDriver writes an "output submission file for trec_eval" (QueryDriver.java:48). trec_eval reads the grade as "an integer between -127 and 127" (get_qrels.c:42-43) through a checked strtol wrapper that rejects out-of-range or non-numeric grades (get_qrels.c:18-35, :217-221), counts relevant only rel >= relevance_level (form_res_rels.c:213, :228; default 1) and maps negative values to "In pool, but unjudged" (form_res_rels.c:178-180).
  • Real data. The TREC 2012 Web track eval-README: "-2: spam or otherwise seems useless for any information need, 0: not relevant, 1: relevant, 2: highly relevant, 3: key, 4: nav"; "Adhoc evaluation results are computed using trec_eval".
  • History. The string test came in with the class (LUCENE-836, #1911, 2007); neither the commit nor the issue discusses graded or negative grades, and no test, comment or later commit says a negative grade means relevant.
The devil's-advocate check171 words

Strongest case (the intended-behaviour check and the cross-model reviews, rated moderate): the constructor javadoc names the 4th column is-relevant and shows only 1 and 0 (TrecJudge.java:41, :47-48, unchanged since 2007); the in-tree qrels fixture is binary; NIST's classic ad hoc qrels page calls the field "a binary code of 0 for not relevant and 1 for relevant"; and the package says "To run on different formats of queries and judgements provide your own Judge" (package-info.java:75-77). So negative grades might be outside the declared input.

Why it fails: the package offers TrecJudge for TREC qrels in general and demonstrates it on graded qrels; grades 2 to 4 already work, so the code is not binary-only in practice. Nothing treats a negative grade as relevant, and official qrels with -2 are scored with trec_eval. Even under the binary reading, the parser silently gives an out-of-format value the opposite meaning instead of rejecting it. Verdict: not intended, a defect. A committer may still call the change a robustness fix.

Suggested fix

Recommended Parse the grade as an integer; relevant iff > 0

Read the grade as an integer and treat only grades above 0 as relevant: Integer.parseInt(st.nextToken()) > 0, plus a javadoc that names the column relevance-grade (+7/-4). The test and all 91 lucene/benchmark tests pass; 0/1 and 0..4 qrels behave as before.

All options and trade-offs3 options ยท 663 words

Option A was built and run in a scratch worktree of 645d4fe14a20. Option B, the hunt's sketch, was checked by shadowing the patched class over the main jars. A javadoc-only change was considered and not built.

Parse the grade as an integer; relevant iff > 0 (validated by the checks) (recommended)

Replace the string test with Integer.parseInt(grade) > 0 and document the column as an integer grade with trec_eval's default rule (grade 1 or more is relevant; 0 and negative grades are not).

    * <pre>
-   *     qnum  0   doc-name     is-relevant
+   *     qnum  0   doc-name     relevance-grade
    * </pre>
    *
-   * Two sample lines:
+   * The relevance grade is an integer. As in trec_eval, a document is relevant only if its grade is
+   * 1 or more; 0 and negative grades (such as -2 for spam pages in some TREC tracks) are not
+   * relevant.
+   *
+   * <p>Two sample lines:
 ...
-    String zero = "0";
 ...
-        boolean relevant = !zero.equals(st.nextToken());
+        boolean relevant = Integer.parseInt(st.nextToken()) > 0;

Trade-offs

  • 0/1 and 0..4 qrels give exactly the same results as today (TestQualityRun passes).
  • Odd spellings of zero change meaning: 00, -0 and +0 become non-relevant (relevant today); +1 is accepted as 1.
  • A grade token that is not a plain Java int now throws NumberFormatException instead of silently counting as relevant: 0.0, words such as x, values beyond the int range, and any number with other characters attached. trec_eval also rejects x and out-of-range values (it stops loading the qrels) but reads 0.0 as 0 with a warning. Wrap it in an IllegalArgumentException naming the line if a clearer message matters.
  • A query whose qrels lines are all negative (or all 0) keeps no relevant document: maxRecall 0, a "queries have no judgments" warning from validateData, and QualityStats.average leaves it out of the SUMMARY. All-0 queries are handled this way today; pre-filtering the qrels to grades >= 1 has the same effect.
  • No relevance-level option like trec_eval's -l is added (out of scope).

What the checks validated

  • Stock + test: fails with assertions on and off (gradle); + fix: passes, on and off.
  • + fix: all lucene/benchmark tests, 91 tests in 19 suites, 0 skipped, 0 failures (assertions on); TestQualityRun and the new test with assertions off.
  • + fix: :lucene:benchmark:check -x test and renderJavadoc pass; the check tasks that ran were google-java-format, ecjLint and forbiddenApis (eclint is not configured in this checkout and errorProne was skipped).
  • With the patched class shadowed: the hunt's end-to-end test (P@1 0, AP 0.5), the independent reproduction's three tests (MRR 0.5; 00, -0, +0) and the minimal test through the standalone runner (-ea, -da) pass. Not run: the full Lucene test suite.
  • An independent fact check repeated the gradle runs in its own scratch worktree, with the same results.

Read the leading integer like trec_eval's strtol (the hunt's sketch) (validated by the checks)

A private parseGrade reads an optional sign and the leading digits (0.0 -> 0, -2 -> -2) and throws only when there is no digit; relevant iff > 0 (+17/-3).

Trade-offs

Closer to trec_eval's syntax for odd tokens (0.0 -> 0, without trec_eval's warning), but more code, and not an exact trec_eval parser. It throws on a grade beyond the long range, as trec_eval also rejects one: its str_to_long wrapper treats strtol's out-of-range result as an error and stops loading the qrels.

What the checks validated

Shadowed over the 645d4fe14a20 jars: the minimal test passes with -ea and -da, and so do a cross-model reviewer's test and the upstream TestQualityRun; in the hunt's harness the token mismatches drop from 10 to 0. Not run through gradle.

Javadoc only (not recommended)

Document that the 4th column is a 0/1 flag and that any value other than 0 is relevant. Not built.

Trade-offs

Smallest change, but TREC Web track qrels stay silently wrong, and it contradicts the package's "TREC QRels" claim and its graded example.

Downstream note

Anyone who reports Lucene-computed P@n, AP or MRR from TrecJudge on qrels with negative grades has wrong numbers; indexes, search results and the trec_eval submission file are not affected.

How it was found

A bounded-exhaustive differential check: a harness asked the real TrecJudge about every grade token in a small alphabet and every grade assignment of four documents over -2..2, and compared each answer with trec_eval's rule (relevant iff the integer grade is at least 1). Every mismatch involved a negative grade or an odd spelling of zero.

The method in full122 words

Method: bounded-exhaustive differential check (semi-formal), a hunt unit (benchmark quality metrics versus trec_eval). A planning probe first saw maxRecall 4 instead of 2 and grades -2 and -1 judged relevant. The finder's harness QualityRefCheck.java then ran, on the stock jar, 26 single-token checks over a 13-token grade alphabet (10 mismatches: -2, -1, 00, 0.0, -0) and all 625 four-document assignments of grades -2..2 (544 judged wrongly, exactly those with a negative grade; this count reflects the alphabet, not real-world frequency), against an independent reference of trec_eval's rule taken from its source. With the fix sketch shadowed, both counts were 0. The finder then wrote a public-API JUnit (a four-line qrels and an end-to-end QualityBenchmark run); this doc's test is its one-line form.

Prior reports, history and checks

Prior reports

No prior report found: about 250 queries on 2026-10-28 and 170 on 2026-10-30 (GitHub issues, PRs and commits, the JIRA archive, every Lucene mailing list, Lucene.NET, the web), refreshed on 2026-10-30. A GitHub search for TrecJudge returns nothing.

Searches4 searches

Related, not reports of this bug: a 2010 java-user thread where a committer writes that "the results from the benchmark summary often differ with trec_eval" (general: it names no class, cause or input, and its collections have no negative grades); #1911 / LUCENE-836 (introduced the class, binary qrels, no discussion of grades); #3134 / LUCENE-2058 (added the trec_eval submission file); #9048 / LUCENE-8000 (committers score QueryDriver runs with trec_eval). The Lucene.NET port has the same test with no report. Tracking issue #16937 and the dev@ thread do not mention TrecJudge.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; apache/lucene-solr, apache/lucenenet; all of GitHub)2026-10-28 and 2026-10-30scripted prior-report searches and manual searches: TrecJudge, QRelJudgement, isRelevant, maxRecall, qrels (grade, negative), graded relevance, spam -2, trec_eval, QueryDriver; issues read with comments; code and commit searchesno report; TrecJudge returns 0 issues or PRs
Open and recent apache/lucene PRs2026-10-28 and 2026-10-30changed-file lists of all 413 open PRs, then of the 52 PRs updated since 2026-10-28only #16911, the branch_10x decommission, which deletes every file
Mailing lists and JIRA archive2026-10-28 and 2026-10-30every lucene.apache.org list including openrelevance-dev@ and -user@ (168 matching mails read in full); JQL over LUCENE, SOLR, LUCENENET and ORPno report; the 2010 java-user remark above is the closest
Refresh2026-10-30issue and PR searches for TrecJudge, "qrels grade", "qrels negative", "trec_eval relevance", "isRelevant maxRecall", "QueryDriver qrels"; the file's newest commit; PRs created since 2026-10-30no new item; the last commit on TrecJudge.java is still the 2021 reformat

History

Present since TrecJudge was created by LUCENE-836 (98fa2d898d1, 2007, first released in 2.3.0); the string test is unchanged since. Executed and failing on 17 releases from 2.3.0 to 10.5.2, on 10.6.0 and on main; 2.2.0 has no quality package.

Versions tested6 rows

LUCENE-836 "Add support for search quality benchmarking" (Doron Cohen; 98fa2d898d1, svn r560372, 2007-07-27; review on JIRA LUCENE-836, migrated as #1911) created the quality package with String zero = "0" and !zero.equals(...); git blame -w -M -C -C and git log -S 'zero.equals' lead back to it. Later commits on the file (generics, which also removed a cast inside the parse loop, module moves, the license header, javadoc and @Override fixes, the diamond operator, a spelling fix and the LUCENE-9570 reformat 7f8b7ffbcad, 2021-01-03) left the grade test unchanged. Not a regression and not fixed upstream. The first release with it is 2.3.0 (tag commit of 2008-01-24 UTC).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe test fails, assertions on and off (gradle and standalone)
10.6.0 (release jars, built at 4ae946e5a9b9) affectedthe test fails, assertions on and off
10.5.2 affectedthe test fails, assertions on and off; the hunt's tests too
2.3.0, 2.3.2, 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.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affecteda version-portable check gives the same wrong values (maxRecall 3 instead of 1; 2 relevant documents, P@1 1.0, AP 1.0); a fixed TrecJudge makes it pass on 2.3.0 and 8.11.4
2.2.0 API absentno quality package (the check does not compile)
with the fix (main 645d4fe14a20) not affectedthe test and all lucene/benchmark tests pass

Independent checks

The finder, two executed cross-model reviews (refute and intended lens, both real with high confidence), the independent reproduction, the intended-behaviour check (real, medium-high), an adjudication (confirmed, high) and a credibility audit rate it real; every check re-ran a repro on the stock jars.

Checks and verdicts422 words

The reviewer from another model family (OpenAI GPT-5.6) ran its own tests in the lenses; its refute-lens tests were re-run on 645d4fe14a20. Every verifier rated the intended-behaviour case (the is-relevant javadoc) moderate and rejected it.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateBounded-exhaustive harness: 10 of 26 token checks and 544 of 625 four-document assignments wrong, all with a negative grade or an odd zero; 0 with the fix. JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3.
cross-model review, refute lens real, highOwn test (grades 3, -2, 0): the -2 document is relevant and maxRecall 2 instead of 1, with -ea, -da and 4 seeds; the binary control passes; the fix passes it and TestQualityRun. Re-run on 645d4fe14a20.
Independent reproducer realOwn 3-test JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; the grade 1, 2 and 0 controls hold; read the trec_eval source.
Cross-model review, intended lens real, highOwn test: -2 and -1 relevant, maxRecall 3 instead of 1 on main, 10.5.2 and 9.12.3; passes with the fix. Works-as-designed case moderate, rejected.
Devil's advocate real, medium-highBuilt the binary is-relevant case and rejected it; a token probe shows every token except the exact 0 relevant; re-ran the finder's JUnit.
adjudication confirmed, highRe-ran the independent reproduction's (3 of 3 fail) and the finder's (2 of 2 fail, -da) JUnits, and the independent reproduction's test with its fix (pass).
Follow-up, audit, packaging keepHistory on 17 releases and main; two prior-report searches; audit 4 re-ran the finder's JUnit (-da); the one-line test on 645d4fe14a20 and 10.5.2, and with the hunt's fix.
this doc's runs reproduced, fix validatedThe test on main 645d4fe14a20 (gradle, asserts on and off) and the 10.6.0 jars; the fix with all lucene/benchmark tests, check -x test and renderJavadoc; the hunt's and the independent reproduction's tests with the fix shadowed.
fact checks of the doc substantively correct; corrections appliedA separate AI tool re-ran the gradle commands in its own scratch worktree, the standalone runner on main, 10.6.0 and 10.5.2, fix A shadowed and the diagram probe (identical values), and counted the official Terabyte qrels grades. An independent reviewer from another model family (OpenAI GPT-5.6) checked the text by reading. Their corrections (trec_eval's parser, the qrels counts, the fix trade-offs, the 2.3.0 date, citations) are in this version.

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,427 words

Finding record

LFV-940, snapshot of 2026-11-06

Registry title
TrecJudge counts every qrels grade other than the literal string "0" as relevant, so negative TREC grades (such as -2 for spam pages in TREC Web track qrels) are judged relevant and inflate the relevant-document count, precision, AP and MRR
Area
benchmark / search-quality package (trec.TrecJudge, the qrels reader used by QualityBenchmark and QueryDriver)
Priority
Low, rank 582: It silently gives wrong precision, AP, MRR and recall in every release since 2.3.0 when qrels have negative grades, as TREC Web track qrels do, but only in the opt-in benchmark evaluation tool and with no effect on any index or search, so it is Low.
Impact class
silent wrong results
Impact note
Only users of the opt-in lucene/benchmark search-quality package (a developer evaluation tool: QueryDriver, QualityBenchmark or TrecJudge directly) whose qrels file contains negative grades, as TREC Web track 2010-2014 qrels do (-2 for spam or useless pages), get wrong Lucene-computed metrics with no error: every negatively graded document counts as relevant, which raises the relevant-document count (maxRecall, the recall denominator) and changes precision at n, AP, MRR and recall in the per-query and SUMMARY output that QueryDriver prints (in the repro 2 relevant documents instead of 1, P@1 1.0 instead of 0, AP 1.0 instead of 0.5); binary 0/1 qrels and non-negative graded qrels (0..4) give the same answer as trec_eval at its default relevance level, the trec_eval submission file that QueryDriver writes is unaffected, no index or search result is touched, and it is in every release since 2.3.0.
Summary
You evaluate search quality with QueryDriver or QualityBenchmark and a TREC qrels file read by TrecJudge, expecting a document to count as relevant only when its grade is 1 or more, as trec_eval counts it. TrecJudge instead treats every grade token except the exact string "0" as relevant, so documents graded -2 (spam or useless, in TREC Web track qrels) or -1 count as relevant: a junk page graded -2 that ranks above the only relevant page gives 2 relevant documents, P@1 1.0 and AP 1.0, where trec_eval gives 1, 0 and 0.5. There is no error or warning.
Introduced
98fa2d898d1882f0bee2ccb7401e1135efd05520, LUCENE-836 'Add support for search quality benchmarking' (SVN r560372, Doron Cohen; review venue JIRA LUCENE-836, migrated as apache/lucene#1911; no PR), which created TrecJudge with String zero = "0"; ... boolean relevant = !zero.equals(st.nextToken()); (now TrecJudge.java:57 and :70) [read: git log --follow, git log -S 'zero.equals' over all refs and git blame -w -M -C -C in the full-history clone; bracketed by execution: 2.2.0 has no quality package, 2.3.0 fails]. Not a regression: the later commits on the file (LUCENE-1257 and LUCENE-2681 generics, LUCENE-2845 and LUCENE-3965 module moves, LUCENE-3949 license header, LUCENE-5512 diamond, a 2017 spelling fix, LUCENE-9570 reformat) left the parse loop unchanged (2007-07-27 (author date = commit date, 2007-07-27T20:24:52Z))
First affected version
2.3.0 (2008-01-23) [exec]; 2.2.0 has no search-quality package [exec: compile error]
Affected versions
Every release from 2.3.0 to 10.6.0 (the 154 lucene-benchmark versions on Maven Central up to 10.5.2, and 10.6.0 [exec] on its release jars by the bug doc's test, -ea and -da), plus branch_10_6 and main a9779ef3c193 [read: git tag --contains 98fa2d898d1; TrecJudge.java is blob 43bf121521ff on main a9779ef3c193, branch_10_6 and upstream main 645d4fe14a20] [exec: a version-portable main() repro gives the same wrong values (maxRecall 3 instead of 1, relevant docs 2.0 instead of 1, P@1 1.0 instead of 0, AP 1.0 instead of 0.5) on 17 sampled releases (2.3.0, 2.3.2, 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.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2; -ea, 10.5.2 also -da) and on main a9779ef3c193 (-ea and -da); the finder's JUnit fails 2 of 2 on main a9779ef3c193 (-ea and -da), ea57360912ed, 10.0.0, 10.5.2 (-ea and -da) and 9.12.3; the independent reproduction's JUnit fails 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3]. Shadowing only TrecJudge.java with the fix sketch makes the portable repro pass on 2.3.0, 8.11.4 and 10.5.2. Not fixed on upstream main 645d4fe14a20 (2026-10-28): TrecJudge.java last changed by 7f8b7ffbcad (LUCENE-9570 reformat, author date 2021-01-03). 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): 250 follow-up queries (two scripted prior-report searches of 73 queries each over GitHub issues, PRs and commits, downstream trackers, the Lucene and Solr mailing lists, the JIRA archive and the web; 47 manual GitHub issue searches, 4 code searches and 19 issues read with comments; 17 JQL searches over LUCENE, SOLR and LUCENENET; 21 list searches and 10 threads read; the changed-file lists of all 413 open apache/lucene PRs), plus the finder's 51-query run and the independent reproduction, the intended-behaviour check and judge legs. GitHub search 'TrecJudge' over all issues and PRs: 0 items. Closest items, none this bug: a 2010 java-user reply by a committer that the benchmark summary 'often differ[s] with trec_eval' (general, names no class, cause or input; the collections in that thread have no negative grades; a list post); LUCENE-836 (#1911), the introducing issue (binary qrels, no discussion of grades); LUCENE-1116 (#2193), LUCENE-2058 (#3134), LUCENE-1540 (#2614) and other quality-package issues, about other code. The Lucene.NET port (TrecJudge.cs) keeps the same test with no report. Open PRs: only the branch_10x decommission tombstone #16911 lists TrecJudge.java (it deletes every file). Not fixed upstream: TrecJudge.java on upstream main 645d4fe14a20 is blob 43bf121521ff, last changed by 7f8b7ffbcad (LUCENE-9570 reformat, author date 2021-01-03). Refresh (2026-10-28): 'repo:apache/lucene TrecJudge' still 0, 'qrels' 4 (#1911, #3532, #2041, #3345, none about grades), 'trec_eval' 5 (#3263, #9048, #71, #2614, #3134, none about TrecJudge), 'isRelevant maxRecall' 0, 'benchmark quality created:>=2026-10-26' 0; none of the 19 apache/lucene PRs created since 2026-10-26 touches lucene/benchmark/.../quality (first 100 files each; the only larger one is the 7,630-file #16911 tombstone). Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): the one-method repro through gradle in a scratch worktree and through the standalone runner, assertions on and off, stock and with the recommended fix; the 10.6.0 and 10.5.2 release jars (assertions on and off). The hunt, the verifiers and the study's history runs used main a9779ef3c193, where TrecJudge.java is the same blob; the history runs covered 17 releases from 2.3.0 to 10.5.2. Upstream main on 2026-10-30 (716d6f216f52), branch_10_6 and the 10.6.0 tag have the same TrecJudge.java.

Lucene and upstream sources

Finding record
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecJudge.java:28-53 (class javadoc 'based on Trec format for judgements'; constructor format 'qnum 0 doc-name is-relevant' with sample grades 1 and 0), 57 (String zero = "0"), 70 (boolean relevant = !zero.equals(st.nextToken())), 72 (only relevant docs kept), 89-93 (isRelevant), 153-161 (maxRecall); QualityBenchmark.java:126 (judge.maxRecall), 137 (judge.isRelevant), 142 (per-query stats log); trec/QueryDriver.java:45-48 (usage 'submission file for trec_eval'), 82 (new TrecJudge(qrelsFile)), 100-101 (SUMMARY log); package-info.java:33-34 ('For benchmarking TREC collections with TREC QRels') [read at the registration check]
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecJudge.java:54-93, :113-115, :153-161
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityBenchmark.java:126, :137-143
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityStats.java:85-100, :122-124, :156-166, :196-235
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:82, :98, :101-102
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/package-info.java:33-34, :54
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:82
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecJudge.java:119-151 (validateData)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecJudge.java:28-30, :41, :47-48, :57, :70
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/package-info.java:33-34, :36-69, :75-77
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/package-info.java:18
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:48
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/trecQRels.txt (grades 0 and 1 only)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecJudge.java:72-81, :119-151, :153-161; a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityStats.java:201-217; a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:85 (a query with no kept document: no judgement, maxRecall 0, validateData warning, left out of the average)

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

Repro files

About the diagrams

It is made in parallel with this doc; every on-screen value comes from the code at 645d4fe14a20 or an executed run.

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.