All findings · Lucene finding · LFV-732 · flexible-minshouldmatch-boosted-or-collapsed-group-classcastexception

Flexible StandardQueryParser: (the fox)@1 with a stop-word analyzer, or the boosted group (fox dog)^2@1, throws ClassCastException

The flexible StandardQueryParser builds (...)@N, "at least N of these clauses", by casting the group's query to BooleanQuery. So (the fox)@1 with a stop-word analyzer, or the boosted (fox dog)^2@1, makes parse() throw ClassCastException instead of returning a query, in every release since 9.1.0.

Summary

Priority
Low, rank 892The flexible parser's documented @N operator throws an unchecked ClassCastException on valid groups such as (the fox)@1 with stopwords or a boosted group, which is more reachable than a fractional slop but still only the wrong error type in a niche operator.
Impact
wrong error
Since
9.1.0 (2022; added by LUCENE-10223)introduced 2021-11-11 · about 4 years
Affected
9.1.0 to 10.6.0 and main
Status
documented
Found by
a differential exploration of the flexible parser's analysis pipeline

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

(...)@N asks for at least N of a group's clauses. Its builder assumes the group's query is a BooleanQuery, so any group that builds something else makes parse() throw.

Background: the @N operator of the flexible parser

  1. Since 9.1.0 the flexible StandardQueryParser accepts (blue crab fish)@2: match documents with at least 2 of the 3 clauses. It becomes a BooleanQuery with minimum-should-match 2, printed (body:blue body:crab body:fish)~2.
  2. Parsing has three phases: the grammar builds a tree of query nodes; a pipeline of processors rewrites it (the analyzer runs here, and a Boolean node left with one clause is replaced by that clause); then one builder per node type turns the tree into Lucene queries.
  3. A boost on a group, (fox dog)^2, becomes a BoostQuery around the group's query.
Diagram of the bug. StandardQueryParser with a stop-word analyzer parses (the fox)@1. The grammar builds MinShouldMatchNode(1) over a group holding the clauses the and fox; the analyzer turns the into a deleted node; the single-child processor replaces the group's one-clause Boolean node by body:fox; MinShouldMatchNodeBuilder casts that TermQuery to BooleanQuery and parse() throws ClassCastException. The control (fox dog)@1 builds (body:fox body:dog)~1.
(the fox)@1 through the parser's three phases: the analyzer drops "the", the group's one-clause Boolean node collapses to body:fox, and the @N builder's cast to BooleanQuery fails. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Create a StandardQueryParser whose analyzer drops the stop word "the". (fox dog)@1, "at least 1 of fox, dog", parses to (body:fox body:dog)~1.
  2. Parse (the fox)@1, the same kind of two-clause OR group. You expect a query for "at least 1 of {fox}", such as (body:fox)~1, or at worst the declared QueryNodeException.
  3. You get ClassCastException: TermQuery cannot be cast to BooleanQuery. The analyzer removed "the", the parser replaced the group's one-clause Boolean node by that clause, body:fox, and the @N builder casts that TermQuery to BooleanQuery without a check.
  4. The boosted group (fox dog)^2@1 fails the same way (BoostQuery cannot be cast), because the grammar puts the boost inside the @N node; the other order, (fox dog)@1^2, is a syntax error, and only ((fox dog)@1)^2 works. One-clause groups such as (fox)@1 or ("quick fox")@1 fail too.
  5. Nothing is searched and no result is wrong, but a valid query is refused with an undeclared exception: with assertions on and off, with operator OR or AND, through PrecedenceQueryParser too, in every release since 9.1.0.

Who is affected

Applications that pass query strings with the documented @N operator to the flexible StandardQueryParser or PrecedenceQueryParser, with default settings: a stop word that leaves one clause in a group (needs a stop-word analyzer), a boosted group, or a one-clause group such as a generated (t)@1. The failure is immediate and loud; the classic QueryParser has no @N operator.

Mechanism, terms and numbers671 words

The flexible StandardQueryParser (and PrecedenceQueryParser, which shares its builders) documents a min-should-match operator for disjunction groups, (blue crab fish)@2, and boosts on grouped clauses. MinShouldMatchNodeBuilder builds the @N node by casting the group's query to BooleanQuery without a check. That query is not a BooleanQuery when a stop-word analyzer leaves one clause in the group (the parser then replaces the group's one-clause Boolean node by that clause), when the group carries a boost (the grammar puts the boost inside the @N node), or when the group holds a single term, phrase or prefix. parse() then throws java.lang.ClassCastException instead of returning a query or its declared QueryNodeException.

The failure is immediate and deterministic, with assertions on and off and with either default operator; there are no wrong results and no index effect. Every release with the operator is affected, 9.1.0 (2022) to 10.6.0, and main. A one-file fix in the builder (move the boost outside, accept a lone query as one SHOULD clause) passes the repro and all 534 queryparser tests.

