All findings · Lucene finding · LFV-164 · bayesian-score-min-competitive-clamp

BayesianScoreQuery rounds the pruning threshold up to 1e-7, so top-k search can lose the best hits

When every hit of a BayesianScoreQuery scores below probability 1e-7, a plain searcher.search(query, n) that matches more than 1,000 documents (and more than n) can silently return weaker hits than an exhaustive search: the query raises the pruning threshold to 1e-7 before passing it to its inner query.

Summary

Priority
Low, rank 949BayesianScoreQuery rounds the minimum competitive score up to 1e-7, so top-k loses its best hits when every calibrated score is below that, an experimental 10.5 class needing boundary calibrations.
Impact
silent wrong results
Since
10.5.0 (added with the class)introduced 2026-03-27
Affected
10.5.0 to 10.6.0 and main
Status
documented
Found by
bounded-exhaustive check of top-k against exhaustive search

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

To pass a pruning threshold down, the query inverts its sigmoid and turns the collector's minimum probability into a minimum BM25 score. Before inverting, it raises that probability to at least 1e-7, so for hits below 1e-7 the BM25 threshold is too high. The scores themselves are not clamped.

Background: top-k pruning and BayesianScoreQuery

  1. searcher.search(query, n) counts the first max(1,000, n) hits exactly. After that it tells the scorer the lowest score that can still enter the top n (setMinCompetitiveScore), and the scorer may skip documents that score less.
  2. BayesianScoreQuery turns its inner query's score (for example BM25) into a probability, sigmoid(alpha * (score - beta) + logit(baseRate)). The sigmoid never falls, so the ranking should follow the inner ranking (up to ties, since the probability is a float). To pass a threshold down, it inverts the sigmoid: a minimum probability becomes a minimum inner score.
Diagram of the bug. 1,100 weak matches score BM25 0.545 (probability 1.16e-8) and doc 5000 scores BM25 1.03 (probability 1.89e-8). After 1,000 hits the collector asks BayesianScoreQuery to skip anything below 1.16e-8. The query raises that to 1e-7 and converts it to a BM25 threshold of 2.70, which is above every document, so the term query skips doc 5000 and search(q, 1) returns doc 3900. With the one-line fix (no lower clamp) the threshold is 0.545 and doc 5000 is returned.
How the threshold travels in the repro: the collector asks for 1.16e-8, the query raises it to 1e-7 and forwards BM25 2.70, above the best document's 1.03, so doc 5000 is skipped; without the lower clamp (the one-line fix) it forwards 0.545 and doc 5000 is found. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 5,001 documents: 1,100 contain a once (BM25 0.545), doc 5000 contains a a a (BM25 1.030), the rest do not match. Search BayesianScoreQuery(TermQuery(body:a), alpha=1, beta=5, baseRate=1e-6): the hits score 1.16e-8 and 1.89e-8.
  2. You expect searcher.search(q, 1) to return doc 5000, the top hit of an exhaustive search and of explain().
  3. After 1,000 hits the collector starts pruning and asks the query to skip anything below 1.16e-8. The query first raises that to 1e-7 and turns it into the BM25 threshold 2.70, higher than any document in the index (the best is 1.03).
  4. You get doc 3900, the first weak match (1.16e-8), and totalHits "1001+": the inner query skipped the last 100 matches, doc 5000 included. No error, with assertions on or off. Without the lower clamp the threshold is 0.545 and doc 5000 comes back.

Who is affected

Applications that rank with a BayesianScoreQuery (experimental, new in 10.5.0) at top level, as the only scoring clause next to filters, or as the only active signal of a LogOddsFusionQuery, on queries with more than max(1,000, n) matches whose k-th best hit calibrates below probability 1e-7. Default searcher and codec. Rare: it needs such low calibrations.

Mechanism, terms and numbers681 words

BayesianScoreQuery (new in 10.5.0, experimental) turns its inner query's score into a probability with a sigmoid, sigmoid(alpha * (score - beta) + logit(baseRate)). To support top-k pruning it inverts the sigmoid: the minimum competitive probability the collector asks for becomes a minimum inner score for the inner scorer. Before inverting, BayesianScoreScorer#setMinCompetitiveScore clamps the request to at least 1e-7, while the scores themselves are not clamped. So when the minimum the collector asks for is below 1e-7, the inner scorer is told to skip documents that can still win.

