All findings · Lucene finding · LFV-937 · submissionreport-score-grouping-separator

SubmissionReport writes scores of 1000 or more as "1,200.5000", which trec_eval reads as 1.0

SubmissionReport writes every score of 1000 or more into the TREC run file that QueryDriver calls the "submission file for trec_eval" with a thousands comma: 1,200.5000. trec_eval reads that score with C atof() as 1.0 and silently ranks the hit as if it had scored 1.

Summary

Priority
Low, rank 583It is the same opt-in benchmark silent-wrong-metric profile as LFV-940 but only for hits scoring 1000 or more, so it moves down from the MoreLikeThis rows to join its benchmark siblings, right after LFV-940.
Impact
silent wrong results
Since
LUCENE-836 (2007), first released in 2.3.0introduced 2007-07-27 · over 19 years
Affected
every release from 2.3.0 to 10.6.0, and main
Status
documented
Found by
code reading of the run-file writer next to trec_eval's reader, then a public-API JUnit

AI-assisted page: this finding was first signalled by code reading or testing 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

SubmissionReport prints each hit's score with a NumberFormat that still has thousands grouping on. trec_eval parses that column with C atof(), which stops at the first comma.

Background: TREC run files and trec_eval

  1. A TREC run file has one line per retrieved document: query id, the literal Q0, document name, rank, score and run name. trec_eval reads it with the relevance judgments and computes precision, MAP, nDCG and other metrics.
  2. trec_eval ignores the rank column. It sorts each query's documents by the score column, highest first, and breaks ties by document name.
  3. lucene/benchmark's quality package runs TREC topics against an index and writes such a file with SubmissionReport; QueryDriver is its command line.
Summary card in four panels. 1, what you do: QueryDriver or SubmissionReport.report writes the run for query 1 with docA scoring 1200.5 and docB 900.25; expected score column 1200.5000, with trec_eval ranking docA first. 2, inside SubmissionReport on main 645d4fe14a20: lines 48-50 build NumberFormat.getInstance(Locale.ROOT) with 4 fraction digits, line 82 formats the score; grouping is on by default and never turned off; nf.format(1200.5) = 1,200.5000, nf.format(900.25) = 900.2500. 3, what trec_eval reads (its atof() and sort, run in C): atof gives 1.0 for docA and 900.25 for docB, so docA drops from rank 1 to rank 2 and docB rises to rank 1, silently; 1200.5000 with grouping off keeps rank 1. 4, fix and reach: one line nf.setGroupingUsed(false) makes all repros pass; affected since LUCENE-836 (2007), every release 2.3.0 to 10.6.0 and main; only hits scoring 1000 or more; stricter parsers reject the line; QualityStats.log turns grouping on explicitly for its human-readable summary.
A four-panel card: what you do (docA scores 1200.5, docB 900.25), the formatter inside SubmissionReport, what trec_eval's atof() reads (1.0 and 900.25, so the two hits swap ranks), and the one-line fix and who is hit. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Evaluate search quality with lucene/benchmark: QualityBenchmark (or the QueryDriver command line) writes one TREC line per hit, qid Q0 docno rank score tag, through SubmissionReport, for trec_eval to score.
  2. Lucene ranks docA first with score 1200.5 and docB second with 900.25. You expect the score column to read 1200.5000 and 900.2500.
  3. Instead docA's line says 1,200.5000. The formatter is NumberFormat.getInstance(Locale.ROOT) with 4 decimals, and nobody turned its thousands grouping off, so every score of 1000 or more gets a comma.
  4. trec_eval reads the column with C atof(), which stops at the comma: docA scores 1.0. It ignores the rank column and re-sorts by score, so it evaluates [docB, docA], the reverse of Lucene's ranking.
  5. No error anywhere: trec_eval reports precision, MAP and so on for the wrong order. Lucene's own results and its in-process metrics are right; only the file is wrong.

Who is affected

Users of the opt-in lucene/benchmark quality package who score its run file with trec_eval, when hits score 1000 or more: boosts, FeatureField (impact) queries, function scores or custom similarities. QueryDriver's default title-only BM25 queries stay far below 1000. Silent with trec_eval; every release since 2.3.0.

