To match fast, the Monitor splits a stored query into parts and runs the parts instead of the query. For a query with one required clause and minimumNumberShouldMatch=1 but no SHOULD clause, the split drops the "at least one" condition and keeps only the required clause.
(+text:alpha)~1 ("alpha AND at least one of []") on the document "alpha beta". IndexSearcher rewrites it to MatchNoDocsQuery and counts 0 hits; the default decomposer stores only text:alpha, so the Monitor reports the query. With the fix the query is kept whole. Select the still to open it at full size. Generated with AI, checked against the code.- Build "alpha AND at least one of [options]" with
BooleanQuery.Builder: MUSTtext:alpha, one SHOULD clause per option,setMinimumNumberShouldMatch(1). With an empty list it is(+text:alpha)~1. Register it in a defaultMonitorand match the document "alpha beta". - You expect no match: the query needs one SHOULD clause to match and has none. IndexSearcher counts 0 hits, and core's test
testNoOptionalButMin2asserts 0 hits for this exact shape. - You get a match:
Monitor.matchreports the query. With options [beta, gamma] (1 hit) or [zzz] (0 hits) the Monitor is right; only the empty list goes wrong. - Why: the default
QueryDecomposerkeeps a query with one required clause whole only if minimumNumberShouldMatch is above 1 or a SHOULD clause decomposes to a part. Here it stores justtext:alpha, and the Monitor runs that part as the final matcher. - The same with a FILTER clause, with MUST_NOT exclusions (kept, while "at least one" is dropped), and inside a disjunction, a DisjunctionMaxQuery or a boost. Only extra matches, never misses, from 8.2.0 to 10.5.2 and on main.
Applications using lucene-monitor with its default configuration that register a BooleanQuery with one required clause, minimumNumberShouldMatch=1 and no SHOULD clause that decomposes to a part, typically "X AND at least one of [list]" built in code when the list is empty. The query is reported for every document matching X (minus exclusions). Any scale; only extra matches.
Mechanism, terms and numbers674 words
The Monitor (lucene-monitor) matches documents against stored queries. To select candidate queries quickly, its default QueryDecomposer splits a registered query into parts, stores each part under the query's id and later runs the parts instead of the query. For a BooleanQuery it keeps the query whole only when minimumNumberShouldMatch is above 1, when it has more than one distinct required clause, or when one required clause comes with a SHOULD clause that decomposes to something. Otherwise it decomposes its lone MUST or FILTER clause instead (plus the MUST_NOT exclusions), which is not valid in general when minimumNumberShouldMatch is above 0. A query with one required clause, minimumNumberShouldMatch=1 and no SHOULD clause that decomposes to a part, such as "alpha AND at least one of []" = (+text:alpha)~1, matches nothing in core (it rewrites to MatchNoDocsQuery, and core's testNoOptionalButMin2 asserts 0 hits), but the Monitor reports it for every document that contains alpha. Only extra matches occur, in every release with the module (8.2.0 to 10.6.0, 10.6.0 by reading) and on main. Keeping such a query whole (one condition) fixes it, and the whole monitor module passes with that change.
- Monitor (lucene-monitor,
org.apache.lucene.monitor): stores queries and reports which of them an incoming document matches. A presearcher first selects candidate queries by their terms; the selected queries are then run against the document. - QueryDecomposer: splits a registered query into parts that are indexed and run separately ("Split a disjunction query into its constituent parts"). Each part keeps the registered query's id, and a match of any part reports that id.
- minimumNumberShouldMatch (shown as
~1): how many SHOULD clauses must match. "If this method is used, then the specified number of clauses is required." With 1 and no SHOULD clause, nothing can match.
QueryDecomposer.decomposeBooleanreturns the query whole ifminimumNumberShouldMatch > 1(QueryDecomposer.java:78).- It sorts the clauses into required (MUST or FILTER, a
HashSet), exclusions (MUST_NOT) and the decompositions of the SHOULD clauses (:80-91). It returns the query whole if there are two or more distinct required clauses, or one plus a non-empty SHOULD decomposition (:93-95). - Otherwise, with one required clause, it decomposes that clause instead, "If we only have a single MUST clause and no SHOULD clauses" (
:97-101), and adds the exclusions back to every part (:103-116). The rewrite is not valid in general whenminimumNumberShouldMatchis above 0: with 1, the "at least one" condition is gone. QueryCacheEntry.decomposestores each part under the parent's id (QueryCacheEntry.java:51-60), andMonitor.StandardQueryCollectorruns the part as the final matcher (Monitor.java:293-299). So the wrong part is a wrong final answer, not just an extra candidate.
The trigger is exactly: minimumNumberShouldMatch 1, one distinct MUST/FILTER clause, and no SHOULD clause that decomposes to a query (none, an empty BooleanQuery, or a pure-negative one), with any MUST_NOT clauses. Such a query also leaks from inside a SHOULD disjunction, a DisjunctionMaxQuery or a BoostQuery of any boost, because those are decomposed into their children. The BooleanQuery with this shape matches nothing, but a registered query that contains it need not: text:zzz ((+text:alpha)~1) correctly matches documents with zzz, and the Monitor also reports it for "alpha beta".
Document {text: "alpha beta"}, StandardAnalyzer, IndexSearcher on a one-document index vs Monitor.match with QueryMatch.SIMPLE_MATCHER:
| query | stored as | IndexSearcher hits | Monitor |
|---|---|---|---|
(+text:alpha)~1 (alpha AND at least one of []) | text:alpha | 0 | reported |
(#text:alpha)~1 | text:alpha | 0 | reported |
(#text:alpha -text:omega)~1 | +text:alpha -text:omega | 0 | reported |
text:zzz ((+text:alpha)~1) | text:zzz, text:alpha | 0 | reported |
DisjunctionMaxQuery of (+text:alpha)~1 and text:zzz | text:alpha, text:zzz | 0 | reported |
(+text:alpha text:beta text:gamma)~1 | kept whole | 1 | reported |
(+text:alpha text:zzz)~1 | kept whole | 0 | not reported |
+text:alpha | text:alpha | 1 | reported |
In the finder's bounded comparison (46,233 queries with up to three leaves, 40 tiny documents), 56,903 of the 1,849,320 (query, document) checks were false positives in each of 202 runs (200 presearcher configurations, two of them repeated end to end), and 0 were misses; with the fix, the three end-to-end re-runs had 0 and 0.
Exposure and likelihood241 words
- Who. lucene-monitor users with the default
QueryDecomposer(MonitorConfiguration.java:38) who register such a query: built withBooleanQuery.Builder(one SHOULD clause per list element andsetMinimumNumberShouldMatch(1)), or from an XML query parser template, which takes aminimumNumberShouldMatchattribute. The list is empty, for example a subscriber with no groups or a filter with no selected values. - Default vs opt-in. Default Monitor configuration and decomposer; lucene-monitor is an optional module. The checks ran the simple matcher only; by reading, all built-in matchers are affected, because each is handed the stored part to run. Read-only Monitors decompose the stored query again at match time with the same decomposer.
- Parsers. The classic, multi-field and simple query parsers cannot express minimumNumberShouldMatch (0 false positives in 1,500 random strings each). The flexible
StandardQueryParsercan, with@N, which it documents for pure disjunction groups only;(+alpha -omega)@1still reaches the bug. - Scale. None needed: one query and one document, deterministic, with assertions on and off.
- Effect. Silent extra matches, caused by a BooleanQuery that matches nothing in core (a registered query that contains one can still match other documents correctly); nothing fails or is corrupted, and IndexSearcher is unaffected.
- How likely. Rare: it needs exactly minimumNumberShouldMatch 1 with one required clause and an optional part that decomposes to nothing (typically an empty list). The two public Monitor users found parse queries with the classic parser, and no report was found in the public sources searched.
The test shows that a default Monitor reports (+text:alpha)~1, a BooleanQuery with one required clause, minimumNumberShouldMatch=1 and no SHOULD clause, as matching the document alpha beta, although core Lucene defines that query to match nothing (QueryDecomposer drops the msm=1 requirement).
/*
* 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 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.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 TestMonitorMinShouldMatchSingleRequiredClause extends LuceneTestCase {
public void testRequiredClauseWithMinShouldMatchAndNoShouldClause() throws Exception {
Query query =
new BooleanQuery.Builder()
.add(new TermQuery(new Term("text", "alpha")), Occur.MUST)
.setMinimumNumberShouldMatch(1)
.build();
Document doc = new Document();
doc.add(new TextField("text", "alpha beta", Field.Store.NO));
try (Monitor monitor = new Monitor(new StandardAnalyzer())) {
monitor.register(new MonitorQuery("q", query));
// (+text:alpha)~1 matches nothing (no SHOULD clause), so expected 0 matches; actual: 1
assertEquals(0, monitor.match(doc, QueryMatch.SIMPLE_MATCHER).getMatchCount());
}
}
}
# in a copy of this finding's repro folder (repro/LFV-325/, 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.TestMonitorMinShouldMatchSingleRequiredClause
- Expected vs actual
- Expected 0 matches; actual 1 (
expected:<0> but was:<1>), with-eaand-da; passes with the doc's one-conditionQueryDecomposerfix.
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> 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.junit.Assert.assertEquals(Assert.java:633)
org.apache.lucene.monitor.TestMonitorMinShouldMatchSingleRequiredClause.testRequiredClauseWithMinShouldMatchAndNoShouldClause(TestMonitorMinShouldMatchSingleRequiredClause.java:43)setMinimumNumberShouldMatch: "the specified number of clauses is required"; core rewrites this query to MatchNoDocsQuery, andtestNoOptionalButMin2asserts 0 hits.- Core made the same reduction in
BooleanQuery.rewriteand fixed it as a bug in 2008 (LUCENE-1134). - The decomposer already keeps minimumNumberShouldMatch > 1 whole; nothing documents that splitting may change which queries match.
The full argument282 words
- BooleanQuery contract.
BooleanQuery.java:50-62: "Specifies a minimum number of the optional BooleanClauses which must be satisfied ... If this method is used, then the specified number of clauses is required."rewritereturnsMatchNoDocsQuery("SHOULD clause count less than minimumNumberShouldMatch")(:570-576); its one-clause shortcut fires only for 0, or for 1 with a SHOULD clause (:278-297). - Core tests.
TestBooleanMinShouldMatch.testNoOptionalButMin2builds exactly one MUST clause with minimum 1 and asserts 0 hits;testNoOptionalButMindoes the same with two MUST clauses. - Monitor contract. The package javadoc: the simple matcher "just returns the set of query ids that a Document has matched"; candidates "are then run against the document", so the Monitor "can avoid running queries that have no chance of matching".
QueryDecomposer: parts "can be indexed and run separately", which needs the parts together to match what the query matches. - The code already cares. The guard at
QueryDecomposer.java:78keeps minimum > 1 whole; the comment at:97-98("a single MUST clause and no SHOULD clauses") states a rewrite that is not valid in general for a minimum above 0. No monitor test covers minimum 1 with a required clause. - Precedent. LUCENE-1134 (2008): core
BooleanQuery.rewritereduced a single-clause query to its clause while ignoring the minimum; fixed as a bug (CHANGES 2.4.0: "only optimize a single clause query if minNumShouldMatch<=0"), addingtestNoOptionalButMin2. - History. In the predecessor library (Luwak), the decomposer first kept any query with a MUST clause whole, where the minimum > 1 guard was enough; a 2014 commit ("Allow single-valued conjunctions to be decomposed") added the single-MUST branch without revisiting the guard. Luwak treated a lost minimum elsewhere as a bug (#122 / #123). No by-design discussion was found.
The devil's-advocate check221 words
The strongest works-as-designed case (the devil's advocate, the cross-model reviewer and the adjudication): the triggering BooleanQuery matches nothing, so it is degenerate input and arguably a caller mistake; the decomposer is a documented, pluggable heuristic; the comment shows the single-MUST split was deliberate; and the classic parser cannot build the shape, while the flexible parser documents @N for pure disjunctions only.
Why it fails: nothing documents that decomposition may change which queries match or that registered queries must be pre-rewritten, and the default Monitor contradicts IndexSearcher on the same query and document. The registered query need not be degenerate: nested in a disjunction, a parent that can match gains extra matches. The shape is legal and tested in core and comes naturally from code that adds one SHOULD clause per list element; in LUCENE-10618 @jpountz noted that such queries "can also happen to non-degenerate queries if some SHOULD clauses rewrite to a MatchNoDocsQuery" (about core's rewrite; the Monitor decomposes the query before any rewrite). The split's premise is false whenever the minimum is above 0, and core fixed the identical reduction as a bug. A pluggable decomposer is a workaround, not a contract. Verdict: not intended; a defect since the import. Expect pushback on severity, not on correctness; maintainers may also prefer another fix, such as rejecting such queries at registration.
Recommended Keep a BooleanQuery with one required clause and minimumNumberShouldMatch > 0 whole (QueryDecomposer)
In QueryDecomposer.decomposeBoolean, also keep the query whole when it has one required clause and minimumNumberShouldMatch > 0 (one condition, plus a decomposer unit test). The Monitor then runs the original query, which core evaluates to no hits. The whole monitor module passes (193 tests) with assertions on and off, and its static checks pass.
// More than one MUST clause, or a single MUST clause with disjunctions, or a single MUST
// clause with minimumNumberShouldMatch > 0: then the SHOULD clauses are required, and with no
// SHOULD clause that can match, the query matches nothing
if (mandatory.size() > 1
|| (mandatory.size() == 1
&& (subqueries.size() > 0 || q.getMinimumNumberShouldMatch() > 0)))
return Collections.singleton(q);All options and trade-offs5 options · 696 words
One fix was built and run in a scratch worktree at a9779ef3c193; a no-change workaround was run on stock main. The alternatives were not run.
Keep a BooleanQuery with one required clause and minimumNumberShouldMatch > 0 whole (QueryDecomposer) (validated by the checks) (recommended)
The finder's one-condition fix with reworded comments, plus testSingleRequiredClauseWithMinShouldMatchIsNotDecomposed in TestQueryDecomposer (MUST; FILTER with an exclusion; nested in a disjunction, which is still split). +8/-4 lines in QueryDecomposer.java (comments included), +29 in the test.
// More than one MUST clause, or a single MUST clause with disjunctions, or a single MUST
// clause with minimumNumberShouldMatch > 0: then the SHOULD clauses are required, and with no
// SHOULD clause that can match, the query matches nothing
if (mandatory.size() > 1
|| (mandatory.size() == 1
&& (subqueries.size() > 0 || q.getMinimumNumberShouldMatch() > 0)))
return Collections.singleton(q);Trade-offs
- Only the decomposition of a BooleanQuery that matches nothing changes (and so the parts of a registered query that contains one); minimum 0 and the shapes already kept whole are untouched. Cost: expected to be small for this rare shape (when selected, the whole query is rewritten and run instead of the bare required clause); not benchmarked.
- Upgrade note (read, not run): the stored parts, and so the cache ids, of an affected query change. A writable Monitor re-decomposes stored queries when it opens a persisted index and skips index entries with unknown cache ids; a read-only Monitor over an index written before the fix looks the old cache id up in a fresh decomposition and throws "Cached queries not found" if it is gone. That needs an affected query whose required clause split into two or more parts, such as
(+(a b))~1; re-registering such queries clears it, but only through a writable Monitor over the same index (a read-only Monitor cannot commit). Until then a writable Monitor keeps the old index rows, looked up by their old cache ids, so if the part count of such a query changed, the other parts of a parent query can be missed.
What the checks validated
Gradle in a scratch worktree of a9779ef3c193 (as its script states; the repro runner records that commit for the same primary-test result), seed DEADBEEF: the whole :lucene:monitor:test with the 5 repro classes added, 37 classes, 193 tests, 0 failures, 1 skipped (a @Nightly test), with assertions on and off; all 10 repro tests that fail on stock pass. :lucene:monitor:check -x test with the fix and the primary test (google-java-format, ecj lint, forbidden APIs): BUILD SUCCESSFUL. Earlier, the finder's bounded comparison with the fix: 0 false positives and 0 misses.
if (msm > 1 || (msm > 0 && <any MUST or FILTER clause>)) return Collections.singleton(q);
Trade-offs
Same results by reading. It scans the clauses once more but returns such a query before decomposing its SHOULD clauses, so its cost compared with the recommended fix is not established. Not run.
Return an empty decomposition when the minimum cannot be met.
Trade-offs
Cheaper, but it must re-implement core's rules for which SHOULD clauses can match (empty and pure-negative nested queries) and changes what getDisjunctCount() reports. A query with no part also disappears from getQueryIds() and getQueryCount(), and updating an existing id to no part leaves its old index rows in place (WritableQueryIndex.java:107-127, 185-203). Keeping the query whole lets core decide. Not run.
register throws for a BooleanQuery that cannot match.
Trade-offs
An API decision for the maintainers. It has to find such a node anywhere in the query, and it would also refuse a parent such as text:zzz ((+text:alpha)~1), which can match. Not run.
A QueryDecomposer subclass whose decomposeBoolean returns the query whole when getMinimumNumberShouldMatch() > 0 and it has a MUST or FILTER clause, else calls super, set with new MonitorConfiguration().setQueryDecomposer(...). No Lucene change.
Trade-offs
The primary test's six checks pass with it on stock main. Every application has to know about it; not registering "X AND at least one of []" at all also avoids the bug (not run).
Only extra matches: an application that writes a restriction as "topic AND at least one of the user's groups" (one SHOULD clause per group, minimumNumberShouldMatch 1) would report every topic document for a user with no groups, which IndexSearcher correctly refuses.
An end-to-end comparison of the Monitor with IndexSearcher over 46,233 queries (up to three leaves), 40 tiny documents and 200 presearcher configurations (202 runs) found 56,903 false positives per run and no misses. The fix's end-to-end re-runs found none, so each came from a BooleanQuery with one required clause and minimumNumberShouldMatch=1, registered or nested.
The method in full239 words
Method: bounded-exhaustive translation validation against a reference semantics (the hunt's "semi-formal" tier).
- The finder's harness enumerated every distinct query of a grammar with at most three leaves (BooleanQuery with MUST, SHOULD, FILTER and MUST_NOT clauses and minimumNumberShouldMatch 0 to 2, DisjunctionMaxQuery, ConstantScoreQuery, BoostQuery, nested): 46,233 queries, on 40 documents (token sequences over three terms, length 0 to 3).
- For each of 200 presearcher configurations it registered the queries in a Monitor, recorded which parts
Monitor.matchselects and runs (a recording matcher), evaluated each such part with IndexSearcher on the document, and compared the result with the exact answer from IndexSearcher for the whole query: 1,849,320 checks per run. Two of the configurations were also run end to end throughMonitor.matchwith the simple matcher, asserting that both ways agree (202 runs in all). - Result: 56,903 false positives in every run and 0 misses. Two deliberately broken variants (a presearcher that drops terms, a decomposer that drops exclusions) were caught, which shows the comparison is not vacuous.
- With the one-condition decomposer fix, the end-to-end re-runs (two presearcher configurations and a control) had 0 false positives and 0 misses. (The stock summary also lists a prototype control decomposer with 1,464 false positives: an earlier version of that prototype, whose witness, a pure-negative SHOULD clause, the current version keeps whole; on the stock jar the current version gives 0.)
- The finder then wrote the public-API JUnit test, and independent checkers reproduced it.
No prior report found (over 100 recorded searches of GitHub issues and PRs, every open PR's file list, the mailing lists, the JIRA archive, the predecessor library's tracker, downstream projects and the web, 2026-10-26; refreshed 2026-10-28). The closest item is LUCENE-1134 (2008), the same mistake in core, fixed.
Searches4 searches
Registry verdict new: nobody else has reported or discussed this bug. Nearest items, none a report: LUCENE-1134 / #2211 (2008, the same reduction in core BooleanQuery.rewrite, fixed as a bug; a precedent), LUCENE-10618 / #11654 (core rewrites fewer SHOULD clauses than the minimum to MatchNoDocsQuery), Luwak #122 / #123 (a lost minimum in Luwak's SpanRewriter), Luwak #150 / #151 (FILTER counted as required in the same method; the minimum guard untouched), #14886 / #14885 / #14967 (same file, disjunct order across JVMs), #14427 (a presearcher miss, a different Monitor defect), apache/solr#2382 (an open Monitor integration, no discussion of the minimum), elastic/elasticsearch#14167 (minimum_should_match without optional clauses in Elasticsearch's own DSL, different code). Not fixed upstream: main, branch_10_6 and branch_10x have the same lines on 2026-10-28, and the only open PR touching the file on 2026-10-26 (#12295) is a refactor. Limits: GitHub search does not index diffs (open PRs were checked by file list); Luwak discussion outside its GitHub tracker was not searched.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; web | 2026-10-26 | a scripted prior-report search: 5 GitHub, 40 mailing-list and 8 JIRA queries; 6 web searches (QueryDecomposer, decomposeBoolean, getMinimumNumberShouldMatch, monitor/luwak/presearcher with minimumNumberShouldMatch or minimum should match, false positive) | only #14886 / #14885 (a different defect) |
| GitHub GraphQL: apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, flaxsearch/luwak, apache/lucenenet, all of GitHub | 2026-10-26 | 48 searches over issues and PRs, titles, bodies and comments; the file lists of all 411 open apache/lucene PRs; the upstream history of QueryDecomposer.java | no report; #12295 touches the file (refactor) |
| Luwak tracker; JIRA LUCENE-8766, -9791, -9869, -10422 | 2026-10-26 | all 191 Luwak issue and PR titles, the relevant ones read; the JIRA texts grepped | related but different: Luwak #122 / #123, #150 / #151 |
| apache/lucene issues and PRs; upstream branches | 2026-10-28 | gh search for QueryDecomposer, monitor minimumNumberShouldMatch, decomposeBoolean, monitor minShouldMatch; main, branch_10_6 and branch_10x read through the contents and commits API | no new item (only #14886 and #14885); the same lines on all three branches; newest commit on the file: 54283150a816 on main, 89bd9f0ecd5b (the #14885 backport) on branch_10x and branch_10_6 |
In the code since the Monitor was imported from the Luwak library (LUCENE-8766, 251dbe7cea8 and the 8.x commit b8773d13ad8, 2019-06-13); first released in 8.2.0. A version-portable check fails the same way on 10 releases from 8.2.0 to 10.5.2 and on main (11 releases run in all); 8.1.1 has no Monitor module.
Versions tested7 rows
Introduced by LUCENE-8766 "Add monitor subproject" (#9811; the import of the Luwak monitoring library), by @romseygeek, author date 2019-06-13: 251dbe7cea8126cb7ecadc921ba444c63d06c618 on the main line (first tag 9.0.0) and b8773d13ad8e249a045862249bf476c30a444461 on branch_8x (identical QueryDecomposer.java; first released in 8.2.0). git log -S / -G on the guard and the single-MUST condition return only that commit; later edits to the method are cosmetic (a reformat, record accessors, set construction, #14885 insertion order). The defect is older than the import: Luwak commit 4230bb4d12 (2014) added the single-MUST branch (read, not executed). Upstream main, branch_10_6 and branch_10x have the same lines on 2026-10-28; nothing has fixed it.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | 10 of the 11 tests of the 5 repro classes fail through gradle, assertions on and off |
| 10.5.2, 9.12.3 | affected | the primary test fails 5 of 6; the finder's and the independent reproducer's tests fail |
| 10.3.2, 10.0.0, 9.5.0, 9.1.0, 9.0.0 | affected | the version-portable check reports the same three false positives, 0 misses (on 9.1.0 the finder's test fails instead) |
| 8.11.4, 8.8.2, 8.5.0 | affected | the version-portable check: the same three false positives |
| 8.2.0 | first release | the version-portable check fails, -ea and -da |
| 8.1.1 | no module | lucene-monitor does not exist yet (no artifact; the check does not compile) |
fix on a9779ef3c193 | not affected | the whole monitor module, repros included: 193 tests, 0 failures, assertions on and off |
An independent reproducer, a devil's-advocate check with its own test, the AI tool that drove a cross-model review, an adjudication and an audit all reproduced it on main with assertions on and off, and every test passes with the fix. Both other-family reviews could only read code; one agreed it is real, the other returned a procedural default.
Checks and verdicts335 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 in a scratch worktree of a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder (bounded-exhaustive comparison, JUnit) | candidate | 56,903 false positives per configuration, 0 misses; JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the one-condition fix. |
| independent reproducer | real | Own test in its own package with a regular-index reference: seven shapes (FILTER-only, nested in a disjunction, in a DisjunctionMaxQuery, boosted), five controls; a flexible parser string (+alpha -omega)@1 reaches it. Fails on main, 10.5.2 and 9.12.3 and in main() form on 8.2.0 and 8.11.4; passes with the fix. |
| 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: "not substantive refutation evidence. Static evidence strongly favors a genuine default-Monitor false positive." A separate run re-ran the finder's test: it fails. |
| cross-model review, works-as-designed lens | real, medium | The same model family, reading only: intended argument weak, inputs in contract, root cause agrees. The AI tool that drove it re-ran the finder's test on main (-ea, -da) and 10.5.2 (fails) and with the fix (passes). |
| intended-behaviour devil's advocate | real | Own test ("required apple AND at least one of []"): a Monitor whose decomposer keeps every query whole is right, which isolates the decomposer; found the LUCENE-1134 precedent and the Luwak history. |
| adjudication | confirmed, high | Re-ran the independent reproducer's test (-ea and -da): 2 of 2 fail with the same 7 false positives, misses=[]; passes with the fix. |
| this doc's gradle runs | stock fails, fix passes | Stock main: 10 of the 11 repro tests fail with assertions on and off, every control passes (upstream TestQueryDecomposer, run alongside, 11 of 11). Fix: the whole monitor module, 193 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 554 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,390 words
- Registry title
- Monitor's QueryDecomposer ignores minimumNumberShouldMatch=1 when a BooleanQuery has one required clause and no usable SHOULD clause, so Monitor.match reports queries that match nothing
- Area
- monitor / query decomposition (QueryDecomposer.decomposeBoolean)
- Priority
- Low, rank 473: Default Monitor reports false positives for an msm=1 BooleanQuery that has one required clause and no usable SHOULD clause, a shape that mostly comes from empty lists, so it stays with the rare-input silent rows.
- Impact class
- silent wrong results
- Impact note
- Applications that use lucene-monitor with its default configuration (default presearcher and QueryDecomposer) and register a BooleanQuery with minimumNumberShouldMatch=1, exactly one distinct MUST/FILTER clause and no SHOULD clause that decomposes to a query (none, an empty BooleanQuery or a pure-negative one; also when such a query is nested in a SHOULD disjunction, a DisjunctionMaxQuery or a non-unit BoostQuery) get it reported as matching every document that its required clause matches (minus its MUST_NOT exclusions), although IndexSearcher finds 0 hits for it; only false positives, no misses, no scale needed, in every release since 8.2.0.
- Summary
- You register with the Monitor a BooleanQuery that has one required clause and minimumNumberShouldMatch=1 but no SHOULD clause that can match, for example 'topic AND at least one of [list]' built from an empty list, (+text:alpha)~1. Core Lucene defines this query to match nothing (BooleanQuery.rewrite returns MatchNoDocsQuery and IndexSearcher counts 0 hits), so you expect the Monitor never to report it. Instead the default QueryDecomposer keeps such a query whole only when minimumNumberShouldMatch > 1 and otherwise replaces it by its lone required clause (plus its MUST_NOT exclusions), which the Monitor runs as the final matcher, so Monitor.match reports the query for every document containing alpha, for example 'alpha beta'.
- Introduced
- 251dbe7cea8126cb7ecadc921ba444c63d06c618 on the main line and b8773d13ad8e249a045862249bf476c30a444461 on the 8.x line (identical QueryDecomposer.java), both 'LUCENE-8766: Add monitor subproject' by romseygeek (apache/lucene-solr#648, closed without a GitHub merge and committed directly; JIRA LUCENE-8766, fix version 8.2) [read: git log -S'getMinimumNumberShouldMatch() > 1' and -G'mandatory.size\(\) == 1' on lucene/monitor return only 251dbe7cea8; git blame of lines 78 and 93-101; git log -L on decomposeBoolean shows only cosmetic edits since (2cbf261032d, 135f3fab412, 2ee537ce160, 2527992f266)]. Not a regression: present since the module's first release. Origin outside Lucene: the module is an import of the Luwak library, where commit 4230bb4d12 ('Allow single-valued conjunctions to be decomposed') added the single-MUST branch without revisiting the msm > 1 guard; the previous version 5d75003e4e returned any query with a MUST clause whole [read through the GitHub API, not executed] (2019-06-13 (251dbe7cea8 and b8773d13ad8, author date); the Luwak origin 4230bb4d12 is dated 2014-10-17)
- First affected version
- 8.2.0 [exec] (first release with lucene-monitor; 8.1.1, the release before, has no Monitor API: lucene-monitor-8.1.1.jar does not exist on Maven Central (HTTP 404) and the main() check does not compile against 8.1.1 core)
- Affected versions
- every release that ships lucene-monitor: 8.2.0 to 8.11.4, 9.0.0 to 9.12.3, 10.0.0 to 10.6.0 (10.6.0 by reading), and main ea57360912ed. [exec] the follow-up's version-portable main() check (public API, IndexSearcher oracle) fails with exit 1 on 8.2.0 (-ea and -da), 8.5.0, 8.8.2, 8.11.4, 9.0.0 (-ea and -da), 9.5.0, 9.12.3, 10.0.0, 10.3.2, 10.5.2 (-ea and -da) and main (-ea and -da), always with the same three false positives and 0 misses; the finder's JUnit fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) (compile_error on 9.0.0, whose test framework is in another package); the independent reproduction's JUnit fails on 9.12.3, 10.5.2 (-ea and -da) and main (-ea and -da) and its main() form on 8.2.0, 8.11.4 and main; the intended-behaviour check's JUnit fails on 10.5.2 and main. [read] all other 8.x, 9.x and 10.x releases (the decomposeBoolean logic is unchanged per git log -S/-G/-L and a normalised body hash at the 8.2.0, 8.8.1, 9.0.0, 9.12.3 and 10.0.0 tags; not each run). Not fixed: no upstream commit after 251dbe7cea8 changes the logic (the newest commit touching the file is 54283150a816, 2026-06-14, a javadoc typo fix) and no open PR changes it (2026-10-26).
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, the prior-report record, verdict new: a scripted prior-report search with 5 GitHub issue/PR queries on apache/lucene, 40 mailing-list queries over dev@, java-user@, issues@ and commits@ and 8 JIRA-archive queries; 48 GraphQL issue/PR searches over apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, flaxsearch/luwak, apache/lucenenet and all of GitHub; the touched-file lists of all 411 open apache/lucene PRs; the upstream history of QueryDecomposer.java; the text of LUCENE-8766, -9791, -9869 and -10422; all 191 Luwak issue and PR titles; 6 web searches; plus the intended-behaviour check's earlier 24-query list and JIRA run); not fixed upstream (the newest commit touching QueryDecomposer.java is 54283150a816, 2026-06-14, a javadoc typo fix; the only open PR touching the file, #12295, is an instanceof refactor, diff read). Related items, not reports of this bug: LUCENE-1134 / #2211 (2008: the same single-clause reduction ignoring minimumNumberShouldMatch, in core BooleanQuery.rewrite, fixed as a bug with testNoOptionalButMin2; precedent), LUCENE-10618 / #11654 (core rewrites fewer SHOULD clauses than minimumNumberShouldMatch to MatchNoDocsQuery), Luwak #122 / #123 (lost minimumNumberShouldMatch in Luwak's SpanRewriter), Luwak #150 / #151 (FILTER as mandatory in the same method, msm guard untouched), #14886 / #14885 / #14967 (same file, disjunct order across JVMs), #14427 (a different Monitor defect, a presearcher miss), apache/solr#2382 (open lucene-monitor integration, no msm discussion), elastic/elasticsearch#14167 (minimum_should_match with no optional clauses in Elasticsearch's own DSL, different code)
- Lucene commit tested
- main
a9779ef3c193(gradle in a scratch worktree of that commit, as its script states: the 5 repro classes on stock with assertions on and off; the fix on the whole monitor module with assertions on and off, and the module's static checks; the repro runner: the primary test with assertions on and off, a decomposer-subclass workaround, and the registry re-runs); mainea57360912ed(the hunt, its verifiers, reviews, adjudication, audit and follow-up, assertions on and off); the release jars 8.2.0, 8.5.0, 8.8.2, 8.11.4, 9.0.0, 9.1.0, 9.5.0, 9.12.3, 10.0.0, 10.3.2 and 10.5.2, and 8.1.1 (no monitor module). JDK 25.0.3, Linux aarch64.
- Finding record
lucene/monitor/src/java/org/apache/lucene/monitor/QueryDecomposer.java:30-33 (class javadoc 'Split a disjunction query into its constituent parts, so that they can be indexed and run separately'), :78 (only msm > 1 keeps the query whole), :93-95 (kept whole for > 1 required clause or one plus a non-empty SHOULD decomposition), :97-101 (single required clause decomposed alone), :103-116 (MUST_NOT exclusions re-added)lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-59 (one entry per decomposed part under the parent id) and Monitor.java:293-299 (StandardQueryCollector runs query.matchQuery as the final matcher)lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:38 (default QueryDecomposer), :111 (setQueryDecomposer) and package-info.java:53-63 (queries selected by the presearcher 'are then run against the document')lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:49-62 (setMinimumNumberShouldMatch javadoc: 'If this method is used, then the specified number of clauses is required'), :575 (MatchNoDocsQuery 'SHOULD clause count less than minimumNumberShouldMatch')lucene/core/src/test/org/apache/lucene/search/TestBooleanMinShouldMatch.java:301-310 (testNoOptionalButMin2: one MUST clause, msm 1, 0 hits)
- The finding in 30 seconds
a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/QueryDecomposer.java:30-42, 46-69, 77-117; QueryCacheEntry.java:51-60; Monitor.java:293-299; package-info.java:53-63a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:50-62, 570-576
- Who is affected
a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:38, 111; Monitor.java:299; ReadonlyQueryIndex.java:186-195a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/BooleanQueryBuilder.java:46; flexible/standard/StandardQueryParser.java:188-202
- Why it looks unintended
a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:50-62, 278-297, 570-576a Lucene main checkout lucene/core/src/test/org/apache/lucene/search/TestBooleanMinShouldMatch.java:289-310a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:38-39, 53-63; QueryDecomposer.java:30-42, 78, 97-98a Lucene main checkout lucene/monitor/src/test/org/apache/lucene/monitor/TestQueryDecomposer.java:67-70; TestHighlightingMatcher.java:481 (the only minimumNumberShouldMatch in the monitor tests: 2, on a pure disjunction)
- Suggested fix
a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:83, 137-170, 382-385; Monitor.java:257-262, 296; ReadonlyQueryIndex.java:186-195 (upgrade note, read)a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:268-279; WritableQueryIndex.java:107-127, 185-203; ReadonlyQueryIndex.java:67-70 (read: the store-nothing consequences and the read-only commit)
- History
a Lucene main checkout lucene/CHANGES.txt:6328-6330 (LUCENE-8766 in 8.2.0)
- TestMonitorMinShouldMatchSingleRequiredClause.java (JUnit test):
repro/LFV-325/src/org/apache/lucene/monitor/TestMonitorMinShouldMatchSingleRequiredClause.java(46 lines)
A step-by-step picture of the repro's example, made in parallel with this doc; every value on screen comes from an executed probe on main a9779ef3c193, with assertions on and off. The PNG is the final summary card.
assets/LFV-325/bug.png(554 KB)
