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.
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.- You index two "from" documents with
FloatDocValuesField("fromF", -1)andFloatDocValuesField("fromF", -2), and three "to" documents withFloatPoint("toF", ...)= -1, -2 and 3. - 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. JoinUtilcollects the raw float bits, -1 =0xBF800000and -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.- It hands the points over in that order, -1 (
[40 7f ff ff]) then -2 ([3f ff ff ff]), toPointInSetQuery, which accepts only ascending points.createJoinQuerythrowsvalues are out of order: saw [40 7f ff ff] before [3f ff ff ff]; no query is built. - 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 andInteger/Longjoins work.
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.
- Numeric query-time join:
JoinUtil.createJoinQuery(fromField, multipleValuesPerDocument, toField, numericType, fromQuery, fromSearcher, scoreMode)runsfromQuery, collects the join keys of the matching documents from the doc values offromField, and returns a query that matches the documents whose point fieldtoFieldholds one of those keys.numericTypeisInteger,Long,FloatorDouble. - Raw bits:
FloatDocValuesFieldstores a float asFloat.floatToRawIntBits(value)andDoubleDocValuesFielda double asDouble.doubleToRawLongBits(value): the IEEE 754 pattern, a sign bit followed by the magnitude. - Point encoding:
FloatPointandDoublePointencode a value as bytes that sort in numeric order (NumericUtils.floatToSortableInt, which flips the magnitude bits of negative numbers).
- Collect. The collectors add each from-side doc value unchanged to a
LongHashSet(JoinUtil.java:151,:239-240multi-valued,:287-289single-valued). ForFloatandDoublethat is the raw bit pattern. - Sort. After
fromSearcher.search(fromQuery, collector)(:314) the values are sorted as signed longs:Arrays.sort(joinValuesList.buffer, 0, joinValuesList.size())(:316-318). - Stream. The Float stream decodes each value with
Float.intBitsToFloatand encodes it withFloatPoint.encodeDimension(:360-378); the Double stream does the same withDouble.longBitsToDoubleandDoublePoint.encodeDimension(:379-396). - Build. The stream goes into
PointInSetIncludingScoreQueryfor the scoring modes (:406) orPointInSetQueryfor ScoreMode.None (:415).PointInSetQuerysays "ThepackedPointsiterator must be in sorted order" (PointInSetQuery.java:74) and throwsvalues are out of orderotherwise (:116-122);PointInSetIncludingScoreQueryhas the same check and also rejects duplicates (PointInSetIncludingScoreQuery.java:124-133). Both are plainif/throw, so they fire with assertions off too.
| value | raw bits | raw bits as a signed integer | FloatPoint bytes |
|---|---|---|---|
| -2 | 0xC0000000 | -1,073,741,824 | 3f ff ff ff |
| -1 | 0xBF800000 | -1,082,130,432 | 40 7f ff ff |
| -0.0 | 0x80000000 | -2,147,483,648 | 7f ff ff ff |
| +0.0 | 0x00000000 | 0 | 80 00 00 00 |
| 3 | 0x40400000 | 1,077,936,128 | c0 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.createJoinQueryoverload (lucene-join module,@lucene.experimental) withFloat.classorDouble.class. Choosing that API is the only opt-in part: default codec andIndexWriterConfig, 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.
createJoinQuerythrows before anyQueryexists, 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 anUnsupportedOperationExceptionwith no message, which is harder to diagnose. - Downstream (code reading, not run): Elasticsearch and OpenSearch call only the global-ordinals
JoinUtilvariant. Solr's score join ({!join score=...}) calls the numeric variant withFloat.class/Double.classsince 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.
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).
/*
* 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));
}
}
}
}
# 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].
More files, environment, full output74 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- The javadoc lists
FloatandDoubleas supported key types, with no sign restriction; the original review (LUCENE-7095) questioned float keys, then documented them. PointInSetQueryrequires sorted points from its caller, and that caller isJoinUtil, which builds the stream itself.IntegerandLongjoins 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 beIntPoint,LongPoint,FloatPointorDoublePoint" (:131-132);numericTypeis "eitherjava.lang.Integer,java.lang.Long,java.lang.Floatorjava.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. "ThepackedPointsiterator 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.
TestJoinUtilgenerates 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 at0xFFFFFFto 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.
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
TestJoinUtil16 of 16; the wholelucene/jointest 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.diffandfix-with-test.diffapply cleanly toa9779ef3c193. 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).
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.
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.
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.
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
JoinUtilpipeline: how the from-side values are collected, how they are sorted, and the orderPointInSetQuery/PointInSetIncludingScoreQueryrequire. 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; downstream repositories; dev@, java-user@, issues@ and commits@ lists; LUCENE JIRA archive; web | 2026-10-26 | a 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 read | only the related items above |
| GitHub (apache/lucene, apache/solr, OpenSearch, Elasticsearch) | 2026-10-26 | 20 manual searches, for example "JoinUtil" "FloatPoint", "floatToSortableInt" join, "numeric join" negative, "values are out of order" | no report |
| Open apache/lucene PRs | 2026-10-26 | all 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 lists | 2026-10-26 | 8 JIRA queries and 14 phrase or keyword list queries | no report; SOLR-18440 is a downstream caller |
| Git history | 2026-10-26 | git 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 |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the repro 4 of 4 fail (gradle, assertions on and off; standalone runner); the wider test 6 of 10 fail |
main ea57360912ed | affected | the 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 | affected | IllegalArgumentException "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 | UOE | 4 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 absent | no point fields and no Class overload; the legacy numeric join rejects FLOAT and DOUBLE |
| main + the fix | not affected | repro 4 of 4 and the wider test 10 of 10 pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Code 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 weak | OpenAI 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 | real | Own 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 weak | OpenAI 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 | real | No 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), crash | Re-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 reproduces | Registration 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 validated | Stock 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 corrected | An 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. |
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.
- PNG 551 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,327 words
- 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 mainea57360912ed; between the two commits nothing changed inlucene/join,PointInSetQuery, the float and double field classes orNumericUtils, 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-portablemain()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, upstreamTestJoinUtiland the wholelucene/jointest task (122 tests), plus:lucene:join:check -x test; the standalone runner ran every hunt test class with the fix in place.
- 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, 415a 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-133a 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-91a 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, 369a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestJoinUtil.java:1690-1696, 1924, 1932, 1934a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/SortedNumericDocValuesField.java:33-37; FloatField.java:73, DoubleField.java:73 (the sortable encoding)
- TestNumericJoinNegativeFloats.java (JUnit test):
repro/LFV-217/src/org/apache/lucene/search/join/TestNumericJoinNegativeFloats.java(59 lines)
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.
assets/LFV-217/bug.png(551 KB)
![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.](../assets/LFV-217/bug.png)