All findings · Lucene finding · LFV-450 · bayesian-min-competitive-inversion-bucket-center

BayesianScoreQuery top-k pruning can skip a hit that is still competitive

Once more than 1,000 documents match, search(new BayesianScoreQuery(inner, 1f, 0f), 1) can return the hit with probability 0.9933071 and skip the one with 0.9933072, because the inner-score threshold the query derives from the minimum competitive probability is too high. In 10.5.0 to 10.6.0 and main; rare on natural data.

Summary

Priority
Low, rank 950A second top-k pruning defect in the experimental 10.5 BayesianScoreQuery silently drops a hit that is one float step more competitive, only with probabilities very close to 1.
Impact
silent wrong results
Since
10.5.0 (2026, #15827)introduced 2026-03-27
Affected
10.5.0 to 10.6.0 and main
Status
documented
Found by
bounded-exhaustive check on the real jar

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

The query forwards the inverse of the rounded probability, which sits in the middle of the inner scores that share it, instead of the lowest of them.

Background: top-k pruning and BayesianScoreQuery

  1. Top-k pruning: once a top-k search has collected more than 1,000 hits (the default), the collector tells the scorer the minimum competitive score, the next float above the current k-th score. The scorer may then skip every document that scores less.
  2. BayesianScoreQuery wraps an inner query (typically BM25) and returns sigmoid(alpha * (inner - beta) + logit(baseRate)) as a float. To prune, it must translate the probability threshold into an inner-score threshold for the inner query.
Diagram of the bug. A number line of float inner scores around 5.0: all inner scores from 4.9999995 to 5.000008 round to the same probability 0.9933072. Doc 1000 has inner score 4.999999 (probability 0.9933071) and doc 1001 has 5.0 (probability 0.9933072). After 1,000 hits the collector asks for probability 0.9933072 or more; BayesianScoreQuery converts it back with logit to 5.000004, the middle of that range, and the inner query skips everything below it, including doc 1001, so search(q, 1) returns doc 1000. The fix forwards 4.9999995, the lowest inner score with that probability, and doc 1001 is returned.
How the threshold is forwarded on main: probability 0.9933072 covers inner scores 4.9999995 to 5.000008; the closed-form inverse forwards 5.000004, so doc 1001 at 5.0 is skipped and doc 1000 is returned; the fix forwards 4.9999995. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Rank with BayesianScoreQuery(inner, alpha=1, beta=0), which turns each inner score into the float probability sigmoid(inner). 1,000 documents have inner score 1.0, doc 1000 has 4.999999 (probability 0.9933071) and doc 1001 has 5.0 (probability 0.9933072).
  2. You expect search(q, 1) to return doc 1001, as exhaustive collection does: pruning may skip only documents that score less than the current threshold.
  3. After 1,000 hits the collector asks for probability >= 0.9933072, one float step above the best hit so far. Near 1, many inner scores round to the same float: every inner score from 4.9999995 to 5.000008 has probability 0.9933072.
  4. The query converts 0.9933072 back with logit() and tells the inner query to skip inner scores below 5.000004, the middle of that range. Doc 1001 (inner 5.0) is skipped; you get doc 1000, with no error, with assertions on and off.

Who is affected

Applications that rank with a standalone BayesianScoreQuery (top level, or the only scoring clause next to filters) through the default search(query, n), once more than 1,000 documents match. Fusion through LogOddsFusionQuery is not affected where its fusion scorer runs; a lone active signal takes the standalone path (by reading). It needs a hit one float step above the k-th, so probabilities very close to 1: rare on natural BM25 data.

Mechanism, terms and numbers584 words

BayesianScoreQuery (new in 10.5.0, @lucene.experimental) maps an inner query's score to a calibrated probability, sigmoid(alpha * (score - beta) + logit(baseRate)), returned as a float. Near 1 a whole range of inner scores rounds to the same float probability: for alpha=1, beta=0, every inner score from 4.9999995 to 5.000008 has probability 0.9933072.

Top-k search tells a scorer it may skip every document scoring less than the minimum competitive score, which TopScoreDocCollector sets to the next float above the current k-th score once more than 1,000 hits are collected. BayesianScoreScorer#setMinCompetitiveScore turns that probability into an inner-score threshold with the closed-form inverse (logit(minScore) - logitBaseRate) / alpha + beta. That is the inverse of the rounded probability, so it lands inside the range (5.000004 for 0.9933072), and the inner query skips inner scores that still have exactly the minimum competitive probability. Those documents are one float step better than the k-th hit, so the default IndexSearcher.search(query, n) silently returns the weaker hit. Even the exact real logit (5.0000036) is above the range's start, so a more precise inverse alone does not help; forwarding the smallest inner score whose probability, computed as score() computes it, reaches minScore (a binary search over float bits, 33 sigmoid evaluations per call that forwards a threshold) fixes it. It has been there since the class was added in #15827. On natural BM25 data it showed only under steep calibrations, with probabilities within about 1e-5 of 1.

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

  1. Float forward function. score() is sigmoid(alpha * (innerScore - beta) + logitBaseRate) (:262-266), with sigmoid computed in double and cast to float (:113-120). Each float probability is the rounded image of a range of float inner scores, and the range widens towards 1: for alpha=1, beta=0, 6 floats at p=0.953, 19 at p=0.9933, 507 at p=0.99988.
  2. The collector asks for the next float. Past 1,000 hits (IndexSearcher.java:100), TopScoreDocCollector calls scorer.setMinCompetitiveScore(Math.nextUp(topScore)): "since we tie-break on doc id and collect in doc id order, we can require the next float" (TopScoreDocCollector.java:156-162).
  3. Closed-form inverse. For 0 < minScore < 1: clamped = Math.max(1e-7f, Math.min(1f - 1e-7f, minScore)), logitMin = (float) Math.log(clamped / (1f - clamped)), innerMin = (logitMin - logitBaseRate) / alpha + beta, in.setMinCompetitiveScore(Math.max(0f, innerMin)) (:286-290).
  4. It lands inside the range. It inverts the rounded probability, not the range's lower end, so the inner scorer skips inner scores below it whose probability is exactly minScore (the independent reproduction: 972 of 1,956 such inner scores over 16 buckets).
  5. The skipped hit is strictly better than the k-th, because minScore is the next float above the k-th probability. Nothing throws; totalHits becomes a lower bound.

Worked through (alpha=1, beta=0, executed)

returned: inner (p)skipped: inner (p)forwarded thresholdexact real logitlowest inner with p >= minScore
4.999999 (0.9933071)5.0 (0.9933072)5.0000045.0000036374.9999995
8.999863 (0.99987656)9.0 (0.9998766)9.0001059.0001050588.999864
11.9959545 (0.9999938)12.0 (0.99999386)12.00079712.00079720611.995955
15.382769 (0.99999976)15.5 (0.9999998)15.5369215.53691986615.38277

Even the exact real logit is above the skipped hit's inner score, so a more precise inverse cannot fix it. In the last row the skipped hit's inner score is 0.12 higher, although the probabilities differ by one float step.

Sibling: LFV-164

The same four lines hold LFV-164: the lower clamp at :287 raises the threshold when every competitive probability is below 1e-7. Its minimal fix (removing the clamp) leaves this bug; the fix recommended here fixes both.

Exposure and likelihood387 words
  • Who is hit. A BayesianScoreQuery at top level, or as the only scoring clause next to FILTER or MUST_NOT clauses (with MUST_NOT ReqExclScorer passes the threshold on, ReqExclScorer.java:89-91; with FILTER ConjunctionScorer forwards it to its single scoring clause, ConjunctionScorer.java:88-93; both executed), with the default IndexSearcher.search(query, n) or any TOP_SCORES collector. These are examples, not a complete list: by reading (not run), the threshold also reaches it as the optional clause next to a FILTER clause (ReqOptSumScorer.java:282-295) and inside a DisjunctionMaxQuery with tie-breaker 0 (DisjunctionMaxScorer.java:112-121); a conjunction with several scoring clauses does not forward it (BlockMaxConjunctionScorer.java:234-236). The error is in the query's own arithmetic, which does not depend on the codec; the runs covered only the codecs their test seeds picked.
  • Fusion: a query fused through LogOddsFusionQuery, the use the class javadoc names (BayesianScoreQuery.java:28-30), is not affected where its fusion scorer runs: LogOddsFusionScorer.setMinCompetitiveScore only moves a block-boundary index and never calls the sub-scorers (LogOddsFusionScorer.java:214-218, DisjunctionScoreBlockBoundaryPropagator.java:104-109). A lone active signal is not shielded (by reading, not run for this bug): LogOddsFusionQuery hands its scorer straight to the collector (LogOddsFusionQuery.java:230-233) and rewrites to the clause itself when only one clause is left (:359-360, :393-394); LFV-164's executed fused-query probe fails through that path.
  • Inner query. It must honour setMinCompetitiveScore: shown for constant-score clauses and WAND disjunctions, and on natural BM25 term and 2-3-term disjunction queries. Block-level pruning of a single TermQuery was not shown to trigger.
  • What goes wrong. The k-th hit (the top hit for k=1) is replaced by one whose probability is one float step lower; the number of hits stays the same and nothing throws. Where the sigmoid saturates, the dropped hit's inner score can be clearly higher (15.5 against 15.382769).
  • Scale. 1,002 documents in one segment are enough; 1,000 is the default totalHitsThreshold.
  • Likelihood: rare. On synthetic natural-text BM25 indexes: 2 of 5,600 searches differed, both at alpha=3; 0 of 54,000 with BayesianScoreEstimator's own alpha or twice it, 45 of 18,000 at 4x that alpha (k-th probabilities within about 1e-5 of 1); 0 with the fix. One synthetic corpus each, so steeper hand-set calibrations or denser high scores may hit it more often; these are not real-world frequencies. Experimental class, new in 10.5.0; no prior report was found.

Minimal repro

One JUnit test in lucene/core, public API only: 1,002 documents in one segment, inner scores placed exactly with constant-score clauses, then search(new BayesianScoreQuery(inner, 1f, 0f), 1). Deterministic; fails with assertions on and off.

TestBayesianMinCompetitiveTies.java (1 test)58 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.Field;
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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestBayesianMinCompetitiveTies extends LuceneTestCase {

  private static Query constant(String term, float score) {
    return new BoostQuery(new ConstantScoreQuery(new TermQuery(new Term("f", term))), score);
  }

  public void testTopHit() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (int i = 0; i < 1002; i++) {
          Document doc = new Document();
          doc.add(new StringField("f", i < 1000 ? "filler" : i == 1000 ? "a" : "b", Field.Store.NO));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query inner =
            new BooleanQuery.Builder()
                .add(constant("filler", 1f), BooleanClause.Occur.SHOULD)
                .add(constant("a", 4.999999f), BooleanClause.Occur.SHOULD)
                .add(constant("b", 5.0f), BooleanClause.Occur.SHOULD)
                .build();
        TopDocs top = new IndexSearcher(reader).search(new BayesianScoreQuery(inner, 1f, 0f), 1);
        // expected: doc 1001 "b" (sigmoid(5.0) = 0.9933072), not doc 1000 "a" (0.9933071)
        assertEquals("score=" + top.scoreDocs[0].score, 1001, top.scoreDocs[0].doc);
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or any commit with the same BayesianScoreQuery.java)
cp $REPRO/TestBayesianMinCompetitiveTies.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestBayesianMinCompetitiveTies -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
Doc 1001 (inner 5.0, probability 0.9933072), the hit exhaustive collection returns.
Observed
Doc 1000 (inner 4.999999, probability 0.9933071): score=0.9933071 expected:<1001> but was:<1000>, on main, 10.6.0, 10.5.2 and 10.5.0, assertions on and off.

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

More files, environment, full output215 words

The test sits in the org.apache.lucene.search test package, as Lucene's own tests do, but uses no package-private member. The three SHOULD clauses BoostQuery(ConstantScoreQuery(TermQuery), x) give the fillers inner score 1.0, doc 1000 inner score 4.999999 and doc 1001 inner score 5.0. Four seeds give the same result.

As the regression test it needs one formatting edit: the doc.add(...) line is 101 characters, over google-java-format's 100, and ./gradlew tidy wraps it:

          doc.add(
              new StringField("f", i < 1000 ? "filler" : i == 1000 ? "a" : "b", Field.Store.NO));

With that edit and the fix in place, :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs, license checks) passes; the formatted test passes with the fix and fails on stock.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0, 10.5.2 and 10.5.0 release jars
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

TestBayesianMinCompetitiveTies > testTopHit FAILED
    java.lang.AssertionError: score=0.9933071 expected:<1001> but was:<1000>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:13AC4067EA6145B7]: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.TestBayesianMinCompetitiveTies.testTopHit(TestBayesianMinCompetitiveTies.java:54)
