All findings · Lucene finding · LFV-221 · doublevaluessource-field-bound-rounds-to-nearest-float

DoubleValuesSource rounds the skip-index bound to the nearest float, so a boosted FunctionScoreQuery reports a max score one float step below a real score and top-k search can return the wrong top hit

On unreleased main, a boosted FunctionScoreQuery on a skip-indexed field can bound a block at 25,165,824 while one of its documents scores 25,165,826, because the block's largest value, 16,777,217, is rounded to the nearest float first. Once another hit scores 25,165,824, top-k search skips the block and misses it.

Summary

Priority
Low, rank 719The skip-index bound rounded to the nearest float.
Impact
silent wrong results
Since
#16431 (main, 2026-08-22)introduced 2026-08-22
Affected
main and branch_11x (unreleased 11.0.0); no release
Status
documented
Found by
semi-formal: an SMT model of the score bound, then a public-API JUnit

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

FunctionScoreQuery scores a document by rounding value × boost once, but its block-max bound first rounds the block's largest value to the nearest float and then multiplies. The two can differ by one float step, and the bound comes out below a real score.

Background

  1. Floats. Lucene scores are 32-bit floats. Above 2^24 = 16,777,216 a float holds only even integers, so 16,777,217 is stored as 16,777,216; near 25 million, floats are 2 apart (25,165,824, 25,165,826, ...).
  2. Skip index. A numeric doc-values field can opt in to a skip index (NumericDocValuesField.indexedField): for each interval of documents (with the default codec normally 4,096 that have a value) it stores the smallest and largest value.
  3. Block-max pruning. For top-k search every clause reports, per block of documents, an upper bound of its scores (getMaxScore). With plain search(q, n), once more than 1,000 hits have been counted the searcher asks for scores of at least the next float above the k-th score and skips any block whose bound is lower.
  4. New in #16431. FunctionScoreQuery now reports (wrapped query's bound) × (the block's largest value, as a float) × boost instead of +∞, so blocks of small values can be skipped.
Diagram of the bug: a skip-indexed field count holds 16,777,216 in doc 0 and 16,777,217 in doc 4999 (second skip block). The query ranks by count with boost 1.5. Doc 4999's real score is (float)(16,777,217 x 1.5) = 25,165,826, but its block's bound is (float)16,777,217 x 1.5 = 16,777,216 x 1.5 = 25,165,824. After collecting doc 0 at 25,165,824 the searcher needs at least 25,165,826, sees the bound 25,165,824 and skips the block, so search(q, 1) returns doc 0. With the fix the bound rounds up to 16,777,218 x 1.5 = 25,165,828 and doc 4999 is returned.
One block, two roundings: the score rounds 16,777,217 × 1.5 = 25,165,825.5 once, to 25,165,826; the bound first rounds 16,777,217 to 16,777,216 and claims 25,165,824. After doc 0 at 25,165,824 the searcher skips the block and misses doc 4999. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index a long field with the doc-values skip index (NumericDocValuesField.indexedField("count", v)) and rank with BoostQuery(FunctionScoreQuery(MatchAllDocsQuery, fromLongField("count")), 1.5f) OR a text clause. 5,000 documents: doc 0 has count = 16,777,216 (2^24), doc 4999 has 16,777,217, the rest 0.
  2. You expect doc 4999 on top: 16,777,217 × 1.5 = 25,165,825.5, which as a float is 25,165,826 and beats doc 0's 25,165,824. An exhaustive search agrees.
  3. Instead plain IndexSearcher.search(q, 1) returns doc 0 (25,165,824). No error, with assertions on or off.
  4. Why: to allow skipping, doc 4999's block claims "nobody here scores more than (float) 16,777,217 × 1.5". A float cannot hold 16,777,217 (above 2^24 floats are 2 apart), so it becomes 16,777,216 and the claim is 25,165,824. With doc 0 on top the searcher needs at least 25,165,826, trusts the claim and skips the block.
  5. Doubles do it too: 0.065 with boost 1.1 scores 0.0715 under a claim of 0.071499996. Boost 1 and values that are exact floats are safe; rounding the claim up (Math.nextUp) fixes it.

Who is affected