Mechanism, terms and numbers499 words

lucene/benchmark's search-quality package writes a TREC run file, one line per hit (qid Q0 docno rank score tag), through SubmissionReport. QueryDriver documents that file as the "output submission file for trec_eval". The score is formatted with NumberFormat.getInstance(Locale.ROOT) and 4 fixed decimals, and grouping is never turned off, so any score of 1000 or more is written with a thousands comma: 1200.5 as 1,200.5000. trec_eval reads the score with C atof(), which stops at the comma and returns 1.0, ignores the rank column and re-sorts each query's hits by the parsed score. A run Lucene ranked [1200.5, 900.25] is therefore evaluated as [900.25, 1.0], and every metric is computed on that order, with no error. It needs hit scores of 1000 or more (boosts, FeatureField or function scores, custom similarities); QueryDriver's default title-only BM25 queries stay far below, and through its own parser only a contrived query repeating one word 400 times got there. Lucene's ranking, the index and the in-process QualityStats numbers are not affected. Every release since 2.3.0 (2008) has it, through 10.6.0 and main; the fix is one line, nf.setGroupingUsed(false).

The contract

  • QueryDriver usage: "submissionFile: output submission file for trec_eval" (QueryDriver.java:48); it writes the file with the stock SubmissionReport (:59-63).
  • SubmissionReport javadoc: "Create a log ready for submission" (SubmissionReport.java:30). QualityBenchmark names two configurations, "Against existing judgements" and "For submission (e.g. for a contest)" (QualityBenchmark.java:32-41).
  • trec_eval documents the line as qid iter docno rank sim run_id, with "similarity sim (a double)"; "the rank field is ignored here; internally ranks will be assigned by sorting by the sim field with ties broken determinstically (using docno)" (get_trec_results.c:17-26, its spelling).

The mechanism

  1. The constructor builds NumberFormat.getInstance(Locale.ROOT) and sets 4 minimum and maximum fraction digits (SubmissionReport.java:48-50). Nothing calls setGroupingUsed, so the formatter is the pattern #,##0.0000 with grouping on and group size 3 (executed on JDK 25: 999.5 -> 999.5000, 1200.5 -> 1,200.5000, 1234567.0 -> 1,234,567.0000).
  2. report() writes, per hit, the query id, Q0, the padded doc name, the 0-based rank, nf.format(sd[i].score) and the run name, separated by " \t " (:62-86, the score at :82).
  3. trec_eval parses the field with text_results_ptr->sim = atof(lines[i].sim); (get_trec_results.c:210) and sorts by it (form_res_rels.c:111-113, comp_sim_docno at :266-275). No trec_eval source file calls setlocale, so atof runs in the "C" locale every C program starts in, and it reads the longest numeric prefix: 1,200.5000 is 1.0. Three independent C checks read grouped scores (1,200.5000, 1,200.0000, 1,000.5000) as 1.0.

In the example

Hits docA (1200.5) and docB (900.25) are written 1,200.5000 and 900.2500; trec_eval reads 1.0 and 900.25 and ranks docB first. In general a score of 1000 or more reads as its leading digit group (1 to 999): every score from 1000 to 1999.9999 becomes 1.0, and those hits are then ordered by document name, not by Lucene's ranking.

