All findings · Lucene finding · LFV-217 · joinutil-numeric-float-double-negative-sort-order

JoinUtil's numeric join on Float or Double throws 'values are out of order' when the from side has two or more distinct negative values

With float or double keys, JoinUtil's numeric join throws values are out of order as soon as the from side holds two different negative keys, say -1 and -2. It sorts the raw float bits as signed integers, which reverses negative numbers; the bug dates from 6.0.0.

Summary

Priority
Medium, rank 221Since 6.0.0 the documented Float/Double JoinUtil numeric join throws whenever the from side holds two distinct negative values, deterministic and loud with no wrong results, and an unreleased Solr change (SOLR-18440) will route float/double score joins there.
Impact
crash
Since
6.0.0 (LUCENE-7095, 2016), with the featureintroduced 2016-03-25 · over 10 years
Affected
6.0.0 to 10.5.2 and main
Status
documented
Found by
code reading of the numeric join pipeline

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

Two from documents with the float keys -1 and -2 are enough. The numeric join sorts the keys it collected and hands them to a point query that needs them in ascending order; for negative floats the sort gets the order backwards.

Summary card: JoinUtil's Float/Double join throws for two different negative from values. Example in one segment: from docs 0 and 1 hold FloatDocValuesField -1 and -2; to docs 2, 3 and 4 index FloatPoint -1, -2 and 3. Step 1: join = JoinUtil.createJoinQuery("fromF", false, "toF", Float.class, fromQuery, searcher, ScoreMode.None) collects the from values as raw float bits in a long: -1 is 0xBF800000 = -1082130432, -2 is 0xC0000000 = -1073741824. Step 2: Arrays.sort(joinValuesList.buffer, 0, joinValuesList.size()) (JoinUtil.java:318): signed order, -1082130432 < -1073741824, so -1 comes before -2, the reverse of numeric order. Step 3: FloatPoint.encodeDimension in that order: [40 7f ff ff] (-1), then [3f ff ff ff] (-2), into PointInSetQuery; points must ascend, the second is smaller, so the constructor throws inside createJoinQuery. Expected: searcher.count(join) = 2, doc 2 and doc 3. Actual: createJoinQuery throws IllegalArgumentException 'values are out of order: saw [40 7f ff ff] before [3f ff ff ff]'. The same with Double, in all five ScoreModes (the scoring ones through PointInSetIncludingScoreQuery), and for -0.0 and -1. Controls count 2: from values {1, 2}, one negative {-1, 2}, and Integer or Long joins with {-1, -2}. The fix, re-sorting by NumericUtils.floatToSortableInt / doubleToSortableLong: count 2 in all seven failing cases. Why: FloatDocValuesField stores the IEEE sign-and-magnitude bits (Float.floatToRawIntBits); sorted as signed longs (JoinUtil.java:316-318), the values with the sign bit set come out reversed, -0.0 first; the stream encodes the points in that order, and PointInSetQuery and PointInSetIncludingScoreQuery reject points that do not ascend. The javadoc lists floats with no sign restriction; Integer and Long keep their order. Affected: every release from 6.0.0 (the API's first) to 10.5.2, and main; with -ea and -da, default settings, any index size. Before 10.4.0 the visible exception is an UnsupportedOperationException, raised while that message is built. No wrong results.
The repro's five documents, step by step: JoinUtil collects the raw float bits, sorts them as signed integers so -1 comes before -2, and PointInSetQuery rejects points that do not ascend. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index two "from" documents with FloatDocValuesField("fromF", -1) and FloatDocValuesField("fromF", -2), and three "to" documents with FloatPoint("toF", ...) = -1, -2 and 3.
  2. You call JoinUtil.createJoinQuery("fromF", false, "toF", Float.class, fromQuery, searcher, ScoreMode.None) and expect a query that matches 2 documents: the "to" documents -1 and -2.
  3. JoinUtil collects the raw float bits, -1 = 0xBF800000 and -2 = 0xC0000000, and sorts them as signed integers: -1,082,130,432 comes before -1,073,741,824, so -1 comes before -2. For negative numbers that order is backwards.
  4. It hands the points over in that order, -1 ([40 7f ff ff]) then -2 ([3f ff ff ff]), to PointInSetQuery, which accepts only ascending points. createJoinQuery throws values are out of order: saw [40 7f ff ff] before [3f ff ff ff]; no query is built.
  5. The same with Double.class, in every ScoreMode, for multi-valued fields that store raw bits and for -0.0 next to a negative key, with assertions on or off. One negative key, positive keys and Integer/Long joins work.

Who is affected

Applications that call the numeric JoinUtil.createJoinQuery with Float.class or Double.class whose from query matches two different negative keys, or -0.0 and a negative key: temperatures, coordinates, signed scores. Default settings, any index size, every ScoreMode. With raw-bit keys every such join fails with an exception; none returns wrong results.

Mechanism, terms and numbers771 words