Applications on main or the 11.0 branch (unreleased, since #16431) that rank with FunctionScoreQuery over fromLongField, fromIntField, fromDoubleField or a monotone fromField on a skip-indexed field (opt-in), with a boost other than 1, next to another scoring clause. It needs a near-tie: the lost hit beats a collected one by about one float step.

Mechanism, terms and numbers671 words

Since #16431 (unreleased, 11.0.0) the field sources of DoubleValuesSource (fromLongField, fromIntField, fromDoubleField, fromField with a declared monotonicity) report per-block bounds from the doc-values skip index, and FunctionScoreQuery turns them into a block-max score for top-k pruning. The value bound is (float) decoder.applyAsDouble(skipper.maxValue(level)): a nearest-float cast, which for a value that is not a float can land below the block's largest value (16,777,217 becomes 16,777,216). FunctionScoreQuery then multiplies that rounded value by the boost in float, while it scores each document as (float) (value * boost) from the exact value. With boost 1.5 a document scores 25,165,826 under a block bound of 25,165,824, one float step lower. MaxScoreBulkScorer (and BlockMaxConjunctionBulkScorer) trust the bound, so once the k-th collected hit scores 25,165,824 the block is skipped and the better document is lost. With the default codec and plain IndexSearcher.search(q, 1), a 5,000-document index returns the wrong top hit; boost 1, float-exact values and fromFloatField are not affected. Only main and branch_11x have it; every release returns the right hit. Rounding the float bound outward in DoubleValuesSource fixes it.

The code (main 645d4fe14a20)

  1. The value bound rounds to nearest. DoubleValuesSource.java:571-584 (getMaxScore of the field source): with a skip index and a declared monotonicity it picks the first skipper level that covers upTo and returns (float) decoder.applyAsDouble(rawBound) (:580), where rawBound is the level's maxValue (INCREASING) or minValue (DECREASING) (:576-579). getMinScore (:586-599) does the same at :595. A Java narrowing cast rounds to nearest, so the max bound can be below the largest value and the min bound above the smallest (2^24 + 3 becomes 16,777,220).
  2. Which sources. fromLongField is fromField(field, v -> (double) v, INCREASING), fromIntField returns fromLongField, fromDoubleField decodes raw bits, fromFloatField decodes a float (DoubleValuesSource.java:329-347). Longs above 2^24 in magnitude and doubles that are not floats can round the wrong way; fromFloatField values are floats and cannot.
  3. The score rounds once, the bound twice. FunctionScoreQuery.java:243-252: score() returns (float) (factor * boost) from the exact double (:247). :262-269: getMaxScore returns innerMaxScore * valueMaxScore * boost in float (:268) from the already-rounded value bound. With a constant-score inner query (bound 1) and boost 1 the bound stays sound because the cast is monotone; with any other boost it can end one float below the score.
  4. Pruning trusts it. IndexSearcher.search(q, n) collects with TopScoreDocCollectorManager(n, after, 1000) (IndexSearcher.java:100, :599), i.e. TOP_SCORES (TopScoreDocCollector.java:58-60); once more than 1,000 hits have been counted (:155) it sets the minimum competitive score to Math.nextUp of the k-th score (:154-160). MaxScoreBulkScorer.partitionScorers() adds the clause bounds of a window and skips the window when the sum is below that minimum (MaxScoreBulkScorer.java:474-517, :502; skip at :133-138).
  5. Only near-ties are lost. The bound is about one float step low, so a document is dropped only when an already-collected hit scores within about one float step below it.

The numbers

One document (the minimal repro): count = 16,777,217, boost 1.5. (float) 16777217.0 = 16,777,216; 16777216f * 1.5f = 25,165,824 (exact); (float) (16777217.0 * 1.5) = (float) 25165825.5 = 25,165,826. The scorer reports getMaxScore 25,165,824 and score() 25,165,826.

5,000 documents in one segment (the finder's test): doc 0 = 2^24 (score 25,165,824), doc 4999 = 2^24 + 1 (score 25,165,826), only doc 1 matches body:b, query SHOULD boosted function query + SHOULD body:b. The skip interval of docs 4096-4999 reports the value bound 16,777,216 and the score bound 25,165,824, and the text clause has no document there (window bound 0, MaxScoreBulkScorer.java:431-445); with doc 0 on top the searcher needs 25,165,826, so that window is skipped and the search returns doc 0. The same with plain search(q, 1), and with MUST function query + MUST text clause (BlockMaxConjunctionBulkScorer).

Ordinary doubles (fromDoubleField, boost 1.1): 0.065 scores 0.0715 under a bound of 0.071499996; with doc 4999 = 1000.0007, plain search(q, 1) returns doc 0 (1100.0007) instead of doc 4999 (1100.0009).

Exposure and likelihood262 words
  • Who. Users of main or branch_11x who rank with FunctionScoreQuery over fromLongField, fromIntField, fromDoubleField or a fromField with a declared monotonicity, on a numeric field with the opt-in doc-values skip index (NumericDocValuesField.indexedField, or a field type with DocValuesSkipIndexType.RANGE), with a boost other than 1 (a BoostQuery or a boosted clause), as one scoring clause next to another: executed for SHOULD + SHOULD and for MUST + MUST with a text clause.
  • Trigger. A skip block whose largest value is not exactly a float: a long above 2^24 (16,777,216) in magnitude that a float cannot hold (every odd one, for example; 2^24 + 2 is a float), or an ordinary double such as 0.065 or 1000.0007. For such fields the low bound is common; a hit is lost only when it beats an already-collected hit by about one float step. Deterministic for a fixed index and query.
  • Effect. Silent wrong results: the true top hit (or a top-k member) is replaced by a slightly worse one, and Scorer#getMaxScore and DoubleValues#getMaxScore report bounds below real scores and values. No exception, default codec and searcher, assertions on and off. Never a superset: returned hits still match the query and all its filters.
  • Not affected. Boost 1, values that are exact floats, fromFloatField, fields without a skip index, boostByValue and boostByQuery (their bound is +∞), and every release.
  • How likely: rare. The one-float-step near-tie is the limit: the finder's randomized TOP_SCORES-versus-exhaustive comparison (202,320 top-k queries with the bug present) never hit one by chance; the repros construct it on purpose.

Minimal repro

One JUnit test, one document, public API: count = 16,777,217 in a skip-indexed field, the query BoostQuery(FunctionScoreQuery(MatchAllDocsQuery, fromLongField("count")), 1.5f), and its TOP_SCORES scorer must not report a max score below the document's score.

TestFunctionScoreQueryMaxScoreRounding.java (one test, public API)63 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.queries.function;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.BoostQuery;
import org.apache.lucene.search.DoubleValuesSource;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreMode;
import org.apache.lucene.search.Scorer;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFunctionScoreQueryMaxScoreRounding extends LuceneTestCase {

  public void testBoostedLongScoreWithinMaxScore() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(NumericDocValuesField.indexedField("count", (1L << 24) + 1));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query query =
            new BoostQuery(
                new FunctionScoreQuery(
                    MatchAllDocsQuery.INSTANCE, DoubleValuesSource.fromLongField("count")),
                1.5f);
        Scorer scorer =
            searcher
                .createWeight(searcher.rewrite(query), ScoreMode.TOP_SCORES, 1f)
                .scorer(reader.leaves().get(0));
        float maxScore = scorer.getMaxScore(scorer.advanceShallow(0));
        assertEquals(0, scorer.iterator().nextDoc());
        float score = scorer.score();
        // expected: score <= getMaxScore; actual: 2.5165826E7 > 2.5165824E7
        assertTrue("score " + score + " > getMaxScore " + maxScore, score <= maxScore);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFunctionScoreQueryMaxScoreRounding -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
score() <= getMaxScore(upTo): the block's max score covers the document's score 25,165,826.
Observed
AssertionError: score 2.5165826E7 > getMaxScore 2.5165824E7, with assertions on and off.

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

More files, environment, full output251 words

ByteBuffersDirectory and a plain IndexSearcher. The codec is the test framework's random choice (Asserting(Lucene104) with seed DEADBEEF); the doc-values formats it can pick all implement the skip index, and with one document the block's largest value is that document's value, so the outcome does not depend on the seed.

The test shows the broken bound on one document. The wrong top hit described above needs a second scoring clause and two skip intervals (5,000 documents); it is the same bug and is not part of this repro.

Regression test: this same test; it passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Release 10.6.0: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestFunctionScoreQueryMaxScoreRounding.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/function/
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFunctionScoreQueryMaxScoreRounding -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed; -Ptests.asserts=false identical)
> Task :lucene:queries:test FAILED

TestFunctionScoreQueryMaxScoreRounding > testBoostedLongScoreWithinMaxScore FAILED
    java.lang.AssertionError: score 2.5165826E7 > getMaxScore 2.5165824E7
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:BDFF435CEEBB315A]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.queries.function.TestFunctionScoreQueryMaxScoreRounding.testBoostedLongScoreWithinMaxScore(TestFunctionScoreQueryMaxScoreRounding.java:59)
  2> NOTE: test environment is: codec=Asserting(Lucene104): {}, ...
1 test completed, 1 failed

# the finder's 5,000-document class on the same stock build (gradle, seed DEADBEEF, -ea and -da): 3 tests, 3 failed
testTopHitWithDynamicPruning  java.lang.AssertionError: TOP_SCORES top hit (score 2.5165824E7) differs from COMPLETE expected:<4999> but was:<0>
testMaxScoreIsUpperBound      java.lang.AssertionError: doc 4999 scores 2.5165826E7 > getMaxScore(4999) = 2.5165824E7
testValueBound                java.lang.AssertionError: value 1.6777217E7 > getMaxScore(4999) = 1.6777216E7

Why it looks unintended

  • DoubleValues#getMaxScore is "the maximum score that documents ... can produce", with no caveat; upstream's test asserts doubleValue() <= getMaxScore(upTo), only with float-exact values.
  • The pruned search returns a different top hit than the exhaustive one.
  • #13397 fixed a float bound below actual scores as a bug; #16431's review never discussed rounding.
The full argument269 words
  • The contracts. DoubleValues#getMaxScore returns "the maximum score that documents between the current position and upTo can produce" (DoubleValues.java:55-58); Scorer#getMaxScore is the maximum score of the documents up to upTo (Scorer.java:75-79); Scorable#setMinCompetitiveScore lets a scorer "safely ignore all documents whose score is less than the given minScore" (Scorable.java:45-51). Here a document whose score equals the minimum (25,165,826) is ignored.
  • Upstream tests. TestDoubleValuesSource.java:412-422 (testMaxScoreBeyondTheCurrentBlock, from #16697) asserts getMinScore(upTo) <= doubleValue() <= getMaxScore(upTo), the exact double against the float bound, over the values 0-199 (:384-397), all exact floats. TestFunctionScoreQuery has no skip-indexed field at all, so its bound tests take the +∞ branch. The test framework asserts score <= maxScore (CheckHits.java:766, AssertingScorer.java:130) and equal TOP_SCORES and COMPLETE top hits (CheckHits.checkTopScores, :690-697).
  • Lucene rounds bounds outward elsewhere. The collector reasons at one-float precision ("we can require the next float", TopScoreDocCollector.java:154-160); MathUtil.sumUpperBound pads summed bounds for floating-point error (MathUtil.java:177-194).
  • History. #16431's description shows the cast, (float) decoder.applyAsDouble(skipper.maxValue(0)); its review (19 comments, 14 inline comments, 15 reviews) discussed monotonicity only, and the author (rajat315315), explaining why a decreasing function takes the skipper's minimum, wrote that this gives "exact and sound block upper bounds" (PR comment, 2026-07-29). #16697 fixed two other defects of the same methods and kept the cast.
  • Precedent. #13397 (jpountz, merged 2024-05-21, closes #13371 and #13396): "It sums up max scores in a float when it should sum them up in a double like we do for Scorer#score(). Otherwise, max scores may be returned that are less than actual scores."
The devil's-advocate check144 words

The strongest case: scores are floats, so the nearest float is the tightest cheap bound, and a one-ulp near-tie is noise. A weaker one: upstream's own customSqrt test source also casts to nearest (TestFunctionScoreQuery.java:538).

Why it fails: the bound is then below the score Lucene itself returns for that document, so the pruned search of a query returns a different top hit than the exhaustive search of the same query, which the TOP_SCORES contract and the test framework do not allow; the collector's own tie-break works at exactly that precision (Math.nextUp). The customSqrt source is test code with a loose assert (maxScore >= 22.3f, :586); it shows the rounding direction was not considered, not that it was chosen. Attribution caveat: the defect can equally be placed in FunctionScoreQuery's float product; the suggested fix covers both levels.

Suggested fix

Recommended Round the skip-index bound outward in DoubleValuesSource (validated)

Round the float bound outward in DoubleValuesSource: in getMaxScore, return floatBound < bound ? Math.nextUp(floatBound) : floatBound; (and Math.nextDown in getMinScore). Values that are floats keep their exact bound. The repro and the finder's 5,000-document test pass, and so do the closest suites and the whole queries and expressions modules.

// getMaxScore
double bound = decoder.applyAsDouble(rawBound);
float floatBound = (float) bound;
// round up: the float bound must not be below the double values it bounds
return floatBound < bound ? Math.nextUp(floatBound) : floatBound;

// getMinScore
double bound = decoder.applyAsDouble(rawBound);
float floatBound = (float) bound;
// round down: the float bound must not be above the double values it bounds
return floatBound > bound ? Math.nextDown(floatBound) : floatBound;
All options and trade-offs3 options · 562 words

Option A was built and run through gradle. B (round up in FunctionScoreQuery) and C (double-valued bounds) were not run; B must round up unconditionally, because the value already arrives rounded down and 16,777,216 × 1.5 is exact.

Round the skip-index bound outward in DoubleValuesSource (validated) (recommended)

In the field source's getMaxScore and getMinScore (DoubleValuesSource.java:580, :595), keep the decoded double and round the float bound away from it only when the cast moved the wrong way (+8/-2 lines).

// getMaxScore
double bound = decoder.applyAsDouble(rawBound);
float floatBound = (float) bound;
// round up: the float bound must not be below the double values it bounds
return floatBound < bound ? Math.nextUp(floatBound) : floatBound;

// getMinScore
double bound = decoder.applyAsDouble(rawBound);
float floatBound = (float) bound;
// round down: the float bound must not be above the double values it bounds
return floatBound > bound ? Math.nextDown(floatBound) : floatBound;

Trade-offs

  • Fixes both levels. DoubleValues bounds then cover the double values, as TestDoubleValuesSource expects. For FunctionScoreQuery with a constant-score inner query: the rounded-up value bound and the boost are floats, their product is exact in double and rounding is monotone, so the float product covers (float) (v * boost) for every value below the bound (the finder's reasoning; the finder's 5e7-pair JVM check found 0 violations with the fix and 1,975,394 without, not re-checked by a verifier).
  • Cost. Unchanged for values that are floats (longs up to 2^24 in magnitude, float-valued doubles); otherwise one float looser (for the repro value 16,777,218, and the function bound 25,165,828). No benchmark was run.
  • NaN. Both comparisons are false for NaN, so a NaN bound passes through unchanged (the separate LFV-189).
  • Scope. It does not change FunctionScoreQuery; the other bound defects of the same feature (LFV-189, LFV-203, LFV-211, LFV-214) need their own changes.

What the checks validated

Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-31):

  • stock: the minimal test fails with assertions on and off; the finder's 5,000-document class fails 3 of 3 with assertions on and off;
  • with the fix: the minimal test passes with assertions on and off; the finder's class passes 3 of 3;
  • core TestDoubleValuesSource, TestDoubleValuesSourceRescorer, TestLongValuesSource, TestSortRescorer and TestMaxScoreBulkScorer pass (38 tests, 0 failures); queries TestFunctionScoreQuery, TestFunctionScoreExplanations, TestFunctionQuerySort, TestFunctionMatchQuery and TestValueSources pass (67 tests, 0 failures);
  • the whole :lucene:queries:test (512 tests, 0 failures, 5 skipped) and :lucene:expressions:test (170 tests, 0 failures) pass; checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain for core pass.
  • Earlier: the same change makes every verifier's and reviewer's own test pass, and with all five fixes of this hunt combined upstream TestFunctionScoreQuery (17) and TestDoubleValuesSource (11) pass.

Round FunctionScoreQuery's bound up instead (not run)

At FunctionScoreQuery.java:268, return Math.nextUp(innerMaxScore * valueMaxScore * boost).

Trade-offs

It must round up unconditionally: the value bound already arrives rounded down, and 16,777,216 × 1.5 = 25,165,824 is exact, so rounding up only an inexact product changes nothing for the repro (arithmetic executed). Whether one extra step always covers the two roundings (value, then product) against the score's one was not checked, and DoubleValues#getMaxScore stays below the value for any other consumer.

Double-valued bounds (not run)

Return the bounds as doubles from DoubleValues and round once, upward, in FunctionScoreQuery.

Trade-offs

Numerically cleanest, but it changes the signature of DoubleValues#getMaxScore/getMinScore, which #16431 added; that is still possible before 11.0.0 ships.

Downstream note

Where end users set the values of such a field, a value can only lower its own document's bound, so the effect stays an ordinary near-tie ranking error; returned hits always respect the query's filters, and no Elasticsearch, OpenSearch or Solr call site of this query shape is known.

How it was found

While writing an exact floating-point SMT model of the new score bound, the finder asked whether a score can exceed its bound with a boost other than 1; Z3 and cvc5 found such values, and proved none exist for boost 1 with a constant-score inner query. A Java replay and a public-API JUnit then confirmed it.

The method in full166 words

Method: semi-formal (the hunt's tier for a hypothesis formed while modelling and then witnessed by a solver). The hunt unit covered function queries and block-max pruning.

  • Model. The score and the block bound of FunctionScoreQuery over the field source were written in IEEE-754 SMT (QF_FP), with a model-to-Java line map. Z3 4.12.2 and cvc5 1.4.2: score above bound with a constant-score inner query and a boost other than 1 is SAT (one query for doubles, one for longs); with boost 1 it is UNSAT; the float cast is monotone (UNSAT). The registration check re-ran these queries with both solvers and replayed the fresh models as Java arithmetic: all violate score <= bound.
  • JUnit. The models were replayed on the JVM, generalised (long 2^24 + 1, doubles such as 0.065 with boost 1.1) and turned into a directed public-API test (5,000 documents) that fails on main with assertions on and off; the minimal repro above is that test cut down to one document and one assertion.

Prior reports, history and checks

Prior reports

No prior report found in the documented searches (267 queries and full PR threads on 2026-10-26; re-checked 2026-10-31). Closest: #16431 itself, whose description shows the cast but whose review discussed only monotonicity, and #16697, which kept it.

Searches3 searches
  • #16431 (the introducing PR): the description gives getMaxScore as (float) decoder.applyAsDouble(skipper.maxValue(0)); the review covered non-monotonic, decreasing and constant functions; nobody mentioned rounding, precision, ulps or values above 2^24.
  • #16697 fixed advanceShallow and the level selection in the same methods and kept the cast; its new test asserts the value-space bound with float-exact values only. #16429 is the feature request.
  • Precedent, not prior art: #13397 (2024, closes #13371 and #13396), a float max-score sum below actual scores in BlockMaxConjunctionBulkScorer.
  • Same family (the hunt's own findings): LFV-189, LFV-203, LFV-211, LFV-214, LFV-273 on the same feature; LFV-060 is the same bug class in MaxScoreBulkScorer.
  • Limits: private reports and unindexed discussion cannot be ruled out; the code exists only since 2026-08-22.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr)2026-10-26267 logged read-only queries (243 through a scripted prior-report search, 24 manual): class and method names with rounding, ulp, nextUp, nearest float, upper bound; the block-max side (DocValuesSkipper, Monotonicity, TOP_SCORES); the bug class anywhere. Full threads of #16431, #16429, #16697, #16628, #16705, #13371, #13396 and #13397 read and grepped (review comments are not indexed by search); changed files of all 413 open PRs16 hits read; none reports this bug
Mailing lists (dev@, java-user@, issues@, commits@, builds@), JIRA archive, web2026-10-26140 list queries, 22 JIRA-archive queries, 8 web searchesonly notification mails of #16431, #16429 and #16697 and older unrelated threads
GitHub issues and PRs (apache/lucene)2026-10-318 searches on items updated since 2026-10-24 (DoubleValuesSource getMaxScore, FunctionScoreQuery rounding, FunctionScoreQuery nextUp, getMaxScore float rounding, DoubleValuesSource skipper, FunctionScoreQuery block-max, 16431, nearest float bound); titles of all 93 PRs and 27 issues updated since 2026-10-26; tracking issue #16937nothing new

History

Introduced by #16431 (0d4f93a9ff9, merged 2026-08-22), which replaced the +∞ bound; the wrong top hit of the tested SHOULD query is reachable end to end since #16697 (2026-10-09). On main and branch_11x; no release has it (10.0.0, 10.5.2 and 10.6.0 are correct).

Versions tested7 rows

Introduced by 0d4f93a9ff93ccc396c1323f26416b57d8dc08d9, #16431 "Enable Block-Max WAND dynamic pruning for FunctionScoreQuery via DocValuesSkipper (+422% QPS)" (rajat315315, co-author uschindler; merged to main 2026-08-22; resolves #16429). It added both halves of the mismatch: the nearest-float cast of the decoded skipper bound and the float product in getMaxScore. The score side, (float) (factor * boost), dates from LUCENE-7996 (2017) and was harmless while the bound was +∞. Blame of the lines and git log -S return only this commit.

Masked at first: until #16697 (d150ba27d11, easyice, 2026-10-09) the tested SHOULD query threw inside the skipper (a separate advanceShallow defect) before it could return a wrong hit; the bounds were already one float low at the introducing commit, and #16697's change shadowed onto it gives the wrong top hit.

Now: main is 12.0.0-SNAPSHOT and branch_11x is the 11.0.0 branch; both carry identical files (also at branch_11x d0c0decc6606, 2026-10-30, by a read-only GitHub check on 2026-10-31). branch_10_6 and the 10.6.0 tag do not contain #16431.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off (gradle and standalone); the 5,000-document class fails 3 of 3; passes with the fix
main d150ba27d11, ea57360912ed, a9779ef3c193 affectedthe finder's class fails 3 of 3, including the wrong top hit (assertions on and off on d150ba27d11 and ea57360912ed; on a9779ef3c193 assertions on)
0d4f93a9ff9 (#16431) bounds onlyboth bounds one float low; the end-to-end query threw in the skipper first; with #16697's change shadowed: the wrong top hit
main 2f424fd428d, branch_11x 603af870375 same codethe three files are byte-identical to 645d4fe14a20 (read, not run)
10.6.0 not affectedminimal test passes; the bound is +∞
10.0.0, 10.5.2, parent a1fcd50799f not affecteda portable copy of the finder's test passes (10.0.0 with assertions on; 10.5.2 and the parent on and off); the bound is +∞
9.12.3 no skip indexthe trigger cannot be written: NumericDocValuesField.indexedField does not exist

Independent checks

Reproduced by an independent reproducer, a devil's-advocate verifier, two executed reviews by OpenAI GPT-5.6, an adjudication with fresh seeds, re-verification on newer main and this doc's gradle run; boost-1, float-exact and no-skip-index controls pass, and the outward-rounding fix passes every repro.

Checks and verdicts323 words

Every check ran its own tests or re-ran an earlier one on stock jars; the cross-model reviewers executed code.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (SMT, then JUnit) candidateZ3 and cvc5: score above bound with boost other than 1 (SAT), sound with boost 1 (UNSAT); directed 3-test JUnit fails 3/3 on main with assertions on and off.
Refute lens (OpenAI GPT-5.6, executed) real (high)Its own class fails 3 of 4 with assertions on and off; the float-exact 2^24 control passes; a plain check without the test framework fails; with the fix 4/4 and upstream classes 11/11 and 17/17.
Independent reproducer realLong 2^24 + 1 with boost 1.5 and double 0.065 with boost 1.1 fail; TOP_SCORES and plain search(q, 1) return doc 0; getMinScore rounds 2^24 + 3 up; controls, 10.5.2 and the fix pass.
Works-as-designed lens (OpenAI GPT-5.6) real (high)Intended-behaviour argument weak; its own class fails 3 of 4; fromDoubleField fails and fromFloatField passes; the fix passes.
Intended-behaviour devil's advocate realNo intended, documented, misuse or unreachable argument survives; 1000.0007 with boost 1.1: plain search(q, 1) returns doc 0 (1100.0007) instead of doc 4999 (1100.0009); boost-1 control and fix pass.
Adjudication confirmed (high)Re-ran the independent reproduction's tests with seed C0FFEE and assertions off (3 of 5 fail, controls pass) and the finder's with seed 1234ABCD (3/3 fail).
Registration check and re-verification on newer main still reproducesIndependent cvc5/z3 re-check of the SMT queries; on 645d4fe14a20 every recorded variant gives the same outcomes and messages, plus a MUST + MUST conjunction probe; the fix passes.
This doc's fix validation (gradle) real; fix validatedScratch worktree of 645d4fe14a20: stock fails the minimal test and the finder's class (assertions on and off); the outward-rounding fix passes them, the closest suites, the whole queries and expressions modules (682 tests) and core lint; 10.6.0 passes the minimal test.

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

Finding record

LFV-221, snapshot of 2026-11-06

Registry title
DoubleValuesSource rounds the skip-index block bound to the nearest float, so with a boost FunctionScoreQuery's max score can be one ulp below a real score and top-k search returns the wrong top hit
Area
search / function scoring (DoubleValuesSource skip-index score bounds for FunctionScoreQuery block-max pruning)
Priority
Low, rank 719: The skip-index bound rounded to the nearest float.
Impact class
silent wrong results
Impact note
Applications on unreleased main (11.0.0-SNAPSHOT, since #16431) that index a numeric field with the opt-in doc-values skip index (NumericDocValuesField.indexedField) and rank it with FunctionScoreQuery over fromLongField or fromDoubleField (fromIntField and monotone fromField decoders by code reading) with a boost other than 1, as a scoring clause next to another scoring clause: when a skip block's largest value is not exactly a float (a long above 2^24, or an ordinary double such as 0.065 or 1000.0007), that block's score bound is about one float ulp below its best score, so the default IndexSearcher.search(q, n) (TOP_SCORES) can drop a hit that beats the current k-th hit by less than about one ulp, with the default codec, -ea or -da, at a few thousand docs; boost 1, fromFloatField and boostByValue/boostByQuery are not affected, and no released version is affected.
Summary
You index a long counter with the doc-values skip index (NumericDocValuesField.indexedField('count', v)) and search BooleanQuery(SHOULD BoostQuery(FunctionScoreQuery(MatchAllDocsQuery, DoubleValuesSource.fromLongField('count')), 1.5f), SHOULD TermQuery(body:b)), expecting the same top hit as an exhaustive search. Instead, with 5,000 docs where doc 0 holds 2^24 and doc 4999 holds 2^24 + 1, the pruned search (TOP_SCORES, which plain IndexSearcher.search(q, 1) also uses) returns doc 0 (score 25165824) instead of doc 4999 (score 25165826). The cause is that the block bound is the decoded block maximum cast to the nearest float (2^24 + 1 becomes 2^24) and FunctionScoreQuery multiplies that rounded float by the boost, so the block's bound (25165824) is one float step below the score it must cover and the block is skipped; ordinary doubles do the same (0.065 with boost 1.1 scores 0.0715 under a bound of 0.071499996).
Introduced
0d4f93a9ff93ccc396c1323f26416b57d8dc08d9 on main (apache/lucene#16431 'Enable Block-Max WAND dynamic pruning for FunctionScoreQuery via DocValuesSkipper (+422% QPS)', by rajat315315 with uschindler as co-author, related issue #16429, merged 2026-08-22) [exec: the follow-up history sweep shows both bound tests failing at 0d4f93a9ff9 with the same numbers as on main, -ea and -da, while the parent a1fcd50799f, 10.0.0 and 10.5.2 pass the version-portable repro; read: git blame of DoubleValuesSource.java:580 and :595 and of FunctionScoreQuery.java:268, and git log -S, return only 0d4f93a9ff9]; it added both halves of the mismatch, the nearest-float cast of the decoded skipper bound and the float product innerMaxScore * valueMaxScore * boost, while score() has returned (float) (value * boost) from the exact double since LUCENE-7996 (a8a63464e7d, 2017), which was harmless while the bound was +Infinity; the silent wrong top hit of the tested query became reachable end to end with #16697 (d150ba27d11, by easyice, 2026-10-09), which fixed a separate advanceShallow defect that made that query throw first and kept the cast [exec: #16697's DoubleValuesSource change shadowed onto 0d4f93a9ff9 gives main's wrong top hit, and adding the candidate fix makes all 3 tests pass] (2026-08-22 (0d4f93a9ff9 on main); wrong top hit of the tested query reachable end to end from 2026-10-09 (d150ba27d11, #16697))
First affected version
unreleased (main only; 11.0.0 would be the first release with it) [exec: 10.0.0 and 10.5.2 correct; read: no release tag contains 0d4f93a9ff9, CHANGES.txt lists GITHUB#16431 and GITHUB#16697 under Lucene 11.0.0, and branch_10x and branch_10_6 do not contain #16431]
Affected versions
main (11.0.0-SNAPSHOT) from 0d4f93a9ff9 (2026-08-22) through 645d4fe14a2 (upstream main head on 2026-10-29) [exec: DoubleValues#getMaxScore one ulp below the value and FunctionScoreQuery's Scorer#getMaxScore one ulp below the score at 0d4f93a9ff9, d150ba27d11 and main, -ea and -da; silently wrong TOP_SCORES top hit at d150ba27d11 and main, and at 0d4f93a9ff9 only with #16697's change shadowed, because before #16697 the tested query threw in the skipper first]; not affected: 10.0.0 [exec, -ea] and 10.5.2 [exec, -ea and -da] (the version-portable repro passes; the DoubleValues bound API does not exist there and FunctionScoreQuery's bound is +Infinity) and the parent commit a1fcd50799f [exec]; not applicable: 9.x [exec on 9.12.3: compile error, no doc-values skip index]; unreleased branch_10x and branch_10_6 do not contain #16431 [read, 2026-10-26; re-read 2026-10-29 at branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: Z3/cvc5
Upstream and prior reports
none found (2026-10-26, the prior-report record: 267 logged read-only queries, 243 through a scripted prior-report search over apache/lucene issues and PRs, commits, the Elasticsearch, OpenSearch and Solr repositories, the dev@, java-user@, issues@, commits@ and builds@ lists and the LUCENE JIRA archive, plus 24 manual GitHub, JIRA, list and web queries; full threads of #16431, #16429, #16697, #16628, #16705, #13371, #13396 and #13397 read and grepped for rounding terms, because GitHub search does not index review comments; the changed-file lists of all 413 open PRs; 16 hits read, none of them this bug; the finder's scripted prior-report search and the intended-behaviour check's searches found nothing either); not fixed on upstream main (HEAD ea57360912ed on 2026-10-26 and 645d4fe14a2 on 2026-10-29 still return (float) decoder.applyAsDouble(rawBound) at DoubleValuesSource.java:580 and :595; no new report or PR on 2026-10-29); no open PR changes this code (stale #13187, #14475 and #14775 touch DoubleValuesSource.java on pre-#16431 bases; #16847 only renames maxDoc() calls in the two test classes); origin: #16431, whose description already shows the nearest cast and whose review discussed monotonicity only; #16697 fixed other defects in the same methods and kept the cast; precedent, same bug class in different code: #13397 (merged 2024-05-21, closes #13371 and #13396), a float max-score sum in BlockMaxConjunctionBulkScorer that returned bounds below actual scores
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; the finder's 5,000-document class fails 3 of 3 through gradle; with the recommended fix both pass, together with the closest suites in core and queries. Without the test framework, on the default codec (Lucene104), the diagram probe's 5,000-document index returns the wrong top hit from plain search(q, 1) with assertions on and off, and the right one with the fix. Release 10.6.0 passes the minimal test (standalone runner). Upstream main 2f424fd428d (now 12.0.0-SNAPSHOT, 2026-10-30) and branch_11x 603af870375 (2026-10-29) carry byte-identical DoubleValuesSource.java, DoubleValues.java and FunctionScoreQuery.java [read].

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:572-584 (getMaxScore returns (float) decoder.applyAsDouble(rawBound) at :580), :587-599 (getMinScore, same cast at :595), :340-346 (fromLongField; fromIntField = fromLongField)
  • a Lucene main checkout lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-252 (score() returns (float) (factor * boost) at :247), :262-269 (getMaxScore returns innerMaxScore * valueMaxScore * boost at :268)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:55-60 (getMaxScore: 'the maximum score that documents between the current position and upTo can produce'); lucene/core/src/java/org/apache/lucene/search/Scorer.java:75-79 (getMaxScore); lucene/core/src/test/org/apache/lucene/search/TestDoubleValuesSource.java:412-419 (testMaxScoreBeyondTheCurrentBlock asserts getMinScore(upTo) <= doubleValue() <= getMaxScore(upTo)); lucene/CHANGES.txt:223-224 (Lucene 11.0.0: GITHUB#16431, GITHUB#16697)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:329-347, (at 645d4fe14a20) :571-584 (:576-580), :586-599 (:595)
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-252 (at 645d4fe14a20) (:247), :262-269 (:268)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, (at 645d4fe14a20) :599; TopScoreDocCollector.java:58-60, :154-160; MaxScoreBulkScorer.java:133-138, :431-445 (a clause with no document in the window gets bound 0), :474-517 (:502)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesFormat.java:226 (at 645d4fe14a20) (default skip index interval 4096); lucene/core/src/java/org/apache/lucene/document/NumericDocValuesField.java:46-58 (indexedField)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:55-61 (at 645d4fe14a20) (default getMaxScore +Infinity)
Minimal repro
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/SetupAndRestoreStaticEnv.java:160-212 (at 645d4fe14a20) (codec choices: Asserting, SimpleText, CheapBastard, Compressing, Lucene104, RandomCodec); tests/index/RandomCodec.java:261-265 (doc-values formats: default, Lucene90DocValuesFormat, AssertingDocValuesFormat); getSkipper implemented by Lucene90DocValuesProducer, SimpleTextDocValuesReader and AssertingDocValuesFormat
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:55-58; (at 645d4fe14a20) Scorer.java:75-79; Scorable.java:45-51; TopScoreDocCollector.java:154-160; lucene/core/src/java/org/apache/lucene/util/MathUtil.java:177-194
  • lucene/core/src/test/org/apache/lucene/search/TestDoubleValuesSource.java:384-397, (at 645d4fe14a20) :412-422
  • lucene/queries/src/test/org/apache/lucene/queries/function/TestFunctionScoreQuery.java:385-423, (at 645d4fe14a20) :538, :586 (0 uses of indexedField or DocValuesSkipIndexType)
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:690-697, (at 645d4fe14a20) :766; AssertingScorer.java:130
History
  • a Lucene clone refs as fetched 2026-10-30: origin/main 2f424fd428d (Version.LATEST = LUCENE_12_0_0), origin/branch_11x 603af870375 (LATEST = LUCENE_11_0_0, contains 0d4f93a9ff9), git diff 645d4fe14a20 against both: empty for DoubleValuesSource.java, DoubleValues.java, FunctionScoreQuery.java; origin/branch_10_6 and releases/lucene/10.6.0 do not contain 0d4f93a9ff9 (getMaxScore returns Float.POSITIVE_INFINITY)
  • lucene/CHANGES.txt:238-239 (at 645d4fe14a20) (GITHUB#16431, GITHUB#16697 under 11.0.0)
  • read-only GitHub API, 2026-10-31: branch_11x head d0c0decc6606 (2026-10-30), main head 2f424fd428da; blob ids of DoubleValuesSource.java, DoubleValues.java and FunctionScoreQuery.java at d0c0decc6606 equal those at 645d4fe14a20

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 the code at 645d4fe14a20, the minimal repro, the finder's 5,000-document test and the float arithmetic check.

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.