All findings · Lucene finding · LFV-936 · qualityquery-compareto-not-a-total-order

QualityQuery.compareTo goes in a circle when numeric and non-numeric topic ids are mixed, so the TREC topic readers can throw

A TREC topics file whose ids mix plain numbers with sub-topic ids like 1a can make TrecTopicsReader throw "Comparison method violates its general contract!". QualityQuery.compareTo compares two whole-number ids as numbers but other pairs as text, so 2 < 100 < 1a < 2 is a circle.

Summary

Priority
Low, rank 664The 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
crash
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
semi-formal: an exhaustive Comparable-law check, then a search for topic files that make the real reader throw

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

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.

Background: the benchmark module's search-quality tools

  1. lucene/benchmark ships tools that measure search quality on TREC-style data: a topics file (the queries), a judgements file (which documents are relevant) and an index.
  2. TrecTopicsReader (or Trec1MQReader, for one id:text line per topic) turns each topic into a QualityQuery with a String id and sorts them by id. QueryDriver is the command-line tool that runs a whole evaluation this way.
  3. The sort uses QualityQuery.compareTo. Java's sort requires a consistent order: if a < b and b < c, then a < c.
Diagram of QualityQuery.compareTo on main 645d4fe14a20: ids 2 and 100 are both ints and compare as numbers (2 < 100); 100 and 1a, and 1a and 2, fall back to String order (100 < 1a, 1a < 2), so the order is the cycle 2 < 100 < 1a < 2; TrecTopicsReader.readQueries sorts a 115-topic file (ids 1..111 plus 1a, 20a, 64a, 99a) with Arrays.sort and throws IllegalArgumentException 'Comparison method violates its general contract!'; the fix compares two ints with Integer.compare, puts ints before all other ids and orders the rest as Strings.
The repro in pictures: how 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.

The finding, step by step

  1. Load a TREC topics file with TrecTopicsReader.readQueries, as the QueryDriver tool does: topics 1 to 111 in order, plus sub-topics 1a, 20a, 64a and 99a, each right after its parent (115 topics).
  2. You expect the 115 topics back sorted by id; the reader's code says // sort result array (by ID).
  3. You get IllegalArgumentException: Comparison method violates its general contract! from Arrays.sort, and the evaluation stops before any query runs. The same file without the four sub-topics (111 topics) sorts fine.
  4. 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.
  5. 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.

Who is affected

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.

The code (main 645d4fe14a20)

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.

Why it is circular

  • 2 vs 100: both ints, 2 - 100 = -98, so 2 < 100.
  • 100 vs 1a: 1a is not an int, so Strings: '0' (48) < 'a' (97), -49, so 100 < 1a.
  • 1a vs 2: 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).

Why it throws only sometimes

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.

When it does not throw

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.

Side issue: overflow

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: the QueryDriver command-line tool ("Command-line tool for doing a TREC evaluation run") and code that calls TrecTopicsReader.readQueries or Trec1MQReader.readQueries, or sorts QualityQuery objects 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 plus 3000000015 and 3000000016, each right after 15 and 16, 35 topics, throw through Trec1MQReader). 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 IllegalArgumentException from readQueries; QueryDriver stops 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 topics setMaxQueries(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.

Minimal repro

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.

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

import 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);
  }
}
Command
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.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 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)
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

Observed output

# 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]

Why it looks unintended

  • QualityQuery implements Comparable, which requires one consistent order for all ids; Arrays.sort throws 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. Comparable requires signum(x.compareTo(y)) == -signum(y.compareTo(x)) and transitivity for all x, y, z; Arrays.sort documents the IllegalArgumentException for 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, getQueryID and the package docs put no limit on ids, and the readers keep the id text as written.
  • Tests and history. Both TestQualityRun tests read trecTopics.txt (20 topics, ids 0..19, in order) with TrecTopicsReader: testTrecTopicsReader checks qqs[0], qqs[1] and qqs[19], and testTrecQuality'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 to compareTo was cosmetic. The code predates JDK 7, whose TimSort turned the inconsistency into an exception.
  • Precedent. Lucene fixed broken compareTo methods 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.

Suggested fix

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, 1a sorts 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 NumberFormatException per comparison (it stops at the first parseInt that 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 645d4fe14a20 with the diff applied: the minimal test passes with -Ptests.asserts=true and false; --tests 'org.apache.lucene.benchmark.quality.*' passes (TestQualityQueryCompareTo, and TestQualityRun's testTrecQuality and testTrecTopicsReader); :lucene:benchmark:check passes (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; TestQualityRun stays green also with -Dtests.verbose=true (that run had three other hunt fixes applied too).

Order every digit-only id by value, of any length (not built)

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.

A natural order that compares digit runs as numbers (not built)

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.

Plain String order (not built)

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).

Long.parseLong instead of Integer.parseInt (not a fix on its own)

Widens the numeric range.

Trade-offs

Keeps the 2 < 100 < 1a < 2 circle.

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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!".

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), commits, downstream repos, mailing lists, JIRA archive, web2026-10-28three 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 readno report of this defect
Open and recent PRs (apache/lucene)2026-10-28file lists of all 413 open PRs and the 200 most recently updated closed ones; upstream commits on the three filesnone touches QualityQuery or the readers' sort; last change dbf0d1426f13 (#16056, unrelated)
GitHub issues and PRs (apache/lucene), refresh2026-10-30QualityQuery, 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-28the 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)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
2.2.0 API absentno org.apache.lucene.benchmark.quality package (compile error)
2.3.0 affectedall 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 affectedthe same 6 failures; control passes
10.6.0 affectedthe minimal test fails (-ea, -da); the 115-topic test throws at TrecTopicsReader.readQueries:108 (-ea, -da)
main a9779ef3c193, 645d4fe14a20 affectedthe 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 exceptionthe law checks still fail; the readers return every topic in an inconsistent order
main with the fix not affectedall checks and the minimal test pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (semi-formal) candidateExhaustive 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, highOwn 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 realOwn 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, highOwn test fails 3 of 4, passes with the fix; "works as designed" rated weak.
Devil's advocate real, impact lowBuilt 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, highRe-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 keepHistory 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 realGradle 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, fixedRe-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 correctedA 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 passEvery 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.

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

Finding record

LFV-936, snapshot of 2026-11-06

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:check with the fix (19 test classes, 91 tests, 0 failures); a plain main() probe on the main jars, stock and with the fixed class (-ea, -da and the legacy merge sort); the 10.6.0 release jars through the standalone runner (the minimal test -ea and -da, the finder's 115-topic test -da). The hunt, the verifiers and the adjudication used main a9779ef3c193, where lucene/benchmark is identical; the history runs covered 17 targets from 2.3.0 to main. Upstream main 2f424fd428da, branch_11x d0c0decc6606 and branch_10_6 91eaab4fb298 (2026-10-30) have the same compareTo logic (branch_10_6 still has the older catch clause).

Lucene and upstream sources

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-81
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecTopicsReader.java:71-73, 100, 106-108
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/Trec1MQReader.java:70-71, 78, 84-86
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/QueryDriver.java:40-41, 77-79
  • JDK 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-82
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/QualityBenchmark.java:96-115, 154-157
  • lucene/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:91
  • lucene/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-69
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/TrecTopicsReader.java:33, 106
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/quality/trec/Trec1MQReader.java:32, 84
  • lucene/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)

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

Repro files

About the diagrams

Made in parallel with this page, from the code at main 645d4fe14a20 and the executed repro.

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.