All findings · Lucene finding · LFV-853 · monitor-filter-clause-decomposed-as-scoring-query

Monitor scores a lone FILTER clause, so FILTER-only queries get a positive or inflated score

When a stored BooleanQuery's only required clause is FILTER, the Monitor runs that clause as a scoring query: ScoringMatch and ExplainingMatch give #body:apple 0.13076457 where IndexSearcher gives 0, and (#body:apple) body:banana 0.26152915, twice its true score. Matching is right; scores are wrong since 8.2.0.

Summary

Priority
Low, rank 458Monitor reports a positive or doubled score for a FILTER-only BooleanQuery, which only affects scores and needs a rare query shape built in code.
Impact
silent wrong results
Since
8.2.0 (2019), the module's first releaseintroduced 2019-06-13 · about 7 years
Affected
8.2.0 to 10.6.0 and main (all 57 releases to 10.5.2 and 10.6.0 run)
Status
documented
Found by
bounded-exhaustive comparison with IndexSearcher

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-03.

The finding in 30 seconds

To pick candidate queries fast, the Monitor splits a stored query into parts and adds up the scores of the parts that match. For a BooleanQuery whose only required clause is FILTER, the split keeps the clause but drops its FILTER flag, so a part that should add nothing to the score adds its full term score.

The registered query (#body:apple) body:banana on the document "apple banana". The decomposer splits it into body:apple, with its FILTER flag lost, and body:banana, 0.13076457 each; the Monitor adds them to 0.26152915, while IndexSearcher gives 0.13076457 because the FILTER clause does not score.
The nested example (#body:apple) body:banana on the document "apple banana": the decomposer drops the FILTER flag, and the Monitor adds both parts (0.26152915) where IndexSearcher scores only banana (0.13076457). Select the still to open it at full size. Generated with AI, checked against the code.
  1. Register #body:apple (a BooleanQuery whose only clause is FILTER body:apple) in a default Monitor, and match the document body "apple banana" with ExplainingMatch.MATCHER or ScoringMatch.DEFAULT_MATCHER (BM25).
  2. You expect IndexSearcher's score, 0: FILTER clauses "do not participate in scoring". You get 0.13076457, the score of body:apple alone. (#body:apple)^2 gets 0.26152915, and #(body:apple body:banana) gets 0.26152915, the sum of both terms.
  3. Nest it: (#body:apple) body:banana should score 0.13076457, since only banana scores. The Monitor adds the FILTER clause's score on top and reports 0.26152915, here twice that because both terms score the same. No error.
  4. Why: the default QueryDecomposer treats FILTER like MUST. When that is the only required clause and no SHOULD clause adds decomposed parts (usually: there is none), it returns that clause's parts as plain queries, so the FILTER flag is lost, each part scores, and the matchers add the parts up.
  5. This bug does not change which queries match. Controls are scored right: two distinct FILTER clauses, FILTER next to a MUST or a SHOULD term, and a plain MUST clause.

Who is affected

lucene-monitor users with the default decomposer who read scores through ScoringMatch (also via ParallelMatcher or PartitionMatcher) or ExplainingMatch, and register a BooleanQuery whose only required clause is one FILTER clause, alone or nested beside scoring clauses. Such queries are built in code or by the XML query parser. No scale needed; this bug does not change which queries match.

Mechanism, terms and numbers598 words

The Monitor (lucene-monitor) matches documents against stored queries. To select candidate queries quickly, its default QueryDecomposer splits a registered query into parts; when several parts of one query match a document, ScoringMatch and ExplainingMatch add their scores. The decomposer groups FILTER clauses with MUST clauses, and when a BooleanQuery has exactly one required clause and no SHOULD clause that yields decomposed parts it replaces the query by the decomposition of that clause. For a MUST clause that is right. For a FILTER clause, which is "Like MUST except that these clauses do not participate in scoring", the FILTER flag is lost and the part scores: #body:apple gets 0.13076457 instead of 0, #body:apple -body:kiwi the same, #(body:apple body:banana) 0.26152915 instead of 0, and (#body:apple) body:banana 0.26152915 instead of 0.13076457. Core gives the same one-clause shape a score of 0 in BooleanQuery.rewrite. Which queries match is unchanged by this bug. The code came with the 2019 import of the Luwak library (LUCENE-8766) and is in every release from 8.2.0 on. Wrapping each decomposed part of a lone FILTER clause in a FILTER-only BooleanQuery fixes it, and the whole monitor module passes with that change.

Terms

  • Monitor (lucene-monitor, org.apache.lucene.monitor): stores queries and matches incoming documents against them. A matcher chosen per match call decides what is reported: SIMPLE_MATCHER (query ids), ScoringMatch (ids and scores), ExplainingMatch (ids and an Explanation), HighlightsMatch (matching terms).
  • QueryDecomposer: splits a registered query into parts that are indexed and run separately, so the presearcher can select only the parts whose terms occur in the document. Each part keeps the registered query's id.
  • FILTER clause #q: a required clause that does not score ("Like MUST except that these clauses do not participate in scoring").

The bug

  1. decomposeBoolean puts FILTER clauses into the same mandatory set as MUST clauses (QueryDecomposer.java:84-86).
  2. With more than one distinct required clause, or one plus decomposed SHOULD parts, the query is kept whole (:93-95): those shapes are scored right.
  3. With exactly one required clause and no decomposed SHOULD parts (normally: no SHOULD clause), it returns the decomposition of that clause, "decompose the MUST clause instead" (:97-101). For a FILTER clause the occur is lost: #body:apple is stored as the plain body:apple. With MUST_NOT clauses each part is rebuilt as +part -exclusion (:103-116), still scoring.
  4. Each part runs on its own as the matcher's query (Monitor.java:299). ScoringMatch collects its score and ExplainingMatch explains it (ExplainingMatch.java:38); two matches of one id are merged by resolve, which adds the scores (CandidateMatcher.java:75-85, ScoringMatch.java:42-44, ExplainingMatch.java:44-53). So a FILTER-only clause nested next to a scoring clause is counted on top of it.

Core treats the same one-clause shape as non-scoring: BooleanQuery.rewrite turns a single FILTER clause into new BoostQuery(new ConstantScoreQuery(query), 0), "no scoring clauses, so return a score of 0" (BooleanQuery.java:289-291). The decomposer's rule is analogous to that rewrite (core's applies only to a query with exactly one clause and minimumNumberShouldMatch 0), but it drops exactly the FILTER case.

The numbers

Document body "apple banana", BM25, IndexSearcher on a one-document index vs the default Monitor:

registered queryIndexSearcherScoringMatch / ExplainingMatch
#body:apple0.00.13076457
#body:apple -body:kiwi0.00.13076457
(#body:apple)^2.00.00.26152915
#(body:apple body:banana)0.00.26152915
(#body:apple) body:banana0.130764570.26152915
#body:apple #body:banana (control)0.00.0 (ScoringMatch: not reported)
#body:apple +body:banana (control)0.130764570.13076457
#body:apple body:banana (control)0.130764570.13076457
+body:apple (control)0.130764570.13076457

SIMPLE_MATCHER matches in every case (all with the default minimumNumberShouldMatch 0). A Monitor configured with a decomposer that never splits gives IndexSearcher's values.

Exposure and likelihood268 words
  • Who. lucene-monitor users with the default QueryDecomposer (MonitorConfiguration.java:38) who read scores through ScoringMatch (directly or through ParallelMatcher / PartitionMatcher, which report the same values) or ExplainingMatch, and register a BooleanQuery whose only required clause is a single FILTER clause with no SHOULD clause that yields decomposed parts (MUST_NOT clauses and boosts allowed): on its own, or nested as a SHOULD clause next to scoring clauses.
  • Where such queries come from. Built in code (for example a "must match, do not score" condition emitted as a filter-only boolean), or by the XML query parser (occurs="filter"). The classic and standard query parsers do not produce FILTER clauses.
  • Default vs opt-in. The Monitor configuration and the decomposer are the defaults; lucene-monitor is an optional module, and the matcher is chosen per match call. ScoringMatch and ExplainingMatch are two of the four documented matchers.
  • Scale. None needed: one query and one document show it, deterministically, with assertions on and off.
  • Effect. A part that should add nothing adds its full term score: FILTER-only queries get a positive score, nested ones are over-scored (here doubled), and ExplainingMatch shows a scoring explanation for a non-scoring clause. Applications that rank or threshold on Monitor scores see it. The set of matching queries is right; nothing fails.
  • How likely. Rare: the shape is uncommon (FILTER next to a MUST clause, or next to a SHOULD clause that has decomposed parts, in the same BooleanQuery is not affected), it needs a scoring matcher, and the saved searches found no user report since 2019 (weak evidence, because the wrong values look plausible).

Minimal repro

The test shows that the Monitor's default QueryDecomposer turns a FILTER-only clause into a scoring query, so ScoringMatch reports twice the true score for '(#body:apple) body:banana'.

TestMonitorFilterClauseScore.java (JUnit test)56 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.monitor;

import java.io.IOException;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.Term;
import org.apache.lucene.index.memory.MemoryIndex;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMonitorFilterClauseScore extends LuceneTestCase {

  public void testFilterOnlyClauseInDisjunction() throws IOException {
    Analyzer analyzer = new StandardAnalyzer();
    Document doc = new Document();
    doc.add(new TextField("body", "apple banana", Field.Store.NO));
    Query filterOnly =
        new BooleanQuery.Builder()
            .add(new TermQuery(new Term("body", "apple")), Occur.FILTER)
            .build();
    Query query =
        new BooleanQuery.Builder()
            .add(filterOnly, Occur.SHOULD)
            .add(new TermQuery(new Term("body", "banana")), Occur.SHOULD)
            .build();
    float searcherScore = MemoryIndex.fromDocument(doc, analyzer).search(query);
    try (Monitor monitor = new Monitor(analyzer)) {
      monitor.register(new MonitorQuery("q", query));
      ScoringMatch match = monitor.match(doc, ScoringMatch.DEFAULT_MATCHER).matches("q");
      // expected: IndexSearcher's score of (#body:apple) body:banana; actual: twice that
      assertEquals(searcherScore, match.getScore(), 0f);
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-853/, 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:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/monitor/build/libs/lucene-monitor-$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.monitor.TestMonitorFilterClauseScore
Expected vs actual
Expected IndexSearcher's score 0.13076457 (the FILTER clause does not score); actual 0.26152915.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<0.13076457> but was:<0.26152915>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:577)
       org.junit.Assert.assertEquals(Assert.java:701)
       org.apache.lucene.monitor.TestMonitorFilterClauseScore.testFilterOnlyClauseInDisjunction(TestMonitorFilterClauseScore.java:53)

Why it looks unintended

  • FILTER clauses "do not participate in scoring"; core's own one-clause rewrite scores this exact shape 0.
  • The Monitor javadoc reports "the score that each one records against a Document"; for the controls (single MUST, distinct disjunctions, two distinct FILTER clauses) the parts add up to exactly that.
  • The FILTER-as-MUST line was a 2017 matching fix; scores were never discussed.
The full argument316 words
  • Contract. BooleanClause.java:35: FILTER is "Like MUST except that these clauses do not participate in scoring". package-info.java:40-45: ScoringMatch.matchWithSimilarity "returns the set of matching queries, with the score that each one records against a Document"; ExplainingMatch is "similar to ScoringMatch, but include the full Explanation". No javadoc, CHANGES or MIGRATE entry says Monitor scores are approximate or that FILTER parts score.
  • Design. The matcher layer rebuilds the whole query's score from its parts (CandidateMatcher.resolve: "If two matches from the same query are found (for example, two branches of a disjunction), combine them"). The only upstream test that pins a Monitor score value, ConcurrentMatcherTestBase.testMatchesAreDisambiguated, expects "the sum of the scores for the 'test' and 'doc' parts" to equal the whole query's score. In the tests the rebuild is exact for the control shapes (a single MUST clause, plain and boosted disjunctions of distinct parts, FILTER next to MUST or SHOULD, two distinct FILTER clauses); DisjunctionMaxQuery shapes and repeated sub-queries are not exact, and those are the sibling bugs LFV-852 and LFV-856.
  • Core. BooleanQuery.rewrite handles the same one-clause FILTER shape as "no scoring clauses, so return a score of 0" (since 2015).
  • Tests. TestQueryDecomposer.testFilterAndShouldClause asserts only that FILTER+SHOULD is not split; no upstream test asserts the score or explanation of a FILTER-only query.
  • History. The MUST || FILTER line came from the Luwak library (PR #151, issue #150, 2017) and was imported unchanged in 2019. It fixed matching: FILTER clauses had been treated as SHOULD, so #a b was split. The review and tests never mention scores, and before that change a FILTER-only query was already split into the same scoring part, and nothing found in that history shows a decision that FILTER parts should score.
  • Inconsistent today. The stock Monitor already scores #body:apple #body:banana (two distinct FILTER clauses, kept whole) 0, but #body:apple 0.13076457: whether a FILTER-only query scores depends on a decomposer internal.
The devil's-advocate check216 words

The strongest works-as-designed case (the devil's-advocate check, the cross-model reviewer's reading and the adjudication's caveats): QueryDecomposer promises only a split ("Split a disjunction query into its constituent parts, so that they can be indexed and run separately"), the stored part is documented as "The (possibly partial due to decomposition) query", so ExplainingMatch explains what ran; grouping FILTER with MUST was deliberate; the module already changes scores elsewhere (the DisjunctionMaxQuery sibling); and ScoringMatch drops zero scores, so the stock behaviour is what makes a bare FILTER-only query appear in ScoringMatch at all. A maintainer could call it a documentation gap and point to a custom decomposer.

Why it fails: the deliberate part was about matching, and keeping the parts non-scoring keeps the same split and the same matches (the fix passes the whole module, presearcher and highlighting tests included). ExplainingMatch has no zero-score rule and contradicts IndexSearcher.explain; in the nested form the query is reported either way and its score is doubled; neither depends on the zero-score drop. "Partial query" describes the split, not a licence to change a clause's occur. Verdict: not intended; a defect since the import. Say up front that after a fix a bare FILTER-only query scores 0 and therefore no longer appears in ScoringMatch results, as two-FILTER queries already do not.

Suggested fix

Recommended A: keep the decomposed parts of a lone FILTER clause as FILTER clauses (QueryDecomposer)

When the lone required clause is FILTER, wrap each decomposed part in a FILTER-only BooleanQuery, so the parts match the same documents, keep the presearcher split and score 0 (+11/-1 in QueryDecomposer.java, plus one decomposer test). The whole monitor test suite passes with it, assertions on and off, and so do the module's static checks.

All options and trade-offs4 options · 670 words

Two fixes were built and run in a scratch worktree at a9779ef3c193; both pass the whole monitor module with the repro classes. Fix A keeps the split; fix B is smaller but keeps such queries whole.

A: keep the decomposed parts of a lone FILTER clause as FILTER clauses (QueryDecomposer) (validated by the checks) (recommended)

The finder's change, unchanged, plus TestQueryDecomposer.testSingleFilterClauseIsDecomposedIntoFilterParts, which asserts that #(hello world) becomes #hello, #world and that #hello -goodbye becomes +(#hello) -goodbye. +11/-1 in QueryDecomposer.java, +24 in the test.

Set<Query> mandatory = new HashSet<>();
Set<Query> scoringMandatory = new HashSet<>();
for (BooleanClause clause : q) {
  if (clause.occur() == BooleanClause.Occur.MUST) scoringMandatory.add(clause.query());
  ... // unchanged
}
...
if (mandatory.size() == 1) {
  Query required = mandatory.iterator().next();
  if (scoringMandatory.contains(required)) {
    subqueries.addAll(decompose(required));
  } else {
    // a FILTER clause matches but does not score: keep its parts non-scoring
    for (Query part : decompose(required)) {
      subqueries.add(new BooleanQuery.Builder().add(part, BooleanClause.Occur.FILTER).build());
    }
  }
}

Trade-offs

  • Each part matches the same documents and scores 0, so the parts add back up to IndexSearcher's score in every shape of the repro tests (not for the sibling bugs' shapes). The presearcher extracts the same terms (its query analyzer treats FILTER like MUST), highlighting reports the same terms (the module's highlighting tests pass, and an independent check compared HighlightsMatch hits with and without the fix), and the number of stored parts is unchanged.
  • A clause that is both MUST and FILTER (+a #a) still decomposes as scoring, which is its true score.
  • Visible side effect: a bare FILTER-only query now scores 0, so ScoringMatch (which keeps only scores above 0) no longer reports it, as it already does not for two-FILTER queries. ExplainingMatch and SIMPLE_MATCHER still report it.
  • Each part is wrapped in a one-clause FILTER BooleanQuery, created at registration and rewritten and run with the part at each match; not benchmarked. It does not touch the sibling decomposition bugs (DisjunctionMaxQuery sums, collapsed repeated parts).

What the checks validated

Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the whole :lucene:monitor:test with the 3 repro classes added, 35 classes, 201 tests, 0 failures, 1 skipped (a @Nightly test), with assertions on and off; all 13 repro tests that fail on stock pass. :lucene:monitor:tidy leaves the patch unchanged, and :lucene:monitor:check -x test with the fix and the primary test (google-java-format, ecj lint, forbidden APIs): BUILD SUCCESSFUL. The finder's earlier run of the same change: 180 upstream monitor tests, 0 failures.

B: keep a BooleanQuery whose only required clause is FILTER whole (QueryDecomposer) (validated by the checks)

A hasMust flag in the clause loop and one early return, if (mandatory.size() == 1 && hasMust == false) return Collections.singleton(q);, plus TestQueryDecomposer.testSingleFilterClauseIsNotDecomposed. +6 lines in QueryDecomposer.java, +13 in the test. The stored query is exactly the registered one.

Trade-offs

A FILTER clause over a disjunction, #(a b ...), becomes one stored entry instead of one per disjunct, so it runs whole whenever selected and Monitor.getDisjunctCount() drops for such queries, and the query index holds and runs fewer entries for them. Matching is unchanged. Not benchmarked.

What the checks validated

The same gradle runs: 35 classes, 201 tests, 0 failures, 1 skipped, with assertions on and off; :lucene:monitor:check -x test: BUILD SUCCESSFUL.

Run the registered query whole once per id (the DisjunctionMaxQuery sibling's broader fix)

Keep the split for the presearcher, but when any part is selected run the whole registered query. Written for the sibling bug; its whole-module run includes this bug's finder test, which passes.

Trade-offs

Also fixes the two sibling decomposition bugs, but changes every matcher and the meaning of the queries-run count, and keeps a reference to the whole query in every cache entry. A design change for maintainers to weigh.

Document it

Say that the Monitor scores the decomposed parts and that a lone FILTER clause is scored.

Trade-offs

A legitimate maintainer choice, but ExplainingMatch would keep contradicting IndexSearcher.explain. Workaround today: a QueryDecomposer subclass that returns such a query whole, set with MonitorConfiguration.setQueryDecomposer.

Downstream note

This bug changes only scores, not which queries match, so products that route or alert on Monitor matches are unaffected; products that rank or threshold on ScoringMatch scores see FILTER-only queries, or queries nesting them, over-scored.

How it was found

A planning probe saw ScoringMatch give a FILTER-only query a score where a search gives 0. The finder then compared six Monitor matchers with IndexSearcher over every small query and document in a bounded space plus 3,000 random larger queries (6.5 million checks); a decomposer variant that never splits a BooleanQuery with a FILTER clause removed this family of mismatches.

The method in full237 words

Method: bounded-exhaustive translation validation against a reference semantics (the hunt's "semi-formal" tier).

  • The hunt's planning probe for this area compared ScoringMatch with IndexSearcher and gave the first signal: #apple scored 0 in a search but 0.13076457 in the Monitor.
  • The finder's harness ran six matchers (simple, scoring, explaining, highlighting, parallel, partition) on the stock decomposer, and three of them on six decomposer variants, against IndexSearcher on a one-document index: every depth-1 query (420) on 55 documents, every depth-2 query (7,685) on 19 documents, and 3,000 random depth-3 queries on 34 documents; about 6.5 million checks with assertions on and the same with them off.
  • Root attribution with public decomposer subclasses: of the harness's named variants, the FILTER variant (it never splits a BooleanQuery with a FILTER clause, which is broader than fix A) was the only one that on its own removed 348, 5,060 and 2,468 score-mismatch rows in the three tiers, for the scoring and the explaining matcher each; further rows were removed by it together with a sibling variant. Other variants explain the other families: the two sibling bugs (DisjunctionMaxQuery sums, collapsed repeated parts) and a separately registered minimumShouldMatch bug.
  • A Z3 model of the score algebra over the reals confirmed that the single-FILTER rule preserves the score only when the clause scores 0.
  • The finder then wrote the public-API JUnit test, and independent checkers reproduced it and widened it (the nested and FILTER-over-disjunction forms).

Prior reports, history and checks

Prior reports

No prior report found (three tool runs of 51 planned queries, 45 executed each, over GitHub issues, PRs and commits, the mailing lists, the JIRA archive and downstream projects; 8 separate web searches; 31 distinct manual GitHub searches; and every open PR's file list; 2026-10-28). The FILTER-as-MUST line comes from a 2017 Luwak matching fix that never mentions scores.

Searches4 searches

Registry verdict new: the saved searches found no report or discussion of this bug by anyone else. Nearest items, none a report: Luwak #150 / #151 (2017, where the MUST || FILTER line was written, a matching fix; scores never mentioned), #9811 / LUCENE-8766 (the import; no discussion of FILTER or scores), #14886 / #14885 (same file, disjunct order across JVMs), #12295 (open, an instanceof refactor of the file), #16847 (open, touches ExplainingMatch only for Math.toIntExact), #13109 (matcher visibility), #14427 / #14660 (presearcher metadata filter fields, not Occur.FILTER), apache/solr#2382 (a downstream user of QueryDecomposer, no discussion of FILTER or scores). No upstream commit after June 2026 touches QueryDecomposer.java. Limits: keyword search only (at most 50 results per GitHub query); several rate limits were retried; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; LUCENE JIRA archive2026-10-28a scripted prior-report search, three runs of 51 planned queries each (identifiers; symptoms and matchers; history, Luwak and JIRA): 45 executed per run; each run's 6 web queries stayed pendingno report; medium hits triaged by hand
the web2026-10-288 general web searches, run separately (titles and snippets read)no relevant hit
GitHub: apache/lucene, flaxsearch/luwak, apache/solr, OpenSearch, Elasticsearch, apache/lucenenet, two Monitor-based projects, all of GitHub2026-10-2831 distinct manual searches (34 calls, 3 of them retries after rate limits), including all 50 module:monitor items and the issues and PRs opened since 2026-10-25no report; Luwak #150/#151 is the origin of the line (matching only)
apache/lucene PRs and history2026-10-28file lists of all 411 open PRs and the 100 most recently updated closed PRs; QueryDecomposer.java history on main, branch_10_6 and branch_10xno open PR changes the FILTER handling; no fix upstream

History

In the code since the Monitor was imported from the Luwak library (LUCENE-8766, 2019); first released in 8.2.0. The finder's test fails with identical values on all 57 releases from 8.2.0 to 10.5.2, on 10.6.0 (branch_10_6 4ae946e5a9b) and on main; 8.1.1 has no Monitor module. Not a regression.

Versions tested7 rows

Introduced by LUCENE-8766 (#9811, "Add Luwak as a lucene module"; commit title "Add monitor subproject"; the import of the Luwak monitoring library), by @romseygeek, author date 2019-06-13: 251dbe7cea8126cb7ecadc921ba444c63d06c618 on the main line (in 9.0.0 and later) and b8773d13ad8e249a045862249bf476c30a444461 on branch_8x (first released in 8.2.0). git log -S 'Occur.FILTER' on QueryDecomposer.java returns only that commit; later commits are refactors with no behaviour change on this path (reformat, collection sizing, record accessors, #14885 LinkedHashSet, instanceof patterns, a typo). The line itself was written in Luwak PR #151 (2017-11, a matching fix) and imported unchanged (read, not executed). Upstream main c5ab3e57b9b (2026-10-28), branch_10_6 and branch_10x have the same code; nothing has fixed it.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe 3 repro classes fail through gradle: 13 of their 19 tests (30 run, counting the 11 passing upstream TestQueryDecomposer tests), assertions on and off
branch_10_6 at 4ae946e5a9b (its head as read on 2026-10-26; released as 10.6.0 on 2026-10-29) affectedthe finder's test fails 3 of 4, -ea and -da (monitor compiled from the branch sources over the cached branch jars, with main's module dependency graph: not a clean branch build)
10.5.2, 9.12.3 affectedthe primary test fails 5 of 6; the finder's and the independent reproducer's tests fail too
8.3.0 to 10.5.1 (55 releases, 9.12.3 among them) affectedthe finder's test fails 3 of 4 on each, same values
8.2.0 first releasethe finder's test fails 3 of 4, -ea and -da
8.1.1 no modulelucene-monitor does not exist yet (no artifact; the test does not compile)
fix A on a9779ef3c193 not affectedthe whole monitor module, repros included: 201 tests, 0 failures, assertions on and off

Independent checks

An independent reproducer, a second an independent test, a devil's-advocate check and an adjudication all reproduced it on main with assertions on and off; their tests pass with the fix. The cross-model reviewer could not run code and returned its procedural default while agreeing on the root cause.

Checks and verdicts367 words

Each check ran or read on its own; the registration check re-ran the tests with fresh seeds, and this doc re-ran them through gradle on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder (bounded-exhaustive comparison, Z3 algebra, JUnit) candidateJUnit fails 3 of 4 on main (-ea, -da), branch_10_6, 10.5.2, 9.12.3 and 8.11.4; passes with the fix, and 180 upstream monitor tests stay green; the sibling fixes leave it failing.
independent reproducer realOwn test with two references (IndexSearcher, and the same Monitor with a no-split decomposer); found the nested doubled score; 4 controls pass. Fails 5 of 9 on main (-ea, -da), 10.5.2 and 9.12.3; passes 9 of 9 with the fix.
second an independent test own test failsFails 3 of 7 on main (-ea, -da) and 10.5.2, including a FILTER clause over a disjunction (0.26152915 instead of 0); controls pass.
cross-model review, refute lens not real, low (procedural); root cause agreesAn independent reviewer from another model family (OpenAI GPT-5.6) could not run any command and voted not real on procedure only; its notes call this "not a substantive refutation" and agree that the inputs are in contract. A separate run re-ran the finder's test on main with assertions off: 3 of 4 fail.
intended-behaviour devil's advocate real, medium-highBuilt the works-as-designed case (a split-only promise, deliberate FILTER grouping, the zero-score drop) and rejected it; showed core rewrites the same shape to score 0 and a no-split decomposer is exact. Own test fails 2 of 6 on main (-ea, -da) and 10.5.2.
adjudication confirmed, highRe-ran the independent reproducer's test on main: 9 run, 5 failed, same values; all controls pass.
Registration check and audit reproduces; keepFresh seeds: the finder's test 3 of 4 fail (-da and -ea), the reproducer's 5 of 9 (-ea and -da).
this doc's gradle runs stock fails, fixes passStock main: 13 of the 19 repro tests fail (30 run with the 11 passing upstream TestQueryDecomposer tests) with assertions on and off, every control passes. Fix A and fix B: the whole monitor module, 201 tests, 0 failures, on and off; static checks pass.

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

Finding record

LFV-853, snapshot of 2026-11-03

Registry title
Monitor's QueryDecomposer turns a BooleanQuery whose only required clause is a single FILTER clause into that clause as a scoring query, so ScoringMatch and ExplainingMatch report a positive score for a query (or query part) that scores 0, and twice the true score when such a query is nested in a disjunction
Area
monitor / query decomposition (QueryDecomposer.decomposeBoolean; ScoringMatch, ExplainingMatch)
Priority
Low, rank 458: Monitor reports a positive or doubled score for a FILTER-only BooleanQuery, which only affects scores and needs a rare query shape built in code.
Impact class
silent wrong results
Impact note
Applications that use lucene-monitor with its default QueryDecomposer and read scores through ScoringMatch (also through ParallelMatcher and PartitionMatcher) or ExplainingMatch, and register a BooleanQuery built in code whose only required clause is one FILTER clause with no SHOULD part (MUST_NOT clauses and boosts allowed; also nested as a SHOULD clause, and a FILTER clause over a disjunction), get a positive score (0.13076457 for '#body:apple', which IndexSearcher scores 0) or a doubled score ('(#body:apple) body:banana': 0.26152915 instead of 0.13076457); which queries match is never affected, there is no error, no scale is needed, and it is in every release since 8.2.0.
Summary
You register with the Monitor a BooleanQuery whose only required clause is a FILTER clause, for example '#body:apple' or '(#body:apple) body:banana', and ask for scores with ScoringMatch or ExplainingMatch; since FILTER clauses 'do not participate in scoring', you expect the score IndexSearcher gives the same query: 0 for '#body:apple' and 0.13076457 for the nested query on the document 'apple banana'. Instead the default QueryDecomposer treats the FILTER clause like a MUST clause and replaces the query by that clause as a plain scoring query, so ExplainingMatch and ScoringMatch report 0.13076457 for '#body:apple' (0.26152915 for '(#body:apple)^2') and 0.26152915, twice the true score, for the nested query. Matching is correct; only the reported scores and explanations are wrong.
Introduced
251dbe7cea8126cb7ecadc921ba444c63d06c618 on the main line and b8773d13ad8e249a045862249bf476c30a444461 on branch_8x, both 'LUCENE-8766: Add monitor subproject' by romseygeek (JIRA LUCENE-8766 = #9811; the review PR apache/lucene-solr#648 was closed unmerged and the code committed directly; CHANGES 8.2.0 New Features) [read: git log -S 'Occur.FILTER' on QueryDecomposer.java returns only 251dbe7cea8; the later commits 2cbf261032d, 135f3fab412, 2ee537ce160, 2527992f266, b59848c7049 and 54283150a81 are refactorings with no behaviour change on this path]. Not a regression: present since the module's first commit. Origin outside Lucene: the 'MUST || FILTER' line was written in Luwak PR flaxsearch/luwak#151 (issue #150, commit 953e19748ea, a matching fix) and imported unchanged; before it a FILTER clause fell into the SHOULD branch, so a FILTER-only query was decomposed into a scoring part there as well [read, not executed] (2019-06-13 (author date of 251dbe7cea8 and b8773d13ad8); the Luwak line 953e19748ea is dated 2017-11-05 (PR #151 merged 2017-11-07))
First affected version
8.2.0 [exec] (the first release with lucene-monitor; 8.1.1 has no monitor module: lucene-monitor-8.1.1.jar does not exist on Maven Central (HTTP 404) and the repro fails to compile against 8.1.1 only on the Monitor classes)
Affected versions
Every release that ships lucene-monitor: 8.2.0 to 8.11.4, 9.0.0 to 9.12.3 and 10.0.0 to 10.5.2 (all 57 releases executed with -ea, and -da on 8.2.0, 8.11.4, 9.12.3 and 10.5.2), the branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29; lucene-monitor compiled from the branch sources over the cached branch jars) and main a9779ef3c193 (-ea and -da) [exec: the finder's JUnit fails 3 of 4 identically on each; a ScoringMatch-only variant fails on 8.2.0, 8.11.4, 9.12.3, 10.5.2, branch_10_6 and main; the intro commit b8773d13ad8, shadow-compiled over the 8.2.0 jars, fails the same way; the independent reproduction's JUnit fails on main -ea/-da, 10.5.2 and 9.12.3]. Not fixed: no upstream commit after 54283150a81 (2026-06-14, typo fix) touches QueryDecomposer.java on main (head c5ab3e57b9b, 2026-10-28) or on branch_10_6/branch_10x after 89bd9f0ecd5 [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
Upstream and prior reports
none found (2026-10-28, read-only; the prior-report record, verdict new: three scripted prior-report searches of 51 queries each (identifiers; symptoms and matchers; history, Luwak and JIRA) over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, plus the finder's own run; about 25 manual GitHub searches over apache/lucene, flaxsearch/luwak, apache/solr, opensearch-project/OpenSearch, elastic/elasticsearch, apache/lucenenet and two downstream Monitor users; the file lists of all 411 open apache/lucene PRs and of the 100 most recently updated closed PRs; the upstream history of QueryDecomposer.java on main, branch_10_6 and branch_10x; re-check: none of the 7 apache/lucene PRs and 4 issues created since 2026-10-27 concerns the monitor (file lists of #16917, #16919, #16923 and #16926 read, the rest are two dependency bumps and a javadoc fix), no upstream commit has touched lucene/monitor since 2026-10-01 (main head c5ab3e57b9b), and gh searches for 'QueryDecomposer' (PRs) and 'monitor FILTER score' (issues) return only #14885): no issue, PR, review comment, JIRA entry, list thread or downstream report says that the decomposer turns a FILTER-only BooleanQuery into a scoring query or that ScoringMatch or ExplainingMatch report a positive or doubled score for it. Related, different: flaxsearch/luwak#150 / #151 (2017, origin of the 'MUST || FILTER' line, a matching fix; scores not discussed), #9811 / LUCENE-8766 (the import; no discussion of FILTER or scores), #14886 / #14885 (same file, disjunct order across JVMs), #12295 (open, instanceof refactor of the file), #16847 (open, touches ExplainingMatch only for Math.toIntExact), #13109, #14427 / #14660 (presearcher metadata filter fields), apache/solr#2382 (downstream user of QueryDecomposer, no discussion of FILTER or scores).
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: the 3 repro classes on stock with assertions on and off; fix A and fix B on the whole monitor module with assertions on and off, and the module's static checks; the repro runner: this doc's test on main, 10.5.2 and 9.12.3, the finder's test, the independent reproducer's, the second verifier's, the devil's-advocate test, the adjudication's and the registry re-runs, several seeds, assertions on and off); every release jar from 8.2.0 to 10.5.2 (57 releases, the finder's test) and 8.1.1 (no monitor module); the introducing commit b8773d13ad8 shadow-compiled over 8.2.0; branch_10_6 at 4ae946e5a9b (its head as read on 2026-10-26; monitor compiled from the branch sources over the cached branch jars, with main's module dependency graph). JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryDecomposer.java:30-33 (at a9779ef3c193) (class javadoc: 'Split a disjunction query into its constituent parts'), :77-117 (decomposeBoolean: :84-86 FILTER added to 'mandatory' with MUST; :93-95 keep whole if more than one required clause or one plus a SHOULD part; :97-101 'decompose the MUST clause instead'; :103-116 parts rebuilt as MUST with the MUST_NOT exclusions)
  • lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:35 (at a9779ef3c193) (FILTER: 'Like MUST except that these clauses do not participate in scoring'); lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:290 (one-clause rewrite: 'no scoring clauses, so return a score of 0')
  • lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:40-45 (at a9779ef3c193) (ScoringMatch returns 'the score that each one records against a Document'; ExplainingMatch 'similar to ScoringMatch, but include the full Explanation'); ScoringMatch.java:37 (score > 0 kept) and :43 (resolve sums parts); ExplainingMatch.java:38 (searcher.explain on the decomposed matchQuery); MonitorConfiguration.java:38 (default new QueryDecomposer()); Monitor.java:299 (the decomposed part is the final matcher's query)
The finding in 30 seconds
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/QueryDecomposer.java:77-117; QueryCacheEntry.java:51-60; Monitor.java:299; CandidateMatcher.java:75-95; ScoringMatch.java:36-44; ExplainingMatch.java:34-53
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:35; BooleanQuery.java:278-296
Who is affected
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:38; package-info.java:35-51
  • a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/BooleanQueryBuilder.java:78-79 (the only Occur.FILTER under lucene/queryparser/src/java)
Why it looks unintended
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:40-45; CandidateMatcher.java:87-95; QueryDecomposer.java:30-33; QueryCacheEntry.java:27; ScoringMatch.java:37
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:35; BooleanQuery.java:289-291
  • a Lucene main checkout lucene/monitor/src/test/org/apache/lucene/monitor/TestQueryDecomposer.java:102-113; ConcurrentMatcherTestBase.java:83-87
Suggested fix
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/QueryAnalyzer.java:76-77; Monitor.java:258-261
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:134-164

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 nested example, made in parallel with this doc.

Built 2026-11-03 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.