JoinUtil.createJoinQuery(fromField, multipleValuesPerDocument, toField, numericType, fromQuery, fromSearcher, scoreMode) is documented for int, long, float and double keys. For Float.class and Double.class it collects the from-side doc values, which are raw IEEE 754 bits when the from side uses FloatDocValuesField or DoubleDocValuesField (the bits JoinUtil decodes), sorts them as signed longs and streams them in that order into PointInSetQuery (ScoreMode.None) or PointInSetIncludingScoreQuery (scoring modes). Both constructors require the points in ascending order: PointInSetQuery drops equal neighbours, PointInSetIncludingScoreQuery rejects them. IEEE 754 is sign-magnitude, so for negative numbers the signed order of the bits is the reverse of the numeric order: -1 sorts before -2. As soon as the from query matches documents holding two different negative keys (or -0.0 and a negative key), createJoinQuery throws IllegalArgumentException: values are out of order instead of returning a query, in every ScoreMode, for single-valued from fields and for multi-valued ones that store raw bits (as upstream TestJoinUtil does; SortedNumericDocValuesField's javadoc recommends the sortable encoding instead), with assertions on or off, with default settings and at any index size. One negative key, positive keys and Integer/Long joins work, and with raw-bit from values no join returns wrong results. The bug arrived with the numeric point join in 6.0.0 (LUCENE-7095); releases before 10.4.0 show an UnsupportedOperationException raised while the same error message is built. Collecting the keys in their sortable form fixes it.

Terms

  • Numeric query-time join: JoinUtil.createJoinQuery(fromField, multipleValuesPerDocument, toField, numericType, fromQuery, fromSearcher, scoreMode) runs fromQuery, collects the join keys of the matching documents from the doc values of fromField, and returns a query that matches the documents whose point field toField holds one of those keys. numericType is Integer, Long, Float or Double.
  • Raw bits: FloatDocValuesField stores a float as Float.floatToRawIntBits(value) and DoubleDocValuesField a double as Double.doubleToRawLongBits(value): the IEEE 754 pattern, a sign bit followed by the magnitude.
  • Point encoding: FloatPoint and DoublePoint encode a value as bytes that sort in numeric order (NumericUtils.floatToSortableInt, which flips the magnitude bits of negative numbers).

The bug

  1. Collect. The collectors add each from-side doc value unchanged to a LongHashSet (JoinUtil.java:151, :239-240 multi-valued, :287-289 single-valued). For Float and Double that is the raw bit pattern.
  2. Sort. After fromSearcher.search(fromQuery, collector) (:314) the values are sorted as signed longs: Arrays.sort(joinValuesList.buffer, 0, joinValuesList.size()) (:316-318).
  3. Stream. The Float stream decodes each value with Float.intBitsToFloat and encodes it with FloatPoint.encodeDimension (:360-378); the Double stream does the same with Double.longBitsToDouble and DoublePoint.encodeDimension (:379-396).
  4. Build. The stream goes into PointInSetIncludingScoreQuery for the scoring modes (:406) or PointInSetQuery for ScoreMode.None (:415). PointInSetQuery says "The packedPoints iterator must be in sorted order" (PointInSetQuery.java:74) and throws values are out of order otherwise (:116-122); PointInSetIncludingScoreQuery has the same check and also rejects duplicates (PointInSetIncludingScoreQuery.java:124-133). Both are plain if/throw, so they fire with assertions off too.

Why the orders disagree

valueraw bitsraw bits as a signed integerFloatPoint bytes
-20xC0000000-1,073,741,8243f ff ff ff
-10xBF800000-1,082,130,43240 7f ff ff
-0.00x80000000-2,147,483,6487f ff ff ff
+0.00x00000000080 00 00 00
30x404000001,077,936,128c0 40 00 00

The signed sort gives -0.0, -1, -2, +0.0, 3; the point order is -2, -1, -0.0, +0.0, 3. Every non-NaN key with the sign bit set comes out reversed, so any two distinct ones throw. A single negative key is fine, because the negative block still comes before the non-negative block. Doubles behave the same with 64 bits. Integer and Long are two's complement, where signed order is numeric order. JoinUtil builds and sorts the stream itself; with the standard from-side encodings (raw bits, or the sortable encoding) the caller cannot make it ascend.

Edge cases found while validating the fix. A NaN whose sign bit is set (raw 0xFFC00000) sorts after every negative number, but FloatPoint encodes every NaN as the canonical NaN, the largest point; next to the key 1 it throws values are out of order: saw [ff c0 0 0] before [bf 80 0 0] (ScoreMode.None and Max). Separately, two different NaN bit patterns (0x7FC00000, 0x7FC00001) next to 1 make the scoring path throw unexpected duplicated value: [ff c0 0 0]. By reading only: a sign-bit NaN next to -1 or -0.0 does not throw (it is last in both orders), and two different sign-bit NaN patterns become one point, which ScoreMode.None drops and the scoring modes reject as a duplicate. All are rare inputs, and the recommended fix covers them.

Exposure and likelihood249 words
  • Who is hit. Applications that use the numeric JoinUtil.createJoinQuery overload (lucene-join module, @lucene.experimental) with Float.class or Double.class. Choosing that API is the only opt-in part: default codec and IndexWriterConfig, any index size (two from documents suffice), every ScoreMode, single-valued from fields and multi-valued ones that store raw bits.
  • When. Whenever the from query matches documents holding at least two distinct non-NaN keys with the sign bit set. That depends on the data: a join that works today starts failing the day a second negative key is indexed. It is deterministic.
  • What happens. createJoinQuery throws before any Query exists, so the request fails. With raw-bit from values: no wrong or partial results, no index damage. Releases 6.0.0 to 10.3.x throw an UnsupportedOperationException with no message, which is harder to diagnose.
  • Downstream (code reading, not run): Elasticsearch and OpenSearch call only the global-ordinals JoinUtil variant. Solr's score join ({!join score=...}) calls the numeric variant with Float.class/Double.class since SOLR-18440 (on Solr main and branch_10x since September 2026, not in a Solr release yet), and Solr's single-valued float/double point doc values store the IEEE bit layout (Float.floatToIntBits / Double.doubleToLongBits, the same as raw bits for every non-NaN value), so such a Solr join whose from side matches two distinct negative values should hit this error once that code ships.
  • How common. Float and double join keys are uncommon (in the original review they were described as allowed "strangely"), and no public report was found.

Minimal repro

The test shows that JoinUtil's numeric join on Float.class (ScoreMode.None) fails when the from side has the two negative values -1 and -2 (FloatDocValuesField).

TestNumericJoinNegativeFloats.java (JUnit test)59 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.join;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.FloatDocValuesField;
import org.apache.lucene.document.FloatPoint;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestNumericJoinNegativeFloats extends LuceneTestCase {

  public void testFloatJoinOnTwoNegativeValues() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      for (float v : new float[] {-1f, -2f}) {
        Document doc = new Document();
        doc.add(new FloatDocValuesField("from", v));
        doc.add(new FloatPoint("to", v));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query fromQuery = new MatchAllDocsQuery();
        Query join;
        try {
          join =
              JoinUtil.createJoinQuery(
                  "from", false, "to", Float.class, fromQuery, searcher, ScoreMode.None);
        } catch (IllegalArgumentException e) {
          // expected: a join query matching the 2 docs (to = -1 and -2); actual: it throws
          throw new AssertionError("expected a join on from-values {-1, -2}, but threw " + e, e);
        }
        assertEquals(2, searcher.count(join));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-217/, 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:$LUCENE/lucene/join/build/libs/lucene-join-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$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.join.TestNumericJoinNegativeFloats
Expected vs actual
Expected: a join query that counts the 2 documents with FloatPoint -1 and -2. Actual (-ea and -da): IllegalArgumentException values are out of order: saw [40 7f ff ff] before [3f ff ff ff].

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

More files, environment, full output74 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 a join on from-values {-1, -2}, but threw java.lang.IllegalArgumentException: values are out of order: saw [40 7f ff ff] before [3f ff ff ff]
       org.apache.lucene.search.join.TestNumericJoinNegativeFloats.testFloatJoinOnTwoNegativeValues(TestNumericJoinNegativeFloats.java:53)
     Caused by: java.lang.IllegalArgumentException: values are out of order: saw [40 7f ff ff] before [3f ff ff ff]
       org.apache.lucene.search.PointInSetQuery.<init>(PointInSetQuery.java:120)
       org.apache.lucene.search.join.JoinUtil$8.<init>(JoinUtil.java:415)
       org.apache.lucene.search.join.JoinUtil.createJoinQuery(JoinUtil.java:415)
       org.apache.lucene.search.join.TestNumericJoinNegativeFloats.testFloatJoinOnTwoNegativeValues(TestNumericJoinNegativeFloats.java:49)

Why it looks unintended

  • The javadoc lists Float and Double as supported key types, with no sign restriction; the original review (LUCENE-7095) questioned float keys, then documented them.
  • PointInSetQuery requires sorted points from its caller, and that caller is JoinUtil, which builds the stream itself.
  • Integer and Long joins with negative keys work; upstream tests use only non-negative keys.
The full argument205 words
  • The contract. The method javadoc (JoinUtil.java:122-123): "Method for query time joining for numeric fields. It supports multi- and single- values longs, ints, floats and longs." toField "should be IntPoint, LongPoint, FloatPoint or DoublePoint" (:131-132); numericType is "either java.lang.Integer, java.lang.Long, java.lang.Float or java.lang.Double" (:133-134). Nothing restricts the sign.
  • Deliberate support. In the LUCENE-7095 review (apache/lucene#8151, migrated from JIRA) @mikemccand wrote: "The javadocs state that only int and long are supported, yet float and double (strangely, but matching what the legacy numerics join supports?) are also allowed?" @martijnvg replied that they had updated the javadocs, which now list floats.
  • The precondition binds JoinUtil. "The packedPoints iterator must be in sorted order" (PointInSetQuery.java:74) is a precondition on the code that builds the stream. The exception reports an internal invariant, not anything the user passed.
  • Why tests never caught it. TestJoinUtil generates only non-negative link values: "positive nums makes it easier" (TestJoinUtil.java:1691), random.nextInt(0xFFFFFF) (:1696). The non-negative generator dates from LUCENE-5868 (bc51c86b4bb, 2015), an int/long-only join, before float keys existed; LUCENE-7571 (09b0eee5bbc, 2017) later capped it at 0xFFFFFF to avoid float precision loss and kept it non-negative.
The devil's-advocate check291 words

The strongest case for "intended": the JoinUtil javadoc does not say how float keys must be stored on the from side, and SortedNumericDocValuesField's javadoc says "if you want to encode doubles or floats with proper sort order, you will need to encode them with NumericUtils" (SortedNumericDocValuesField.java:33-37, example NumericUtils.floatToSortableInt(-5.3f)); FloatField and DoubleField store that sortable form. So raw bits may be the wrong encoding, at least for multi-valued fields, and the sortable one may have been meant.

Why it fails: JoinUtil decodes with Float.intBitsToFloat (JoinUtil.java:369), which reads raw bits; FloatDocValuesField writes raw bits; the introducing commit's own tests write raw bits, single- and multi-valued (TestJoinUtil.java:1924, :1932). And the sortable encoding fails too: two negative sortable keys throw the same exception, and a single one silently matches nothing (count 0 instead of 1). Neither standard encoding joins two negative floats (by reading only, a contrived custom encoding with the sortable key in the high 32 bits and the raw float bits in the low 32 bits would, because the Float decode reads only the low 32 bits; no Lucene field writes that). The other arguments fail as well: @lucene.experimental (JoinUtil.java:54) allows the API to change, not documented inputs to fail, and the checked input is the stream JoinUtil built, so this is not input validation. Every reviewer, including a cross-model review and a devil's-advocate check with a works-as-intended lens, rated the intended case weak; the cross-model fact check of this doc agrees for the single-valued fields and rates it moderate for a multi-valued raw-bit field, because of the SortedNumericDocValuesField javadoc: there the API is inconsistent whichever encoding is preferred, so the fix review should settle the supported encoding. Verdict: a defect.

Suggested fix

Recommended Collect the sortable form of Float/Double keys (assumes raw-bit from values)

Collect Float/Double keys in the sortable form the point encoding uses (NumericUtils.floatToSortableInt / doubleToSortableLong) instead of raw bits, so the existing primitive sort yields point order; decode with sortableIntToFloat / sortableLongToDouble. +19/-4 in JoinUtil.java. The repro passes 4 of 4, and the existing lucene/join tests pass (104 tests, 3 skipped). Assumes raw bits are the supported from-side encoding.

final LongUnaryOperator toJoinKey;
if (Float.class.equals(numericType)) {
  toJoinKey = v -> NumericUtils.floatToSortableInt(Float.intBitsToFloat((int) v));
} else if (Double.class.equals(numericType)) {
  toJoinKey = v -> NumericUtils.doubleToSortableLong(Double.longBitsToDouble(v));
} else {
  toJoinKey = v -> v;
}
// both collectors: long value = toJoinKey.applyAsLong(<doc value>);
// Float stream:  FloatPoint.encodeDimension(NumericUtils.sortableIntToFloat((int) value.value), ...)
// Double stream: DoublePoint.encodeDimension(NumericUtils.sortableLongToDouble(value.value), ...)
All options and trade-offs3 options ยท 773 words

Three ways to order the keys correctly; the recommended one was built and validated through gradle, the hunt's comparator variant through the standalone runner. Whichever is chosen, add negative float and double keys, including -0.0, to the join tests.

Collect the sortable form of Float/Double keys (assumes raw-bit from values) (validated by the checks) (recommended)

In createJoinQuery, map each collected Float or Double doc value to the key the point encoding sorts by (NumericUtils.floatToSortableInt(Float.intBitsToFloat((int) v)), NumericUtils.doubleToSortableLong(Double.longBitsToDouble(v))) before it enters the key set and the score maps. The existing primitive Arrays.sort then gives point order, and the streams decode with NumericUtils.sortableIntToFloat / sortableLongToDouble. Integer and Long are unchanged. 1 file, +19/-4.

final LongUnaryOperator toJoinKey;
if (Float.class.equals(numericType)) {
  toJoinKey = v -> NumericUtils.floatToSortableInt(Float.intBitsToFloat((int) v));
} else if (Double.class.equals(numericType)) {
  toJoinKey = v -> NumericUtils.doubleToSortableLong(Double.longBitsToDouble(v));
} else {
  toJoinKey = v -> v;
}
// both collectors: long value = toJoinKey.applyAsLong(<doc value>);
// Float stream:  FloatPoint.encodeDimension(NumericUtils.sortableIntToFloat((int) value.value), ...)
// Double stream: DoublePoint.encodeDimension(NumericUtils.sortableLongToDouble(value.value), ...)

Trade-offs

A few bit operations (through a LongUnaryOperator) per collected value, no per-value allocation or boxing, and the sort stays primitive. Because the key is exactly what FloatPoint/DoublePoint sort by, NaN keys behave like the point fields: every NaN bit pattern becomes the canonical NaN, so the NaN edge cases stop throwing, and the scores of different NaN bit patterns are aggregated together. It does not change which from-side encodings work, and for a sortable-encoded from side it turns the stock exception for two negative keys into a silent zero match (see the separate observation below), so settle the encoding question in the same review. No benchmark or allocation profile was run.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the repro fails 4 of 4 with assertions on and off; + fix it passes with assertions on and off. The wider test: stock 6 of 10 fail, fix 10 of 10 pass. The NaN probe: stock 3 of 4 fail, fix 4 of 4 pass.
  • With the fix: upstream TestJoinUtil 16 of 16; the whole lucene/join test task 19 classes, 122 tests, 3 skipped, 0 failures (the 16 existing classes: 104 tests); :lucene:join:check -x test (format, ecjLint, forbiddenApis) passes with the new tests in place.
  • Standalone runner with the fix: the finder's repro (assertions on and off), the independent reproducer's test and the cross-model reviewers' tests pass; the only remaining failures are the sortable-encoding cases of the separate observation, as with the hunt's comparator sketch.
  • fix.diff and fix-with-test.diff apply cleanly to a9779ef3c193. Not run: the full Lucene test suite, nightly mode and any benchmark.

Separate observation, not part of this bug. If the from side uses the sortable encoding (FloatField/DoubleField, or Solr's multi-valued float/double point fields), JoinUtil's raw-bit decode turns negative keys into different numbers and the join silently misses them: one negative FloatField key gives count 0 instead of 1 on stock main, and with this fix two negative FloatField keys give count 0 instead of 2 where stock throws. Which from-side encodings JoinUtil supports is undocumented, and Lucene points two ways: FloatDocValuesField/DoubleDocValuesField and TestJoinUtil use raw bits, while SortedNumericDocValuesField's javadoc (:33-37) tells callers to store floats and doubles in the sortable encoding. Ask the fix review to settle it (document raw bits as required, or support or reject the sortable encoding).

Remap only around the sort (not built)

Before Arrays.sort, replace each value by NumericUtils.sortableFloatBits((int) v) / sortableDoubleBits(v) (each is its own inverse), sort, and map back.

Trade-offs

The smallest diff, at one site. But it orders by the raw bits, not by the canonical key the encoder uses, so a NaN with the sign bit set still sorts first, encodes last and throws (reasoning from the stock NaN probe's encoding, not run); mapping a float back is exact only for int bits widened to a long, which is what FloatDocValuesField writes.

Re-sort with a comparator on the sortable key (the hunt's sketch) (validated by the checks)

After the primitive sort, box the values into a Long[], sort it with Comparator.comparingLong on floatToSortableInt(intBitsToFloat(...)) / doubleToSortableLong(longBitsToDouble(...)), and copy back.

Trade-offs

Correct order, and the collected values stay raw, but it boxes every distinct join value. By reasoning (not run), two different NaN bit patterns still give duplicate points, which the scoring path rejects.

What the checks validated

Through the standalone runner on main ea57360912ed: the finder's repro 4 of 4, the independent reproducer's test 10 of 10 and a cross-model reviewer's test 5 of 5 pass; upstream TestJoinUtil passes 16 of 16.

Downstream note

By code reading, Solr's not-yet-released numeric score join (SOLR-18440) sends float/double point-field joins down this path, while Elasticsearch and OpenSearch use only the global-ordinals join.

How it was found

AI-assisted code reading of the join module: it compared the order in which JoinUtil sorts the collected float and double keys with the order PointInSetQuery requires, then confirmed the mismatch with a public-API JUnit. No formal tool flagged it, and upstream tests use only non-negative keys.

The method in full125 words

Method: code reading (the hunt's non-formal tier), by the hunt unit for the join and block-join module, on main ea57360912ed (2026-10-26).

  • The unit traced the numeric JoinUtil pipeline: how the from-side values are collected, how they are sorted, and the order PointInSetQuery / PointInSetIncludingScoreQuery require. It noticed that Float/Double values are raw IEEE bits sorted as signed longs.
  • It wrote a public-API JUnit (two negative keys, Float and Double, with and without scores), which failed 4 of 4 on main with assertions on and off and on 10.5.2 and 9.12.3, and a fix sketch that made it pass.
  • The unit's formal and bounded-exhaustive tools (a block-join reference harness and SMT queries on ScoreMode semantics) do not cover this path, so they did not find it.

Prior reports, history and checks

Prior reports

No prior report found (107 queries, 2026-10-26: GitHub issues, PRs and commits, the Lucene and Solr lists, JIRA, downstream repositories and the web; 24 hits read). Nearest: #14161, the UnsupportedOperationException while building the same check's message, fixed in 10.4.0; it never mentions JoinUtil or negative keys.

Searches5 searches

No issue, PR, JIRA ticket, list thread, downstream report or web page found in these searches describes JoinUtil's Float/Double join failing on negative keys.

Closest, not this bug: #14161 ("UnsupportedOperationException instead of IllegalArgumentException from PointInSetQuery when values are out of order", fixed by #14676 in 10.4.0) is about how the message was built; it does not say what produced the out-of-order values and never mentions JoinUtil, floats or negatives. #8151 (LUCENE-7095) is where the code comes from. #13392 replaced the sorted set and kept the order. SOLR-18440 (apache/solr #4880, #4920) is a new downstream caller, not a report.

Not fixed upstream: main still sorts the raw bits (JoinUtil.java:316-318); the two open PRs that touch JoinUtil.java, #16805 ("Collector manager join") and #13747, leave the sort unchanged (2026-10-26).

Gaps: private security reports and chat channels cannot be searched; GitHub search reads the first page per query; broad list hits were screened by subject; the file lists of three open PRs (#365, #13949, #16847) stop at 100 files, so they may be cut off.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream repositories; dev@, java-user@, issues@ and commits@ lists; LUCENE JIRA archive; web2026-10-26a scripted prior-report search: 59 queries (22 GitHub, 3 commit, 2 downstream, 20 list, 6 JIRA, 6 web), 1181 distinct hits, every high and medium hit readonly the related items above
GitHub (apache/lucene, apache/solr, OpenSearch, Elasticsearch)2026-10-2620 manual searches, for example "JoinUtil" "FloatPoint", "floatToSortableInt" join, "numeric join" negative, "values are out of order"no report
Open apache/lucene PRs2026-10-26all 413 open PRs listed with their returned changed-file lists (three stop at 100 files and may be cut off) and filtered to lucene/join and the two point-set queries (15 PRs); every PR that changes JoinUtil.java read#16805 and #13747 touch JoinUtil.java and keep the sort
SOLR and LUCENE JIRA; Solr and Lucene lists2026-10-268 JIRA queries and 14 phrase or keyword list queriesno report; SOLR-18440 is a downstream caller
Git history2026-10-26git log -S on the sort line and on floatToSortableInt in lucene/join; the latest commits on the three files (main and branch_10x)introduced by 2babaf8c3d1, kept by f70999980c7; never fixed

History

Introduced with the Float/Double point join itself in 6.0.0 (LUCENE-7095, 2babaf8c3d1, 2016-03-25). It fails on every release tried from 6.0.0 to 10.5.2 and on main; before 10.4.0 it surfaces as an UnsupportedOperationException.

Versions tested6 rows

Introduced by LUCENE-7095 "Add point values support to the numeric field query time join" (@martijnvg, reviewed by @mikemccand; 2babaf8c3d1, 2016-03-25; JIRA patch era, migrated as apache/lucene#8151), backported the same day to branch_6_0 (5733f05c70d, in 6.0.0) and branch_6x (e26a4431942). It collected the keys into a TreeSet<Long> of raw bits, which iterates in signed order. Before it, the legacy numeric join accepted only INT and LONG.

Kept by f70999980c7 (#13392, 2024; on main, first released in 10.0.0; in 9.11.0 through the branch_9x backport 816db793428), which replaced the TreeSet<Long> with LongHashSet + Arrays.sort and kept the signed order. Visible exception: before 10.4.0 the out-of-order branch built its message by string concatenation on a BytesRefBuilder, whose hashCode() threw UnsupportedOperationException (#14161, fixed by #14676 in 10.4.0). All 83 release tags from 6.0.0 to 10.5.2 in the history clone have the signed raw-bit sort. The clone has no tags for 8.8.2 to 8.11.4; of those only 8.11.4 was run, so the eight releases 8.8.2 to 8.11.3 were neither read nor run (8.8.1 and 8.11.4 on either side are affected).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe repro 4 of 4 fail (gradle, assertions on and off; standalone runner); the wider test 6 of 10 fail
main ea57360912ed affectedthe hunt's runs: repro 4 of 4, wider test 6 of 10 (assertions on and off, three seeds); version-portable main() 4 of 4 bug cases
10.4.0, 10.5.2 affectedIllegalArgumentException "values are out of order"; 10.5.2 also with -da; controls pass
6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.3.2 UOE4 of 4 bug cases fail as UnsupportedOperationException from the same out-of-order branch; 9.12.3 also with -da; controls pass
5.5.0, 5.5.5 API absentno point fields and no Class overload; the legacy numeric join rejects FLOAT and DOUBLE
main + the fix not affectedrepro 4 of 4 and the wider test 10 of 10 pass

Independent checks

An independent reproducer, reviews by another model family (refute and works-as-intended lenses), a devil's-advocate check and an adjudication all found it real; their tests fail on main with assertions on and off and pass with the fix. A registry re-run and this doc's gradle runs reproduced it again.

Checks and verdicts469 words

Every executed check ran on the stock jars, with assertions on and off where noted. The hunt ran on main ea57360912ed; this doc's runs are on a9779ef3c193, where nothing relevant changed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateCode reading, then a public-API JUnit: 4 of 4 fail on main (-ea and -da), 10.5.2 and 9.12.3; a fix sketch passes 4 of 4.
cross-model review, refute lens real (high); intended argument weakOpenAI GPT-5.6, executed: own tests with keys -100 and -0.5 fail on main (2 of 3, and 4 of 5 with the scoring modes, -ea and -da); controls pass; the fix sketch passes 5 of 5 and upstream TestJoinUtil 16 of 16.
Independent reproducer realOwn public-API tests written from the claim: 6 of 10 fail with -ea and -da and two seeds (two-negative Float and Double, multi-valued, -0.0); 4 controls pass; 10.5.2 and 9.12.3 fail the same; the fix passes 10 of 10.
Cross-model review, works-as-intended lens real (medium); intended argument weakOpenAI GPT-5.6, reading only (its tool could not run Java). Its strongest point, @lucene.experimental, only warns that the API may change. Its own test, run by a separate re-run: 2 of 4 fail on main (-ea and -da), 4 of 4 pass with the fix.
Intended-behaviour devil's advocate realNo intended, documented, misuse or unreachable argument survives; float/double support was deliberately documented in the LUCENE-7095 review; its probe fails 3 of 6 as predicted (raw bits single- and multi-valued, and the sortable encoding).
adjudication confirmed (high), crashRe-ran the independent reproduction's tests with a fresh seed (6 of 10 fail, -ea and -da), the finder's repro (4 of 4 fail) and the independent reproduction's tests with the fix (10 of 10 pass).
registry and refresh re-runs still reproducesRegistration check: the repro fails 4 of 4 with -ea and -da and passes with the fix; the independent reproduction's tests fail 6 of 10 with a new seed. Main refresh: 4 of 4 fail on a9779ef3c193, messages byte-identical.
this doc's gradle runs reproduced; fix validatedStock a9779ef3c193: the repro 4 of 4 fail (assertions on and off), the wider test 6 of 10, the NaN probe 3 of 4. With the recommended fix: all pass, upstream TestJoinUtil 16 of 16, the lucene/join test task 122 tests with 0 failures, and :lucene:join:check -x test passes.
fact checks of this doc bug confirmed; wording correctedAn independent fact check (it re-ran the repro through gradle: 4 of 4 fail with assertions on and off, and pass with the fix), a cross-model fact check by OpenAI GPT-5.6 (reading only) and a diagram check found the bug, the repro and the fix sound and asked for precision fixes in the wording.

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

Finding record

LFV-217, snapshot of 2026-11-06

Registry title
JoinUtil's numeric join on Float or Double throws 'values are out of order' when the from side has two or more distinct negative values
Area
join / JoinUtil numeric (point-based) query-time join
Priority
Medium, rank 221: Since 6.0.0 the documented Float/Double JoinUtil numeric join throws whenever the from side holds two distinct negative values, deterministic and loud with no wrong results, and an unreleased Solr change (SOLR-18440) will route float/double score joins there.
Impact class
crash
Impact note
Applications that call the documented (@lucene.experimental) numeric join JoinUtil.createJoinQuery(..., Float.class or Double.class, ...) on from-side doc values written with FloatDocValuesField/DoubleDocValuesField (raw IEEE bits, the encoding JoinUtil decodes and the upstream tests use) get an exception instead of a query whenever the from query matches documents holding two or more distinct values with the sign bit set (any two different negative numbers, or -0.0 and a negative), in every ScoreMode, single- and multi-valued, with -ea and -da, the default codec and settings and any index size; one negative value, positive values and Integer/Long joins work, there are no wrong results and no index damage, and every release since 6.0.0 is affected.
Summary
You build JoinUtil.createJoinQuery("fromF", false, "toF", Float.class, fromQuery, searcher, ScoreMode.None) where the from documents store -1 and -2 in a FloatDocValuesField and the to documents index FloatPoint -1, -2 and 3, and expect a query that matches the two to documents with -1 and -2, because the javadoc lists Float and Double as supported numeric types with no sign restriction. Instead createJoinQuery throws IllegalArgumentException 'values are out of order: saw [40 7f ff ff] before [3f ff ff ff]' (that is, -1 before -2; releases before 10.4.0 show an UnsupportedOperationException raised while that message is built), and Double and every scoring ScoreMode fail the same way. The cause is that JoinUtil sorts the collected Float/Double join values as raw IEEE bits in signed-long order, which for negative numbers is the reverse of numeric order, and streams them in that order into PointInSetQuery / PointInSetIncludingScoreQuery, which require strictly ascending points.
Introduced
2babaf8c3d1710280ef53da3d7ba286615e7293e on master (LUCENE-7095 'Add point values support to the numeric field query time join', by martijnvg, reviewed by mikemccand; JIRA patch era, migrated as apache/lucene#8151), with same-day backports 5733f05c70d (branch_6_0, in 6.0.0) and e26a4431942 (branch_6x, from 6.1.0) [read: git history; exec: the follow-up history sweep shows the API absent in 5.5.0 and 5.5.5 and the bug present from 6.0.0]; it collected the values into a TreeSet<Long> of raw bits (signed-long order). Kept by f70999980c7 (apache/lucene#13392, 2024-05-21, LongHashSet + Arrays.sort, still signed-long order); apache/lucene#14676 (first released in 10.4.0) changed only the visible exception from UnsupportedOperationException to IllegalArgumentException. The defect was in the API's first version (2016-03-25)
First affected version
6.0.0 [exec]
Affected versions
every release from 6.0.0 to 10.5.2 and main (11.0.0-SNAPSHOT) through ea57360912ed [exec: 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3 (-ea and -da), 10.0.0, 10.3.2, 10.4.0, 10.5.2 (-ea and -da) and main (-ea and -da), 4 of 4 bug cases fail and 4 of 4 controls pass on each; read: all 83 release tags 6.0.0-10.5.2 in the history clone have the signed raw-bit sort and no sortable-bits conversion]; visible exception: UnsupportedOperationException from BytesRefBuilder.hashCode while the 'values are out of order' message is built on 6.0.0-10.3.x, IllegalArgumentException 'values are out of order' from 10.4.0 on; not applicable: 5.5.x [exec: the API and the point fields do not exist; the legacy numeric join rejects FLOAT and DOUBLE with 'Only INT and LONG are supported']
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
Upstream and prior reports
none found (2026-10-26, the prior-report record: 107 logged read-only query entries, 59 through a scripted prior-report search over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web, and 48 manual ones over apache/lucene, apache/solr, OpenSearch and Elasticsearch on GitHub, a listing of all 413 open apache/lucene PRs with their changed files, SOLR and LUCENE JIRA, the Lucene and Solr lists, the web and the git history of the sort line; 24 hits read, none of them this bug; the finder's and the intended-behaviour check's searches found nothing either); not fixed upstream (the tested main ea57360912ed still sorts the raw bits at JoinUtil.java:316-318; per the prior-report record the latest JoinUtil.java commits on main and branch_10x, through 2026-08-20, and the two open PRs that touch JoinUtil, #16805 'Collector manager join' and #13747, leave the sort line unchanged); nearest item, not a report of this bug: #14161 (UnsupportedOperationException instead of IllegalArgumentException from PointInSetQuery when values are out of order, fixed by #14676 in 10.4.0), a separate defect in how the error message is built, whose report does not say what produced the out-of-order values and does not mention JoinUtil, floats or negative values; origin: #8151 (LUCENE-7095, where the review asked why float and double were allowed and the javadoc was then updated to list them); downstream caller, not a report: SOLR-18440 (apache/solr #4880 and #4920, unreleased on Solr main and branch_10x) sends Solr float/double point-field score joins to this path [read, not executed]
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree, with assertions on and off, and through the standalone JUnit runner. The hunt and its reviews ran on main ea57360912ed; between the two commits nothing changed in lucene/join, PointInSetQuery, the float and double field classes or NumericUtils, and the finder's repro gives byte-identical failures on both. Release jars 10.5.2 and 9.12.3 (the repro), and a version-portable main() on 12 releases from 6.0.0 to 10.5.2 and on main, plus 5.5.0 and 5.5.5 (not applicable there). The recommended fix was validated in the scratch worktree: the repro with assertions on and off, the wider test, a NaN probe, upstream TestJoinUtil and the whole lucene/join test task (122 tests), plus :lucene:join:check -x test; the standalone runner ran every hunt test class with the fix in place.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:50-54 (@lucene.experimental), :121-140 (numeric join javadoc: 'supports multi- and single- values longs, ints, floats and longs'; toField 'IntPoint, LongPoint, FloatPoint or DoublePoint'; numericType 'either Integer, Long, Float or Double'), :314 (from-side search), :316-318 (LongArrayList of raw collected values, Arrays.sort as signed longs), :360-390 (Float.intBitsToFloat / Double.longBitsToDouble decode, FloatPoint/DoublePoint.encodeDimension in that order), :406 and :415 (point-set constructors in the return statements)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/PointInSetQuery.java:74 ('The packedPoints iterator must be in sorted order'), :116-122 (unconditional out-of-order IllegalArgumentException); lucene/join/src/java/org/apache/lucene/search/join/PointInSetIncludingScoreQuery.java:124-133 (unconditional duplicate and out-of-order checks); lucene/core/src/java/org/apache/lucene/document/FloatDocValuesField.java:40 and DoubleDocValuesField.java:39 (raw-bit encoding)
  • a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestJoinUtil.java:1691 ('positive nums makes it easier') and :1696 (random.nextInt(0xFFFFFF)), :1920-1934 (FloatDocValuesField/DoubleDocValuesField and raw-bit SortedNumericDocValuesField link fields)
The finding in 30 seconds
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:151, 239-240, 287-289, 314, 316-318, 360-396, 406, 415
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/PointInSetQuery.java:74, 116-122; lucene/join/src/java/org/apache/lucene/search/join/PointInSetIncludingScoreQuery.java:124-133
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/FloatDocValuesField.java:40, DoubleDocValuesField.java:39, FloatPoint.java:175-177; lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:47-91
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/SortedNumericDocValuesField.java:33-37 (javadoc: store floats and doubles in the sortable encoding)
Why it looks unintended
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:54, 122-134, 369
  • a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestJoinUtil.java:1690-1696, 1924, 1932, 1934
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/SortedNumericDocValuesField.java:33-37; FloatField.java:73, DoubleField.java:73 (the sortable encoding)

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

Repro files

About the diagrams

Made in parallel with this doc from an executed diagram probe on the same five documents (main with assertions on and off, 10.5.2, and the fix shadowed). Its fix line shows ordering by the sortable key, which the recommended fix and the hunt's comparator sketch share.

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.