With the default IndexSearcher.search(query, n), pruning starts once more than max(1,000, n) hits are counted; in the repros the search then in effect stops, returns the first matches in doc-id order and reports totalHits as "1001+", with no error. In the repro search(q, 1) returns doc 3900 (1.16228565E-8) instead of doc 5000 (1.8876845E-8): the requested minimum 1.1622857E-8 is forwarded as BM25 threshold 2.6974144, above every score in the index. It needs a BayesianScoreQuery that does the top-level scoring (alone, next to filters, or as the only active signal of a LogOddsFusionQuery; where the fusion scorer itself runs, fused queries are not affected) and a calibration that puts the collector's minimum below 1e-7, which parameters estimated by BayesianScoreEstimator reached on a 100,000-document index. It has been there since the class was added (#15827) and affects 10.5.0 to 10.6.0 and main. Dropping the lower clamp fixes it; LFV-450, a separate bug in the same four lines, needs its own fix.

The bug, step by step (main 645d4fe14a20, BayesianScoreQuery.java unless stated)

  1. Pruning starts after max(1,000, n) hits. IndexSearcher.search(Query, int) (IndexSearcher.java:623-625) calls searchAfter, which uses TopScoreDocCollectorManager(cappedNumHits, after, TOTAL_HITS_THRESHOLD) with TOTAL_HITS_THRESHOLD = 1000 (:100, :598-599); the manager keeps Math.max(totalHitsThreshold, numHits) (TopScoreDocCollectorManager.java:69), scoring in ScoreMode.TOP_SCORES (TopScoreDocCollector.java:59). Once a collector has counted more than that, it calls scorer.setMinCompetitiveScore(Math.nextUp(topScore)) with the k-th best score so far (TopScoreDocCollector.java:116-120, :154-162); with several slices it can also take a higher minimum found by another slice (:99-100, :137-151). In the repro n = 1.
  2. The scores are not clamped. score() returns sigmoid(alpha * (innerScore - beta) + logitBaseRate) (:263-266), with logitBaseRate = log(baseRate / (1 - baseRate)) or 0 (:75-79); getMaxScore (:274-278) and explain() (:199-201) use the same transform. Probabilities below 1e-7 are returned and ranked.
  3. The threshold is clamped. BayesianScoreScorer#setMinCompetitiveScore (:281-292), for 0 < minScore < 1 (:286):
float clamped = Math.max(1e-7f, Math.min(1f - 1e-7f, minScore));   // :287
float logitMin = (float) Math.log(clamped / (1f - clamped));        // :288
float innerMin = (logitMin - logitBaseRate) / alpha + beta;         // :289
in.setMinCompetitiveScore(Math.max(0f, innerMin));                   // :290
  1. What the inner scorer gets. For any request below 1e-7 it gets the inner score whose probability is 1e-7: beta + (logit(1e-7) - logit(baseRate)) / alpha, about beta - 2.30/alpha with base rate 1e-6 and beta - 16.12/alpha without one. It may then skip every document whose probability lies between the request and 1e-7. The upper clamp only lowers a threshold, so it is safe.
  2. Not needed to avoid log(0). The guard at :286 already excludes 0 and 1; for every positive float the logit is finite (about -103 at the smallest one). Overflow in the rest of the inversion, with extreme legal parameters, is a separate matter.

The numbers (executed on main)

doc 3900 (and 1,099 more)doc 5000
texta filler filler fillera a a
inner BM250.54521751.03018
calibrated (alpha 1, beta 5, base rate 1e-6)1.16228565E-81.8876845E-8

The collector scores 1,001 documents, then asks for minimum score 1.1622857E-8. The stock code forwards BM25 threshold 2.6974144 (= 5 - 2.30), so the TermQuery scorer skips the remaining 100 matches and search(q, 1) returns doc 3900 with totalHits "1001+". Without the lower clamp it forwards 0.5452175, scores all 1,101 matches, then forwards 1.03018 and returns doc 5000. The skipping is done by the inner TermScorer, which acts on the threshold through its impacts because BayesianScoreQuery forwards setTopLevelScoringClause (:238-241, TermScorer.java:61-74, :127-131).

