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.
- 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, ...).
- 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. - Block-max pruning. For top-k search every clause reports, per block of documents, an upper bound of its scores (
getMaxScore). With plainsearch(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. - New in #16431.
FunctionScoreQuerynow reports (wrapped query's bound) × (the block's largest value, as a float) × boost instead of +∞, so blocks of small values can be skipped.
- Index a long field with the doc-values skip index (
NumericDocValuesField.indexedField("count", v)) and rank withBoostQuery(FunctionScoreQuery(MatchAllDocsQuery, fromLongField("count")), 1.5f)OR a text clause. 5,000 documents: doc 0 hascount= 16,777,216 (2^24), doc 4999 has 16,777,217, the rest 0. - 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.
- Instead plain
IndexSearcher.search(q, 1)returns doc 0 (25,165,824). No error, with assertions on or off. - 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.
- 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.
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 value bound rounds to nearest.
DoubleValuesSource.java:571-584(getMaxScoreof the field source): with a skip index and a declared monotonicity it picks the first skipper level that coversupToand returns(float) decoder.applyAsDouble(rawBound)(:580), whererawBoundis the level'smaxValue(INCREASING) orminValue(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). - Which sources.
fromLongFieldisfromField(field, v -> (double) v, INCREASING),fromIntFieldreturnsfromLongField,fromDoubleFielddecodes raw bits,fromFloatFielddecodes a float (DoubleValuesSource.java:329-347). Longs above 2^24 in magnitude and doubles that are not floats can round the wrong way;fromFloatFieldvalues are floats and cannot. - The score rounds once, the bound twice.
FunctionScoreQuery.java:243-252:score()returns(float) (factor * boost)from the exact double (:247).:262-269:getMaxScorereturnsinnerMaxScore * valueMaxScore * boostin 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. - Pruning trusts it.
IndexSearcher.search(q, n)collects withTopScoreDocCollectorManager(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 toMath.nextUpof 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). - 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.
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_11xwho rank withFunctionScoreQueryoverfromLongField,fromIntField,fromDoubleFieldor afromFieldwith a declared monotonicity, on a numeric field with the opt-in doc-values skip index (NumericDocValuesField.indexedField, or a field type withDocValuesSkipIndexType.RANGE), with a boost other than 1 (aBoostQueryor 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#getMaxScoreandDoubleValues#getMaxScorereport 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,boostByValueandboostByQuery(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.
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.
/*
* 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);
}
}
}
}
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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Release 10.6.0: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
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
# 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.6777216E7DoubleValues#getMaxScoreis "the maximum score that documents ... can produce", with no caveat; upstream's test assertsdoubleValue() <= 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#getMaxScorereturns "the maximum score that documents between the current position andupTocan produce" (DoubleValues.java:55-58);Scorer#getMaxScoreis the maximum score of the documents up toupTo(Scorer.java:75-79);Scorable#setMinCompetitiveScorelets a scorer "safely ignore all documents whose score is less than the givenminScore" (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) assertsgetMinScore(upTo) <= doubleValue() <= getMaxScore(upTo), the exact double against the float bound, over the values 0-199 (:384-397), all exact floats.TestFunctionScoreQueryhas no skip-indexed field at all, so its bound tests take the +∞ branch. The test framework assertsscore <= 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.sumUpperBoundpads 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.
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.
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.
DoubleValuesbounds then cover the double values, asTestDoubleValuesSourceexpects. ForFunctionScoreQuerywith 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,TestSortRescorerandTestMaxScoreBulkScorerpass (38 tests, 0 failures); queriesTestFunctionScoreQuery,TestFunctionScoreExplanations,TestFunctionQuerySort,TestFunctionMatchQueryandTestValueSourcespass (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,ecjLintMainandforbiddenApisMainfor 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) andTestDoubleValuesSource(11) pass.
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.
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.
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.
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
FunctionScoreQueryover 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 violatescore <= 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.
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
getMaxScoreas(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
advanceShallowand 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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr) | 2026-10-26 | 267 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 PRs | 16 hits read; none reports this bug |
| Mailing lists (dev@, java-user@, issues@, commits@, builds@), JIRA archive, web | 2026-10-26 | 140 list queries, 22 JIRA-archive queries, 8 web searches | only notification mails of #16431, #16429 and #16697 and older unrelated threads |
| GitHub issues and PRs (apache/lucene) | 2026-10-31 | 8 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 #16937 | nothing new |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal 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 | affected | the 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 only | both 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 code | the three files are byte-identical to 645d4fe14a20 (read, not run) |
| 10.6.0 | not affected | minimal test passes; the bound is +∞ |
10.0.0, 10.5.2, parent a1fcd50799f | not affected | a 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 index | the trigger cannot be written: NumericDocValuesField.indexedField does not exist |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (SMT, then JUnit) | candidate | Z3 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 | real | Long 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 | real | No 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 reproduces | Independent 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 validated | Scratch 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. |
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 599 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,440 words
- 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 plainsearch(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 main2f424fd428d(now 12.0.0-SNAPSHOT, 2026-10-30) andbranch_11x603af870375(2026-10-29) carry byte-identicalDoubleValuesSource.java,DoubleValues.javaandFunctionScoreQuery.java[read].
- 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-194lucene/core/src/test/org/apache/lucene/search/TestDoubleValuesSource.java:384-397, (at 645d4fe14a20) :412-422lucene/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
- TestFunctionScoreQueryMaxScoreRounding.java (one test, public API):
repro/LFV-221/src/org/apache/lucene/queries/function/TestFunctionScoreQueryMaxScoreRounding.java(63 lines)
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.
assets/LFV-221/bug.png(599 KB)
