All findings · Lucene finding · LFV-1015 · rescoretopn-negative-values-drop-disjunction-hits

RescoreTopNQuery passes negative values through as negative scores, so a top-k BooleanQuery that ORs it with another clause returns no hits

OR a RescoreTopNQuery whose values are negative with another clause, and a top-k search can return nothing: one document with v = -2 gives 0 hits while count(q) is 1. The query passes -2 through as a score; top-k pruning assumes scores are never below 0.

Summary

Priority
Medium, rank 55In released 10.3+ the experimental RescoreTopNQuery with a negative-valued non-vector source silently returns 0 hits when ORed with another clause, which is severe but a rarer pairing than its siblings LFV-087 and LFV-315, so it closes that RescoreTopNQuery group ahead of LFV-424 and the related negative-score row LFV-190.
Impact
silent wrong results
Since
10.3.0 (#14009, merged 2025-06-28)introduced 2025-06-28 · about 1 year
Affected
10.3.0 to 10.6.0 and main (all 8 releases run)
Status
documented
Found by
AI tools probing unchecked RescoreTopNQuery inputs; a differential harness bounded it

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

A rescoring clause whose values are negative makes a top-k OR query skip documents that match its other clauses.

Background: rescoring and top-k pruning

  1. RescoreTopNQuery(query, valuesSource, n) runs query, gives each match the value of valuesSource (a doc-values field, an expression, a vector similarity) and keeps the n best. The result acts like a query that matches exactly those n documents, each with its value as the score.
  2. A BooleanQuery with SHOULD clauses matches a document if any clause matches it; its score is the sum of the matching clauses' scores. Combining a rescored result with other queries this way is the hybrid use named by the #14009 author and in the javadoc of RescoreTopNQuery.createLateInteractionQuery.
  3. A top-k search skips work: each clause reports the highest score it can give, and documents whose best possible score cannot reach the minimum competitive score are skipped unscored. That threshold starts at 0, because Lucene scores are never negative.
Summary of the bug: one document with v = -2; the query ORs RescoreTopNQuery(MatchAllDocsQuery, fromLongField("v"), 1) with MatchAllDocsQuery; count(q) is 1 but search(q, 10) returns 0 hits. rescoreInto stores the value -2 as the score, DocAndScoreQuery reports -2 as the clause's max score, and MaxScoreBulkScorer adds -2 + 1 = -1, below its starting minimum competitive score 0, so every clause is non-essential and the window is skipped. FunctionScoreQuery maps negative values to 0; with the clamp the document is returned.
The one-document repro: the rescored clause reports a best score of -2 and the match-all clause 1; their sum, -1, is below the starting threshold 0, so the top-k scorer skips the document. 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 NumericDocValuesField("v", -2). Query: RescoreTopNQuery(MatchAllDocsQuery, DoubleValuesSource.fromLongField("v"), 1) as one SHOULD clause of a BooleanQuery, MatchAllDocsQuery as the other.
  2. You expect searcher.search(q, 10) to return the document: it matches the MatchAllDocsQuery clause, and count(q) is 1.
  3. You get 0 hits and totalHits 0, with no error, assertions on or off. With 10 documents (v = -2 to -11) it is 0 of 10.
  4. RescoreTopNQuery copies the value -2 into the score, so its clause reports a best possible score of -2. Lucene requires scores to be at least 0; FunctionScoreQuery would have turned -2 into 0.
  5. The top-k scorer adds up the clauses' best scores, -2 + 1 = -1, and compares the sum with its starting threshold 0. Below it, no document can compete, so it skips the window; the best scores are the same in every window, so it skips them all.

Who is affected

Applications that use the experimental RescoreTopNQuery with a values source that can be negative (a signed numeric field, an expression such as -age or ln(x) for x < 1, a negative constant) next to other scoring clauses, on 10.3.0 to 10.6.0 and main. One document is enough. Vector-similarity sources are never negative and are safe.

Mechanism, terms and numbers593 words

RescoreTopNQuery (experimental, new in 10.3.0) rescores another query's matches with a DoubleValuesSource and keeps the top n. It stores each value as the score as it is, so a negative value becomes a negative score and a negative max score, although Lucene requires non-negative scores (LUCENE-7996) and FunctionScoreQuery maps negative values to 0. When the query is a SHOULD clause of a top-k BooleanQuery, MaxScoreBulkScorer starts with a minimum competitive score of 0; if the clauses' max scores add up to less than 0, it treats every clause as non-essential and skips the window, and here every window of the segment. One document with v = -2, OR MatchAllDocsQuery: search(q, 10) returns 0 hits while count(q) is 1, with no error, default configuration, assertions on or off, in every release from 10.3.0 to 10.6.0 and on main.

The bug

  1. RescoreTopNQuery.rewrite fills a HitQueue of size n through rescoreInto and returns DocAndScoreQuery.createDocAndScoreQuery(reader, topDocs, 0) (RescoreTopNQuery.java:68-91).
  2. rescoreInto inserts new ScoreDoc(docBase + docId, (float) values.doubleValue()) with no clamp; only a missing value becomes 0 (:224-231). The value -2 becomes the score -2.
  3. DocAndScoreQuery takes maxScore = topK.scoreDocs[0].score (DocAndScoreQuery.java:125) and reports it from getMaxScore (:239-240): -2.
  4. search(q, 10) collects with ScoreMode.TOP_SCORES, and for two SHOULD clauses BooleanScorerSupplier builds a MaxScoreBulkScorer (BooleanScorerSupplier.java:315-328), whose minimum competitive score starts at 0 (SimpleScorable.java:24).
  5. partitionScorers marks clauses non-essential while their running max-score sum stays below that threshold (MaxScoreBulkScorer.java:494-511): -2 < 0, then -2 + 1 = -1 < 0. Every clause is non-essential, it returns false (:515-516), and the window is skipped as "No matches in this window" (:133-137). The max score is the same for every window, so the whole segment is skipped.

The numbers

  • One document, v = -2: count(q) = 1, search(q, 10) = 0 hits, totalHits 0. Collected exhaustively (TopScoreDocCollectorManager(10, Integer.MAX_VALUE)), the same query returns the document with score -1.0.
  • 10 documents, v = -2 to -11: 0 of 10 hits, while exhaustive collection returns all 10 (nine at +1.0).
  • A hybrid query, TermQuery OR RescoreTopNQuery(same TermQuery, fromLongField("v"), 1), 3 matching documents with v = -2, -3, -4: 0 of 3 hits.
  • Expressions as the source: -age and ln(age / 100000) give 0 of 10 hits.
  • Two segments, {-2, -3} and {-5, -6}, n = 1: 2 of 4 hits. Only the segment that holds the kept document loses its hits; a segment without one has no scorer for the rescored clause (DocAndScoreQuery.java:198-200).
  • n > 1 with mixed signs, OR MatchAllDocsQuery: values {5, -2} with n = 2 give 0 of 2 hits, {5, 3, -2} with n = 3 give 0 of 3; with n = 2 the -2 is not kept and all 3 are returned. The clause's max score is the first entry of the kept set in heap order, its lowest value.

Not claimed here (same unclamped value, other symptoms)

  • If the max-score sum is still at or above 0 (for example a constant -1 OR MatchAllDocsQuery), the search reaches ScorerUtil.minRequiredScore at a threshold of 0 and, with assertions off, never returns; with assertions on it throws AssertionError "All clauses are essential if minCompetitiveScore is equal to zero". That loop is a separate bug with its own fix site (LFV-1016).
  • With minimumNumberShouldMatch 2 over three SHOULD clauses, WANDScorer throws IllegalArgumentException "Scores must be positive or null" (WANDScorer.java:69-71).
  • The query alone, or as a MUST or FILTER clause, returns the right hits, with negative scores.
Exposure and likelihood300 words
  • Who is hit. Callers of RescoreTopNQuery with a non-vector values source that can be negative, used as a SHOULD clause of a top-k BooleanQuery. With n = 1 the kept value has to be negative; with n > 1 one negative value among the n kept documents is enough, because the clause's max score is scoreDocs[0].score (DocAndScoreQuery.java:125) and rewrite fills scoreDocs in heap order (RescoreTopNQuery.java:83-87), so it is the lowest kept value: values {5, -2} with n = 2 give 0 of 2 hits. That is the use the class was built for: the #14009 author described "a hybrid search where KnnFloatVectorQuery and TermQuery (amongst others) are combined into a single BooleanQuery", and the late-interaction factory's javadoc says it is "typically useful in combining a query's results with other queries for hybrid search" (RescoreTopNQuery.java:313-315).
  • Who is not. The built-in vector-similarity sources (full precision, float16, late interaction) are non-negative by construction. The query alone, or as a MUST or FILTER clause, returns the right hits.
  • Default vs opt-in. Default IndexWriterConfig, codec and IndexSearcher; the trigger is the opt-in, @lucene.experimental query (RescoreTopNQuery.java:34) with a non-vector source.
  • Scale. In a single-segment index every matching document disappears; in the tested two-segment index the segment that holds the kept document loses both its hits, and totalHits is wrong too. In general a window is skipped when its clauses' max scores add up to less than the minimum competitive score; at 0 or more, the separate LFV-1016 loop applies instead. No exception or warning, assertions on or off.
  • How likely. Rare: every public user of the query that was found uses vector similarities. The failure does not depend on chance: the same index and query give 0 hits every time (10 of 10 derived seeds).

Minimal repro

One JUnit class for lucene/core/src/test/org/apache/lucene/search/, one method, public API: one document, default IndexWriterConfig, plain IndexSearcher. It fails with assertions on and off; it is also the suggested regression test.

TestRescoreTopNQueryNegativeValues.java (JUnit for Lucene's build)51 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;

public class TestRescoreTopNQueryNegativeValues extends LuceneTestCase {

  public void testDisjunctionReturnsAllMatches() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new NumericDocValuesField("v", -2));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query q =
            new BooleanQuery.Builder()
                .add(
                    new RescoreTopNQuery(
                        MatchAllDocsQuery.INSTANCE, DoubleValuesSource.fromLongField("v"), 1),
                    BooleanClause.Occur.SHOULD)
                .add(MatchAllDocsQuery.INSTANCE, BooleanClause.Occur.SHOULD)
                .build();
        // expected: the doc matches the MatchAllDocsQuery clause, so 1 hit; actual: 0 hits
        assertEquals(1, new IndexSearcher(reader).search(q, 10).scoreDocs.length);
      }
    }
  }
}
Command
cp $REPRO/TestRescoreTopNQueryNegativeValues.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestRescoreTopNQueryNegativeValues -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
Expected
1 hit: the document matches the MatchAllDocsQuery clause, and count(q) is 1.
Observed
0 hits and totalHits 0, no error (expected:<1> but was:<0>), with assertions on and off.

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