Exposure and likelihood500 words
  • Who is hit. A BayesianScoreQuery at top level, or as the only scoring clause of a conjunction (also reproduced with FILTER clauses), on 10.5.0 or later.
  • Fused queries. Where the fusion scorer runs they are not affected: LogOddsFusionScorer.setMinCompetitiveScore only updates its own block propagator and never forwards the threshold to its sub-scorers (LogOddsFusionScorer.java:213-218). But LogOddsFusionQuery hands a lone active signal's scorer straight to the collector (LogOddsFusionQuery.java:230-233), for example on a segment where the other signals have no match, and rewrites to the clause itself when only one clause is left (:359-360, :393-394); there the bug fires too. Executed on main with assertions on and off: on the repro's index, fusing body:a with a signal on an absent term returns doc 3900 instead of doc 5000, and so does a two-segment index where the second signal matches only in the second segment; both pass with the fix, and a control with both signals active passes on stock. Open PR #16410's description says "Single-active-signal leaves and MatchNoDocsQuery rewrites could also bypass or change the declared fusion model". Fusion with a vector signal that has no hit on some segments is plausible but was not run.
  • No special setting. Plain IndexSearcher.search(query, n), the default codec, a term or a disjunction inner query, single-threaded or sliced search, assertions on or off. Exhaustive collection is not affected.
  • What it takes. More than max(1,000, n) matches counted by a collector; a minimum competitive score below 1e-7 from the collector, which sends Math.nextUp of the k-th best score so far, so a k-th best probability below 1e-7: with base rate 1e-6 (the smallest BayesianScoreEstimator returns), an inner score more than 2.3/alpha below beta; without a base rate, more than 16.1/alpha below beta; and better hits later in doc-id order that the inner scorer can skip. The estimator sets beta to the median and alpha to 1/std of pseudo-query scores (BayesianScoreEstimator.java:41, :147, :162) and clamps the base rate to [1e-6, 0.5] (:55-56, :170).
  • Measured reach. With parameters from BayesianScoreEstimator.estimate on a 100,000-document default-codec index of mostly rare tokens (alpha 1.2305909, beta 4.930353, base rate 1.7e-5), a single-term query on a term in one third of the documents: search(q, 10) returned docs 4..112 in doc-id order and none of the ten true top hits. In the same verifier's scratch sweep (its code was not kept), a 200,000-document index with the term in 10% of the documents calibrated to 1.29e-7 and was not affected, so the window is real but bounded.
  • Silent. No exception; totalHits "1001+" is the normal lower bound of a pruned search.
  • Likelihood: rare. New and @lucene.experimental (BayesianScoreQuery.java:41), and it needs the collector's minimum below probability 1e-7. Its javadoc presents the probabilities as "suitable for combination with other probability signals via LogOddsFusionQuery" (:27-30); fusion shields the signals only where the fusion scorer runs, so fused use lowers the exposure but does not remove it. Nobody has reported it.

Minimal repro

One JUnit test in lucene/core, public API only: 5,001 documents in one segment with the default codec and BayesianScoreQuery(TermQuery(body:a), 1, 5, 1e-6). It compares search(q, 1) with an exhaustive search of the same query. Deterministic, single thread; fails with assertions on and off.

TestBayesianScoreTopScores.java (1 test)61 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 java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestBayesianScoreTopScores extends LuceneTestCase {

  public void testTopHitIsNotPruned() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      addDocs(w, 3900, "filler filler");
      addDocs(w, 1100, "a filler filler filler");
      addDocs(w, 1, "a a a");
      w.forceMerge(1);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query q = new BayesianScoreQuery(new TermQuery(new Term("body", "a")), 1f, 5f, 1e-6f);
        ScoreDoc best =
            searcher.search(q, new TopScoreDocCollectorManager(1, Integer.MAX_VALUE)).scoreDocs[0];
        ScoreDoc top = searcher.search(q, 1).scoreDocs[0];
        // expected: search(q, 1) returns the exhaustive top hit (doc 5000); actual: doc 3900
        assertEquals("exhaustive " + best + ", search(q, 1) " + top, best.doc, top.doc);
      }
    }
  }

  private static void addDocs(IndexWriter w, int count, String body) throws IOException {
    for (int i = 0; i < count; i++) {
      Document doc = new Document();
      doc.add(new TextField("body", body, Field.Store.NO));
      w.addDocument(doc);
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or any commit with the same BayesianScoreQuery.java)
cp $REPRO/TestBayesianScoreTopScores.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestBayesianScoreTopScores -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
search(q, 1) returns doc 5000 (1.8876845E-8), the exhaustive top hit.
Observed
It returns doc 3900 (1.16228565E-8), expected:<5000> but was:<3900>, on main and 10.6.0 with assertions on and off.

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

More files, environment, full output247 words

The test sits in org.apache.lucene.search, as Lucene's own search tests do, but uses no package-private member. It pins TestUtil.getDefaultCodec() (LuceneTestCase otherwise randomizes the codec) and uses ByteBuffersDirectory, the IndexWriterConfig default StandardAnalyzer and the default BM25 similarity. The base rate 1e-6 is the smallest one BayesianScoreEstimator returns. The assertion is the TOP_SCORES-equals-COMPLETE rule of CheckHits.checkTopScores. It is also the suggested regression test.

As a regression test it needs no edits: :lucene:core:check -x test passes with it in place, with either fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0 release jars with this test
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

TestBayesianScoreTopScores > testTopHitIsNotPruned FAILED
    java.lang.AssertionError: exhaustive doc=5000 score=1.8876845E-8 shardIndex=-1, search(q, 1) doc=3900 score=1.16228565E-8 shardIndex=-1 expected:<5000> but was:<3900>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:A8DACEEFAE12CDB7]: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.apache.lucene.search.TestBayesianScoreTopScores.testTopHitIsNotPruned(TestBayesianScoreTopScores.java:49)
