All findings · Lucene finding · LFV-144 · numeric-sort-skipper-raw-float-bits

A FLOAT/DOUBLE sort on a skip-indexed NUMERIC field can drop the best hits when values are negative

A float or double sort on a raw-bit doc-values field with a skip index (no points) can silently return wrong top hits once values are negative: with doc i holding -i, the ascending top 3 of 10,000 docs is -8191, -8190, -8189, not -9999, -9998, -9997. Since 10.3.0.

Summary

Priority
Medium, rank 81Once any value is negative, FLOAT/DOUBLE sorts deterministically and silently return wrong or empty top-N pages, but only on a hand-built raw-bit NUMERIC field with a skip index and no points (the layout of Lucene's own test, no known downstream user), and only since 10.3.
Impact
silent wrong results
Since
10.3.0 (#14672, merged 2025-06-06)introduced 2025-06-06 · about 1 year
Affected
10.3.0 to 10.5.2 and main (14 targets run); 10.6.0 by reading
Status
documented
Found by
code reading while building a bounded-exhaustive check of sorted search

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

Since 10.3.0 a numeric sort can skip whole blocks of documents with the doc-values skip index. For FLOAT and DOUBLE on a NUMERIC field, that check compares numbers in two encodings that disagree for negative values.

Summary card: a FLOAT sort on a raw-bit, skip-indexed doc-values field can skip the best hits when values are negative. Apache Lucene, 10,000 docs, doc i holds -(float) i in NumericDocValuesField.indexedField("f", Float.floatToRawIntBits(v)), no points; searcher.search(q, 3, sort). Step 1: the field stores raw bits and its skip index keeps each block's min and max of those longs (4096 docs here); for negative floats raw bits run in reverse order. Step 2: after 1000 hits NumericComparator turns the bottom value into a bound in sortable bits, floatToSortableInt(bottom) - 1 (FloatComparator.java:111, NumericComparator.java:280-281), and hands it to a SkipBlockRangeIterator. Step 3: at doc 8192 block 2's raw min -973078528 (-8192.0) is compared with the bound -1174345730 (the bottom was -8163.0 at the last sampled refresh); the raw min looks larger, so the block is skipped although it holds -8192.0 to -9999.0. Step 4: docs 8192-9999 never reach the collector. Expected -9999, -9998, -9997; actual -8191, -8190, -8189, no error; descending, the page after -9000.0 has no hits. Affected 10.3.0 to 10.5.2 and main, FLOAT and DOUBLE. Needs negative values (or -0.0 with a tie-break sort; rarer, NaN payloads) and pruning, by default after 1000 hits, with a query that uses the competitive iterator; not every such search is wrong; FloatField, DoubleField, fields with points and fields without a skip index are correct. The contract quotes of SortField.Type.FLOAT and FloatComparator and Lucene's own skip-index test are at the bottom.
The repro's 10,000 docs in the skip index's three blocks: after the first 8,192 docs the bound, in sortable bits, is compared with the last block's raw-bit min, and the block that holds the three lowest values is skipped. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 10,000 docs; doc i holds the float -i as raw bits in a NUMERIC doc-values field with a skip index and no points, NumericDocValuesField.indexedField("f", Float.floatToRawIntBits(v)): the layout of Lucene's own skip-index sort test. Sort a TermQuery that matches every doc by SortField.Type.FLOAT, ascending, top 3.
  2. You expect -9999, -9998, -9997, which the same values without a skip index return. You get -8191, -8190, -8189, with no error. A descending searchAfter(-9000) page comes back empty, and doubles fail the same way.
  3. Why: after the first 8,192 docs, Lucene asks the skip index which blocks (4,096 docs each here) could still beat the current 3rd-best value. That bar is in sortable bits (floatToSortableInt), but each block's min and max are the stored raw bits. The two agree for positive numbers; for negative numbers the raw bits grow as the number gets more negative.
  4. So the last block (docs 8,192-9,999, values -8,192 to -9,999) has a raw min of -973,078,528, above the bar of -1,174,345,730 (the sortable bits of -8,163, minus 1). It looks non-competitive and is skipped, although it holds the three answers.
  5. Only this hand-built layout is hit; FloatField/DoubleField (sortable bits), fields with points and fields without a skip index are correct. It needs pruning to kick in (by default after 1,000 hits), and not every search over negative values goes wrong: that depends on the values in each block and on the bound.

Who is affected

Applications that store floats or doubles as raw bits (FloatDocValuesField's encoding) in a single-valued NUMERIC field with a skip index and no points, and sort with SortField.Type.FLOAT/DOUBLE. A negative value (or -0.0 with a tie-break) plus pruning can give wrong pages, silently; whether it does depends on the values. FloatField/DoubleField are not affected. 10.3.0-10.6.0 (10.6.0 by reading) and main.

Mechanism, terms and numbers972 words

Since 10.3.0 (#14672), a numeric sort on a field that has a doc-values skip index and no points prunes with the skip index: once more hits than the hits threshold (default 1,000) have been counted, NumericComparator turns the current bottom (and the searchAfter top) into a bound and skips each skip-index block (nominally 4,096 docs at level 0) whose min/max cannot beat it. For SortField.Type.FLOAT / DOUBLE, FloatComparator / DoubleComparator give that bound in sortable bits (NumericUtils.floatToSortableInt / doubleToSortableLong), but on a NUMERIC field the skip index records the min/max of the stored longs, which are raw bits (Float.floatToRawIntBits): the only NUMERIC encoding these comparators decode, the encoding FloatDocValuesField writes, and the layout of Lucene's own skip-index float sort test. The two encodings agree for non-negative numbers (+0.0 to +Infinity) and the canonical NaN, run in opposite directions for values whose sign bit is set, and also differ for NaNs with a non-canonical payload, because the bounds canonicalize NaN. So once a segment holds negative values (or -0.0 with a tie-break sort), blocks that hold the best hits can be skipped; whether a search goes wrong depends on the magnitudes of the block's values and of the bound. In a 10,000-doc index where doc i holds -i, the ascending top 3 is -8191, -8190, -8189 instead of -9999, -9998, -9997, a descending searchAfter(-9000) page is empty, and there is no error. Lucene's FloatField / DoubleField (SORTED_NUMERIC, sortable bits), fields with points and fields without a skip index are not affected. Every release from 10.3.0 to 10.6.0 (10.6.0 by reading) and main is affected; 10.0.0 to 10.2.2 are not. No upstream report was found.

Terms

  • Raw bits: Float.floatToRawIntBits / Double.doubleToRawLongBits, what FloatDocValuesField / DoubleDocValuesField store in a NUMERIC doc-values field, and what FloatComparator / DoubleComparator decode (Float.intBitsToFloat).
  • Sortable bits: NumericUtils.floatToSortableInt / doubleToSortableLong, the encoding of FloatPoint, FloatField and the comparators' pruning bounds. It first turns any NaN into the canonical NaN (Float.floatToIntBits), then keeps the bits when the sign bit is clear and flips all other bits when it is set, so signed-integer order equals numeric order.
  • Skip index: per block of docs (nominally 4,096 at level 0, plus coarser levels), the min and max of the stored longs (NumericDocValuesField.indexedField, or FieldType.setDocValuesSkipIndexType(RANGE)).
valueraw bitssortable bits
1.01,065,353,2161,065,353,216
0.000
-0.0-2,147,483,648 (smallest int)-1
-1.0-1,082,130,432-1,065,353,217
-2.0-1,073,741,824 (larger than -1.0's)-1,073,741,825 (smaller)
-8,192.0-973,078,528-1,174,405,121
-9,999.0-971,228,160-1,176,255,489

The bug

  1. NumericComparator.buildCompetitiveDISIBuilder uses points if the field has them, otherwise the doc-values skipper (NumericComparator.java:117-131), whatever the sort type or doc-values type.
  2. Once more hits than the threshold (default 1,000) have been counted (until then updateCompetitiveIterator returns early, :242-244, also with a searchAfter top, which only adds a second bound), encodeBottom / encodeTop (:277-314) take the bounds from bottomAsComparableLong() / topAsComparableLong(), which for floats are NumericUtils.floatToSortableInt(bottom) (FloatComparator.java:110-117; doubles the same, DoubleComparator.java:111, :116).
  3. new SkipBlockRangeIterator(skipper, minValueAsLong, maxValueAsLong) (NumericComparator.java:530) hands those sortable bounds to DocValuesSkipper.advance(min, max), which skips a block when minValue(0) > maxValue || maxValue(0) < minValue (DocValuesSkipper.java:125): raw block bounds against sortable bounds.
  4. The same comparator reads each document's value as raw bits (FloatComparator.java:81). For segments without sign-bit values the encodings coincide, which is why Lucene's test with values 0..N-1 passes. With negative values, blocks holding the best hits can look non-competitive. A sign-bit value is needed but not enough: for floats, an ascending sort skips an all-negative block when the bit patterns of the block's smallest magnitude and of the bound's magnitude add up to more than 2^31 - 2 (roughly, both 2 or more); a descending sort skips one only when the magnitudes are small or the block holds only -0.0. On the repro's layout one -5 among positives, values in [-1, -0.0001] and the first descending page over -i were all correct; -i/1000 ascending was wrong (fact-check runs). -0.0 is the smallest raw int but sortable -1, so with a tie-break sort the -0.0 documents that should win the tie are skipped.

The numbers

Repro, one segment of 10,000 docs, doc i = -i (doc 0 holds -0.0), TermQuery matching all, query cache off. Blocks: docs 0-4095 raw min -2,147,483,648 (-0.0), max -981,471,232 (-4095); docs 4096-8191 raw min -981,467,136 (-4096), max -973,080,576 (-8191); docs 8192-9999 raw min -973,078,528 (-8192), max -971,228,160 (-9999). The TermQuery runs on DenseConjunctionBulkScorer, which collects docs 0-8191 as one range and then asks the competitive iterator for the next doc. By then the bound is max = -1,174,345,730 (sortable bits of -8,163.0, the bottom at the last sampled refresh, minus 1); the last block's raw min -973,078,528 is greater, so advance(8192) returns NO_MORE_DOCS. The same index with doc i = +i also collects exactly 8,192 docs and is right, so the 8,192 is not part of the bug: it ends the term's first dense postings run (one level-1 postings block, 32 x 256 docs), which the bulk scorer collects as one range. On 10.3.x that block is 32 x 128 = 4,096 docs and the top 3 is -4095, -4094, -4093.

A NaN whose sign bit is set (Float.intBitsToFloat(0xFFC00000)) is also missed: Float.compare sorts it above every number, but its raw bits are negative. With docs 0-8191 holding 1000..9191, docs 8192-9998 holding 0..1806 and doc 9999 holding that NaN, the descending top 3 is docs 8191, 8190, 8189 instead of docs 9999, 8191, 8190. Two layouts whose skip index has no negative minimum fail too: every doc holding the positive-sign NaN 0x7f800001 (or the double 0x7ff0000000000001), descending with a reverse doc-id tie-break (the bound is the canonical NaN 0x7fc00000), and floats stored zero-extended (Integer.toUnsignedLong(Float.floatToRawIntBits(v))), ascending; the canonical NaN passes.

Exposure and likelihood376 words
  • Who is hit. A FLOAT/DOUBLE sort on a single-valued NUMERIC doc-values field that holds raw bits, with a skip index (NumericDocValuesField.indexedField(name, Float.floatToRawIntBits(v)), the double equivalent, or FieldType.setDocValuesSkipIndexType(RANGE)) and no points. It is opt-in, but it is the layout of Lucene's own TestSortOptimization#testFloatSortOptimizationSkpperIndex, and FieldType#setDocValuesSkipIndexType says a skip index is "typically useful on fields that are part of the index sort".
  • Trigger. A value whose raw and sortable bits differ in the segment (a negative value; -0.0 together with a tie-break sort; a NaN with the sign bit set; rarer, a NaN with another payload and a tie-break sort), and pruning that engages: more matches than the hits threshold (default 1,000; a searchAfter alone does not start it), with a query whose scorer consults the competitive iterator. Even then not every search is wrong: it depends on the magnitudes of the values in each block and of the bound (see What goes wrong). Reproduced with a TermQuery (also with the default query cache), MUST + FILTER conjunctions, 3 and 5 segments, an index sorted on the field, and random unclustered values (ascending top 5 wrong in 20 of 20 mixed-sign random indices). A dense single-segment MatchAllDocsQuery and a pure SHOULD disjunction did not prune in the probes and stayed correct. Descending without searchAfter was correct on random data.
  • What they see. Deterministic, silent wrong or missing top-N hits: ascending pages miss the smallest values, descending searchAfter pages can be empty, -0.0 ties come out in the wrong order. Every returned hit still matches the query and carries its own value. Default codec, IndexWriterConfig, searcher and hits threshold; -ea and -da the same.
  • Not hit. FloatField / DoubleField and other SORTED_NUMERIC sortable fields with SortedNumericSortField (0 wrong results in 119,460 randomized checks), fields with points, fields without a skip index. Elasticsearch, OpenSearch and Solr (default branches read on 2026-10-26, not run) do not build the affected layout; custom applications are unknown.
  • How likely. Registry likelihood "plausible": the layout is hand-built, but on it ordinary negative data and an ordinary sorted query over more than 1,000 hits fail deterministically at about 10,000 docs (the repro, the independent reproduction's random data, the intended-behaviour check's natural data); some value ranges stay correct by luck. Priority medium.

Minimal repro

The test shows that an ascending SortField.Type.FLOAT sort on raw-float-bit NUMERIC doc values with a skip index and no points (NumericDocValuesField.indexedField) misses the smallest negative values once doc-values-skipper pruning engages (10,000 docs valued 0..-9999, TermQuery).

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

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
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 TestFloatSortSkipIndexNegative extends LuceneTestCase {

  public void testAscendingFloatSortWithNegativeValues() throws IOException {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      for (int i = 0; i < 10_000; i++) {
        Document doc = new Document();
        doc.add(new StringField("all", "x", Field.Store.NO));
        doc.add(NumericDocValuesField.indexedField("f", Float.floatToRawIntBits(-i)));
        w.addDocument(doc);
      }
      w.forceMerge(1);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setQueryCache(null);
        Sort sort = new Sort(new SortField("f", SortField.Type.FLOAT));
        List<Object> top3 = new ArrayList<>();
        for (ScoreDoc hit : searcher.search(new TermQuery(new Term("all", "x")), 3, sort).scoreDocs) {
          top3.add(((FieldDoc) hit).fields[0]);
        }
        // expected the three smallest values [-9999, -9998, -9997]; actual [-8191, -8190, -8189]
        assertEquals(List.of(-9999f, -9998f, -9997f), top3);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-144/, 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.search.TestFloatSortSkipIndexNegative
Expected vs actual
Expected the top 3 [-9999.0, -9998.0, -9997.0]; actual [-8191.0, -8190.0, -8189.0], with -ea and -da.

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

More files, environment, full output38 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:<[-9999.0, -9998.0, -9997.0]> but was:<[-8191.0, -8190.0, -8189.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.search.TestFloatSortSkipIndexNegative.testAscendingFloatSortWithNegativeValues(TestFloatSortSkipIndexNegative.java:57)

Why it looks unintended

  • SortField.Type.FLOAT: "lower values are at the front"; FloatComparator is "based on Float#compare". Skipping must not change the hits.
  • Lucene's own testFloatSortOptimizationSkpperIndex uses this exact layout and asserts pruned hits equal a full scan, with values 0..N-1 only.
  • The comparator decodes the values as raw bits, then reads their skip index as sortable bits.
The full argument264 words
  • Contract. SortField.Type.FLOAT: "Sort using term values as encoded Floats. Sort values are Float and lower values are at the front. Fields must either be not indexed, or indexed with FloatPoint." (SortField.java:82-86; DOUBLE :94-98). The field has no points. FloatComparator: "Comparator based on Float#compare ... This comparator provides a skipping functionality – an iterator that can skip over non-competitive documents." (FloatComparator.java:26-29).
  • Upstream test. #14672 added testFloatSortOptimizationSkpperIndex with FloatDocValuesField.indexedField(field, Float.floatToRawIntBits(value)) (TestSortOptimization.java:474-478); its helper checks that a searcher that "hides index structures to force a linear scan" returns "the same hits" (:1499-1520). It indexes only 1f * i, i >= 0 (:484-487), where raw and sortable bits are equal. There is no double variant.
  • Self-contradiction. FloatComparator decodes the NUMERIC values with Float.intBitsToFloat (:81) but compares the skip index built from those values with sortable bounds (:110-117, NumericComparator.java:530).
  • CHANGES 10.3.0: "Numeric sort queries can take advantage of DocValuesSkipper to dynamic prune." (CHANGES.txt:1582), with no type or sign restriction; 10.2.2 returns the right hits.
  • Precedent. In #16573 / #16629 (fixed in 10.5.2) maintainers treated the same raw-vs-sortable mismatch in range-query code as a bug: on #16573 HoustonPutman wrote that "NUMERIC expects floats and doubles to not be in sorted order", and on #16629 romseygeek, about points min/max not matching the actual double values, wrote "I think we need to check that that [sic] we aren't making this assumption in other places as well", naming IndexSortSortedNumericDocValuesRangeQuery. Earlier competitive-iterator false negatives were fixed as bugs (LUCENE-10106, LUCENE-10126).
The devil's-advocate check220 words

The intended-behaviour checker built the strongest case: the FLOAT javadoc says "not indexed, or indexed with FloatPoint", and a skip index is "indexed"; Lucene has no first-class float field with a NUMERIC skip index, so the user owns the encoding; TestSegmentReordering stores sortable bits in a NUMERIC skip-indexed field and sorts it with Type.FLOAT, which could be read as the convention; and SortField.setOptimizeSortWithIndexedData(false) turns pruning off.

Why it fails: the same PR's own test uses raw bits plus a skip index and asserts pruned hits equal a full scan, and, although "indexed" read literally is ambiguous (a maintainer may raise it), the class javadoc's "indexed with both doc values and points" (SortField.java:42-44) shows the FloatPoint sentence is the points precondition. Raw bits are the only NUMERIC encoding FloatComparator decodes correctly: sortable bits in a NUMERIC field give wrong sort values for negatives even without a skip index (executed control), so TestSegmentReordering only works because its values are non-negative. The off switch is deprecated, "should only be used for compatibility with 8.x indices that got created with inconsistent data across fields" (SortField.java:608-629). Nothing in javadoc, CHANGES, MIGRATE, tests or the PR reviews limits skipper pruning to non-negative values. Verdict: not intended, a defect. The cross-model reviewer with the same lens rated the intended argument weak.

Suggested fix

Recommended Option A+: option A plus a maximum check, so only segments whose raw values all lie from +0.0 to +Infinity keep skip-index pruning

Skip the skip-index pruning for a FLOAT/DOUBLE sort on a NUMERIC field unless the segment's raw values all lie between +0.0 and +Infinity, where raw and sortable bits are equal (+19 lines in NumericComparator.buildCompetitiveDISIBuilder). The repro, 4 edge-encoding tests and 163 nearby tests pass. Cost: no skip-index pruning on such segments, as before 10.3.0.

// raw bits from +0.0 to +Infinity equal their sortable bits; also give up when the segment
// holds a NaN (its payload may differ from the canonical NaN of the bounds) or float bits
// stored zero-extended
long positiveInfinityBits =
    NumericComparator.this instanceof FloatComparator
        ? Float.floatToRawIntBits(Float.POSITIVE_INFINITY)
        : Double.doubleToRawLongBits(Double.POSITIVE_INFINITY);
if (rawIeeeBits && (skipper.minValue() < 0 || skipper.maxValue() > positiveInfinityBits)) {
  return null;
}
All options and trade-offs4 options · 1,465 words

Options A and B were implemented and validated with gradle in a scratch worktree at ea57360912ed (removed after the runs), together with a randomized differential test written for the check (random FLOAT and DOUBLE indices of 2,000-20,000 docs over 1-4 segments, with negative, -0.0, infinite and NaN values, random direction, tie-break, page size and searchAfter; stock main fails it 3 of 3 on 5 seeds; its DOUBLE values come from floats, and it has no positive-sign non-canonical NaN and no zero-extended values). The fact checks then found that both key on a negative skipper minimum, which misses two rarer encodings; an edge-encoding test (stock fails 3 of 4) and option A+, which closes that gap, were added. Option C was not written. Until a fix lands, users can turn pruning off with the deprecated SortField.setOptimizeSortWithIndexedData(false) or an exact total-hit count, or change the field: FloatField / DoubleField (points plus sortable SORTED_NUMERIC doc values, no skip index) sorted with SortedNumericSortField prune through points and are correct; to keep a doc-values skip index without points, store sortable bits with SortedNumericDocValuesField.indexedField(name, NumericUtils.floatToSortableInt(v)) and sort with SortedNumericSortField (executed: correct and pruned). Open PR #16163 touches the same builder but keeps the comparison (read, not run); today pruning waits for the hits threshold even with searchAfter, and #16163 would change that.

Option A+: option A plus a maximum check, so only segments whose raw values all lie from +0.0 to +Infinity keep skip-index pruning (validated by the checks) (recommended)

Raw and sortable bits are equal exactly for the raw range from +0.0 to +Infinity (plus the canonical NaN). Option A+ keeps option A and also gives up when the segment's raw maximum is above +Infinity's bits, which catches every NaN payload and zero-extended float bits. The change is +19 lines in buildCompetitiveDISIBuilder (option A's lines plus the ones below).

// raw bits from +0.0 to +Infinity equal their sortable bits; also give up when the segment
// holds a NaN (its payload may differ from the canonical NaN of the bounds) or float bits
// stored zero-extended
long positiveInfinityBits =
    NumericComparator.this instanceof FloatComparator
        ? Float.floatToRawIntBits(Float.POSITIVE_INFINITY)
        : Double.doubleToRawLongBits(Double.POSITIVE_INFINITY);
if (rawIeeeBits && (skipper.minValue() < 0 || skipper.maxValue() > positiveInfinityBits)) {
  return null;
}

Trade-offs

  • Everything option A gives up, and also skip-index pruning on segments that hold any NaN, including the canonical NaN, where stock pruning was right.
  • The same style points as option A. No benchmark was run.

What the checks validated

  • The repro runner: the edge-encoding test 4 of 4 (-ea, -da; stock, A and B fail 3 of 4), the finder's repro 6 of 6 (-ea, -da), the differential 3 of 3 on 5 seeds, the independent reproduction's test (seed 1A2B3C4D) 6 of 6, the intended-behaviour check's (seed 7E57AB1E) 5 of 5.
  • Gradle in a fresh extraction of ea57360912ed: the 163-test set above plus the edge test, 167 tests, 0 failures (seed DEADBEEF, assertions on; seed CAFEBABE, assertions off); tidy only joined the if onto one line. These runs used an earlier wording of the code comment; the final file was re-run with the repro runner.
  • Not run: the whole search package, other modules.

Option A: no skip-index pruning on segments that hold sign-bit values (the hunt's sketch, minimal) (validated by the checks)

The hunt's sketch. For the sign-extended raw bits FloatDocValuesField / DoubleDocValuesField write, a segment whose skipper minimum (DocValuesSkipper.minValue(), segment-wide) is non-negative has no value with the sign bit set, and there raw and sortable bits are identical, except NaNs with a non-canonical payload. It keys on the doc-values type, because SORTED_NUMERIC float/double values are sortable-encoded and already correct; a negative-sign NaN also makes the minimum negative. Gap (executed): a positive-sign non-canonical NaN with a tie-break sort, and floats stored zero-extended, keep a non-negative minimum, so option A still fails 3 of the 4 edge-encoding tests; option A+ closes it. The code below is the diff's text.

DocValuesSkipper skipper = reader.getDocValuesSkipper(field);
if (skipper != null) {
  // FLOAT/DOUBLE NUMERIC doc values hold raw IEEE bits (FloatDocValuesField /
  // DoubleDocValuesField), whose signed-long order differs from the sortable encoding used
  // for the bottom/top bounds as soon as a value has its sign bit set.
  FieldInfo fi = reader.getFieldInfos().fieldInfo(field);
  boolean rawIeeeBits =
      (NumericComparator.this instanceof FloatComparator
              || NumericComparator.this instanceof DoubleComparator)
          && fi != null
          && fi.getDocValuesType() == org.apache.lucene.index.DocValuesType.NUMERIC;
  if (rawIeeeBits && skipper.minValue() < 0) {
    return null;
  }
  return new DVSkipperCompetitiveDISIBuilder(skipper, this);
}

Trade-offs

  • Gives up skip-index pruning on every segment that holds a negative value, -0.0 or a negative-sign NaN, the same as 10.2.x, which never pruned with the skipper; non-negative segments keep it. On a mixed-sign segment (-i for the first 8,192 docs, +i after) it visits all 10,000 docs where option B visits 8,192, both correct.
  • The instanceof test and the fully qualified DocValuesType are style points a maintainer may want changed (a hook on the comparator, an import). No benchmark was run.

What the checks validated

  • Gradle, stock: the repro fails 6 of 6 with assertions on and off.
  • Gradle with the fix: the repro, the differential test and 12 upstream classes (TestSortOptimization, TestSort, TestSearchAfter, TestTopFieldCollector, TestTopFieldCollectorEarlyTermination, TestDenseConjunctionBulkScorer, TestBooleanQuery, TestSortedNumericSortField, TestSortRandom, TestSkipBlockRangeIterator, TestElevationComparator, comparators.TestNumericComparator): 163 tests, 0 failures (seed DEADBEEF, assertions on; seed CAFEBABE, assertions off).
  • Gradle with the fix: all of org.apache.lucene.search.* in :lucene:core with the repro, 2,336 tests, 0 failures, 49 skipped; tidy leaves the fix unchanged.
  • The repro runner: the finder's, the independent reproduction's, the cross-model review's and the reviewer's tests pass; the bounded-exhaustive harness and a randomized differential (2,113,290 checks) find 0 mismatches; the differential test passes on 5 seeds; the security probe's hit lists no longer depend on hidden documents.
  • Not run: the rest of :lucene:core, other modules.

Option B: map the sortable bounds to a raw-bit range on segments that hold sign-bit values (keeps more pruning) (validated by the checks)

+63/-4 in NumericComparator.java. buildCompetitiveDISIBuilder tells the skipper builder whether the field holds raw float (32) or double (64) bits (a FLOAT/DOUBLE sort on a NUMERIC field). When it does and skipper.minValue() < 0, doUpdateCompetitiveIterator replaces the sortable range [lo, hi] by the smallest raw-bit range that holds the raw bits of every value in it, plus every NaN bit pattern when the range reaches above +Infinity.

if (lo < 0) { // sign bit set: [lo, min(hi, -1)] in reverse order
  min = Math.min(hi, -1) ^ highest; // highest = Integer.MAX_VALUE or Long.MAX_VALUE
  max = lo ^ highest;
}
if (hi >= 0) { // sign bit clear: the same bits
  min = Math.min(min, Math.max(lo, 0));
  max = Math.max(max, hi);
}
if (hi > positiveInfinity) { // NaNs may compete: every NaN bit pattern, either sign
  min = Math.min(min, (positiveInfinity + 1) | lowest);
  max = highest;
}

Trade-offs

  • Can keep pruning for ascending sorts on segments with negative values: the mixed-sign probe visits 8,192 docs instead of 10,000 (the pruned block is the positive one); the all-negative probe visits all 10,000, because the answers are in the last block; pruning of negative blocks follows from the mapping but was not measured. Non-negative segments are unchanged.
  • Still gives up pruning when the competitive range covers both signs, for example a descending sort whose bottom is negative, because one raw range then spans nearly everything; an exact version needs a two-range block test.
  • More code than option A, and it must stay in step with NumericUtils. A first variant that mapped on every raw-bit segment failed upstream TestNumericComparator.testEmptyCompetitiveIteratorOptimization (the NaN clause stopped a descending sort on non-negative values from pruning); limiting it to skipper.minValue() < 0 fixed that.
  • The same gap as option A: it activates only when skipper.minValue() < 0, so it still fails 3 of the 4 edge-encoding tests; closing it needs option A+'s maximum check (not written for B).

What the checks validated

  • Gradle with the fix: the same 163 tests, 0 failures (seed DEADBEEF, assertions on; seed CAFEBABE, assertions off); all of org.apache.lucene.search.* with the repro and the differential, 2,339 tests, 0 failures, 49 skipped; tidy clean.
  • The repro runner: the differential passes on 5 seeds and with --da; the finder's repro (-ea, -da), the independent reproduction's test (with its pruning-engaged control) and the intended-behaviour check's test pass; the security probe shows 0 wrong searches and hit lists that do not depend on hidden documents.
  • Not run: the rest of :lucene:core, other modules, a benchmark.

Option C: exact two-range block test (not written)

Teach SkipBlockRangeIterator (or a sibling) to accept the union of the two raw-bit intervals a sortable range maps to, so no pruning is lost.

Trade-offs

Most code; touches a public class; untested.

How it was found

An AI tool building a bounded-exhaustive check of sorted search (the real TopFieldCollector against a brute-force sort on every tiny index) noticed in the code that the skip-index bound and the block min/max use different encodings. The check then found 77,738 wrong results in 2,597,184 for FLOAT, and a default-codec JUnit confirmed it.

The method in full191 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier), a hunt unit (sorting / TopFieldCollector), 2026-10-25, main ea57360912ed.

  • The finder wrote a harness that runs the real TopFieldCollector on the stock jar against an independent brute-force sort for every small index layout (tiny codec with a skip interval of 2, at most 5 docs, several field encodings and sort types).
  • While building it, the finder noticed by code reading that the skipper bounds are sortable-encoded while the skipper's block min/max are raw bits. The harness then witnessed it: for NUMERIC + skip index + FLOAT with the values (-3, -2), 77,738 of 2,597,184 checks were wrong; for DOUBLE with (-0.0, 0.0), 30,090 of 4,328,640. With the fix sketch both gave 0.
  • A default-codec JUnit with 10,000 docs on the stock main jar confirmed it (6 of 6 failing). A randomized differential at scale (up to 30,000 docs, 1-4 segments, deletions, index sorts) found 268 wrong results in 11,489 checks on the affected family and 0 in 119,460 on the sortable SORTED_NUMERIC family.
  • So it is a code-reading find confirmed by the exhaustive check, not a blind discovery by the tool.

Prior reports, history and checks

Prior reports

No report found (2026-10-26): 182 logged queries over GitHub issues, PRs and review comments, git history, all 413 open PRs, four mailing lists, the JIRA archive, three downstream trackers and the web. Closest, a different bug: #16573 / #16629, the same raw-bits hazard in range queries, fixed in 10.5.2.

Searches4 searches

No report of this bug was found, and upstream main, branch_10x, branch_10_6 and branch_10_5 still have the code. Related items, none of them this bug: #16573 (hossman) / #16629 (HoustonPutman, fixed in 10.5.2): range queries over float/double points plus NumericDocValuesField; the maintainers stated the hazard and asked to check "other places", naming a range query, not sorting. #16698 / #16699 (open): range-query follow-up. #749 (LUCENE-10466, 2022): FloatDocValuesField raw bits vs floatToSortableInt in IndexSortSortedNumericDocValuesRangeQuery. #14666 / #14672: introduced the code; its review did not discuss float encoding. Open PR #16163 builds the skipper iterator immediately for searchAfter and keeps the comparison (diff read, not run). Framing suggestion: one of the "other places" #16629 asked about.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and review comments (apache/lucene)2026-10-26REST and GraphQL search: skipper float/double/negative/sort, NumericComparator, FloatComparator, DoubleComparator, SkipBlockRangeIterator, indexedField, "raw bits", floatToRawIntBits sort, "sort optimization" float, "competitive iterator" skipper, search_after skipper, "negative zero"no report; #16573 / #16629 and #749 are the closest (range queries)
Open PRs and branches2026-10-26changed files of all 413 open PRs: 8 touch NumericComparator, SortField, TestSortOptimization or TopFieldCollector (#16163, #16857, #16847, #14395, #14775, #15752, #16276, #13525); the patches of the first 7 grepped by the prior-report search, #13525's (multi-vector work in progress; in TestSortOptimization only an import and a FieldInfo constructor argument) read in the resolution step; main, branch_10x, branch_10_6, branch_10_5nothing changes the bounds encoding; #16163 touches the same builder
Mailing lists, JIRA archive, downstream trackers, web2026-10-26dev@, java-user@, issues@, commits@; JIRA (frozen 2022); Elasticsearch, OpenSearch and Solr trackers; web searchnothing about this bug
Earlier passes2026-10-26the finder's 45-query pass; the intended-behaviour check's GitHub searches (16 query strings) and 26-query passno report

History

Introduced by #14672 "Dynamic pruning with DocValueSkipper" (merged 2025-06-06), first released in 10.3.0. Every release from 10.3.0 to 10.5.2 and main fails the repro; 10.0.0 to 10.2.2 pass; 10.3.0 with only NumericComparator reverted passes.

Versions tested6 rows

#14672 (gf2121, closes #14666) was committed on 2025-06-06 as 9a7bcc9b590 on main and cherry-picked as e2fdc61ad32 to branch_10x; CHANGES 10.3.0: "Numeric sort queries can take advantage of DocValuesSkipper to dynamic prune." It added the skipper pruning path for every numeric sort type and reused the comparators' sortable bounds (from #13246, correct for points). Later changes kept the mismatch: #15397 / #15410, #15632 (SkipBlockRangeIterator), #15663, #15757.

Every release row ran the hunt's repro on the release's Maven Central jars (SHA-1 checked), seed DEADBEEF, assertions on, on OpenJDK 25.0.3, Linux aarch64 (only main was also run with assertions off). The main commit pair was not built from source; branch_10x and branch_10_6 were read, not run. The change of the first dense postings run from 4,096 docs (10.3.x) to 8,192 (10.4.0 and later) was not bisected.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed (unreleased 11.0) affected6 of 6 fail, -ea and -da; ascending top 3 -8191, -8190, -8189
10.4.0 to 10.5.2 affected6 of 6 fail; same wrong hits as main
10.3.0 to 10.3.2 affected6 of 6 fail; first dense postings run 4,096 docs, ascending top 3 -4095, -4094, -4093
10.0.0 to 10.2.2 not affected6 of 6 pass: skip index, but no skipper-based sort pruning
9.12.3 n/adoes not compile: no skip index API (added in 10.0.0 by #13449)
10.3.0 with only NumericComparator.java from d176a42b659 (before #14672: the parent of the branch_10x cherry-pick e2fdc61ad32) revert6 of 6 pass

Independent checks

Tests written independently by the finder, AI verifiers and cross-model reviews (OpenAI GPT-5.6) all fail on main and pass with the fix; an adjudication re-ran two of them on new seeds. No intended-behaviour argument survived. This page's own gradle runs reproduced it and validated two fixes.

Checks and verdicts524 words

Every check wrote or re-ran its own test on the stock main jars; all voted real, with no dissent.

Each independent check and its verdict
CheckVerdictWhat it found
cross-model review (refute lens) real, highAn independent reviewer from another model family (OpenAI GPT-5.6). Its own test fails 2 of 4 on main (-ea, -da, 3 seeds) and on 10.5.2, passes on 10.2.2 and with the fix. Its plain -0.0 probe passed, so the -0.0 claim was narrowed to a tie-break sort.
Independent reproducer realOwn test (13,788 docs, sort oracle plus a no-skip control) fails 5 of 6 on main, 10.5.2 and 10.3.0; only its positive-value control passes. Reach probes: default query cache, MUST + FILTER, 3 segments, random data.
Cross-model review (intended lens) real, high; intended argument weakOwn 2-test class fails 2 of 2 on main (-ea, -da), 10.3.0 and 10.5.2 with no-skip and point-index controls passing; upstream TestSortOptimization passes 31 of 31 with the fix.
Intended-behaviour devil's advocate realNo intended, documented or misuse argument survives. Own natural-data test (5 segments, default query cache, an index sorted on the field) fails 5 of 5; 4 context tests pass.
Adjudication confirmed, highRe-ran the independent reproduction's test with seed 1A2B3C4D (5 of 6 fail, -ea and -da; 0 with the fix) and the intended-behaviour check's with seed 7E57AB1E (5 of 5 fail).
History, prior-report and security follow-ups introduced by #14672 (10.3.0); new; security noneRelease sweep over 14 targets plus a one-file revert; 182 prior-art queries; a fails-closed security probe over 45,072 returned hits. Security stays none: one residual signal (which visible hits a page shows can depend on hidden documents' values, 299 of 720 paired searches) is out of scope under SECURITY.md; the cross-model fact check dissented (see the brief, section 12).
This page's own runs reproduced; two fixes validatedGradle: the repro fails 6 of 6 stock (assertions on and off); options A and B pass 163 nearby tests and the whole search package. A differential test fails on stock on 5 seeds and passes with both options.
Fact checks of this page and their resolution 13 errors raised, 12 fixed, 1 kept with reasonsAn independent fact check re-ran the repro, the fixes and the release sweep and found 5 wording errors; a cross-model fact check (OpenAI GPT-5.6) found 8 (2 high); a diagram check passed with 6 minor items. All wording errors were fixed and the visuals re-rendered. The reviewer's reasoned claim that the fixes miss two rarer encodings was executed and confirmed, and option A+ was added.
Correction to the registry row registry wording out of dateThe registry card below still carries wording this page corrects: its title, impact_note, priority_note and likelihood_note read as if any negative value plus pruning (or a searchAfter alone) gives wrong pages; its affected_versions calls the 4,096 docs on 10.3.x a skipper block (it is the first dense postings run); its upstream field counts 7 open PRs touching the files (there are 8) and 17 intended-behaviour check searches (16 query strings). The text of this page is the corrected version.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,404 words

Finding record

LFV-144, snapshot of 2026-11-06

Registry title
A FLOAT or DOUBLE sort on a NUMERIC doc-values field with a skip index and no points silently returns wrong or missing top hits when values have the sign bit set (negative values, or -0.0 with a tie-break sort): skipper pruning compares sortable-encoded bounds with the raw IEEE-bit block min/max
Area
search / sorting (NumericComparator doc-values-skipper pruning for FLOAT/DOUBLE)
Priority
Medium, rank 81: Once any value is negative, FLOAT/DOUBLE sorts deterministically and silently return wrong or empty top-N pages, but only on a hand-built raw-bit NUMERIC field with a skip index and no points (the layout of Lucene's own test, no known downstream user), and only since 10.3.
Impact class
silent wrong results
Impact note
Applications on 10.3.0-10.6.0 (10.6.0 by reading) or main that store floats or doubles in a single-valued NUMERIC doc-values field in FloatDocValuesField/DoubleDocValuesField's raw-bit encoding with a doc-values skip index and no points (NumericDocValuesField.indexedField or FieldType.setDocValuesSkipIndexType; the layout of Lucene's own TestSortOptimization#testFloatSortOptimizationSkpperIndex) and sort on it with SortField.Type.FLOAT/DOUBLE: once any value has the sign bit set and pruning engages (more matches than the default 1000-hit threshold, or searchAfter, with a query whose scorer consults the competitive iterator: TermQuery, conjunctions, multi-segment or index-sorted indices), top-N pages silently miss the smallest values or come back empty, with the default codec, IndexWriterConfig and searcher, -ea or -da, at about 10,000 docs; Lucene's FloatField/DoubleField (SORTED_NUMERIC, sortable bits), fields with points and fields without a skip index are not affected, and none of the major downstreams checked (Elasticsearch, OpenSearch, Solr) builds the affected layout.
Summary
You store float or double values as raw IEEE bits in a single-valued NUMERIC doc-values field with a skip index and no points (for example NumericDocValuesField.indexedField("f", Float.floatToRawIntBits(v)), the layout of Lucene's own skip-index sort test) and sort on it with SortField.Type.FLOAT or DOUBLE. You expect the same hits as on the same values without a skip index, lowest value first. Instead, with 10,000 docs holding 0, -1, ..., -9999 and a TermQuery that matches all of them, the ascending top 3 is [-8191, -8190, -8189] instead of [-9999, -9998, -9997] and a descending searchAfter(-9000) returns no hits, because the pruning compares bounds in NumericUtils sortable order with skipper block min/max in raw-bit order, and the two orders disagree for values with the sign bit set.
Introduced
9a7bcc9b590 on main / e2fdc61ad32 on branch_10x (apache/lucene#14672 'Dynamic pruning with DocValueSkipper', by gf2121, closes #14666) [read: git log -S 'getDocValuesSkipper(field)' and -S DVSkipperCompetitiveDISIBuilder on NumericComparator.java each return only 9a7bcc9b590; it added the doc-values-skipper pruning path for every numeric sort type and fed it the comparators' existing sortable bounds (FloatComparator/DoubleComparator bottomAsComparableLong/topAsComparableLong = NumericUtils.floatToSortableInt/doubleToSortableLong, from #13246, 2024-04-10, correct for the points path, where FloatPoint/DoublePoint are sortable-encoded); exec: the finder repro passes on 10.2.2 and fails on 10.3.0, and 10.3.0 with only NumericComparator.java swapped back to its pre-#14672 version (parent d176a42b659, via --shadow-src) passes 6/6; the commit pair was not built from source]; later refactors kept the mismatch: #15397/#15410, #15632 (block-only SkipBlockRangeIterator), #15663, #15757 (2025-06-06 (9a7bcc9b590 on main and e2fdc61ad32 on branch_10x))
First affected version
10.3.0 [exec]
Affected versions
10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main through e1faeba08bd [exec; on e1faeba08bd (2026-11-02) the minimal repro fails 1/1 and the finder class 6/6 with -ea and -da; earlier runs: the finder repro fails 6/6 on each with seed DEADBEEF, main also with -da; the independent reproduction, the cross-model review, the intended-behaviour check and the adjudication reproduced on main with their own tests, -ea and -da; the independent reproduction and the cross-model review also on 10.3.0 and 10.5.2, the intended-behaviour check on 10.5.2]; not affected: 10.0.0, 10.1.0, 10.2.0, 10.2.1 and 10.2.2 [exec: 6/6 pass; they have the skip index but no skipper-based sort pruning]; not applicable: 9.x [exec on 9.12.3: compile error, NumericDocValuesField.indexedField does not exist; read: DocValuesSkipper arrived in 10.0.0 with #13449]; 10.6.0 (released 2026-10-29) [exec 2026-11-02: minimal repro fails with -ea]; affected by reading: branch_10_6 and branch_11x (11.0, unreleased; its comparator, SortField and collector files are identical to main) [read 2026-11-02]; branch_10x was decommissioned on 2026-10-29 (#16911); the exact wrong hits differ by version (10.3.x scans only the first 4096-doc skipper block before the bad pruning, 10.4.0 and later the first 8192 docs) [exec]
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, code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record: 182 logged read-only queries over apache/lucene issues, PRs and review comments (REST and GraphQL search), git history of main and the release branches, the changed-file lists of all 413 open PRs (the patches of the 7 that touch NumericComparator.java, SortField.java, TestSortOptimization.java or TopFieldCollector.java grepped), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, the Elasticsearch, OpenSearch and Solr trackers, and the web; plus the finder's 45-query pass and the intended-behaviour check's 17 GitHub searches and 26-query pass; 40 hits read, none of them this bug); not fixed on upstream main, branch_10x, branch_10_6 or branch_10_5; open PR #16163 (builds the skipper iterator immediately for search_after) keeps the sortable-vs-raw comparison and would not fix it (diff read, not run); related prior items, a different bug (different code and fix): #16573 / #16629 (2026, range queries over float/double points + NumericDocValuesField, fixed in 10.5.2; maintainers state there that NUMERIC floats and doubles are not stored in sorted order, and romseygeek asked to check whether the assumption is made in other places, naming IndexSortSortedNumericDocValuesRangeQuery), #16698 / #16699 (open range-query follow-up), #749 (LUCENE-10466, 2022), #14666 / #14672 (introduced the code; float encoding not discussed in review), LUCENE-10106 / LUCENE-10126 (earlier competitive-iterator false negatives with unrelated causes)
Lucene commit tested
main ea57360912ed (commit 2026-10-24; tested 2026-10-26): the JUnit repro with gradle in a scratch worktree, stock (assertions on and off) and with two fixes, each with 12 nearby upstream test classes and the whole org.apache.lucene.search test package of :lucene:core (the worktree was removed after the runs); the repro through the repro runner with -ea and -da on main, and with -ea on the Maven Central jars of 9.12.3 and 10.0.0 to 10.5.2 (SHA-1 checked); the independent tests of AI verifiers, cross-model reviews and an adjudication; a randomized differential test and a probe for this page; after the fact checks, an edge-encoding test and a third fix (option A+) with gradle and the repro runner. On 2026-10-26 upstream main (later that day a9779ef3c19), branch_10x, branch_10_6 and branch_10_5 kept the same bounds encoding, raw decode and comparison (read, not run).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java:117-131 (buildCompetitiveDISIBuilder: skipper builder whenever the field has no points), :277-312 (encodeBottom/encodeTop from bottomAsComparableLong/topAsComparableLong), :507-532 (DVSkipperCompetitiveDISIBuilder, new SkipBlockRangeIterator(skipper, minValueAsLong, maxValueAsLong) at :530)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/comparators/FloatComparator.java:27 (based on Float#compare), :81 (Float.intBitsToFloat decode), :59, :111, :116 (NumericUtils.floatToSortableInt); DoubleComparator.java the same lines with Double / doubleToSortableLong
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/FloatDocValuesField.java:21, :40 (Float.floatToRawIntBits) and DoubleDocValuesField.java:21, :39 (Double.doubleToRawLongBits); NumericDocValuesField.java:50-60 (indexedField adds a skip index to a 64-bit long value)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/SortField.java:80-98 (FLOAT/DOUBLE: lower values first; not indexed or indexed with FloatPoint/DoublePoint), :612-658 (setOptimizeSortWithIndexedData deprecated, 8.x compatibility only)
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/search/TestSortOptimization.java:474-478 (testFloatSortOptimizationSkpperIndex: FloatDocValuesField.indexedField(field, Float.floatToRawIntBits(value)), non-negative values only), :1499-1520 (optimized hits must equal a linear scan)
  • a Lucene main checkout lucene/CHANGES.txt:1582 (10.3.0: GITHUB#14672 numeric sort queries can take advantage of DocValuesSkipper to dynamic prune)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java:117-131, :241-266, :277-314, :527-531; search/comparators/FloatComparator.java:58-60, :81, :110-117; search/comparators/DoubleComparator.java:59, :81, :111, :116; index/DocValuesSkipper.java:118-136; util/NumericUtils.java:84-91; document/FloatDocValuesField.java:19-21, :40; document/NumericDocValuesField.java:45-60; index/DocValuesSkipIndexType.java:28-31
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java:242-244; search/TopFieldCollector.java:83-88; util/NumericUtils.java:48, :71; codecs/lucene104/Lucene104PostingsFormat.java:348-351; codecs/lucene104/Lucene104PostingsReader.java:1100-1129; codecs/lucene90/Lucene90DocValuesFormat.java:226; index/DocValuesSkipper.java:127-133
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/FieldType.java:525-530
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/SortField.java:42-44, :82-86, :94-98, :608-629; search/comparators/FloatComparator.java:26-29, :81, :110-117
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestSortOptimization.java:474-478, :484-487, :1499-1520; lucene/CHANGES.txt:1582
History
  • Lucene main ea57360912ed: lucene/CHANGES.txt:1582; dev-tools/doap/lucene.rdf:115-116 (10.3.0 released 2025-09-13)

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

Repro files

About the diagrams

Every value on screen comes from a traced probe run on the main jars or from the code. Made in parallel with this page; the alt text follows the scene's summary card.

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.