Exposure and likelihood274 words
  • Who. People who evaluate relevance with the quality package and an external scorer: the QueryDriver command line, QualityBenchmark with a SubmissionReport, or a direct report() call. Robert Muir described this workflow on PR #71 (2021): "generate submission.txt, then i run trec_eval etc from commandline myself".
  • When. Only for hits scoring 1000 or more. Measured reach with public API: a BoostQuery of 10000 through QualityBenchmark scored 1139.5098 (written 1,139.5098); FeatureField linear queries with 10 query terms of impact 120 each scored 1200, written 1,200.0000, with no boosts, the custom-parser route the package docs suggest; QueryDriver's own SimpleQQParser with default BM25 got there only with a contrived query, the word needle repeated 400 times over 1000 documents of which one contains it: each copy adds the one-term score 2.956, so 400 x 2.956 = 1182.4163 (200 copies: 591.2). Whether realistic long queries of distinct terms reach 1000 was not measured. Ordinary title queries score far less. For scale, upstream TestQualityRun (title queries, BM25, the 578-line Reuters test file) writes 4,952 submission lines whose highest score is 4.9965.
  • Effect. With trec_eval: silent. Each affected hit is read as its leading digit group (1 to 999) and sinks below every hit whose parsed score is higher; scores from 1000 to 1999.9999 all tie at 1.0 and are ordered by document name; and the metrics are computed on that order. Strict parsers (Java Double.parseDouble, Python float()) reject the line instead.
  • Not affected. Lucene's search results, the index, and the metrics QualityBenchmark computes itself with a Judge.
  • Releases. 2.3.0 (2008) through 10.6.0 and main. Likelihood: rare (a reasoned assessment, not a measured usage rate).

Minimal repro

One JUnit test in lucene/benchmark, public API: one stored doc name, SubmissionReport.report of one hit with score 1200.5, then an exact check of the line's fifth column. Deterministic and single-threaded; it fails with assertions on and off.

TestSubmissionReportScoreFormat.java (lucene/benchmark, one test)61 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.utils;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Map;
import org.apache.lucene.benchmark.quality.QualityQuery;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.TotalHits;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSubmissionReportScoreFormat extends LuceneTestCase {

  public void testScoreColumnHasNoGroupingSeparator() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new StringField("docname", "d", Field.Store.YES));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        StringWriter out = new StringWriter();
        SubmissionReport report = new SubmissionReport(new PrintWriter(out), "run");
        TopDocs td =
            new TopDocs(
                new TotalHits(1, TotalHits.Relation.EQUAL_TO),
                new ScoreDoc[] {new ScoreDoc(0, 1200.5f)});
        report.report(new QualityQuery("1", Map.of()), td, "docname", new IndexSearcher(reader));
        report.flush();
        String score = out.toString().trim().split("\\s+")[4];
        // expected the score column 1200.5000 (trec_eval reads it with atof()); actual 1,200.5000
        assertEquals("1200.5000", score);
      }
    }
  }
}
Command
mkdir -p $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/utils
cp $REPRO/TestSubmissionReportScoreFormat.java $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/utils/
cd $LUCENE && ./gradlew :lucene:benchmark:test --tests org.apache.lucene.benchmark.quality.utils.TestSubmissionReportScoreFormat -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
Expected
The score column reads 1200.5000, a number trec_eval can parse.
Observed
1,200.5000 (expected:<1[]200.5000> but was:<1[,]200.5000>), assertions on and off; trec_eval's atof() reads it as 1.0.

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

More files, environment, full output187 words

The index holds one document only because report() reads the stored doc name. SubmissionReport uses Locale.ROOT, so the test framework's random default locale does not matter; the seed only satisfies the framework. The assertion pins the existing 4-decimal format without grouping, which both fix options produce, so the same test is the regression test.

The trec_eval consequence (atof("1,200.5000") = 1.0, then a re-sort by score) is not asserted: it comes from trec_eval's source and three independent C atof() checks. Nobody ran the trec_eval binary itself.

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; the code path is pure Java)
Command (environment)
mkdir -p $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/utils
cp $REPRO/TestSubmissionReportScoreFormat.java $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/utils/
cd $LUCENE && ./gradlew :lucene:benchmark:test --tests org.apache.lucene.benchmark.quality.utils.TestSubmissionReportScoreFormat -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false

Observed output

TestSubmissionReportScoreFormat > testScoreColumnHasNoGroupingSeparator FAILED
    org.junit.ComparisonFailure: expected:<1[]200.5000> but was:<1[,]200.5000>
:lucene:benchmark:test (FAILURE): 1 test, 1  failure

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