The mechanism (main 645d4fe14a20)

  1. Grammar. GroupingExpr is <LPAREN> q = Query(field) <RPAREN> (q = Boost(q))? ("@" minShouldMatch = <NUMBER>)? and then builds new MinShouldMatchNode(parseInt(minShouldMatch), new GroupQueryNode(q)) (StandardSyntaxParser.jj:358-361). The boost is applied first, so in (fox dog)^2@1 the @N node wraps the boost; (fox dog)@1^2 is not in the grammar (a ParseException).
  2. Analysis. AnalyzerQueryNodeProcessor (StandardQueryNodeProcessorPipeline.java:58) turns a term that analyzes to no token into a NoTokenFoundQueryNode (AnalyzerQueryNodeProcessor.java:162-163), a DeletedQueryNode that RemoveDeletedQueryNodesProcessor removes (pipeline :63).
  3. One-child collapse. BooleanSingleChildOptimizationQueryNodeProcessor (pipeline :65) "removes every BooleanQueryNode that contains only one child and returns this child" (:28-34, code :42-58). It is the Boolean node inside the group that is replaced, so the group keeps only the field node body:fox: GroupQueryNode [BooleanQueryNode [FieldQueryNode(body:fox)]] becomes GroupQueryNode [FieldQueryNode(body:fox)] (the diagram probe's trace).
  4. Group builder. GroupQueryNodeBuilder returns the child's query unchanged (GroupQueryNodeBuilder.java:36-39): a TermQuery for (the fox), a BoostQuery for (fox dog)^2, a PhraseQuery for ("quick fox").
  5. The cast. MinShouldMatchNodeBuilder.build reads that query (:38), does BooleanQuery booleanQuery = (BooleanQuery) q; (:40), and copies its clauses into a new BooleanQuery with the minimum set (:41-44). Its javadoc: "Builds a BooleanQuery from a MinShouldMatchNode" (:27).
  6. Escape. QueryTreeBuilder.processNode calls the builder directly (QueryTreeBuilder.java:165), so the ClassCastException leaves StandardQueryParser.parse (:277-279), which declares only QueryNodeException (:274; QueryParserHelper.java:211-213).

PrecedenceQueryParser extends StandardQueryParser and only swaps the processor pipeline (PrecedenceQueryParser.java:35, :41, :50), so it fails the same way.

What each shape parses to (stop set {the, a})

InputMain 645d4fe14a20With the fix
(fox dog)@1(body:fox body:dog)~1same
(the fox)@1, (the OR fox)@1ClassCastException (TermQuery)(body:fox)~1
(the quick fox)@2(body:quick body:fox)~2same
(the a)@1MatchNoDocsQuery("")same
(fox dog)^2@1ClassCastException (BoostQuery)((body:fox body:dog)~1)^2.0
((fox dog)@1)^2((body:fox body:dog)~1)^2.0same
(fox dog)@1^2ParseExceptionsame
(fox)@1, (+fox)@1ClassCastException (TermQuery)(body:fox)~1
("quick fox")@1ClassCastException (PhraseQuery)(body:"quick fox")~1
(fox*)@1ClassCastException (PrefixQuery)(body:fox*)~1
(-fox)@1(-body:fox)~1same
operator AND, (the fox)@1ClassCastException (TermQuery)(body:fox)~1
PrecedenceQueryParser, (the fox)@1ClassCastException (TermQuery)(body:fox)~1

The table is a selection: the independent reproducer's probe also saw the cast for ((fox dog)^2)@1 (BoostQuery), (fox~)@1 (FuzzyQuery), (*:*)@1 (MatchAllDocsQuery), ([a TO c])@1 (TermRangeQuery) and (body:fox)@1 (TermQuery), with OR and AND. Any group whose query is not an unboosted BooleanQuery fails.

(the a)@1 never reaches the builder: NoChildOptimizationQueryNodeProcessor (pipeline :62) turns the emptied Boolean node into a MatchNoDocsQueryNode, a DeletedQueryNode (NoChildOptimizationQueryNodeProcessor.java:43-62, MatchNoDocsQueryNode.java:23), and RemoveDeletedQueryNodesProcessor (pipeline :63) deletes the group and the @N node above it and returns a match-no-docs root (RemoveDeletedQueryNodesProcessor.java:38-43, :50-72). With default operator AND, BooleanQuery2ModifierNodeProcessor (pipeline :61) makes the clauses required; @N is documented for OR groups only, so the AND row shows only that the cast fails there too.

The no-argument StandardAnalyzer() has no stop words, so the stop-word trigger needs a stop-word analyzer (an explicit stop set, EnglishAnalyzer or StopAnalyzer); the boost and one-clause triggers need none.

Exposure and likelihood126 words
  • What goes wrong. A valid query is refused with an undeclared ClassCastException. Code that catches only QueryNodeException to answer "invalid query" sees an unexpected runtime exception instead.
  • When. Whenever the group does not build a BooleanQuery: a stop word leaves one clause (users cannot know which words the analyzer drops), the group carries a boost, or a query generator renders a one-element list as (t)@1.
  • Limits. Deterministic and immediate; nothing is searched, so no wrong results; no hang, no index effect, no scale needed. ((a b)@1)^2 is a workaround for the boost case only.
  • Reach is narrow. @N is the flexible parser's own syntax (the classic QueryParser has no such operator), and no public report was found since 9.1.0, released in March 2022.

Minimal repro

One JUnit test in lucene/queryparser, public API only: a StandardQueryParser with a StandardAnalyzer whose stop set is {the} parses (the fox)@1. It fails if parse() throws (naming the ClassCastException in its message); any returned query passes. No index; deterministic; fails with assertions on and off.

TestMinShouldMatchStopwordGroup.java (1 test)36 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.queryparser.flexible.standard;

import java.util.List;
import org.apache.lucene.analysis.CharArraySet;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMinShouldMatchStopwordGroup extends LuceneTestCase {

  public void testStopwordLeavesOneClauseInGroup() throws Exception {
    StandardQueryParser parser =
        new StandardQueryParser(new StandardAnalyzer(new CharArraySet(List.of("the"), false)));
    try {
      parser.parse("(the fox)@1", "body");
    } catch (ClassCastException e) {
      // Expected: a query (at least 1 of {fox}); actual: ClassCastException from the @N builder.
      throw new AssertionError("parse(\"(the fox)@1\") must return a query but threw " + e, e);
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or any commit with the same MinShouldMatchNodeBuilder.java)
cp $REPRO/TestMinShouldMatchStopwordGroup.java lucene/queryparser/src/test/org/apache/lucene/queryparser/flexible/standard/
./gradlew :lucene:queryparser:test --tests org.apache.lucene.queryparser.flexible.standard.TestMinShouldMatchStopwordGroup -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
parse("(the fox)@1") returns a query for "at least 1 of {fox}", for example (body:fox)~1.
Observed
ClassCastException: TermQuery cannot be cast to BooleanQuery at MinShouldMatchNodeBuilder.java:40, on main, 10.6.0 and 10.5.2, with assertions on and off.

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

More files, environment, full output233 words

The test sits in the parser's test package, as Lucene's own parser tests do, but uses no package-private member. It is also the suggested regression test, unchanged, for the recommended fix (with the QueryNodeException alternative, (the fox)@1 would still throw and the test would have to expect that exception); it passes checkGoogleJavaFormat, ecjLint and forbiddenApis as is.

The second lead trigger, the boosted group (fox dog)^2@1, has the same root cause and the same fix; it was run as a separate probe (ClassCastException: BoostQuery cannot be cast to BooleanQuery, same line) and is left out of the test.

The same file fails on every release from 9.1.0 to 10.6.0 (assertions on and off); 9.0.0 has no @N operator and needs the pre-9.1 test-framework import.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0 and 10.5.2 release jars with this test
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

TestMinShouldMatchStopwordGroup > testStopwordLeavesOneClauseInGroup FAILED
    java.lang.AssertionError: parse("(the fox)@1") must return a query but threw java.lang.ClassCastException: class org.apache.lucene.search.TermQuery cannot be cast to class org.apache.lucene.search.BooleanQuery (org.apache.lucene.search.TermQuery and org.apache.lucene.search.BooleanQuery are in module org.apache.lucene.core@11.0.0-SNAPSHOT of loader 'app')
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:8450CFC8EF670AD6]:0)
        at org.apache.lucene.queryparser.flexible.standard.TestMinShouldMatchStopwordGroup.testStopwordLeavesOneClauseInGroup(TestMinShouldMatchStopwordGroup.java:33)

        Caused by:
        java.lang.ClassCastException: class org.apache.lucene.search.TermQuery cannot be cast to class org.apache.lucene.search.BooleanQuery (...)
            at org.apache.lucene.queryparser.flexible.standard.builders.MinShouldMatchNodeBuilder.build(MinShouldMatchNodeBuilder.java:40)
            at org.apache.lucene.queryparser.flexible.standard.builders.MinShouldMatchNodeBuilder.build(MinShouldMatchNodeBuilder.java:28)
            at org.apache.lucene.queryparser.flexible.core.builders.QueryTreeBuilder.processNode(QueryTreeBuilder.java:165)
            ...
            at org.apache.lucene.queryparser.flexible.core.QueryParserHelper.parse(QueryParserHelper.java:222)
            at org.apache.lucene.queryparser.flexible.standard.StandardQueryParser.parse(StandardQueryParser.java:279)
:lucene:queryparser:test (FAILURE): 1 test, 1  failure

# boosted group, separate probe, same line:
java.lang.ClassCastException: class org.apache.lucene.search.BoostQuery cannot be cast to class org.apache.lucene.search.BooleanQuery

Why it looks unintended

  • (the fox)@1 is written in the documented form, an OR group like the javadoc's (blue crab fish)@2; the parser's own analyzer removes "the".
  • (fox dog)^2@1 is the only boost-plus-@N order the grammar accepts; (fox dog)@1^2 is a syntax error.
  • parse() declares QueryNodeException, and the grammar rejects other bad @ uses that way; no test or review discusses the cast.
The full argument233 words
  • The input is written in the documented form. (the fox)@1 and (the OR fox)@1 are disjunction groups as written, the shape of the javadoc examples (StandardQueryParser.java:195, :198) and of the only upstream test, (f:foo OR f:bar OR f:baz)@2 (TestStandardQPEnhancements.java:55-67). The stop word is removed by the parser's own pipeline; with two survivors, (the quick fox)@2, it works.
  • The boost order is the grammar's own. The same commit wrote GroupingExpr so that (a b)^2@1 is the only accepted boost-plus-@ order, and the class javadoc documents boosts on grouped clauses (StandardQueryParser.java:163-164).
  • The declared failure is QueryNodeException (:274). The grammar already rejects unsupported @ uses that way: (fox dog)@1^2, fox@1 and (fox dog)@x are ParseExceptions. This is a robustness argument, not a broken contract: Java lets any method throw an unchecked exception, and the javadoc does not say what @N does once analysis leaves one clause or when a boost sits inside it. But nothing documents a ClassCastException, and the parser refuses every other unsupported @ use with the declared exception.
  • Nobody discussed it. No test, javadoc, CHANGES or MIGRATE entry, issue or review comment mentions the cast or non-Boolean groups; the only test uses a three-clause unboosted group, which is why it never failed.
  • Precedent. LUCENE-4103 (#5175, fixed 2012), a ClassCastException in the same flexible pipeline, was fixed as a bug.
The devil's-advocate check111 words

The strongest case for "intended" (moderate; raised by every verifier): @N is documented only for "a disjunction Boolean query (a query with only "OR"-subclauses)" (StandardQueryParser.java:190-191) and "Boolean groups" (standard/package-info.java:33), so direct one-clause or one-phrase groups such as (fox)@1 or ("quick fox")@1 are arguably outside the contract, and their meaning is unspecified.

Why it fails: it does not cover the lead triggers, two-clause OR groups reduced by the parser's own analysis and a documented group boost, and even for inputs outside the contract the declared failure is QueryNodeException; nothing documents a ClassCastException. It is a fair reason to accept a QueryNodeException for the direct one-clause shapes.

Suggested fix

Recommended Accept any group query in MinShouldMatchNodeBuilder

In MinShouldMatchNodeBuilder, move a group boost outside the min-should-match query and accept a lone non-Boolean query as its one SHOULD clause: (the fox)@1 gives (body:fox)~1, (fox dog)^2@1 gives ((body:fox body:dog)~1)^2.0. One file, +16/-3; the repro and all 534 queryparser tests pass, and queries that built before are unchanged.

float boost = 1f;
while (q instanceof BoostQuery bq) {
  boost *= bq.getBoost();
  q = bq.getQuery();
}
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.setMinimumNumberShouldMatch(mmNode.minShouldMatch);
if (q instanceof BooleanQuery booleanQuery) {
  booleanQuery.clauses().forEach(builder::add);
} else {
  builder.add(q, BooleanClause.Occur.SHOULD);
}
Query result = builder.build();
return boost == 1f ? result : new BoostQuery(result, boost);
All options and trade-offs4 options ยท 530 words

One fix was built and checked through gradle in a scratch worktree of 645d4fe14a20; the alternatives come from the verifiers and were not run.

Accept any group query in MinShouldMatchNodeBuilder (validated by the checks) (recommended)

Unwrap any BoostQuery around the group's query and re-apply the boost outside the result; copy a BooleanQuery's clauses as today; otherwise add the query as the one SHOULD clause. BooleanQuery already defines a minimum larger than the SHOULD count (no match), so (the fox)@2 gives (body:fox)~2, like (fox dog)@3 today. Also update the builder javadoc (:27) and add a CHANGES entry.

float boost = 1f;
while (q instanceof BoostQuery bq) {
  boost *= bq.getBoost();
  q = bq.getQuery();
}
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.setMinimumNumberShouldMatch(mmNode.minShouldMatch);
if (q instanceof BooleanQuery booleanQuery) {
  booleanQuery.clauses().forEach(builder::add);
} else {
  builder.add(q, BooleanClause.Occur.SHOULD);
}
Query result = builder.build();
return boost == 1f ? result : new BoostQuery(result, boost);

Trade-offs

  • Gives direct one-clause groups such as (fox)@1 a meaning the javadoc does not define (the natural one: at least N of 1 clause).
  • A lone user + is not kept: (+fox)@1 gives (body:fox)~1, as (+fox) without @ gives body:fox today.
  • Every input that built a query before builds the same query: the old code succeeded only when the group's query was an unboosted BooleanQuery, and the new code takes the same path for it (by reading; the shape probe agrees for the controls, (the quick fox)@2, (-fox)@1, (the a)@1 and AND groups).

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: stock, the repro fails with assertions on and off; with the fix it passes with assertions on and off.
  • With the fix: org.apache.lucene.queryparser.flexible.* 162 tests, 1 skipped, 0 failures (all 23 TestStandardQPEnhancements tests); the whole :lucene:queryparser:test 534 tests, 1 skipped, 0 failures (an independent gradle re-run in the fact check counted the same 534 tests in 31 classes from the JUnit XML); :lucene:queryparser:check -x test passes with the repro test in place.
  • Standalone runner: the repro, the boosted-group probe, the 22-input shape probe and the 9.1.0 release pass with the fixed builder shadowed in.

Reject non-Boolean groups with a QueryNodeException

Keep the cast for a BooleanQuery and throw QueryNodeException otherwise (the builder interface allows it, QueryBuilder.java:35).

Trade-offs

Smallest change and turns the crash into the declared failure, but it still refuses (the fox)@1 and (fox dog)^2@1, which are written in documented syntax; acceptable for the direct one-clause shapes only, so it needs the boost unwrap anyway. The builder sees the same TermQuery for (the fox)@1 as for (fox)@1, so it refuses both, and the suggested regression test would have to expect the exception. Not run.

Apply the group boost outside the @N node in the grammar

Build Boost(MinShouldMatchNode(...)) in GroupingExpr.

Trade-offs

Fixes only the boost trigger, needs the JavaCC parser regenerated, and still needs a builder change for the stop-word and one-clause triggers. Not run.

Keep a one-child Boolean node under @N

Make BooleanSingleChildOptimizationQueryNodeProcessor skip the group of a MinShouldMatchNode.

Trade-offs

Fixes the stop-word trigger only, couples a generic processor to one node type, and leaves the boost and phrase shapes. Not recommended; not run.

Downstream note

Not a Lucene security issue; downstream, a service that catches only QueryNodeException to answer "invalid query" returns a generic server error for these user queries instead.

How it was found

A differential exploration by an AI tool: 57 edge-case query strings parsed under 6 analyzers, with both flexible parsers and both default operators. The only unchecked exception it produced was this ClassCastException, first on (the fox)@2 with a stop-word analyzer; reading the builder and the grammar then gave the boosted and one-clause triggers.

The method in full141 words

Method: semi-formal (a hunt unit, the flexible parser's analysis processors composed with the rest of the processor pipeline).

  • A probe parsed 57 edge-case query strings (stop words, synonyms, word delimiters, CJK, boosts, phrases, @N groups) under 6 analyzers, each with StandardQueryParser and PrecedenceQueryParser and default operator OR and AND (1,368 parses), and recorded the query or exception of each.
  • The only unchecked exception class in the output was this ClassCastException, on (the fox)@2 with the three analyzers that drop "the" (all four parser variants), while (the fox dog)@2 built normally.
  • An AI tool then read MinShouldMatchNodeBuilder and the grammar and found the analysis-independent triggers (the boosted group and direct one-clause groups), and wrote the public-API JUnit test.
  • So the exploration targeted the analysis pipeline in general; the @N strings were among its edge cases, not a hypothesis about this cast.

Prior reports, history and checks

Prior reports

No prior report: a scripted prior-report search run, 31 manual GitHub searches, the changed files of all 410 open PRs, code search, 9 downstream searches and 14 list searches (2026-10-31, repeating a search of 2026-10-27). Closest: #429, which added the operator and the cast, and open PR #16892, which only changes escaping.

Searches4 searches

Verdict new; none of the hits is this bug. Related, not reports of this bug: #429 / #11259 (LUCENE-10223, added @N; the review and the issue's comments never discuss non-Boolean or boosted groups), #16892 (open, adds @ to QueryParserUtil.escape), #11800 (closed, @ became syntax in 9.1), #5175 (LUCENE-4103, a different ClassCastException in the same parser, fixed 2012), #6938 and #8975 (open robustness issues elsewhere in the flexible parser), #6554 (open, a boosted-group exception in BoostQueryNodeProcessor from before @N). Gaps: GitHub search drops the @1 tokens and does not index PR diffs (covered by the commit history: no commit changed the builder); list search is keyword-based.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs2026-10-31a scripted prior-report search run (32 GitHub, 3 commit and 2 downstream queries) and 31 manual searches over apache/lucene and all of GitHub, e.g. MinShouldMatchNodeBuilder, "TermQuery cannot be cast", "BoostQuery cannot be cast", the full exception text; 16 gh pr list searchesno report; the exact-exception searches return 0 results
Open PRs and commit history2026-10-31the changed-file lists of all 410 open apache/lucene PRs; the history of the fix-site files on main, branch_11x and branch_10_6no PR touches the builder, the node, the single-child processor, the grammar or StandardQueryParser; the builder's only commit is #429
Code search and downstream trackers2026-10-314 GitHub code searches; 9 downstream searches over 7 trackers (Solr, Elasticsearch, OpenSearch, Hibernate Search, Lucene.NET, Zulia, Jackrabbit Oak) and all of GitHubonly vendored copies and ports with the same cast; no report
Mailing lists, JIRA archive, web2026-10-3140 list queries in the tool run (dev@, java-user@, issues@, commits@) and 14 direct searches (java-user@, dev@, issues@); 6 JIRA queries; 10 web queriesonly LUCENE-10223 traffic and older threads on other topics

History

Introduced with the @N operator by LUCENE-10223 (#429, November 2021) and first released in 9.1.0 (March 2022); the builder has not changed since. All 33 releases from 9.1.0 to 10.6.0 and main fail the repro; 9.0.0 has no @N operator. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-10223 "interval support in standard syntax parser" (apache/lucene#429, issue #11259), Dawid Weiss, 2021-11-11: main f725b27e12a, branch_9x backport ff9ee28c603, which shipped in 9.1.0. It added the @N operator, MinShouldMatchNode, MinShouldMatchNodeBuilder with the unchecked cast, and the GroupingExpr rule that puts the boost inside the @N node; CHANGES for 9.1.0 says "Add min-should-match operator support to StandardQueryParser". The one-clause collapse is older (LUCENE-1567, 2009) and harmless on its own. The builder file is the same blob in every release from 9.1.0 to 10.6.0 and on main.

Versions and branches executed, with results
Version or commitVerdictResult
9.0.0 no @N operator@ is not syntax: (the fox)@1 parses as body:fox body:1; the repro passes
9.1.0 first affectedClassCastException at MinShouldMatchNodeBuilder.java:40, assertions on and off; passes with the fixed builder shadowed in
9.2.0 to 9.12.3 (19 releases) affectedsame failure, assertions on and off
10.0.0 to 10.6.0 (13 releases) affectedsame failure, assertions on and off
main 645d4fe14a20 affectedfails through gradle and the standalone runner, assertions on and off; passes with the fix
upstream main 2f424fd428da (2026-10-30) by readingsame builder blob; no open PR touches it

Independent checks

Two execution-capable reviews by another model family (OpenAI GPT-5.6), an independent reproducer, a devil's advocate and an adjudication each ran their own tests and rated it real; the adjudication confirmed it with high confidence. A 2026-10-31 re-run on 645d4fe14a20, a 33-release sweep and a gradle check of the fix followed.

Checks and verdicts304 words

Every verifier rated the intended-behaviour argument (direct one-clause groups are outside the documented scope) moderate, and none found it covers the stop-word or boosted triggers.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidatePublic-API JUnit with a two-document index: 4 of 5 fail on main (assertions on and off) and on 10.5.2, 9.12.3 and 9.1.0 (assertions on), the control passes; 5 of 5 pass with the fix.
Cross-model review, refute lens (OpenAI GPT-5.6) real (high)Own test with a StopAnalyzer and a boosted two-clause group fails, the plain control passes; the fix makes it and the 23 TestStandardQPEnhancements tests pass. Its test class, re-run on 645d4fe14a20: stock fails 3 of 4 with assertions on and 9 of 12 over three iterations with assertions off; 4 of 4 pass with the fix in each mode.
Cross-model review, intended lens (OpenAI GPT-5.6) real (high)Own tests see the TermQuery, BoostQuery and PhraseQuery casts and the PrecedenceQueryParser failure; inputs in contract; the fix fixes it.
Independent reproducer realOwn test with StandardQueryParser and PrecedenceQueryParser: 4 of 5 fail on main, 10.5.2, 9.12.3 and 9.1.0; the controls return the expected hits; 5 of 5 with the fix.
Intended-behaviour devil's advocate real12 ClassCastExceptions with EnglishAnalyzer and StopAnalyzer on main, 10.5.2 and 9.1.0, including (the OR fox)@1; the javadoc examples build; unsupported @ syntax is a ParseException.
Adjudication confirmed (high)Own test outside the parser package: (the OR fox)@1 and (fox dog)^2@1 fail with assertions on and off, controls pass, 5 of 5 with the fix.
2026-10-31 reproducer and this doc's runs real, not fixedThe minimal test fails on main 645d4fe14a20, 10.6.0 and 10.5.2; the boosted probe fails; the fix passes the repro and all 534 queryparser tests through gradle.

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

Finding record

LFV-732, snapshot of 2026-11-06

Registry title
Flexible StandardQueryParser: the (...)@N min-should-match operator throws ClassCastException when the group does not build a BooleanQuery, e.g. (the fox)@1 with a stop-word analyzer or the boosted group (fox dog)^2@1
Area
queryparser / flexible: standard query builders (MinShouldMatchNodeBuilder for the @N min-should-match operator, composed with the grammar's group boost and the single-child Boolean optimization)
Priority
Low, rank 892: The flexible parser's documented @N operator throws an unchecked ClassCastException on valid groups such as (the fox)@1 with stopwords or a boosted group, which is more reachable than a fractional slop but still only the wrong error type in a niche operator.
Impact class
wrong error
Impact note
Applications that pass query strings with the documented @N min-should-match operator to the flexible StandardQueryParser or PrecedenceQueryParser (default parser settings, documented syntax) get an unchecked ClassCastException from parse() instead of a query or the declared QueryNodeException whenever the group does not build a BooleanQuery: a two-clause group that the stock analysis reduces to one clause (a stopword, as in (the fox)@1), a boosted group such as (fox dog)^2@1, or a group holding one term, phrase, prefix or other non-Boolean query; no wrong results, no hang, no index effect, no scale needed, every release since 9.1.0.
Summary
You parse (the fox)@1 ('at least one of the, fox') with the flexible StandardQueryParser and an analyzer that drops the stopword 'the', or the boosted group (fox dog)^2@1; the class javadoc documents @N for disjunction groups and boosts on grouped clauses, and parse() declares QueryNodeException for failures, so you expect a query (at least one of the remaining clauses, boosted as written). Instead parse() throws java.lang.ClassCastException ('TermQuery / BoostQuery cannot be cast to BooleanQuery') from MinShouldMatchNodeBuilder.build, which casts the group's query to BooleanQuery without a check: after the stopword is removed the one-clause group is collapsed to a TermQuery, and the grammar puts the boost inside the min-should-match node, so the group's query is a BoostQuery. Groups holding a single term, phrase, prefix or other non-Boolean query, such as (fox)@1 or ("quick fox")@1, fail the same way, while (fox dog)@1 works.
Introduced
f725b27e12a992f12aab12a8e8bfb9b31125bdae (LUCENE-10223 'interval support in standard syntax parser', apache/lucene#429 by dweiss, for issue apache/lucene#11259; branch_9x backport ff9ee28c60354fdb50337192883082192c8e494c). It added the @N operator, MinShouldMatchNode and MinShouldMatchNodeBuilder with the unchecked cast (the builder's only commit), and the GroupingExpr grammar rule that puts the group's boost node inside the MinShouldMatchNode; the one-clause collapse comes from the older BooleanSingleChildOptimizationQueryNodeProcessor (LUCENE-1567, 2009). Not a regression: present since the operator exists [read: git log, git log --diff-filter=A and git blame on the builder; executed: 9.0.0 has no operator, 9.1.0 fails] (2021-11-11)
First affected version
9.1.0 [exec] (first release with the @N operator; in 9.0.0, the release before, '@1' parses as a separate term and no ClassCastException occurs [exec])
Affected versions
Every release from 9.1.0 through 10.6.0 (33 releases) and main a9779ef3c193 (unreleased 11.0); never fixed. [exec] the minimal test fails on the 10.6.0 jars (2026-10-31); the follow-up's version-portable probe fails on all 32 releases (9.1.0-9.12.3, 10.0.0-10.5.2) and on main, and passes with the fix shadowed on 9.1.0 and main; the finder's JUnit fails 4 of 5 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's test fails 4 of 5 on 9.1.0, 9.12.3, 10.5.2 (-ea and -da) and main (-ea and -da); the intended-behaviour check's probe fails on 9.1.0, 10.5.2 and main. 8.x and earlier have no MinShouldMatchNode classes [read: 8.11.4 queryparser jar]. MinShouldMatchNodeBuilder.java is byte-identical (blob bbb19618d06) from f725b27e12a through every release to main, branch_10x and branch_10_6 [read]. All runs with JDK 25 on aarch64.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal; also reached by code reading
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-27, read-only, 232 recorded queries: a scripted prior-report search with 16 GitHub issue/PR, 3 commit, 2 downstream-repository, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries, plus the finder's and the intended-behaviour check's earlier runs (86 queries, re-read); 50 manual GitHub issue/PR searches over apache/lucene, downstream projects and all of GitHub; 4 GitHub code searches; 18 direct mailing-list searches; 11 web searches; the changed-file lists of all 410 open apache/lucene PRs). Nearest items, not this bug: apache/lucene#429 and #11259 (LUCENE-10223, introduced the operator and the cast; the reviews and the 13 issue comments never discuss non-Boolean or boosted groups), #11800 ('@' became syntax in 9.1, closed as not a bug), #16892 (open, escapes '@' in QueryParserUtil.escape, does not touch the builder), #6938 (LUCENE-5876, open, an analogous unchecked-tag NullPointerException in BoostQueryNodeBuilder), #6554 (LUCENE-5491, open, a boosted-group exception in BoostQueryNodeProcessor from before @N). Not fixed upstream: the builder's only commit on main, branch_10_6 and branch_10_5 is f725b27e12a (ff9ee28c60 on branch_9x), and no open PR touches it.
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): the one-method test through gradle (assertions on and off) and on the standalone runner; the release jars of 10.6.0 and 10.5.2; all 33 releases from 9.1.0 to 10.6.0, and 9.0.0, in the history sweep (assertions on and off). The whole lucene/queryparser tree is the same at the study's main a9779ef3c193, at 645d4fe14a20 and on upstream main 2f424fd428da (2026-10-30). The fix was run through gradle in a scratch worktree of 645d4fe14a20 on 2026-10-31 (the repro, all 534 queryparser tests and :lucene:queryparser:check -x test).

Lucene and upstream sources

Finding record
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/builders/MinShouldMatchNodeBuilder.java:27 ('Builds a BooleanQuery from a MinShouldMatchNode') and :38-44 (unchecked cast at :40)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:358-361 (GroupingExpr: optional boost, then optional '@' NUMBER; new MinShouldMatchNode(..., new GroupQueryNode(q)) wraps the boosted node)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/BooleanSingleChildOptimizationQueryNodeProcessor.java:29-58 (a Boolean node with one child is replaced by that child)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/StandardQueryParser.java:163-164 (grouped clauses can have a boost), :188-202 (min-should-match for Boolean disjunction groups, examples (blue crab fish)@2 and ((yellow OR blue) crab fish)@2), :274-277 (parse @throws QueryNodeException)
The finding in 30 seconds
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:358-361 (at 645d4fe14a20)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/StandardQueryNodeProcessorPipeline.java:58, (at 645d4fe14a20) :63, :65; standard/processors/AnalyzerQueryNodeProcessor.java:162-163; core/nodes/NoTokenFoundQueryNode.java:25; standard/processors/BooleanSingleChildOptimizationQueryNodeProcessor.java:28-34, :42-58
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/builders/GroupQueryNodeBuilder.java:36-39; (at 645d4fe14a20) standard/builders/MinShouldMatchNodeBuilder.java:27, :38-44; core/builders/QueryTreeBuilder.java:165; standard/StandardQueryParser.java:274-279; core/QueryParserHelper.java:211-213; precedence/PrecedenceQueryParser.java:35, :41, :50
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardAnalyzer.java:51-53 (at 645d4fe14a20)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/processors/NoChildOptimizationQueryNodeProcessor.java:43-62; (at 645d4fe14a20) core/nodes/MatchNoDocsQueryNode.java:23; core/processors/RemoveDeletedQueryNodesProcessor.java:38-43, :50-72; standard/processors/StandardQueryNodeProcessorPipeline.java:61-63
Who is affected
  • Lucene main 645d4fe14a20 dev-tools/doap/lucene.rdf:289-291 (lucene-9.1.0 created 2022-03-22)
Why it looks unintended
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/StandardQueryParser.java:163-164, (at 645d4fe14a20) :188-202, :274; standard/package-info.java:33; standard/parser/StandardSyntaxParser.jj:358-361
  • lucene/queryparser/src/test/org/apache/lucene/queryparser/flexible/standard/TestStandardQPEnhancements.java:55-67 (at 645d4fe14a20)
Suggested fix
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/builders/QueryBuilder.java:35 (at 645d4fe14a20)
History
  • Lucene main 645d4fe14a20 dev-tools/doap/lucene.rdf:289-291 (lucene-9.1.0 created 2022-03-22)
Diagrams
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/builders/MinShouldMatchNodeBuilder.java:40; (at 645d4fe14a20) standard/processors/BooleanSingleChildOptimizationQueryNodeProcessor.java:42-58

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

Repro files

About the diagrams

A picture of the parse of (the fox)@1, phase by phase, and of the cast that fails, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.

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.