All findings · Lucene finding · LFV-900 · positivescoresonly-collector-inherits-delegate-scoremode-and-setweight

PositiveScoresOnlyCollector gets the hits wrong when it wraps a collector that needs no scores

Around a collector that needs no scores, PositiveScoresOnlyCollector checks placeholder scores and lets a counter skip its check: sorted by a field, g:y returns 0 of its 6 positive hits, and counting (f:x)^0, where every score is 0, gives 12. The cause dates from 5.1.0 (the count half from 9.3.0); around a scoring collector it works.

Summary

Priority
Low, rank 335PositiveScoresOnlyCollector has no in-tree or major downstream user, and its documented use (wrapping a scoring collector) is correct.
Impact
silent wrong results
Since
5.1.0 (2015); the count half since 9.3.0 (2022)introduced 2015-02-05 · over 11 years
Affected
5.1.0 to 10.6.0 and main (25 releases to 10.5.2 run, and 10.6.0 at 4ae946e5a9b)
Status
documented
Found by
a model-written probe, then bounded-exhaustive comparison (960 checks)

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

PositiveScoresOnlyCollector wraps another collector and passes on only the hits whose score is above 0. To check a score it needs real scores, but it tells the searcher it needs whatever the wrapped collector needs, and it hands the wrapped collector the Weight.

Summary card: PositiveScoresOnlyCollector gives wrong hits and counts when the collector it wraps needs no scores. Under the title, the javadoc promise and the cause: it overrides only getLeafCollector, whose filter reads scorer.score(), and inherits scoreMode() and setWeight() from FilterCollector. Panel 1, a sort-by-field TopFieldCollector gets no hits (leg since 5.1.0): 12 documents, g:y has 6 matches that score above 0 with scores on; the inherited score mode is TOP_DOCS, so IndexSearcher builds the Weight without scores, the filter reads 0.0 for every hit and drops all 6; expected 6 hits, actual 0. Panel 2, a TotalHitCountCollector counts zero-score hits (since 9.3.0): for (g:y)^2 OR (f:x)^0, 12 matches of which 6 score above 0, the inherited setWeight passes the Weight on, Weight#count is added and the segment is skipped before the filter runs; expected 6, actual 12, and 12 instead of 0 for (f:x)^0. Footer: any wrapped collector that needs no scores is wrong.
The example: a sort-by-field collector wrapped in PositiveScoresOnlyCollector passes on the wrapped collector's "no scores" mode, so the check reads a score of 0 for every g:y hit and drops all 6; a wrapped counter takes Weight#count before the check runs. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 12 documents: all have f:x, the 6 even ones also g:y. Wrap a TopScoreDocCollector in PositiveScoresOnlyCollector and search g:y: 6 hits, all scoring above 0. This is the documented use, and it works.
  2. Wrap a TopFieldCollector sorted by a price field instead, the collector IndexSearcher#search(Query, int, Sort) uses. You expect the same 6 hits, sorted by price. You get 0 hits.
  3. Wrap a TotalHitCountCollector to count the hits that score above 0. For (f:x)^0, where every score is 0, you expect 0 and get 12; for (g:y)^2 OR (f:x)^0, with 6 positive hits, you expect 6 and get 12. No error, with assertions on or off.
  4. Why: the wrapper inherits two FilterCollector hooks. scoreMode() passes on the wrapped collector's "no scores", so the search computes none and the > 0 check reads a placeholder 0 (for a function score query a constant 1, so zero-score hits get through). setWeight() hands the counter the Weight, whose count() covers whole segments without the check.

Who is affected

Applications that wrap a collector that needs no scores (sort by a field, hit counting, doc-id or aggregation collectors) in PositiveScoresOnlyCollector get wrong hits or counts silently, with default settings and any index size; on main a sort by a field loses every TermQuery hit. Few are exposed: no in-tree or major downstream user is known.

Mechanism, terms and numbers703 words

PositiveScoresOnlyCollector wraps another collector and is documented to make "sure only documents with scores > 0 are collected"; the Collector overview says it "wraps any other Collector". To check a score it reads scorer.score() itself, but it extends FilterCollector and inherits two hooks meant for pure delegators: scoreMode() reports the wrapped collector's needs, and setWeight() forwards the Weight. Around a collector that needs no scores, both go wrong. The search builds scorers without scores, so the > 0 check reads placeholder values: 0 for a TermQuery, so a sort-by-field TopFieldCollector gets 0 of the 6 hits of g:y; a constant 1 for a FunctionScoreQuery over MatchAllDocsQuery, so documents whose score is 0 get through (docs 0-5 instead of 1, 3, 5). And a wrapped TotalHitCountCollector counts whole segments with Weight#count and never runs the check: 12 instead of 0 for (f:x)^0, 12 instead of 6 for (g:y)^2 OR (f:x)^0. No exception is raised, with assertions on or off, with the default codec and settings and 12 documents. The score-mode half dates from LUCENE-6218 (first released in 5.1.0), the count half from LUCENE-10620 / #964 (9.3.0); neither edited the class. Overriding both hooks in PositiveScoresOnlyCollector fixes every repro, and the search-package and queries-module test suites pass with it. Wrapping a scoring collector such as TopScoreDocCollector, the documented use, has always worked, and the class has no known in-tree or major downstream user.