Why it looks unintended

  • QueryDriver calls the file the "output submission file for trec_eval"; trec_eval reads the score as "a double" with atof().
  • Grouping was never chosen: the 2007 commit turned it on explicitly only for QualityStats.log, a summary for people.
  • No score range is documented, and legal queries (boosts, FeatureField impact queries) score 1000 or more.
The full argument194 words
  • The documented purpose is trec_eval. QueryDriver.java:48 (added by LUCENE-2058, commit subject "specify trec_eval output file from commandline"); SubmissionReport.java:30 "Create a log ready for submission"; QualityBenchmark.java:32-41 "For submission (e.g. for a contest)". LUCENE-1116 (2008) made the line exactly the 6-column TREC run format.
  • The reader needs a plain number. trec_eval: "similarity sim (a double)" (get_trec_results.c:21), parsed with atof (:210). 1,200.5000 is not a plain decimal number: strict parsers such as Java Double.parseDouble reject it, and C atof reads only its leading numeric prefix and returns 1.0.
  • Grouping was inherited, not chosen. LUCENE-836 (98fa2d898d18, 2007) built NumberFormat.getInstance() and set only the fraction digits; the same commit called nf.setGroupingUsed(true) in QualityStats.log (today QualityStats.java:145-148), the human-readable summary. LUCENE-4199 (2012, "fix more default Locale uses") only changed getInstance() to getInstance(Locale.ROOT). The issue threads #1911, #2193 and #3134 never discuss the score format.
  • No test pins it. The only test use, TestQualityRun.java:84, creates a SubmissionReport only when VERBOSE and never parses the output.
  • Not claimed. The 4-decimal rounding can also tie scores that differ by less than 1e-4, which trec_eval then orders by document name; that is a weaker, separate point.
The devil's-advocate check175 words

The strongest case for "works as designed" (the intended-behaviour verifier and the cross-model reviews, each rating it moderate): the class calls its output a "log" and says "Extend this class and override report(...) to create different reports" (SubmissionReport.java:30-31); in LUCENE-836 (#1911) the author wrote that the "format of submission log can be modified, by extending a logger class"; lucene/benchmark is a developer tool with no compatibility promise; ordinary BM25 scores stay below 1000; and no test pins the format.

Why it fails: the shipped QueryDriver uses the stock class and documents its output as the trec_eval submission file, so the default is the format, not a sample to subclass. Nothing documents a score range. The only explicit format choice in the package (grouping on) is for the human-readable log. And the trigger is legal API that the package docs invite ("Create sophisticated Lucene queries by supplying a different Quality query parser"). The extension point is still a fair reason the defect stayed latent: a user who needed another format could override report(...).

Suggested fix

Recommended Turn grouping off (validated, recommended)

Add nf.setGroupingUsed(false) to the SubmissionReport constructor (+1 line). Non-negative scores below 1000 are written exactly as today; larger ones lose only the commas. The repro passes with assertions on and off, and the whole lucene/benchmark module (91 tests) and its format and lint checks pass.

     nf = NumberFormat.getInstance(Locale.ROOT);
     nf.setMaximumFractionDigits(4);
     nf.setMinimumFractionDigits(4);
+    nf.setGroupingUsed(false); // the score column must be a plain number for trec_eval
   }
All options and trade-offs2 options · 382 words

Both options were built in a scratch worktree of 645d4fe14a20 and run with gradle. Option A changes nothing for non-negative scores below 1000, which is why it is recommended; a maintainer may still prefer the explicit format string of option B.

Turn grouping off (validated, recommended)

One line in the SubmissionReport constructor (1 file, +1).

     nf = NumberFormat.getInstance(Locale.ROOT);
     nf.setMaximumFractionDigits(4);
     nf.setMinimumFractionDigits(4);
+    nf.setGroupingUsed(false); // the score column must be a plain number for trec_eval
   }

Trade-offs