More files, environment, full output179 words

The test indexes one document with NumericDocValuesField("v", -2) in a ByteBuffersDirectory and runs search(q, 10) with q = SHOULD RescoreTopNQuery(MatchAllDocsQuery.INSTANCE, DoubleValuesSource.fromLongField("v"), 1) + SHOULD MatchAllDocsQuery.INSTANCE. Deterministic, single thread, no random data (it failed on 10 of 10 derived seeds). It uses a plain IndexSearcher on purpose, to keep it deterministic: through newSearcher the test still fails, but the randomized searcher sometimes takes another scorer path (in 200 seeds with -ea, 198 returned 0 hits and 2 threw IllegalArgumentException "Scores must be positive or null" from WANDScorer; no AssertionError, because no score is computed). On 10.3.x replace MatchAllDocsQuery.INSTANCE with new MatchAllDocsQuery() (the constant first appears in 10.4.0); that port fails the same way.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); every release from 10.3.0 to 10.6.0 also fails; 10.2.2 and older: class absent
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

TestRescoreTopNQueryNegativeValues > testDisjunctionReturnsAllMatches FAILED
    java.lang.AssertionError: expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:45D837F95F0A64BE]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.search.TestRescoreTopNQueryNegativeValues.testDisjunctionReturnsAllMatches(TestRescoreTopNQueryNegativeValues.java:47)