Terms

  • ScoreMode: what a collector needs from the scorer. COMPLETE means "visit all matches and get their score"; COMPLETE_NO_SCORES means "scores won't be available"; TOP_DOCS (a sort by a field with the default hit-count threshold) also needs no scores. IndexSearcher builds the query's Weight and scorers for the collector's mode.
  • FilterCollector: a base class for collector delegators. It forwards scoreMode() and setWeight() to the wrapped collector.
  • Weight#count: a per-segment shortcut that returns the number of matches without iterating (-1 when unknown). TotalHitCountCollector uses it when setWeight gave it the Weight, and then skips the segment.

The bug

PositiveScoresOnlyCollector (PositiveScoresOnlyCollector.java:26-52 at a9779ef3c193) overrides only getLeafCollector, whose leaf collector does if (scorer.score() > 0) in.collect(doc) (:47). It inherits:

  1. scoreMode() = in.scoreMode() (FilterCollector.java:51-54). Around a collector that needs no scores, IndexSearcher creates the Weight without scores and rewrites the query as ConstantScore(query), whose weight is then the plain inner weight. A TermQuery without scores uses a dummy SimScorer that returns 0 and a constant-0 bulk scorer, so every hit fails > 0. A FunctionScoreQuery without scores returns its inner weight; MatchAllDocsQuery's weight scores 1, so every hit passes, including those whose function value is 0.
  2. setWeight() = in.setWeight(weight) (FilterCollector.java:41-44). A wrapped TotalHitCountCollector asks weight.count(context), adds it and throws CollectionTerminatedException, so the wrapper's leaf collector never runs for that segment. With deletions Weight#count returns -1, collect() runs, and half 1 takes over.

Each half is an independent cause: fixing only scoreMode() still leaves 12 of 24 count searches of the independent reproducer wrong, fixing only setWeight() 16 of 24 in each of its tests; fixing both, all pass.

The numbers (executed on main)

wrapped collectorqueryexpected (hits scoring > 0)actual
TopScoreDocCollector (documented use)g:y / (f:x)^0 / (g:y)^2 OR (f:x)^06 / 0 / 66 / 0 / 6
TopFieldCollector, sort by field (TOP_DOCS)g:y6 hits0 hits
TopFieldCollector, sort by field(g:y)^2 OR (f:x)^06 hits0 hits
TotalHitCountCollector(f:x)^0012
TotalHitCountCollector(g:y)^2 OR (f:x)^0612
TotalHitCountCollector, 20 docs with a deletion (independent reproducer)tag:rare, 5 matches50 (1 segment) or 4 (3 segments)
a COMPLETE_NO_SCORES doc-set collectorFunctionScoreQuery(MatchAll, v), v = 0, 1, 0, 2, 0, 3docs [1, 3, 5]docs [0, 1, 2, 3, 4, 5]

Over a bounded-exhaustive grid (2 segment layouts, 2 deletion states, 8 queries, 3 search paths, 10 wrapped collectors covering 4 of the 5 ScoreMode values, all but TOP_DOCS_WITH_SCORES), 384 of 960 checks are wrong, every one with one of the 5 wrapped collectors that need no scores (384 of their 480 checks; the 5 scoring ones are right in all 480), and 0 with the fix.