:lucene:core:test (FAILURE): 1 test, 1  failure

# the same assertion with -Ptests.asserts=false, and on the 10.6.0, 10.5.2 and 10.5.0 release jars

# inside the search (probe on the same data shape, identical code):
stock: inner scores with probability 0.9933072: [4.9999995, 5.000008]
stock: requested minScores=[0.9933072] | forwarded inner thresholds=[5.000004]
fix:   requested minScores=[0.9933072, 0.99330723] | forwarded inner thresholds=[4.9999995, 5.0000086]

Why it looks unintended

  • Scorable#setMinCompetitiveScore allows skipping only documents "whose score is less than the given minScore"; the skipped hit's score equals it.
  • The class's own tests call CheckHits.checkTopScores, which requires pruned and exhaustive top hits to match; an independent test fails it 13 of 16 times.
  • Lucene binary-searches other score inverses "due to all potential rounding errors" (LUCENE-8340).
The full argument238 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:46-47). The collector relies on it when it asks for the next float above the k-th score (TopScoreDocCollector.java:156).
  • The class's tests. TestBayesianScoreQuery calls CheckHits.checkTopScores three times (:230, 333, 441). The oracle compares TOP_SCORES (pruning as soon as the queue is full) with COMPLETE hit by hit and, after setMinCompetitiveScore with a document's own score, asserts every skipped document scores < minScore (CheckHits.java:690-709, 748, 768-771). The three calls use a single TermQuery on a 53-document index with alpha=0.5 or the estimator's parameters, which do not aim at probabilities near 1, where the rounding ranges are wide.
  • The author's intent. The #15827 description: "Since sigmoid is monotone, the original BM25 ranking is preserved exactly"; its test list names "WAND max score correctness". The code comment derives the threshold from an exact equation (:282-285). No javadoc, CHANGES, MIGRATE entry, test or review mentions approximate pruning.
  • Lucene's practice. WANDScorer rounds max scores up and min scores down "to make sure that we do not miss any matches" (WANDScorer.java:85-88, 107-109); LongDistanceFeatureQuery and LatLonPointDistanceFeatureQuery binary-search their threshold (:270-271, :284-285; LUCENE-8340, and LUCENE-8482 where a reviewer agreed "that binary searching is easier than inverting the distance computation"). #13371, a top-k mismatch from float summation, was fixed by #13397.