:lucene:core:test (FAILURE): 1 test, 1  failure

# the same message with -Ptests.asserts=false, and on the 10.6.0 release jars with -ea and -da

# probe on the same index and query, assertions off
# stock main 645d4fe14a20
[match] count(q)=1101
[doc] 3900 inner BM25=0.5452175 calibrated=1.16228565E-8
[doc] 5000 inner BM25=1.03018 calibrated=1.8876845E-8
[complete] top=doc=5000 score=1.8876845E-8 shardIndex=-1 totalHits=1101 hits
[search(q, 1)] top=doc=3900 score=1.16228565E-8 shardIndex=-1 totalHits=1001+ hits
[search(q, 1)] inner documents scored=1001
[search(q, 1)] call 0: requested minScore=1.1622857E-8 -> forwarded inner threshold=2.6974144
# with the lower clamp removed
[search(q, 1)] top=doc=5000 score=1.8876845E-8 shardIndex=-1 totalHits=1101+ hits
[search(q, 1)] inner documents scored=1101
[search(q, 1)] call 0: requested minScore=1.1622857E-8 -> forwarded inner threshold=0.5452175
[search(q, 1)] call 1: requested minScore=1.8876847E-8 -> forwarded inner threshold=1.03018

Why it looks unintended

  • Scorable#setMinCompetitiveScore lets a scorer skip only documents scoring less than the given minimum; raising the minimum first skips documents above it.
  • Only the threshold is clamped; score(), getMaxScore() and explain() are not, and the upstream tests require pruned top hits to equal an exhaustive search.
  • The introducing PR: "the original BM25 ranking is preserved exactly".
The full argument195 words
  • The contract. "Tell the scorer that its iterator may safely ignore all documents whose score is less than the given {@code minScore}." (Scorable.java:45-51).
  • The class contradicts itself. Scores, max scores and explanations are unclamped (:263-278, :199-201); only the threshold is floored. The method's own comment states the goal: "Invert the sigmoid to get the minimum inner score needed" (:282).
  • Upstream tests. testMaxScoreCorrectness, testBaseRateMaxScoreCorrectness and testEstimatedMaxScoreCorrectness (TestBayesianScoreQuery.java:227-231, :330-334, :435-442) call CheckHits.checkTopScores, which compares a TOP_SCORES collector with a COMPLETE one (CheckHits.java:690-705). Their scores are far above 1e-7, so they never reach the clamp.
  • Sibling pruning code rounds the other way. WANDScorer.scaleMinScore rounds a min competitive score down "in order to make sure that we do not miss any matches" (WANDScorer.java:107-120).
  • History. #15827: "Since sigmoid is monotone, the original BM25 ranking is preserved exactly", with "WAND max score correctness" among its tests. #15948: the base rate shifts scores "without changing ranking order within a single query". No review comment on #15827, #15948, #16110 or #16410 discusses this clamp or pruning accuracy; no javadoc, CHANGES or MIGRATE text declares a floor.
The devil's-advocate check177 words

The strongest case (an intended-behaviour reviewer of another model family and the intended-behaviour verifier): the class is @lucene.experimental, the symmetric 1e-7 clamp has been there since #15827, and LogOddsFusionScorer uses the same floor (CLAMP_MIN = 1e-7f, LogOddsFusionScorer.java:48-49). On that reading probabilities below 1e-7 are a saturated tail whose order does not matter, and the clamp is a numerical guard that also makes pruning cheaper.

Why it fails: the fusion floor applies to scores and max scores alike and never to a pruning threshold; this query returns the unclamped scores, so callers see and rank them. The lower clamp is not needed to avoid log(0) (the logit of any positive float is finite). The effect is not a reorder of near-ties: the search stops early and returns the first matches in doc-id order (none of the ten true top hits in the estimator case). "Experimental" allows the API to change, not wrong top hits. The cross-model lenses rated the case weak; the independent reproducer and the devil's advocate argued it through and rejected it.

Suggested fix

Recommended Drop the lower clamp (one line)

