(...)@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.
- Since 9.1.0 the flexible
StandardQueryParseraccepts(blue crab fish)@2: match documents with at least 2 of the 3 clauses. It becomes aBooleanQuerywith minimum-should-match 2, printed(body:blue body:crab body:fish)~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.
- A boost on a group,
(fox dog)^2, becomes aBoostQueryaround the group's query.
(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.- Create a
StandardQueryParserwhose analyzer drops the stop word "the".(fox dog)@1, "at least 1 of fox, dog", parses to(body:fox body:dog)~1. - 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 declaredQueryNodeException. - 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@Nbuilder casts thatTermQuerytoBooleanQuerywithout a check. - The boosted group
(fox dog)^2@1fails the same way (BoostQuery cannot be cast), because the grammar puts the boost inside the@Nnode; the other order,(fox dog)@1^2, is a syntax error, and only((fox dog)@1)^2works. One-clause groups such as(fox)@1or("quick fox")@1fail too. - 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
PrecedenceQueryParsertoo, in every release since 9.1.0.
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.
- Grammar.
GroupingExpris<LPAREN> q = Query(field) <RPAREN> (q = Boost(q))? ("@" minShouldMatch = <NUMBER>)?and then buildsnew MinShouldMatchNode(parseInt(minShouldMatch), new GroupQueryNode(q))(StandardSyntaxParser.jj:358-361). The boost is applied first, so in(fox dog)^2@1the@Nnode wraps the boost;(fox dog)@1^2is not in the grammar (aParseException). - Analysis.
AnalyzerQueryNodeProcessor(StandardQueryNodeProcessorPipeline.java:58) turns a term that analyzes to no token into aNoTokenFoundQueryNode(AnalyzerQueryNodeProcessor.java:162-163), aDeletedQueryNodethatRemoveDeletedQueryNodesProcessorremoves (pipeline:63). - 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 nodebody:fox:GroupQueryNode [BooleanQueryNode [FieldQueryNode(body:fox)]]becomesGroupQueryNode [FieldQueryNode(body:fox)](the diagram probe's trace). - Group builder.
GroupQueryNodeBuilderreturns the child's query unchanged (GroupQueryNodeBuilder.java:36-39): aTermQueryfor(the fox), aBoostQueryfor(fox dog)^2, aPhraseQueryfor("quick fox"). - The cast.
MinShouldMatchNodeBuilder.buildreads that query (:38), doesBooleanQuery booleanQuery = (BooleanQuery) q;(:40), and copies its clauses into a newBooleanQuerywith the minimum set (:41-44). Its javadoc: "Builds a BooleanQuery from a MinShouldMatchNode" (:27). - Escape.
QueryTreeBuilder.processNodecalls the builder directly (QueryTreeBuilder.java:165), so theClassCastExceptionleavesStandardQueryParser.parse(:277-279), which declares onlyQueryNodeException(: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.
| Input | Main 645d4fe14a20 | With the fix |
|---|---|---|
(fox dog)@1 | (body:fox body:dog)~1 | same |
(the fox)@1, (the OR fox)@1 | ClassCastException (TermQuery) | (body:fox)~1 |
(the quick fox)@2 | (body:quick body:fox)~2 | same |
(the a)@1 | MatchNoDocsQuery("") | same |
(fox dog)^2@1 | ClassCastException (BoostQuery) | ((body:fox body:dog)~1)^2.0 |
((fox dog)@1)^2 | ((body:fox body:dog)~1)^2.0 | same |
(fox dog)@1^2 | ParseException | same |
(fox)@1, (+fox)@1 | ClassCastException (TermQuery) | (body:fox)~1 |
("quick fox")@1 | ClassCastException (PhraseQuery) | (body:"quick fox")~1 |
(fox*)@1 | ClassCastException (PrefixQuery) | (body:fox*)~1 |
(-fox)@1 | (-body:fox)~1 | same |
operator AND, (the fox)@1 | ClassCastException (TermQuery) | (body:fox)~1 |
PrecedenceQueryParser, (the fox)@1 | ClassCastException (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 onlyQueryNodeExceptionto 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)^2is a workaround for the boost case only. - Reach is narrow.
@Nis the flexible parser's own syntax (the classicQueryParserhas no such operator), and no public report was found since 9.1.0, released in March 2022.
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.
/*
* 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);
}
}
}
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 BooleanQueryatMinShouldMatchNodeBuilder.java:40, on main, 10.6.0 and 10.5.2, with assertions on and off.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0 and 10.5.2 release jars with this test |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
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(the fox)@1is 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@1is the only boost-plus-@Norder the grammar accepts;(fox dog)@1^2is a syntax error.parse()declaresQueryNodeException, 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)@1and(the OR fox)@1are 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
GroupingExprso that(a b)^2@1is 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@1and(fox dog)@xareParseExceptions. This is a robustness argument, not a broken contract: Java lets any method throw an unchecked exception, and the javadoc does not say what@Ndoes once analysis leaves one clause or when a boost sits inside it. But nothing documents aClassCastException, 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
ClassCastExceptionin 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.
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.
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)@1a meaning the javadoc does not define (the natural one: at least N of 1 clause). - A lone user
+is not kept:(+fox)@1gives(body:fox)~1, as(+fox)without@givesbody:foxtoday. - 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)@1and 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 23TestStandardQPEnhancementstests); the whole:lucene:queryparser:test534 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 testpasses 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.
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.
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.
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.
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.
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,
@Ngroups) under 6 analyzers, each withStandardQueryParserandPrecedenceQueryParserand 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)@2with the three analyzers that drop "the" (all four parser variants), while(the fox dog)@2built normally. - An AI tool then read
MinShouldMatchNodeBuilderand 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
@Nstrings were among its edge cases, not a hypothesis about this cast.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs | 2026-10-31 | a 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 searches | no report; the exact-exception searches return 0 results |
| Open PRs and commit history | 2026-10-31 | the changed-file lists of all 410 open apache/lucene PRs; the history of the fix-site files on main, branch_11x and branch_10_6 | no 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 trackers | 2026-10-31 | 4 GitHub code searches; 9 downstream searches over 7 trackers (Solr, Elasticsearch, OpenSearch, Hibernate Search, Lucene.NET, Zulia, Jackrabbit Oak) and all of GitHub | only vendored copies and ports with the same cast; no report |
| Mailing lists, JIRA archive, web | 2026-10-31 | 40 list queries in the tool run (dev@, java-user@, issues@, commits@) and 14 direct searches (java-user@, dev@, issues@); 6 JIRA queries; 10 web queries | only LUCENE-10223 traffic and older threads on other topics |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 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 affected | ClassCastException at MinShouldMatchNodeBuilder.java:40, assertions on and off; passes with the fixed builder shadowed in |
| 9.2.0 to 9.12.3 (19 releases) | affected | same failure, assertions on and off |
| 10.0.0 to 10.6.0 (13 releases) | affected | same failure, assertions on and off |
| main 645d4fe14a20 | affected | fails through gradle and the standalone runner, assertions on and off; passes with the fix |
| upstream main 2f424fd428da (2026-10-30) | by reading | same builder blob; no open PR touches it |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Public-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 | real | Own 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 | real | 12 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 fixed | The 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. |
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 525 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,166 words
- 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 wholelucene/queryparsertree is the same at the study's maina9779ef3c193, at645d4fe14a20and on upstream main2f424fd428da(2026-10-30). The fix was run through gradle in a scratch worktree of645d4fe14a20on 2026-10-31 (the repro, all 534 queryparser tests and:lucene:queryparser:check -x test).
- 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-58lucene/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, :50lucene/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-361lucene/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
- TestMinShouldMatchStopwordGroup.java (1 test):
repro/LFV-732/src/org/apache/lucene/queryparser/flexible/standard/TestMinShouldMatchStopwordGroup.java(36 lines)
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.
assets/LFV-732/bug.png(525 KB)