Smallest change. Every non-negative score below 1000 is written byte for byte as today (0 differences in 1,000,000 random float scores in [0, 1000)); larger scores lose only the commas. Existing run files without such scores stay identical. The comparison and the test cover finite non-negative scores; Lucene scorers may not produce negative ones (LUCENE-7996). A negative score in a hand-built TopDocs also only loses its commas, and infinity and NaN are written as today.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle: the repro fails on stock and passes with the fix, assertions on and off.
  • The whole lucene/benchmark module: 91 tests, 0 failures.
  • Upstream TestQualityRun with -Ptests.verbose=true, which writes a submission report: passes, and its 4,952 submission lines are identical to stock's.
  • :lucene:benchmark:check -x test (format, ecjLint, forbiddenApis; main and test sources, the new test included) passes.

Format with String.format(Locale.ROOT, "%.4f", score) (validated)

Drop the NumberFormat field, its import and the constructor lines, and write String.format(Locale.ROOT, "%.4f", sd[i].score) in report() (1 file, +1/-6).

-              + nf.format(sd[i].score)
+              + String.format(Locale.ROOT, "%.4f", sd[i].score)

Trade-offs

No formatter state, and the intent is explicit. But String.format rounds half-up where DecimalFormat rounds half-even, so exact ties change in the fourth decimal: 305 of 1,000,000 random float scores below 1000 come out different from today (584.28125 gives 584.2813 instead of 584.2812), and about 8.8% of float scores between 1000 and 1,001,000 differ from option A. Harmless for trec_eval, but a few run files change in the last digit. It also writes infinity as Infinity where today's formatter writes ∞ (NaN stays NaN).

What the checks validated

  • Same worktree: the repro passes; the quality tests with -Ptests.verbose=true pass with the same 4,952 submission lines as stock; :lucene:benchmark:check -x test passes; the standalone runner passes with assertions on and off.
  • Not run: the whole module with this option.

Downstream note

Search results are not affected; only offline evaluation pipelines that feed this run file to trec_eval or another external scorer get wrong metrics.

How it was found

Code reading in a hunt unit that compared lucene/benchmark's quality metrics with trec_eval's semantics. While checking the callers of QualityBenchmark, it read the run-file writer next to trec_eval's reader (atof), then confirmed the effect with a public-API JUnit on the stock jar.

The method in full79 words
  • Hunt unit. Its main method checked the quality package's metrics against trec_eval's definitions; a side step of its second method read the callers (QualityBenchmark, SubmissionReport) and compared the written run line with trec_eval's reader. Found 2026-10-28.
  • Witness. A public-API JUnit on the stock main jar (two tests: a direct report of [1200.5, 900.25] read back the way atof does, and QualityBenchmark with a BoostQuery), not a model.
  • Method tier. Non-formal: code reading and executed tests.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-28: 196 queries over GitHub issues and PRs including the JIRA archive, the Lucene lists, downstream trackers and the web; the changed files of all 413 open PRs), refreshed 2026-10-30. Only #1911, which created the class, names SubmissionReport.

Searches4 searches

Nobody has reported a grouping separator in the run file's score column or trec_eval misreading it. Closest, not the same bug: LUCENE-2849 (#3923, open, stale since 2011), a decimal-comma crash in QualityStats.log under a non-English default locale (different file, symptom and fix; background only); LUCENE-4199, the Locale.ROOT sweep, which never mentions grouping. The Lucene.NET port formats the score with {0:F4} (no grouping), a port choice, not a report.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Solr, Elasticsearch, OpenSearch, Lucene.NET, trec_eval)2026-10-28two scripted prior-report searches and manual searches (196 queries in total: class and method names, grouping/thousands separator, comma in score, atof, run file, submission file, trec_eval, the related LUCENE ids); all 156 module:benchmark itemsno report; only #1911 contains SubmissionReport; context: #3923, LUCENE-4199, #2193, #3134, #9048, #3263
Open apache/lucene PRs2026-10-28changed-file lists of all 413 open PRs; the file's commit historynone touches SubmissionReport.java; its last commit is #16255 (2026-06-14, a field rename)
dev@, java-user@, issues@, commits@ lists, JIRA, Stack Overflow, the web2026-10-28list searches (14 quality-package and trec_eval threads read in full); all 49 JIRA benchmark-component bugs; Stack Overflow API; 9 web querieshow-to questions and usage only; nobody mentions commas or misparsed scores
GitHub refresh2026-10-30git ls-remote; issue and PR searches for SubmissionReport and four symptom phrasings; the file at main, 10.6.0 and branch_10_6; changed files of the 33 open PRs updated since 2026-10-28nothing new; the formatter is unchanged at main 716d6f216f52 and in 10.6.0; no open PR touches the quality package

