All findings · Lucene finding · LFV-325 · monitor-decomposer-drops-msm-single-required-clause

Monitor reports a query with one required clause and minimumNumberShouldMatch=1 that matches nothing

Register "alpha AND at least one of []", that is (+text:alpha)~1, in a default Monitor and it is reported for every document containing alpha, although IndexSearcher finds 0 hits. The default QueryDecomposer keeps only the required clause; every release since 8.2.0 is affected.

Summary

Priority
Low, rank 473Default 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
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 (10.6.0 by reading) and main (11 releases 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-06.

The finding in 30 seconds

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.

Summary card titled "Lucene Monitor reports (+text:alpha)~1 for 'alpha beta', although the query matches nothing". It shows the query "text:alpha AND at least one of words" with an empty word list, how core Lucene rewrites it to MatchNoDocsQuery (0 hits) while the default QueryDecomposer keeps only text:alpha and the Monitor reports it, a table of variants executed on main for the document 'alpha beta' (empty list: stored as text:alpha, 0 hits, reported; empty list with MUST_NOT omega: reported; minimum 2, or option gamma: kept whole, not reported; option beta: kept whole, 1 hit, reported; minimum 0: 1 hit, reported), and the one-condition fix sketch with the LUCENE-1134 precedent.
The repro's example: (+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.
  1. Build "alpha AND at least one of [options]" with BooleanQuery.Builder: MUST text:alpha, one SHOULD clause per option, setMinimumNumberShouldMatch(1). With an empty list it is (+text:alpha)~1. Register it in a default Monitor and match the document "alpha beta".
  2. You expect no match: the query needs one SHOULD clause to match and has none. IndexSearcher counts 0 hits, and core's test testNoOptionalButMin2 asserts 0 hits for this exact shape.
  3. You get a match: Monitor.match reports the query. With options [beta, gamma] (1 hit) or [zzz] (0 hits) the Monitor is right; only the empty list goes wrong.
  4. Why: the default QueryDecomposer keeps a query with one required clause whole only if minimumNumberShouldMatch is above 1 or a SHOULD clause decomposes to a part. Here it stores just text:alpha, and the Monitor runs that part as the final matcher.
  5. 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.

Who is affected

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.

Terms

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

The bug

  1. QueryDecomposer.decomposeBoolean returns the query whole if minimumNumberShouldMatch > 1 (QueryDecomposer.java:78).
  2. 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).
  3. 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 when minimumNumberShouldMatch is above 0: with 1, the "at least one" condition is gone.
  4. QueryCacheEntry.decompose stores each part under the parent's id (QueryCacheEntry.java:51-60), and Monitor.StandardQueryCollector runs 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".

The numbers

Document {text: "alpha beta"}, StandardAnalyzer, IndexSearcher on a one-document index vs Monitor.match with QueryMatch.SIMPLE_MATCHER:

querystored asIndexSearcher hitsMonitor
(+text:alpha)~1 (alpha AND at least one of [])text:alpha0reported
(#text:alpha)~1text:alpha0reported
(#text:alpha -text:omega)~1+text:alpha -text:omega0reported
text:zzz ((+text:alpha)~1)text:zzz, text:alpha0reported
DisjunctionMaxQuery of (+text:alpha)~1 and text:zzztext:alpha, text:zzz0reported
(+text:alpha text:beta text:gamma)~1kept whole1reported
(+text:alpha text:zzz)~1kept whole0not reported
+text:alphatext:alpha1reported

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 with BooleanQuery.Builder (one SHOULD clause per list element and setMinimumNumberShouldMatch(1)), or from an XML query parser template, which takes a minimumNumberShouldMatch attribute. 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 StandardQueryParser can, with @N, which it documents for pure disjunction groups only; (+alpha -omega)@1 still 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.

Minimal repro

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

TestMonitorMinShouldMatchSingleRequiredClause.java (JUnit test)46 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 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());
    }
  }
}
Command
# 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 -ea and -da; passes with the doc's one-condition QueryDecomposer fix.

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

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> 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)

Why it looks unintended

  • setMinimumNumberShouldMatch: "the specified number of clauses is required"; core rewrites this query to MatchNoDocsQuery, and testNoOptionalButMin2 asserts 0 hits.
  • Core made the same reduction in BooleanQuery.rewrite and 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." rewrite returns MatchNoDocsQuery("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.testNoOptionalButMin2 builds exactly one MUST clause with minimum 1 and asserts 0 hits; testNoOptionalButMin does 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:78 keeps 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.rewrite reduced 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"), adding testNoOptionalButMin2.
  • 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.

Suggested fix

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.

Variant: extend the minimumNumberShouldMatch guard at the top

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.

Store nothing for a query that provably matches nothing

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.

Reject such queries at registration

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.

Workaround today: a decomposer subclass

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

Downstream note

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.

How it was found

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.match selects 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 through Monitor.match with 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; web2026-10-26a 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 GitHub2026-10-2648 searches over issues and PRs, titles, bodies and comments; the file lists of all 411 open apache/lucene PRs; the upstream history of QueryDecomposer.javano report; #12295 touches the file (refactor)
Luwak tracker; JIRA LUCENE-8766, -9791, -9869, -104222026-10-26all 191 Luwak issue and PR titles, the relevant ones read; the JIRA texts greppedrelated but different: Luwak #122 / #123, #150 / #151
apache/lucene issues and PRs; upstream branches2026-10-28gh search for QueryDecomposer, monitor minimumNumberShouldMatch, decomposeBoolean, monitor minShouldMatch; main, branch_10_6 and branch_10x read through the contents and commits APIno 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

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected10 of the 11 tests of the 5 repro classes fail through gradle, assertions on and off
10.5.2, 9.12.3 affectedthe 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 affectedthe 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 affectedthe version-portable check: the same three false positives
8.2.0 first releasethe version-portable check fails, -ea and -da
8.1.1 no modulelucene-monitor does not exist yet (no artifact; the check does not compile)
fix on a9779ef3c193 not affectedthe whole monitor module, repros included: 193 tests, 0 failures, assertions on and off

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (bounded-exhaustive comparison, JUnit) candidate56,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 realOwn 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 agreesAn 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, mediumThe 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 realOwn 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, highRe-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 passesStock 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.

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

Finding record

LFV-325, snapshot of 2026-11-06

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); main ea57360912ed (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.

Lucene and upstream sources

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-63
  • a 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-195
  • a 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-576
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/search/TestBooleanMinShouldMatch.java:289-310
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:38-39, 53-63; QueryDecomposer.java:30-42, 78, 97-98
  • a 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)

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

Repro files

About the diagrams

A step-by-step picture of the repro's example, made in parallel with this doc; every value on screen comes from an executed probe on main a9779ef3c193, with assertions on and off. The PNG is the final summary card.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.