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

Finding LFV-144Area: search / sorting (NumericComparator doc-values-skipper pruning for FLOAT/DOUBLE)Priority: mediumImpact: silent wrong resultsNovelty: new

Finding page for LFV-144

AI-assisted finding, confirmed by automated checks, not yet triaged by a human Lucene committer. This site files nothing upstream.

What it shows

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.

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

Expected vs actual

Expected the top 3 [-9999.0, -9998.0, -9997.0]; actual [-8191.0, -8190.0, -8189.0], with -ea and -da.

Files

Run it

# 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

Gradle alternative: copy the test into the matching module's src/test/ tree of the checkout and run ./gradlew -p lucene/<module> test --tests <class> -Ptests.seed=<seed> (add -Ptests.asserts=false for a run without assertions).

Feedback

Open an issue in lucene-ai-bug-hunt. Please do not open issues or pull requests in apache/lucene about this finding unless a Lucene committer agrees it should be filed.

Snapshot of 2026-11-04. AI-assisted; not yet triaged by a human Lucene committer. Feedback: open an issue in lucene-ai-bug-hunt.