toSortableLongDoubleValuesSource() turns a double source into a long source whose longs sort exactly like the doubles. Lucene tells callers to rewrite(searcher) a source before reading it, and IndexSearcher.search rewrites the Sort it is given by itself. This source's rewrite returns the wrong adapter: the one that simply casts each double to long.
rewrite, the sort's expected and actual order, and the copied line with its one-line fix. Select the still to open it at full size. Generated with AI, checked against the code.- You index four documents with a double
price: 0.75, 0.25, -0.5 and 2.5 (docs 0-3), and wrap the field withfromDoubleField("price").toSortableLongDoubleValuesSource(): a long source whose longs sort like the doubles. - Read directly, it keeps that promise: 0.75 gives 4604930618986332160 and -0.5 gives -4602678819172646913, four keys in price order.
- You sort with
searcher.search(query, 10, new Sort(src.getSortField(false)))and expect docs 2, 1, 0, 3 (-0.5, 0.25, 0.75, 2.5). - Instead you get docs 0, 1, 2, 3 with sort keys 0, 0, 0, 2.
IndexSearcherrewrites the sort, and this source'srewrite()returnstoLongValuesSource(), a plain(long)cast: the three prices in (-1, 1) tie at 0 and stay in docID order. - The documented
rewrite-then-getValuesstep gets the same casts, and NaN, -0.0 and 1e300 break too: 2.0, -0.0, NaN, 1e300 sort as docs 1, 2, 0, 3 instead of 1, 0, 3, 2. No error is raised.
Code that calls the public toSortableLongDoubleValuesSource() and then rewrites it: by sorting on its getSortField() through IndexSearcher.search, by the documented rewrite-then-getValues step, or through toDoubleValuesSource() in function scores and rescorers. Default settings, one document is enough, every time, no error. Reach is narrow: the only in-tree caller never rewrites, and no outside use was found.
Mechanism, terms and numbers851 words
DoubleValuesSource#toSortableLongDoubleValuesSource() (public, final, in core since 9.12.0) is documented as "Convert to LongValuesSource by calling NumericUtils#doubleToSortableLong", and its values are exactly that as long as the source is not rewritten. Its rewrite(IndexSearcher) returns inner.rewrite(searcher).toLongValuesSource(), the plain (long)-cast adapter: the line is a copy of the cast adapter's own rewrite, where it is right. Both class javadocs tell clients to call rewrite before getValues, and IndexSearcher.search/searchAfter rewrite every Sort they are given, which rewrites the sort field's source. So on these paths every value is a cast: over 0.75, 0.25, -0.5 and 2.5 the rewritten source prints long(double(price)) and returns 0, 0, 0 and 2, and an ascending sort on its getSortField(false) returns docs 0, 1, 2, 3 with keys 0, 0, 0, 2 instead of docs 2, 1, 0, 3. Every key is then a (long) cast, which differs from the sortable long for every value except +0.0. The order can be wrong where the cast ties (fractions in one integer bucket, everything in (-1, 1) including -0.0) or saturates (values beyond the long range), because ties fall back to docID order; NaN is reversed outright: it casts to 0, so it sorts before every value >= 1 instead of last. No exception, nothing written. The reach is narrow: the only in-tree caller, the sandbox range-facet cutter, never rewrites, and no downstream use was found. The fix is one line, validated.
DoubleValuesSource/LongValuesSource: produce onedouble(orlong) per document, for example from a doc-values field.getValues(leaf, scores)returns the values for one segment.rewrite(IndexSearcher): "Return a LongValuesSource specialised for the given IndexSearcher". Both class javadocs make it the first step for clients: "clients should callrewrite(IndexSearcher)against the top-level searcher, and thengetValues" (LongValuesSource.java:30-32,DoubleValuesSource.java:34-36).- Two adapters from double to long.
toLongValuesSource(): "Convert to a LongValuesSource by casting the double values to longs" (DoubleValuesSource.java:134-137), so 0.75 gives 0.toSortableLongDoubleValuesSource(): "Convert toLongValuesSourceby callingNumericUtils#doubleToSortableLong" (:139-142), a bit-level encoding whose longs keep the order ofDouble.compareTo, NaN last (NumericUtils.java:38-43). It was added in 9.12.0 for the sandbox's range facets. getSortField(reverse): everyLongValuesSourcecan be sorted on; the sort field holds the source (LongValuesSource.java:77-79).
- Un-rewritten, the sortable source returns
NumericUtils.doubleToSortableLong(in.doubleValue())(DoubleValuesSource.java:159) and printssortableLong(double(price))(:188-190). - Its
rewritereturnsinner.rewrite(searcher).toLongValuesSource()(:192-195): aLongDoubleValuesSource, which returns(long) in.doubleValue()(:217) and printslong(double(price)). The line is identical toLongDoubleValuesSource.rewrite(:255-258), where re-applyingtoLongValuesSource()is right. IndexSearcher.search(query, n, sort)andsearchAfterrewrite the sort first (IndexSearcher.java:730,final Sort rewrittenSort = sort.rewrite(this);).LongValuesSortField.rewriterewrites its source and keeps the old field only if the same object comes back (LongValuesSource.java:306-316). This rewrite always returns a new cast adapter, so a sort on this source through these methods always compares casts. ASortused without them is not rewritten:search(Query, CollectorManager)(IndexSearcher.java:750) andTopFieldCollectorManagernever callSort.rewrite, andSortRescorerbuilds its collector from theSortdirectly (SortRescorer.java:49); the sameSortin a hand-builtTopFieldCollectorManagersorts correctly (executed).toDoubleValuesSource()on it rewrites the inner source and re-wraps it (LongValuesSource.java:120-123), soFunctionScoreQuery(when scores are needed,FunctionScoreQuery.java:118-121),DoubleValuesSourceRescorerandFunctionMatchQuery, which rewrite their source, see casts too (the last from reading only).
Main a9779ef3c193, one segment, field price:
| doc | price | doubleToSortableLong(price) (expected) | after rewrite |
|---|---|---|---|
| 0 | 0.75 | 4604930618986332160 | 0 |
| 1 | 0.25 | 4598175219545276416 | 0 |
| 2 | -0.5 | -4602678819172646913 | 0 |
| 3 | 2.5 | 4612811918334230528 | 2 |
Ascending sort on src.getSortField(false): docs 0, 1, 2, 3 with keys 0, 0, 0, 2; expected docs 2, 1, 0, 3, which SortField("price", Type.DOUBLE) and DoubleValuesSource.fromDoubleField("price").getSortField(false) both return. The rewritten toDoubleValuesSource() view gives 0.0 for 0.75 instead of 4.604930618986332E18.
Edge values 2.0, -0.0, NaN, 1e300 (docs 0-3): after rewrite 2, 0, 0, Long.MAX_VALUE (expected 4611686018427387904, -1, 9221120237041090560, 9094988921128908188); the sort returns docs 1, 2, 0, 3, while Type.DOUBLE returns 1, 0, 3, 2. Eight values with searchAfter pages of 3: expected docs 1, 5, 4, 7, 2, 0, 6, 3, got 1, 3, 4, 5, 7, 0, 2, 6.
Precisely. After a rewrite every value changes encoding, integral ones too (2.0 gives 2 instead of 2^62); +0.0 is the only double whose cast equals its sortable long (both 0). So FieldDoc sort keys and comparisons with doubleToSortableLong bounds are wrong for every other value. Over non-NaN doubles the cast never gives a smaller value a larger key: it only merges values into ties (fractions that truncate to the same integer, everything in (-1, 1) including -0.0) or clips them (magnitudes beyond the long range). Ties fall back to docID order, which can put the larger value first (in the 8-value run, 1.5 in doc 0 before 1.25 in doc 2). NaN is reversed outright: doubleToSortableLong puts it after +Infinity, but it casts to 0, so it sorts before every value >= 1 (with doc 0 = 1.5 and doc 1 = NaN the sort returns docs 1, 0).
Exposure and likelihood294 words
- Who is hit. Applications that call the public
toSortableLongDoubleValuesSource()and then rewrite the result: by sorting on itsgetSortField(...)throughIndexSearcher.search/searchAfter(which rewrite everySortthey are given), by following the documented rewrite-then-getValuesprotocol in their own code, or by passingtoSortableLongDoubleValuesSource().toDoubleValuesSource()toFunctionScoreQuery(when scores are needed) or aDoubleValuesSourceRescorer. - Default vs opt-in. No setting is involved: default
IndexWriterConfig, codec andIndexSearcher; any index size (one document gives a wrong value). The opt-in part is the method itself. - Silent and deterministic. On that path every value is a cast, every time; no exception, no log. The symptoms are the wrong values, keys, order and scores themselves; the source's
toStringalso changes (sortableLong(...)becomeslong(...)). With a top-n limit, a wrong order can also change which documents are returned; the set of documents that match the query does not change. - Narrow reach. The only in-tree caller is the sandbox
DoubleRangeFacetCutter(DoubleRangeFacetCutter.java:54), which never rewrites, so in-tree facet counts are fine. A GitHub code search found only copies of Lucene's sources (a port and a vendored copy, with the same line). Sorting doubles is usually done withDoubleValuesSource#getSortField, which is not affected. Scoring with the documented sortable-long values (about 4.6e18 for 0.75) would be an unusual use; affected versions score with the casts. - Workaround. To sort doubles, use
DoubleValuesSource#getSortField, which gives docs 2, 1, 0, 3 on the example. To read values, skipping the rewrite works only when the inner source needs none (a doc-values field does not; afromQuerysource throws until it is rewritten,DoubleValuesSource.java:768). The general workaround is to rewrite the double source and applyNumericUtils.doubleToSortableLongto its values, which is what the un-rewritten adapter does (:159).
The test shows that DoubleValuesSource#toSortableLongDoubleValuesSource() loses its NumericUtils.doubleToSortableLong encoding after the documented rewrite(IndexSearcher) step; the rewritten source is the plain (long) cast adapter.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.search;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.DoubleDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.NumericUtils;
public class TestSortableLongValuesSourceRewrite extends LuceneTestCase {
public void testRewriteKeepsSortableLongEncoding() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new DoubleDocValuesField("price", 0.75));
w.addDocument(doc);
try (DirectoryReader reader = DirectoryReader.open(w)) {
LongValuesSource source =
DoubleValuesSource.fromDoubleField("price")
.toSortableLongDoubleValuesSource()
.rewrite(new IndexSearcher(reader));
LongValues values = source.getValues(reader.leaves().get(0), null);
assertTrue(values.advanceExact(0));
// expected doubleToSortableLong(0.75) = 4604930618986332160, actual the (long) cast 0
assertEquals(
"value of 0.75 after rewrite, via " + source,
NumericUtils.doubleToSortableLong(0.75),
values.longValue());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-770/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.TestSortableLongValuesSourceRewrite
- Expected vs actual
- Expected:
doubleToSortableLong(0.75)= 4604930618986332160 for a document with value 0.75. Actual: 0.
More files, environment, full output40 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: value of 0.75 after rewrite, via long(double(price)) expected:<4604930618986332160> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.search.TestSortableLongValuesSourceRewrite.testRewriteKeepsSortableLongEncoding(TestSortableLongValuesSourceRewrite.java:45)- The javadoc promises the encoding without condition ("Convert to
LongValuesSourceby callingNumericUtils#doubleToSortableLong"), and the class javadocs makerewrite-then-getValuesthe protocol. - Sibling adapters re-apply their conversion on rewrite; only this one switches, and its
toStringturns fromsortableLong(...)tolong(...): a copied line. - Nothing documents a change on rewrite, and no test calls the method.
The full argument248 words
- An unconditional contract.
DoubleValuesSource.java:139: "Convert toLongValuesSourceby callingNumericUtils#doubleToSortableLong", on a public final method of a core class with no@lucene.experimentalor@lucene.internaltag.rewriteis "Return a LongValuesSource specialised for the given IndexSearcher" (LongValuesSource.java:64-70): bound to a searcher, not a different function. - Rewrite is part of the documented use. "To obtain a
LongValuesobject for a leaf reader, clients should callrewrite(IndexSearcher)against the top-level searcher, and thengetValues(LeafReaderContext, DoubleValues)" (LongValuesSource.java:30-32; the same inDoubleValuesSource.java:34-36).IndexSearcher.search/searchAfterrewrite everySortthey are given (IndexSearcher.java:730), so on that standard sort path the inherited, unrestrictedgetSortFieldcan never deliver the documented encoding (only aSortthat bypasses those methods keeps it). - The siblings keep their conversion.
LongDoubleValuesSource.rewritere-appliestoLongValuesSource()(DoubleValuesSource.java:255-258),DoubleLongValuesSource.rewritere-appliestoDoubleValuesSource()(LongValuesSource.java:120-123). Only the sortable adapter switches, to a different class with a differenttoStringandequals. - Nothing says otherwise. The CHANGES entry (9.12.0, API Changes: "GITHUB#13568: Add DoubleValuesSource#toSortableLongDoubleValuesSource and MultiDoubleValuesSource#toSortableMultiLongValuesSource methods."), MIGRATE and the code comments say nothing about rewrite.
- Tests never covered it. No test in
lucene/*/src/testcallstoSortableLongDoubleValuesSource; the only caller never rewrites, which may be why the copied line went unnoticed. - History. The class came in whole with #13568 ("Compute facets while collecting", @epotyom, merged by @gsmiller, 2024-08-12). None of its 415 review comments and 30 conversation comments mentions
rewrite. The copied line comes from LUCENE-7736 (2017), which addedrewriteto the values sources.
The devil's-advocate check191 words
The strongest case for "intended": the method is plumbing for the experimental collect-time facets, and the facet module says rewrite is never called there. MultiDoubleValuesSource documents that DoubleValuesSource#rewrite(IndexSearcher) "will also never be called ... The faceting use-cases don't call rewrite or provide scores, which is why this simplification was made" (MultiDoubleValuesSource.java:43-47). Also, sorting doubles has a dedicated DoubleValuesSource#getSortField.
Why it fails: that note is in the class javadoc of the facet module's @lucene.experimental multi-valued wrappers and covers the sources they wrap. toSortableLongDoubleValuesSource() is a public final core method whose own class javadoc makes rewrite mandatory, whose javadoc promises the encoding without condition, and whose inherited sort field IndexSearcher.search rewrites automatically. An unrestricted public API that breaks on its documented path is a defect even if a better alternative exists. The weakest part of the case is the score path: NumericUtils says the integer form "is not usable" (NumericUtils.java:27-28), so a maintainer may discount sortable longs as scores; the protocol and Sort paths do not depend on that use. Verdict: not intended. The intended-lens cross-model review, the devil's advocate and the adjudication all rated the argument weak.
Recommended Re-apply the sortable adapter on rewrite
Make rewrite re-apply the sortable adapter: return inner.rewrite(searcher).toSortableLongDoubleValuesSource(); (one line in DoubleValuesSource.java). The repro passes with assertions on and off, and so do 30 nearby test classes in core, queries, facet and sandbox.
@Override
public LongValuesSource rewrite(IndexSearcher searcher) throws IOException {
return inner.rewrite(searcher).toSortableLongDoubleValuesSource(); // was: .toLongValuesSource()
}All options and trade-offs3 options · 392 words
One fix was built and checked (A). B is a small variant; C is listed only to be rejected.
In SortableLongDoubleValuesSource.rewrite (DoubleValuesSource.java:192-195), return toSortableLongDoubleValuesSource() of the rewritten inner source instead of toLongValuesSource(). It mirrors the sibling adapters, which re-apply their own conversion.
@Override
public LongValuesSource rewrite(IndexSearcher searcher) throws IOException {
return inner.rewrite(searcher).toSortableLongDoubleValuesSource(); // was: .toLongValuesSource()
}Trade-offs
- Changes behaviour only for callers that rewrite this source; they get the documented values. Callers that never rewrite (the sandbox range-facet cutter) see no change.
- Allocation stays as today: rewrite returns a new object every time, as the cast adapter's rewrite does, so
LongValuesSortField.rewritestill builds a new sort field. - No benchmark was run. The work per value stays constant but changes:
Double.doubleToLongBitsplus a shift, an AND and an XOR (NumericUtils.java:48,:84-85) instead of a cast, the same work the un-rewritten source already does.
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, seed DEADBEEF: stock, the repro fails 2 of 3 with-Ptests.asserts=trueand=false; with the fix it passes 3 of 3 in both modes. - Closest suites with the fix: core
TestDoubleValuesSource,TestLongValuesSource,TestDoubleValuesSourceRescorer,TestSortRescorer,TestSort,TestSortOptimization; all oforg.apache.lucene.queries.function.*(includingTestFunctionScoreQuery,TestFunctionMatchQuery);org.apache.lucene.facet.range.*;org.apache.lucene.sandbox.facet.*(includingTestRangeFacet, the only caller's tests): 30 upstream classes, 292 tests, 0 failures. ecjLintandcheckGoogleJavaFormatforlucene/corewith the test in place: pass.- In the hunt: the same line shadowed in passes the finder's test and
TestDoubleValuesSource+TestLongValuesSource(17 tests), makes 9.12.0 and 10.5.2 pass, and leaves the exhaustive harness with 0 mismatches (1,752 configs / 4.66 million checks; 272,344 configs / 1.30e9 checks). - Not run: the full module suites.
DoubleValuesSource r = inner.rewrite(searcher); return r == inner ? this : r.toSortableLongDoubleValuesSource();
Trade-offs
Same values as A. For a searcher-independent inner source (a doc-values field), LongValuesSortField.rewrite and Sort.rewrite would then return the same objects (LongValuesSource.java:309-311, Sort.java:98). A small extra the sibling adapters do not have. Not run.
Javadoc only.
Trade-offs
Not recommended: the public sort field would stay useless on the standard path (IndexSearcher.search/searchAfter always rewrite it) and the method javadoc would contradict itself.
An application that filtered with FunctionMatchQuery over toSortableLongDoubleValuesSource().toDoubleValuesSource() would test cast values, since that query rewrites its source (read from code, not run); no such use is known.
Exhaustive checking, not code review: a harness ran the core DoubleValuesSource and LongValuesSource factories and adapters, before and after rewrite, through sorts, function scores and rescorers on every index of 1-2 documents over 12 edge values per type. All 649 mismatches, and only those, were this source after a rewrite; the finder then reduced it to a JUnit test.
The method in full215 words
Method: bounded-exhaustive translation validation (the hunt's semi-formal tier), in a hunt unit on value sources against their documented semantics.
- The harness runs 103
DoubleValuesSourceand 85LongValuesSourcecases on the stock jars (the core factories and adapters it models, includingtoLongValuesSource,toSortableLongDoubleValuesSourceandtoDoubleValuesSourcecompositions; not covered: vector-similarity sources, expression bindings, the facet module's multi-valued sources andTOP_SCORESblock-max bounds) throughrewrite(searcher)+getValues,FunctionScoreQuery,boostByValue/boostByQuery,getSortFieldandDoubleValuesSourceRescorer, and compares each result with an exact reference. - Scope: every index of 1-2 documents over type-specific 12-value domains (doubles: absent, ±0.0, ±1, NaN, a negative NaN, ±Infinity, 2^63, 2^53+1, -0.5; longs include
Long.MIN_VALUE/MAX_VALUEand ±(2^53+1)), all segmentations, deletion masks and two-indexMultiReadersplits: 1,752 configs, 4,658,632 checks, 649 mismatch keys, every one a source containingtoSortableLongDoubleValuesSource()after rewrite; the un-rewritten values all matched; 0 with the fix shadowed. - A larger run over the full bounded scope (every index of 1-3 documents over the full domains and of 4 documents over 5 values, 1-3 segments): 272,344 configs, 1.30e9 checks, the same 649 keys and nothing else; 0 with the fix.
- Found 2026-10-27; then checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6), an adjudication and a credibility audit.
No prior report found (2026-10-27) in 51 tool queries over GitHub issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, 16 manual GitHub searches, and the changed files of all 408 open PRs. Upstream main and the 10.x and 9.12 branches still have the line.
Searches4 searches
Verdict new: no hit is this bug. The API was added after the move from JIRA to GitHub, so no LUCENE-NNNN issue can exist for it. Closest items, none a report: #13568 (introduced the class; its review never mentions rewrite), #13645 (its 9.x backport), #8787 / LUCENE-7736 (added rewrite to the values sources, 2017), #16697 (a recent fix elsewhere in the same file), #16122 (open; touches the sandbox caller, adds no rewrite).
Limits: text search cannot rule out a report that describes only the symptom (a wrong values-source sort order) without naming the API; private channels cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream repositories; the web | 2026-10-27 | the hunt's prior-report tool: 16 GitHub issue/PR queries, 3 commit queries, 2 downstream queries, 20 list queries, 4 JIRA queries, 6 web searches; 78 distinct hits, the 17 medium-overlap ones read | no report; the medium hits match only on class names, the list hits are commit and PR notifications |
| GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr) | 2026-10-27 | 16 manual issue/PR searches for the method and class names and symptom phrases; code search for the method | no report; code search finds only copies of Lucene sources, with the same line |
| apache/lucene PRs | 2026-10-27 | changed files of all 408 open PRs and of 2,300 closed PRs created since 2024-07-17; the #13568 review and conversation comments | 4 open PRs touch the files, none changes this rewrite; #13568's review never mentions rewrite |
| upstream branches | 2026-10-27 | file contents on main f7bcc0abd816, branch_10x, branch_10_6, branch_10_5, branch_9_12; CHANGES | the line is unchanged everywhere; no fix entry |
Born with the method: #13568 (304d4e7855de, merged 2024-08-12; backport f68e74034a7e), first released in 9.12.0. Executed: all 16 releases 9.12.0 to 10.5.2 and main fail; 9.11.1 and older have no such method. Not fixed upstream.
Versions tested5 rows
Introduced by #13568 "Compute facets while collecting" (@epotyom, merged by @gsmiller, 2024-08-12): main 304d4e7855de, branch_9x backport f68e74034a7e the same day. CHANGES lists the API under 9.12.0 (API Changes). The rewrite body was copied from LongDoubleValuesSource.rewrite (LUCENE-7736, 2a4dd499bbd, 2017) with its target adapter unchanged; nothing has touched the class since (git log -S, git blame, git log -L). Not a regression: no correct version ever shipped.
Not fixed upstream (read 2026-10-27): main f7bcc0abd816, branch_10x, branch_10_6, branch_10_5 and branch_9_12 still return toLongValuesSource(); branch_10_6 was read, not run.
Every run used OpenJDK 25.0.3 on Linux aarch64, also for releases older than that JDK. Release jars come from Maven Central (SHA-1 checked); main used jars built from the pinned checkout.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the repro fails 2 of 3 through gradle, assertions on and off; the control passes; 3 of 3 pass with the fix |
| 10.0.0 to 10.5.2 (12 releases) | affected | fails 2 of 3 (Maven Central jars, SHA-1 checked); -da as well on 10.0.0 and 10.5.2 |
| 9.12.0 to 9.12.3 | first release | fails 2 of 3; -da as well on 9.12.0 and 9.12.3 |
| 9.10.0, 9.11.0, 9.11.1 | no such method | the repro does not compile: toSortableLongDoubleValuesSource() does not exist |
| 9.12.0 and 10.5.2 with the one-line fix | fix | the release's own file with only the rewrite line changed: 3 of 3 pass |
An independent reproducer and a devil's advocate wrote their own tests (5 and 8 documents, with NaN and paging) and reproduced it, controls passing. Read-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication and a credibility audit confirmed it, and this doc's gradle runs reproduce it and validate the fix.
Checks and verdicts316 words
Every executing check wrote or re-ran its own JUnit on stock main; every control passed.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Exhaustive harness: 649 mismatch keys, all this source after rewrite, 0 with the fix. Public-API JUnit fails 2 of 3 on main (-ea, -da); the fix passes it and 17 upstream value-source tests. |
| cross-model review, refute lens | not real (low, procedural) | An independent reviewer from another model family (OpenAI GPT-5.6) could only read code; the review needs an executed repro of its own to vote real. It agreed on the root cause, found the inputs in contract and rated the intended argument weak. A separate re-run fails 2 of 3. |
| independent reproducer | real | Own JUnit, 5 documents: fails 3 of 4 on main (-ea, -da), 10.5.2, 9.12.3 and 9.12.0; 9.11.1 lacks the API; own one-line shadow fix passes 4 of 4. |
| cross-model review, intended lens | real, medium | Reading-only. Intended argument weak; root cause agrees. Its verifier's re-runs fail 2 of 3 and pass with the fix. |
| intended-behaviour devil's advocate | real, high | Built the facet-module case for "intended" and showed it covers only the facet wrappers. Own 8-document test with NaN, 1e300 and searchAfter paging fails 4 of 5 on main and 10.5.2, passes with the fix. |
| adjudication | confirmed, high | Re-ran the independent reproduction's JUnit with a new seed: 3 of 4 fail with -ea and -da, 0 of 4 with the fix. Duplicate of none. |
| credibility audit | keep | Re-ran the finder's JUnit with fresh seeds (2 of 3 fail, -ea and -da; 3 of 3 pass with the fix); score kept. |
| this doc's runs | reproduces; fix validated | Gradle in a clean worktree at a9779ef3c193: 2 of 3 fail stock, 3 of 3 pass with the fix; 30 nearby test classes pass. |
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 488 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,325 words
- Registry title
- DoubleValuesSource#toSortableLongDoubleValuesSource() loses its NumericUtils.doubleToSortableLong encoding when rewritten (rewrite() returns the plain toLongValuesSource() cast adapter), so values read after the documented rewrite step, and sorts on it through IndexSearcher, use (long) casts instead
- Area
- core / search value sources (DoubleValuesSource, LongValuesSource adapters)
- Priority
- Medium, rank 253: It gives deterministic silent wrong values and sort order on every rewrite of a public core values-source adapter since 9.12.0, but the method has no known callers outside the sandbox, so it sits beside its sibling LFV-409, the rare bridged-DoubleValuesSource row, near the bottom of Medium.
- Impact class
- silent wrong results
- Impact note
- Hits only applications that call the public core method DoubleValuesSource#toSortableLongDoubleValuesSource() (no in-tree caller outside the sandbox facets, no downstream use found) and then rewrite the result, which happens through the documented rewrite-then-getValues protocol, through getSortField plus IndexSearcher.search/searchAfter with a Sort (IndexSearcher rewrites every Sort), or through toDoubleValuesSource() inside FunctionScoreQuery or DoubleValuesSourceRescorer; on that path, with the default configuration and as little as one document, every value is a (long) cast instead of the sortable-long encoding, FieldDoc sort keys are always wrong, and the sort order is wrong wherever the cast ties or saturates (fractions that truncate to the same integer, everything in (-1, 1) including -0.0, NaN, magnitudes beyond the long range), with no exception and no index damage, in every release since 9.12.0; the sandbox range-facet cutter, the method's only in-tree caller, never rewrites and is unaffected.
- Summary
- You turn a double source into a long source with DoubleValuesSource#toSortableLongDoubleValuesSource(), documented as 'Convert to LongValuesSource by calling NumericUtils#doubleToSortableLong', and, as the LongValuesSource javadoc tells clients to, call rewrite(IndexSearcher) before getValues, or you sort on its getSortField(), which IndexSearcher rewrites for you. You expect the sortable-long encoding, which keeps the order of the doubles; instead rewrite() returns the plain toLongValuesSource() adapter (its toString changes from sortableLong(...) to long(...)), so 0.75, 0.25 and -0.5 all become 0, 2.5 becomes 2 and NaN becomes 0, and a sort over the values {0.75, 0.25, -0.5, 2.5} comes back in docID order with keys 0, 0, 0, 2. The source is correct when it is not rewritten, and the one-line fix is to return inner.rewrite(searcher).toSortableLongDoubleValuesSource().
- Introduced
- 304d4e7855de (main, 'Sandbox: Compute facets while collecting (#13568)', PR by epotyom, merged by gsmiller; backported to branch_9x as f68e74034a7e the same day, which is the copy in 9.12.0); born buggy, not a regression: SortableLongDoubleValuesSource.rewrite is a verbatim copy of LongDoubleValuesSource.rewrite (from LUCENE-7736, 2a4dd499bbd, 2017, where it is correct) that kept the target adapter toLongValuesSource() [read: git log -S 'SortableLongDoubleValuesSource' over all refs of the full-history clone finds only these two commits, git blame and git log -L show the class body unchanged since; exec: release level only, 9.11.1 has no such API (compile error), 9.12.0 fails, and 9.12.0 and 10.5.2 with only the rewrite line fixed pass; no commit-level build, the parent commit has no such class] (2024-08-12 (merged to main as 304d4e7855de and backported to branch_9x as f68e74034a7e the same day))
- First affected version
- 9.12.0 [exec] (9.10.0, 9.11.0 and 9.11.1 have no toSortableLongDoubleValuesSource(): compile error [exec]); 10.0.0 is the first affected 10.x release [exec]
- Affected versions
- every release from 9.12.0 to 10.5.2, 10.6.0 (by reading) and main a9779ef3c193. [exec] the finder's JUnit (seed 5EED257) fails 2 of 3 with the same two messages, the no-rewrite control passing, on all 16 releases 9.12.0, 9.12.1, 9.12.2, 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 and 10.5.2 and on main with -ea, and with -da on 9.12.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's JUnit fails 3 of 4 on 9.12.0, 9.12.3, 10.5.2 and main; the intended-behaviour check's fails 4 of 5 on 10.5.2 and main; all runs on JDK 25, also for releases that predate it, Linux aarch64 only. [read] the class body is byte-identical in all 16 releases, in main a9779ef3c193 and in upstream main, branch_10x, branch_10_6 and branch_10_5. Not fixed upstream: main f7bcc0abd816, branch_10x, branch_10_6, branch_10_5 and branch_9_12 still return inner.rewrite(searcher).toLongValuesSource() (follow-up prior-report record, read 2026-10-27); branch_10_6 was not executed.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-27, follow-up prior-report record verdict new: a scripted prior-report search with 51 queries (16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, web; 78 distinct hits, 0 high, the 17 medium-overlap hits are generic DoubleValuesSource/LongValuesSource items matched on class names and were read), 16 own GitHub issue/PR searches (apache/lucene, all of GitHub, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr) and 12 other checks (GitHub code search: only copies of Lucene sources, among them a Kotlin port and a vendored copy that carry the same line unfixed; the changed files of all 408 open apache/lucene PRs, of which 4 touch these files and none changes this rewrite; 2,300 closed PRs created since 2024-07-17; the #13568 review comments and its list thread; JIRA REST for LUCENE and SOLR; git log -S over the full history); the finder, the independent reproduction and a scripted prior-report search, 48 queries also found nothing. Not fixed upstream: main f7bcc0abd816, branch_10x, branch_10_6, branch_10_5 and branch_9_12 still return inner.rewrite(searcher).toLongValuesSource(), and upstream CHANGES has no fix entry. Related, not reports of this bug: #13568 (introduced the class; none of its review or conversation comments mentions rewrite), #13645 (its 9.x backport), #8787 / LUCENE-7736 (added rewrite to the values sources in 2017), #16697 (a recent fix elsewhere in the same file). Gaps: text search cannot rule out a report that describes only the symptom (a wrong values-source sort order) without naming the API, and a private report to security@lucene.apache.org would not be visible)
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26): the repro test through gradle in a clean worktree, stock and with the fix, assertions on and off, plus 30 nearby upstream test classes in core, queries, facet and sandbox with the fix (2026-10-28). Through the hunt's classpath runner: the repro fails on all 16 releases 9.12.0 to 10.5.2 and on main (-ea;-daas well on 9.12.0, 9.12.3, 10.0.0, 10.5.2 and main), does not compile on 9.10.0 to 9.11.1, and passes with the one-line fix shadowed into 9.12.0, 10.5.2 and main. The exhaustive harness ran on mainea57360912ed, whose value-source files are identical.
- Finding record
lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:34-36 (rewrite-then-getValues protocol), :139-142 (toSortableLongDoubleValuesSource javadoc and factory), :159 (doubleToSortableLong in getValues), :192-195 (SortableLongDoubleValuesSource.rewrite, the fix site), :255-258 (LongDoubleValuesSource.rewrite, the copied line, correct there)lucene/core/src/java/org/apache/lucene/search/LongValuesSource.java:30-32 (client protocol), :64-70 (rewrite javadoc 'specialised for the given IndexSearcher'), :120-123 (DoubleLongValuesSource.rewrite keeps its adapter), :306-316 (LongValuesSortField.rewrite rewrites the producer)lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:730 (sort.rewrite(this))lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/cutters/ranges/DoubleRangeFacetCutter.java:54 (only in-tree caller; no rewrite)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:34-36, 134-142, 153-167, 188-195, 203-225, 251-258lucene/core/src/java/org/apache/lucene/search/LongValuesSource.java:30-32, 64-70, 77-79, 92-94, 120-123, 306-316lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:677-678, 691-692, 730, 750; Sort.java:88-99; SortRescorer.java:49; lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:38-43lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:118-121
- Who is affected
lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/cutters/ranges/DoubleRangeFacetCutter.java:46-67lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:159, 745-768 (fromQuery: getValues throws until rewritten)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:34-36, 139-142, 192-195, 255-258lucene/core/src/java/org/apache/lucene/search/LongValuesSource.java:30-32, 64-70, 120-123lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:730lucene/facet/src/java/org/apache/lucene/facet/MultiDoubleValuesSource.java:41-53lucene/CHANGES.txt:2528, 2543-2544 (9.12.0, API Changes)lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:27-28 (class javadoc: "the integer form is not usable")
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/LongValuesSource.java:306-316; lucene/core/src/java/org/apache/lucene/search/Sort.java:88-99
- TestSortableLongValuesSourceRewrite.java (JUnit test):
repro/LFV-770/src/org/apache/lucene/search/TestSortableLongValuesSourceRewrite.java(52 lines)
Made in parallel with this doc from a probe run on main a9779ef3c193 (assertions on and off, and with the fix shadowed in) and on 10.5.2; the values on screen come from that probe and match the repro test, which uses the same four documents. The PNG is the summary card.
assets/LFV-770/bug.png(488 KB)
