All findings · Lucene finding · LFV-203 · functionscorequery-maxscore-ignores-negative-value-clamp

FunctionScoreQuery scores negative values as 0, but its block-max bound stays negative, so top-k search skips competitive documents

On unreleased main, FunctionScoreQuery scores a negative field value as 0 but tells the top-k search that its documents score at most that negative value, so a BooleanQuery that adds it to a text query skips real matches. One matching document with delta = -1000: search(q, 1) returns 0 hits.

Summary

Priority
Low, rank 715A skip interval whose values are all negative gets a negative bound although scores clamp to 0, so top-k drops competitive documents (even to 0 hits), main only and narrower than its Medium sibling LFV-211.
Impact
silent wrong results
Since
#16431 (main, 2026-08-22)introduced 2026-08-22
Affected
main and branch_11x (unreleased 11.0.0); no release
Status
documented
Found by
semi-formal: SMT on the score bound, then a public-API JUnit

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

The finding in 30 seconds

FunctionScoreQuery turns a negative value into a score of 0, but since #16431 its block-max bound passes the negative value through. A top-k BooleanQuery trusts that bound, concludes the block cannot reach the top hits and skips it.

Background

  1. Negative values score 0. FunctionScoreQuery scores each document with a value from a field (here delta). Since Lucene 8.0 (LUCENE-7996) a negative value scores 0, because Lucene scores must not be negative.
  2. Skip index. A numeric doc-values field can opt in to a skip index (NumericDocValuesField.indexedField): for each interval of documents (with the default codec normally 4,096 documents that have a value) it stores the minimum and maximum value.
  3. Block-max pruning. For top-k search every clause reports, per block of documents, an upper bound of its scores (getMaxScore). For a BooleanQuery of SHOULD clauses, MaxScoreBulkScorer adds the clause bounds and skips a block whose sum cannot beat the lowest score still worth collecting; that threshold starts at 0.
  4. New in #16431. FunctionScoreQuery now reports (wrapped query's bound) × (the block's largest value) × boost instead of +∞, so blocks of small values can be skipped.
Diagram of the bug: one document matches body:b and has delta = -1000 in a skip-indexed field. The query is body:b OR FunctionScoreQuery(fromLongField(delta)). The function clause scores the document 0 (negative values are truncated to 0) but reports the bound 1.0 x -1000 = -1000; the text clause's bound is 0.131. The searcher adds the bounds, -999.87, which is below the starting threshold 0, so it skips the block and search(q, 1) returns 0 hits. With the fix the function bound is max(0, -1000) = 0, the sum is 0.131, and the document is returned with score 0.131.
The one-document repro: the function clause claims a bound of -1000 while its document scores 0, the summed bound falls below 0, and the block is skipped; with the fix the bound is 0 and the document is returned. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document with body:b and delta = -1000 in a numeric field with a doc-values skip index (NumericDocValuesField.indexedField). Search body:b OR FunctionScoreQuery(MatchAllDocsQuery, fromLongField("delta")) with IndexSearcher.search(q, 1).
  2. You expect 1 hit with score 0.131: FunctionScoreQuery scores negative values as 0 (documented since Lucene 8), so the score is the text score 0.131 plus 0.
  3. Instead you get 0 hits and totalHits 0. No error, with assertions on or off.
  4. Why: for top-k pruning the function clause claims that no document in its block scores more than 1 × -1000 = -1000, although all of them score 0. The searcher adds the clauses' claims, -1000 + 0.131 = -999.87, finds that below 0, the lowest score worth collecting, and skips the block unseen.
  5. Bigger indexes lose top hits the same way: with 5,000 documents where only doc 0 has delta = 1, search(q, 1) returns doc 0 (score 1.0) instead of doc 4999, the only text match. Clamping the claim at 0 fixes it.

Who is affected