The devil's-advocate check132 words

The strongest case (built by the intended-behaviour check and the adjudication): the class is @lucene.experimental, which in #15827 a reviewer said "allows the API or behavior to change if we deem it necessary after more testing within Lucene"; the O(1) closed-form inverse is deliberate, its clamps show the author thought about numerical extremes, and the miss is the smallest possible: one float step of probability.

Why it fails: experimental lets the API change; it does not license TOP_SCORES results that differ from COMPLETE, which the class's own tests forbid. Lucene treats this kind of rounding miss as a bug elsewhere and solved it with a binary search, and near saturation one probability step can hide a clear gap in inner score. It argues for low priority, not for intended behaviour.

Suggested fix

Recommended Forward the lowest inner score that reaches minScore, found by binary search

Forward the smallest non-negative inner score whose probability, computed exactly as score() computes it, reaches minScore, found by binary search over float bits (33 sigmoid evaluations per forwarded threshold). Validated on main: the repro passes with assertions on and off, the 66 upstream tests using the class and check pass, and it also fixes the sibling bug LFV-164.

All options and trade-offs3 options · 816 words

The recommended fix was run in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3), removed afterwards. "Closest suites" means TestBayesianScoreQuery and TestLogOddsFusionQuery, the only tests that use the class, plus the repro. If open PR #16410 (same method, divides minScore by the boost) lands first, probability() must compare exactly what score() then returns.