Drop the lower bound of the clamp: float clamped = Math.min(1f - 1e-7f, minScore);. The minScore > 0 guard already keeps the logarithm finite. On main the repro passes with assertions on and off, as do the closest suites, check and every verifier test. It leaves the sibling LFV-450 open. Open PR #16410 edits the same method.

       if (minScore > 0f && minScore < 1f) {
-        float clamped = Math.max(1e-7f, Math.min(1f - 1e-7f, minScore));
+        float clamped = Math.min(1f - 1e-7f, minScore);
         float logitMin = (float) Math.log(clamped / (1f - clamped));
         float innerMin = (logitMin - logitBaseRate) / alpha + beta;
         in.setMinCompetitiveScore(Math.max(0f, innerMin));
All options and trade-offs3 options ยท 574 words

Both options were run in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3), removed afterwards. "Closest suites" means TestBayesianScoreQuery (26 tests) and TestLogOddsFusionQuery (40 tests), the only tests that use these classes, plus the repro. Open PR #16410 by the class author rewrites the same method (dividing by the boost) and keeps Math.max(1e-7f, ...); either fix applies there the same way. Neither option fixes LFV-450, a separate unsafe-inversion bug in the same four lines: its minimal test still fails on 645d4fe14a20 with either one. LFV-450's recommended fix also passes this bug's minimal test, so that fix covers both.

Drop the lower clamp (one line) (validated by the checks) (recommended)