1 test completed, 1 failed

# the same with -Ptests.asserts=false; with the recommended fix the test passes

Why it looks unintended

  • Lucene requires non-negative scores since LUCENE-7996: "Queries are now required to produce positive scores."
  • FunctionScoreQuery, which the class javadoc compares this query to, maps negative values to 0, and a test checks it on a similar input (one document, v = -2).
  • Nothing documents or checks a value range, and the same query collected exhaustively returns the hit.
The full argument274 words
  • The rule. lucene/CHANGES.txt:6636: "LUCENE-7996: Queries are now required to produce positive scores." lucene/MIGRATE.md:1368-1375: "Scorers are no longer allowed to produce negative scores" and "FunctionScoreQuery maps negative values to 0." The test framework asserts it (AssertingScorer.java:131): with AssertingIndexSearcher and -ea, the rescored clause alone fails with AssertionError: -2.0.
  • Lucene clamps caller-supplied values itself. FunctionScoreQuery maps a negative value or NaN to 0 (FunctionScoreQuery.java:243-251), and TestFunctionScoreQuery.testTruncateNegativeScores (:236-253) checks it on one document with NumericDocValuesField("foo", -2) and fromLongField. In the LUCENE-7996 thread (#9044) romseygeek asked "could we just set the score to 0?", and jpountz answered with a patch: "New patch that maps negative scores to 0. The explanation says when this happens so that this change is not too trappy." In the same disjunction, FunctionScoreQuery over the same source returns the hit.
  • The class invites this use. Its javadoc says it re-scores "with a DoubleValuesSource function", "can work with any type of Query" and compares it with FunctionScoreQuery (RescoreTopNQuery.java:26-32); the constructor checks only n >= 1 (:58-61); CHANGES calls the source "generic" (CHANGES.txt:1595-1596); hybrid use is advertised (:313-315).
  • Two collections disagree. Exhaustive collection returns the document, top-k returns nothing; CheckHits.doCheckTopScores requires them to be equal (CheckHits.java:700-709).
  • History. Earlier built-in negative-score paths were fixed as bugs: #11184, #12342 / #12727, #13356, #16139. The upstream RescoreTopNQuery tests use constant(0), SCORES over a kNN query's scores and vector similarities, so no test value is negative, and they never put the query in a BooleanQuery; no comment on #14009 or #16926 discusses value ranges.
The devil's-advocate check166 words

The strongest case for "works as designed": the class is experimental and was built to rescore vector shortlists, whose similarities are non-negative; MIGRATE.md tells authors of custom queries to keep "their score formula" non-negative, so a negative values source is the caller's mistake; and negative values carry ranking information (for example -distance), so passing them through is a choice.

Why it fails: RescoreTopNQuery is Lucene's own query, and the caller only passes a values source through a public constructor; for that exact shape LUCENE-7996 chose to clamp inside Lucene; the query is advertised for generic sources and hybrid use, where sorting by the function instead is not an option; nothing documents or checks the precondition; and the failure is silent and far from its cause. Even if negative values are declared illegal, the silent 0-hit result needs a check or a clamp. The ranking point is real, which is why the recommended fix keeps the selection on the raw values. Verdict: not intended, a defect.

Suggested fix

Recommended B: keep the top n by raw value, clamp only the emitted scores (validated)

Keep picking the top n by the raw value, then report negative values (and NaN) as score 0, as FunctionScoreQuery does: 5 lines in RescoreTopNQuery.rewrite. With it the repro and the 56 tests of five upstream RescoreTopNQuery, prefetch and kNN test classes pass (0 failures; the nightly tests too).

for (ScoreDoc topDoc : queue) {
  // The top n are chosen by raw value, but scores must not be negative (LUCENE-7996): like
  // FunctionScoreQuery, report negative values and NaN as 0
  if ((topDoc.score > 0) == false) {
    topDoc.score = 0;
  }
  scoreDocs[i++] = topDoc;
}
All options and trade-offs4 options · 674 words

Options A and B were applied in a scratch worktree of 645d4fe14a20 and run with gradle (the worktree was removed after the runs). Options C and D were not built. Whichever is chosen removes this query as a trigger of a separate ScorerUtil.minRequiredScore loop (LFV-1016) but does not fix that loop.

B: keep the top n by raw value, clamp only the emitted scores (validated) (recommended)

+5 lines in RescoreTopNQuery.rewrite, in the loop that copies the queue into scoreDocs (RescoreTopNQuery.java:83-87): a kept document whose value is not above 0 gets score 0.

for (ScoreDoc topDoc : queue) {
  // The top n are chosen by raw value, but scores must not be negative (LUCENE-7996): like
  // FunctionScoreQuery, report negative values and NaN as 0
  if ((topDoc.score > 0) == false) {
    topDoc.score = 0;
  }
  scoreDocs[i++] = topDoc;
}

Trade-offs

  • Keeps the documented meaning (the top n of the values source) and meets the non-negative rule; the max score handed to DocAndScoreQuery can no longer be negative. > 0 also maps -0.0 and NaN to +0.
  • Kept documents with negative values all score 0, so their relative order no longer shows in the combined score (as with FunctionScoreQuery). NaN values, which Float.compare orders above every number in the HitQueue, are still kept first, then reported as 0.
  • Cost: one branch per kept document, outside the per-candidate loop. No benchmark was run.

What the checks validated

  • Gradle, stock 645d4fe14a20: the repro fails with assertions on and off.
  • Gradle with the fix (seed DEADBEEF): the repro, the selection probe below and TestRescoreTopNQuery, TestRescoreTopNQueryPrefetch, TestRescoreTopNQueryFloat16Prefetch, TestFilterReadersForwardVectorPrefetch and TestKnnFloatVectorQuery: 7 classes, 58 tests (56 of them upstream), 0 failures, 2 @Nightly skipped. With assertions off, the repro and the TestRescoreTopNQuery* suites: 10 tests, 0 failures. TestRescoreTopNQuery with -Ptests.nightly=true: 7 of 7 pass. checkGoogleJavaFormat passes.
  • Shadowed into the stock main jars: the two-segment probe returns 4 of 4 hits, and a 7-case control probe (including the hybrid TermQuery case) passes 7 of 7.
  • A selection probe (v = -4, -3, -2, n = 1) keeps doc 2, the best value, with score 0.0.
  • n > 1 with mixed signs (values {5, -2}, n = 2; {5, 3, -2}, n = 3), shadowed: every document is returned.

A: clamp before the queue insert, as FunctionScoreQuery does (validated, changes the selection)

In rescoreInto (RescoreTopNQuery.java:224-231): value >= 0 ? (float) value : 0f.

if (values.advanceExact(docId)) {
  double value = values.doubleValue();
  // Scores must not be negative (LUCENE-7996): like FunctionScoreQuery, map negative values and
  // NaN to 0
  queue.insertWithOverflow(new ScoreDoc(docBase + docId, value >= 0 ? (float) value : 0f));
} else {
  queue.insertWithOverflow(new ScoreDoc(docBase + docId, 0f));
}

Trade-offs

  • Mirrors FunctionScoreQuery line for line, and also fixes the repro.
  • All negative values tie at 0 before the top n are chosen, so the HitQueue keeps the lowest doc ids instead of the best values: with v = -4, -3, -2 and n = 1 it keeps doc 0 (the worst value) where stock and option B keep doc 2. A -distance rescore would keep arbitrary documents. -0.0 passes through.

What the checks validated

Gradle: the repro, the selection probe and the TestRescoreTopNQuery* suites, 11 tests, 0 failures, 2 @Nightly skipped. The hunt's earlier checks validated it too (with the upstream TestRescoreTopNQuery* tests, 7 run, 0 failed).

C: reject negative values with IllegalArgumentException (not built)

Throw during rewrite when a value is below 0 (or NaN), and document the range on the constructor.

Trade-offs

Loud and explicit, but breaks callers whose sources are legitimately negative (log-probabilities, -distance) and who only need the selection. A maintainer may still prefer it for an experimental API.

D: clamp or reject in DocAndScoreQuery.createDocAndScoreQuery (not built)

The factory has been public on main and branch_11x since #16686 (DocAndScoreQuery.java:121-125; package-private on branch_10_6); a check there covers every producer at once.

Trade-offs

Changes a public factory's contract and does not apply to 10.x as it is; the kNN producers already emit non-negative scores.

Downstream note

Results only lose hits, never gain any; if an application rescores by values its users can set (a signed field they fill, a boost expression), every search that rescores by those values can come back empty.

How it was found

After an upstream fix for empty inner results in RescoreTopNQuery (#16926), AI tools tried its other unchecked inputs; negative values gave 0 hits in an executed public-API probe. A differential harness (1,560 query and value combinations) bounded the effect, and the case was reduced to a one-document JUnit.

The method in full173 words

Method: non-formal (sibling analysis of an upstream fix plus executed probes; the finder tagged it semi-formal; this page classes it by the first signal).

  • Hunt planners read upstream fix #16926 (empty inner results, 2026-10-28) and listed the other inputs RescoreTopNQuery never checks. For negative values an executed public-API probe on main 645d4fe14a20 showed both 0 hits and a hang.
  • An AI finder reduced it to a JUnit and ran a differential harness on the stock jars: 39 value producers x 10 BooleanQuery shapes x 2 indexes x k in {1, 10}, 1,560 cases, with termination, hit count and the exhaustive top k as the oracle. Result: 94 hangs, 128 zero-hit results, 56 IllegalArgumentExceptions and 392 cases with negative scores only; all 520 non-negative controls pass, and with the clamp all 1,440 RescoreTopNQuery cases pass. An SMT check (Z3 and cvc5) covered the sibling minRequiredScore loop.
  • The 0-hit disjunction was then reduced to the one-document test. So this is a find from targeted probing, confirmed by execution, not a blind discovery by a tool.

Prior reports, history and checks

Prior reports

No prior report found in two searches (2026-10-29 and 2026-10-30) of GitHub issues and PRs incl. the JIRA archive, four mailing lists and the web, including every public item that names RescoreTopNQuery and the file lists of all 410 open PRs. Not fixed upstream.

Searches5 searches

No report of this bug was found, and upstream main still inserts the raw value. The non-negative rule (LUCENE-7996) and the clamps in other producers state the expected behaviour; they do not report this defect, and RescoreTopNQuery (2025) is newer than most of them. Open #16847 is the only PR that touches RescoreTopNQuery.java and DocAndScoreQuery.java; it changes doc-id types and keeps the raw insert.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, commits (apache/lucene incl. the migrated JIRA archive; downstream repositories)2026-10-29 and 2026-10-30six automated passes (identifiers, symptoms, variants) and 34 + 43 manual search-API queries, including every public GitHub item naming RescoreTopNQuery (11 in apache/lucene, 16 in all) and items created or updated since 2026-10-27no report; closest #14009 (added the query; hybrid use is its motivation), #16926 (last change)
Related items (read in full)2026-10-30full threads of 32 items, grepped for negative and clampprecedents only: #9044 / LUCENE-7996 and #9054 / LUCENE-8006 (the rule), #12342, #12727, #13356, #11184, #16139, #15411 (clamps in other producers), #16410 (open, signed values kept internal), #3347 / LUCENE-2271 (2010, different code)
Open PRs2026-10-30changed-file lists of all 410 open apache/lucene PRsonly #16847 touches the two files (doc-id types), keeping the raw insert
Mailing lists, JIRA, web2026-10-29 and 2026-10-30dev@, java-user@, issues@, commits@ (tool queries plus every October subject since 2026-10-28); 12 JIRA queries; 19 web queriesnothing about this bug; the class postdates the 2022 JIRA migration
Re-check for this page2026-10-30last commit on RescoreTopNQuery.java on main; issues and PRs created since 2026-10-29unchanged (645d4fe14a20 is still the last commit on the file); nothing new

History

Introduced with the class by #14009 (merged 2025-06-28) and its backport #14860, first released in 10.3.0. Every release from 10.3.0 to 10.6.0 and main fails; the rescore loop never clamped. 10.2.2 and older have no RescoreTopNQuery.

Versions tested7 rows

#14009 "Add Query for reranking KnnFloatVectorQuery with full-precision vectors" (dungba88, commit 3404496b3d77, merged 2025-06-28) added RescoreTopNQuery with the unclamped insert; the branch_10x backport #14860 (4fcb6d6d50de, 2025-06-29) shipped it in 10.3.0 (tagged 2025-09-13). The original insert, in rewrite(), reads double v = rescores.doubleValue(); and (float) v; #16705 (82d73908cb1, 2026-10-01, on main and branch_11x, not on 10.x) moved it into rescoreInto as (float) values.doubleValue(), with the same unclamped behaviour; branch_10_6 still has the (float) v form; later changes (#16628, #16926, #16271, #16686) never clamp or reject negative values. Not a regression: the parents of both commits have no RescoreTopNQuery.java.

The release rows ran the repro (on 10.3.x its port with new MatchAllDocsQuery(), as MatchAllDocsQuery.INSTANCE first appears in 10.4.0); the commits built from source ran a public-API main() twin of it with a v = 2 control that passes; all on OpenJDK 25.0.3, Linux aarch64; release jars were SHA-1 checked.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (unreleased 11.0) affectedfails, -ea and -da, gradle and standalone: 0 hits instead of 1
10.6.0 affectedfails, -ea and -da (release jars)
10.5.0 to 10.5.2 affectedfail (10.5.2 with -ea and -da)
10.4.0 affectedfails
10.3.0 to 10.3.2 affectedfail with the 10.3.x port (10.3.0 with -ea and -da)
commits 3404496b3d77 (#14009) and 4fcb6d6d50de (#14860), built from source affectedfail, -ea and -da (main() twin of the repro; a v = 2 control passes)
10.2.2, 9.12.3, parent b6fb10d5f931 n/ado not compile: no RescoreTopNQuery (the repro on the releases, the main() twin on the parent)

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate, an adjudication, two later re-checks and this page's gradle runs all reproduce it. Cross-model reviews (OpenAI GPT-5.6) could run nothing: some said real, the others "not real" for procedure alone; separate re-runs of the repro failed as claimed. A read-only cross-model fact check found this page substantially sound.

Checks and verdicts520 words

Every executing check ran on the stock jars and voted real. The only dissent is procedural: two cross-model refute reviews that could run nothing voted not real on procedure only at low confidence (one could not even read the code and called the mechanism technically plausible from the report), and separate re-runs of the repro failed as claimed.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateIts JUnit fails 3 of 3 with -ea and -da; the 1,560-case differential harness bounds the effect; with the clamp all 1,440 RescoreTopNQuery cases pass.
Independent reproducer realOwn JUnit, written from the claim: 4 of 4 fail on main and 10.5.2; exhaustive collection returns 10 hits, top-k 0; positive and zero constants pass; passes with the clamp.
Cross-model review before the candidate, refute lens (OpenAI GPT-5.6) not real, low (procedural)Read-only: it could run nothing, so it voted not real on procedure only; root cause agreed, inputs in contract, intended-behaviour argument weak. A separate run re-ran the repro: 3 of 3 fail with -da.
Cross-model review, intended lens (OpenAI GPT-5.6) real, medium; intended argument weakRead-only review; root cause agreed; a separate run re-ran the repro: 3 of 3 fail with -da.
Intended-behaviour devil's advocate realBuilt and rejected the "experimental, caller's problem" case; narrowed the claim to the 0-hit disjunction; re-runs fail on main and 10.5.2.
Adjudication confirmed, highRe-ran the independent reproduction's JUnit: 4 of 4 fail with -ea and -da.
Cross-model review of this bug, refute lens (OpenAI GPT-5.6) not real, low (procedural)It could run no command, so it could neither run nor read the code; from the report alone it called the mechanism technically plausible and the intended-behaviour case weak. A separate re-run fails 1 of 1.
Current-main reproducer realThe minimal test fails on main and 10.5.2 with -ea and -da and passes with the clamp.
Second adjudication real, high10 of 10 seeds, 10.6.0 and 10.5.2 fail; a 7-case control probe isolates the cause (a positive value, exhaustive collection and FunctionScoreQuery all return the hit).
Credibility audit keepRe-ran the claimed test on main (fails with -ea and -da, passes with the clamp); re-checked the code chain, contract, dedup and prior art.
This page's own runs reproduced; fix B validatedGradle in a scratch worktree: the repro fails stock with assertions on and off; fix B passes the repro, the selection probe and 56 upstream tests (58 in all), 10 with assertions off and 7 nightly; a selection probe shows option A's trade-off; a two-segment probe shows which segments lose hits.
Fact check of this page reproduced; wording fixesRe-ran the doc's gradle command (fails with -ea and -da), fixes A and B and the standalone and tie-break probes; found the n > 1 trigger and how newSearcher behaves; its wording, quote and count fixes are applied.
Cross-model fact check of this page (OpenAI GPT-5.6, read-only) substantially soundRead the code and the saved command output; no-noise call claim-low; its wording errors, overclaims and missing caveats are applied.

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

Finding record

LFV-1015, snapshot of 2026-11-06

Registry title
RescoreTopNQuery passes negative DoubleValuesSource values through as negative scores, so a top-k BooleanQuery that ORs it with another clause silently returns no hits
Area
search / rescoring (RescoreTopNQuery scores, consumed by MaxScoreBulkScorer dynamic pruning)
Priority
Medium, rank 55: In released 10.3+ the experimental RescoreTopNQuery with a negative-valued non-vector source silently returns 0 hits when ORed with another clause, which is severe but a rarer pairing than its siblings LFV-087 and LFV-315, so it closes that RescoreTopNQuery group ahead of LFV-424 and the related negative-score row LFV-190.
Impact class
silent wrong results
Impact note
Applications that use the experimental RescoreTopNQuery with a values source whose best values are negative (a signed numeric field, an expression such as -age or ln(x) for x < 1, a negative constant; the built-in vector-similarity sources are non-negative and safe) as a SHOULD clause of a top-k BooleanQuery, the hybrid use its javadoc names, get 0 hits and totalHits 0 from the default search(q, n) whenever the clauses' max scores sum below 0, while count() matches every document; default IndexWriterConfig, codec and searcher, 10 documents suffice, -ea and -da, released 10.3.0 to 10.6.0; with smaller negative values the same search instead never returns (-da) or throws an AssertionError (-ea), a loop owned by the sibling ScorerUtil candidate.
Summary
You index 10 documents with a numeric doc-values field v = -2..-11 and run BooleanQuery(SHOULD RescoreTopNQuery(MatchAllDocsQuery, DoubleValuesSource.fromLongField("v"), 1), SHOULD MatchAllDocsQuery) with searcher.search(q, 10), expecting all 10 documents, since each one matches the MatchAllDocsQuery clause and count(q) is 10. Instead it returns 0 hits and totalHits 0. RescoreTopNQuery copies the raw value -2 into the score, although Lucene requires non-negative scores (LUCENE-7996) and FunctionScoreQuery maps negative values to 0, so the clause reports a max score of -2, the two clauses' max scores sum to -1, below the initial minimum competitive score of 0, and MaxScoreBulkScorer skips the whole window as if no document could compete.
Introduced
3404496b3d77 (#14009 'Add Query for reranking KnnFloatVectorQuery with full-precision vectors', by GitHub dungba88, main), released through its branch_10x backport 4fcb6d6d50de (#14860 'Backport RescoreTopNQuery to 10.3') [read: git log -G/-S on the insert line finds only #14009 and 82d73908cb1 (#16705, main only, 2026-10-01), which moved the unclamped line into rescoreInto; exec: the repro fails on 10.3.0, the first release with the class, and RescoreTopNQuery is absent in 10.2.2]. Not a regression: the rescore loop never clamped, while FunctionScoreQuery has clamped since LUCENE-7996 (8.0.0) (2025-06-28 (#14009 author date; backport #14860 merged 2025-06-29))
First affected version
10.3.0 [exec]; 10.2.2 and 9.12.3 are not applicable (no RescoreTopNQuery, the repro does not compile) [exec]
Affected versions
10.3.0 (-ea and -da), 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 (-da), 10.5.2 (-ea and -da), branch_10x 657014b3c57 (-da), the 10.6.0 RC1 core jar (branch_10_6 4ae946e5a9b, main() twin, -ea and -da) and main 645d4fe14a20 (-ea and -da) [exec: the finder's JUnit fails 3 of 3 on each, and the claimed test gives 0 hits instead of 10 on every one, seed DEADBEEF; the independent reproduction's own JUnit also fails on 10.5.2 and 10.3.0]; not applicable to 10.2.2 and 9.12.3 [exec: compile error, class absent]; not fixed upstream: main HEAD is still 645d4fe14a20 and rescoreInto inserts the raw value on main, branch_11x and branch_10_6 [read, 2026-10-29]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by Z3/cvc5
Upstream and prior reports
none found (2026-10-29, read-only, the prior-report record verdict new: three scripted prior-report searches with 170 queries over GitHub issues and PRs incl. the migrated JIRA archive, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; 34 manual GitHub searches, including every public GitHub item that names RescoreTopNQuery (16 in 6 repositories, all read); full reads of #14009, #14729, #14860, #16628, #16686, #16705, #16926 and the LUCENE-7996 / LUCENE-8006 threads; and the changed-file lists of 415 open apache/lucene PRs, none of which touches RescoreTopNQuery.java or DocAndScoreQuery.java). Related, not reports: #14009 (introduced the query; hybrid BooleanQuery use is its stated motivation), #9044 / LUCENE-7996 (the non-negative score rule; FunctionScoreQuery clamps), #12342, #12727, #13356, #11184 and #16139 (earlier negative-score clamps), #3347 / LUCENE-2271 (2010: -inf/NaN function scores in TopScoreDocCollector, different code), #11464 / LUCENE-10428 (an earlier ulp loop, precedent for the sibling loop candidate only), #16926 (empty inner results, LFV-297). Follow-up re-check 2026-10-29: the last commit on RescoreTopNQuery.java is still 645d4fe14a20 (#16926, author date 2026-10-28); 'RescoreTopNQuery negative' finds only #14009; since 2026-10-27 no issue or PR matches 'RescoreTopNQuery' (except #16926), 'DocAndScoreQuery', 'negative score(s)' or 'clamp negative', and none of the 11 open PRs created since then is related. Not fixed upstream
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28; upstream main 716d6f216f52 has the same RescoreTopNQuery.java on 2026-10-30): the repro with gradle in a scratch worktree, stock with assertions on and off, and with fixes A and B together with the RescoreTopNQuery, prefetch and kNN suites (the worktree was removed after the runs); a standalone runner on main with -ea and -da (10 of 10 derived seeds fail), on the published jars of every release from 10.3.0 to 10.6.0 (10.3.x with a port that uses new MatchAllDocsQuery()), and on the introducing commits built from source; the independent tests of the finder, verifiers, an adjudication, a current-main reproducer and a tie-break. JDK OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • a Lucene main checkout (645d4fe14a20): lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:25-57 (class and constructor javadoc, @lucene.experimental at :34, only n >= 1 checked), :80-90 (rewrite hands the top n to DocAndScoreQuery.createDocAndScoreQuery), :224-231 (rescoreInto stores (float) values.doubleValue(); only a missing value becomes 0f), :313-315 ('useful in combining a query's results with other queries for hybrid search'); DocAndScoreQuery.java:125 (maxScore = topK.scoreDocs[0].score), :239-240 (getMaxScore); MaxScoreBulkScorer.java:474-517 (partitionScorers: a clause is non-essential while the max-score sum is below minCompetitiveScore; all non-essential returns false and the window is skipped), :447-458 (assert 'All clauses are essential if minCompetitiveScore is equal to zero', filterCompetitiveHits); ScorerUtil.java:131-142 (minRequiredScore); WANDScorer.java:69-72 ('Scores must be positive or null')
  • a Lucene main checkout: lucene/MIGRATE.md:1368-1375 ('Scorer must produce positive scores (LUCENE-7996)'); lucene/CHANGES.txt:6636 ('Queries are now required to produce positive scores') and :6732 (FunctionQuery and FunctionScoreQuery return 0 for negative values); lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-251 (clamp); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorable.java:36 and AssertingScorer.java:131 (score >= 0 asserts)
  • gh api read-only re-check 2026-10-29: repos/apache/lucene/commits?path=...RescoreTopNQuery.java; search/issues 'RescoreTopNQuery created:>=2026-10-27', '"negative score" created:>=2026-10-27', '"negative scores" created:>=2026-10-27', 'RescoreTopNQuery negative', 'is:pr is:open created:>=2026-10-27', 'DocAndScoreQuery created:>=2026-10-27', 'clamp negative created:>=2026-10-26'
The finding in 30 seconds
  • Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:68-91, :224-231; DocAndScoreQuery.java:125, :198-200, :239-240; BooleanScorerSupplier.java:315-328; SimpleScorable.java:24; MaxScoreBulkScorer.java:46, :133-137, :494-517; IndexSearcher.java:100, :599; TopScoreDocCollector.java:58-59; WANDScorer.java:69-71
Who is affected
  • Lucene main 645d4fe14a20: RescoreTopNQuery.java:26-34, :313-315
Why it looks unintended
  • Lucene main 645d4fe14a20: lucene/CHANGES.txt:6636, :6732-6733, :1595-1596; lucene/MIGRATE.md:1368-1375; lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-251; lucene/queries/src/test/org/apache/lucene/queries/function/TestFunctionScoreQuery.java:236-253; lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131, CheckHits.java:700-709; RescoreTopNQuery.java:26-32, :58-61, :313-315
  • Lucene main 645d4fe14a20: lucene/core/src/test/org/apache/lucene/search/TestRescoreTopNQuery.java:77, :101
Suggested fix
  • Lucene main 645d4fe14a20: DocAndScoreQuery.java:121-125; RescoreTopNQuery.java:83-87, :224-231; HitQueue.java:76-82
History
  • a Lucene clone: git show origin/branch_10_6 and origin/branch_11x RescoreTopNQuery.java (insert lines :76-77 and :227), git merge-base --is-ancestor 82d73908cb1 (branch_11x yes, branch_10_6 no), DocAndScoreQuery.java factory visibility per branch

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 page: a summary card (PNG), built from facts checked against the code, the minimal repro and executed probe output. The alt text follows the repro and the mechanism.

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.