History

Introduced with the class by LUCENE-836 (98fa2d898d18, 2007-07-27), first released in 2.3.0. LUCENE-4199 (2012) switched to Locale.ROOT but kept grouping. Executed on 15 releases from 2.3.0 to 10.6.0 plus main: all affected; 2.2.0 has no quality package.

Versions tested6 rows

LUCENE-836 created SubmissionReport with NumberFormat.getInstance() and 4 fixed decimals; LUCENE-1116 (2008, also in 2.3.0) made the line the 6-column TREC run format; LUCENE-2058 (2009, first in 3.0.0) made QueryDriver write it as the trec_eval submission file; LUCENE-4199 (2012, first tag 4.0.0-beta) replaced the default locale with Locale.ROOT. Before 4.0 a decimal-comma default locale such as de_DE also cost every score its fraction (999,5000 read as 999.0, run with a current JDK); that part is gone, the grouping comma is not. No release tag from 2.3.0 to 10.5.2 calls setGroupingUsed in the class; 10.6.0 and upstream main 716d6f216f52 have the same formatter.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-method test fails, 1,200.5000, assertions on and off (gradle and standalone)
10.6.0, 10.5.2 (release jars) affectedthe one-method test fails the same way
main a9779ef3c193, ea57360912ed; 9.12.3 affectedthe finder's and verifiers' tests fail, assertions on and off; pass with the fix
2.3.0, 2.9.4, 3.0.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 version probe1200.5 written 1,200.5000 and read back by atof as 1.0; the below-1000 control reads correctly (en_US)
2.2.0 no such APIthe benchmark jar has no quality package; the probe does not compile
main + setGroupingUsed(false) fix1200.5000, read back as 1200.5; the tests pass

Independent checks

The finder, an executed cross-model review (OpenAI GPT-5.6), verifiers, an adjudication and a credibility audit agree that it is real; all but one read-only review ran code. This doc's one-method test fails on main, 10.6.0 and 10.5.2 and passes with either fix.

Checks and verdicts346 words

The cross-model reviews used an independent reviewer from another model family (OpenAI GPT-5.6) on main ea57360912ed, where the relevant classes are identical to 645d4fe14a20. Nobody ran the trec_eval binary; its reader behaviour comes from its source plus three independent C atof() checks.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidatePublic-API JUnit (direct report with an atof-style read; QualityBenchmark with a BoostQuery): fails 2 of 2 on main (-ea, -da), ea57360912ed, 10.5.2 and 9.12.3; passes with the fix.
cross-model review, refute lens real (high); intended argument moderateOwn checks on ea57360912ed: direct report, QualityBenchmark + BoostQuery, QueryDriver's SimpleQQParser + BM25 (the word needle repeated 400 times: 1,182.4163), a C atof check; all fail on stock and pass with the fix; upstream TestQualityRun passes with and without it.
Independent reproducer realOwn JUnit (2 triggers and a below-1000 control): 2 of 3 fail on main (-ea, -da), 10.5.2 and 9.12.3, 3 of 3 pass with the fix; read trec_eval's source; own C atof check.
Cross-model review, intended lens real (medium); intended argument moderateRead-only, ran no Java; a separate run re-ran the finder's test with -da (fails) and with the fix (passes).
Devil's advocate real (high), impact lowBuilt the works-as-designed case; own JUnit reaches 1200 with FeatureField queries and no boosts: fails on main (-ea, -da) and 10.5.2, passes with the fix.
adjudication confirmed, high (Low)Re-ran the independent reproduction's test (2 of 3 fail, -ea, -da), the finder's (2 of 2 fail) and the independent reproduction's fix check (3 of 3 pass).
credibility audit keepRe-ran the finder's test with -da (fails 2 of 2) and its own glibc atof check; kept the trec_eval-binary caveat.
this doc reproduced; both fixes validatedThe one-method test on 645d4fe14a20 (gradle and standalone, -ea, -da) and on the 10.6.0 and 10.5.2 jars; both fix options in a scratch worktree; the format comparison.

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

