All findings · Lucene finding · LFV-214 · doublevalues-withdefault-maxscore-ignores-missing-value

DoubleValues.withDefault's block-max bound ignores the default, so FunctionScoreQuery can skip the true top hit

On unreleased main, DoubleValues.withDefault scores a document without a value with the default, but tells top-k search that a block scores at most its largest stored value. With a default of 100 over prices 0-9 (and one document at 50), search(q, 1) returns a document scoring 50 and skips the one scoring 100.

Summary

Priority
Low, rank 718DoubleValues.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
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
non-formal: code reading of the new bounds, then a public-API JUnit

AI-assisted page: this finding was first signalled by code reading or testing 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

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.

Background

  1. A value with a default. DoubleValues.withDefault(values, d) (since Lucene 7.0) returns the wrapped value, or d for a document that has none. It reports a value for every document.
  2. 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.
  3. Block-max pruning. For top-k search every clause reports, per block of documents, an upper bound of its scores (getMaxScore). A BooleanQuery of SHOULD clauses adds the clause bounds and skips a block whose sum cannot beat the lowest score still worth collecting.
  4. New in #16431. DoubleValues got getMaxScore; a field source answers with the block's largest value, and FunctionScoreQuery reports (wrapped query's bound) x (value bound) x boost instead of +Infinity. withDefault passes the wrapped bound on unchanged.
Diagram of the bug: 12,288 documents in three skip-index blocks of a price field; withDefault(price, 100) reports the block bounds 50, 9 and 9, but doc 9000 in the third block has no price and scores 100. The query FunctionScoreQuery OR tag:t keeps doc 0 (50.0018) as the top hit, skips the third block because 9 + 0.0018 is below 50.0018, and returns doc 0 instead of doc 9000. With the fix the bounds are 100 and doc 9000 is returned.
The 12,288-document example: per skip-index block, the bound 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.

The finding, step by step

  1. Index 12,288 documents with a price in 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).
  2. Search FunctionScoreQuery(MatchAllDocsQuery, thatSource) OR tag:t with IndexSearcher.search(q, 1). You expect doc 9000: it scores the default 100, every other document at most 50.0018, and explain and an exhaustive search agree.
  3. Instead you get doc 0 with score 50.0018. No error, with assertions on or off.
  4. Why: for pruning, withDefault passes 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.
  5. Letting the bound cover the default fixes it: the blocks report 100, 100 and 100, and search(q, 1) returns doc 9000.

Who is affected

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 code (main 645d4fe14a20)

  1. The default. DoubleValues.java:71-98: withDefault "Wrap[s] a DoubleValues instance, returning a default if the wrapped instance has no value" (:71). advanceExact stores the wrapped answer and returns true (:83-86); doubleValue() returns hasValue ? in.doubleValue() : missingValue (:78-80).
  2. The bound. DoubleValues.java:93-96: getMaxScore(upTo) returns in.getMaxScore(upTo). getMinScore is not overridden, so the lower bound stays -Infinity (:63-69), which is sound; only the upper bound is wrong.
  3. Where the finite bound comes from. fromLongField is fromField(field, v -> (double) v, Monotonicity.INCREASING) and fromIntField returns it (DoubleValuesSource.java:340-347). Its values read the field's DocValuesSkipper (:528-529); advanceShallow returns the current skip interval's last doc (:541-550) and getMaxScore the decoded skipper.maxValue(level) of the first level that covers upTo (: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). Inside withDefault it produces missingValue instead.
  4. The scorer bound. FunctionScoreQuery.java:261-269 returns innerMaxScore * valueMaxScore * boost (it returns +Infinity outright when the value bound is infinite, :265-267); its advanceShallow is the minimum of the wrapped query's and the values' (:254-259). With MatchAllDocsQuery the inner bound is 1.0.
  5. The pruning. For SHOULD clauses under ScoreMode.TOP_SCORES, BooleanScorerSupplier.java:315-328 builds a MaxScoreBulkScorer. Per window it reads each clause's getMaxScore(windowMax - 1) (MaxScoreBulkScorer.java:439); partitionScorers() makes clauses non-essential while their summed bound is below minCompetitiveScore (: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).

The numbers (probe on 645d4fe14a20)

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.

