Topic ids are Strings. compareTo uses number order when both ids are whole numbers and text order otherwise; when the two kinds are mixed the orders contradict each other, so no correct sorted order exists.
lucene/benchmarkships tools that measure search quality on TREC-style data: a topics file (the queries), a judgements file (which documents are relevant) and an index.TrecTopicsReader(orTrec1MQReader, for oneid:textline per topic) turns each topic into aQualityQuerywith a String id and sorts them by id.QueryDriveris the command-line tool that runs a whole evaluation this way.- The sort uses
QualityQuery.compareTo. Java's sort requires a consistent order: if a < b and b < c, then a < c.
compareTo orders the ids 2, 100 and 1a, why its three answers form a circle, and what the reader's sort does with it. Select the still to open it at full size. Generated with AI, checked against the code.- Load a TREC topics file with
TrecTopicsReader.readQueries, as theQueryDrivertool does: topics 1 to 111 in order, plus sub-topics 1a, 20a, 64a and 99a, each right after its parent (115 topics). - You expect the 115 topics back sorted by id; the reader's code says
// sort result array (by ID). - You get
IllegalArgumentException: Comparison method violates its general contract!fromArrays.sort, and the evaluation stops before any query runs. The same file without the four sub-topics (111 topics) sorts fine. - Why: two whole-number ids are compared as numbers, anything else as text. So 2 < 100 as numbers, but 100 < 1a and 1a < 2 as text ("0" sorts before "a", "1" before "2"): a circle, so no sorted order exists.
- Java's sort notices only sometimes: never below 32 topics, and only for some file orders. Otherwise the order silently follows the file: topics 2, 100 and 1a come back in whichever of the three rotations the file had.
Users of the benchmark module's search-quality tools (QueryDriver, TrecTopicsReader, Trec1MQReader) whose topic ids mix plain numbers with ids like 12a or 3.1, or with digit-only ids above 2,147,483,647, in sets of 32 or more topics. Search, indexing and the other modules are unaffected; when the sort returns, each query's results and metrics are unchanged.
Mechanism, terms and numbers802 words
QualityQuery implements Comparable<QualityQuery>, and its compareTo (QualityQuery.java:68-81 on main 645d4fe14a20) returns n - nOther when both ids parse as int and otherwise compares the ids as Strings. When whole-number ids and other ids are mixed, the two orders disagree and form cycles: 2 < 100 as numbers, 100 < 1a and 1a < 2 as Strings. TrecTopicsReader.readQueries and Trec1MQReader.readQueries sort their result with Arrays.sort(qq) under // sort result array (by ID), and QueryDriver calls the former on its topics file. With 32 or more topics the JDK's TimSort can detect the cycle and throw IllegalArgumentException: Comparison method violates its general contract!: a 115-topic file (ids 1..111 plus 1a, 20a, 64a, 99a, each after its parent) throws on main and 10.6.0, with assertions on and off. Otherwise the result order depends on the file order. Digit-only ids beyond the int range (3000000015) also fall back to String order and trigger it with 35 topics. A side issue, n - nOther overflow (2147483647 vs -1), needs a negative id. The logic dates from LUCENE-836 (2007, first released in 2.3.0) and became an exception when JDK 7 made TimSort the default. A total order (Integer.compare for two int ids, int ids first, String order for the rest) fixes it.
QualityQuery.java:68-81:
/* for a nicer sort of input queries before running them.
* Try first as ints, fall back to string if not int. */
@Override
public int compareTo(QualityQuery other) {
try {
// compare as ints when ids ints
int n = Integer.parseInt(queryID);
int nOther = Integer.parseInt(other.queryID);
return n - nOther;
} catch (NumberFormatException _) {
// fall back to string comparison
return queryID.compareTo(other.queryID);
}
}Both readers end with // sort result array (by ID) and Arrays.sort(qq) (TrecTopicsReader.java:106-108, Trec1MQReader.java:84-86); QueryDriver.main reads its topics file with TrecTopicsReader (QueryDriver.java:77-79). The readers take the id text as written (TrecTopicsReader.java:71-73, Trec1MQReader.java:70-71) and the public constructor accepts any String.
2vs100: both ints,2 - 100 = -98, so 2 < 100.100vs1a:1ais not an int, so Strings:'0'(48) <'a'(97),-49, so 100 < 1a.1avs2: Strings,'1'<'2',-1, so 1a < 2.
So 2 < 100 < 1a < 2. The same can happen when an id that starts with a digit but is not an int (1a, 10.1, 31_1) meets int ids of different lengths (2 < 10 < 10.1, but "10.1" < "2"; not every such mix cycles: 2 < 10 < 3a is consistent), and when in-range ints meet digit-only ids too large for an int (9 < 10 as ints, but "10" < "3000000015" < "9" as Strings). Integer.parseInt accepts a sign, so signed ids can cycle even at one length (-99, -01, -0a). Letter-prefixed ids (q1, MB01) mixed with ints, and fixed-length ids without a sign, stay consistent (with ASCII digits): letters sort after digits, and equal-length digit strings sort like numbers (6,500 random fixed-length trials, 0 exceptions; exhaustive law checks over all two- and three-character ids of small alphabets, 0 violations).
Arrays.sort(Object[]) is the JDK's TimSort (unless -Djava.util.Arrays.useLegacyMergeSort=true), documented to throw IllegalArgumentException "if the natural ordering of the array elements is found to violate the Comparable contract". Below 32 elements it sorts by binary insertion and never merges; the exception comes only from its merge steps (ComparableTimSort.mergeLo / mergeHi) when a merge meets an impossible state. So fewer than 32 topics never throw, and larger files throw only for some orders (these counts are for JDK 25's TimSort; the broken contract does not depend on the runtime): 172 of 100,000 random natural-order files with 1 to 12 <n>a sub-topics threw in one seeded search; 889 and 1,055 of 200,000 with dotted (3.1) and underscore (31_1) sub-topics in an independent reproducer's search.
The sort returns some order that depends on the input: the three topics 2, 100, 1a come back unchanged for each of the three rotations of the file ([2, 100, 1a], [100, 1a, 2], [1a, 2, 100]). With the legacy merge sort the 115-topic file returns all 115 topics, starting [1, 1a, 2, 3, 4, ...], with 1a before 100 (positions 1 and 102) although compareTo(1a, 100) is 49.
n - nOther wraps for extreme ids: compareTo(2147483647, -1) and compareTo(-1, 2147483647) are both -2147483648, so each is "less than" the other. Two non-negative ints cannot overflow, so this needs a negative id.
Exposure and likelihood310 words
- Who can hit it. Users of
lucene/benchmark's search-quality package: theQueryDrivercommand-line tool ("Command-line tool for doing a TREC evaluation run") and code that callsTrecTopicsReader.readQueriesorTrec1MQReader.readQueries, or sortsQualityQueryobjects itself. The package docs invite other id sources: "To run on different formats of queries and judgements provide your own Judge and Quality queries". - What it takes. A topics file in the readers' format (neither validates ids) whose ids mix plain numbers with ids that start with a digit and are not ints (sub-topics such as
12a,3.1,31_1), or with digit-only ids beyond 2147483647 (1..33 plus3000000015and3000000016, each right after 15 and 16, 35 topics, throw throughTrec1MQReader). Three topics are enough for an order that depends on the file; the exception needs 32 or more and an order the sort detects (JDK 25). Default settings, public API, single thread. - Effect. A clean
IllegalArgumentExceptionfromreadQueries;QueryDriverstops before any query runs, and the submission file it already created is left empty (read, not run). When the sort returns, judgements are looked up by id (TrecJudge.java:91), so each query's results and metrics do not change; the order sets the run, log and submission order, which topicssetMaxQueries(n)("Useful at debugging") keeps, and the summation order of the averages, so they can differ by floating-point rounding (read, not run). - Not hit. Search, indexing and every module other than
lucene/benchmark; topic sets whose ids are all non-negative ints, all letter-prefixed, ints mixed only with letter-prefixed ids, or all of one length without a sign (ASCII digits). - How likely. Rare. TREC topic sets are conventionally numbered with plain ints, and no public topic set mixing these id kinds is known; the example ids are synthetic. Hand-extended topic sets (sub-topics, merged collections with long ids) make it plausible.
One JUnit test in lucene/benchmark, public API only: ids 2, 100 and 1a, and a check that their three compareTo answers do not form a circle. No index, no randomness; it fails with assertions on and off, and passes with any fix that makes the order total.
/*
* 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 java.util.Map;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestQualityQueryCompareTo extends LuceneTestCase {
public void testCompareToIsTransitive() {
QualityQuery a = new QualityQuery("2", Map.of());
QualityQuery b = new QualityQuery("100", Map.of());
QualityQuery c = new QualityQuery("1a", Map.of());
int ab = Integer.signum(a.compareTo(b));
int bc = Integer.signum(b.compareTo(c));
int ca = Integer.signum(c.compareTo(a));
// expected a total order; actual 2 < 100 (as ints), 100 < 1a and 1a < 2 (as strings): a cycle
assertFalse(
"expected no cycle, got compareTo(2,100)=" + ab + ", (100,1a)=" + bc + ", (1a,2)=" + ca,
ab != 0 && ab == bc && bc == ca);
}
}
cp $REPRO/TestQualityQueryCompareTo.java $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/
cd $LUCENE && ./gradlew :lucene:benchmark:test --tests org.apache.lucene.benchmark.quality.TestQualityQueryCompareTo -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
- Expected
- The three answers are consistent: at least one of 2 < 100, 100 < 1a and 1a < 2 is false.
- Observed
compareTo(2,100)=-1, (100,1a)=-1, (1a,2)=-1: 2 < 100 < 1a < 2, a circle, with assertions on and off.
More files, environment, full output261 words
The test builds new QualityQuery(id, Map.of()) for 2, 100 and 1a and fails only if the signs of compareTo(2, 100), compareTo(100, 1a) and compareTo(1a, 2) are equal and non-zero, which is a cycle. It does not fix which way the ids must sort, so any total order a fix picks passes.
The user-visible symptom, the readQueries exception on the 115-topic file, is left out of the repro on purpose: it follows from this cycle, depends on the input order and needs 32 or more topics. Its output is in the observed output below and in the steps above.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestQualityQueryCompareTo.java $LUCENE/lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/
cd $LUCENE && ./gradlew :lucene:benchmark:test --tests org.apache.lucene.benchmark.quality.TestQualityQueryCompareTo -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
# the minimal test, stock main 645d4fe14a20, gradle, -Ptests.asserts=false (true prints the same)
TestQualityQueryCompareTo > testCompareToIsTransitive FAILED
java.lang.AssertionError: expected no cycle, got compareTo(2,100)=-1, (100,1a)=-1, (1a,2)=-1
at __randomizedtesting.SeedInfo.seed([DEADBEEF:8D60531F55597FD9]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.assertTrue(Assert.java:42)
at junit@4.13.2/org.junit.Assert.assertFalse(Assert.java:65)
at org.apache.lucene.benchmark.quality.TestQualityQueryCompareTo.testCompareToIsTransitive(TestQualityQueryCompareTo.java:32)
1 test completed, 1 failed
BUILD FAILED
# the same failure on the 10.6.0 release jars, -ea and -da; with the fix: BUILD SUCCESSFUL
# the user-visible symptom: TrecTopicsReader on ids 1..111 plus 1a, 20a, 64a, 99a (115 topics), stock main jars
java.lang.IllegalArgumentException: Comparison method violates its general contract!
at java.base/java.util.ComparableTimSort.mergeHi(ComparableTimSort.java:870)
at java.base/java.util.ComparableTimSort.mergeAt(ComparableTimSort.java:487)
at java.base/java.util.ComparableTimSort.mergeForceCollapse(ComparableTimSort.java:426)
at java.base/java.util.ComparableTimSort.sort(ComparableTimSort.java:222)
at java.base/java.util.Arrays.sort(Arrays.java:1038)
at org.apache.lucene.benchmark.quality.trec.TrecTopicsReader.readQueries(TrecTopicsReader.java:108)
# with the fix: 115 topics, sorted, starting [1, 2, 3, 4, 5, 6, 7, 8]QualityQueryimplementsComparable, which requires one consistent order for all ids;Arrays.sortthrows because that contract is broken.- The code expects non-numeric ids ("fall back to string if not int"), and the public constructor takes any String.
- Digit-only ids too large for an
int(3000000015) break it too, inside the documented numeric format.
The full argument203 words
- The JDK contract.
Comparablerequiressignum(x.compareTo(y)) == -signum(y.compareTo(x))and transitivity for allx,y,z;Arrays.sortdocuments theIllegalArgumentExceptionfor exactly that violation. - The code's own intent. "for a nicer sort of input queries before running them. Try first as ints, fall back to string if not int" (
QualityQuery.java:68-69): non-int ids were expected. Both readers say// sort result array (by ID); a cycle has no sorted order. - The API. The public constructor takes any String id (
QualityQuery.java:44); the class javadoc,getQueryIDand the package docs put no limit on ids, and the readers keep the id text as written. - Tests and history. Both
TestQualityRuntests readtrecTopics.txt(20 topics, ids 0..19, in order) withTrecTopicsReader:testTrecTopicsReaderchecksqqs[0],qqs[1]andqqs[19], andtestTrecQuality's per-position checks (i % 8) assume numeric order. No test uses a non-int id or 32 topics. No commit, CHANGES or MIGRATE entry discusses the id order, and every later edit tocompareTowas cosmetic. The code predates JDK 7, whose TimSort turned the inconsistency into an exception. - Precedent. Lucene fixed broken
compareTomethods as bugs before: LUCENE-5790 (2014), "Fix compareTo in MutableValueDouble and MutableValueBool".
The devil's-advocate check212 words
The strongest case for "intended" (the intended-behaviour check and the cross-model intended-lens reviewer): the readers document numeric ids (<num> Number: nnn, qnum:qtext), and as long as every id is a non-negative number that fits in an int, compareTo is a total order (an exhaustive law check and a shuffled 5,000-topic file pass); sub-topic ids such as 1a are outside the format's numeric examples; letter-prefixed and fixed-length unsigned ids are consistent too; the sort is cosmetic, each query's metrics do not depend on it, the module is a developer tool, and the overflow needs a negative id.
Why it fails: (1) the format does not stop at the int range: digit-only ids beyond it are numbers in the documented format and still cycle (9 < 10 as ints, "10" < "3000000015" < "9" as Strings) and throw (35 topics); this needs no sub-topic ids; (2) the format lines are sketches, not preconditions: nothing validates ids, the shipped topics use Number: 0 to 19, and the package docs invite other formats; (3) the failure is not cosmetic: readQueries throws and QueryDriver stops; (4) a public Comparable must order every value its constructor accepts. All verifiers rate the intended argument weak; the intended-behaviour check judged the bug real, impact low.
Recommended Int ids first, by value; every other id after them, as Strings
Make the order total: compare two whole-number ids with Integer.compare, put every whole-number id before every other id, and compare the rest as text (one method in QualityQuery). The repro, the quality-package tests and :lucene:benchmark:check pass with it. It decides for the first time where non-numeric ids go.
All options and trade-offs5 options ยท 724 words
Option A was built and run; B, C and D are alternatives with different orders, not built. Whatever is chosen, the minimal test stays the regression test: it passes for any total order.
A PR would also add a CHANGES.txt line under Bug Fixes.
Int ids first, by value; every other id after them, as Strings (validated by the checks) (recommended)
One method in QualityQuery.java (+16/-7), the finder's fix unchanged. Every id is either an int or not; ints are ordered by Integer.compare (no overflow), the rest by String.compareTo, and every int comes before every non-int. Each part is a total order and the parts do not interleave, so the whole is one. It also removes the n - nOther overflow.
/* for a nicer sort of input queries before running them.
* Numeric ids sort first, by value; other ids follow, as strings. This is a total order. */
@Override
public int compareTo(QualityQuery other) {
Integer n = parseIntOrNull(queryID);
Integer nOther = parseIntOrNull(other.queryID);
if (n != null && nOther != null) {
return Integer.compare(n, nOther);
} else if (n != null) {
return -1;
} else if (nOther != null) {
return 1;
}
return queryID.compareTo(other.queryID);
}
private static Integer parseIntOrNull(String id) {
try {
return Integer.parseInt(id);
} catch (NumberFormatException _) {
return null;
}
}Trade-offs
- Unchanged: all-int sets (the shipped ids 0..19 stay
[0, 1, ..., 19]), all-non-int sets, and ints mixed with letter-prefixed ids ([2, 5, 30, MB01, q1, q10]before and after). - Now deterministic:
2, 100, 1asorts to[2, 100, 1a]for every input order. - Changed: non-int ids that start with a character below
'0'move from before the ints to after them ([#1, -x, 2, 5, 30]becomes[2, 5, 30, #1, -x]). - Still text order: digit-only ids beyond the int range follow the ints and sort as Strings among themselves (
[7, 33, 10000000000, 3000000015]); option B orders them by value. - Ids that parse to the same int (
007,7) compare equal, as before; the stable sort keeps their file order. - Cost: the stock code throws at most one
NumberFormatExceptionper comparison (it stops at the firstparseIntthat fails); the fix parses both ids, so it throws one for each non-int id, two when both are non-int. Parsing once in the constructor would avoid both (not built).
What the checks validated
- Gradle in a scratch worktree of main
645d4fe14a20with the diff applied: the minimal test passes with-Ptests.asserts=trueandfalse;--tests 'org.apache.lucene.benchmark.quality.*'passes (TestQualityQueryCompareTo, andTestQualityRun'stestTrecQualityandtestTrecTopicsReader);:lucene:benchmark:checkpasses (19 test classes, 91 tests, 0 failures; google-java-format, ecjLint, forbiddenApis). - With the fixed class: the 115-topic file reads as 115 sorted topics; the three-topic files all come back
[2, 100, 1a];compareTo(2147483647, -1)is 1 and the reverse -1. - Earlier, on main
a9779ef3c193: an exhaustive law check over 14 ids finds 0 antisymmetry and 0 transitivity violations (109 transitivity and 4 antisymmetry violations without the fix); the finder's 115-topic test, an independent 6-test reproduction and the cross-model reviewers' tests pass;TestQualityRunstays green also with-Dtests.verbose=true(that run had three other hunt fixes applied too).
Treat ids made only of digits as numbers compared by value (for example BigInteger, or length after stripping leading zeros, then the digits), then the rest as Strings.
Trade-offs
Also orders 33 < 3000000015 < 10000000000. More code; signs and leading zeros must be decided and kept total.
For example 1, 1a, 2, 10, 10.1, 100.
Trade-offs
The nicest order for sub-topic ids, but the most code, and it must itself be shown to be a total order.
return queryID.compareTo(other.queryID);
Trade-offs
Smallest change and total, but it reorders every all-numeric topic set (0, 1, 10, 11, ..., 19, 2, ..., 9), changing the run and report order for every current user; TestQualityRun.testTrecTopicsReader expects qqs[19] to be topic 19, which would become topic 9, and testTrecQuality's per-position checks would no longer line up (read, not run).
Widens the numeric range.
Trade-offs
Keeps the 2 < 100 < 1a < 2 circle.
The Lucene.NET port of the benchmark module has the same CompareTo (n - nOther for two ints, otherwise an ordinal String compare), so it inherits the bug.
An exhaustive check of the Comparable laws on the real class, over 14 ids, found 109 transitivity and 4 antisymmetry violations; a seeded search over 100,000 topic files then found ones that make the real reader throw. Independent reviewers, one from another model family, reproduced it with their own tests.
The method in full176 words
Method: semi-formal. The hunt unit compared Lucene's search-quality code with reference semantics. For QualityQuery.compareTo it checked antisymmetry and transitivity exhaustively over a 14-id alphabet on the real class (2,940 checks): 4 antisymmetry violations, all from the n - nOther overflow, and 109 transitivity violations, 6 of them without the int extremes (example: 9 < 10 < 5x but not 9 < 5x). With the fix: 0 and 0.
A seeded random search (seed 371) then generated 100,000 natural-order topic files (ids 1..N, N = 32..331, plus 1 to 12 <n>a sub-topics right after their parents) and ran the real TrecTopicsReader.readQueries on each: 172 threw. The smallest, 115 topics with 1a, 20a, 64a, 99a, became the finder's JUnit; the registry's minimal repro keeps only the three-id cycle.
Then: an independent reproducer wrote its own tests from the claim (dotted sub-topics, beyond-int ids, the overflow); cross-model reviews (OpenAI GPT-5.6, refute and intended lenses) wrote and ran their own tests; a devil's-advocate verifier argued "intended" and rejected it; an adjudication re-ran the tests and confirmed.
No prior report found (searched 2026-10-28, refreshed 2026-10-30): GitHub issues, PRs and commits, the mailing lists, the JIRA archive, downstream projects and the web. Nearest: #1911 (LUCENE-836), where the code came from.
Searches4 searches
No issue, PR, JIRA entry, mailing-list thread or web page reports that QualityQuery.compareTo is not a total order, that it overflows, or that the topic readers throw "Comparison method violates its general contract!".
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), commits, downstream repos, mailing lists, JIRA archive, web | 2026-10-28 | three scripted prior-report searches: 48 GitHub issue/PR, 9 commit, 6 downstream, 60 mailing-list (dev@, java-user@, issues@, commits@), 12 JIRA-archive and 12 web queries; 18 manual apache/lucene searches; apache/lucenenet, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub; 10 JQL searches; every lists.apache.org thread naming QualityQuery or TrecTopicsReader read | no report of this defect |
| Open and recent PRs (apache/lucene) | 2026-10-28 | file lists of all 413 open PRs and the 200 most recently updated closed ones; upstream commits on the three files | none touches QualityQuery or the readers' sort; last change dbf0d1426f13 (#16056, unrelated) |
| GitHub issues and PRs (apache/lucene), refresh | 2026-10-30 | QualityQuery, TrecTopicsReader, Trec1MQReader, "Comparison method violates", compareTo benchmark quality, QualityQuery compareTo; file lists of the 19 PRs created since 2026-10-28 (#16926-#16955); run twice, the second time with the raw responses saved | #1911, #6095, #3286, #6307, #3072, #2557, #3952: none is this bug; no recent PR touches the quality package |
| Related items (read) | 2026-10-28 | the closest hits | #1911 (LUCENE-836, origin of the code; no discussion of id order); #3286 and #6095 (TrecTopicsReader parsing); #6307 (the Clover tool's comparator); apache/lucenenet PR #1056 (reworked the port's CompareTo, kept the logic) |
Introduced by LUCENE-836 (98fa2d898d18, 2007) and first released in 2.3.0; unchanged since apart from cosmetic edits. It became an exception when JDK 7 made TimSort the default sort. Fails on 17 executed targets from 2.3.0 to main, and on 10.6.0.
Versions tested7 rows
Introduced by 98fa2d898d18 (Doron Cohen, 2007-07-27, "LUCENE-836: Add support for search quality benchmarking"; migrated as #1911; before GitHub PRs), which added compareTo with today's logic and TrecTopicsReader's Arrays.sort(qq). Trec1MQReader, with the same sort, came in 9e65cd554f2 (LUCENE-1116, 2008-01-03), before 2.3.0 was tagged (2008-01-24). Later edits are cosmetic: a0776e5cd0a (2009, generics), e2f54df3ca7 (2012, @Override), 7f8b7ffbcad (2021, reformatting), 2971f311a2b and ba7f659b1d8 (2021, 2025, the catch variable).
The JDK decides whether it throws. The comparator is invalid on any runtime. With the pre-JDK-7 merge sort (-Djava.util.Arrays.useLegacyMergeSort=true) the readers return every topic without an exception, in an inconsistent order; since Lucene 4.8.0 requires Java 7, every supported runtime uses TimSort by default. The shipped test topics (ids 0..19, 20 topics) are all ints and fewer than 32, so no upstream test could catch it.
| Version or commit | Verdict | Result |
|---|---|---|
| 2.2.0 | API absent | no org.apache.lucene.benchmark.quality package (compile error) |
| 2.3.0 | affected | all 6 checks fail, the exception in both readers; the all-int control passes |
| 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.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 | affected | the same 6 failures; control passes |
| 10.6.0 | affected | the minimal test fails (-ea, -da); the 115-topic test throws at TrecTopicsReader.readQueries:108 (-ea, -da) |
main a9779ef3c193, 645d4fe14a20 | affected | the 6 checks fail (-ea, -da); the minimal test fails through gradle and the standalone runner |
| 2.3.0 and main with the legacy merge sort | no exception | the law checks still fail; the readers return every topic in an inconsistent order |
| main with the fix | not affected | all checks and the minimal test pass |
The independent reproducer (real), the devil's-advocate verifier (real, impact low), the cross-model reviews (real, high, each with its own executed tests) and the adjudication (confirmed, high) agree; the registry audit kept it. Fact checks and a diagram check found only wording errors, now fixed.
Checks and verdicts505 words
Every check ran code. The cross-model reviews ran on ea57360912ed, where the three files are identical to main 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| finder (semi-formal) | candidate | Exhaustive law check over 14 ids (4 antisymmetry, 109 transitivity violations; 0 with the fix); seeded search (172 of 100,000 files throw); JUnit 2 of 2 fail on main (-ea, -da) and on 10.5.2 and 9.12.3 (-ea; the follow-up ran the releases with -da); the fix keeps TestQualityRun green. |
| cross-model review, refute lens (OpenAI GPT-5.6) | real, high | Own public-API tests: the exception through Trec1MQReader (-ea, -da), the transitivity and overflow failures; fix passes. One shuffled mixed-id control sorted without an exception: the exception depends on order, the law violation does not. |
| Independent reproducer | real | Own 6-test JUnit (dotted sub-topics, beyond-int ids, overflow, control): 5 of 6 fail on main -ea/-da, 10.5.2 and 9.12.3; control passes; the same fix passes 6 of 6. |
| Cross-model review, intended lens (OpenAI GPT-5.6) | real, high | Own test fails 3 of 4, passes with the fix; "works as designed" rated weak. |
| Devil's advocate | real, impact low | Built the documented-numeric-format case and rejected it; own JUnit 2 of 5 fail (its 3 documented-format controls pass) on main, 10.5.2 and 9.12.3; 6,500 fixed-length trials, 0 exceptions. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test (-da, 5 of 6 fail) and the finder's (-ea, 2 of 2 fail); duplicate of none; novelty new. |
| Follow-up, audit, re-runs | keep | History on 17 targets plus 2.2.0, legacy-sort controls and fix shadow; prior-art search; security screen (none); re-runs (fresh seeds 51A7E001 and 51A7E002; the audit's with seed DEADBEEF). |
| this brief | real | Gradle in a scratch worktree of 645d4fe14a20: the minimal test fails stock (-ea, -da) and passes with the fix, and the quality tests and :lucene:benchmark:check pass with it; the minimal test and the 115-topic test on 10.6.0; the probes, stock and fixed. |
| Fact check of this doc (independent) | reproduced; 4 text errors, fixed | Re-ran the doc's gradle command in its own worktree (stock fails with asserts on and off, the fix passes, :lucene:benchmark:check green), the minimal test on main and 10.6.0 and the finder's test on 10.6.0 (-ea, -da), and its own probe. Errors: a second test also reads the topics, the cost of the stock code, branch_10_6's last change, and two trigger statements that were too broad; all fixed. |
| Fact check of this doc (OpenAI GPT-5.6, read-only) | core bug real; wording corrected | A reading-only review against the code and the saved runs: 8 factual errors (0 high, 7 medium, 1 low), 3 overclaims and 3 missing caveats, none of which changes the verdict; intended case "moderate", no-noise judgement "claim-low, not noise". Every item was fixed, or answered with evidence. |
| Diagram check | pass | Every on-screen value, line number and step matches the code, the JDK sources and the probes; the TimSort walk-through was re-derived by hand and matches all 128 logged comparisons. Five minor layout and wording items, none blocking. |
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 579 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,503 words
- Registry title
- QualityQuery.compareTo is not a total order when numeric and non-numeric topic ids are mixed (ints compared as numbers, everything else as strings), so TrecTopicsReader and Trec1MQReader.readQueries, and with them QueryDriver, can throw IllegalArgumentException 'Comparison method violates its general contract!'
- Area
- benchmark / search-quality package (QualityQuery, TREC topic readers)
- Priority
- Low, rank 664: The non-total comparator gives a loud IllegalArgumentException that aborts a benchmark run only for 32 or more topics with mixed numeric and digit-leading ids, a clear crash in a rarely used offline tool, so it is Low.
- Impact class
- crash
- Impact note
- Only users of the benchmark module's search-quality tools (QueryDriver, or their own code that calls TrecTopicsReader or Trec1MQReader.readQueries) are hit, and only with a topics file of 32 or more topics whose ids mix plain numbers with ids that start with a digit but are not ints (1a, 10.1, 31_1) or with digit-only ids beyond the int range, in an input order the sort happens to detect: readQueries then throws IllegalArgumentException and the evaluation run aborts; all-numeric, letter-prefixed (q1, MB01) and fixed-length id sets are unaffected, no scale is needed, and every release from 2.3.0 to 10.6.0 has it.
- Summary
- You read a TREC topics file with TrecTopicsReader or Trec1MQReader (as QueryDriver does) whose ids are mostly numbers but include a few sub-topic ids such as 1a or 10.1, or a few digit-only ids larger than Integer.MAX_VALUE, and expect the topics back sorted by id, as the readers' '// sort result array (by ID)' comment says. Instead QualityQuery.compareTo compares two int ids as numbers and every other pair as strings, which forms cycles (2 < 100 as numbers, 100 < 1a and 1a < 2 as strings), so Arrays.sort can throw IllegalArgumentException 'Comparison method violates its general contract!' (the repro: ids 1..111 plus 1a, 20a, 64a and 99a, each listed after its parent); when the sort does not detect the cycle, the topics come back in an order that depends on the file order. Separately, the int branch returns n - nOther, which overflows for extreme ids such as 2147483647 vs -1 (this needs a negative id).
- Introduced
- 98fa2d898d18, LUCENE-836 'Add support for search quality benchmarking' (migrated as apache/lucene#1911; SVN era, no PR): added QualityQuery.compareTo (n - nOther when both ids parse as int, else String.compareTo) and TrecTopicsReader's Arrays.sort(qq) under '// sort result array (by ID)'. Trec1MQReader, with the same Arrays.sort(qq), came in 9e65cd554f2 (LUCENE-1116, apache/lucene#2193), before 2.3.0 was tagged. Later edits to compareTo are cosmetic: a0776e5cd0a (2009, compareTo(Object) to compareTo(QualityQuery)), e2f54df3ca7 (2012, @Override), 7f8b7ffbcad (2021, reformatting), 2971f311a2b and ba7f659b1d8 (2021, 2025, catch-variable form). [read: git log -L on compareTo and git log -S 'Arrays.sort(qq)' in the full-history clone; bracketed by execution: 2.2.0 has no quality package, 2.3.0 fails] (2007-07-27 (LUCENE-836, author date); Trec1MQReader's sort site 2008-01-03 (LUCENE-1116, author date))
- First affected version
- 2.3.0 [exec] (tagged 2008-01-24), the first release with the quality package; 2.2.0 has no org.apache.lucene.benchmark.quality package [exec: compile error]. The comparator breaks the Comparable contract in every version; whether readQueries throws depends on the JDK's sort: with the pre-JDK-7 merge sort (-Djava.util.Arrays.useLegacyMergeSort=true) the readers return every topic without an exception, in an order that is not sorted [exec on 2.3.0 and main], so the exception needs JDK 7 or later, which every Lucene release since 4.8.0 requires
- Affected versions
- Every release from 2.3.0 to 10.6.0 (latest 9.x: 9.12.3; 10.6.0 [exec] on its release jars by the bug doc's tests, -ea and -da), branch_10_6 4ae946e5a9b (the 10.6.0 release revision) [read: same logic] and main a9779ef3c193 [exec]. A version-neutral main() repro (public API, 6 checks plus a control, -da) fails identically on 17 targets: 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.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (main also with -ea); the all-int control passes everywhere, and the fix sketch shadowed on main passes all 6 checks [exec]. The finder's JUnit fails 2 of 2 on main a9779ef3c193 and ea57360912ed, 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 5 of 6 on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's fails 2 of 5 on main, 10.5.2 and 9.12.3 [exec]. Not fixed upstream: the three files were last changed by dbf0d1426f13 (#16056, author date 2026-05-14, an unrelated toArray change) on upstream main 645d4fe14a20 (2026-10-28). 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: exhaustive check
- Upstream and prior reports
- none found (2026-10-28, read-only; the prior-report record, verdict new): three scripted prior-report searches (48 GitHub issue/PR, 9 commit, 6 downstream, 60 mailing-list over dev@, java-user@, issues@ and commits@, 12 JIRA-archive and 12 web queries), 18 manual apache/lucene issue/PR searches, searches in apache/lucenenet, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and 7 across all of GitHub, 10 JQL searches (LUCENE, LUCENENET, SOLR), lists.apache.org reads of every thread naming QualityQuery or TrecTopicsReader, a file-list scan of all 413 open apache/lucene PRs and of the 200 most recently updated closed ones, and the upstream commits on the three files; the independent reproduction, the intended-behaviour check and the adjudication ran their own GitHub legs with the same result. Closest items, none this bug: #1911 (LUCENE-836, 2007), the origin of the quality package and of this compareTo, whose discussion never mentions id ordering; #3286 (LUCENE-2210) and open #6095 (LUCENE-5031), TrecTopicsReader field parsing; #6307 (LUCENE-5243), the only apache/lucene hit for the exception message, about the Clover tool's comparator; apache/lucenenet PR #1056 (merged 2024-12), which reworked the .NET port's CompareTo (int.TryParse) but kept n - nOther and the string fallback without discussing either (the port inherits the defect; not a report); open #16911 (deletes branch_10x) and #16847 (touches quality/utils only), not fixes. Not fixed upstream: QualityQuery.java, TrecTopicsReader.java and Trec1MQReader.java were last changed by dbf0d1426f13 (#16056, author date 2026-05-14) on upstream main 645d4fe14a20 (2026-10-28). Refresh (2026-10-28): 'repo:apache/lucene QualityQuery' 1 hit (#1911); 'TrecTopicsReader' 2 (#6095, #3286); 'Trec1MQReader' 0; '"general contract" compareTo' 0; 'benchmark quality updated:>=2026-10-26' 1 unrelated (#16794, HNSW merges); none of the PRs created since 2026-10-26 (#16907-#16932) touches lucene/benchmark/.../quality/. 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 minimal test through gradle in a scratch worktree, assertions on and off, stock (fails) and with the fix (passes), plus the quality-package tests and:lucene:benchmark:checkwith the fix (19 test classes, 91 tests, 0 failures); a plainmain()probe on the main jars, stock and with the fixed class (-ea,-daand the legacy merge sort); the 10.6.0 release jars through the standalone runner (the minimal test-eaand-da, the finder's 115-topic test-da). The hunt, the verifiers and the adjudication used maina9779ef3c193, wherelucene/benchmarkis identical; the history runs covered 17 targets from 2.3.0 to main. Upstream main2f424fd428da,branch_11xd0c0decc6606andbranch_10_691eaab4fb298(2026-10-30) have the samecompareTologic (branch_10_6still has the older catch clause).
- Finding record
lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityQuery.java:34 (public class QualityQuery implements Comparable<QualityQuery>), 68-81 (compareTo: comment 'for a nicer sort ... Try first as ints, fall back to string if not int', line 76 return n - nOther, line 79 String fallback); lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecTopicsReader.java:33 ('Number: nnn'), 106-108 ('// sort result array (by ID)', Arrays.sort(qq)); lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/Trec1MQReader.java:32 ('qnum:qtext'), 84-86; lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:79 (readQueries on the topics file) [read at the registration check]GitHub, read-only, registration check 2026-10-28: search/issues 'repo:apache/lucene QualityQuery' (1: #1911), 'repo:apache/lucene TrecTopicsReader' (2: #6095, #3286), 'repo:apache/lucene Trec1MQReader' (0), 'repo:apache/lucene "general contract" compareTo' (0), 'repo:apache/lucene benchmark quality updated:>=2026-10-26' (1: #16794, unrelated); pulls created since 2026-10-26 (#16907-#16932) with file lists for #16907, #16913-#16917, #16923, #16929-#16932 (none under lucene/benchmark/.../quality/); commits by path for the three files (latest dbf0d1426f13, author date 2026-05-14); upstream main head 645d4fe14a20
- The finding in 30 seconds
lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityQuery.java:34, 44-47, 68-81lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecTopicsReader.java:71-73, 100, 106-108lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/Trec1MQReader.java:70-71, 78, 84-86lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:40-41, 77-79JDK 25 sources: java/lang/Comparable.java (compareTo contract), java/util/Arrays.java (sort(Object[]) javadoc and LegacyMergeSort), java/util/ComparableTimSort.java (MIN_MERGE = 32, mini-TimSort with no merges, the exception in mergeLo and mergeHi)
- Who is affected
lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:40-41, 59-63, 67-68, 77-82lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityBenchmark.java:96-115, 154-157lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityStats.java:195-218 (average sums in array order)lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecJudge.java:91lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/package-info.java:75-77
- Why it looks unintended
JDK 25 sources: java/lang/Comparable.java, java/util/Arrays.java (sort(Object[]) @throws)lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityQuery.java:34, 44, 68-69lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecTopicsReader.java:33, 106lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/Trec1MQReader.java:32, 84lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/TestQualityRun.java:50-157 (testTrecQuality: readQueries :62-67, per-position checks :94-140), 159-186 (testTrecTopicsReader) and trecTopics.txt (ids 0..19)
- Suggested fix
lucene/benchmark/src/test/org/apache/lucene/benchmark/quality/TestQualityRun.java:94-140, 159-186 and trecTopics.txt (ids 0..19; option D)
- TestQualityQueryCompareTo.java (lucene/benchmark, one test):
repro/LFV-936/src/org/apache/lucene/benchmark/quality/TestQualityQueryCompareTo.java(36 lines)
Made in parallel with this page, from the code at main 645d4fe14a20 and the executed repro.
assets/LFV-936/bug.png(579 KB)