Applications on main or the 11.0 branch (unreleased, since #16431) that add FunctionScoreQuery over fromLongField, fromIntField or a fromField whose decoder really is monotone in its declared direction, on a skip-indexed field (opt-in) as one clause of a scoring query. It fires when a block's values are all negative, or when a sparse field's only value is negative.

Mechanism, terms and numbers841 words

FunctionScoreQuery maps a negative function value to a score of 0 (LUCENE-7996, Lucene 8.0). Since #16431 (unreleased, 11.0.0) its scorer also reports a block-max bound for top-k pruning, innerMaxScore * valueMaxScore * boost, where valueMaxScore is the decoded skip-index maximum of the block. That bound has no clamp at 0, so for a block whose values are all negative it reports a negative number, below the 0 its documents really score. MaxScoreBulkScorer sums the clause bounds of a BooleanQuery of SHOULD clauses and skips windows whose sum is below the minimum competitive score, so documents made competitive by another clause are skipped; when the sum is below 0, the starting minimum, every window is skipped. With the default codec and IndexSearcher.search(q, n), a query that matches one document returns 0 hits, and a 5,000-document index returns the wrong top hit. Only main and branch_11x have it; every release returns the hit. A one-line clamp fixes it.

The code (main 645d4fe14a20)

  1. The score clamps. FunctionScoreQuery.java:243-252: score() returns (float) (factor * boost) only when factor >= 0, else 0 ("default: missing value, negative value or NaN", :250); for a negative value explain() returns a node "0 = truncated score, max of:" with a child "0.0 = minimum score" (:191-195).
  2. The bound does not. FunctionScoreQuery.java:262-269 returns innerMaxScore * valueMaxScore * boost and guards only Float.isInfinite(valueMaxScore).
  3. Where the negative factor comes from. fromLongField is fromField(field, v -> (double) v, Monotonicity.INCREASING) and fromIntField returns fromLongField (DoubleValuesSource.java:340-347). With a skip index, getMaxScore of their values returns the decoded skipper.maxValue(level) (or minValue for DECREASING) of the first level that covers upTo (DoubleValuesSource.java:571-584). For a decoder that really is monotone in its declared direction that is a correct bound for the values, and negative when every value of the interval decodes negative. fromFloatField and fromDoubleField are declared INCREASING too (:330-337), but their raw-bits decoders are not monotone over negative values; that is the separate LFV-189, not counted here. Only the scorer-level bound, which must cover the clamped scores, is wrong.
  4. Sparse fields. For a document without a value, advanceShallow(target) moves the skipper on to the next interval and returns that interval's last doc ID (DoubleValuesSource.java:541-550), and getMaxScore then applies that interval's bound to the whole span from target (:571-584). So in a field most documents leave unset, one negative value lowers the bound for the unvalued documents before it.
  5. The pruning, SHOULD + SHOULD (the minimal repro). A BooleanQuery of only SHOULD clauses (two or more) under TOP_SCORES gets a MaxScoreBulkScorer (BooleanScorerSupplier.java:210-232, :315-328). partitionScorers() sums the clause bounds and makes clauses non-essential while the sum is below minCompetitiveScore (MaxScoreBulkScorer.java:494-511); with no essential clause left it returns false and the window is skipped ("No matches in this window", :133-138). minCompetitiveScore starts at 0. The assertion at :454-455 sits in scoreNonEssentialClauses, which a skipped window never reaches, so assertions on do not catch it.
  6. The pruning, MUST text + SHOULD function query (code reading). For required plus optional clauses booleanScorer() returns null (BooleanScorerSupplier.java:237-241) and the scorer is a ReqOptSumScorer (:174-182). Its getMaxScore adds the optional clause's bound to the required one's (ReqOptSumScorer.java:273-279), and once the collector has raised the minimum competitive score above 0 it skips every block whose summed bound is below it (:89-105, :127-128). So the negative bound hides competitive documents there too, but only after the threshold has risen: the route of the MUST + SHOULD probe below.
  7. Default searcher. IndexSearcher.search(q, n) uses TopScoreDocCollectorManager(n, after, 1000) (IndexSearcher.java:100, :599), which is TOP_SCORES (TopScoreDocCollector.java:58-60) and raises the threshold after 1,000 hits.

The numbers

One document (the minimal repro), instrumented: the function clause's bound is 1.0 (wrapped MatchAllDocsQuery) × -1000.0 × boost 1 = -1000.0; the text clause's bound is 0.13076457; the sum is -999.86926 < 0, so partitionScorers() returns false and nothing is collected. An exhaustive search returns doc 0 with 0.13076457 and count(q) is 1; the function query alone returns the hit with score 0.0. With the fix the function bound is 0.0 and search(q, 1) returns doc 0 = 0.13076457.

5,000 documents in one segment (the finder's test): doc 0 has delta = 1, the rest -1000, and only doc 4999 matches body:b. The second skip interval (docs 4096-4999) reports -1000 ("doc 4096 scores 0.0 > getMaxScore(4999) = -1000.0"); after 1,000 hits only scores above doc 0's 1.0 are competitive, -1000 + the text bound is below that, and plain search(q, 1) returns doc 0 (score 1.0) instead of doc 4999.

Earlier executed shapes (same code at ea57360912ed): 6,000 documents with values in [-1999, -1000] give no hit and totalHits=0 for 6,000 matches, also with a DECREASING decoder x -> -x over positive stored values; BooleanQuery(MUST body:common, SHOULD the function query) over 18,823 documents with one negative value in a sparse field lost 78 of the true top 100 with the default search(q, 100) (through ReqOptSumScorer, item 6).

Exposure and likelihood352 words
  • Who. Users of main or branch_11x who rank with FunctionScoreQuery over fromLongField or fromIntField, or over fromField with a decoder that really is monotone in its declared INCREASING/DECREASING direction (declaring it does not make it so; fromFloatField/fromDoubleField belong to LFV-189), on a numeric field with the opt-in doc-values skip index (NumericDocValuesField.indexedField, or a field type with DocValuesSkipIndexType.RANGE), as one scoring clause next to another: "text query plus a numeric boost", as SHOULD + SHOULD or MUST text + SHOULD boost.
  • Trigger. A skip interval whose values all decode negative: a signed adjustment field, all-negative values in a small segment, a decreasing decoder such as x -> -x over positive values, or in a sparse field a single negative value. With the default codec an interval normally closes once it holds 4,096 documents with a value; the last one of a segment holds fewer, and a gap-free run of one repeated value can grow past 4,096. Such an interval loses matches when its negative bound pulls a window's summed bound below the current competitive threshold (at once when the sum is below 0, as in the minimal repro). For a fixed index, query and collector the outcome is deterministic: no randomness, no timing.
  • Effect. Silent wrong results: wrong top hits or no hits, and totalHits too low (once even reported as exact: 4,705 hits for 18,823 matches) while count() is right. No exception, assertions on and off, default codec and searcher, from one document up.
  • Never a superset. In 5 probe runs of 432 checks each, no non-matching, deleted or filtered document was returned.
  • Not reproduced in the recorded controls (their scope, not a guarantee): FunctionScoreQuery alone (it returns the hit with score 0), boostByValue, boostByQuery and expressions (their bound is +∞), fields without a skip index and non-negative values. No release has the bug: every release tested returns the hit.
  • How likely: rare. Negative values score 0 by design, so few applications store them for scoring, and they must fill a whole skip interval or be the only values of a sparse field.

Minimal repro

One JUnit test, one document, default codec, public API: body:b plus delta = -1000 in a skip-indexed field, the query body:b OR the function query, and IndexSearcher.search(q, 1) must return 1 hit.

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

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
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.TermQuery;
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 TestFunctionScoreQueryNegativeValueMaxScore extends LuceneTestCase {

  public void testNegativeValueDoesNotHideMatch() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        Document doc = new Document();
        doc.add(new StringField("body", "b", Field.Store.NO));
        doc.add(NumericDocValuesField.indexedField("delta", -1000));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query q =
            new BooleanQuery.Builder()
                .add(
                    new FunctionScoreQuery(
                        MatchAllDocsQuery.INSTANCE, DoubleValuesSource.fromLongField("delta")),
                    Occur.SHOULD)
                .add(new TermQuery(new Term("body", "b")), Occur.SHOULD)
                .build();
        // expected 1 hit (term score + function score 0); actual 0: the function's bound is -1000
        assertEquals(1, new IndexSearcher(reader).search(q, 1).scoreDocs.length);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFunctionScoreQueryNegativeValueMaxScore -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
search(q, 1) returns the document: 1 hit, score 0.131 (the text score plus 0).
Observed
0 hits: AssertionError: expected:<1> but was:<0>, with assertions on and off.

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

More files, environment, full output205 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, a plain IndexSearcher, nothing random. The function clause's bound alone (-1000) pushes the summed bound below 0, so one document is enough.

The 5,000-document wrong-top-hit form in the collapsed explanation above is the same bug; it is not part of this repro.

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

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Releases 10.6.0 and 10.5.2: pass
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/TestFunctionScoreQueryNegativeValueMaxScore.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/function/
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFunctionScoreQueryNegativeValueMaxScore -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

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

TestFunctionScoreQueryNegativeValueMaxScore > testNegativeValueDoesNotHideMatch FAILED
    java.lang.AssertionError: expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:57AC69C71F23E2DD]:0)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.queries.function.TestFunctionScoreQueryNegativeValueMaxScore.testNegativeValueDoesNotHideMatch(TestFunctionScoreQueryNegativeValueMaxScore.java:60)

# the finder's 5,000-document test on the same stock build (gradle, seed DEADBEEF): 2 tests, 2 failed
testTopHitWithDynamicPruning  java.lang.AssertionError: TOP_SCORES top hit (score 1.0) differs from COMPLETE expected:<4999> but was:<0>
testMaxScoreIsUpperBound      java.lang.AssertionError: doc 4096 scores 0.0 > getMaxScore(4999) = -1000.0

Why it looks unintended

  • Negative values are a supported input: MIGRATE.md says "FunctionScoreQuery maps negative values to 0", and the upstream test testTruncateNegativeScores covers it.
  • Scorer#getMaxScore must bound the scores; the test framework asserts score <= maxScore.
  • Before #16431 the bound was +∞ and every release returns the hit; the #16431 review discussed -x functions, never the sign.
The full argument214 words
  • The contract. MIGRATE.md:1368-1375 ("FunctionScoreQuery maps negative values to 0", LUCENE-7996); CHANGES.txt:6732-6733 (8.0.0: "FunctionQuery and FunctionScoreQuery now return a score of 0 when the function produces a negative value"); the score() comment and explain() branch (FunctionScoreQuery.java:250, :191-195); TestFunctionScoreQuery.java:236-253 indexes -2 and runs QueryUtils.check on this very query, with a plain field, so the bound path is never reached (that test class has no indexedField at all).
  • Bounds bound scores. Scorer#getMaxScore returns "the maximum score that documents between the last target ... and upTo" can have (Scorer.java:75-79); CheckHits.java:766 and AssertingScorer.java:130 assert score <= maxScore; MaxScoreBulkScorer.java:454-455 asserts "All clauses are essential if minCompetitiveScore is equal to zero", which holds only if no bound is negative (in the repro it is never reached: the window is skipped before any clause is scored, so assertions on stay silent).
  • History. Before #16431 the bound was +Infinity, useless for pruning but always safe; 10.0.0, 10.5.2 and 10.6.0 return the hit. In the #16431 review uschindler noted that functions like 1/x or -x are not increasing, which led to Monotonicity.DECREASING (direction, not sign); no comment mentions the clamp or a negative bound. The follow-up #16697 fixed bounds that covered the wrong range, so upstream treats an unsafe bound of this feature as a defect.
The devil's-advocate check186 words

The strongest case: in #9044 (LUCENE-7996), in a comment of 2018-10-14, Adrien Grand wrote that "FunctionScoreQuery is probably the easiest way to mistakenly create a scorer that returns negative scores, in which case one should sort by the function rather than using this function to compute scores", so negative values could be called a user mistake. A second argument: a negative DoubleValues bound is correct for the values.

Why it fails: the project had already chosen to truncate negative values to 0 ten months earlier. Alan Woodward proposed in the same issue on 2017-11-30 to "document that functions that return negative values will be truncated to 0 for scoring purposes", and a8a63464e7d (Adrien Grand, 2017-12-06) added the clamp, the migration note and testTruncateNegativeScores. The 2018 remark did not change that; all three are still on main. The remark bears on how many users hit the bug, not on whether a top-k search may drop a match that an exhaustive search returns. The second argument is true and not in dispute; it only places the defect in FunctionScoreQuery, whose bound must cover its clamped scores.

Suggested fix

Recommended Clamp the scorer's bound at 0

Clamp the bound like score() clamps the score: return Math.max(0f, innerMaxScore * valueMaxScore * boost); in the scorer's getMaxScore. One line; the repro and the 5,000-document test pass, and so do the closest suites and the whole queries module (512 tests).

// score() maps negative values to 0, so the bound must not be negative
return Math.max(0f, innerMaxScore * valueMaxScore * boost);
All options and trade-offs4 options · 550 words

Option A was built and run through gradle. B agrees with A only when the wrapped query's bound is finite (it gives NaN under a wrapped FunctionQuery), so A is preferred; C is not recommended. The sibling fix for LFV-211 changes the other factor of the same line and does not fix this bug.

Clamp the scorer's bound at 0 (validated by the checks) (recommended)

In FunctionScoreQuery's scorer, getMaxScore (FunctionScoreQuery.java:268) returns the product clamped at 0, the same rule score() applies (+2/-1 lines).

// score() maps negative values to 0, so the bound must not be negative
return Math.max(0f, innerMaxScore * valueMaxScore * boost);

Trade-offs

  • Clamp after the multiply. innerMaxScore and boost are never negative (negative boosts are rejected), so when the wrapped query's bound is finite this equals clamping the value bound first. It also covers a wrapped query whose bound is +∞, such as a wrapped FunctionQuery: there the stock bound is -∞ and this fix gives 0.0 (executed).
  • Pruning kept. Non-negative bounds are unchanged; DoubleValues bounds stay as they are, since they are correct for the values.
  • NaN. Math.max(0f, NaN) is NaN, so a NaN bound from a NaN stored double (the separate LFV-189) passes through unchanged.
  • Cost. One Math.max per bound computation; no benchmark was run.
  • Scope. It fixes the negative bound only; the other defects of the same feature (LFV-211, LFV-189, LFV-214, LFV-221, and LFV-273 in the score clamp) 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-30):

  • stock: the minimal test fails with assertions on and off; the finder's 5,000-document test fails 2 of 2;
  • with the fix: the minimal test passes with assertions on and off; the finder's test passes 2 of 2;
  • TestFunctionScoreQuery, TestFunctionScoreExplanations, TestFunctionQuerySort and TestValueSources pass (65 tests, 0 failures), and in core TestDoubleValuesSource and TestMaxScoreBulkScorer (26 tests, 0 failures);
  • the whole :lucene:queries:test passes (51 classes, 512 tests, 0 failures, 5 skipped); checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain for queries pass.
  • Earlier: the clamp makes every verifier's and reviewer's own test pass, and with all five fixes of this hunt combined upstream TestFunctionScoreQuery (17) and TestDoubleValuesSource (11) pass.