Finding record

LFV-937, snapshot of 2026-11-06

Registry title
SubmissionReport writes scores of 1000 or more with a grouping comma ("1,200.5000") into the TREC run file that QueryDriver documents as the submission file for trec_eval, so trec_eval reads such a score as 1.0 and ranks those hits wrongly
Area
benchmark / quality (utils.SubmissionReport, the TREC run-file writer used by QualityBenchmark and QueryDriver)
Priority
Low, rank 583: It is the same opt-in benchmark silent-wrong-metric profile as LFV-940 but only for hits scoring 1000 or more, so it moves down from the MoreLikeThis rows to join its benchmark siblings, right after LFV-940.
Impact class
silent wrong results
Impact note
Only users of the lucene/benchmark quality package who write a TREC run file with SubmissionReport (directly, through QualityBenchmark, or with the QueryDriver command line) and score it with trec_eval or another external tool are hit, and only for hits scoring 1000 or more (boosts, FeatureField or function scores, custom similarities, or very long queries; QueryDriver's default title-only BM25 queries stay far below that): trec_eval silently reads such a score as 1.0 and re-ranks the run, so its metrics come out wrong, and stricter parsers reject the line instead. Lucene's own ranking, the index and the in-process QualityStats numbers are correct.
Summary
You evaluate search quality with lucene/benchmark's SubmissionReport, for example through QueryDriver, whose usage text calls the output the 'submission file for trec_eval', and expect the score column of each TREC run line (qid Q0 docno rank score tag) to be a plain number. SubmissionReport formats scores with NumberFormat.getInstance(Locale.ROOT) and never turns grouping off, so a hit scoring 1200.5 is written as '1,200.5000'. trec_eval reads that column with C atof(), which stops at the comma and returns 1.0, and then re-sorts the run by the parsed score, so a run [1200.5, 900.25] is evaluated as [900.25, 1.0], with the top hit moved down, and the reported metrics are silently wrong.
Introduced
98fa2d898d1882f0bee2ccb7401e1135efd05520, LUCENE-836 'Add support for search quality benchmarking.' (author Doron Cohen; Subversion/JIRA-patch era, no PR; migrated as apache/lucene#1911): the constructor built NumberFormat.getInstance() with 4 fraction digits and never turned grouping off [read: git blame on main a9779ef3c193 and a scan of every release tag 2.3.0-10.5.2 in the history clone, none of which calls setGroupingUsed in SubmissionReport; exec: 2.2.0 has no quality API, 2.3.0 already writes '1,200.5000']. Related: LUCENE-1116 (9e65cd554f2e, 2008-01-03, also in 2.3.0) made the line the 6-column TREC run format; LUCENE-2058 (149a0c9b2588, 2009-11-13, first in 3.0.0) made QueryDriver write it as the 'output submission file for trec_eval'; LUCENE-4199 (3d964bea6364, 2012-07-08, first tag 4.0.0-beta) switched to Locale.ROOT and kept grouping. (2007-07-27 (LUCENE-836, author date of 98fa2d898d18))
First affected version
2.3.0 [exec] (released 2008-01-24, the first release with the quality package; 2.2.0 has no quality API [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 main a9779ef3c193 [exec: the finder's JUnit fails 2 of 2 on main (-ea and -da), ea57360912ed, 10.5.2 (-ea) and 9.12.3 (-ea and -da); the independent reproduction's JUnit fails 2 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; a version-bridging main() probe shows 1200.5 written as '1,200.5000' and read back by atof() as 1.0 on 2.3.0, 2.9.4, 3.0.0, 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, with a below-1000 control (999.5) read correctly under en_US] [read: all 141 release tags 2.3.0-10.5.2 in the history clone have the same formatter; 8.8.2-8.11.x are not tagged there, 8.11.4 was executed; upstream main 645d4fe14a20, branch_10x and branch_10_6 have the same formatter]. In 2.3.0-3.6.2 the formatter followed the JVM default locale, so under a decimal-comma locale such as de_DE every score also lost its fraction ('999,5000' read as 999.0) [exec on 2.3.0 and 3.6.2]; LUCENE-4199 removed that part with Locale.ROOT but left the grouping comma. All runs on aarch64 Linux with JDK 25, release jars checked against Maven Central SHA-1s.
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
non-formal: code reading
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only, 196 queries): two scripted prior-report searches (the finder's 48 queries plus 6 web, the follow-up's 81 plus 8 web) over apache/lucene issues and PRs including the migrated JIRA archive, commits, downstream trackers (apache/solr, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet, usnistgov/trec_eval), dev@, java-user@, issues@ and commits@ and the web; all 156 items labelled module:benchmark; all 49 JIRA benchmark-component bugs; 14 quality-package and trec_eval list threads read in full; Stack Overflow; the changed-file lists of all 413 open PRs (none touches SubmissionReport.java). The only apache/lucene item whose text contains 'SubmissionReport' is LUCENE-836 (#1911), which created the class. Related, not this bug: LUCENE-2849 (#3923, still open, stale since 2011): a decimal-separator crash in QualityStats.log under a non-English default locale, a different file and defect that Locale.ROOT made obsolete; LUCENE-4199 (the Locale.ROOT sweep, no word on grouping); LUCENE-1116 (#2193) and LUCENE-2058 (#3134, contract evidence). The Lucene.NET port formats the score with '{0:F4}' (no grouping), a port choice, not a report. Not fixed upstream: SubmissionReport.java at upstream main 645d4fe14a20 (2026-10-28) is identical to a9779ef3c193; its last change was #16255 (2026-06-14, a typos rename). Recheck 2026-10-28: GitHub search 'SubmissionReport' still returns only #1911; none of the newest PRs (#16912-#16932, created 2026-10-26) or issues created since 2026-10-26 (#16918-#16935) concerns lucene/benchmark.
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 both fix options (plus the benchmark module's tests and checks with the fixes); the 10.6.0 and 10.5.2 release jars (SHA-1 checked against Maven Central). Earlier hunt runs: main a9779ef3c193 and ea57360912ed, 9.12.3, and a version probe on 14 releases from 2.3.0 to 10.5.2. SubmissionReport.java is identical at the three main commits and unchanged on upstream main 716d6f216f52 (2026-10-29).

Lucene and upstream sources

Finding record
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/utils/SubmissionReport.java:29-32 (javadoc 'Create a log ready for submission'), :45-51 (constructor: NumberFormat.getInstance(Locale.ROOT), 4 fraction digits, grouping left on), :70-83 (report: 'qid Q0 docno rank score tag' line, nf.format(sd[i].score))
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:40-48 (usage 'submissionFile: output submission file for trec_eval'), :60-63 (writes it with the stock SubmissionReport)
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityStats.java:148 (nf.setGroupingUsed(true), set explicitly for the human-readable log; the only setGroupingUsed call in lucene/benchmark/src/java)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/utils/SubmissionReport.java:30, :45-51, :62-86 (:68, :82)
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:48, :59-63; lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityBenchmark.java:32-41
Who is affected
  • github.com/apache/lucene/pull/71 (LUCENE-9651), comment by rmuir of 2021-04-08 (read 2026-10-30)
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/package-info.java:78; utils/SimpleQQParser.java:72 (escapes the topic text)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:48; utils/SubmissionReport.java:30-31; QualityBenchmark.java:32-41; QualityStats.java:145-148; package-info.java:78; lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/TestQualityRun.java:84

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

Repro files

About the diagrams

It was made in parallel with this doc; its values come from a probe on 645d4fe14a20 (assertions on and off) and a small reader that applies trec_eval's atof line and its sort to the written lines.

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.