Forward the lowest inner score that reaches minScore, found by binary search (validated by the checks) (recommended)

Replace the closed-form inverse with a binary search over the bit patterns of the non-negative floats for the smallest inner score whose probability, computed exactly as score() computes it, is >= minScore. The finder's fix logic, rebased on 645d4fe14a20 with a rewritten comment (+29/-8 lines). It also removes the 1e-7 clamp, so it fixes LFV-164 too.

    // the probability of an inner score, computed exactly as score() computes it
    private float probability(float innerScore) {
      return sigmoid(alpha * (innerScore - beta) + logitBaseRate);
    }

    @Override
    public void setMinCompetitiveScore(float minScore) throws IOException {
      // Many inner scores round to the same float probability, so the closed-form inverse of
      // minScore can land inside that range and skip inner scores whose probability equals
      // minScore. Forward instead the smallest non-negative inner score whose probability is
      // >= minScore: probability() is non-decreasing in the inner score, so binary search over
      // the bit patterns of the non-negative floats finds it.
      if (minScore > 0f && minScore < 1f) {
        if (probability(0f) >= minScore) {
          return; // every inner score is competitive
        }
        if (probability(Float.MAX_VALUE) < minScore) {
          in.setMinCompetitiveScore(Float.MAX_VALUE);
          return;
        }
        // invariant: probability(lo) < minScore <= probability(hi), as float bit patterns
        int lo = 0;
        int hi = Float.floatToIntBits(Float.MAX_VALUE);
        while (hi - lo > 1) {
          int mid = (lo + hi) >>> 1;
          if (probability(Float.intBitsToFloat(mid)) >= minScore) {
            hi = mid;
          } else {
            lo = mid;
          }
        }
        in.setMinCompetitiveScore(Float.intBitsToFloat(hi));
      }
    }