Clamp the value bound before multiplying

float valueMaxScore = Math.max(0f, scores.getMaxScore(upTo));, closer to score()'s factor >= 0 test.

Trade-offs

Passes the minimal repro (executed), but it is not equivalent to the first option: the two agree only when the wrapped query's bound is finite. With a wrapped FunctionQuery (bound +∞) it computes ∞ × 0 = NaN where the first option gives 0.0 (executed on 645d4fe14a20). One more reason to prefer the first option.

Clamp inside DoubleValuesSource (not recommended)

Return max(0, ...) from the field source's getMaxScore.

Trade-offs

Makes a correct value bound wrong for any other consumer of DoubleValues#getMaxScore, and leaves FunctionScoreQuery relying on every source to clamp. Not run.

Not enough on its own: the LFV-211 fix

LFV-211 stops multiplying by the max score of a wrapped query that runs without scores, the other factor of the same return.

Trade-offs

This repro still fails with that fix alone (2 of 2), and LFV-211's repro still fails with this clamp alone; the two compose (Math.max(0f, ...) around the product with the inner factor fixed).

Downstream note

Where end users can set the value of a sparse, skip-indexed boost field, one negative value can push other documents out of the top hits; no hidden, deleted or filtered document is ever returned, and no Elasticsearch, OpenSearch or Solr path to this query is known.