Exposure and likelihood193 words
  • Who is exposed. Code that wraps a sort-by-field TopFieldCollector (the collector behind IndexSearcher#search(Query, int, Sort)), a TotalHitCountCollector, a custom COMPLETE_NO_SCORES collector or a MultiCollector of such collectors. The count half also bites when the search does compute scores: MultiCollector(PositiveScoresOnlyCollector(TotalHitCountCollector), TopScoreDocCollector) has the right score mode, but the forwarded Weight still lets the counter skip the check (6 of 8 probe cases wrong from 9.3.0).
  • Default vs opt-in. The class is opt-in; everything else is default: codec, IndexWriterConfig, IndexSearcher, both search paths. A plain TermQuery triggers the sort case; a zero boost (legal) or a function score gives zero-score hits.
  • Effect. Wrong hits or counts in both directions: missing hits that score above 0, extra hits that score 0, counts too high (zero-score matches counted) or too low (with deletions). No exception.
  • How likely. Low. Outside its own test nothing in Lucene uses the class, no use was found in Elasticsearch, OpenSearch or Solr, the one user code found upstream (#13499) wraps a scoring collector, which works, and since 8.0 ("Queries are now required to produce positive scores.", LUCENE-7996) the class only removes hits with a score of exactly 0.

Minimal repro

The test shows that PositiveScoresOnlyCollector inherits scoreMode() and setWeight() from FilterCollector, so on a one-document index a wrapped sort-by-field TopFieldCollector loses the positive-score hit and a wrapped TotalHitCountCollector counts the zero-score hit (TestPositiveScoresOnlyCollectorWrapsNoScoreCollector, one test per leg).

TestPositiveScoresOnlyCollectorWrapsNoScoreCollector.java (JUnit 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.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestPositiveScoresOnlyCollectorWrapsNoScoreCollector extends LuceneTestCase {

  /** Indexes one document (f:x, n=0) and searches it through PositiveScoresOnlyCollector(in). */
  private static void searchOneDoc(Query query, Collector in) throws IOException {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new StringField("f", "x", Field.Store.NO));
        doc.add(new NumericDocValuesField("n", 0));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        new IndexSearcher(reader).search(query, new PositiveScoresOnlyCollector(in));
      }
    }
  }

  public void testWrappedSortByFieldCollector() throws IOException {
    Sort byField = new Sort(new SortField("n", SortField.Type.LONG));
    TopFieldCollector sorted = new TopFieldCollectorManager(byField, 1, 1000).newCollector();
    searchOneDoc(new TermQuery(new Term("f", "x")), sorted);
    assertEquals(
        "hits (expected: the one match, its score is > 0)", 1, sorted.topDocs().scoreDocs.length);
  }

  public void testWrappedTotalHitCountCollector() throws IOException {
    TotalHitCountCollector counter = new TotalHitCountCollector();
    searchOneDoc(new BoostQuery(new TermQuery(new Term("f", "x")), 0f), counter);
    assertEquals("count (expected: none, the one match scores 0)", 0, counter.getTotalHits());
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-900/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.TestPositiveScoresOnlyCollectorWrapsNoScoreCollector
Expected vs actual
Expected: 1 hit for TermQuery f:x sorted by field, count 0 for (f:x)^0; actual: 0 hits (expected:<1> but was:<0>) and count 1 (expected:<0> but was:<1>), with -ea and -da.

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

More files, environment, full output72 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 2 test(s) run, 2 failed
    => java.lang.AssertionError: count (expected: none, the one match scores 0) expected:<0> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.search.TestPositiveScoresOnlyCollectorWrapsNoScoreCollector.testWrappedTotalHitCountCollector(TestPositiveScoresOnlyCollectorWrapsNoScoreCollector.java:59)
  JUnit Vintage:TestPositiveScoresOnlyCollectorWrapsNoScoreCollector:testWrappedSortByFieldCollector
    MethodSource [className = 'org.apache.lucene.search.TestPositiveScoresOnlyCollectorWrapsNoScoreCollector', methodName = 'testWrappedSortByFieldCollector', methodParameterTypes = '']
    => java.lang.AssertionError: hits (expected: the one match, its score is > 0) expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.search.TestPositiveScoresOnlyCollectorWrapsNoScoreCollector.testWrappedSortByFieldCollector(TestPositiveScoresOnlyCollectorWrapsNoScoreCollector.java:52)

Why it looks unintended

  • The javadoc promises "only documents with scores > 0 are collected" for "any other Collector"; nothing restricts the wrapped collector.
  • The wrapper reads scores, so it must ask for them, as CachingCollector does since a 2015 fix its commit called "a small bug".
  • The class predates both hooks, its only test wraps a scoring collector, and nothing accepts this.
The full argument305 words
  • Javadoc. Class: "A Collector implementation which wraps another Collector and makes sure only documents with scores > 0 are collected." Collector overview: "PositiveScoresOnlyCollector wraps any other Collector and prevents collection of hits whose score is <= 0.0". Collector#scoreMode: "Indicates what features are required from the scorer." ScoreMode.COMPLETE_NO_SCORES: "scores won't be available". No CHANGES or MIGRATE text restricts the wrapped collector.
  • In-tree precedent. CachingCollector.ScoreCachingCollector.scoreMode() returns COMPLETE: "Ensure the scores are collected so they can be replayed, even if the wrapped collector doesn't need them." It came as a bug fix: LUCENE-6912 (2015-12-04, first released in 5.5.0) added it, and its commit message says "In core, CachingCollector should have always returned true when scores are cached (small bug)."
  • The test framework trips too, through a neighbouring rule. Under AssertingIndexSearcher with assertions on, the assertion "Scorable.score() must not be called inside collect(DocIdStream) or collectRange() ..." fires inside PositiveScoresOnlyCollector.collect for both no-score wrapped collectors the finder's probe tried (3 seeds), and never with the fix. That assertion is about batch collection, not about whether scores were requested: the no-score bulk scorer hands over a DocIdStream, which the wrapper's inherited default collect(DocIdStream) turns into collect(int) calls that read score().
  • Tests. TestPositiveScoresOnlyCollector.testNegativeScores drives a TopScoreDocCollector by hand with a fake scorer; it never runs a search, never wraps a no-score collector and never calls setWeight.
  • History. The class (LUCENE-1575, 2.9) predates both hooks; its CHANGES usage example wraps a TopScoreDocCollector. LUCENE-6218 (2015-02) added needsScores forwarding to FilterCollector; in CachingCollector it only gave the placeholder collector inside CachingCollector.create a needsScores() of true, and it did not touch this class. CachingCollector's own override came ten months later with LUCENE-6912, as the fix of "a small bug"; #964 (2022) added setWeight forwarding and never discusses wrappers that filter collect() (all comments and reviews read).
The devil's-advocate check265 words

The strongest case for "intended" (the intended-behaviour checker): for the count half, #964 met this exact interaction in its own tests. A test wrapper that filters collect() around a TotalHitCountCollector stopped working, and the author moved the test to a test-only counter: "I fixed some tests that were assuming that TotalHitCountCollector would naively iterate over matches". TotalHitCountCollector's javadoc documents the segment skipping. For the score half: the class was built to wrap a scoring collector, and LUCENE-6329 only says score() must not throw when scores were not requested, not that it is meaningful. And Collector and FilterCollector are @lucene.experimental.

Why it fails: the #964 change concerned a private test class and never mentions PositiveScoresOnlyCollector, whose public javadoc the forwarding breaks; it does not touch the score half at all, where hits that really score above 0 are dropped. The component that needs scores is Lucene's own wrapper; in #15239, about a user's collector that wraps a TopScoreDocCollector with a different score mode, a maintainer wrote "It is the responsibility of the collector to prevent such calls, so this is not a bug": the wrapper was at fault, and here the wrapper is Lucene's own. Lucene's sibling wrapper overrides scoreMode for this reason (LUCENE-6912 called the inherited forwarding "a small bug"), and no won't-fix or by-design statement exists. Verdict: not intended (score half: high confidence; count half: medium, because of the #964 precedent). It is a design inference: no javadoc says a FilterCollector subclass must override these hooks, and for the count half documenting the restriction would also be a defensible maintainer choice.

Suggested fix

Recommended Ask for scores, and hide Weight#count from the wrapped collector

Override both hooks in PositiveScoresOnlyCollector: scoreMode() returns COMPLETE when the wrapped collector needs no scores (else its own mode, so score pruning is kept), and setWeight() passes on a FilterWeight whose count() is -1 (+22 lines). All repros pass, and so do 2,340 search-package tests and the 513 queries-module tests, with assertions on and off.

All options and trade-offs6 options ยท 864 words

Both shapes of the setWeight change were validated on the module test suites; they differ in what the wrapped collector can still do with the Weight.

Ask for scores, and hide Weight#count from the wrapped collector (validated by the checks) (recommended)

scoreMode() keeps the wrapped mode when it needs scores and maps TOP_DOCS and COMPLETE_NO_SCORES to COMPLETE. setWeight() wraps the Weight in a FilterWeight whose count() returns -1, so the wrapped collector can no longer count a segment without collect(), but keeps matches, explain and the scorers. This is the finder's fix, in the shape an independent reviewer from another model family proposed.

@Override
public ScoreMode scoreMode() {
  // This collector reads scores itself, even if the wrapped collector does not need them.
  ScoreMode scoreMode = in.scoreMode();
  return scoreMode.needsScores() ? scoreMode : ScoreMode.COMPLETE;
}

@Override
public void setWeight(Weight weight) {
  // The wrapped collector must not count matches with Weight#count and skip collect(): that would
  // also count matches whose score is <= 0.
  in.setWeight(
      weight == null
          ? null
          : new FilterWeight(weight) {
            @Override
            public int count(LeafReaderContext context) {
              return -1;
            }
          });
}

Trade-offs

  • A wrapped TopScoreDocCollector keeps TOP_SCORES pruning. For a wrapped sort-by-field or counting collector the Weight is now built for COMPLETE: scores are computed, which the wrapper needs anyway, and the search is exhaustive, so a query loses any shortcut it takes for a non-exhaustive mode (not benchmarked). By code reading no competitive-iterator skipping is lost: the wrapper's leaf collector does not pass one on today.
  • setWeight() hides Weight#count from every wrapped collector, scoring or not; one that counts with it would count in collect() instead. By code reading the only in-tree collectors that call Weight#count are TotalHitCountCollector and the sandbox HistogramCollector, and neither needs scores.
  • Targeted: it blocks only Weight#count and keeps every other Weight method (matches, explain, scorerSupplier, getQuery) for per-document users (Lucene's monitor module calls weight.matches(ctx, doc) from its own collectors).
  • FilterWeight is @lucene.internal, which is fine inside core.
  • Gap, by code reading only: getQuery() still returns the query, and the sandbox HistogramCollector uses it to bulk-count a PointRangeQuery without collect(). That matters for a point range whose scores can be 0 (a zero boost, or a FunctionScoreQuery around it whose values can be 0: HistogramCollector unwraps both), wrapped through a custom manager (the collector is package-private). Not executed.
  • Not benchmarked; only this class changes.

What the checks validated

Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF0353: :lucene:core, every test class of org.apache.lucene.search with the 3 repro classes: 217 classes, 2,340 tests, 0 failures, with assertions on and off (TestPositiveScoresOnlyCollector, TestTotalHitCountCollector, TestMultiCollector, TestTopFieldCollector, TestCachingCollector, TestIndexSearcher among them); the whole :lucene:queries suite with the fail-open test: 52 classes, 513 tests, 0 failures, on and off. :lucene:core:check -x test and :lucene:queries:check -x test with the fix and both tests: BUILD SUCCESSFUL. The repro runner: the primary test passes 3 of 3 with the fix; the 960-check harness finds 0 wrong.

Ask for scores, and do not forward setWeight (validated by the checks)

The same scoreMode() override, and an empty setWeight (+13 lines). This is what downstream engines did for their own min-score wrappers: OpenSearch gave its MinimumScoreCollector (a FilterCollector subclass) a no-op setWeight (PR #16366), and Elasticsearch withholds setWeight under min_score (#97030).

@Override
public void setWeight(Weight weight) {
  // Do not pass the weight on: the wrapped collector could count matches with Weight#count and
  // skip collect(), which would also count matches whose score is <= 0.
}

Trade-offs

Simpler, and closes every Weight-based shortcut, including the HistogramCollector one. Cost: the wrapped collector never gets the setWeight callback (it is skipped, not called with null), so a per-document user of the Weight has none, and a collector that requires the callback breaks: by code reading, the test framework's AssertingCollector asserts that it was called, so one wrapped inside the class would fail with assertions on (not executed).

What the checks validated

Gradle, assertions on: the same selection, 217 classes, 2,340 tests and 52 classes, 513 tests, 0 failures. The finder also ran it on the repro, the upstream test (3 seeds), the 960-check harness and the asserting probe (3 seeds), while it was the finder's only fix: all pass. Not run with assertions off or through the static checks.

Override scoreMode() only

Ask for scores but keep forwarding the Weight.

Trade-offs

Not enough: the counter still skips the check through Weight#count (12 of 24 count searches still wrong in the independent reproducer's shadow run).

Always return COMPLETE

scoreMode() returns COMPLETE whatever the wrapped collector needs.

Trade-offs

Correct together with a setWeight change, but drops TOP_SCORES pruning for the documented TopScoreDocCollector use. Not run.

Document the restriction

Say in the javadoc that only collectors that need scores may be wrapped, and never a TotalHitCountCollector.

Trade-offs

Contradicts "wraps any other Collector" and keeps a public class that silently fails for natural uses. Not run.

Workaround without a Lucene change

Subclass PositiveScoresOnlyCollector with the two overrides of the recommended fix, or filter by score in the application's own collector.

Trade-offs

Every application has to know about it.

Downstream note

An application that uses a score > 0 cut-off to hide documents is not protected by this collector around a no-score collector (zero-score hits can get through); a filter clause is the tool for visibility rules.

How it was found

An independent model from another family (OpenAI GPT-5.6), asked for test ideas, wrote a small probe: a wrapped collector that needs no scores got 0 of 6 positive hits, and a wrapped counter reported 12. The finder then checked the wrapper against the truly positive hits on 960 small cases (384 wrong) and reduced it to a JUnit test.

The method in full217 words

Method: semi-formal (a model-written probe, then bounded-exhaustive differential testing of the real core jar, guided by mutation analysis).

  • First signal (2026-10-28). A planning round asked an independent model from another family (OpenAI GPT-5.6) for testing-research leads; one lead's probe ran on the stock main jar: a wrapped collector that needs no scores collected 0 of 6 positive hits, and a wrapped TotalHitCountCollector reported 12 instead of 0 or 6.
  • Hunt. The finder checked the wrapper against an independent reference, the matches whose COMPLETE score is above 0, over 2 segment layouts, 2 deletion states, 8 queries, 3 search paths and 10 wrapped collectors covering 4 of the 5 ScoreMode values (all but TOP_DOCS_WITH_SCORES): 384 of 960 checks wrong, every one with a wrapped collector that needs no scores. Mutants of the wrapper that override only scoreMode(), only setWeight(), or both separated the two causes; a mutant of the reference (>= 0) checked that the oracle was not vacuous.
  • A class-file inventory of the 8 Collector wrapper classes in the non-sandbox jars flagged the same pattern (reads scores, inherits both hooks) only in CachingCollector's classes, a separate hunt finding (LFV-161), partly known upstream.
  • The finder then wrote a public-API JUnit test, which failed on main and on every release run from 8.0.0.

Prior reports, history and checks

Prior reports

No prior report found: 239 queries over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream projects and the web, plus the file lists of all 410 open PRs, searched 2026-10-28. Nearest: two downstream engines fixed the same count problem in their own min-score wrappers.

Searches5 searches

Nobody has reported that PositiveScoresOnlyCollector gets hits or counts wrong around a collector that needs no scores. Upstream, the class name appears only in #2649 (added the class), #3347 (-inf/NaN function scores) and #13499 (user code on the working scoring path); no PR of any state names it. The downstream fixes (OpenSearch PR #16366 for its MinimumScoreCollector; Elasticsearch #97030 for its QueryPhaseCollector under min_score) are precedents for the fix shape, not reports of this bug: neither names Lucene's class or reported anything to Lucene.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-28two scripted prior-report searches (class-centric and mechanism-centric) and 50 own searches: PositiveScoresOnlyCollector with scoreMode, setWeight, TotalHitCountCollector; FilterCollector forwarding; Weight#count with filtering collectors; re-checkedno report; only #2649, #3347, #13499 name the class
Threads read in full2026-10-28#964 (all comments and reviews), #7280 (LUCENE-6218), #7584, #7390, #10668, #14778, #12083, the LUCENE-6218 diffno mention of wrappers that filter collect()
Open and recent PRs2026-10-28changed files of all 410 open PRs; every PR created since 2026-10-26 (#16907-#16926)none touches PositiveScoresOnlyCollector.java, FilterCollector.java or Collector.java; #16133 touches FilterLeafCollector (competitiveIterator only)
Mailing lists and JIRA2026-10-28dev@, java-user@, issues@, commits@ (50 queries); JIRA archive (10 queries)usage threads from 2009-2014 and LUCENE-1575 / LUCENE-2271 only
Downstream and web2026-10-28Elasticsearch, OpenSearch and Solr code and trackers; 6 web searchessame-mechanism fixes in OpenSearch #16366 and Elasticsearch #97030 / #97271, on their own classes

History

Score half since LUCENE-6218 (2015-02-05, first release 5.1.0), which made FilterCollector forward the score mode; count half since #964 (2022-06-23, first release 9.3.0), which made it forward the Weight. Neither edited the class. Affects 5.1.0 to 10.6.0 and main.

Versions tested6 rows

Score half: LUCENE-6218 "don't decode freqs or enumerate all positions, when scores are not needed" (@rmuir, 2015-02-05; branch_5x 8c0dbb90087, trunk 010e352f603; Subversion era, no PR) added Collector#needsScores() and FilterCollector#needsScores() { return in.needsScores(); }. LUCENE-4100 (8.0.0) renamed it to scoreMode() and kept the forwarding. The class had become a FilterCollector subclass in LUCENE-5527 (2014; the change shipped in 5.0.0), harmless while FilterCollector had no hooks.

Count half: LUCENE-10620 / #964 "Pass the Weight to Collectors." (@jpountz, merged 2022-06-23; branch_9x e983d391d71, main 4c1ae2a332c) added Collector#setWeight, forwarding in FilterCollector and MultiCollector, and the Weight#count shortcut in TotalHitCountCollector; no CHANGES entry.

PositiveScoresOnlyCollector.java is byte-identical in 5.0.0 and 5.1.0 and in 9.2.0 and 9.3.0. The visible symptom moved with unrelated scorer changes while the cause stayed: 5.1.0 loses the positive hits of +(f:x)^0 g:y; 5.2.0 also those of (g:y)^2 (f:x)^0; 5.2.1-7.7.3 also every hit of a plain TermQuery; 8.0.0-9.7.0 keep the plain TermQuery hits but let zero-boost hits through and still return 0 of the 6 positive hits of (g:y)^2 (f:x)^0; from 9.8.0 plain TermQuery hits are lost again.

The two halves were separated by shadow patches of each release's own class: with only a scoreMode override, 9.2.0 passes while 9.3.0 still fails the count checks, and 9.3.0 passes once setWeight is not forwarded; 5.1.0 passes with a needsScores() { return true; } override.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193, branch_10_6 4ae946e5a9b affectedevery repro fails with assertions on and off; upstream main c5ab3e57b9b has the same files
10.5.2, 10.0.0 affectedthis doc's test fails 3/3; the queries test fails on 10.5.2 and 10.0.0
9.3.0 to 9.12.3 affectedboth halves (the finder's 9.x copy fails 2/2; the portable probe fails)
5.1.0 to 9.2.0 score half onlythe portable probe fails on 15 releases; the count goes through the check, but under the wrong score mode
4.10.4, 5.0.0 not affectedthe portable probe passes; 5.1.0 passes with a needsScores() override
fix (main + both overrides) not affectedevery repro passes; the core search and queries suites pass

Independent checks

An independent reproducer, a root-cause review by another model family that ran its own tests, and an intended-behaviour devil's advocate each reproduced it and called it real; the adjudication confirmed it with a new seed, and a credibility audit re-ran it.

Checks and verdicts444 words

Every check ran on main a9779ef3c193 except the two executing outside reviews (refute and root-cause), whose runs used main ea57360912ed, where every file involved is identical. Four earlier sandboxed attempts (two per lens) could not start a command and gave reading-only verdicts; the executing reviews replace them. This page was then fact-checked independently and by a reviewer from another model family, and its diagram was checked.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn test from the claim (20 documents, 1 and 3 segments, with and without a deletion, both search paths) fails 3 of 4 on main, 10.5.2 and 9.12.3 with assertions on and off; the scoring control is right in 24 of 24; shadows show each hook is an independent cause; the fix passes.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real, highWrote its own JUnit (7 documents) and reproduced both halves in 4 runs (expected 4 / actual 0; expected 0 / actual 7); proposed the FilterWeight fix shape, which was adopted.
cross-model review, root-cause lens (OpenAI GPT-5.6, executed) real, highOwn JUnit fails 3 of 4 and a CollectorManager variant 2 of 2; instrumentation shows score 0.0 under the inherited mode and Weight.count ending the segment before the check; the fix passes.
cross-model review, intended lens (OpenAI GPT-5.6, reading only) real, mediumRated the intended-behaviour argument weak; a separate re-run failed 2 of 2.
intended-behaviour devil's advocate real (score half high, count half medium)Built the strongest case from the #964 test change and rejected it; re-ran the repro on main and 10.5.2.
adjudication confirmed, highWith a new seed the reproducer's test fails 3 of 4 with assertions on and off and passes 4 of 4 with the fix.
Registration check and credibility audit keepRe-ran the finder's test (2 of 2 fail, on and off) and the reproducer's (3 of 4 fail).
this doc's validation fix validatedGradle on stock and with both fix shapes (search package of the core module, whole queries module, static checks), assertions on and off.
Fact checks and diagram check of this doc facts hold; wording correctedAn independent fact check re-ran the core test on main (assertions on and off), 10.5.2 and 10.0.0, the queries test on main and 10.0.0, and both fixes, and found 8 precision errors. A read-only fact check by an independent reviewer from another model family (OpenAI GPT-5.6) found 8 errors, 6 overclaims and 4 missing caveats. The diagram check found 0 errors and 6 minor items. None changes the conclusion; the corrections are in this doc and its diagram.

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

Finding record

LFV-900, snapshot of 2026-11-06

Registry title
PositiveScoresOnlyCollector inherits its wrapped collector's scoreMode() and setWeight(), so a wrapped sort-by-field TopFieldCollector or TotalHitCountCollector silently gets the wrong hits or count instead of exactly the hits that score > 0 (score-mode leg since 5.1.0, count leg since 9.3.0)
Area
core / search collectors (PositiveScoresOnlyCollector; FilterCollector scoreMode and setWeight forwarding; TotalHitCountCollector Weight#count shortcut)
Priority
Low, rank 335: PositiveScoresOnlyCollector has no in-tree or major downstream user, and its documented use (wrapping a scoring collector) is correct.
Impact class
silent wrong results
Impact note
Applications that wrap a collector that needs no scores (a sort-by-field TopFieldCollector, the collector behind IndexSearcher#search(Query, int, Sort); TotalHitCountCollector; a doc-id or other COMPLETE_NO_SCORES collector; or a MultiCollector of them) in the public core PositiveScoresOnlyCollector get silently wrong hits or counts on every search, with no exception under -ea or -da, with the default IndexWriterConfig, codec and searcher and at any index size (12 documents suffice), in every release from 5.1.0 to 10.6.0 and on main; wrapping a scoring collector such as TopScoreDocCollector (the documented use) is correct, the class has no in-tree, Elasticsearch, OpenSearch or Solr consumer, and since 8.0 (no negative scores) it can only remove zero-score hits, so few applications are exposed.
Summary
You wrap a collector in PositiveScoresOnlyCollector, whose javadoc says it 'makes sure only documents with scores > 0 are collected' and which Collector's overview says 'wraps any other Collector', and expect the wrapped collector to see exactly the matches that score > 0: in the repro 6 hits for the TermQuery g:y with a TopFieldCollector sorted by a field, and counts 0 for (f:x)^0 and 6 for (g:y)^2 OR (f:x)^0 with a TotalHitCountCollector. Instead the sort-by-field collector receives 0 hits and the counter reports 12 for both queries, because PositiveScoresOnlyCollector, which reads scorer.score() itself, inherits two FilterCollector hooks: scoreMode() reports the wrapped collector's no-score mode, so the search builds scorers without scores and the > 0 test reads placeholder values (0 for TermQuery, so real hits are dropped; a constant positive value for FunctionScoreQuery over MatchAllDocsQuery, so zero-score hits get through), and setWeight() hands the wrapped TotalHitCountCollector the Weight, so it counts whole segments with Weight#count and never goes through the filter. Overriding scoreMode() to return COMPLETE when the wrapped collector needs no scores, and passing on a Weight whose count() returns -1 (or not forwarding setWeight at all), fixes both.
Introduced
two legs, neither an edit to PositiveScoresOnlyCollector.java (byte-identical in 5.0.0 and 5.1.0, and in 9.2.0 and 9.3.0) [read: git blame -w -M -C and git log -S on the full history; both boundaries confirmed by execution]: (1) score-mode leg: 8c0dbb90087716841b895d839e15a8edfdbb8dae (branch_5x, svn r1657571; trunk 010e352f60301b1ab3967ad70413240163703ec4, r1657554), LUCENE-6218 'don't decode freqs or enumerate all positions, when scores are not needed' (apache/lucene#7280, by rmuir, Subversion era, no PR): it added Collector#needsScores() and FilterCollector#needsScores() { return in.needsScores(); }, which PositiveScoresOnlyCollector (a FilterCollector subclass since LUCENE-5527, 85642f59ddf, 2014, first released in 5.0.0) inherited although it reads scores; LUCENE-4100 (4fc5a872ded, 2017, first released in 8.0.0) renamed the hook to scoreMode() and kept the forwarding. (2) count leg: e983d391d71f3f3a628eb5520e8714623ac05202 (branch_9x; main 4c1ae2a332cb85878310142ebb9fd5beba0345f2), LUCENE-10620 / apache/lucene#964 'Pass the Weight to Collectors' by jpountz: it added Collector#setWeight with forwarding in FilterCollector and MultiCollector and made TotalHitCountCollector count through Weight#count; it has no CHANGES entry (9.3.0's CHANGES does not mention LUCENE-10620 or setWeight). Not born with the class (LUCENE-1575, 2.9): it broke when FilterCollector gained hooks that a filtering subclass must override. (2015-02-05 (score-mode leg; branch_5x 14:11:57Z, trunk 12:34:28Z, author date = commit date); 2022-06-23 (count leg; author date 2022-06-23T17:56:15+02:00 on both branches, PR #964 merged 15:56:15Z))
First affected version
5.1.0 [exec] (score-mode leg: 4.10.4 and 5.0.0 pass, 5.1.0 fails, and 5.1.0 passes once a needsScores() { return true; } override is added to its own copy of the class); the count leg from 9.3.0 [exec] (with only the score-mode leg shadow-fixed, 9.2.0 passes and 9.3.0 still fails the count checks until setWeight is not forwarded)
Affected versions
every release from 5.1.0 to 10.5.2 (106 releases; a class-file scan of every release jar shows FilterCollector forwarding the hook and PositiveScoresOnlyCollector declaring no override [read]), branch_10x, branch_10_6 4ae946e5a9b (10.6.0, released 2026-10-29) and main a9779ef3c193. Executed fail: a version-portable main() probe on 5.1.0, 5.2.0, 5.2.1, 5.3.0, 5.4.0, 5.5.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.2.0, 9.3.0, 9.7.0, 9.8.0, 9.12.3, 10.0.0, 10.5.2 and main [exec]; the finder's JUnit (or its 8.x/9.x API copy) on 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.3.0, 9.7.0, 9.8.0, 9.9.0, 9.10.0, 9.11.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main (-ea; -da as well on 10.5.2 and main) [exec]; the independent reproduction's own JUnit on 9.12.3, 10.5.2 and main (-ea and -da) [exec]. 4.10.4 and 5.0.0 pass [exec]. The visible symptom changes with unrelated scorer changes while the cause stays the same: from 5.1.0 positive hits are lost for some queries (for every positive TermQuery from 5.2.1 to 7.7.3); 8.0.0-9.7.0 let zero-boost hits through while plain TermQuery hits survive; from 9.8.0 every positive TermQuery hit is lost again (not bisected; 9.7.0..9.8.0 contains #12383, the dummy SimScorer for non-scoring term weights); from 9.3.0 a wrapped TotalHitCountCollector also counts through Weight#count. Not fixed on upstream main c5ab3e57b9b (2026-10-28).
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; also reached by exhaustive check
Upstream and prior reports
none found (the prior-report record verdict new, written 2026-10-28, read-only): no issue, PR, review comment, commit message, JIRA entry, mailing-list message or downstream report describes Lucene's PositiveScoresOnlyCollector giving wrong hits or counts for a wrapped collector that needs no scores. Searched: 239 logged queries (162 GitHub issue/PR, 7 commit, 4 downstream-repository, 50 over the dev@, java-user@, issues@ and commits@ lists, 10 JIRA, 6 web), 18 hits read, the file lists of all 410 open apache/lucene PRs (none touches PositiveScoresOnlyCollector.java, FilterCollector.java, Collector.java or TestPositiveScoresOnlyCollector.java), the finder's own scripted prior-report search (968 hits, none about this class) and the independent reproduction's and the intended-behaviour check's searches. GitHub issue search for the class name finds only #2649 (LUCENE-1575, added the class), #3347 (LUCENE-2271, -inf/NaN function scores) and #13499 (user code that wraps a scoring TopScoreDocCollector, the working path); no PR of any state names it. Refresh for this row (2026-10-28): GitHub PR and issue search for PositiveScoresOnlyCollector, 'FilterCollector setWeight', 'FilterCollector scoreMode', 'positive scores collector scoreMode' and 'TotalHitCountCollector Weight count filter collector' (no new hit); the 15 PRs created since 2026-10-26 (#16907-#16926) touch no Collector file (file lists read); upstream commits by author date: the last change to PositiveScoresOnlyCollector.java is f527eb3b12d (#12407, 2023-07-05) and to FilterCollector.java 4c1ae2a332c (#964, 2022-06-23), upstream head c5ab3e57b9b (author date 2026-10-28). Related, not reports of this bug: OpenSearch PR #16366 (comment 2024-12-18, merged 2025-01-22) hit the count mechanism on OpenSearch's own MinimumScoreCollector (a FilterCollector subclass) and fixed that class with a no-op setWeight; Elasticsearch #97030 (2023) withholds setWeight from its QueryPhaseCollector when min_score is set, and #97271 asks for scores under min_score; #964 (introduced the count leg; its test-wrapper swap); #7280 (LUCENE-6218, introduced the score-mode leg); #7584 and #7390 (score-mode assertion design); #16133 (open, FilterLeafCollector competitiveIterator, does not change these hooks); #16918 (search(Query, Collector) slated for removal in 11.0; PositiveScoresOnlyCollector has no CollectorManager); LFV-161 (the hunt's CachingCollector row, same mechanism class, different fix site).
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: this doc's core and queries-module tests, the finder's and the independent reproducer's tests and upstream TestPositiveScoresOnlyCollector on stock, assertions on and off; the recommended fix on every org.apache.lucene.search test class of :lucene:core and the whole :lucene:queries suite, assertions on and off, and both modules' static checks; the no-forward alternative on the same selection, assertions on; the repro runner: the finder's, the verifiers', the adjudication's, the registry re-runs, the credibility audit and this doc's tests, several seeds, assertions on and off); the release jars 4.10.4 and 5.0.0 (clean), 5.1.0 to 10.5.2 (25 releases: 21 with a version-portable probe, and JUnit copies on 14 releases from 8.0.0 to 10.5.2, 4 of them not in the probe's set); branch_10_6 head 4ae946e5a9b. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/PositiveScoresOnlyCollector.java:22-25 (class javadoc), :26-52 (extends FilterCollector, overrides only getLeafCollector), :47 (scorer.score() > 0); FilterCollector.java:41-44 (setWeight forwards), :51-54 (scoreMode returns in.scoreMode()); Collector.java:40-41 ('wraps any other Collector and prevents collection of hits whose score is <= 0.0')
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/PositiveScoresOnlyCollector.java:22-52 (javadoc :22-25, filter :47)
  • lucene/core/src/java/org/apache/lucene/search/FilterCollector.java:41-44 (setWeight), :51-54 (scoreMode)
  • lucene/core/src/java/org/apache/lucene/search/ScoreMode.java:22-44; Collector.java:40-41, 55, 58-63
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:639-641, 752-754, 811 (createWeight with the collector's mode; setWeight), :882-889 (rewrite as ConstantScoreQuery without scores); ConstantScoreQuery.java:140-146, 202-204
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:349-353; TotalHitCountCollector.java:22-53; TermQuery.java:80-92, 168-174, 255-268; BooleanWeight.java:167-172, 237-253; MatchAllDocsQuery.java:39-48
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:109-121
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, 677-679, 733 (search(Query, int, Sort) uses TopFieldCollectorManager with threshold 1000)
  • lucene/CHANGES.txt:6619 (LUCENE-7996)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/PositiveScoresOnlyCollector.java:22-25; Collector.java:40-41, 55; ScoreMode.java:25-26; CachingCollector.java:135-142; Collector.java:44, FilterCollector.java:25 (@lucene.experimental); LeafCollector.java:122-134 (default collect(DocIdStream))
  • lucene/core/src/test/org/apache/lucene/search/TestPositiveScoresOnlyCollector.java:91-128
  • lucene/CHANGES.txt:19027-19038 (LUCENE-1575 usage example), 11063-11064 (LUCENE-6218)
  • github.com/apache/lucene/issues/15239 (read 2026-10-28: jimczi 2025-10-01 'It is the responsibility of the collector to prevent such calls, so this is not a bug'; 2025-10-03 follow-up on BatchScoreBulkScorer; issue open)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/FilterWeight.java:22-28 (@lucene.internal); lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:368; lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollector.java:47, 86-98, 370-378 (count at 377), 385-389 (unwraps BoostQuery, ConstantScoreQuery, FunctionScoreQuery, IndexOrDocValuesQuery), 401-412
  • lucene/core/src/java/org/apache/lucene/search/FilterLeafCollector.java:32-65 (no competitiveIterator override); ScoreCachingWrappingScorer.java:42-75
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingCollector.java:58, 91-94 (asserts setWeight was called); lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollector.java:48, ScoreMode.java:63 (isExhaustive)

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

Repro files

About the diagrams

A step-by-step picture of the repro's example, made in parallel with this doc from an executed probe on the stock main jars (assertions on and off, and with the fix). The PNG is the summary card.

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.