withDefault gives a document without a value the default score, but its block-max bound is the wrapped field's bound, which only counts documents that have a value. A top-k BooleanQuery trusts that bound, concludes the block cannot reach the top hits and skips it.
- A value with a default.
DoubleValues.withDefault(values, d)(since Lucene 7.0) returns the wrapped value, ordfor a document that has none. It reports a value for every document. - Skip index. A numeric doc-values field can opt in to a skip index (
NumericDocValuesField.indexedField): for each block of documents (with the default codec normally 4,096 documents that have a value) it stores the smallest and largest value among the documents that have one. - Block-max pruning. For top-k search every clause reports, per block of documents, an upper bound of its scores (
getMaxScore). ABooleanQueryof SHOULD clauses adds the clause bounds and skips a block whose sum cannot beat the lowest score still worth collecting. - New in #16431.
DoubleValuesgotgetMaxScore; a field source answers with the block's largest value, andFunctionScoreQueryreports (wrapped query's bound) x (value bound) x boost instead of +Infinity.withDefaultpasses the wrapped bound on unchanged.
withDefault reports next to the scores its documents really produce; the block holding doc 9000 claims 9 while doc 9000 scores 100, so top-k search skips it. Select the still to open it at full size. Generated with AI, checked against the code.- Index 12,288 documents with a
pricein a numeric doc-values field with a skip index (NumericDocValuesField.indexedField): doc 0 has 50, the others 0-9, and doc 9000 has none. Rank with your own source "price, or 100 when missing":DoubleValues.withDefault(fromLongField("price") values, 100). - Search
FunctionScoreQuery(MatchAllDocsQuery, thatSource)ORtag:twithIndexSearcher.search(q, 1). You expect doc 9000: it scores the default 100, every other document at most 50.0018, andexplainand an exhaustive search agree. - Instead you get doc 0 with score 50.0018. No error, with assertions on or off.
- Why: for pruning,
withDefaultpasses on the field's bound, the largest stored price of each skip-index block: 50, 9 and 9. Doc 9000's block claims "at most 9" although doc 9000 scores 100. Once doc 0 sets the bar at 50.0018, that block's 9 + 0.0018 cannot compete, so it is skipped unseen. - Letting the bound cover the default fixes it: the blocks report 100, 100 and 100, and
search(q, 1)returns doc 9000.
Applications on main or the 11.0 branch (unreleased, since #16431) that write their own DoubleValuesSource returning withDefault around a field source on a skip-indexed field (opt-in), with a default above some block's largest value, and use it in a FunctionScoreQuery next to another scoring clause. No stock source does this.
Mechanism, terms and numbers600 words
DoubleValues.withDefault(in, missingValue) (LUCENE-7737, Lucene 7.0) returns missingValue for every document the wrapped instance has no value for. #16431 (unreleased, 11.0.0) added block-max bounds to DoubleValues and, in the same diff, a withDefault override of getMaxScore that returns the wrapped instance's bound unchanged. For a field source on a skip-indexed field that bound is the skip-index block's largest stored value, which only counts documents that have a value. When the default is larger, FunctionScoreQuery multiplies the too-low bound into its scorer's bound, and a top-k BooleanQuery skips the block that holds the document without a value, even when it is the true top hit. With the default codec and IndexSearcher.search(q, 1), 12,288 documents return doc 0 (score 50.0018) instead of doc 9000 (score 100). It needs an application-written source that uses withDefault this way; no stock source does. Only main and branch_11x have it. Letting the bound also cover the default fixes it.
- The default.
DoubleValues.java:71-98:withDefault"Wrap[s] a DoubleValues instance, returning a default if the wrapped instance has no value" (:71).advanceExactstores the wrapped answer and returns true (:83-86);doubleValue()returnshasValue ? in.doubleValue() : missingValue(:78-80). - The bound.
DoubleValues.java:93-96:getMaxScore(upTo)returnsin.getMaxScore(upTo).getMinScoreis not overridden, so the lower bound stays -Infinity (:63-69), which is sound; only the upper bound is wrong. - Where the finite bound comes from.
fromLongFieldisfromField(field, v -> (double) v, Monotonicity.INCREASING)andfromIntFieldreturns it (DoubleValuesSource.java:340-347). Its values read the field'sDocValuesSkipper(:528-529);advanceShallowreturns the current skip interval's last doc (:541-550) andgetMaxScorethe decodedskipper.maxValue(level)of the first level that coversupTo(:571-584). That covers only documents that have a value, which is right for the plain source: there a document without a value scores 0 (FunctionScoreQuery.java:243-252). InsidewithDefaultit producesmissingValueinstead. - The scorer bound.
FunctionScoreQuery.java:261-269returnsinnerMaxScore * valueMaxScore * boost(it returns +Infinity outright when the value bound is infinite,:265-267); itsadvanceShallowis the minimum of the wrapped query's and the values' (:254-259). WithMatchAllDocsQuerythe inner bound is 1.0. - The pruning. For SHOULD clauses under
ScoreMode.TOP_SCORES,BooleanScorerSupplier.java:315-328builds aMaxScoreBulkScorer. Per window it reads each clause'sgetMaxScore(windowMax - 1)(MaxScoreBulkScorer.java:439);partitionScorers()makes clauses non-essential while their summed bound is belowminCompetitiveScore(:474-512,:502), and with no essential clause the window is skipped ("No matches in this window",:133-138).IndexSearcher.search(q, n)collects with a 1,000-hit threshold (IndexSearcher.java:100,:599), so TOP_SCORES is on (TopScoreDocCollector.java:58-60).
Three skip-index blocks: docs 0-4095, 4096-8191 and 8192-12287. withDefault(fromLongField("price"), 100) reports 50.0, 9.0 and 9.0; doc 9000, in the third block, produces 100.0. The function clause's bound there is 1.0 x 9.0 = 9.0, the term clause's 0.0017790452. explain gives doc 9000 100.0 and doc 0 50.001778; the exhaustive top hit is doc 9000 (100.0), search(q, 1) returns doc 0 (50.001778, totalHits "4096+ hits"), and count(q) is 12,288. With the fix the three bounds are 100.0 and search(q, 1) returns doc 9000.
Affected (executed): SHOULD + SHOULD disjunctions, MUST function query + SHOULD term, and those under FILTER or MUST_NOT clauses. Not affected (read): a top-level FunctionScoreQuery alone (its FilterScorer does not override setMinCompetitiveScore, so nothing is skipped), and the stock sources: boostByValue wraps the bounded source in withDefault but returns a new DoubleValues without bounds (FunctionScoreQuery.java:294-307); boostByQuery (:371-386) and the five spatial-extras callers wrap DoubleValues that have no bounds.
Exposure and likelihood275 words
- Who. Applications on main or
branch_11x(unreleased 11.0.0) that write their ownDoubleValuesSourcereturningDoubleValues.withDefault(values, d)around a field source with a finite bound (fromLongField,fromIntField,fromFloatField,fromDoubleField, orfromFieldwith a declared monotonicity) on a numeric field with the opt-in doc-values skip index (NumericDocValuesField.indexedField), and use it in aFunctionScoreQuerythat is one scoring clause next to another, searched top-k (the defaultsearch(q, n)). - Trigger. A document without a value in a skip block whose largest stored value is below the default
d. A default above the real values (100 over prices 0-9 in the tests) makes that likely; a default of 0 never triggers it for non-negative values. For a fixed index, query and collector the outcome is deterministic: no randomness, no timing. - Effect. Silent wrong results: documents without a value, which score the default, are missing from the top hits, including the true top hit; lower-scoring documents are returned in their place. No exception, with assertions on and off, default codec and searcher.
- Never a superset. In 5 probe runs of 55,848 checks each, no non-matching, filtered-out, excluded or deleted document was returned, and every returned score was exact.
- Scale. From a few skip-index blocks (12,288 documents in the end-to-end tests); the bound itself is wrong from 2 documents.
- How likely: rare. It needs custom source code, the opt-in skip index, a default above a block's maximum and a summing composition. No stock source does this, and the only downstream uses found (the lang-expression
EmptyMemberValueSourceof Elasticsearch and OpenSearch) wrap an instance without a bound (read, not tested). No release has the bug.
One JUnit test, two documents, default codec, public API: doc 0 without a price, doc 1 with a skip-indexed price 1. withDefault(fromLongField("price") values, 100) produces 100 for doc 0, so the bound over its block must be at least 100.
/*
* 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.NumericDocValuesField;
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.tests.util.TestUtil;
public class TestDoubleValuesWithDefaultMaxScore extends LuceneTestCase {
public void testMaxScoreCoversMissingValue() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
try (IndexWriter w = new IndexWriter(dir, iwc)) {
w.addDocument(new Document()); // doc 0: no price
Document doc1 = new Document();
doc1.add(NumericDocValuesField.indexedField("price", 1));
w.addDocument(doc1);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
DoubleValues values =
DoubleValues.withDefault(
DoubleValuesSource.fromLongField("price").getValues(reader.leaves().get(0), null),
100);
int upTo = values.advanceShallow(0);
float maxScore = values.getMaxScore(upTo);
values.advanceExact(0);
// expected: getMaxScore(upTo) >= 100, the value withDefault produces for doc 0; actual: 1.0
assertTrue(
"doc 0 value " + values.doubleValue() + " > getMaxScore(" + upTo + ") = " + maxScore,
values.doubleValue() <= maxScore);
}
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestDoubleValuesWithDefaultMaxScore -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
getMaxScore(1)is at least 100, the valuewithDefaultproduces for doc 0.- Observed
getMaxScore(1)is 1.0:AssertionError: doc 0 value 100.0 > getMaxScore(1) = 1.0, with assertions on and off.
More files, environment, full output248 words
The default codec is pinned because the bug needs its doc-values skip index (LuceneTestCase would otherwise pick a codec from the seed); ByteBuffersDirectory, nothing random. At least one document must have a value, otherwise the field and its skipper do not exist and the bound is +Infinity.
The test shows the root cause, the bound below the produced value. The 12,288-document wrong top hit in the explanation above is the same bug; it is not part of this repro.
Regression test: this same test; it passes with the recommended fix. In a PR it could sit next to testMaxScoreBeyondTheCurrentBlock in TestDoubleValuesSource.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Releases 10.6.0 and 10.5.2: the test does not compile (no bound API); the end-to-end test returns the right top hit |
|---|---|
| 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/TestDoubleValuesWithDefaultMaxScore.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestDoubleValuesWithDefaultMaxScore -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:core:test FAILED
TestDoubleValuesWithDefaultMaxScore > testMaxScoreCoversMissingValue FAILED
java.lang.AssertionError: doc 0 value 100.0 > getMaxScore(1) = 1.0
at __randomizedtesting.SeedInfo.seed([DEADBEEF:7B094C9106BA0E78]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.apache.lucene.search.TestDoubleValuesWithDefaultMaxScore.testMaxScoreCoversMissingValue(TestDoubleValuesWithDefaultMaxScore.java:49)
# the first verifier's 12,288-document test on the same stock jars (standalone runner, seed DEADBEEF): 4 tests, 2 failed (the 2 controls pass)
testTopHitDefaultSearchApi java.lang.AssertionError: pruned top-1 doc=0 score=50.001778 vs exhaustive doc=9000 score=100.0 expected:<9000> but was:<0>getMaxScorepromises "the maximum score that documents between the current position and upTo can produce";withDefaultproduces the default for documents without a value.- Upstream
testMaxScoreBeyondTheCurrentBlockasserts value <=getMaxScore; no upstream test useswithDefault. - Before #16431 the bound was +Infinity; every release returns the right top hit, and the #16431 review never mentions defaults.
The full argument184 words
- The contract.
DoubleValues#getMaxScore: "Return the maximum score that documents between the current position and {@code upTo} can produce" (DoubleValues.java:55-58).withDefaultproducesmissingValuefor documents without a value and says they have one (:78-86).Scorer#getMaxScorehas the same meaning for the scorer (Scorer.java:75-79), andScorable#setMinCompetitiveScorelets a scorer "safely ignore" only documents that score less (Scorable.java:45-50). - Tests.
TestDoubleValuesSource.java:412-422(testMaxScoreBeyondTheCurrentBlock, added by #16697) assertsgetMinScore(upTo) <= value <= getMaxScore(upTo)for a produced value. No upstream test callsDoubleValues.withDefault. - History. Before #16431
DoubleValueshad no bounds andFunctionScoreQuery's bound was +Infinity: always safe. 10.0.0, 10.5.2 and 10.6.0 return the right top hit. The #16431 review discussed the monotonicity of user functions; no comment mentionswithDefault, missing values or defaults. The author described the design as "If any layer in the nesting chain is non-monotonic or lacks skip index information, it returnsFloat.POSITIVE_INFINITY, making nested expressions safe by construction" (rajat315315 on #16431);withDefaultis such a layer and does not. The follow-up #16697 fixed other unsound bounds of the same API as defects.
The devil's-advocate check170 words
The strongest case: #16431 added the forwarding override on purpose (it is in the PR's first commit), it keeps the bound tight, and a skip-index bound only has to cover documents that have a value. A second argument: defaults are meant to be neutral (0 or 1). A third: uschindler said in the same review that a user who implements maxScore wrongly for a function like sin() has "your own problem".
Why it fails: "covers only documents with a value" is right for the plain field source, but withDefault reports a value for every document, and the javadoc has no exception for defaults; no review comment chose this trade-off, and a bound below a produced score is not a bound. Nothing documents that defaults must be neutral. The sin() remark is about user code declaring a wrong bound; here the wrong bound is Lucene's own wrapper's, and the user declared nothing. Verdict: not intended, a defect (all verifiers and the adjudication; the cross-model reviewers rated the intended argument only moderate).
Recommended Let withDefault's bound cover the default
In withDefault's getMaxScore, return the larger of the wrapped bound and the default rounded up to float, and keep the wrapped bound for a NaN default. One method in DoubleValues.java; the repro and the 12,288-document test pass, and so do the closest suites and the queries and spatial-extras modules.
float maxScore = in.getMaxScore(upTo);
if (Double.isNaN(missingValue)) {
return maxScore;
}
// documents without a value produce missingValue, so the bound must cover it too
float missing = (float) missingValue;
if (missing < missingValue) {
missing = Math.nextUp(missing);
}
return Math.max(maxScore, missing);All options and trade-offs3 options ยท 610 words
Option A was built and run through gradle. B would keep more pruning but needs new API; C is safe but gives up pruning for every withDefault user. The sibling fixes in FunctionScoreQuery (LFV-203, LFV-211) do not touch this bug.
In DoubleValues.withDefault, getMaxScore (DoubleValues.java:93-96) returns max(in.getMaxScore(upTo), missingValue rounded up to float), and leaves a NaN default out (+10/-1 lines).
float maxScore = in.getMaxScore(upTo);
if (Double.isNaN(missingValue)) {
return maxScore;
}
// documents without a value produce missingValue, so the bound must cover it too
float missing = (float) missingValue;
if (missing < missingValue) {
missing = Math.nextUp(missing);
}
return Math.max(maxScore, missing);Trade-offs
- Rounding. The bound is a float and the default a double;
(float)rounds to nearest, so the value is rounded up when it went down. A default beyond the float range becomes +Infinity, whichFunctionScoreQuerytreats as no bound. - NaN.
Math.max(x, NaN)is NaN and would poison the scorer's bound;FunctionScoreQueryscores a NaN value as 0, so the wrapped bound is kept. The hunt's first diff lacked this guard; the adjudication and verifiers flagged it. That bound covers the score 0 only when it is not negative: for a block whose stored values are all negative (and likewise for a negative default) this relies on the sibling LFV-203's clamp of the bound at 0 inFunctionScoreQuery. - Cost. The bound rises to the default for every block, also blocks where every document has a value. Sound, but it can switch off pruning for the function clause when the default is above all real values: in the probe the blocks go from 50/9/9 to 100/100/100. Releases never pruned here at all (+Infinity). No benchmark was run.
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;
- with the fix: the minimal test passes with assertions on and off;
- core
TestDoubleValuesSourceandTestMaxScoreBulkScorer(26 tests) and queriesTestFunctionScoreQuery,TestFunctionQuerySort,TestValueSourcesandTestFunctionScoreExplanations(65 tests): 0 failures; - the whole queries module (512 tests) and spatial-extras module (117 tests, home of the five spatial
withDefaultcallers): 0 failures; corecheckGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass; - on the built
645d4fe14a20jars the 12,288-document test fails 2 of 4 on stock (controls pass) and passes 4 of 4 with the fix, assertions on and off; 10.6.0 passes 4 of 4.
The skipper knows per interval how many documents have a value (DocValuesSkipper#docCount(level)). Compared against the doc range the bound covers, which starts at the shallow-advance target and can begin before minDocID(level) (a document without a value may lie before the interval, DocValuesSkipper.java:37-38), it shows a range without gaps, which could keep the tight bound. The interval's own doc range is not enough: in the minimal repro the level-0 interval is doc 1 alone, with 1 value, while the bound covers docs 0-1 and doc 0 produces 100 (executed by the fact check).
Trade-offs
withDefault only sees a DoubleValues, so this needs a new hook on DoubleValues (for example "may documents up to upTo lack a value?") that the field source implements. More pruning for large defaults, but new API surface for a wrapper. Not built or run.
Delete withDefault's getMaxScore override, so it reports the default +Infinity, as before #16431.
Trade-offs
Always safe, but it gives up the pruning #16431 added for every withDefault user, including those with a default of 0. Not run.
Only an application that builds this source shape is exposed, and then only the ranking of its own results changes; no Elasticsearch, OpenSearch or Solr code path to it is known.
While writing the file-to-line table of an SMT model of FunctionScoreQuery's new bound, the finder mapped each getMaxScore override to the values it must bound and noticed that withDefault forwards a bound that ignores its default. The model itself does not cover withDefault; a public-API JUnit confirmed it, and verifiers then showed the wrong top hit.
The method in full151 words
Method: non-formal (code reading; no formal tool witnessed it). The hunt unit covered function queries and block-max pruning.
- Reading. The unit's SMT model of the bound pipeline (
fsq_bound_smt.py) has a table mapping each model element to code; while filling it, the finder checked everyDoubleValues#getMaxScoreoverride against the values it must bound.withDefaultis not in the model. - JUnit. A public-API test (10 documents, doc 5 without a price, default 100) failed on main with assertions on and off ('value 100.0 of doc 5 > getMaxScore(9) = 9.0') and passed with the fix. The finder first rated it minor and latent.
- End to end. The first and third verifiers wrote top-hit tests over several skip-index blocks (12,288 documents) and showed a wrong top hit through plain
search(q, n), which turned it into a silent-wrong-results bug. The minimal repro above is the finder's test cut down to two documents.
No prior report found in the documented searches: no issue, PR, commit or list post they reached describes it (131 queries plus full reads of the #16431, #16697 and #16429 threads, 2026-10-26; re-checked 2026-10-31). Closest: #16431 itself, whose review never mentions defaults.
Searches3 searches
- #16431 (the introducing PR): the review's correctness discussion was about the monotonicity of user functions; nobody mentions
withDefault, missing values or defaults. - #16429 (the feature request), #16697 (fixes other defects of the same feature in
DoubleValuesSource, does not touchwithDefault), #16705 (the only later change toDoubleValues.java, addsprefetch), open #16847 (edits the affected tests mechanically for long doc ids). - Same family (the hunt's own findings): LFV-203, LFV-211, LFV-189, LFV-221, LFV-273.
- Limits: most scripted GitHub searches reused same-day results, while the 65 GraphQL searches ran live; GitHub issue search does not index PR diffs (hence the direct thread reads); private security reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr) | 2026-10-26 | 20 issue/PR and 3 commit queries of the hunt's search tool, 2 downstream, 65 GraphQL searches, the changed files of all 415 apache/lucene PRs updated since 2026-08-22, full reads of #16431, #16697 and #16429 | 15 hits read; only #16429, #16431 and #16697 discuss this code; none reports the bug |
| Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, web | 2026-10-26 | 20 list queries, 4 JIRA queries, 9 web searches | no report |
| GitHub issues and PRs (apache/lucene) | 2026-10-31 | 6 GraphQL searches on items updated since 2026-10-25 (withDefault, DoubleValues getMaxScore, FunctionScoreQuery, DoubleValuesSource, missingValue, block-max WAND function); titles of all 49 items created since 2026-10-26; tracking issue #16937; re-run read-only by the independent fact check the same day | nothing new |
Introduced by #16431 (0d4f93a9ff9, merged 2026-08-22), which added the bounds and the forwarding override; 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 return the right top hit).
Versions tested7 rows
Introduced by 0d4f93a9ff93ccc396c1323f26416b57d8dc08d9, #16431 "Enable Block-Max WAND dynamic pruning for FunctionScoreQuery via DocValuesSkipper (+422% QPS)" (Rajat Jain, co-author Uwe Schindler; merged to main 2026-08-22; related issue #16429). It added the bound API to DoubleValues, the forwarding withDefault override, and the skipper-based field-source bound that makes the forwarded bound finite. Blame of the override and git log -S'return in.getMaxScore(upTo);' on DoubleValues.java return only this commit (without the path the same search returns 15 commits, because many scorers contain that line). withDefault itself is from LUCENE-7737 (2f2e00ffe23, 2017, first released in 7.0.0).
Masked at first: until #16697 (d150ba27d11, Zhang Chao, 2026-10-09) the tested end-to-end query threw inside the skipper (a separate advanceShallow defect) before it could return a wrong hit; the bound was already wrong, and #16697's change shadowed onto the introducing commit gives the wrong top hit, which the withDefault fix removes.
Now (refs fetched 2026-10-30): branch_11x, the 11.0.0 release branch, was cut from main on 2026-10-28 and main became 12.0.0-SNAPSHOT; both still forward the bound. 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 12,288-document test returns doc 0 instead of 9000; both pass with the fix |
main d150ba27d11, ea57360912ed, a9779ef3c193 | affected | the bound test fails; the end-to-end test returns the wrong top hit (on d150ba27d11 and ea57360912ed, assertions on and off) |
0d4f93a9ff9 (#16431) | bound only | the bound is wrong; 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 | withDefault.getMaxScore and the files involved are unchanged from 645d4fe14a20 (read, not run) |
| 10.6.0 | not affected | the end-to-end test returns the right top hit; no bound API |
10.0.0, 10.5.2, parent a1fcd50799f | not affected | the end-to-end test passes 4/4; no bound API, so the bound is +Infinity |
| 9.12.3 | not applicable | no skip index and no bound API; the tests do not compile |
Reproduced by an independent reproducer (wrong top hit through plain search(q, 1)), a devil's-advocate verifier (wrong top 2), reviews by OpenAI GPT-5.6, an adjudication with fresh seeds and this doc's gradle run; controls with an explicit value pass, and the DoubleValues-only fix clears every repro.
Checks and verdicts379 words
Every check ran its own tests or re-ran an earlier one on stock jars; a cross-model reviewer's final record was written without running code, so its tests were re-run separately.
| Check | Verdict | What it found |
|---|---|---|
| Finder (code reading, then JUnit) | candidate | Public-API JUnit (10 documents) fails 1/1 on main with assertions on and off; the fix passes. First rated minor and latent, overturned by the end-to-end tests. |
| Refute lens (OpenAI GPT-5.6, executed) | real (high) | Its own bound test fails on main (assertions on and off, second seed); controls and the fix pass; upstream TestDoubleValuesSource 11/11 with the fix. Its own top-hit probes found no wrong hit: its 4,096-document index is a single skip block. |
| Independent reproducer | real | Own public-API tests outside org.apache.lucene: the bound test fails (60 of 90 over 30 iterations); the 12,288-document test returns doc 0 instead of doc 9000 with a pruning collector and with plain search(q, 1) (20 of 40 over 10 iterations); controls pass; 10.5.2 and the fix pass. |
| Works-as-designed lens (OpenAI GPT-5.6) | real (high) | Intended-behaviour argument moderate; its own tests fail on main (100.0 above a bound of 9.0; 1000.0 above 16.0 on a writer-built sparse field, also as a FunctionScoreQuery score); with the fix they pass and upstream TestDoubleValuesSource 11/11 and TestFunctionScoreQuery 17/17 pass; re-run separately. |
| Intended-behaviour devil's advocate | real (high) | Five not-a-bug arguments tried, none survives; its own test (default 1000, top 2) gives exhaustive [10000, 0] but pruned [0, 1], assertions on and off, second seed; the low-default control, the fix and 10.5.2 pass. |
| Adjudication | confirmed (high) | Re-ran the finder's test (seed C0FFEE01) and the independent reproduction's end-to-end test (seed C0FFEE02) with assertions on and off: both fail; with only the DoubleValues fix shadowed, 4/4 pass. |
| Re-verification and minimal repro | still reproduces | Still fails on a9779ef3c193; the two-document test fails on 645d4fe14a20 (2 seeds with assertions on, 1 with them off) and passes with the fix. |
| This doc's gradle run | real | Scratch worktree of 645d4fe14a20: stock fails with assertions on and off; the fix passes the repro, the closest suites, the queries and spatial-extras modules and core lint; the 12,288-document test fails on stock jars and passes with the fix and on 10.6.0. |
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 569 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,374 words
- Registry title
- DoubleValues.withDefault's getMaxScore returns the wrapped source's bound, which does not cover missingValue, so a FunctionScoreQuery over such a source can silently drop the true top hit under TOP_SCORES
- Area
- search / function scoring (DoubleValues.withDefault score bound for FunctionScoreQuery block-max pruning, #16431)
- Priority
- Low, rank 718: DoubleValues.withDefault forwards a bound that ignores missingValue, so a custom source with a high default can lose its top hit under pruning, unreleased and with no stock source affected.
- Impact class
- silent wrong results
- Impact note
- Applications on unreleased main (11.0.0-SNAPSHOT, since #16431) that write their own DoubleValuesSource returning DoubleValues.withDefault(values, d) around a bounded field source (fromLongField and similar) on a numeric field with the opt-in doc-values skip index (NumericDocValuesField.indexedField), with a default d above some skip-index block's maximum, and use it in a FunctionScoreQuery that is a scoring clause next to another scoring clause: the default IndexSearcher.search(q, n) (TOP_SCORES past its 1,000-hit threshold) silently leaves documents without a value out of the top hits, including the true top hit, with the default codec and searcher, -ea or -da, on an index of a few skip-index blocks (12,288 documents in the executed tests); no stock DoubleValuesSource reaches it (boostByValue, boostByQuery and the spatial-extras sources wrap unbounded instances), a top-level FunctionScoreQuery alone does not prune, and no released version is affected.
- Summary
- You write a DoubleValuesSource that returns DoubleValues.withDefault(DoubleValuesSource.fromLongField("price").getValues(ctx, scores), 100) over a price field indexed with a skip index (NumericDocValuesField.indexedField), and search BooleanQuery(SHOULD FunctionScoreQuery(MatchAllDocsQuery, thatSource), SHOULD TermQuery(tag:t)) for the top hit, expecting the document without a price (which scores the default 100) to win, as it does in an exhaustive search. Instead, with 12,288 documents (prices i % 10, doc 0 = 50, doc 9000 without a price), the plain IndexSearcher.search(q, 1) returns doc 0 (score 50.0018) instead of doc 9000 (score 100). The cause is that withDefault.getMaxScore returns the wrapped source's bound, the skip-index block maximum, which counts only documents that have a value (9 in doc 9000's block), although withDefault itself produces missingValue (100) for documents without one, so pruning skips the block that holds the true top hit.
- 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 the bound defect ('value 100.0 of doc 5 > getMaxScore(9) = 9.0') at 0d4f93a9ff9, d150ba27d11 and main with -ea and -da, while the parent a1fcd50799f has no bound API (compile error) and returns the right top hit; read: git blame of DoubleValues.java:87-96 and git log -S 'return in.getMaxScore(upTo);' and -G getMaxScore on DoubleValues.java return only 0d4f93a9ff9]; it added the DoubleValues bound API and, in the same diff, the withDefault getMaxScore override that forwards in.getMaxScore(upTo), plus the skipper-based field-source bound that makes the forwarded bound finite; withDefault itself dates from LUCENE-7737 (2f2e00ffe23, 2017, first released in 7.0.0) and had no bound to get wrong before; the end-to-end wrong top hit became reachable for the tested query shape with #16697 (d150ba27d11, by easyice, 2026-10-09), which fixed a separate advanceShallow defect that made the tested query throw in the skipper first [exec: #16697's DoubleValuesSource change shadowed onto 0d4f93a9ff9 gives main's wrong top hit, and adding the withDefault fix removes it] (2026-08-22 (0d4f93a9ff9 on main); silent wrong top hit reachable end to end from 2026-10-09 (d150ba27d11, #16697) for the executed query shape)
- First affected version
- unreleased (main only; 11.0.0 would be the first release with it) [exec: 10.0.0 and 10.5.2 return the right top hit; read: no releases/lucene/* tag contains 0d4f93a9ff9, and branch_10_6, branch_10x and branch_10_5 have no DoubleValues bound API]
- Affected versions
- main (11.0.0-SNAPSHOT) from 0d4f93a9ff9 (2026-08-22) through ea57360912ed (upstream main HEAD, checked 2026-10-26) [exec: withDefault(...).getMaxScore(9) = 9.0 below the produced 100.0 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 (AssertionError with -ea, EOFException with -da)]; not affected: 10.0.0 [exec, -ea] and 10.5.2 [exec, -ea and -da] (the end-to-end test passes 4/4; the bound API does not exist there, so the unit repro is a compile error) and the parent commit a1fcd50799f [exec]; not applicable: 9.12.3 [exec: compile error, no bound API and no NumericDocValuesField.indexedField]; unreleased branch_10x, branch_10_6 and branch_10_5 do not contain #16431 [read, 2026-10-26]
- 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
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, the prior-report record: 131 logged read-only queries: a scripted prior-report search over apache/lucene issues and PRs (20), commit messages (3), the Elasticsearch, OpenSearch and Solr repositories (2), the dev@, java-user@, issues@ and commits@ lists (20), the LUCENE JIRA archive (4) and the web (9), plus 65 GitHub GraphQL issue/PR searches, the changed-file lists of all 415 apache/lucene PRs updated since 2026-08-22, and direct reads of the #16431, #16697 and #16429 conversations, inline review comments and reviews; 15 hits read, none of them this bug; the finder's, the independent reproduction's and the intended-behaviour check's searches found nothing either); not fixed on upstream main (HEAD ea57360912ed on 2026-10-26 still has 'return in.getMaxScore(upTo);' in withDefault; the last commit to DoubleValues.java, 82d73908cb1 / #16705, only adds prefetch); no open or recently closed PR touches DoubleValues.java (#16847 edits the affected tests mechanically); origin: #16431 (related issue #16429; its review discussed only the monotonicity of user functions, and nobody mentioned withDefault, missing values or defaults); #16697 (easyice) fixed other defects of the same feature in DoubleValuesSource.java and did not touch withDefault; search gaps: most of the scripted prior-report searches reused results fetched earlier the same day (the GraphQL searches ran live), GitHub issue search does not index PR diffs (hence the direct thread reads), and private security reports cannot be searched
- 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, and the first verifier's 12,288-document test returns the wrong top hit; with the recommended fix both pass, together with the closest core and queries suites, the whole queries and spatial-extras modules and core lint. Release 10.6.0 returns the right top hit (standalone runner). Upstream main2f424fd428d(now 12.0.0-SNAPSHOT, 2026-10-30) andbranch_11x603af870375(2026-10-29) carry the same code [read].
- Finding record
lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:55-61 (getMaxScore javadoc and default), :71-97 (withDefault: doubleValue :78-80, advanceExact :83-86, advanceShallow :89-91, getMaxScore :94-96)lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:572-584 (field-source getMaxScore = decoded skipper.maxValue(level))lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:262-269 (scorer getMaxScore = innerMaxScore * valueMaxScore * boost)lucene/core/src/test/org/apache/lucene/search/TestDoubleValuesSource.java:412-421 (testMaxScoreBeyondTheCurrentBlock asserts getMinScore(upTo) <= value <= getMaxScore(upTo))
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:55-61, (at 645d4fe14a20) :63-69, :71-98 (:78-80, :83-86, :89-91, :93-96)lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:340-347, (at 645d4fe14a20) :527-529, :541-550, :571-584lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-252, (at 645d4fe14a20) :254-259, :261-269, :294-307, :371-386lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:315-328; (at 645d4fe14a20) MaxScoreBulkScorer.java:133-138, :439, :474-512 (:502); IndexSearcher.java:100, :599; TopScoreDocCollector.java:58-60; FilterScorer.java (no setMinCompetitiveScore override)lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesFormat.java:226 (at 645d4fe14a20) (DEFAULT_SKIP_INDEX_INTERVAL_SIZE = 4096)lucene/spatial-extras/src/java/org/apache/lucene/spatial/{util/ShapeFieldCacheDistanceValueSource.java:62, (at 645d4fe14a20) util/DistanceToShapeValueSource.java:72, util/ShapeAreaValueSource.java:59, vector/DistanceValueSource.java:65, bbox/BBoxSimilarityValueSource.java:71} (withDefault around anonymous DoubleValues without bounds)
- Who is affected
lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:330-347 (at 645d4fe14a20) (field sources and their monotonicity)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:55-58, (at 645d4fe14a20) :71-98; Scorer.java:75-79; Scorable.java:45-50lucene/core/src/test/org/apache/lucene/search/TestDoubleValuesSource.java:412-422 (at 645d4fe14a20) (testMaxScoreBeyondTheCurrentBlock); grep of lucene/**/src/test: no DoubleValues.withDefault
- Suggested fix
lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-252 (at 645d4fe14a20) (NaN scores 0), :265-267 (infinite value bound gives +Infinity); lucene/core/src/java/org/apache/lucene/index/DocValuesSkipper.java:37-38 (minDocID(0) may be greater than the advance target), :51, :81 (minDocID, docCount per level)
- History
a Lucene clone refs fetched 2026-10-30: origin/main 2f424fd428d and origin/branch_11x 603af870375 (merge base c5ab3e57b9ba; contains 0d4f93a9ff9; DoubleValues.java:95 'return in.getMaxScore(upTo);'; no diff in DoubleValues.java, DoubleValuesSource.java, FunctionScoreQuery.java, MaxScoreBulkScorer.java against 645d4fe14a20); origin/branch_10_6 and releases/lucene/10.6.0 (no 0d4f93a9ff9; no getMaxScore in DoubleValues.java)
- TestDoubleValuesWithDefaultMaxScore.java (one test, public API):
repro/LFV-214/src/org/apache/lucene/search/TestDoubleValuesWithDefaultMaxScore.java(55 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and an instrumented run of the 12,288-document example (the per-block bounds, clause bounds and top hits, with and without the fix).
assets/LFV-214/bug.png(569 KB)