How it was found

While modelling the new bound, the finder noticed that score() clamps negative values to 0 and the bound does not. SMT solvers (Z3 and cvc5) found a block of negative values whose bound lies below the clamped score; a Java replay, a randomized top-k-versus-exhaustive comparison and a public-API JUnit then confirmed it.

The method in full152 words

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

  • Model. The finder wrote the scorer's score and bound in floating-point SMT (QF_FP) and asked whether a score can exceed its bound. Z3 4.12.2 and cvc5 1.4.2 both answered SAT for a block whose maximum value is negative: the bound lies below the clamped score 0. A Java replay confirmed the witness.
  • Differential. A randomized harness compared TOP_SCORES with an exhaustive search over 450-600 random indexes per fix level (skip index on 80%, several value modes); its negative-block mismatch classes disappear with the clamp, and none occur without a skip index or for boostByValue/boostByQuery.
  • JUnit. A directed public-API test (5,000 documents) failed on main with assertions on and off and passed on 10.5.2; the minimal repro above is that test cut down to one document and one assertion.

Prior reports, history and checks

Prior reports

No prior report found in the documented searches: no issue, PR, commit or list post they reached describes it (89 queries plus a full-text read of 677 apache/lucene issues and PRs, on 2026-10-26; re-checked 2026-10-30). Closest: #16431 itself, whose review discussed decreasing functions but not negative values.

