All findings · Lucene finding · LFV-770 · sortable-long-values-source-rewrite-drops-sortable-encoding

toSortableLongDoubleValuesSource() loses its sortable encoding when it is rewritten, so values and sorts use plain (long) casts

DoubleValuesSource#toSortableLongDoubleValuesSource() promises NumericUtils.doubleToSortableLong values, but its rewrite() returns the plain (long)-cast adapter. Since IndexSearcher.search rewrites the Sort it is given, a sort on it reads 0.75, 0.25 and -0.5 all as 0 and returns them in document order, silently, in every release since 9.12.0.

Summary

Priority
Medium, rank 253It 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
silent wrong results
Since
9.12.0 (#13568, 2024), when the method was addedintroduced 2024-08-12 · about 2 years
Affected
9.12.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
exhaustive checking of the core value-source adapters on tiny indexes

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Summary card. Title: toSortableLongDoubleValuesSource() loses its sortable encoding on rewrite; rewrite(searcher) returns the plain (long)-cast adapter toLongValuesSource(), and IndexSearcher.search rewrites the Sort. Table, doc / price / expected doubleToSortableLong(price) / actual after rewrite (long) price: doc 0, 0.75, 4604930618986332160, 0; doc 1, 0.25, 4598175219545276416, 0; doc 2, -0.5, -4602678819172646913, 0; doc 3, 2.5, 4612811918334230528, 2. src.toString() before: sortableLong(double(price)); after rewrite: long(double(price)); three prices, one key 0; 2.5 becomes 2. searcher.search(q, 10, sort), ascending: expected docs 2, 1, 0, 3; actual docs 0, 1, 2, 3, keys 0, 0, 0, 2; the ties stay in docID order, no exception. Keys change (2.0 becomes 2, not 2^62); the order breaks at ties (same integer part, all of (-1, 1) including -0.0), at saturation, and at NaN (0, not last). Also hit: the documented rewrite-then-getValues step, and toDoubleValuesSource() (function scores, rescorers): 0.75 becomes 0.0. Why: SortableLongDoubleValuesSource.rewrite (DoubleValuesSource.java:192-195) returns inner.rewrite(searcher).toLongValuesSource(), a copy of LongDoubleValuesSource.rewrite (:255-258), where it is right; IndexSearcher.search rewrites the Sort (IndexSearcher.java:730), and with it the source (LongValuesSource.java:306-316). Fix: return inner.rewrite(searcher).toSortableLongDoubleValuesSource(); executed as a shadow class, the sort gives 2, 1, 0, 3. Not affected: the source when it is not rewritten; its only in-tree caller (sandbox range facets) never rewrites. Affected (executed): all 16 releases from 9.12.0 (where the method was added) through 10.5.2 and main with -ea; -da on 9.12.0, 9.12.3, 10.0.0, 10.5.2 and main. Reach: narrow. Only code that calls this public core method and rewrites the result; no downstream use was found.
The four prices with their expected sortable longs and the casts after 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.
  1. You index four documents with a double price: 0.75, 0.25, -0.5 and 2.5 (docs 0-3), and wrap the field with fromDoubleField("price").toSortableLongDoubleValuesSource(): a long source whose longs sort like the doubles.
  2. Read directly, it keeps that promise: 0.75 gives 4604930618986332160 and -0.5 gives -4602678819172646913, four keys in price order.
  3. 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).
  4. Instead you get docs 0, 1, 2, 3 with sort keys 0, 0, 0, 2. IndexSearcher rewrites the sort, and this source's rewrite() returns toLongValuesSource(), a plain (long) cast: the three prices in (-1, 1) tie at 0 and stay in docID order.
  5. The documented rewrite-then-getValues step 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.

Who is affected

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.

Terms

  • DoubleValuesSource / LongValuesSource: produce one double (or long) 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 call rewrite(IndexSearcher) against the top-level searcher, and then getValues" (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 to LongValuesSource by calling NumericUtils#doubleToSortableLong" (:139-142), a bit-level encoding whose longs keep the order of Double.compareTo, NaN last (NumericUtils.java:38-43). It was added in 9.12.0 for the sandbox's range facets.
  • getSortField(reverse): every LongValuesSource can be sorted on; the sort field holds the source (LongValuesSource.java:77-79).

The bug

  1. Un-rewritten, the sortable source returns NumericUtils.doubleToSortableLong(in.doubleValue()) (DoubleValuesSource.java:159) and prints sortableLong(double(price)) (:188-190).
  2. Its rewrite returns inner.rewrite(searcher).toLongValuesSource() (:192-195): a LongDoubleValuesSource, which returns (long) in.doubleValue() (:217) and prints long(double(price)). The line is identical to LongDoubleValuesSource.rewrite (:255-258), where re-applying toLongValuesSource() is right.
  3. IndexSearcher.search(query, n, sort) and searchAfter rewrite the sort first (IndexSearcher.java:730, final Sort rewrittenSort = sort.rewrite(this);). LongValuesSortField.rewrite rewrites 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. A Sort used without them is not rewritten: search(Query, CollectorManager) (IndexSearcher.java:750) and TopFieldCollectorManager never call Sort.rewrite, and SortRescorer builds its collector from the Sort directly (SortRescorer.java:49); the same Sort in a hand-built TopFieldCollectorManager sorts correctly (executed).
  4. toDoubleValuesSource() on it rewrites the inner source and re-wraps it (LongValuesSource.java:120-123), so FunctionScoreQuery (when scores are needed, FunctionScoreQuery.java:118-121), DoubleValuesSourceRescorer and FunctionMatchQuery, which rewrite their source, see casts too (the last from reading only).

The numbers

Main a9779ef3c193, one segment, field price:

docpricedoubleToSortableLong(price) (expected)after rewrite
00.7546049306189863321600
10.2545981752195452764160
2-0.5-46026788191726469130
32.546128119183342305282

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 its getSortField(...) through IndexSearcher.search/searchAfter (which rewrite every Sort they are given), by following the documented rewrite-then-getValues protocol in their own code, or by passing toSortableLongDoubleValuesSource().toDoubleValuesSource() to FunctionScoreQuery (when scores are needed) or a DoubleValuesSourceRescorer.
  • Default vs opt-in. No setting is involved: default IndexWriterConfig, codec and IndexSearcher; 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 toString also changes (sortableLong(...) becomes long(...)). 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 with DoubleValuesSource#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; a fromQuery source throws until it is rewritten, DoubleValuesSource.java:768). The general workaround is to rewrite the double source and apply NumericUtils.doubleToSortableLong to its values, which is what the un-rewritten adapter does (:159).

Minimal repro

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.

TestSortableLongValuesSourceRewrite.java (JUnit test)52 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

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());
      }
    }
  }
}
Command
# 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.

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

More files, environment, full output40 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 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)

Why it looks unintended

  • The javadoc promises the encoding without condition ("Convert to LongValuesSource by calling NumericUtils#doubleToSortableLong"), and the class javadocs make rewrite-then-getValues the protocol.
  • Sibling adapters re-apply their conversion on rewrite; only this one switches, and its toString turns from sortableLong(...) to long(...): 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 to LongValuesSource by calling NumericUtils#doubleToSortableLong", on a public final method of a core class with no @lucene.experimental or @lucene.internal tag. rewrite is "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 LongValues object for a leaf reader, clients should call rewrite(IndexSearcher) against the top-level searcher, and then getValues(LeafReaderContext, DoubleValues)" (LongValuesSource.java:30-32; the same in DoubleValuesSource.java:34-36). IndexSearcher.search/searchAfter rewrite every Sort they are given (IndexSearcher.java:730), so on that standard sort path the inherited, unrestricted getSortField can never deliver the documented encoding (only a Sort that bypasses those methods keeps it).
  • The siblings keep their conversion. LongDoubleValuesSource.rewrite re-applies toLongValuesSource() (DoubleValuesSource.java:255-258), DoubleLongValuesSource.rewrite re-applies toDoubleValuesSource() (LongValuesSource.java:120-123). Only the sortable adapter switches, to a different class with a different toString and equals.
  • 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/test calls toSortableLongDoubleValuesSource; 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 added rewrite to 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.

Suggested fix

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.

Re-apply the sortable adapter on rewrite (validated by the checks) (recommended)

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.rewrite still builds a new sort field.
  • No benchmark was run. The work per value stays constant but changes: Double.doubleToLongBits plus 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=true and =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 of org.apache.lucene.queries.function.* (including TestFunctionScoreQuery, TestFunctionMatchQuery); org.apache.lucene.facet.range.*; org.apache.lucene.sandbox.facet.* (including TestRangeFacet, the only caller's tests): 30 upstream classes, 292 tests, 0 failures.
  • ecjLint and checkGoogleJavaFormat for lucene/core with 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.

The same, but return this when the inner source did not change

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.

Document that rewrite casts

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.

Downstream note

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.

How it was found

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 DoubleValuesSource and 85 LongValuesSource cases on the stock jars (the core factories and adapters it models, including toLongValuesSource, toSortableLongDoubleValuesSource and toDoubleValuesSource compositions; not covered: vector-similarity sources, expression bindings, the facet module's multi-valued sources and TOP_SCORES block-max bounds) through rewrite(searcher) + getValues, FunctionScoreQuery, boostByValue/boostByQuery, getSortField and DoubleValuesSourceRescorer, 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_VALUE and ±(2^53+1)), all segmentations, deletion masks and two-index MultiReader splits: 1,752 configs, 4,658,632 checks, 649 mismatch keys, every one a source containing toSortableLongDoubleValuesSource() 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream repositories; the web2026-10-27the 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 readno 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-2716 manual issue/PR searches for the method and class names and symptom phrases; code search for the methodno report; code search finds only copies of Lucene sources, with the same line
apache/lucene PRs2026-10-27changed files of all 408 open PRs and of 2,300 closed PRs created since 2024-07-17; the #13568 review and conversation comments4 open PRs touch the files, none changes this rewrite; #13568's review never mentions rewrite
upstream branches2026-10-27file contents on main f7bcc0abd816, branch_10x, branch_10_6, branch_10_5, branch_9_12; CHANGESthe line is unchanged everywhere; no fix entry

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe 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) affectedfails 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 releasefails 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 methodthe repro does not compile: toSortableLongDoubleValuesSource() does not exist
9.12.0 and 10.5.2 with the one-line fix fixthe release's own file with only the rewrite line changed: 3 of 3 pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive 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 realOwn 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, mediumReading-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, highBuilt 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, highRe-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 keepRe-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 validatedGradle in a clean worktree at a9779ef3c193: 2 of 3 fail stock, 3 of 3 pass with the fix; 30 nearby test classes pass.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,325 words

Finding record

LFV-770, snapshot of 2026-11-06

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; -da as 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 main ea57360912ed, whose value-source files are identical.

Lucene and upstream sources

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-258
  • lucene/core/src/java/org/apache/lucene/search/LongValuesSource.java:30-32, 64-70, 77-79, 92-94, 120-123, 306-316
  • lucene/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-43
  • lucene/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-67
  • lucene/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-258
  • lucene/core/src/java/org/apache/lucene/search/LongValuesSource.java:30-32, 64-70, 120-123
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:730
  • lucene/facet/src/java/org/apache/lucene/facet/MultiDoubleValuesSource.java:41-53
  • lucene/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

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

Repro files

About the diagrams

Made in parallel with this doc from 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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.