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.
(#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.- Register
#body:apple(a BooleanQuery whose only clause is FILTERbody:apple) in a defaultMonitor, and match the document body "apple banana" withExplainingMatch.MATCHERorScoringMatch.DEFAULT_MATCHER(BM25). - You expect IndexSearcher's score, 0: FILTER clauses "do not participate in scoring". You get 0.13076457, the score of
body:applealone.(#body:apple)^2gets 0.26152915, and#(body:apple body:banana)gets 0.26152915, the sum of both terms. - Nest it:
(#body:apple) body:bananashould 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. - Why: the default
QueryDecomposertreats 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. - 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.
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.
- Monitor (lucene-monitor,
org.apache.lucene.monitor): stores queries and matches incoming documents against them. A matcher chosen permatchcall decides what is reported:SIMPLE_MATCHER(query ids),ScoringMatch(ids and scores),ExplainingMatch(ids and anExplanation),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").
decomposeBooleanputs FILTER clauses into the samemandatoryset as MUST clauses (QueryDecomposer.java:84-86).- 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. - 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:appleis stored as the plainbody:apple. With MUST_NOT clauses each part is rebuilt as+part -exclusion(:103-116), still scoring. - Each part runs on its own as the matcher's query (
Monitor.java:299).ScoringMatchcollects its score andExplainingMatchexplains it (ExplainingMatch.java:38); two matches of one id are merged byresolve, 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.
Document body "apple banana", BM25, IndexSearcher on a one-document index vs the default Monitor:
| registered query | IndexSearcher | ScoringMatch / ExplainingMatch |
|---|---|---|
#body:apple | 0.0 | 0.13076457 |
#body:apple -body:kiwi | 0.0 | 0.13076457 |
(#body:apple)^2.0 | 0.0 | 0.26152915 |
#(body:apple body:banana) | 0.0 | 0.26152915 |
(#body:apple) body:banana | 0.13076457 | 0.26152915 |
#body:apple #body:banana (control) | 0.0 | 0.0 (ScoringMatch: not reported) |
#body:apple +body:banana (control) | 0.13076457 | 0.13076457 |
#body:apple body:banana (control) | 0.13076457 | 0.13076457 |
+body:apple (control) | 0.13076457 | 0.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 throughScoringMatch(directly or throughParallelMatcher/PartitionMatcher, which report the same values) orExplainingMatch, 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
matchcall.ScoringMatchandExplainingMatchare 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
ExplainingMatchshows 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).
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'.
/*
* 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);
}
}
}
# 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.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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";ExplainingMatchis "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.rewritehandles the same one-clause FILTER shape as "no scoring clauses, so return a score of 0" (since 2015). - Tests.
TestQueryDecomposer.testFilterAndShouldClauseasserts only that FILTER+SHOULD is not split; no upstream test asserts the score or explanation of a FILTER-only query. - History. The
MUST || FILTERline 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 bwas 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:apple0.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.
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
HighlightsMatchhits 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.ExplainingMatchandSIMPLE_MATCHERstill 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.
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.
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.
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.
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
ScoringMatchwith IndexSearcher and gave the first signal:#applescored 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).
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; LUCENE JIRA archive | 2026-10-28 | a 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 pending | no report; medium hits triaged by hand |
| the web | 2026-10-28 | 8 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 GitHub | 2026-10-28 | 31 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-25 | no report; Luwak #150/#151 is the origin of the line (matching only) |
| apache/lucene PRs and history | 2026-10-28 | file lists of all 411 open PRs and the 100 most recently updated closed PRs; QueryDecomposer.java history on main, branch_10_6 and branch_10x | no open PR changes the FILTER handling; no fix upstream |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the 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) | affected | the 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 | affected | the 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) | affected | the finder's test fails 3 of 4 on each, same values |
| 8.2.0 | first release | the finder's test fails 3 of 4, -ea and -da |
| 8.1.1 | no module | lucene-monitor does not exist yet (no artifact; the test does not compile) |
fix A on a9779ef3c193 | not affected | the whole monitor module, repros included: 201 tests, 0 failures, assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (bounded-exhaustive comparison, Z3 algebra, JUnit) | candidate | JUnit 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 | real | Own 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 fails | Fails 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 agrees | An 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-high | Built 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, high | Re-ran the independent reproducer's test on main: 9 run, 5 failed, same values; all controls pass. |
| Registration check and audit | reproduces; keep | Fresh 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 pass | Stock 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. |
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.
- PNG 559 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,367 words
- 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 commitb8773d13ad8shadow-compiled over 8.2.0; branch_10_6 at4ae946e5a9b(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.
- 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-53a 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-51a 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:37a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:35; BooleanQuery.java:289-291a 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-261a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:134-164
- TestMonitorFilterClauseScore.java (JUnit test):
repro/LFV-853/src/org/apache/lucene/monitor/TestMonitorFilterClauseScore.java(56 lines)
A step-by-step picture of the repro's nested example, made in parallel with this doc.
assets/LFV-853/bug.png(559 KB)