Searches3 searches
  • #16431 (the introducing PR): uschindler's remark on 1/x and -x led to Monotonicity.DECREASING; the author's benchmark rows DecNeg_* used g(x) = -x on a skip-indexed field without comment on the resulting scores; nobody mentions the clamp.
  • #16429 (the feature request), #16697 (fixes other defects of the same feature in DoubleValuesSource), #9044 (LUCENE-7996, the contract this bug breaks), open #16847 (edits the same test files only for long doc ids), #16855 (clamps a window end in BlockMaxConjunctionBulkScorer, unrelated).
  • Same family (the hunt's own findings): LFV-211 (other factor of the same line), LFV-189, LFV-214, LFV-221, LFV-273.
  • Limits: private security reports and builds@ test-failure mails since 2026-08 could not 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; all of GitHub)2026-10-2689 logged read-only queries (search and GraphQL): class and method names, getMaxScore negative / clamp, Monotonicity DECREASING negative, FunctionScoreQuery skipper negative, TOP_SCORES wrong; a full-text read of 677 apache/lucene issues and PRs (all 502 created since 2026-07-20 and 175 older ones updated since 2026-07-28) and the changed files of all 413 open PRsonly #16429, #16431 and #16697 discuss this code; none reports the bug
Mailing lists (dev@, java-user@, issues@, commits@, builds@), JIRA archive, web2026-10-2627 list queries, 4 JQL queries, 7 web searchesno report; only notification mails of #16431 and #16697
GitHub issues and PRs (apache/lucene)2026-10-308 searches on items updated since 2026-10-20 (FunctionScoreQuery, negative maxScore, DoubleValuesSource skipper, block-max FunctionScoreQuery, 16431, clamp max score, DocValuesSkipper score) and getMaxScore PRs since 2026-10-25nothing new (only #16855, unrelated)

History

Introduced by #16431 (0d4f93a9ff9, merged 2026-08-22), which replaced the +∞ bound; reachable end to end since #16697 (2026-10-09). On main and the new branch_11x; no release has it (10.0.0, 10.5.2 and 10.6.0 return the 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; resolves #16429). It replaced the scorer's +Infinity bound with innerMaxScore * valueMaxScore * boost without the clamp score() has applied since LUCENE-7996 (a8a63464e7d, 2017). Blame of the lines and git log -S'innerMaxScore * valueMaxScore' return only this commit.

Masked at first: until #16697 (d150ba27d11, Zhang Chao, 2026-10-09) the tested end-to-end queries threw inside the skipper (a separate advanceShallow defect) before they could return a wrong hit; the bound was already negative, and #16697's change shadowed onto the introducing commit gives the wrong top hit.

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 carry the unclamped 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 5,000-document test returns doc 0 instead of 4999; passes with the fix
main d150ba27d11, ea57360912ed, a9779ef3c193 affectedthe finder's test fails 2 of 2 (assertions on and off on d150ba27d11 and ea57360912ed; on a9779ef3c193 assertions on only)
0d4f93a9ff9 (#16431) bound onlynegative bound; the end-to-end queries threw in the skipper first; with #16697's change shadowed: the wrong top hit
main 716d6f216f5, branch_11x 603af870375 (2026-10-29) same codethe unclamped bound and the files involved are unchanged from 645d4fe14a20 (read, not run)
10.6.0 not affectedminimal test passes; the bound is +Infinity
10.0.0, 10.5.2, parent a1fcd50799f not affectedthe finder's test passes, assertions on and off; the bound is +Infinity
9.12.3 no skip indexthe trigger cannot be written: NumericDocValuesField.indexedField does not exist

Independent checks

Reproduced by an independent reproducer (no hit at all for 6,000 matches), a devil's-advocate verifier, two executed reviews by OpenAI GPT-5.6, an adjudication with a fresh seed and this doc's gradle run; controls without a skip index or with non-negative values pass, and the clamp fixes every repro.

Checks and verdicts324 words

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

Each independent check and its verdict
CheckVerdictWhat it found
Finder (SMT, differential, JUnit) candidateZ3 and cvc5: a negative block maximum whose bound is below the clamped score 0 (SAT); the differential's negative-block classes vanish with the clamp; the directed JUnit fails 2/2 on main with assertions on and off and passes on 10.5.2.
Refute lens (OpenAI GPT-5.6, executed) real (high)Its own tests fail on main (2 of 3, two seeds, assertions off too); controls and the fix pass; upstream classes 17/17 and 11/11 with the fix.
Independent reproducer realOwn public-API test, default codec, 6,000 negative documents: no hit and totalHits=0, also with a DECREASING x -> -x decoder; 30/30 bug runs fail over 10 iterations, 20/20 controls pass; 10.5.2 and the clamp pass.
Works-as-designed lens (OpenAI GPT-5.6, executed) real (high)Intended-behaviour argument weak; its own 5-test class fails 3 of 5 on main; controls and 10.5.2 pass; with the fix 5/5 and upstream TestFunctionScoreQuery 17/17.
Intended-behaviour devil's advocate realNo intended, documented, misuse or unreachable argument survives; its mixed-sign repro fails 40/40 over 20 seeds ("0.0 > -2.0 up to 8191"); 10.5.2 and the fix pass.
Adjudication confirmed (high)Re-ran the independent reproduction's tests with seed 1A2B3C4D (3 of 5 fail, assertions on and off) and the finder's (2/2 fail); both pass with the fix.
Re-verification on newer main and the minimal repro still reproducesStill fails on a9779ef3c193; the one-document test fails on 645d4fe14a20 (two seeds with assertions on, one with assertions off), passes with the fix and on 10.5.2.
This doc's fix validation (gradle) real; fix validatedScratch worktree of 645d4fe14a20: stock fails the minimal and the 5,000-document tests; the clamp passes them, the closest suites and the whole queries module (512 tests); 10.6.0 passes the minimal test.

Diagram file

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

Sources and provenance

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

Finding record

LFV-203, snapshot of 2026-11-06

Registry title
FunctionScoreQuery scores negative values as 0, but its block-max bound stays negative, so top-k search skips competitive documents
Area
queries / FunctionScoreQuery block-max pruning (#16431)
Priority
Low, rank 715: A skip interval whose values are all negative gets a negative bound although scores clamp to 0, so top-k drops competitive documents (even to 0 hits), main only and narrower than its Medium sibling LFV-211.
Impact class
silent wrong results
Impact note
Applications on unreleased main (11.0.0-SNAPSHOT, since #16431) that rank with FunctionScoreQuery over a monotone field source (fromLongField, fromIntField, fromFloatField, fromDoubleField, or fromField with INCREASING/DECREASING) on a numeric field indexed with the opt-in doc-values skip index (NumericDocValuesField.indexedField), next to another scoring clause: when every value of a skip interval is negative after decoding, the default IndexSearcher.search(q, n) silently returns wrong top hits, or no hits at all (the independent reproduction: totalHits=0 for a query matching 6,000 documents), with the default codec and searcher, -ea or -da, at about 5,000 documents; no released version is affected.
Summary
You index a numeric doc-values field with a skip index (NumericDocValuesField.indexedField("delta", v)) and search BooleanQuery(SHOULD FunctionScoreQuery(MatchAllDocsQuery, DoubleValuesSource.fromLongField("delta")), SHOULD TermQuery(body:b)) for the top hit, expecting the same hit as an exhaustive search, because FunctionScoreQuery documents that a negative value simply scores 0 (MIGRATE.md, LUCENE-7996). Instead, with 5,000 documents where doc 0 has delta 1, all others have -1000, and only doc 4999 matches body:b, TOP_SCORES and the plain IndexSearcher.search(q, 1) return doc 0 (score 1.0) instead of doc 4999. The cause is that the scorer's getMaxScore returns innerMaxScore * valueMaxScore * boost without the clamp at 0 that score() applies, so a block of negative values reports a bound of -1000 for documents that all score 0, and MaxScoreBulkScorer skips windows that hold competitive documents.
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, resolves #16429, merged 2026-08-22) [exec: the follow-up history sweep shows the negative bound at 0d4f93a9ff9, d150ba27d11 and main with -ea and -da, while the parent a1fcd50799f, 10.0.0 and 10.5.2 pass; read: blame of FunctionScoreQuery.java:263-268 and git log -S'innerMaxScore * valueMaxScore' return only 0d4f93a9ff9]; it replaced the scorer's +Infinity bound with innerMaxScore * valueMaxScore * boost and added the skipper-based DoubleValuesSource bound, without the 0 clamp that score() has applied since LUCENE-7996 (a8a63464e7d, 2017); the silent wrong top hit became reachable end to end with #16697 (d150ba27d11, by easyice, 2026-10-09), which fixed a separate advanceShallow defect that made the tested queries throw first [exec: #16697's DoubleValuesSource change shadowed onto 0d4f93a9ff9 gives main's wrong top hit, and adding the clamp makes both tests pass] (2026-08-22 (0d4f93a9ff9 on main); silent wrong top hit 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, and branch_10x, branch_10_6 and branch_10_5 do not contain #16431]
Affected versions
main (11.0.0-SNAPSHOT) from 0d4f93a9ff9 (2026-08-22) through ea57360912ed (upstream main HEAD on 2026-10-26) [exec: Scorer#getMaxScore negative while score() returns 0 at 0d4f93a9ff9, d150ba27d11 and main, -ea and -da; silently wrong TOP_SCORES / search(q, n) top hit, or no hit at all, at d150ba27d11 and main, and at 0d4f93a9ff9 only with #16697's change shadowed, because before #16697 the tested queries threw in the skipper first]; not affected: 10.0.0 and 10.5.2 [exec, -ea and -da: FunctionScoreQuery's bound is +Infinity there] and the parent commit a1fcd50799f [exec]; not applicable: 9.x [exec on 9.12.3: compile error, no NumericDocValuesField.indexedField; the bound is +Infinity, read]; unreleased branch_10x and branch_10_6 (last fetched 2026-10-24) and branch_10_5 do not contain #16431 [read]
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; also reached by fuzzing/PBT
Upstream and prior reports
none found (2026-10-26, the prior-report record: 89 logged read-only queries over apache/lucene issues and PRs (REST search and GraphQL), commit search, the dev@, java-user@, issues@, commits@ and builds@ lists, the LUCENE JIRA archive, the Elasticsearch, OpenSearch and Solr repositories, all of GitHub and the web, plus a full-text read (bodies, first 60 comments, review threads, changed files) of all 677 apache/lucene issues and PRs created or updated since 2026-07-20 and 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 manual searches, the intended-behaviour check's and the adjudication's searches found nothing either); not fixed on upstream main (HEAD ea57360912ed on 2026-10-26: FunctionScoreQuery.getMaxScore still returns innerMaxScore * valueMaxScore * boost, and no open PR touches FunctionScoreQuery.java); origin: #16431 (resolves #16429; its review discussed decreasing functions such as -x and 1/x, which led to Monotonicity.DECREASING, but nobody mentioned negative values, the 0 clamp or a negative bound); #16697 (easyice) fixed other defects of the same feature in DoubleValuesSource and does not touch FunctionScoreQuery; context, not reports: #9044 (LUCENE-7996, 2017), the decision that negative function values are truncated to 0, which is the contract this bug breaks; open PR #16847 edits TestFunctionScoreQuery and TestDoubleValuesSource only for long doc ids
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; with the recommended fix it passes, together with the finder's 5,000-document test, the closest suites and the whole queries module. Release 10.6.0 passes (standalone runner). Upstream main 716d6f216f5 (now 12.0.0-SNAPSHOT) and branch_11x 603af870375 (both 2026-10-29) carry the same code [read].

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:186-195 (explain(): 'truncated score, max of: 0 minimum score' for value < 0), :243-252 (score() returns factor * boost only when factor >= 0; 'default: missing value, negative value or NaN' -> 0), :262-269 (getMaxScore returns innerMaxScore * valueMaxScore * boost, guarding only Float.isInfinite)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:338-346 (fromLongField/fromIntField = fromField(field, v -> (double) v, Monotonicity.INCREASING)), :571-584 (FieldValuesSource.getMaxScore returns the decoded skipper.maxValue(level), or minValue for DECREASING)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/Scorer.java:75-79 (getMaxScore: the maximum score of documents between the last shallow-advance target and upTo); lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java:494-510 (clauses are non-essential while the summed bounds stay below minCompetitiveScore); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100 (TOTAL_HITS_THRESHOLD = 1000)
  • a Lucene main checkout lucene/MIGRATE.md:1368-1375 ('Scorers are no longer allowed to produce negative scores ... FunctionScoreQuery maps negative values to 0', LUCENE-7996); lucene/CHANGES.txt:6699-6700 (LUCENE-7996) and :223-224 (Lucene 11.0.0: GITHUB#16431, GITHUB#16697)
  • a Lucene main checkout lucene/queries/src/test/org/apache/lucene/queries/function/TestFunctionScoreQuery.java:236 (testTruncateNegativeScores: a negative long value through fromLongField scores 0); lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:766 and AssertingScorer.java:130 (score <= maxScore oracles)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:186-195, (at 645d4fe14a20) :243-252 (:250), :262-269
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:330-347, (at 645d4fe14a20) :541-550, :571-584; DoubleValues.java:55-61
  • lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java:46, (at 645d4fe14a20) :99-138 (:133-138), :474-517 (:502), :447-455 (the assertion is in scoreNonEssentialClauses); SimpleScorable.java:24
  • lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:174-182, (at 645d4fe14a20) :198-242 (:210-232, :237-241), :308-328; ReqOptSumScorer.java:68-105 (:89-105), :124-129, :273-279; IndexSearcher.java:100, :599; TopScoreDocCollector.java:58-60
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesFormat.java:226 (at 645d4fe14a20) (DEFAULT_SKIP_INDEX_INTERVAL_SIZE = 4096); Lucene90DocValuesConsumer.java:250-261 (SkipAccumulator.isDone: a gap-free run of one single value keeps the interval open past 4096); lucene/core/src/java/org/apache/lucene/document/NumericDocValuesField.java:46-58 (indexedField)
Who is affected
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesFormat.java:226 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:59-61 (at 645d4fe14a20) (default getMaxScore +Infinity; not overridden in lucene/expressions or FunctionScoreQuery's boost sources)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesConsumer.java:250-261 (at 645d4fe14a20) (SkipAccumulator.isDone); lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:330-347 (fromDoubleField/fromFloatField raw-bits decoders, fromLongField, fromIntField)
Why it looks unintended
  • lucene/MIGRATE.md:1368-1375; (at 645d4fe14a20) lucene/CHANGES.txt:6732-6733 (8.0.0, Changes in Runtime Behavior), :238-239 (11.0.0, Optimizations)
  • lucene/queries/src/test/org/apache/lucene/queries/function/TestFunctionScoreQuery.java:236-253 (at 645d4fe14a20) (0 uses of indexedField in the file)
  • lucene/core/src/java/org/apache/lucene/search/Scorer.java:75-79; (at 645d4fe14a20) MaxScoreBulkScorer.java:454-455; lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:766; AssertingScorer.java:130
  • github.com/apache/lucene/issues/9044 (comments migrated from JIRA by Alan Woodward, 2017-11-30, and Adrien Grand, 2018-10-14; read 2026-10-30)
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:140-142 (at 645d4fe14a20) (getMaxScore returns Float.POSITIVE_INFINITY); lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:40-42 (negative boosts rejected)
History
  • a Lucene clone refs fetched 2026-10-30: origin/main 716d6f216f5 ('Add Version 12.0 (#16942)' e0fa3535ea5), origin/branch_11x 603af870375 (merge base with main c5ab3e57b9ba, 2026-10-28; contains 0d4f93a9ff9; FunctionScoreQuery.java:268 unchanged), origin/branch_10_6 and releases/lucene/10.6.0 (no 0d4f93a9ff9; getMaxScore returns Float.POSITIVE_INFINITY), origin/branch_10x ('Decommission branch 10x (#16911)')

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and an instrumented run of it (the bounds, the summed bound and the skipped window, 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.