Which compositions

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 own DoubleValuesSource returning DoubleValues.withDefault(values, d) around a field source with a finite bound (fromLongField, fromIntField, fromFloatField, fromDoubleField, or fromField with a declared monotonicity) on a numeric field with the opt-in doc-values skip index (NumericDocValuesField.indexedField), and use it in a FunctionScoreQuery that is one scoring clause next to another, searched top-k (the default search(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 EmptyMemberValueSource of Elasticsearch and OpenSearch) wrap an instance without a bound (read, not tested). No release has the bug.

Minimal repro

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.

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

import org.apache.lucene.document.Document;
import org.apache.lucene.document.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);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestDoubleValuesWithDefaultMaxScore -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
getMaxScore(1) is at least 100, the value withDefault produces for doc 0.
Observed
getMaxScore(1) is 1.0: AssertionError: doc 0 value 100.0 > getMaxScore(1) = 1.0, with assertions on and off.

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

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.

Environment

lucenemain 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
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/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

Observed output

# 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>

Why it looks unintended

  • getMaxScore promises "the maximum score that documents between the current position and upTo can produce"; withDefault produces the default for documents without a value.
  • Upstream testMaxScoreBeyondTheCurrentBlock asserts value <= getMaxScore; no upstream test uses withDefault.
  • 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). withDefault produces missingValue for documents without a value and says they have one (:78-86). Scorer#getMaxScore has the same meaning for the scorer (Scorer.java:75-79), and Scorable#setMinCompetitiveScore lets a scorer "safely ignore" only documents that score less (Scorable.java:45-50).
  • Tests. TestDoubleValuesSource.java:412-422 (testMaxScoreBeyondTheCurrentBlock, added by #16697) asserts getMinScore(upTo) <= value <= getMaxScore(upTo) for a produced value. No upstream test calls DoubleValues.withDefault.
  • History. Before #16431 DoubleValues had no bounds and FunctionScoreQuery'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 mentions withDefault, 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 returns Float.POSITIVE_INFINITY, making nested expressions safe by construction" (rajat315315 on #16431); withDefault is 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).

Suggested fix

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.

Let withDefault's bound cover the default (validated by the checks) (recommended)

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, which FunctionScoreQuery treats as no bound.
  • NaN. Math.max(x, NaN) is NaN and would poison the scorer's bound; FunctionScoreQuery scores 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 in FunctionScoreQuery.
  • 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 TestDoubleValuesSource and TestMaxScoreBulkScorer (26 tests) and queries TestFunctionScoreQuery, TestFunctionQuerySort, TestValueSources and TestFunctionScoreExplanations (65 tests): 0 failures;
  • the whole queries module (512 tests) and spatial-extras module (117 tests, home of the five spatial withDefault callers): 0 failures; core checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass;
  • on the built 645d4fe14a20 jars 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.

Add the default only where a document lacks a value (not built)

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.

Drop the override (not recommended)

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.

Downstream note

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.

How it was found

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 every DoubleValues#getMaxScore override against the values it must bound. withDefault is 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.

Prior reports, history and checks

Prior reports

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 touch withDefault), #16705 (the only later change to DoubleValues.java, adds prefetch), 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.
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-2620 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 #1642915 hits read; only #16429, #16431 and #16697 discuss this code; none reports the bug
Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, web2026-10-2620 list queries, 4 JIRA queries, 9 web searchesno report
GitHub issues and PRs (apache/lucene)2026-10-316 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 daynothing new

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal 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 affectedthe bound test fails; the end-to-end test returns the wrong top hit (on d150ba27d11 and ea57360912ed, assertions on and off)
0d4f93a9ff9 (#16431) bound onlythe 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 codewithDefault.getMaxScore and the files involved are unchanged from 645d4fe14a20 (read, not run)
10.6.0 not affectedthe end-to-end test returns the right top hit; no bound API
10.0.0, 10.5.2, parent a1fcd50799f not affectedthe end-to-end test passes 4/4; no bound API, so the bound is +Infinity
9.12.3 not applicableno skip index and no bound API; the tests do not compile

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (code reading, then JUnit) candidatePublic-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 realOwn 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 reproducesStill 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 realScratch 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.

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

Finding record

LFV-214, snapshot of 2026-11-06

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 main 2f424fd428d (now 12.0.0-SNAPSHOT, 2026-10-30) and branch_11x 603af870375 (2026-10-29) carry the same code [read].

Lucene and upstream sources

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-584
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-252, (at 645d4fe14a20) :254-259, :261-269, :294-307, :371-386
  • lucene/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-50
  • lucene/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)

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 and an instrumented run of the 12,288-document example (the per-block bounds, clause bounds and top hits, with and without the fix).

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.