All findings · Lucene finding · LFV-145 · indexsorter-float-merge-signed-nan-order

Index-sort merges misplace a NaN whose sign bit is set, so FLOAT/DOUBLE-sorted segments break their own sort

Merges of a FLOAT or DOUBLE index sort rank a NaN whose sign bit is set (from -x for a NaN x) below -Infinity; flush puts it after +Infinity. Segments {3, 1, NaN} and {2, 0.5} merge to [2.0, 0.5, NaN, 3.0, 1.0], and sorted searches return wrong top hits.

Summary

Priority
Medium, rank 74Since 9.11 every merge misplaces a sign-bit NaN in an opt-in FLOAT/DOUBLE index sort, which leaves a persistently misordered segment (CheckIndex level 2 fails, early-terminated sorted searches return wrong top hits, a reindex is needed), and that persistence puts it above the count-only LFV-086, but it needs a run-time negated or binary NaN (possibly ordinary computed NaNs on x86, not executed).
Impact
data corruption
Since
9.11.0 (#13320, 2024)introduced 2024-05-14 · about 2 years
Affected
9.11.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
code reading while setting up a bounded-exhaustive check of index-sort early termination

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

Lucene can keep an index physically sorted by a float field so that searches sorted the same way stop early. Flush sorts with Float.compare, which puts every NaN after +Infinity. A merge compares the raw stored bits instead, and for a NaN whose sign bit is set those bits rank below -Infinity, so the merged segment follows neither order.

  1. Sort the index by a float field, descending: setIndexSort(new Sort(new SortField("f", FLOAT, true))). Flush segment 1 with 3.0, 1.0 and a NaN whose sign bit is set (raw bits 0xffc00000, what -x gives for a NaN x), and segment 2 with 2.0 and 0.5.
  2. Flush sorts with Float.compare, which puts every NaN after +Infinity, so segment 1 is [NaN, 3.0, 1.0]. After forceMerge(1) you expect [NaN, 3.0, 2.0, 1.0, 0.5].
  3. You get [2.0, 0.5, NaN, 3.0, 1.0]. The merge compares NumericUtils.sortableFloatBits of the raw stored bits: the NaN's key, -2,143,289,345, is below the key of -Infinity, -2,139,095,041. So the NaN, and the 3.0 and 1.0 behind it, wait until segment 2 is used up.
  4. Searches sorted by the index sort stop early because they trust the order: the top hit of a query that excludes the NaN document is 2.0 instead of 3.0. CheckIndex at level 2 or higher reports docID=1 sorts after docID=2; level 1, the command-line default, reports the index clean.
  5. The damage stays: later merges keep it, and a code fix does not repair merged segments. The canonical Float.NaN merges correctly, and 9.10.0 merges the same index correctly.

Who is affected

Applications that opt into an index sort on a FLOAT or DOUBLE SortField and index a NaN whose sign bit is set: run-time negation of a NaN (for example storing -value to reverse an order), Math.copySign, or raw binary floats. Text parsing canonicalizes NaN. One document suffices, a merge can misplace it, and the damage persists.

Mechanism, terms and numbers708 words

With IndexWriterConfig#setIndexSort on a SortField of type FLOAT or DOUBLE (values from FloatDocValuesField / DoubleDocValuesField), flush sorts documents with Float.compare / Double.compare, which put every NaN after +Infinity. Merges order documents by the keys of IndexSorter.FloatSorter/DoubleSorter.getComparableProviders, which since #13320 (9.11.0) are NumericUtils.sortableFloatBits / sortableDoubleBits of the raw stored bits. The doc-values fields store raw bits, so a NaN keeps its sign bit, and a NaN with the sign bit set gets a key below -Infinity. The merge interleaves already-sorted segments by a key that disagrees with their order, so the merged segment can end up in neither order: descending segments {3, 1, NaN} and {2, 0.5} merge to [2.0, 0.5, NaN, 3.0, 1.0]. CheckIndex's index-sort test (level 2 or higher) rejects the segment, and searches sorted by the index sort that terminate early return wrong top hits, also for queries that do not match the NaN document. The same for DOUBLE. Every release from 9.11.0 through 10.6.0 (10.6.0 by reading) and main; restoring the NaN canonicalization that #13320 removed fixes it.

Terms

  • Index sort: IndexWriterConfig#setIndexSort keeps every segment physically sorted, "for all (flushed and merged) segments" (IndexWriterConfig.java:504). A search sorted by the index sort, or by a prefix of it, stops collecting a segment at the first document that cannot compete (TopFieldCollector.java:56-71, :91-112, :151-170).
  • Sign-bit NaN: a NaN whose sign bit is set, raw bits 0xffc00000 (float) or 0xfff8000000000000 (double). Float.compare treats it like every other NaN: greater than +Infinity. Run-time negation of a NaN, Math.copySign(Float.NaN, -1f) and binary decoding produce it; text parsing does not.

The mechanism (main ea57360912ed)

  1. FloatDocValuesField stores Float.floatToRawIntBits(value) (FloatDocValuesField.java:21, :40); DoubleDocValuesField stores doubleToRawLongBits (:21, :39). The sign bit survives.
  2. Flush: IndexSorter.FloatSorter.getDocComparator uses Float.compare (IndexSorter.java:385; Double.compare at :449). The search comparator and CheckIndex.testSort use the same order.
  3. Merge: MultiSorter.sort calls getComparableValues (MultiSorter.java:84), whose default adapts getComparableProviders (IndexSorter.java:147-150). FloatSorter.getComparableProviders returns NumericUtils.sortableFloatBits(valueBits) of the raw bits (:364; double sortableDoubleBits at :428).
  4. sortableFloatBits(bits) is bits ^ (bits >> 31) & 0x7fffffff, with no NaN handling (NumericUtils.java:88-91). For 0xffc00000 it gives 0x803fffff (-2,143,289,345), below -Infinity's 0x807fffff (-2,139,095,041). A positive NaN (any payload) stays above +Infinity, so only sign-bit NaNs are misplaced; positive NaNs with different payloads get different keys, which Float.compare treats as equal, so that breaks no order. floatToSortableInt, the key before #13320, canonicalizes through floatToIntBits first (:70-72).
  5. MultiSorter is a k-way merge whose inputs "must already be sorted by the same sort!" (MultiSorter.java:35-39); it only interleaves, taking each input's documents in their existing order (:118-136). Segment 1 arrives as [NaN, 3.0, 1.0], but the merge sees its head as the smallest key, so in a descending merge it takes 2.0 and 0.5 first: [2.0, 0.5, NaN, 3.0, 1.0]. Ascending, {1, NaN} + {0.5, 2, 3} give [0.5, 1.0, NaN, 2.0, 3.0].
  6. Early termination trusts the order: for top 1 of a query without the NaN document, doc 0 (2.0) fills the queue, doc 1 (0.5) cannot compete, and 3.0 is never looked at. Here the hit count stays exact (4 hits); above the searcher's default 1,000-hit threshold, an early-terminated search reports a lower bound, with or without this bug.
  7. CheckIndex.testSort ("This segment claims its documents are sorted according to the incoming sort ... let's make sure", CheckIndex.java:1219-1222) rejects the segment, but only at level 2 or higher (:1114, :1148). Level 1, the command-line default (:4673, :4748), reports the index clean, and a CheckIndex created in code without setLevel runs at level 0 (:499), which skips the test too.

The repro's numbers

expected (Float.compare)merged segment on main
merged order, descending[NaN, 3.0, 2.0, 1.0, 0.5][2.0, 0.5, NaN, 3.0, 1.0]
index-sort check (CheckIndex level 2+)no errorsegment has indexSort=<float: "f">! but docID=1 sorts after docID=2
top 1 by the index sort, NaN document excluded3.02.0
top 1 by the index sort, all documentsNaN2.0 (TotalHits 5, exact)
DOUBLE, merged order[NaN, 3.0, 2.0, 1.0, 0.5][2.0, 0.5, NaN, 3.0, 1.0]
control: canonical Float.NaN[NaN, 3.0, 2.0, 1.0, 0.5][NaN, 3.0, 2.0, 1.0, 0.5]
Exposure and likelihood560 words
  • Who is exposed. Applications with an index sort on SortField.Type.FLOAT or DOUBLE (values from FloatDocValuesField / DoubleDocValuesField) that index a NaN with the sign bit set. Index sorting is opt-in; codec, merge policy and searcher are the defaults otherwise. A merge that involves the document (forceMerge or an ordinary background merge) can misorder the merged segment, depending on the segments' contents.
  • How a sign-bit NaN arises (executed on JDK 25, aarch64): run-time negation -x of a NaN, in the interpreter (-Xint), with C1 and C2 (-Xcomp) and after warm-up (0xffc00000 / 0xfff8000000000000), for example an application that indexes -value to reverse an order; Math.copySign(Float.NaN, -1f); binary decoding that keeps bits, such as ByteBuffer.getFloat. Not a trigger: Float.parseFloat("-NaN") and Double.parseDouble("-NaN") (canonical NaN), the compile-time constant -Float.NaN (folded to 0x7fc00000), and on aarch64 0f/0f, inf - inf, sqrt(-1) and NaN * -1f.
  • x86 (not executed). Intel documents that the default NaN of SSE arithmetic ("QNaN floating-point indefinite") has the sign bit set, so computed NaNs there may trigger it. No x86 host was used.
  • Not affected. SortedNumericSortField over FloatField / DoubleField, which store floatToSortableInt / doubleToSortableLong and so canonicalize NaN at index time; INT, LONG, STRING and SORTED_SET sorts; the canonical NaN.
  • Scale. 5 documents in 2 segments; deterministic (this doc's test failed in all 10 iterations). A bounded-exhaustive check of two flushed segments of 1-2 documents over {missing, sign-bit NaN, NaN, -Inf, -1, -0.0, 0.0, 1, +Inf} found 13,652 FLOAT and 13,620 DOUBLE order violations in 48,600 merges each and 4,976 wrong top-1 hits each; 0 without the sign-bit NaN and 0 with the fix. So 13,652 of the 17,496 FLOAT merges that contain a sign-bit NaN break the order, not all of them (a rate over the enumeration, not over real data). With three 400-document segments and one sign-bit NaN, all 20 early-terminated searches tried (4 queries, k from 1 to 200) differed from the full sort in a descending sort; in an ascending sort all 20 matched, although CheckIndex still rejected the merged segment.
  • What you see. No error at merge or search time. Searches sorted by the index sort (or a prefix of it) can return wrong top hits, also for queries that do not match the NaN document; hit counts are not made wrong. CheckIndex at level 2 or higher condemns the merged segment; level 1, the command-line default, reports it clean. "Corrupt" here means a segment that breaks its declared sort: document contents, deletions and blocks are intact.
  • It persists. A second forceMerge(1) with 500 more documents still fails CheckIndex level 2 (executed, stock code). A merge keeps each input's documents in their existing order, so two documents out of order inside a merged segment stay out of order in every later merge, with or without the fix, unless one is deleted (code reading; not executed with the fix). A code fix stops new damage only: segments already merged by 9.11.0-10.6.0 (10.6.0 by reading) need a reindex or a re-sort. CheckIndex -exorcise would drop the whole segment (1,500 of 1,500 documents in a probe).
  • Downstream (read, not tested): Elasticsearch and OpenSearch reject non-finite float/double values at parse time and Elasticsearch index sorts use SortedNumericSortField; Solr's point fields write doc values through floatToIntBits / doubleToLongBits. No affected path was found there.

Minimal repro

The test shows that merging segments of an index sorted by a descending FLOAT field misplaces a NaN whose sign bit is set (0xffc00000), so the merged segment violates its declared index sort.

TestIndexSortMergeSignedNaN.java (JUnit test)62 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.index;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.FloatDocValuesField;
import org.apache.lucene.search.DocIdSetIterator;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestIndexSortMergeSignedNaN extends LuceneTestCase {

  public void testMergedSegmentFollowsIndexSort() throws IOException {
    float signedNaN = Float.intBitsToFloat(0xffc00000);
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setCodec(TestUtil.getDefaultCodec())
            .setIndexSort(new Sort(new SortField("f", SortField.Type.FLOAT, true)));
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (float[] segment : new float[][] {{3f, 1f, signedNaN}, {2f, 0.5f}}) {
        for (float v : segment) {
          Document doc = new Document();
          doc.add(new FloatDocValuesField("f", v));
          w.addDocument(doc);
        }
        w.flush();
      }
      w.forceMerge(1);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        NumericDocValues dv = getOnlyLeafReader(reader).getNumericDocValues("f");
        List<Float> order = new ArrayList<>();
        while (dv.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) {
          order.add(Float.intBitsToFloat((int) dv.longValue()));
        }
        // expected descending Float.compare order [NaN, 3, 2, 1, 0.5]; actual [2, 0.5, NaN, 3, 1]
        assertEquals(List.of(Float.NaN, 3f, 2f, 1f, 0.5f), order);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-145/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestIndexSortMergeSignedNaN
Expected vs actual
Expected the merged order [NaN, 3.0, 2.0, 1.0, 0.5] (Float.compare, as at flush); actual [2.0, 0.5, NaN, 3.0, 1.0], with -ea and -da.

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

More files, environment, full output42 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[NaN, 3.0, 2.0, 1.0, 0.5]> but was:<[2.0, 0.5, NaN, 3.0, 1.0]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.index.TestIndexSortMergeSignedNaN.testMergedSegmentFollowsIndexSort(TestIndexSortMergeSignedNaN.java:58)

Why it looks unintended

  • setIndexSort covers "all (flushed and merged) segments", and flush, search and CheckIndex all order by Float.compare, which puts every NaN after +Infinity.
  • LUCENE-3582 (2011) fixed the same kind of NaN defect in NumericUtils; its sign-bit-NaN tests still pass. #13320 bypassed that helper without discussing NaN.
  • 9.10.0 merges the same index correctly.
The full argument361 words
  • Contracts. IndexWriterConfig#setIndexSort: "Set the Sort order to use for all (flushed and merged) segments." (IndexWriterConfig.java:504). IndexSorter.ComparableProvider#getAsComparableLong: "Returns a long so that the natural ordering of long values matches the ordering of doc IDs for the given comparator" (IndexSorter.java:57-62). MultiSorter.sort: "The documents for each incoming leaf reader must already be sorted by the same sort!" (MultiSorter.java:35-39). NumericUtils.floatToSortableInt, the merge key before #13320: "The sort order (including Float#NaN) is defined by Float#compareTo; NaN is greater than positive infinity." (NumericUtils.java:61-66; double at :37-43). CheckIndex.testSort, Lucene's own integrity check, rejects the merged segment.
  • Lucene decided this order. LUCENE-3582 (#4656, 2011) fixed the same kind of defect in NumericUtils: raw bits made sign-bit NaNs sort before -Infinity; the fix canonicalized through floatToIntBits / doubleToLongBits and added TestNumericUtils cases with sign-bit NaNs (0xff800001, 0xffffffff, 0xfff0000000000001L, 0xffffffffffffffffL) that must sort above +Infinity; they are still on main (TestNumericUtils.java:280-302, :343-365). LUCENE-4937 (#6001, 2013) moved the comparators to Float.compare / Double.compare ("Fix incorrect sorting of float/double values (+/-0, NaN)").
  • History. #13320 "Remove unnecessary bit conversion for IndexSorter" (by @bugmakerrrrrr, merged by @jpountz on 2024-05-14) (its title presents the conversion as unnecessary) replaced floatToSortableInt(Float.intBitsToFloat(bits)) with sortableFloatBits(bits) and the double equivalent; the two differ exactly for non-canonical NaNs. The PR has the empty description template, no reviews, no test change and no CHANGES entry; nobody discussed NaN.
  • Upstream tests. No test indexes a NaN as an index-sort value; TestIndexSorting draws floats and doubles in [0, 1) (TestIndexSorting.java:2481-2557). With the fix the whole core suite passes, so no test pins the current behaviour.
  • Trigger legality. Public API only. FloatDocValuesField / DoubleDocValuesField accept any value and document that they store raw bits; neither setIndexSort nor SortField restricts NaN. Where Lucene does not support NaN, it says so or rejects it: scores are documented as invalid and asserted, and FloatRange / DoubleRange and XYDocValuesField throw.
  • Precedent. LUCENE-8592 (#9638, 2018): the merge order differed from the index sort for MIN_VALUE with a reverse sort; upstream treated it as index corruption, with a CHANGES note that CheckIndex detects affected indices.
The devil's-advocate check319 words

The strongest case for "intended or unsupported" (built by the devil's advocate and by a reviewer from another model family, OpenAI GPT-5.6, with a works-as-designed lens): IEEE 754 NaN is unordered and Java says NaN has no sign, so the order of exotic NaN bit patterns is unspecified; SortField.Type.FLOAT only says "lower values are at the front"; the doc-values fields store raw bits on purpose, so ordering raw bits is consistent; a committer merged #13320; in the LUCENE-3582 thread one participant said Lucene does not really support NaN as a value; and the recorded searches found no public report in the more than two years since 9.11.0 (tagged 2024-06-06).

Why it fails: Lucene does specify the order (flush, search and CheckIndex all use Float.compare; the floatToSortableInt javadoc puts NaN after +Infinity; LUCENE-4937 made it a bug fix), and LUCENE-3582's tests cover sign-bit NaNs specifically; the LUCENE-3582 fix was committed despite that remark. The merge contract is to match the comparator, not the bits, and the repro's merged output follows no order at all, misordering ordinary values too. #13320's title presents the conversion as unnecessary, and the PR never considers NaN. A plain -x produces the value. What still weighs on the other side: no javadoc says in so many words that NaN is a supported index-sort value, and a maintainer could say that applications should sanitize raw binary floats (the doc's cross-model fact check, OpenAI GPT-5.6, rates that case moderate, "NaN is not a supported sort value" weak as an account of today's code but moderate as a future policy, and the -x case weak); neither explains why flush, search and CheckIndex use one order and the merge another. The likely maintainer reply is "real but rare, low priority, restore the canonicalization", not "by design"; no source supports by design. The GPT-5.6 reviewer rated the intended case weak; the devil's advocate found no argument that outweighs the contracts.

Suggested fix

Recommended Canonicalize NaN in the merge keys (2 lines)

Canonicalize NaN before encoding the merge key, as before 9.11.0: NumericUtils.floatToSortableInt(Float.intBitsToFloat(valueBits)) and doubleToSortableLong(Double.longBitsToDouble(valueBits)), two lines in IndexSorter.java. The repro passes, and so do all 8,870 core tests (assertions on and off) and :lucene:core:check. Segments merged before the fix still need a reindex.

-              return NumericUtils.sortableFloatBits(valueBits);
+              // canonicalize NaN like Float.compare (used at flush time) does
+              return NumericUtils.floatToSortableInt(Float.intBitsToFloat(valueBits));
...
-              return NumericUtils.sortableDoubleBits(valueBits);
+              // canonicalize NaN like Double.compare (used at flush time) does
+              return NumericUtils.doubleToSortableLong(Double.longBitsToDouble(valueBits));
All options and trade-offs4 options ยท 502 words

Option A was validated in a scratch worktree at ea57360912ed (removed afterwards); B and C were not run. Until a fix ships, users can canonicalize NaN before indexing (the workaround; the test's canonical-NaN control shows it merges correctly).

Canonicalize NaN in the merge keys (2 lines) (validated by the checks) (recommended)

In FloatSorter / DoubleSorter.getComparableProviders, encode through floatToSortableInt / doubleToSortableLong, which go through floatToIntBits / doubleToLongBits and so map every NaN to the canonical NaN above +Infinity. This restores the 9.10.0 merge keys.

-              return NumericUtils.sortableFloatBits(valueBits);
+              // canonicalize NaN like Float.compare (used at flush time) does
+              return NumericUtils.floatToSortableInt(Float.intBitsToFloat(valueBits));
...
-              return NumericUtils.sortableDoubleBits(valueBits);
+              // canonicalize NaN like Double.compare (used at flush time) does
+              return NumericUtils.doubleToSortableLong(Double.longBitsToDouble(valueBits));

Trade-offs

One NaN canonicalization per merge key (the intBitsToFloat round trip is a reinterpretation). No benchmark was run. It changes only the key used while merging: stored values keep their raw bits, as the doc-values fields document. It prevents new damage only: segments already merged by 9.11.0-10.6.0 (10.6.0 by reading) stay misordered and need a reindex or a re-sort; a CHANGES entry should say so, and that CheckIndex at level 2 or higher finds them.

What the checks validated

  • Through gradle at ea57360912ed: this doc's test fails 4 of 5 on stock (assertions on and off) and passes 5 of 5 with the fix (on and off).
  • With the fix: TestIndexSorting (62 tests), TestSortingCodecReader (3), TestNumericUtils (24), TestCheckIndex (9), the TestSort* and TestTopFieldCollector* classes and this test: 15 classes, 241 tests, 0 failures; the whole :lucene:core:test: 8,870 tests, 0 failures, with assertions on (322 skipped) and off (325 skipped); :lucene:core:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) successful. The suite runs used the test before a formatting-only edit; the repro and check runs used the final file.
  • Earlier steps: the finder's repro, the independent reproducer's 9 tests, the reviewers' tests, the security probe and the bounded-exhaustive check (0 violations in 48,600 merges each for FLOAT and DOUBLE) pass with the same patch as a shadow class.

Canonicalize at index time in the doc-values fields

Store floatToIntBits / doubleToLongBits in FloatDocValuesField / DoubleDocValuesField.

Trade-offs

Changes their documented raw-bits storage for every user, does nothing for values written by older versions or by other producers of NUMERIC doc values, and leaves the merge key inconsistent with its contract. Not recommended on its own. Not run.

Sort flushed segments by the merge key instead

Make getDocComparator order by sortableFloatBits of the raw bits.

Trade-offs

Flush and merge would agree, but the search comparator and CheckIndex.testSort use Float.compare, so early termination would still return wrong hits. Not viable. Not run.

Workaround until a fix ships (validated by the checks)

Canonicalize before indexing: if (Float.isNaN(v)) v = Float.NaN; (and Double.NaN for doubles). Reindex indexes that already hold sign-bit NaNs.

Trade-offs

Needs a change in every application that can produce such values; does not repair merged segments.

What the checks validated

The test's canonical-NaN control merges correctly on stock main (5 runs: it passes every time).

Downstream note

Downstream: the trigger is one document value, often client-supplied, and the damage is persistent and cross-user: wrong top hits for every user of a shared index, and a whole segment that CheckIndex (level 2+) condemns. Reach is narrow: an opt-in FLOAT/DOUBLE index sort and a sign-bit NaN, which text parsing cannot produce. No over-matching or data exposure.

How it was found

The finder, an AI tool, compared the flush-time and merge-time orderings of every index sorter while setting up a bounded-exhaustive check of index-sort early termination, and saw that the FLOAT/DOUBLE merge keys differ only for sign-bit NaNs. A JUnit, CheckIndex and a release bisection confirmed it; the exhaustive check then found no other value that breaks the order.

The method in full233 words

Method: code reading (a hunt unit, sorted search and index-sort early termination, 2026-10-26), while setting up a bounded-exhaustive check of the contract that early termination presumes: every segment really is in index-sort order.

  • The finder compared, for every IndexSorter that SortField uses, the flush-time getDocComparator with the merge-time getComparableProviders, and saw that the FLOAT/DOUBLE merge keys (sortableFloatBits / sortableDoubleBits of raw bits) differ from Float.compare / Double.compare only for NaNs with the sign bit set.
  • It confirmed this with a public-API JUnit on the stock main jars, CheckIndex at level 3 and a release bisection (9.10.0 passes, 9.11.0 fails).
  • A bounded-exhaustive merge-order check written afterwards (two flushed segments of 1-2 documents over {missing, sign-bit NaN, NaN, -Inf, -1, -0.0, 0.0, 1, +Inf}, every FLOAT/DOUBLE SortField variant, order checked with the search comparator as CheckIndex does) showed that the sign-bit NaN is the only value in that domain that breaks the order; INT, LONG, sorted-numeric FLOAT/DOUBLE, STRING and SORTED_SET gave 0 violations.
  • Then: a refute review by an independent reviewer from another model family (OpenAI GPT-5.6, with its own executed test), an independent reproducer, a GPT-5.6 "works as designed" review with local re-runs, a devil's advocate with a reachability probe, an adjudication with fresh-seed re-runs, a per-release history sweep, a prior-report search, a security assessment and probe, and this doc's own test and fix validation. No model checker or prover was involved.

Prior reports, history and checks

Prior reports

No prior report found: 307 read-only queries on 2026-10-26 over GitHub, JIRA, the mailing lists, downstream projects and the web, plus a content scan of 139 items and the changed files of all 413 open PRs. Closest: LUCENE-3582 (2011), which fixed the same kind of NaN defect in NumericUtils, and #16809, the same symptoms from a different cause.

Searches6 searches

Every hit about index sorting, merge order, NaN or float sorting was read; none is this bug. Precedents of the same class, all fixed long before: LUCENE-3582, LUCENE-4937, LUCENE-8592, LUCENE-10210. Same symptoms, different cause: #16809 / #16810 (open; doc-values updates on the sort field; would not fix this), #16887, #14399. Would rely on the merged order, by code reading: #16699 / #16698 (open; FLOAT/DOUBLE support in IndexSortSortedNumericDocValuesRangeQuery). A sibling hunt finding, LFV-144 (search-time skip-index bounds over raw float bits), is in the same family on a different code path. Limits: a search cannot prove absence, and private security@ reports are invisible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2628 queries of a scripted prior-report search and 72 GraphQL phrasings (class and method names, NaN, sign bit, "sorts after docID", early termination, "Remove unnecessary bit conversion"), also in elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr; a content scan of the body, comments and review comments of all 139 items created since 2024-05-01no report; #13320 (the introducing PR, no NaN discussion); #16809 / #16810 (same symptoms, different cause); #16699 / #16698 (would rely on the merged order)
Open PRs2026-10-26changed files of all 413 open apache/lucene PRsnone touches IndexSorter.java or NumericUtils.java; #16571, #16851 and #16847 touch MultiSorter.java but not the merge keys or NaN handling
JIRA (LUCENE)2026-10-2624 archive queries; LUCENE-3582, LUCENE-4937, LUCENE-8592, LUCENE-10210, LUCENE-7569, LUCENE-7574 and LUCENE-7945 readonly precedents and unrelated failures; the regression postdates the move to GitHub
Mailing lists (dev@, java-user@, issues@, commits@, builds@)2026-10-26158 queriesonly automatic notifications of #13320; no report
Commits, downstream and web2026-10-264 commit and 4 downstream-repo queries, a scan of the local full history; 17 web queriesno fix commit on any branch; no downstream report; nothing relevant on the web
Earlier runs2026-10-26the finder's prior-art run (51 queries) and the devil's advocate's (27); direct GitHub searches by verifiersnothing

History

Introduced in 9.11.0 by #13320 (merged 2024-05-14), a refactoring that dropped the NaN canonicalization from the merge keys. The same test fails on all 18 releases 9.11.0-10.5.2 and on main and passes on 9.1.0-9.10.0; upstream branches still carry the lines.

Versions tested8 rows

Introduced by #13320 "Remove unnecessary bit conversion for IndexSorter" (author @bugmakerrrrrr, merged by @jpountz on 2024-05-14): 37837133c357 on main, backport b1470b945f91 on branch_9x; first released in 9.11.0 (2024-06-06), first 10.x release 10.0.0. It replaced floatToSortableInt(Float.intBitsToFloat(bits)), which canonicalizes NaN through floatToIntBits, with sortableFloatBits(bits), which does not; the same for doubles. The old conversion came from LUCENE-9330 (2020).

From 10.5.0, MultiSorter calls getComparableValues (#16202), whose default adapts getComparableProviders; FloatSorter and DoubleSorter do not override it, so merges still use the changed lines. Upstream main, branch_10x, branch_10_6, branch_10_5 and branch_9_12 carry them (read on 2026-10-26), so 10.6.0 (released 2026-10-29) is affected. Releases before 8.0.0 were checked by reading only; x86 was not run.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis doc's test fails 4 of 5 through gradle and the runner, assertions on and off; the finder's, the reproducer's and the reviewers' tests fail too
upstream main, branch_10x, branch_10_6, branch_10_5, branch_9_12 readsame lines on 2026-10-26; no CHANGES entry
10.0.0 - 10.5.2 (12 releases) affectedthe finder's JUnit fails 4 of 4 on each; this doc's test fails on 10.5.2 with -ea and -da
9.11.0 - 9.12.3 (6 releases) first affected 9.11.0fail on each; this doc's test fails on 9.12.3 and 9.11.0
9.1.0 - 9.10.0 (14 releases) last unaffected 9.10.0the finder's JUnit passes 4 of 4 on each (9.10.0 also with -da); this doc's test passes 5 of 5 on 9.10.0
8.0.0, 8.6.0, 8.11.4, 9.0.0 not affecteda core-only probe passes and prints the correct merged order [0xffc00000, 3.0, 2.0, 1.0, 0.5], so the passes are not vacuous
6.2.0 - 7.7.3 readmerge order through Float.compare or the boxed compareTo, which treat every NaN alike (code reading only)
main + the two-line fix not affectedthis doc's test 5 of 5; all 8,870 core tests, assertions on and off

Independent checks

An independent reproducer, reviewers from another model family (each with its own executed test), a devil's advocate and an adjudication say real; the adjudication re-ran with fresh seeds. This doc's test fails on stock and passes with the two-line fix, and with the fix the whole core suite passes.

Checks and verdicts366 words

No reviewer dissented.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidate4-test JUnit fails 4 of 4 on main (assertions on and off), 10.5.2, 9.12.3 and 9.11.0, passes on 9.10.0 and with the fix; CheckIndex level 3 rejects the merged segment.
Bounded-exhaustive merge-order check supports13,652 FLOAT and 13,620 DOUBLE order violations in 48,600 merges each, 4,976 wrong top-1 hits each; 0 without the sign-bit NaN, 0 with the fix, 0 for the other sort types.
Refute review (OpenAI GPT-5.6) real, highIts own test fails for FLOAT and DOUBLE with assertions on and off, a positive-NaN control passes; the fix passes it plus TestIndexSorting and TestSortingCodecReader; intended argument weak.
Independent reproducer real, highOwn 9-test JUnit from the claim only (three segments, NaN by run-time negation, three oracles): 7 of 9 fail on main, 10.5.2, 9.12.3 and 9.11.0; controls pass; 9 of 9 on 9.10.0 and with its own 2-line fix.
Works-as-designed review (OpenAI GPT-5.6) and re-runs real, highIntended argument weak; its test with payloads 0xffd23456 / 0xfffa23456789abcd fails 3 of 4 on main and 9.11.0, passes on 9.10.0 and with the fix; a CheckIndex test flags 0xffc00000 and 0xff800001, positive-payload controls pass.
Devil's advocate realBuilt the strongest "NaN order unspecified" case and answered it (LUCENE-4937, the floatToSortableInt javadoc, LUCENE-3582's tests); reachability probe: run-time negation and copySign give 0xffc00000, text parsing does not.
Adjudication confirmed, highRe-ran the independent reproduction's tests with fresh seed 0BADF00D (7 of 9 fail, assertions on and off; 9 of 9 pass with the fix and on 9.10.0) and the finder's with 0BADF00E (4 of 4 fail).
Security assessment and probe out of scope of SECURITY.md, public route recommendedPersistent cross-user result manipulation and a whole-segment CheckIndex verdict (exorcise drops 1,500 of 1,500 documents); no over-matching, deleted-document exposure, data mixing, broken blocks or hang.
This doc's test and fix validation reproduced; fix validatedA new 5-test JUnit fails 4 of 5 through gradle and the runner on main, 10.5.2, 9.12.3 and 9.11.0 and passes on 9.10.0; with the fix it and all 8,870 core tests pass, and :lucene:core:check -x test succeeds.

Sources and provenance

Finding record, Lucene and upstream sources and repro files1,569 words

Finding record

LFV-145, snapshot of 2026-11-06

Registry title
Merging segments of an index sorted by a FLOAT or DOUBLE field misplaces a NaN whose sign bit is set, so the merged segment violates its declared index sort (CheckIndex at level 2 or higher reports it) and sorted searches that terminate early on the index sort return wrong top hits
Area
index / index sorting at merge time (IndexSorter.FloatSorter/DoubleSorter merge keys, MultiSorter) -> search / TopFieldCollector index-sort early termination
Priority
Medium, rank 74: Since 9.11 every merge misplaces a sign-bit NaN in an opt-in FLOAT/DOUBLE index sort, which leaves a persistently misordered segment (CheckIndex level 2 fails, early-terminated sorted searches return wrong top hits, a reindex is needed), and that persistence puts it above the count-only LFV-086, but it needs a run-time negated or binary NaN (possibly ordinary computed NaNs on x86, not executed).
Impact class
data corruption
Impact note
Applications that opt into an index sort on a SortField of type FLOAT or DOUBLE over FloatDocValuesField/DoubleDocValuesField and index a NaN whose sign bit is set (a run-time sign flip of a NaN, such as unary negation or Math.copySign, or raw binary floats passed through; Java text parsing canonicalizes even '-NaN') get, after any merge that involves that document, a persisted segment that violates its declared sort: CheckIndex at level 2 or higher rejects it (the default level 1 does not run the sort check) and sorted searches that terminate early on the index sort return wrong top hits, also for queries that do not match the NaN document; deterministic with -ea and -da, tiny scale (5 documents in 2 segments), in every release since 9.11.0, and segments already merged stay misordered after a code fix.
Summary
You configure IndexWriterConfig#setIndexSort with a FLOAT (or DOUBLE) SortField over a FloatDocValuesField (DoubleDocValuesField), index a document whose value is a NaN with the sign bit set, for example the run-time negation of a NaN, and expect every flushed and merged segment to follow the sort, in which Float.compare puts every NaN after +Infinity. Flush gets this right, but a merge orders documents by NumericUtils.sortableFloatBits of the raw stored bits, which ranks that NaN below -Infinity, so the merged segment follows neither order: segments {3, 1, NaN} and {2, 0.5} sorted descending merge to [2.0, 0.5, NaN, 3.0, 1.0]. CheckIndex at level 2 or higher reports the segment as unsorted, and a top-1 search sorted by the index sort returns 2.0 instead of NaN, and 2.0 instead of 3.0 for a query that does not match the NaN document.
Introduced
37837133c357 (apache/lucene#13320 'Remove unnecessary bit conversion for IndexSorter', author bugmakerrrrrr, merged by jpountz; branch_9x backport b1470b945f91) replaced NumericUtils.floatToSortableInt(Float.intBitsToFloat(bits)) and doubleToSortableLong(Double.longBitsToDouble(bits)), which canonicalize NaN through floatToIntBits/doubleToLongBits, with sortableFloatBits(bits) and sortableDoubleBits(bits) on the raw stored bits in FloatSorter/DoubleSorter.getComparableProviders [read: git log -S on the new and the old conversion over IndexSorter.java, git blame of IndexSorter.java:355, 362-364, 418-419 and 426-428 on main, git tag --contains and merge-base for the release boundary; the boundary was also executed: 9.10.0 passes, 9.11.0 fails]; the PR has the empty description template, no reviews or review comments, no test change and no CHANGES entry (2024-05-14 (#13320 merged on main and branch_9x); first released 2024-06-06 (9.11.0))
First affected version
9.11.0 [exec]; first affected 10.x release 10.0.0 [exec]
Affected versions
every release from 9.11.0 through 10.5.2 (18 releases: 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2), 10.6.0 (by reading) and main ea57360912ed: the finder JUnit fails 4 of 4 on each of the 18 and on main with identical messages [exec], also with -da on 9.11.0, 10.5.2 and main [exec]; unaffected: 9.1.0 through 9.10.0 (14 releases, the JUnit passes 4 of 4 [exec], 9.10.0 also with -da) and 8.0.0, 8.6.0, 8.11.4 and 9.0.0 (a core-only probe passes and prints the correct merged order [exec]); 6.2.0 (first release with IndexWriter index sorting) through 7.7.3 by code reading only: their merge order goes through Float.compare (6.2.0-6.6.6) or the boxed compareTo (7.0.0-8.1.1), both of which treat every NaN alike [read]; upstream branch_10x, branch_10_6, branch_10_5 and branch_9_12 carry the same lines (read-only contents API, 2026-10-26) [read], so 10.6.0 (released 2026-10-29) is affected [not executed]; not fixed upstream [read]; main 645d4fe14a2 [exec 2026-10-29, -ea and -da: 4 of 4 fail]; branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b (10.6.0 RC1) have the same IndexSorter.java blob as main [read 2026-10-29]
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; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26: the follow-up prior-report search ran 327 queries: scripted prior-report searches A-D (28 GitHub issue/PR, 4 commit, 4 downstream-repo, 158 mailing-list over dev@, java-user@, issues@, commits@ and builds@, 24 JIRA-archive, 17 web), 72 GraphQL issue/PR searches including elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr, a content scan of the body, comments and review comments of all 139 apache/lucene items created since 2024-05-01, and the changed-file lists of all 413 open apache/lucene PRs (none touches IndexSorter.java or NumericUtils.java; #16571, #16851 and #16847 touch MultiSorter.java but not the merge keys or NaN handling); plus the finder's scripted prior-report search (51 queries), the intended-behaviour check's (27 queries) and direct GitHub searches by the independent reproduction and the intended-behaviour check); related prior items: #13320 (introduced it; no discussion of NaN), #4656/LUCENE-3582 (precedent: the same NaN canonicalization defect in NumericUtils, fixed in 2011, whose fix #13320 bypassed), #6001/LUCENE-4937 (precedent: comparators moved to Float.compare/Double.compare), #9638/LUCENE-8592 and #11247/LUCENE-10210 (precedents of the bug class, merge order differing from the index sort, with different causes), #16809 and its fix PR #16810 (open: the same symptoms from a different cause, doc-values updates on the sort field; would not fix this), #16699/#16698 (open: FLOAT/DOUBLE support in IndexSortSortedNumericDocValuesRangeQuery, which by code reading would also rely on the merged order; no NaN discussion), #16887 and #14399 (index-sort early termination returning wrong hits for other causes)
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; tested 2026-10-26): this doc's test through gradle in a scratch worktree (stock fails 4 of 5 with assertions on and off; with the fix it passes, and so do all 8,870 lucene/core tests with assertions on and off and :lucene:core:check -x test; the worktree was removed afterwards) and through the standalone JUnit runner on main (-ea, -da, 10 iterations) and on the Maven Central jars of 10.5.2 (-ea, -da), 9.12.3 and 9.11.0 (fail) and 9.10.0 (pass); the finder's repro on every release 9.1.0-10.5.2 and on main (fails from 9.11.0); upstream main, branch_10x, branch_10_6, branch_10_5 and branch_9_12 read on 2026-10-26 (lines unchanged).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexSorter.java:55-62 (ComparableProvider#getAsComparableLong: 'the natural ordering of long values matches the ordering of doc IDs for the given comparator'), :351-369 (FloatSorter.getComparableProviders: missing value through floatToIntBits at :355, NumericUtils.sortableFloatBits(valueBits) of the raw bits at :362-364), :371-386 (FloatSorter.getDocComparator: Float.compare at :385), :413-432 and :435-450 (DoubleSorter: sortableDoubleBits at :428, Double.compare at :449)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/MultiSorter.java:35-39 ('The documents for each incoming leaf reader must already be sorted by the same sort!'); IndexWriterConfig.java:504 ('Set the Sort order to use for all (flushed and merged) segments'); CheckIndex.java:1114 and :1148 (testSort runs only at level >= MIN_LEVEL_FOR_INTEGRITY_CHECKS), :1219-1222 ('This segment claims its documents are sorted according to the incoming sort ... let's make sure'), :4742-4754 (DEFAULT_VALUE = MIN_VALUE = 1, MIN_LEVEL_FOR_INTEGRITY_CHECKS = 2)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:60-72 (floatToSortableInt: 'The sort order (including Float#NaN) is defined by Float#compareTo; NaN is greater than positive infinity', via Float.floatToIntBits), :83-91 (sortableDoubleBits/sortableFloatBits: no NaN canonicalization); document/FloatDocValuesField.java:21 and :40, DoubleDocValuesField.java:21 and :39 (store floatToRawIntBits/doubleToRawLongBits); search/TopFieldCollector.java:56-71 and :151-170 (early termination when the search sort is a prefix of the segment's index sort)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexSorter.java:57-62 (ComparableProvider), 147-150 (default getComparableValues), 351-369 (FloatSorter.getComparableProviders; missing value 355, return 364), 371-386 (getDocComparator, Float.compare at 385), 413-432 and 435-450 (DoubleSorter: 418-419, 428, 449)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/MultiSorter.java:35-39 (inputs already sorted), 84 (getComparableValues), 88-102 (queue), 118-136 (merge loop; each input's documents in order, 129)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:47-49, 70-72 (doubleToSortableLong / floatToSortableInt via doubleToLongBits / floatToIntBits), 84-91 (sortableDoubleBits / sortableFloatBits)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/FloatDocValuesField.java:21, 40; document/DoubleDocValuesField.java:21, 39 (raw bits)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:56-71, 91-112, 151-170 (early termination on an index-sort prefix; 98-100 GREATER_THAN_OR_EQUAL_TO above the threshold); search/IndexSearcher.java:100, 733 (TOTAL_HITS_THRESHOLD = 1000)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1114, 1148 (testSort at level >= MIN_LEVEL_FOR_INTEGRITY_CHECKS), 1219-1285 (testSort), 4742-4754 (DEFAULT_VALUE = MIN_VALUE = 1, MIN_LEVEL_FOR_INTEGRITY_CHECKS = 2), 4673 (command-line Options.level = DEFAULT_VALUE), 499 (the level field, no initializer: 0 without setLevel)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/FloatField.java:73, 105 (floatToSortableInt at index time); search/SortField.java:586-597 (FLOAT/DOUBLE -> FloatSorter/DoubleSorter over NUMERIC doc values)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/MultiSorter.java:118-136 (the merge takes each input's documents in order)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504; index/IndexSorter.java:57-62; index/MultiSorter.java:35-39; util/NumericUtils.java:37-43, 61-66; index/CheckIndex.java:1219-1285
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/util/TestNumericUtils.java:280-302, 343-365 (sign-bit NaN cases sort above +Inf); lucene/core/src/test/org/apache/lucene/index/TestIndexSorting.java:2481-2557 (nextFloat/nextDouble)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/FloatRange.java:130-142, document/DoubleRange.java:130-142, document/XYDocValuesField.java:79, 91 (NaN rejected); search/TopScoreDocCollector.java:30, search/FieldComparator.java:160, 167 (NaN scores documented as invalid, asserted); search/SortField.java:82-98
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:47-49, 70-72; document/FloatDocValuesField.java:21, 40; index/CheckIndex.java:1219-1285

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

Repro files

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.