DoubleField writes its doc values with NumericUtils.doubleToSortableLong, which flips every bit except the sign of a negative number. The join keeps those longs as they are and decodes them with Double.longBitsToDouble (Float.intBitsToFloat for floats). For a positive value both layouts are the same bits, so only keys with the sign bit set go wrong.
JoinUtil.createJoinQuery(fromField, true, toField, Double.class, fromQuery, fromSearcher, scoreMode)(modulelucene/join) runsfromQuery, collects every doc value offromFieldin the matching documents, and returns a query for the to documents whoseDoublePointtoFieldholds one of those values.- A double is 64 bits: one sign bit, then exponent and fraction. Read as a signed long, these raw IEEE bits sort positive numbers correctly but negative numbers backwards.
- So
NumericUtils.doubleToSortableLongflips all bits but the sign bit of a negative number; then the longs sort like the doubles.DoubleFieldstores its doc values this way, andNumericUtils.sortableLongToDoubleturns them back.DoubleDocValuesFieldstores raw bits instead.
DoubleField value is stored in the sortable bit layout and read back by the join as a different number, so the lookup misses the document. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document with
DoubleField("v", -5.0)and join the field to itself:JoinUtil.createJoinQuery("v", true, "v", Double.class, new MatchAllDocsQuery(), searcher, ScoreMode.None). For aDoubleField,trueis the only flag that does not throw. - You expect 1 hit: the document's from value -5.0 equals its own point -5.0.
- You get 0 hits, with no error, with assertions on and off.
- Why:
DoubleFieldstores -5.0 in the sortable layout,0xbfebffffffffffff. The join reads those bits as a plain double, -0.8749999999999999, and looks that up. - Positive values work, because both layouts are the same bits for them.
FloatFieldfails the same way, and two or more distinct negative normal keys such as -5.0 and -2.0 throwvalues are out of orderinstead.
Applications that join with createJoinQuery(from, true, to, Double.class or Float.class, ...) from a core DoubleField or FloatField, the standard double and float fields since 9.5.0, and whose join keys can be negative, such as signed scores or coordinates. Default codec and configuration, any index size, every release since 9.5.0; the join API itself is opt-in and experimental.
Mechanism, terms and numbers550 words
JoinUtil.createJoinQuery(fromField, multipleValuesPerDocument, toField, numericType, ...), the point-based numeric join, collects the from documents' doc values and looks them up as points on the to field. Its javadoc lists floats and doubles and allows SORTED_NUMERIC from fields, and names no encoding. Core's DoubleField and FloatField (since 9.5.0) write SORTED_NUMERIC doc values in the NumericUtils sortable layout, but the join decodes the collected longs as raw IEEE bits (Double.longBitsToDouble, Float.intBitsToFloat). The two layouts agree exactly when the sign bit is clear, so positive keys work and every key with the sign bit set is looked up as a different value: -5.0 is looked up as -0.8749999999999999, so the repro's self-join silently returns nothing; with two or more distinct negative normal keys (e.g. -5.0 and -2.0) it throws values are out of order (the related LFV-217 symptom). multipleValuesPerDocument=false throws for these fields, so no setting avoids it. Every release from 9.5.0 to 10.6.0 and main are affected. Decoding by the from field's doc-values type (SORTED_NUMERIC sortable, NUMERIC raw) fixes it, keeps raw-bit NUMERIC fields working and also fixes LFV-217 for NUMERIC fields and for DoubleField/FloatField.
- What the field writes.
DoubleFieldsetsDocValuesType.SORTED_NUMERIC(document/DoubleField.java:52) and storesNumericUtils.doubleToSortableLong(value)(:73, and:105in the setter);FloatFieldthe same withfloatToSortableInt(FloatField.java:52,:73,:105). - What the join collects. With
multipleValuesPerDocumenttrue (JoinUtil.java:228) the collector readsDocValues.getSortedNumeric(:250) and adds eachnextValue()unchanged (:239-240). The single-valued branch readsDocValues.getNumeric(:297), which throwsunexpected docvalues type SORTED_NUMERICfor these fields. - The sort. The collected longs are sorted as signed longs (
:316-318), then streamed into a point-set query that needs ascending points. - The decode (fix site). Float stream:
FloatPoint.encodeDimension(Float.intBitsToFloat((int) value.value), ...)(:368-369); Double stream:DoublePoint.encodeDimension(Double.longBitsToDouble(value.value), ...)(:387). That is the inverse of whatDoubleDocValuesField/FloatDocValuesFieldwrite (raw bits,DoubleDocValuesField.java:39,FloatDocValuesField.java:40), not of whatDoubleField/FloatFieldwrite.
-5.0 has raw bits 0xc014000000000000 and sortable bits 0xbfebffffffffffff; read as raw bits, 0xbfebffffffffffff is -0.8749999999999999, the point the join looks up. An independent test with -2.5 prints the query as join:{-1.7499999999999998}. A Z3 bit-vector model shows that the raw decode of the sortable layout gives back the original value exactly when the sign bit is clear, at 64 and 32 bits.
- One distinct negative from value: 0 hits, no error (
DoubleFieldandFloatField). - Two or more distinct negative normal from values (e.g. -5.0 and -2.0):
IllegalArgumentException: values are out of order(the LFV-217 symptom, reached through this decode). -0.0 and the negative subnormal -4.9E-324 are looked up as NaN instead: with -5.0 they give 0 hits and no exception. - Positive values, Integer and Long joins, and raw-bit fields (
DoubleDocValuesField, a hand-written raw-bitSortedNumericDocValuesField) with one negative value: correct.
Wrong extra matches are possible but need a to document whose point equals the mis-decoded number: only this doc's fact check ran it (a from DoubleField -5.0 joins to a to DoublePoint -0.8749999999999999, count 1; 0 with the fix). Not part of this finding: the facet module's MultiDoubleValuesSource.fromDoubleField / fromFloatField and misc's DocValuesStats.SortedDoubleDocValuesStats, which read these fields the same way (other modules and fix sites).
Exposure and likelihood162 words
- Who is hit. Callers of the numeric join with
Double.classorFloat.classwhose from field is aDoubleFieldorFloatField, with negative join keys (signed scores, coordinates, temperatures, offsets). - Default vs opt-in. Default codec,
IndexWriterConfigand searcher; one document suffices. The API is opt-in: thelucene/joinmodule,@lucene.experimental(JoinUtil.java:54), and Float/Double join keys are uncommon. - No setting avoids it. For these fields
multipleValuesPerDocument=trueis the only value that does not throw. - Silent, and hidden by positive data. One distinct negative key: 0 hits, no exception. Positive keys behave, so tests with positive data, like upstream
TestJoinUtil, never see it. Two or more distinct negative normal keys (e.g. -5.0 and -2.0): anIllegalArgumentExceptioninstead. - Downstream (read, not run). Solr 10.1.0 (released 2026-10-27; SOLR-18440) routes multi-valued float/double score joins to this method, and Solr writes those doc values in the sortable layout.
- Likelihood: plausible. Deterministic on the first negative key once the setup is used; the setup itself is rare.
One JUnit test for lucene/join, public API only: one document with DoubleField("v", -5.0), a self-join with Double.class, and a count. Fixed input, single thread; fails with assertions on and off (seeds DEADBEEF and 5EED1111).
/*
* 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.DoubleField;
import org.apache.lucene.document.Field;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestJoinNegativeDoubleField extends LuceneTestCase {
public void testJoinOnNegativeDoubleField() throws Exception {
try (Directory dir = newDirectory()) {
try (IndexWriter w = new IndexWriter(dir, newIndexWriterConfig())) {
Document doc = new Document();
doc.add(new DoubleField("v", -5.0, Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
Query join =
JoinUtil.createJoinQuery(
"v", true, "v", Double.class, new MatchAllDocsQuery(), searcher, ScoreMode.None);
// expected: the document joins to itself (1 hit); actual: 0 hits
assertEquals(1, searcher.count(join));
}
}
}
}
cp $REPRO/TestJoinNegativeDoubleField.java $LUCENE/lucene/join/src/test/org/apache/lucene/search/join/
cd $LUCENE && ./gradlew :lucene:join:test --tests TestJoinNegativeDoubleField -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false; this test failed at both seeds run (DEADBEEF, 5EED1111)
- Expected
- 1 hit: the document's from value -5.0 matches its own point -5.0.
- Observed
- 0 hits, no error:
AssertionError: expected:<1> but was:<0>, with assertions on and off.
More files, environment, full output214 words
The test is the hunt's minimal repro, unchanged; it passes :lucene:join:check -x test (google-java-format, ecj lint, forbidden APIs). The from and to sides are the same field of the same index, which keeps the test to one document; the hunt's and an independent verifier's two-index tests fail the same way and are evidence only.
Executed: gradle on main e1faeba08bde, assertions on (seed DEADBEEF) and off (seed 5EED1111): fails 1 of 1; with the fix it passes. The hunt's standalone runs of this test fail the same way on main with assertions on and off (seed DEADBEEF); the registry re-runs (seeds 5EED0E34, 5EED0D35) ran the finder's two-index test and fail too. newDirectory() and newIndexWriterConfig() randomize only the directory and writer settings; the mis-decode does not depend on them.
Use the same test as the regression test.
| lucene | main e1faeba08bde (12.0.0-SNAPSHOT) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (only this platform was run) |
cp $REPRO/TestJoinNegativeDoubleField.java $LUCENE/lucene/join/src/test/org/apache/lucene/search/join/
cd $LUCENE && ./gradlew :lucene:join:test --tests TestJoinNegativeDoubleField -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false; this test failed at both seeds run (DEADBEEF, 5EED1111)
TestJoinNegativeDoubleField > testJoinOnNegativeDoubleField FAILED
java.lang.AssertionError: expected:<1> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:9B27BB8BCEE348AB]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:647)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:633)
at org.apache.lucene.search.join.TestJoinNegativeDoubleField.testJoinOnNegativeDoubleField(TestJoinNegativeDoubleField.java:45)
1 test completed, 1 failed- The javadoc lists floats, doubles and SORTED_NUMERIC from fields and names no encoding; for a
DoubleFieldthe only accepted flag silently drops negative keys. - Core's documented SORTED_NUMERIC float/double recipe is sortable, and so is every other reader in core and the join module, including
ToParentBlockJoinSortField. TestJoinUtil's raw-bit values are all non-negative, where both layouts are identical.
The full argument253 words
- Contract. "It supports multi- and single- values longs, ints, floats and longs" and "when true fromField might be
SORTED_NUMERIC" (JoinUtil.java:122-130); the to field is aFloatPoint/DoublePoint(:131-134). No encoding is named inJoinUtil, the join package docs, CHANGES or MIGRATE. - Core's recipe. "Note that if you want to encode doubles or floats with proper sort order, you will need to encode them with
NumericUtils" (SortedNumericDocValuesField.java:33-37). - Other readers.
SortedNumericSelector("undo the numericutils sortability",:74-89, used bySortedNumericSortField) and the join module's ownToParentBlockJoinSortField(:319-320,:358-359) decode SORTED_NUMERIC floats/doubles as sortable. That holds for core and the join module; two readers in other modules (facet, misc) decode raw bits, see the devil's advocate. - The join's own sort.
JoinUtil.java:316-318sorts the collected values as signed longs for a point-set query that needs ascending points: numeric order only for sortable bits. - Tests.
TestJoinUtilwrites multi-valued FLOAT/DOUBLE link fields as raw bits (:1920-1928), but generates only non-negative values ("positive nums makes it easier",:1690-1696). It passes 16 of 16 with either decode. No join test usesDoubleFieldorFloatField. - History. The raw decode is from LUCENE-7095 (2016, 6.0.0).
DoubleField/FloatFieldcame with #11997 (9.5.0), which did not touchJoinUtil; there jpountz wrote that multi-valued floats and doubles "should useNumericUtil#doubleToSortableLong", and the fields were made SORTED_NUMERIC-only so queries "do not have to specify whether floats/doubles are indexed via their raw bits or as sortable ints/longs".
The devil's-advocate check223 words
The strongest "intended" case (built by the devil's-advocate verifier and the cross-model reviews): LUCENE-7095 paired the raw decode with raw-writing tests; DoubleDocValuesField/FloatDocValuesField write raw bits, and the multi-valued flag also reads NUMERIC fields ("single val can be handled by multiple-vals", TestJoinUtil.java:1535), so one raw convention for both paths; the sortable recipe is conditional ("if you want ... proper sort order") and SORTED_NUMERIC has no encoding metadata; the facet module's MultiDoubleValuesSource.fromDoubleField and misc's DocValuesStats make the same raw assumption; JoinUtil is @lucene.experimental, and no public report of it was found in the ~3.8 years since 9.5.0. A maintainer could answer "document that the from field must hold raw bits".
Why it fails: nothing in JoinUtil says raw bits or excludes DoubleField/FloatField; core's recipe and every SORTED_NUMERIC float/double reader in core and in the join module are sortable, and #11997 made the new fields sortable-only on purpose. A raw-bit convention covering negatives never worked: with raw bits two distinct negatives already throw (LFV-217), and its only test uses non-negative values. The only accepted flag for core's standard double field silently drops every negative key. And a compatible fix exists: the DocValuesType-keyed decode keeps raw-bit NUMERIC fields working. Verdict: not intended, a defect; the residual risk that maintainers prefer documenting a raw-bit precondition is why priority stays Low.
Recommended Decode by the from field's doc-values type (fix A)
Decode by the from field's doc-values type: keep SORTED_NUMERIC values (what DoubleField/FloatField write) as sortable bits, convert NUMERIC values (DoubleDocValuesField/FloatDocValuesField, raw bits) to sortable when collected, and decode sortable in both streams. The repro, the whole lucene/join test task and :lucene:join:check -x test pass; it also fixes the related out-of-order exception for NUMERIC fields and for DoubleField/FloatField.
// multi-valued collector, per segment:
FieldInfo fi = context.reader().getFieldInfos().fieldInfo(fromField);
rawBits = (floatJoin || doubleJoin) && fi != null
&& fi.getDocValuesType() == DocValuesType.NUMERIC;
// per collected value (unconditionally for Float/Double in the single-valued collector):
if (rawBits) value = floatJoin ? NumericUtils.sortableFloatBits((int) value)
: NumericUtils.sortableDoubleBits(value);
// Float and Double streams:
FloatPoint.encodeDimension(NumericUtils.sortableIntToFloat((int) value.value), encoded.bytes, 0);
DoublePoint.encodeDimension(NumericUtils.sortableLongToDouble(value.value), encoded.bytes, 0);All options and trade-offs4 options ยท 657 words
One fix was built and validated in a scratch worktree of e1faeba08bde; the alternatives are listed with their trade-offs. LFV-217 is a sibling: the recommended fix fixes this bug and LFV-217's recorded repro (a NUMERIC FloatDocValuesField), while LFV-217's own proposed fix, applied alone, would leave this bug in place. Neither fixes raw bits hand-written into SORTED_NUMERIC (see the trade-offs).
In JoinUtil.createJoinQuery's numeric path (+26/-2): per segment, the multi-valued collector checks the from field's DocValuesType; NUMERIC values (raw bits) are converted with NumericUtils.sortableFloatBits / sortableDoubleBits, SORTED_NUMERIC values are kept. The single-valued collector (always NUMERIC) converts Float/Double values the same way. The streams decode with sortableIntToFloat / sortableLongToDouble. Every collected value is then sortable, so the existing signed-long sort is numeric order (which also fixes LFV-217 for NUMERIC fields and for DoubleField/FloatField, not for raw bits hand-written into SORTED_NUMERIC). Integer and Long joins are untouched. The full patch is in the brief.
// multi-valued collector, per segment:
FieldInfo fi = context.reader().getFieldInfos().fieldInfo(fromField);
rawBits = (floatJoin || doubleJoin) && fi != null
&& fi.getDocValuesType() == DocValuesType.NUMERIC;
// per collected value (unconditionally for Float/Double in the single-valued collector):
if (rawBits) value = floatJoin ? NumericUtils.sortableFloatBits((int) value)
: NumericUtils.sortableDoubleBits(value);
// Float and Double streams:
FloatPoint.encodeDimension(NumericUtils.sortableIntToFloat((int) value.value), encoded.bytes, 0);
DoublePoint.encodeDimension(NumericUtils.sortableLongToDouble(value.value), encoded.bytes, 0);Trade-offs
- Behaviour change.
DocValuesTypeis not an encoding tag: the fix assumes NUMERIC holds raw bits and SORTED_NUMERIC sortable bits, the conventional mapping. An application that hand-wrote raw IEEE bits of negative values into aSortedNumericDocValuesField(against that class's sortable recipe) gets 0 hits instead of 1 for a single such key, and a silent 0 instead of today'svalues are out of orderexception for two or more distinct such keys (both executed). Raw-bit NUMERIC fields keep working, single- and multi-valued. Needs a CHANGES/MIGRATE note, or an explicit encoding parameter if results must not change; backporting is a maintainer call. TestJoinUtil's multi-valued FLOAT/DOUBLE link fields still write raw bits; they pass because the values are non-negative. A maintainer may want them switched to the sortable layout.- Cost: one
FieldInfolookup per segment and a few bit operations per collected Float/Double value. No benchmark was run.
What the checks validated
- Gradle, JDK 25, scratch worktree of
e1faeba08bde(removed afterwards): the test fails on stock with assertions on (DEADBEEF) and off (5EED1111), and passes with the fix, on and off. - With the fix: the test plus
TestJoinUtil, 17 tests, 0 failures; the whole:lucene:join:test, 105 tests in 17 classes, 3 skipped, 0 failures;:lucene:join:check -x testpasses. - The hunt, with the same logic: the two-index repro, a
DoubleDocValuesFieldprobe withmultipleValuesPerDocument=trueand -5.0 (1 hit, as on stock), LFV-217's repro and upstreamTestJoinUtil16 of 16 all pass. - Not shown: compatibility for arbitrary raw-bit SORTED_NUMERIC data (the existing tests use only non-negative raw bits); the behaviour change above is the known difference.
The hunt's first sketch: switch only the multi-valued Float/Double decode to sortableIntToFloat / sortableLongToDouble.
Trade-offs
Not recommended: the flag does not say which encoding is stored. A DoubleDocValuesField (NUMERIC, raw bits) joined with multipleValuesPerDocument=true and -5.0 goes from 1 hit to 0, and so does a raw-bit SORTED_NUMERIC -6.25 control (both executed).
State that the numeric join's from field must hold raw IEEE bits and that DoubleField/FloatField are not supported (possibly rejecting them).
Trade-offs
No behaviour change, but core's standard double and float fields could then not be joined through Double.class/Float.class at all, against core's own SORTED_NUMERIC recipe. Not validated.
A new overload that takes the from-side encoding, for a release line where results must not change (suggested by a cross-model reviewer and the finder).
Trade-offs
No change for existing callers; more API surface. Not built.
Downstream, Solr 10.1.0 routes multi-valued float and double score joins, whose doc values Solr writes in the sortable layout, to this method (read in Solr's code, not run).
A probe that checked whether one number decodes the same under different doc-values encodings found it: a join from a DoubleField or FloatField of -5 found 0 of 1 documents, while a LongField control and +5 found 1. A skeptic re-ran it, the hunt unit that judged it reduced it to one test, and a Z3 model proved which values are affected.
The method in full183 words
Method: non-formal (an executed differential probe; the Z3 bit-vector model only proved the trigger set afterwards).
- Round 22 of the hunt planned heterogeneous-schema probes with the property "one logical type stored with different doc-values encodings must decode to the same value on every consumer". Its own probe (2026-11-02, main
e1faeba08bde, assertions on and off, also 10.6.0) joined fromDoubleField,FloatFieldand aLongFieldcontrol: negative keys gave 0, 0 and 1 hits, positive keys 1, 1 and 1. - The round's skeptic re-ran it. The hunt unit that judged it reduced it to a public-API JUnit, modelled the trigger set in Z3 (the raw decode of the sortable layout is the identity exactly when the sign bit is clear) and checked its independence from LFV-217 both ways; a history step bisected it to #11997.
- An independent reproducer, cross-model reviews (OpenAI GPT-5.6), a devil's-advocate verifier, an adjudication and an audit then re-ran it, and a last check reduced the test to one document.
- An earlier sighting (2026-10-26, during LFV-217's verification: one negative
FloatFieldkey gave 0) was noted but not followed up then.
No public report found (2026-11-02): apache/lucene issues and PRs incl. the migrated JIRA archive, the changed files of all 408 open PRs, LUCENE and SOLR JIRA, the Lucene and Solr lists, Solr, Elasticsearch, OpenSearch and the web. Nearest: #11997, which added the fields, and #8151 (LUCENE-7095), which added the raw decode; neither discusses the other.
Searches4 searches
None of the hits is the same bug. Not fixed upstream: the last main commit to JoinUtil.java is e40b4b188e1 (#16091, 2026-08-20), already in e1faeba08bde; upstream main head 5adff78bb16, branch_11x and branch_10_6 still have both raw decodes; the open PRs that touch the file (#16805, #13747) leave the decode unchanged. LFV-217 and LFV-303 are the hunt's own related findings, not prior art. Search limits: keyword search only; GitHub search reads the first page per query; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; apache/solr, Elasticsearch, OpenSearch, lucenenet, all of GitHub) | 2026-11-02 | three scripted prior-report searches (45, 51 and 40 queries; 408 hits in the follow-up search, none rated highly relevant); 17 manual apache/lucene issue/PR and 3 commit queries incl. recent windows; 10 downstream queries; 5 more queries in a same-day re-check | no report; no highly relevant hit |
| Open PRs and upstream branches | 2026-11-02 | the changed files of all 408 open apache/lucene PRs; JoinUtil.java history on main, branch_11x and branch_10_6 | #16805 and #13747 touch JoinUtil.java but not the decode; not fixed on any branch |
| JIRA, mailing lists and the web | 2026-11-02 | 8 SOLR and LUCENE JQL queries; 13 queries over dev@, java-user@, issues@ (Lucene) and users@, dev@ (Solr); 7 web queries | no report |
| Nearest items (read) | 2026-11-02 | #11997 body and comments, #8151 (LUCENE-7095), #14161, SOLR-18440, SOLR-18441 | the change that added the fields (sortable encoding discussed, JoinUtil never mentioned); the origin of the raw decode (encoding not discussed); LFV-217's exception site; a new downstream caller with no negative or sortable discussion |
Reachable since 9.5.0, when #11997 added DoubleField/FloatField with sortable SORTED_NUMERIC doc values; the raw decode dates from LUCENE-7095 (6.0.0) and is unchanged. Fails on main, 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.6.0 and 9.5.0, and on 9.4.2 with only #11997's DoubleField.java added. Not fixed upstream.
Versions tested7 rows
Introduced by #11997 "Add IntField, LongField, FloatField and DoubleField" (fcofdez, merged by jpountz, approved by rmuir; 57201aa9678 on main, 827f762d137 on branch_9x, author date 2022-12-20, first released in 9.5.0). It added DoubleField/FloatField with sortable SORTED_NUMERIC doc values and did not touch JoinUtil. The raw decode it collides with is 2babaf8c3d1 (LUCENE-7095, migrated as #8151, Martijn van Groningen, 2016-03-25, shipped in 6.0.0 via 5733f05c70d), unchanged in meaning since. So it is an interaction bug, not a regression of the join code: JoinUtil.java is byte-identical in 9.4.2 and 9.5.0. All 34 tags from 9.0.0 to 10.6.0 have the raw decode, and every tag from 9.5.0 has the sortable fields. Background, not a claimed range: a from field hand-written with the SortedNumericDocValuesField sortable recipe is mis-decoded the same way since 6.0.0.
| Version or commit | Verdict | Result |
|---|---|---|
main e1faeba08bde | affected | this doc's test fails through gradle with assertions on and off; passes with fix A |
| 10.6.0 | affected | fails with assertions on and off (release jars) |
| 10.5.2, 10.0.0, 9.12.3, 9.6.0 | affected | fails, expected:<1> but was:<0> |
| 9.5.0 | first release | fails (9.x constructor port) |
| 9.4.2 | last before | no DoubleField: the test does not compile |
9.4.2 + only DoubleField.java from 827f762d137 | bisect | fails like 9.5.0: the new field alone makes the old decode reachable |
6.0.0, 9.4.2 with a hand-written sortable SortedNumericDocValuesField | background | -5.0 fails, +5.0 passes; not part of the claimed range |
An independent reproducer, cross-model reviews (OpenAI GPT-5.6, executed), a devil's-advocate verifier, an adjudication, an audit and a last minimal-repro check reproduced it and agree on the root cause; the refute-lens review rated the bug "uncertain" and the intended-behaviour argument strong. This doc's gradle runs reproduced it on main and validated the fix.
Checks and verdicts444 words
Every executed check saw 0 hits for a negative DoubleField/FloatField key from the same decode lines, JoinUtil.java:368-369 and :387. The GPT reviews ran on the earlier main checkout ea57360912ed, whose JoinUtil.java and DoubleField.java are byte-identical to e1faeba08bde's.
| Check | Verdict | What it found |
|---|---|---|
| first signal and skeptic (round 22) | missed matches | Public-API differential probe on main (on, off) and 10.6.0: negative DoubleField/FloatField join 0 hits, LongField control 1; positive 1. The skeptic re-ran it with the same result. |
| finder | reproduced | Two-index JUnit fails on main (on, off), 10.6.0, 9.12.3 and 9.5.0; passes with a sortable decode; TestJoinUtil 16 of 16 either way; Z3 trigger-set model; fix sites independent of LFV-217 both ways. |
| cross-model review, intended lens | real (high) | OpenAI GPT-5.6, executed: its own JUnit gives 0 for a negative DoubleField, 1 for the positive, raw-bit SORTED_NUMERIC and DoubleDocValuesField controls; also fails on 10.6.0. Rated the intended argument moderate. |
| independent reproducer | real | Own test with -2.5 fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3 and passes with a sortable decode; controls: sortable-recipe SORTED_NUMERIC 0, negative FloatField 0, two negatives throw. |
| cross-model review, refute lens | uncertain (confidence high; rated the intended-behaviour argument strong; agrees on facts, root cause and fix) | OpenAI GPT-5.6, executed: reproduced (Double on, off; Float), agrees on root cause and fix; argued raw bits were the 2016 intent (rated that argument strong) and showed that the flag-keyed fix B breaks a raw-bit control. |
| devil's advocate | real | No intended, documented or misuse defence survives; built fix A, which passes the repro, a raw-bit NUMERIC probe, LFV-217's repro and TestJoinUtil 16 of 16. |
| adjudication | confirmed (medium), Low | Re-ran on main (on, off) and 10.6.0: fails; with fix A: passes. Silent wrong results, security none; related to LFV-217. |
| audit and last minimal-repro check | confirmed | The audit re-ran it on main (fails) and confirmed it; the last check reduced the repro to one document and one field (fails on main, on and off; passes with fix A). |
| this doc | reproduced | Gradle on main (on, off): fails; fix A validated with the whole lucene/join test task and :lucene:join:check -x test. |
| fact checks of this doc | real; supporting text corrected | An independent fact check re-ran the test and the fix through gradle (assertions on and off), the whole lucene/join test task with the fix, the Z3 model and edge-case controls; a second fact check by OpenAI GPT-5.6 read the code and the logs. Both found the bug, repro and fix as described; the errors they found in supporting text are corrected in this doc. |
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 517 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,272 words
- Registry title
- JoinUtil's numeric join reads a DoubleField or FloatField from field as raw IEEE bits, so a join on a negative value silently misses its matches
- Area
- join / JoinUtil numeric (point-based) query-time join: multi-valued Float/Double decode of core DoubleField/FloatField doc values
- Priority
- Medium, rank 246: In every release since 9.5.0, the documented Float/Double JoinUtil numeric join silently misses the matches of a negative key when the from field is a core DoubleField or FloatField, every time and with default settings (several distinct negative keys throw instead). Float/Double join keys are an uncommon experimental API, so it is in the lower part of Medium.
- Impact class
- silent wrong results
- Impact note
- Applications that call the documented (@lucene.experimental) numeric join JoinUtil.createJoinQuery(fromField, true, toField, Double.class or Float.class, ...) with a core DoubleField or FloatField as the from field (these write SORTED_NUMERIC doc values in the NumericUtils sortable encoding, so multipleValuesPerDocument must be true; false throws 'unexpected docvalues type SORTED_NUMERIC') silently miss the to documents of every negative from key when the from query collects one such distinct value, and get IllegalArgumentException 'values are out of order' when it collects two or more distinct negative normal values such as -5.0 and -2.0 (the LFV-217 symptom, reached through this decode; -0.0 and negative subnormals decode to NaN and are missed silently); positive keys work because their raw and sortable bits are identical, which hides it in tests; default codec, IndexWriterConfig and searcher, any index size, -ea and -da, every release from 9.5.0 through 10.6.0 and main.
- Summary
- You index one from document with DoubleField("from", -5.0) and one to document with DoubleField("to", -5.0), and count JoinUtil.createJoinQuery("from", true, "to", Double.class, new MatchAllDocsQuery(), fromSearcher, ScoreMode.None) on the to index; you expect 1, because the javadoc lists floats and doubles and allows SORTED_NUMERIC from fields, which is what DoubleField writes. You get 0 with no error: DoubleField stores NumericUtils.doubleToSortableLong(-5.0), but JoinUtil's multi-valued Float/Double path decodes the stored long as raw IEEE bits (Double.longBitsToDouble, Float.intBitsToFloat), so the join looks up -0.8749999999999999 instead of -5.0. Positive values are unaffected because their sortable and raw bits are the same, and FloatField fails the same way.
- Introduced
- 827f762d137643c6f37fee22484b50ad7273bace (branch_9x copy that shipped, first released in 9.5.0) and main-line 57201aa967828a678a6c440b6a2e36fd8846ca37 (same author date, first in 10.0.0), apache/lucene#11997 'Add IntField, LongField, FloatField and DoubleField' (by fcofdez, merged by jpountz, approved by rmuir, milestone 9.5.0, closes #11199 / LUCENE-10162): it added core DoubleField/FloatField writing SORTED_NUMERIC doc values in the NumericUtils sortable encoding and did not touch JoinUtil. The raw-bit decode it collides with is older: 2babaf8c3d1710280ef53da3d7ba286615e7293e (LUCENE-7095, migrated as #8151, by martijnvg, +1 from mikemccand), shipped in 6.0.0 via the branch_6_0 backport 5733f05c70d. [exec: 9.4.2 with only #11997's DoubleField.java shadowed fails like 9.5.0; read: git log -S on the decode finds only 2babaf8c3d1; JoinUtil.java is identical between 9.4.2 and 9.5.0]. An interaction bug, not a regression of the JoinUtil code (2022-12-20 (author date of 57201aa9678 and 827f762d137; #11997 merged 2022-12-20T17:19:46Z); decode site 2016-03-25 (2babaf8c3d1))
- First affected version
- 9.5.0 [exec: the repro (9.x two-argument DoubleField constructor port) fails on 9.5.0; 9.4.2 has no DoubleField/FloatField (compile error 'cannot find symbol class DoubleField')]
- Affected versions
- every release from 9.5.0 on: 9.5.0, 9.6.0, 9.7.0, 9.8.0, 9.9.0-9.9.2, 9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2, 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main e1faeba08bde (12.0.0-SNAPSHOT). Executed, all failing 'expected:<1> but was:<0>': main -ea and -da, 10.6.0 -ea and -da, 10.5.2, 10.0.0, 9.12.3, 9.6.0 and 9.5.0 [exec]; the other releases by code identity (all 34 tags 9.0.0-10.6.0 read: the raw decode is in JoinUtil at every tag, DoubleField/FloatField with sortable SORTED_NUMERIC at every tag from 9.5.0) [read]; upstream main head 5adff78bb16 (2026-11-02), branch_11x and branch_10_6 still have both raw decodes [read]. Not applicable before 9.5.0 (no DoubleField/FloatField; 9.4.2 compile error [exec]). Background, not a claimed range: since 6.0.0 the same mis-decode hits a from field hand-written with the SortedNumericDocValuesField javadoc's NumericUtils recipe (6.0.0 and 9.4.2 [exec], +5.0 control passes). Not new in 10.6.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that stayed undecided, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by fuzzing/PBT
- Upstream and prior reports
- none found (2026-11-02, confidence high but not certain, on top of the finder's scripted prior-report search of 51 queries, the intended-behaviour check's scripted search of 40 queries and the independent reproduction's 5-query manual re-check; rechecked the same day): a scripted prior-report search (45 queries over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the LUCENE JIRA archive; 408 hits, none rated highly relevant), 17 manual apache/lucene issue/PR and 3 commit queries (including updated:>=2026-10-31 and created:>=2026-10-25 windows), 10 downstream queries (apache/solr, Elasticsearch, OpenSearch, lucenenet, all of GitHub), the changed-file lists of all 408 open apache/lucene PRs, 8 SOLR and LUCENE JQL queries, 13 list queries and 7 web queries: no report of this mis-decode or its symptom, and no hit names the decode lines. Not fixed upstream: the last main commit to JoinUtil.java is e40b4b188e1 (#16091, 2026-08-20; already in e1faeba); the open PRs that touch JoinUtil.java (#16805 'Collector manager join', #13747) leave the decode unchanged, and #16847 touches PointInSetIncludingScoreQuery/TestJoinUtil only for doc-id widths. A same-day recheck found no apache/lucene issue or PR for "JoinUtil DoubleField", "JoinUtil sortable", "JoinUtil negative", "createJoinQuery Double.class" or "join sortableLongToDouble"; issues and PRs created since 2026-11-01 (#16959-#16963) and join items updated since 2026-11-01 (#16805, #16858, #16959, #15902) do not touch the decode. Background, not reports: #11997 (introduced DoubleField/FloatField; jpountz: multi-valued doubles 'should use NumericUtil#doubleToSortableLong'), #8151 (LUCENE-7095, the raw decode), #14161 (LFV-217's exception site); downstream caller, not a report: SOLR-18440 (the Solr change, released in Solr 10.1.0 on 2026-10-27, that routes multi-valued float/double score joins to this overload) [read, not executed]
- Lucene commit tested
- main
e1faeba08bde(12.0.0-SNAPSHOT) through gradle and a standalone runner, assertions on and off; release jars 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.6.0, 9.5.0 and 9.4.2 (plus only #11997'sDoubleField.java); the fix in a scratch worktree ofe1faeba08bde(JDK 25.0.3, Linux aarch64). The cross-model reviews ran on mainea57360912ed, whoseJoinUtil.javaandDoubleField.javaare byte-identical toe1faeba08bde's.
- Finding record
lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:54 (at e1faeba08bde) (@lucene.experimental), :121-140 (numeric overload javadoc: 'supports multi- and single- values longs, ints, floats and longs'; multipleValuesPerDocument 'when true fromField might be SORTED_NUMERIC'; toField FloatPoint or DoublePoint; no encoding stated), :228-251 (multi-valued collector adds SortedNumericDocValues.nextValue() unchanged), :316-318 (signed-long sort, LFV-217's site), :368-369 (Float stream: Float.intBitsToFloat((int) value.value), fix site), :387 (Double stream: Double.longBitsToDouble(value.value), fix site)lucene/core/src/java/org/apache/lucene/document/DoubleField.java:31-33 (at e1faeba08bde) (class javadoc), :52 (DocValuesType.SORTED_NUMERIC), :73 and :105 (NumericUtils.doubleToSortableLong); FloatField.java:52, :73, :105 (NumericUtils.floatToSortableInt)lucene/core/src/java/org/apache/lucene/document/SortedNumericDocValuesField.java:33-37 (at e1faeba08bde) ('if you want to encode doubles or floats with proper sort order, you will need to encode them with NumericUtils')
- The finding in 30 seconds
lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:228, (at e1faeba08bde) :239-240, :250, :297, :316-318, :368-369, :387lucene/core/src/java/org/apache/lucene/document/DoubleField.java:52, (at e1faeba08bde) :73, :105; FloatField.java:52, :73, :105; DoubleDocValuesField.java:39; FloatDocValuesField.java:40lucene/core/src/java/org/apache/lucene/index/DocValues.java:219 (at e1faeba08bde) ('unexpected docvalues type')lucene/misc/src/java/org/apache/lucene/misc/search/DocValuesStats.java:436 (at e1faeba08bde) (Double.longBitsToDouble(sndv.nextValue()); read only)
- Who is affected
lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:54 (at e1faeba08bde) (@lucene.experimental)
- Why it looks unintended
lucene/join/src/java/org/apache/lucene/search/join/JoinUtil.java:122-134 (at e1faeba08bde) (javadoc), :316-318 (sort)lucene/core/src/java/org/apache/lucene/document/SortedNumericDocValuesField.java:33-37 (at e1faeba08bde)lucene/core/src/java/org/apache/lucene/search/SortedNumericSelector.java:74-89; (at e1faeba08bde) lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:319-320, :358-359lucene/join/src/test/org/apache/lucene/search/join/TestJoinUtil.java:1535, (at e1faeba08bde) :1690-1696, :1920-1928lucene/facet/src/java/org/apache/lucene/facet/MultiDoubleValuesSource.java:81-88 (at e1faeba08bde)lucene/misc/src/java/org/apache/lucene/misc/search/DocValuesStats.java:436 (at e1faeba08bde) (SortedDoubleDocValuesStats: Double.longBitsToDouble(sndv.nextValue()))
- TestJoinNegativeDoubleField.java (lucene/join, public API):
repro/LFV-1111/TestJoinNegativeDoubleField.java(49 lines)
A summary still (PNG).
assets/LFV-1111/bug.png(517 KB)