Trade-offs

  • Cost: 33 sigmoid evaluations (Math.exp) per setMinCompetitiveScore call that forwards a threshold, instead of one Math.log: two bound checks plus 31 bisection steps (Float.floatToIntBits(Float.MAX_VALUE) is 2,139,095,039). Calls happen when the minimum competitive score increases (the collector's own k-th score, or with several slices the global minimum, TopScoreDocCollector.java:99-100, 137-164), not per document; no benchmark was run. The distance-feature queries sample their calls for this kind of cost (LUCENE-8482).
  • Soundness rests on monotonicity and on non-negative inner scores (Lucene's scoring convention, which the stock Math.max(0f, innerMin) also relies on; Scorable does not state it as a rule): alpha * (x - beta) + logitBaseRate is non-decreasing in x for alpha > 0, and sigmoid was checked non-decreasing over all 4,278,190,079 adjacent finite float pairs on the real jar. The threshold is then the tightest one that never skips a document with probability >= minScore, and it grows with minScore, as successive calls require.
  • The probability(Float.MAX_VALUE) < minScore branch is defensive: it is reachable only with an extremely small legal alpha (for example alpha = 1e-38f, where alpha * Float.MAX_VALUE is about 3.4), and no run exercised it.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20: stock fails the repro with assertions on and off; with the fix it passes with assertions on and off.
  • Closest suites with the fix, seeds DEADBEEF and 5EEDF00D: 67 tests pass (TestBayesianScoreQuery 26, TestLogOddsFusionQuery 40, the repro 1); stock passes the same 66 upstream tests. Not run: the full core suite, other JDKs and x86.
  • :lucene:core:check -x test passes with the fix and the formatted regression test in place.
  • With the fixed class shadowed into the main jars: the finder's two tests pass; the independent reproducer's three tests pass (0 of 1,956 tied inner scores skipped, 0 of 16 top hits differ, 0 of 16 checkTopScores failures; stock 972, 13 and 13); LFV-164's minimal test passes.
  • The finder's bounded-exhaustive check with the same logic: 0 counterexamples in 293,213,101 checks (every float inner score in [2^-3, 2^5) whose probability is strictly between 0 and 1, for 5 parameter sets; 104,478,882 on stock).

Bracket the search around the closed-form estimate (not run)

Start from today's innerMin, gallop down (or up) with probability() checks until the lower end of the range is bracketed, then binary-search inside the bracket. Usually fewer sigmoid evaluations than the recommended fix, with the same correctness argument; more code. Not run.

Closed-form inverse with a safety margin (not recommended)

Invert the lower edge of the probability's rounding interval and step down a few nextDowns.

Trade-offs

Every float operation in score() rounds, so the margin needed is hard to prove (the lesson of LUCENE-8340). A sketch of this kind (double-precision inversion plus two nextDown steps) still fails this repro and leaves 50,655,235 of 293,213,101 bounded-exhaustive counterexamples. Stepping down one float at a time until the next lower float drops below minScore passes every repro, but near 1 the range spans hundreds to millions of floats, so that loop is unbounded in practice.

Downstream note

Downstream, a product that ranks with a standalone BayesianScoreQuery can show the second-best hit in place of the best one at position k; filters and deletions still apply.

How it was found

A harness checked Lucene's scorers against pruning rules; the rule "after setMinCompetitiveScore(m), every document scoring at least m must come back" flagged this class. A bounded-exhaustive check on the real jar then tried every float inner score in [2^-3, 2^5) with a probability strictly between 0 and 1 (5 calibrations): 104,478,882 of 293,213,101 tying minScore were skipped.

The method in full168 words

Method: semi-formal (the finder's label): instrumented bounded translation validation, then a bounded-exhaustive check on the real jar; not a proof.

  • Harness BoundTV ran seven protocols over many scorers and parameter sets; two of them flagged this class: P_C (after setMinCompetitiveScore(m) every document with score >= m is still returned) and P_D (TOP_SCORES top-k equals COMPLETE top-k). For this class it reported 64,583 P_C skips and 71 top-k differences, not split by mechanism.
  • BayesInversionExhaustive then ran the real BayesianScoreScorer on every float inner score in [2^-3, 2^5) whose probability is strictly between 0 and 1 (those that round to 0 or 1 are skipped), for 5 parameter sets, each fed through a one-document probe, checking that an inner score whose probability equals minScore is not skipped: 104,478,882 counterexamples of 293,213,101 on main, 50,655,235 with LFV-164's fix sketch, 0 with this bug's fix.
  • The finder read the root cause in the code and wrote a deterministic public-API JUnit; an independent reproducer wrote its own test from the claim alone.

Prior reports, history and checks

Prior reports

No prior report found: 97 queries over GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream projects and the web, plus the changed files of all 408 open PRs (2026-10-26); GitHub re-checked 2026-10-31. Open PR #16410 edits the same method but keeps the inverse.

Searches4 searches

Verdict new: no prior report was found in the searches below. The closest item is #16410 ("Correct Bayesian score fusion semantics and pruning", by the class author; open, labelled Stale, last update 2026-08-24, no reviews): it divides minScore by the boost but keeps the clamp, the float logit and the closed-form inverse, so the bug survives it (read from its diff, not run), and its new checkTopScores test does not reach probabilities near 1 with more than 1,000 hits. Precedent, not a report: LUCENE-8340 / #9387 and LUCENE-8482 / #9528 (binary-searched threshold because of rounding). GitHub search reads one page per query, list search is keyword-based, and private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits), downstream repositories, mailing lists, JIRA archive, web2026-10-26a scripted prior-report search, 65 queries (30 GitHub, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 6 web), plus a mailing-list re-run134 distinct hits, none about this mechanism or any precision problem in this method
GitHub, manual2026-10-2612 searches, including author:jaepil and BayesianScoreQuery across Elasticsearch, OpenSearch and Solr; full reads of #15827, #15948, #16110 and #16410 and the #16410 diffno report; #16410 keeps the inverse
Open PRs2026-10-26changed files of all 408 open apache/lucene PRsonly #16410 touches BayesianScoreQuery.java
GitHub, refresh2026-10-31BayesianScoreQuery PRs and issues; "Bayesian" and setMinCompetitiveScore items created since 2026-10-01; #16410 statestill only #15827, #15948, #16110 and #16410 (open, Stale); nothing new

History

Since the class was added in #15827 (5af15dc4be5b, merged 2026-03-27); first released in 10.5.0. The minimal test fails on 10.5.0, 10.5.2, 10.6.0 and main; the finder's version also on 10.5.1. Not fixed upstream.

Versions tested8 rows

Introduced by 5af15dc4be5b (GITHUB#15827 "Add BayesianScoreQuery and LogOddsFusionQuery for probabilistic hybrid search", by jaepil); 10.x backport 7f55b56a40de plus e8d1a2a26d6e (a throws clause only). git log -S on the inversion finds only that commit on main (with --all, also its backport 7f55b56a40de). Kept by d9aa7cbc47d4 (GITHUB#15948, 2026-05-27), which added the base-rate term on both sides; later commits (#16110, #16519) do not touch the method.

Introducing-source check (10.4.0 jars with the #15827-era class compiled on top; the commit itself was not built): fails with assertions on and off, still fails with only the 1e-7 clamp removed, passes with the exact inversion.

Not fixed upstream: the method is byte-identical on upstream main 2f424fd428da, branch_10_6 and the 10.6.0 tag (read, 2026-10-31).

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, assertions on and off)
10.5.2 affectedthe minimal test fails (release jars, assertions on and off; fact check)
10.5.1 affectedthe finder's two-test version fails
10.5.0 first affectedthe minimal test fails with assertions off and, in the fact check, on; the finder's version fails on and off
10.4.0 + #15827-era class introducing sourcefails on and off; still fails with only the clamp removed; passes with the exact inversion
10.4.0, 9.12.3 API absentno BayesianScoreQuery: the test does not compile
fix on 645d4fe14a20 not affectedthe repro passes with assertions on and off; 67 closest-suite tests and check pass

Independent checks

An independent reproducer, a devil's advocate and an adjudication rated it real; cross-model reviews (OpenAI GPT-5.6, reading only) agreed on the root cause, two rating it real and one "not real" on procedure only. An audit re-ran it with a fresh seed; it still reproduces on main and 10.6.0.

Checks and verdicts361 words

There is no executing cross-model review: the reviews by the other model family read the code only; separate runs and verifiers re-ran the repro.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (translation validation, then a bounded-exhaustive check) candidate293,213,101 checks: 104,478,882 counterexamples on main, 50,655,235 with LFV-164's sketch, 0 with this fix; the JUnit fails on main, 10.5.0 and 10.5.2, assertions on and off.
Earlier cross-model reviews (OpenAI GPT-5.6, intended and refute lenses) intended: real (medium); refute: not real (low, procedural)Reading only; agreed with the root cause and named no flaw; a re-run of the repro failed.
Independent reproducer realOwn test from the claim only: 972 of 1,956 tied inner scores skipped, 13 of 16 top hits differ, checkTopScores fails 13 of 16, on main, 10.5.0 and 10.5.2; natural BM25: 2 of 5,600 at alpha=3.
Root-cause review (OpenAI GPT-5.6, reading only, with a verifier that ran Java) real (medium)The exact real logit 5.0000036 lies above 5.0 and above the range start 4.9999995; the fix passes the repro and the upstream suite (26 of 26).
Devil's advocate real; intended argument weak; low per-search likelihoodNatural BM25: 0 of 54,000 searches at the estimator's alpha or twice it, 45 of 18,000 at 4x.
Adjudication confirmed (high)Re-ran the independent reproduction's test (3 of 3 fail with assertions on and off) and with the fix (3 of 3 pass).
Credibility audit keepFresh seed C0FFEE01: fails 2 of 2 with assertions on and off, passes with the fix; kept separate from LFV-164.
Minimal repro on current main verifiedOne test; fails on 645d4fe14a20 with assertions on (four seeds) and off (DEADBEEF).
This doc still reproduces; fix validatedGradle on a scratch worktree of 645d4fe14a20; the 10.6.0 jars; every earlier repro with the fix shadowed.
Fact checks of this doc (Claude, executing; OpenAI GPT-5.6, reading only) real; minor corrections appliedA new seed (FAC70450) fails on main, 10.6.0, 10.5.2 and 10.5.0 with assertions on and off and passes with the fix. Corrected: the exhaustive check's scope (probabilities below 1) and the fix's cost (33 sigmoid evaluations).

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

Finding record

LFV-450, snapshot of 2026-11-06

Registry title
BayesianScoreQuery turns the minimum competitive probability into an inner-score threshold that lies inside the range of inner scores with exactly that probability, so default top-k search can skip a hit that is still competitive
Area
search / top-k dynamic pruning (BayesianScoreQuery: BayesianScoreScorer#setMinCompetitiveScore)
Priority
Low, rank 950: A second top-k pruning defect in the experimental 10.5 BayesianScoreQuery silently drops a hit that is one float step more competitive, only with probabilities very close to 1.
Impact class
silent wrong results
Impact note
Applications that rank with a BayesianScoreQuery (@lucene.experimental, new in 10.5.0) at top level or as the only scoring clause next to FILTER or MUST_NOT clauses, over an inner query that prunes (shown: constant-score clauses, WAND disjunctions, plain BM25 term disjunctions), can get the wrong k-th hit from the default IndexSearcher.search(query, n) once more than 1,000 documents match, with -ea and -da; the dropped hit's probability is exactly one float step above the returned hit's, although its inner score can be much higher where the sigmoid saturates; on natural BM25 data it showed only under steep calibrations with probabilities within about 1e-5 of 1.0; queries fused through LogOddsFusionQuery are not affected where its fusion scorer runs (it does not forward the threshold), but by reading a lone active signal takes the standalone path (LogOddsFusionQuery.java:230-233, :359-360, :393-394; executed for the sibling LFV-164, not run for this bug); 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, and more than 1,000 documents match. You expect the same top hits as an exhaustive search, because the probability is a monotone function of the inner score and pruning may skip only documents that score below the current k-th hit. Instead, when a later document's probability is exactly one float step above the k-th hit's (most often when probabilities are close to 1), BayesianScoreQuery turns that probability back into an inner-score threshold with a closed-form inverse that lands in the middle of the range of inner scores that all round to that probability, so the inner query skips the document and the search silently returns the weaker hit.
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 closed-form inversion came with the class [read: git blame on main; git log -S 'Math.log(clamped / (1f - clamped))', git log -G innerMin and git log -L over the full history find no other change; exec: the 10.4.0 release jars with the #15827-era class source (at e8d1a2a26d6) compiled on top fail the repro 2 of 2 with -ea and -da, still fail with only the 1e-7 lower clamp removed (LFV-164's minimal fix), and pass with this bug's exact inversion; the commit itself was not built]; kept by d9aa7cbc47d4 (GITHUB#15948, 2026-05-27), which added the base-rate term to score() and 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 repro does 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, also in the fact check); the finder's repro fails on all four, with -ea and -da on 10.5.0, 10.5.2 and main; the independent reproduction's repro fails on 10.5.0, 10.5.2 and main with -ea and -da]; setMinCompetitiveScore is byte-identical (md5) in all four and on branch_10_5, branch_10_6 and branch_10x [read, branch heads of 2026-10-24]; not fixed upstream: the method is unchanged on upstream main, branch_10x and branch_10_6 [read 2026-10-26], and open PR #16410 keeps the closed-form inverse [read, not executed]; the released 10.6.0 (2026-10-29) carries the same method [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 could only read the code (its low-confidence “not real” is procedural), 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: 97 queries in total, including 65 scripted prior-report search queries with 134 distinct hits 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; a fresh 12-query mailing-list re-run; 12 manual GitHub searches, including author:jaepil and BayesianScoreQuery across downstream repositories; the changed files of all 408 open apache/lucene PRs; full reads of #15827, #15948, #16110 and #16410 and the #16410 diff): no issue, PR, review comment, commit, list post or web page reports or discusses this mechanism or any precision problem in this method; not fixed upstream: setMinCompetitiveScore is unchanged on upstream main, branch_10x and branch_10_6, and the file's commit lists on main, branch_10x, branch_10_6 and branch_10_5 end with #16519 and #16110, which do not touch it; 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 the clamp, the float logit and the closed-form linear inverse (diff read, not executed), so the bug would survive it, and its new checkTopScores test does not reach probabilities near 1 with more than 1,000 hits; a fix edits the same lines; precedent: LUCENE-8340 and LUCENE-8482 (#9387, #9528), where Lucene chose a binary-searched threshold because inverting a score formula is unsafe under rounding; gaps: GitHub search reads one page per query (the generic setMinCompetitiveScore query was cut at 34 results, all older pruning history), list search is keyword-based, and JIRA cannot hold a report about a class added in 2026
Lucene commit tested
main 645d4fe14a20: gradle in a scratch worktree (the minimal test, assertions on and off; the fix with the class's test suites and :lucene:core:check -x test) and the standalone runner (the fix and every earlier repro with the fixed class shadowed into the jars); the 10.6.0 release jars (assertions on and off), 10.5.0 (off) and 10.4.0 (does not compile: no class); the fact check ran the same test on 10.5.2 (on and off) and 10.5.0 (on). Only OpenJDK 25.0.3 on Linux aarch64; other JDKs, x86 and the full core suite were not run. The study's runs on main ea57360912ed, whose BayesianScoreQuery, collector, scorer and searcher files are identical, and on 10.5.0, 10.5.1 and 10.5.2. Upstream main 2f424fd428da (2026-10-30), branch_10_6 and the 10.6.0 tag have the same method (read).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BayesianScoreQuery.java:41 (@lucene.experimental), :263-265 (score() = sigmoid(alpha * (innerScore - beta) + logitBaseRate) in float), :281-290 (setMinCompetitiveScore: clamp at :287, closed-form innerMin at :289, 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'); TopScoreDocCollector.java:160 (localMinScore = Math.nextUp(topScore))
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/LongDistanceFeatureQuery.java:270 and LatLonPointDistanceFeatureQuery.java:284 ('Inverting the score computation is very hard due to all potential rounding errors, so we binary search', LUCENE-8340); lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:88, :109 ('make sure that we do not miss any matches'); lucene/core/src/test/org/apache/lucene/search/TestBayesianScoreQuery.java:230, :333, :441 (CheckHits.checkTopScores)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/BayesianScoreQuery.java:23-41, (at 645d4fe14a20) 113-120, 262-266, 280-292
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:154-164; (at 645d4fe14a20) IndexSearcher.java:97-100, 599
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/ReqExclScorer.java:89-91; (at 645d4fe14a20) ConjunctionScorer.java:88-93; ReqOptSumScorer.java:282-295; DisjunctionMaxScorer.java:112-121; BlockMaxConjunctionScorer.java:234-236; LogOddsFusionScorer.java:214-218; DisjunctionScoreBlockBoundaryPropagator.java:104-109; LogOddsFusionQuery.java:230-233, 359-360, 393-394; BayesianScoreQuery.java:28-30, 41
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-52; (at 645d4fe14a20) TopScoreDocCollector.java:156; BayesianScoreQuery.java:41, 282-285
  • lucene/core/src/test/org/apache/lucene/search/TestBayesianScoreQuery.java:40-75 (at 645d4fe14a20) (53 documents), 226-231, 330-334, 435-442; lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:690-709, 740-771
  • lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:85-88, (at 645d4fe14a20) 107-109; lucene/core/src/java/org/apache/lucene/document/LongDistanceFeatureQuery.java:270-271; LatLonPointDistanceFeatureQuery.java:284-285
Prior reports
  • gh read-only queries, 2026-10-31 (gh search prs/issues --repo apache/lucene BayesianScoreQuery; Bayesian and setMinCompetitiveScore created since 2026-10-01; gh pr view 16410)
History
  • a Lucene clone (git tag --contains 7f55b56a40de: 10.5.0, 10.5.1, 10.5.2, 10.6.0; setMinCompetitiveScore md5 on releases/lucene/10.6.0, origin/main 2f424fd428da, origin/branch_10_6 7e5aec4370e)

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 the executed runs.

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.