Keep only the upper bound, which can only lower the threshold. The minScore > 0f && minScore < 1f guard already keeps the logarithm finite.

       if (minScore > 0f && minScore < 1f) {
-        float clamped = Math.max(1e-7f, Math.min(1f - 1e-7f, minScore));
+        float clamped = Math.min(1f - 1e-7f, minScore);
         float logitMin = (float) Math.log(clamped / (1f - clamped));
         float innerMin = (logitMin - logitBaseRate) / alpha + beta;
         in.setMinCompetitiveScore(Math.max(0f, innerMin));

Trade-offs

  • Smallest change; removes exactly the direction that over-prunes.
  • Keeps the existing float inversion, whose rounding can move the threshold by a few ulps; that is not part of this bug and is not claimed (in the probe the forwarded thresholds equal the BM25 scores, 0.5452175 and 1.03018).
  • Pruning gets weaker only where it was wrong, below probability 1e-7.

What the checks validated

  • Stock 645d4fe14a20: the repro fails through gradle with assertions on and off.
  • With the fix: the repro passes with assertions on and off; the closest suites pass with seeds DEADBEEF and 5EED1234 (67 tests each, 0 failures); :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs) passes with the repro test in place.
  • Five of the verifier test classes fail on stock (the sixth, the independent reproduction's control, passes by design); all six pass with the fix; the safety-property probe's bug check fires in 0 of 48 cases. The full Lucene test suite was not run.

Invert in double precision, no lower clamp, round down (the finder's fix) (validated by the checks)

Compute the logit as Math.log(minScore) - Math.log1p(-(double) minScore) and the inner threshold in double, take Math.nextDown twice of its float value and forward it only when it is positive.

      if (minScore > 0f && minScore < 1f) {
        double logitMin = Math.log(minScore) - Math.log1p(-(double) minScore);
        double innerMin = (logitMin - logitBaseRate) / alpha + beta;
        float threshold = Math.nextDown(Math.nextDown((float) innerMin));
        if (threshold > 0f) {
          in.setMinCompetitiveScore(threshold);
        }
      }

Trade-offs

  • Rounds the threshold down, in the spirit of WANDScorer.scaleMinScore; that mitigates some rounding of the inversion but is not a proven conservative inverse: LFV-450's minimal test still fails with it.
  • The two-ulp margin is a heuristic, not proven safe for every parameter combination; more code to review; slightly weaker pruning.

What the checks validated

  • On 645d4fe14a20: the repro passes, the closest suites pass (67 tests, seed DEADBEEF) and :lucene:core:check -x test passes.
  • Earlier, on the study's main ea57360912ed: every verifier repro, TestBayesianScoreQuery 26/26 and TestLogOddsFusionQuery 40/40.

Fold the fix into open PR #16410

#16410 ("Correct Bayesian score fusion semantics and pruning", by the class author, labelled Stale, last activity 2026-08-24) changes this method to minProbability = minScore / boost and keeps Math.max(1e-7f, Math.min(1f - 1e-7f, minProbability)) (read from its diff, not executed).

Trade-offs

Avoids two PRs editing the same lines; depends on that PR moving forward.

Downstream note

Downstream, an application that ranks with a standalone BayesianScoreQuery (or a fused one where only one signal is active) would silently show weaker top hits for broad, low-scoring queries; filters, exclusions and deletions still hold.

How it was found

A bounded-exhaustive check ran Lucene's real query classes over tiny exhaustive and random corpora under 17 similarities and compared every top-k search with an exhaustive search. BayesianScoreQuery was the only one of 26 text query classes whose top hits differed for a reason other than negative scores; the code then showed the clamp.

The method in full131 words

Method: semi-formal (the finder's label): bounded-exhaustive translation validation of the real main jar, with the oracle "TOP_SCORES top-k equals COMPLETE top-k" (the rule of CheckHits.checkTopScores), not a proof.

  • The harness ran the real query classes over an exhaustive tiny corpus and seeded random corpora under 17 similarities.
  • It flagged BayesianScoreQuery with alpha 0.5, beta 30 and base rate 1e-6 on the exhaustive corpus and on two 3,000-document corpora, for example under BM25 top-1 doc 4 at 4.06e-13 instead of doc 2354 at 4.25e-13.
  • On the 3,000-document corpora (23,676 queries over the 26 text query classes) it was the only class with differences not explained by IndriDirichletSimilarity's negative scores.
  • The finder then found the clamp by reading the code and wrote a deterministic public-API JUnit test on the stock jar.

Prior reports, history and checks

Prior reports

No prior report: 55 tool queries and manual searches over GitHub issues, PRs and commits, the mailing lists, downstream projects and the web (2026-10-26), rechecked on GitHub 2026-10-31. Open PR #16410 by the class author edits the same method but keeps the clamp.

Searches3 searches

Verdict new. #16410 is related work, not a report: its diff keeps the lower clamp and its new checkTopScores test uses scores far above 1e-7. Its description says "Single-active-signal leaves and MatchNoDocsQuery rewrites could also bypass or change the declared fusion model", the fused path where this bug also fires; it does not mention the clamp. #15827 and #15948 do not discuss the clamp. GitHub search reads one page per query; private discussions cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists, JIRA archive, web2026-10-26a scripted prior-report search, 55 queries (class names, "Bayesian BM25", the class with pruning, clamp, 1e-7, setMinCompetitiveScore, symptom phrases; dev@, java-user@, issues@, commits@), plus the finder's 12no report of the clamp or the over-pruning
GitHub, manual2026-10-268 searches (BayesianScoreQuery across Elasticsearch, OpenSearch, Solr and OpenSearch neural-search; setMinCompetitiveScore clamp; open PRs mentioning Bayesian or LogOdds); changed files of all 169 open PRs created since 2026-03-16; full reads of #15827, #15948, #16110, #164100 hits downstream; only #16410 and #16847 touch a Bayesian file, neither about the clamp
GitHub, recheck2026-10-31gh search issues and prs for BayesianScoreQuery; the file on main, branch_11x and branch_10_60 issues; PRs #15827, #15948, #16110 (merged) and #16410 (open); the clamp unchanged on all three branches

History

Added with the class in #15827 (5af15dc4be5b, 2026-03-27) and first released in 10.5.0; kept by #15948. 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main all fail the same way; upstream main, branch_11x and branch_10_6 carry the same line.

Versions tested7 rows

Introduced by 5af15dc4be5b (#15827 "Add BayesianScoreQuery and LogOddsFusionQuery for probabilistic hybrid search", by jaepil, merged by benwtrent on 2026-03-27; 10.x backport 7f55b56a40de). git log -S finds no other commit that added or changed the clamp. Kept by d9aa7cbc47d4 (#15948, 2026-05-27), which added the base-rate term to the inversion; the bug does not need the base rate.

The introducing-source check is not a build of the commit: the 10.4.0 release jars with the #15827-era class source compiled on top fail, and pass with only the lower clamp removed.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and the standalone runner, assertions on and off)
10.6.0 affectedthe minimal test fails (release jars, -ea and -da)
10.5.2, 10.5.1, 10.5.0 10.5.0: first affectedthe finder's test and the two-argument history test fail
10.4.0 + the #15827-era class source introducing sourcethe history test fails 2 of 3 (-ea and -da); passes with only the lower clamp removed
10.4.0, 9.12.3 API absentno BayesianScoreQuery
upstream main 2f424fd428da, branch_11x, branch_10_6 readthe same line 287 (2026-10-31)
fix on 645d4fe14a20 not affecteddropping the lower clamp: the repro and the closest suites pass

Independent checks

An independent reproducer and a devil's advocate (both Claude) and executing reviews by OpenAI GPT-5.6 (refute and intended-behaviour lenses) ran their own tests, and an adjudication re-ran them: all real, high confidence. Five of their six test classes still fail on main (the sixth is a control); all six pass with the one-line fix.

Checks and verdicts284 words

Every verification check ran code. The intended-behaviour case was rated weak by the cross-model lenses and rejected by the devil's advocate. Fact checks of this doc (one executing, one read-only) confirmed the bug and corrected its reach: fused queries are hit where a lone signal's scorer is used directly.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (bounded-exhaustive check) candidatePublic-API JUnit fails on main, 10.5.0 and 10.5.2 with -ea and -da; passes with fix A.
Independent reproducer realOwn test with the default codec fails 4 of 4, 40 of 40 over 10 seeds; control above 1e-7 passes; a one-line shadow without the lower clamp passes.
Cross-model reviews, refute and intended lenses (OpenAI GPT-5.6, executing) real, high (intended argument weak)Own tests fail 1 of 3 and 2 of 3 on main and 10.5.2, with and without a base rate; pass with fix A, as do the 26 Bayesian and 40 LogOddsFusion tests.
Devil's advocate real, highEstimator-derived parameters on a 100,000-document index: none of the ten true top hits returned.
Adjudication confirmed, highRe-ran the independent reproduction's test (-ea and -da), its control and the one-line shadow (-ea), and the finder's test.
This doc still reproducesGradle on 645d4fe14a20 and the 10.6.0 jars; both fixes validated; five of the verifier test classes fail on stock (the sixth is a control) and all six pass with the one-line fix.
Fact checks of this doc (Claude, executing; OpenAI GPT-5.6, read-only) real; reach correctedBoth confirmed the bug, the mechanism and the fix. Corrected: fused queries with a lone active signal are hit too (executed), pruning starts after max(1,000, n) hits, and some wording.

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

Finding record

LFV-164, snapshot of 2026-11-06

Registry title
BayesianScoreQuery raises the minimum competitive score to at least 1e-7 before passing it to its inner query, so default top-k search silently drops the best hits when every competitive calibrated score is below 1e-7
Area
search / top-k dynamic pruning (BayesianScoreQuery: BayesianScoreScorer#setMinCompetitiveScore)
Priority
Low, rank 949: BayesianScoreQuery rounds the minimum competitive score up to 1e-7, so top-k loses its best hits when every calibrated score is below that, an experimental 10.5 class needing boundary calibrations.
Impact class
silent wrong results
Impact note
Applications that rank with a BayesianScoreQuery at top level or as the only scoring clause of a conjunction (@lucene.experimental, new in 10.5.0), with a calibration (alpha, beta, baseRate set by hand or taken from BayesianScoreEstimator) that puts every competitive hit below probability 1e-7, silently get the wrong top k from the default IndexSearcher.search(query, n) once more than 1,000 documents match, with -ea and -da, the default codec, a term or a disjunction inner query, and single-threaded or sliced search; queries fused through LogOddsFusionQuery are not affected where its fusion scorer runs (it does not forward the threshold), but they are where LogOddsFusionQuery hands a lone active signal's scorer to the collector or rewrites to that single clause (executed on main in the 2026-10-31 fact check); no scale beyond about 1,000 matches is needed.
Summary
You run an ordinary top-k search such as searcher.search(query, 10) with a BayesianScoreQuery whose calibrated scores (probabilities) are all below 1e-7, and more than 1,000 documents match. You expect the same top hits as an exhaustive search, because the scores are a monotone transform of the inner BM25 scores and explain() reports exactly those scores. Instead, once the collector starts pruning, BayesianScoreQuery rounds the requested minimum score up to 1e-7 before turning it into a BM25 threshold for the inner query, so the inner query skips documents that are still competitive and the search returns weaker hits (in the repros, in effect the first matches in doc-id order) with no error.
Introduced
5af15dc4be5b (GITHUB#15827 'Add BayesianScoreQuery and LogOddsFusionQuery for probabilistic hybrid search', by jaepil; 10.x backport 7f55b56a40de, plus e8d1a2a26d6e 'Adjusting for backport', which changes only a throws clause): the lower clamp was added together with the class [read: git blame on main and git log -S over the full history find no other commit; exec: the 10.4.0 release jars with the #15827-era class source (at e8d1a2a26d6) compiled on top fail the two-argument history repro (2 of 3, -ea and -da) and pass with only the lower clamp removed; the commit itself was not built]; kept by d9aa7cbc47d4 (GITHUB#15948, 2026-05-27, which added the base-rate term to the inversion) [read] (2026-03-27)
First affected version
10.5.0 [exec] (the first release with the class; 10.4.0 and 9.12.3 do not have BayesianScoreQuery, so the repros do not compile there [exec])
Affected versions
10.5.0, 10.5.1, 10.5.2, 10.6.0 and main ea57360912ed [exec: the minimal repro fails on the 10.6.0 release jars with -ea and -da (2026-10-31); the finder's repro and the two-argument history repro fail on all four, -da also on main and 10.5.2; the independent reproduction's and the intended-behaviour check's repros also fail on 10.5.0 and 10.5.2 with -ea and -da]; setMinCompetitiveScore is byte-identical in all four [read]; branch_10x (unreleased) and branch_10_6 carry the same line [read, 2026-10-24 and 2026-10-26]; not fixed upstream: the line is unchanged on main, branch_10x, branch_10_6, branch_10_5 and the 10.5.2 tag, and open PR #16410 keeps it [read, not executed]
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: exhaustive check
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: 55 scripted prior-report search queries over GitHub issues, PRs and commits on apache/lucene, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, plus the finder's 12-query run; 8 manual GitHub searches, including BayesianScoreQuery across Elasticsearch, OpenSearch, Solr and OpenSearch neural-search (0 hits); the changed files of all 169 open apache/lucene PRs created on or after 2026-03-16; full reads of #15827, #15948, #16110 and #16410; git log -S over the full history): no issue, PR, review comment, list post or web page reports the clamp or the over-pruning; not fixed upstream: the line is unchanged on main, branch_10x, branch_10_6, branch_10_5 and the 10.5.2 tag; related, not a report: open PR #16410 'Correct Bayesian score fusion semantics and pruning' (jaepil, the class author; labelled Stale, last activity 2026-08-24, no reviews) rewrites this method to divide by the boost but keeps Math.max(1e-7f, ...) (read from its diff, not executed), and its new checkTopScores test uses scores far above 1e-7, so a fix touches the same lines; #15827 (the introducing PR, which says the BM25 ranking 'is preserved exactly') and #15948 (which kept the clamp) do not discuss it; gaps: GitHub search reads one page per query (generic setMinCompetitiveScore queries were truncated; their hits are old pruning history), the open-PR scan skipped PRs created before 2026-03-16, list search is keyword-based, and JIRA cannot hold a report (the class postdates the JIRA archive)
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree, assertions on and off; the standalone runner; a threshold probe; both fixes; a fused-query probe); the 10.6.0 release jars, assertions on and off; the study's runs on main ea57360912ed (the same BayesianScoreQuery.java) and on 10.5.0, 10.5.1, 10.5.2, and 10.4.0 with the introducing class source. Upstream main 2f424fd428da, branch_11x and branch_10_6 have the same clamp line (read 2026-10-31).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BayesianScoreQuery.java:41 (@lucene.experimental), :61-89 (constructors; logitBaseRate at :76/:78), :263-266 (score(), no clamp), :274-278 (getMaxScore, no clamp), :281-291 (setMinCompetitiveScore: 'float clamped = Math.max(1e-7f, Math.min(1f - 1e-7f, minScore));' at :287, forwarded at :290)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-51 (setMinCompetitiveScore: 'may safely ignore all documents whose score is less than the given minScore')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BayesianScoreEstimator.java:55 (BASE_RATE_MIN = 1e-6f), :170 (base rate clamped to [BASE_RATE_MIN, BASE_RATE_MAX]); LogOddsFusionScorer.java:48-49 (CLAMP_MIN/CLAMP_MAX 1e-7), :214-218 (setMinCompetitiveScore feeds only the block propagator, not the sub-scorers)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/BayesianScoreQuery.java:24-41, (at 645d4fe14a20) 75-79, 113-120, 199-201, 238-241, 263-266, 274-278, 281-292
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, (at 645d4fe14a20) 598-599, 623-625; TopScoreDocCollectorManager.java:69; TopScoreDocCollector.java:59, 99-100, 116-120, 137-162; TermScorer.java:61-74, 127-131
  • lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-51 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/LogOddsFusionScorer.java:213-218; (at 645d4fe14a20) LogOddsFusionQuery.java:230-233, 359-360, 393-394
  • GitHub apache/lucene #16410 description (read with gh pr view, 2026-10-31)
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:69; (at 645d4fe14a20) TopScoreDocCollector.java:99-100, 137-162
  • lucene/core/src/java/org/apache/lucene/search/BayesianScoreEstimator.java:41, (at 645d4fe14a20) 55-56, 147, 162, 170
  • lucene/core/src/java/org/apache/lucene/search/BayesianScoreQuery.java:27-30, (at 645d4fe14a20) 41
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-51 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/BayesianScoreQuery.java:41, (at 645d4fe14a20) 199-201, 263-278, 282-292
  • lucene/core/src/test/org/apache/lucene/search/TestBayesianScoreQuery.java:227-231, (at 645d4fe14a20) 330-334, 435-442
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:690-705 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:107-120; (at 645d4fe14a20) LogOddsFusionScorer.java:48-49
  • GitHub apache/lucene #15827 and #15948 descriptions (read with gh pr view, 2026-10-31)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:107-120 (at 645d4fe14a20)
Prior reports
  • GitHub apache/lucene (read-only gh search and contents API, 2026-10-31)
History
  • a Lucene clone releases/lucene/10.6.0 lucene/core/src/java/org/apache/lucene/search/BayesianScoreQuery.java:287
  • GitHub apache/lucene contents API (main 2f424fd428da, branch_11x d0c0decc6606, branch_10_6 91eaab4fb298), 2026-10-31

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

Repro files

About the diagrams

Made from the code at 645d4fe14a20 and an executed diagram probe that replays the repro on main (assertions on and off), on the 10.5.2 and 10.6.0 releases and with the one-line fix shadowed in. Re-rendered after the 2026-10-31 checks (the fused-query statement, the fix shown); the alt text and caption match the final render.

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.