All findings · Lucene finding · LFV-588 · querybuilder-stacked-boost-outside-synonymquery-range

QueryBuilder throws IllegalArgumentException for a synonym weight above 1 (or 0) on a word that shares its position

With the weighted synonym leopard, panthera|1.5, unquoted queries containing leopard (and the one-word phrase) throw IllegalArgumentException: QueryBuilder hands 1.5 to SynonymQuery, which accepts only (0, 1], though lion => simba|1.5 works on a word alone at its position. Since 8.5.0; loud, no wrong results (a quoted phrase over several words silently drops the weight instead, LFV-584).

Summary

Priority
Low, rank 1056A weighted synonym above 1 or equal to 0 on a stacked token makes every query with that word throw IllegalArgumentException, which fails closed and is rare because published weights are below 1.
Impact
wrong error
Since
8.5.0 (LUCENE-9171, 2020-02-24: commit 663611c99c7, in 8.5.0 as its backport 2752d50dd1d)introduced 2020-02-24 · about 6 years
Affected
8.5.0-10.6.0 and main (7 releases run; the others by tag containment or inference)
Status
documented
Found by
semi-formal: a bounded-exhaustive check of QueryBuilder against a reference model, then JUnit tests

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

Background: weighted query tokens

  1. Since 8.5.0 an analyzer can attach a weight to a query token. The stock DelimitedBoostTokenFilter reads it from word|weight; after SynonymGraphFilter it gives weighted synonyms.
  2. A word alone at its position becomes a BoostQuery: lion => simba|1.5 gives (body:simba)^1.5, which multiplies the score by 1.5.
  3. Words that share a position (a synonym stacked on the query word) become one SynonymQuery, which scores them as one term. Its per-term weight scales the within-document term frequency and must be in (0, 1].
Diagram of the bug. A query analyzer with SynonymGraphFilter and DelimitedBoostTokenFilter. The rule lion => simba|1.5 gives one token, simba with weight 1.5, alone at its position: QueryBuilder builds (body:simba)^1.5. The rule leopard, panthera|1.5 gives two tokens at one position, panthera with weight 1.5 and leopard with weight 1: QueryBuilder.newSynonymQuery passes 1.5 to SynonymQuery.Builder.addTerm, which accepts only weights in (0, 1] and throws IllegalArgumentException: boost must be a positive float between 0 (exclusive) and 1 (inclusive). The weight 0.5 works on both paths. The query fails closed: no query, no wrong hits, no index effect. A quoted phrase over two positions does not throw; it drops the boost instead (a separate bug). Affected: 8.5.0 through 10.6.0, and main.
How QueryBuilder turns a weighted synonym into a query: alone at its position the weight becomes a BoostQuery; stacked with the query word it goes to SynonymQuery, which accepts only (0, 1] and throws for 1.5. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Your query analyzer has the weighted synonym leopard, panthera|1.5. The query word leopard becomes two tokens at the same position: panthera with weight 1.5 and leopard with weight 1.
  2. You expect a query that applies the weight, as other rules do: lion => simba|1.5 gives (body:simba)^1.5, and leopard, panthera|0.5 gives Synonym(body:leopard body:panthera^0.5).
  3. Instead QueryBuilder passes 1.5 unchanged to SynonymQuery, which throws IllegalArgumentException: boost must be a positive float between 0 (exclusive) and 1 (inclusive). The message names neither the word nor the rule, and no query is built.
  4. So createBooleanQuery, QueryParser.parse("spotted leopard") and the other unquoted entry points fail for text with leopard, and so does the one-word phrase; a quoted phrase over several words does not throw but drops the weight (LFV-584). A weight of 0 fails the same way; 0.5, 0.9 and 1 work. Affected: 8.5.0 to 10.6.0 and main (7 releases run, the others by tag containment or inference).

Who is affected

Applications whose query analyzer emits token weights (weighted synonyms or typed word|weight) and give a weight above 1, or 0, to a word stacked with another token, as in leopard, panthera|1.5. Unquoted queries with that word, and the one-word phrase, fail; a quoted phrase over several words drops the weight (LFV-584). Opt-in and rare.

Mechanism, terms and numbers674 words

Since 8.5.0 (LUCENE-9171) QueryBuilder reads the BoostAttribute of query tokens, which the stock DelimitedBoostTokenFilter (SPI name delimitedBoost) sets from word|weight; placed after SynonymGraphFilter it gives weighted synonyms. For a word alone at its position any finite weight >= 0 works: lion => simba|1.5 gives (body:simba)^1.5. But when the weighted word shares its position with another token, as in the equivalence rule leopard, panthera|1.5, QueryBuilder.newSynonymQuery passes the weight unchanged to SynonymQuery.Builder.addTerm, which accepts only (0, 1] (LUCENE-8652: a per-term weight on the term's within-document frequency). So createBooleanQuery("body", "leopard") and QueryParser.parse("spotted leopard") throw IllegalArgumentException: boost must be a positive float between 0 (exclusive) and 1 (inclusive) instead of returning a query, with a message that names neither the word nor the rule. A weight of 0 fails the same way; weights in (0, 1] work on both paths. A quoted phrase over several words does not throw; it drops the weight (the separate bug LFV-584). None of the javadocs and CHANGES entries checked states a limit for token weights. The failure is loud and fail-closed: no wrong results, no index effect. Affected: 8.5.0 through 10.6.0 and main (7 releases run, the others by tag containment or inference). A small change in newSynonymQuery (+10/-2 lines: divide by the largest weight above 1 and boost the whole SynonymQuery by it) makes the repro pass; it is one policy among several.

Terms

  • Token weight (boost): a float on a query token, carried by BoostAttribute. DelimitedBoostTokenFilter (SPI name delimitedBoost) splits word|weight into the token word and the weight, with Float.parseFloat and no range check (DelimitedBoostTokenFilter.java:26-33, 53-54). It is the only producer of the attribute on token streams in Lucene's non-test sources.
  • Stacked tokens: tokens that share a position: the first has a position increment of 1 or more, each one after it 0. The equivalence rule leopard, panthera|1.5 turns the query word leopard into panthera (weight 1.5, increment 1) and leopard (weight 1, increment 0) at one position; a replacement with one output, lion => simba|1.5, does not stack.
  • SynonymQuery: scores several terms as if they were one term. Since LUCENE-8652 (8.1.0) each term can carry a weight that multiplies its within-document frequency (the javadoc says "document frequencies"); addTerm rejects NaN, values <= 0 and values > 1 (SynonymQuery.java:90-94).

The bug

  1. createFieldQuery marks hasSynonyms when a token has position increment 0. For text that is not a token graph it sends one position with several tokens (quoted or not) to analyzeBoolean, unquoted text over several positions to analyzeMultiBoolean, and quoted text over several positions to analyzeMultiPhrase when a token is stacked (QueryBuilder.java:308-365).
  2. A position with one token becomes newTermQuery(term, boost), a BoostQuery for any weight other than 1 (:638-644); BoostQuery accepts any finite value >= 0 (BoostQuery.java:38-44).
  3. A position with several tokens becomes newSynonymQuery, which calls builder.addTerm(t.term, t.boost) with the raw weight (:603-609, the call at :606). It is reached from analyzeBoolean (:385-396), add for analyzeMultiBoolean (:408-409) and analyzeGraphBoolean (:554-555).
  4. SynonymQuery.Builder.addTerm throws for 1.5 or 0 (SynonymQuery.java:90-94). Nothing catches it: QueryParserBase.parse converts only ParseException, TokenMgrError and TooManyClauses (QueryParserBase.java:129-146).

So one attribute means two things: a score multiplier (any finite value >= 0) for a word alone at its position, and a frequency weight in (0, 1] for words that share one. Values legal on the first path throw on the second.

The numbers

Index of four one-word documents (leopard, panthera, simba, cat), BM25, main 645d4fe14a20: leopard, panthera|0.5 gives Synonym(body:leopard body:panthera^0.5) (doc leopard 0.5473, doc panthera 0.3541); |0.9 and |1 work too; lion => simba|1.5 gives (body:simba)^1.5 (0.8209); lion => simba|0 gives (body:simba)^0.0 (match, score 0.0). leopard, panthera|1.5, leopard, panthera|0 and lion => lion, simba|1.5 throw, for leopard, spotted leopard and QueryParser.parse("spotted leopard"). A quoted "spotted leopard" does not throw: it becomes body:"spotted (panthera leopard)" and loses the weight (the separate bug LFV-584).

Exposure and likelihood234 words
  • Who is hit. Applications whose query-time analyzer emits token weights: weighted synonyms (synonymGraph then delimitedBoost, the chain the Solr Reference Guide documents) or user-typed word|weight (delimitedBoost before the synonym filter, where tiger|2 works and leopard|2 throws). The trigger is a weight above 1, or exactly 0, on a word the analyzer stacks with another token: an equivalence rule such as leopard, panthera|1.5, or a replacement with several outputs such as lion => lion, simba|1.5.
  • Entry points (all executed): createBooleanQuery, createMinShouldMatchQuery, a single-position createPhraseQuery, the classic QueryParser and SimpleQueryParser.
  • Effect. Building a query from unquoted text that contains the word fails, through each entry point above (the single-position createPhraseQuery is the one-word phrase); a quoted phrase over several words does not throw but drops the weight (LFV-584). Fail-closed: no query object is returned, so no restriction or exclusion clause can be lost (4 of 4 property tests); no index effect. The message names neither the word nor the rule.
  • Default vs opt-in. The weight-emitting query analyzer is opt-in; writer, codec, similarity, searcher and parsers are defaults. No scale is needed: one word and one rule.
  • How likely. Rare: every weighted-synonym example found in the recorded searches uses weights of at most 1, the failure shows at the first unquoted query that contains the word, and the recorded searches found no report of it since 8.5.0 (2020).

Minimal repro

One JUnit test with stock filters only: a whitespace tokenizer, SynonymGraphFilter with the rule leopard, panthera|1.5, DelimitedBoostTokenFilter, then QueryBuilder.createBooleanQuery("body", "leopard"). No index and no query parser (SolrSynonymParser only parses the rule). Deterministic and single-threaded; the same failure with assertions on and off.

TestStackedTokenBoostRange.java (JUnit, one test)54 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.analysis.boost;

import java.io.StringReader;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.analysis.core.WhitespaceTokenizer;
import org.apache.lucene.analysis.synonym.SolrSynonymParser;
import org.apache.lucene.analysis.synonym.SynonymGraphFilter;
import org.apache.lucene.analysis.synonym.SynonymMap;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.QueryBuilder;

public class TestStackedTokenBoostRange extends LuceneTestCase {

  public void testSynonymWeightAboveOne() throws Exception {
    SolrSynonymParser parser = new SolrSynonymParser(true, true, new WhitespaceAnalyzer());
    parser.parse(new StringReader("leopard, panthera|1.5"));
    SynonymMap synonyms = parser.build();
    Analyzer analyzer =
        new Analyzer() {
          @Override
          protected TokenStreamComponents createComponents(String fieldName) {
            Tokenizer tokenizer = new WhitespaceTokenizer();
            return new TokenStreamComponents(
                tokenizer,
                new DelimitedBoostTokenFilter(
                    new SynonymGraphFilter(tokenizer, synonyms, true), '|'));
          }
        };
    // expected: a query, as weight 1.5 on an unstacked token gives a BoostQuery; actual: IAE
    try {
      new QueryBuilder(analyzer).createBooleanQuery("body", "leopard");
    } catch (IllegalArgumentException e) {
      throw new AssertionError("expected a query for synonym weight 1.5, but got " + e, e);
    }
  }
}
Command
cp TestStackedTokenBoostRange.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/boost/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.boost.TestStackedTokenBoostRange -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
createBooleanQuery("body", "leopard") returns a query that applies the synonym weight 1.5.
Observed
It throws IllegalArgumentException: boost must be a positive float between 0 (exclusive) and 1 (inclusive) from SynonymQuery.Builder.addTerm; the same with assertions off.

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

More files, environment, full output252 words

The test asserts only the claim: building the query throws instead of returning one. It does not assert the query's shape, because upstream could fix this in more than one way (normalize the weights, reject them early with a clear message, or document a limit). The test uses no randomness of its own, and QueryBuilder reads none of the settings LuceneTestCase randomizes; it fails the same with seeds DEADBEEF, 5EED5881 and 5EED5882. It needs the analysis-common module, so it lives in org.apache.lucene.analysis.boost.

As the regression test: the same test (for example next to TestDelimitedBoostTokenFilter). It fails on stock and passes with the recommended fix; with the reject-early option it would become an expectThrows on the new message.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2 and 9.12.3; the hunt's tests fail on every release run from 8.5.0; 8.4.1 not affected
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestStackedTokenBoostRange.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/boost/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.boost.TestStackedTokenBoostRange -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

TestStackedTokenBoostRange > testSynonymWeightAboveOne FAILED
    java.lang.AssertionError: expected a query for synonym weight 1.5, but got java.lang.IllegalArgumentException: boost must be a positive float between 0 (exclusive) and 1 (inclusive)
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:6BCD06C43690A2DD]:0)
        at org.apache.lucene.analysis.boost.TestStackedTokenBoostRange.testSynonymWeightAboveOne(TestStackedTokenBoostRange.java:51)

        Caused by:
        java.lang.IllegalArgumentException: boost must be a positive float between 0 (exclusive) and 1 (inclusive)
            at org.apache.lucene.search.SynonymQuery$Builder.addTerm(SynonymQuery.java:92)
            at org.apache.lucene.util.QueryBuilder.newSynonymQuery(QueryBuilder.java:606)
            at org.apache.lucene.util.QueryBuilder.analyzeBoolean(QueryBuilder.java:395)
            at org.apache.lucene.util.QueryBuilder.createFieldQuery(QueryBuilder.java:360)
            at org.apache.lucene.util.QueryBuilder.createFieldQuery(QueryBuilder.java:256)
            at org.apache.lucene.util.QueryBuilder.createBooleanQuery(QueryBuilder.java:108)
            at org.apache.lucene.util.QueryBuilder.createBooleanQuery(QueryBuilder.java:93)

# gradle, main 645d4fe14a20, seed DEADBEEF; the same with -Ptests.asserts=false
# (module prefixes org.apache.lucene.core@11.0.0-SNAPSHOT/ trimmed)

Why it looks unintended

  • LUCENE-9171 (8.5.0) says QueryBuilder can use token boosts "to selectively boost particular terms or synonyms", and DelimitedBoostTokenFilter accepts any float; none of the javadocs and CHANGES entries checked states a limit.
  • The same weight works or fails only by stacking: lion => simba|1.5 gives (body:simba)^1.5, leopard, panthera|1.5 throws.
  • No test covers it: TestQueryBuilder#testTokenStreamBoosts uses only 0.5.
The full argument203 words
  • The feature's contract. CHANGES 8.5.0, LUCENE-9171: "QueryBuilder can now use BoostAttributes on input token streams to selectively boost particular terms or synonyms in parsed queries" (CHANGES.txt:5965-5966), and "QueryBuilder.newTermQuery() and .newSynonymQuery() now take boost parameters" (:5925-5926). No range is stated.
  • The producer states no range. DelimitedBoostTokenFilter: "Characters before the delimiter are the "token", those after are the boost", parsed with Float.parseFloat; its factory is SPI-registered as delimitedBoost.
  • Stacking decides. lion => simba|1.5 gives (body:simba)^1.5; leopard, panthera|1.5 and lion => lion, simba|1.5 throw. A user cannot tell from the configuration which rules are safe.
  • SynonymQuery documents no range either. The addTerm javadoc says only that "document frequencies of this term will be boosted by boost" (SynonymQuery.java:86-89); the scorer actually scales the within-document frequency (:649-685).
  • No test. TestQueryBuilder#testTokenStreamBoosts (TestQueryBuilder.java:572-605) uses 0.5 only; no test asserts the exception at the QueryBuilder level.
  • History. The (0, 1] check (LUCENE-8652, 2019; in 8.1.0 through the branch_8x backport 24427f9f5cd) predates token boosts and was harmless while QueryBuilder passed none; LUCENE-9171 (2020; in 8.5.0 through the backport 2752d50dd1d) passed them through without range handling. The reviews of LUCENE-9171, SOLR-12238 and apache/lucene-solr#357 never discuss a range.
The devil's-advocate check293 words

The strongest case for "intended" (the refute-lens review, the works-as-designed review and the intended-behaviour devil's advocate): SynonymQuery's range is deliberate. LUCENE-8652 added it as a "deboost" (CHANGES.txt:6549-6550); its author (jimczi) wrote "The boost must be between 0+ and 1 in order to ensure that document term frequencies do not go beyond the total term frequency" (#9698); TestSynonymQuery#testBogusParams expects 1.3f to throw. BoostAttribute is @lucene.internal, and its javadoc says it "is intended to be added only by the TermsEnum to itself in its constructor and consumed by the MultiTermQuery.RewriteMethod" (BoostAttribute.java:24-35); in #12169 a committer (rmuir) objected to any use of it in token streams. Every example in the recorded searches uses weights of at most 1, and newSynonymQuery is an overridable extension point (an override accepts 1.5).

Why it fails: the range is SynonymQuery's own invariant and stays valid; the defect is one layer up. LUCENE-9171 made token weights documented QueryBuilder input, shipped a public, SPI-registered producer that parses any float, and applies the same attribute without a limit when the word is alone at its position. The BoostAttribute javadoc predates that feature, and the #12169 objection argues against the feature as a whole, not for a limit that depends on stacking. No API promises either that a weight above 1 on a stacked token must be accepted, so the claim is narrower: an undocumented limit that depends on stacking, which surfaces as an unchecked exception. Even if upstream rules that synonym weights must be in (0, 1], the current failure is the wrong error: an internal exception that names neither the word nor the rule. Hence the class "wrong error", not "crash". Verdict: not intended; the open question is which layer should handle the range.

Suggested fix

Recommended Normalize in newSynonymQuery (validated)

Normalize in QueryBuilder.newSynonymQuery: divide the weights by the largest one above 1 and boost the whole SynonymQuery by it; a weight of 0 becomes the smallest positive weight. Weights in (0, 1] give today's queries. With it the repro passes with assertions on and off, and the closest suites pass.

float max = 1f;
for (TermAndBoost t : terms) {
  max = Math.max(max, t.boost);
}
SynonymQuery.Builder builder = new SynonymQuery.Builder(field);
for (TermAndBoost t : terms) {
  builder.addTerm(t.term, t.boost == 0f ? Float.MIN_VALUE : t.boost / max);
}
Query query = builder.build();
return max == 1f ? query : new BoostQuery(query, max);
All options and trade-offs4 options ยท 637 words

Upstream should choose the policy: normalize, reject early with a clear message, or fall back to boosted term clauses. The first one was run through gradle in a scratch worktree of 645d4fe14a20.

Normalize in newSynonymQuery (validated) (recommended)

+10/-2 in QueryBuilder.java. Divide every weight by the largest one above 1 and wrap the SynonymQuery in a BoostQuery of that value; a weight of 0 becomes Float.MIN_VALUE, so the term still matches, as (term)^0 does.

Result: leopard, panthera|1.5 gives (Synonym(body:leopard^0.6666667 body:panthera))^1.5; doc panthera scores 0.8209 (the score of (body:simba)^1.5), doc leopard 0.6450. QueryParser.parse("spotted leopard") gives body:spotted (Synonym(body:leopard^0.6666667 body:panthera))^1.5. leopard, panthera|0 gives Synonym(body:leopard body:panthera^1.4E-45): doc panthera matches with score 0.0. Negative, NaN and infinite weights still throw today's exception.

float max = 1f;
for (TermAndBoost t : terms) {
  max = Math.max(max, t.boost);
}
SynonymQuery.Builder builder = new SynonymQuery.Builder(field);
for (TermAndBoost t : terms) {
  builder.addTerm(t.term, t.boost == 0f ? Float.MIN_VALUE : t.boost / max);
}
Query query = builder.build();
return max == 1f ? query : new BoostQuery(query, max);

Trade-offs

  • Not score-equivalent to "panthera counts 1.5 times": SynonymQuery blends frequencies, which BM25 saturates, so the outer 1.5 also raises a document that contains only leopard (0.5473 with weight 1, 0.6450 with the fix). It is one policy; dividing without the outer boost, or clamping to 1, are others.
  • Gives weight 0 a meaning (match, score about 0) where it throws today.
  • Changes the query (shape, toString, equals) only for weights that throw today, so no working configuration changes. It also needs a CHANGES entry; no benchmark was run.
  • The hunt's first sketch did the same scaling but also turned negative and NaN weights into Float.MIN_VALUE silently; this version keeps them throwing.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (removed afterwards), seed DEADBEEF: without the fix the repro fails with assertions on and off; with the fix it passes with assertions on and off (1 of 1 each).
  • Closest suites with the fix: TestQueryBuilder, TestSynonymQuery, the analysis-common boost and synonym packages (the repro included) and the whole lucene/queryparser module: 45 classes, 692 tests, 0 failures, 0 errors, 1 skipped.
  • :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs) and the analysis-common format check pass with the fix and the repro in place.
  • A probe on the main jars with the fixed QueryBuilder shadowed gives the queries and scores listed above; weights in (0, 1] give exactly today's queries and scores. Other modules' suites, Solr and benchmarks were not run.

Reject early with a clear message, and document the limit

In newSynonymQuery, check each weight and throw an IllegalArgumentException that names the term, its weight and the (0, 1] limit for words that share a position; state the limit in the DelimitedBoostTokenFilter and QueryBuilder javadocs.

Trade-offs

No scoring policy to choose and no score change, but the query still fails (now with a message an operator can act on), and it still escapes QueryParser.parse unchecked unless the parser converts it. The repro would become an expectThrows on the message. Not run.

Fall back to boosted term clauses

When a weight at a position is outside (0, 1], build a BooleanQuery of SHOULD (term)^weight clauses for that position, as Solr's as_distinct_terms synonym style does (read in Solr's source, not run).

Trade-offs

Keeps each weight exactly as a score multiplier, like the unstacked path, but loses SynonymQuery's blended statistics for that position, so the query's shape and scoring change with the weight's value. Not run.

Document the limit only (not recommended)

State in the javadocs that weights on stacked tokens must be in (0, 1].

Trade-offs

Leaves the internal exception, whose message names neither the word nor the rule. Still a credible upstream outcome: BoostAttribute is @lucene.internal, and SynonymQuery's bound is deliberate and tested.

Downstream note

Downstream, Solr's default synonym query style (as_same_term) delegates to this Lucene method, so a synonym weight above 1 would fail there, while its pick_best and as_distinct_terms styles wrap weights in BoostQuery (read in Solr's source, not run).

How it was found

A bounded-exhaustive check ran the real QueryBuilder against an independent reference model on every small token stream (up to 3 tokens, stacked tokens, weights from 0 to 2): 864,680 checks. 197,228 threw this exception, each from a stream with a stacked token weighted outside (0, 1]. JUnit tests with stock filters confirmed it.

The method in full145 words

Method: bounded-exhaustive translation validation on the real jar (semi-formal tier), in a hunt unit on QueryBuilder reference semantics (2026-10-26, main a9779ef3c193).

  • Scope C: every well-formed token stream of at most 3 tokens over {a, b, c} (19,458 of 106,956 enumerated; the rest are ill-formed graphs) with position increments 0-2, position lengths 1-2 and weights {0.5, 1, 2}, through 10 QueryBuilder entry points, compared with an independent reference model: 739,080 checks, 155,052 of them threw this IllegalArgumentException.
  • Scope C0: over {a, b} with weights {0, 0.25, 1, 1.5}: 125,600 checks, 42,176 threw it.
  • Every throwing check came from a stream in which a stacked token carries a weight outside (0, 1]; no other exception class occurred.
  • Then public-API JUnit tests on stock analysis chains (SynonymGraphFilter and DelimitedBoostTokenFilter in either order) and through QueryParser and SimpleQueryParser; later cut to the one-test repro above.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26) in GitHub issues, PRs and commits, the JIRA archive, the Lucene and Solr lists, downstream trackers or the web; the exact message has no hit in a synonym context. Closest: LUCENE-8652, which added SynonymQuery's range, and LUCENE-9171, which added token weights without a limit.

Searches4 searches

Verdict new. Closest items, none a report of this bug: LUCENE-8652 (#9698), the rationale for the range; LUCENE-9171 (#10211), SOLR-12238 and apache/lucene-solr#357, the feature (weights of at most 1 only, no limit stated); a 2020-04 java-user@ thread on in-range weights having no effect under BooleanSimilarity (no exception); #12169 (a committer's objection to BoostAttribute in token streams); open #12433 (renames the attribute, leaves newSynonymQuery unchanged); open #14262 (a new multi-field path that rejects synonym weights other than 1); #11864 / #11941 and elasticsearch#54434 (a different empty-array crash in the same method). Limits: Stack Overflow was read through an archived copy; chat channels were not searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@, commits@, solr-user@; the JIRA archive; the web2026-10-26a scripted prior-report search in three runs: 48 issue/PR, 9 commit, 6 downstream, 65 list, 12 JIRA and 12 web queries0 same-bug hits
GitHub issues and PRs (apache/lucene, apache/solr, apache/lucene-solr, Elasticsearch, OpenSearch, Lucene.NET)2026-10-2627 manual searches and 4 code searches, including the exact exception message0 same-bug hits; every hit for the message family is BoostQuery's related message "boost must be a positive float, got -1.0", from an unrelated negative fuzzy boost (#10608)
Open apache/lucene PRs2026-10-26the changed files of all 406 open PRs8 touch the files; none changes the boost handling (#12433, #14262 the closest)
JIRA (LUCENE, SOLR), lists.apache.org, the web2026-10-2611 JIRA queries, 30 list queries, 7 more web searchesonly the related items listed above

History

Introduced in 8.5.0 by 663611c99c7 (LUCENE-9171 / SOLR-12238, 2020-02-24), which started passing token weights to SynonymQuery, whose (0, 1] check dates from LUCENE-8652 (8.1.0). 8.4.1 is not affected; every release run from 8.5.0 to 10.6.0, and main, fails.

Versions tested5 rows

Introduced by 663611c99c7 "[SOLR-12238] Synonym Queries boost (#357)" (apache/lucene-solr#357; SOLR-12238 and LUCENE-9171 = #10211), merged 2020-02-24; branch_8x backport 2752d50dd1d the same day. It made QueryBuilder read BoostAttribute, wrap an unstacked token's weight in BoostQuery, pass a stacked token's weight unchanged to SynonymQuery.Builder.addTerm, and added DelimitedBoostTokenFilter; git blame puts the call at QueryBuilder.java:606 on it. Its backport 2752d50dd1d is the only commit between the 8.4.1 and 8.5.0 tags that touches QueryBuilder.java, SynonymQuery.java or BoostAttribute.java (history reading plus release runs, not a commit-level bisect). 663611c99c7 itself is the master commit (first released in 9.0.0). The (0, 1] check itself is from LUCENE-8652 (d8cfeba912ea on master, 2019-03-11; in 8.1.0 through its branch_8x backport 24427f9f5cd). Later commits only refactored the code (#11941, #12169, #12366). 8.9.0-8.11.1 and 8.11.3 are affected by tag containment and inference.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off (standalone and gradle)
10.6.0, 10.5.2, 9.12.3 affectedminimal test fails (assertions off)
main a9779ef3c193, 10.5.2, 9.12.3, 9.0.0, 8.11.4, 8.11.2, 8.5.0 affectedthe hunt's tests or history probe fail
8.4.1 not affectednot affected: QueryBuilder ignored token weights, DelimitedBoostTokenFilter did not exist
fix on 645d4fe14a20 fixminimal test passes, assertions on and off

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and a works-as-designed review found it real; an adjudication confirmed it (medium confidence, wrong error). A reading-only refute review from another model family said not real (medium): it could not run code, so its verdict is procedural, and it rated the intended-behaviour case strong; it agreed with the root cause.

Checks and verdicts273 words

Every executing check wrote its own test or re-ran an earlier one on stock main and saw it fail; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive check (864,680 cases); 3-test JUnit fails on main (assertions on and off), 10.5.2, 9.12.3, 9.0.0, 8.11.2 and 8.5.0; the first fix sketch passes, with upstream TestQueryBuilder 26 of 26.
cross-model review (refute lens) not real (medium)An independent reviewer from another model family (OpenAI GPT-5.6) reviewed by reading only and could not run code, so its verdict is procedural; it rated the intended-behaviour case strong (section above) and agreed with the root cause. A separate re-run failed 3 of 3.
independent reproducer realOwn test (CustomAnalyzer with the SPI names): 4 of 8 fail on main (assertions on and off), 10.5.2 and 9.12.3; all 4 controls pass; a breadth test adds createMinShouldMatchQuery, single-position createPhraseQuery and SimpleQueryParser.
works-as-designed review real (medium)OpenAI GPT-5.6, reading only: rated the intended case moderate, outweighed by the range-free public filter, the LUCENE-9171 entry and the unstacked path; a separate re-run failed 3 of 3.
intended-behaviour devil's advocate real (medium)Built the strongest intended case and answered it; re-ran on main and 10.5.2; an intent probe showed 0.9 and 1 work and an override accepts 1.5.
adjudication confirmed (medium, wrong error)Re-ran the independent reproduction's test with assertions off: 4 of 8 fail, controls pass.
this doc's runs reproduced; fix validatedThe minimal test on main (standalone and gradle, assertions on and off), 10.6.0, 10.5.2 and 9.12.3; the recommended fix through gradle with the closest suites.

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

Finding record

LFV-588, snapshot of 2026-11-06

Registry title
QueryBuilder hands a token boost (BoostAttribute) above 1 or equal to 0 on a word that shares its position with a synonym straight to SynonymQuery, which throws IllegalArgumentException, so every query containing that word fails, while the same weight on a word without a synonym works
Area
core / util QueryBuilder token boosts on stacked tokens (newSynonymQuery; BoostAttribute from DelimitedBoostTokenFilter and weighted synonyms; reached through the classic and simple query parsers)
Priority
Low, rank 1056: A weighted synonym above 1 or equal to 0 on a stacked token makes every query with that word throw IllegalArgumentException, which fails closed and is rare because published weights are below 1.
Impact class
wrong error
Impact note
Applications that opted in to token boosts in the query analyzer (weighted synonyms through SynonymGraphFilter plus DelimitedBoostTokenFilter, the recipe in the Solr Reference Guide, or user-typed word|weight boosts) and give a weight above 1 or exactly 0 to a word that shares its position with another token (a single-word equivalence synonym such as 'leopard, panthera|1.5') get, for every query containing that word, an IllegalArgumentException from SynonymQuery out of createBooleanQuery, createMinShouldMatchQuery, single-position createPhraseQuery, QueryParser.parse and SimpleQueryParser.parse [exec], with a message that names neither the word nor the synonym rule; fail-closed (no query is returned, no wrong hits, no index effect); no scale needed; every release from 8.5.0 through 10.6.0 and main.
Summary
You configure weighted synonyms ('leopard, panthera|1.5') or let users type boosts ('leopard|2'), and expect QueryBuilder to apply the weight, as it does for a word without a synonym: 'lion => simba|1.5' gives (body:simba)^1.5 and 'tiger|2' gives (body:tiger)^2.0. But when the boosted token shares its position with another token, QueryBuilder.newSynonymQuery passes the raw boost to SynonymQuery.Builder.addTerm, which accepts only (0, 1], so building the query throws IllegalArgumentException 'boost must be a positive float between 0 (exclusive) and 1 (inclusive)' and QueryParser.parse('spotted leopard') fails with it instead of returning a query or a ParseException. Weights in (0, 1] work on both paths; whether 1.5 or 0 works depends only on whether the analyzer stacked the token.
Introduced
663611c99c7d48dd31d53ea17644fcecd5e0fad7 '[SOLR-12238] Synonym Queries boost (#357)' (apache/lucene-solr#357; SOLR-12238 and LUCENE-9171 = apache/lucene#10211; branch_8x backport 2752d50dd1dc, same day) [read: the only commit between releases 8.4.1 and 8.5.0 that touches QueryBuilder.java, SynonymQuery.java or BoostAttribute.java; consistent with the executed release runs (8.4.1 not affected, 8.5.0 affected); no commit-level bisect]. It made QueryBuilder read BoostAttribute, wrap an unstacked token's boost in a BoostQuery and pass a stacked token's boost unchanged to SynonymQuery.Builder.addTerm, whose (0, 1] check comes from LUCENE-8652 (d8cfeba912ea, 2019-03-11, first released in 8.1.0; not a defect on its own, because QueryBuilder passed no boosts before 8.5.0) (2020-02-24)
First affected version
8.5.0 [exec] (8.4.1 not affected [exec]: QueryBuilder ignored BoostAttribute and DelimitedBoostTokenFilter did not exist; the weighted-synonym feature and this failure arrived together)
Affected versions
8.5.0 through 10.6.0 and main [exec: 8.5.0, 8.11.2, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main a9779ef3c193 (-ea and -da) fail the finder's JUnit or the same-source history probe; the independent reproduction's JUnit fails on main, 10.5.2 and 9.12.3, the intended-behaviour check's re-run on main and 10.5.2; read: git tag containment of the introducing commit for 8.5.0-8.8.1, 9.0.0-9.12.3 and 10.0.0-10.5.2; 8.9.0-8.11.1 and 8.11.3 by inference (not in the local history clone); the minimal repro fails on the 10.6.0 release jars (released 2026-10-29; SHA-1 checked against Maven Central; -da, 2026-11-01) and on main 645d4fe14a20 (-ea and -da)]. Not affected: 8.4.1 and earlier [exec on 8.4.1]. All runs on JDK 25, not each release's own JDK.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), 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, read-only): a scripted prior-report search in three runs (48 GitHub issue and PR queries on apache/lucene, 9 commit, 6 downstream, 65 mailing-list over dev@, java-user@, issues@, commits@ and solr-user@, 12 JIRA-archive, 12 web), 27 manual GitHub issue and PR searches (apache/lucene, apache/solr, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet), 4 GitHub code searches, the changed-file lists of all 406 open apache/lucene PRs, 11 manual JIRA queries (LUCENE, SOLR), 30 manual list queries, 7 more web searches and the git history of QueryBuilder.java, SynonymQuery.java and analysis/boost; plus the independent reproduction's, the intended-behaviour check's and the adjudication's own searches (0 hits). The exact exception message has no hit in any synonym context. Related, none a report of this bug: LUCENE-8652 (#9698), which added SynonymQuery's (0, 1] per-term boost with its rationale; LUCENE-9171 (#10211), SOLR-12238 and apache/lucene-solr#357, the feature that created the path (all examples use weights below 1, no limit stated); a 2020-04 java-user@ thread 'DelimitedBoostTokenFilterFactory Issue - Boosting and BooleanSimilarity' (in-range weights without effect under BooleanSimilarity, no exception); #12169 (a committer's objection to BoostAttribute in token streams); open #12433 (renames the attribute, adds a producer, leaves newSynonymQuery unchanged); open #14262 (a new multi-field path that rejects synonym boosts other than 1; the single-field path is unchanged); #11864 / #11941 and elasticsearch#54434 (a different empty-array crash in the same method). Gaps: Stack Overflow was read through an archived copy; Solr chat channels were not searched
Lucene commit tested
main 645d4fe14a20 (2026-11-01: the minimal repro standalone with assertions on and off and through gradle in a scratch worktree; the fix through gradle with the closest suites; an evidence probe of queries and scores, stock and fixed); the SHA-1-checked release jars 10.6.0, 10.5.2 and 9.12.3 (minimal repro, assertions off). The hunt (2026-10-26, main a9779ef3c193, where the relevant files are identical) also ran 8.4.1 (not affected), 8.5.0, 8.11.2, 8.11.4, 9.0.0, 9.12.3 and 10.5.2 (affected), all on JDK 25.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:385-396 (at a9779ef3c193) (analyzeBoolean: reads BoostAttribute of every stacked token and calls newSynonymQuery)
  • lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:398-411 (at a9779ef3c193) and :425-432 (add, used by analyzeMultiBoolean: one token -> newTermQuery, several tokens at one position -> newSynonymQuery)
  • lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:541-556 (at a9779ef3c193) (analyzeGraphBoolean: a segment without side paths with several tokens -> newSynonymQuery)
  • lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:603-609 (at a9779ef3c193) (newSynonymQuery: passes every TermAndBoost.boost unchanged to SynonymQuery.Builder.addTerm)
  • lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:638-644 (at a9779ef3c193) (newTermQuery: the same boost on an unstacked token becomes a BoostQuery)
  • lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:86-94 (at a9779ef3c193) (addTerm: javadoc 'document frequencies of this term will be boosted by boost', states no range; throws for NaN, <= 0 and > 1)
  • lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:38-44 (at a9779ef3c193) (accepts any finite boost >= 0)
  • lucene/core/src/java/org/apache/lucene/search/BoostAttribute.java:24-35 (at a9779ef3c193) (@lucene.internal; javadoc speaks only of TermsEnum and MultiTermQuery rewrites, unchanged by LUCENE-9171)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/boost/DelimitedBoostTokenFilter.java:26-58 (at a9779ef3c193) ('Characters before the delimiter are the token, those after are the boost'; Float.parseFloat, no range check)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:122-145 (at a9779ef3c193) (parse declares ParseException and converts only ParseException, TokenMgrError and TooManyClauses)
  • lucene/CHANGES.txt:5908 (at a9779ef3c193) and :5948 (8.5.0, LUCENE-9171: 'QueryBuilder can now use BoostAttributes on input token streams to selectively boost particular terms or synonyms in parsed queries'), :6532 (LUCENE-8652: 'SynonymQuery can now deboost the document frequency of each term')
  • upstream, read-only: apache/lucene#9698 (LUCENE-8652, the SynonymQuery range rationale), apache/lucene#10211 (LUCENE-9171), apache/lucene-solr#357, apache/lucene#12169, apache/lucene#12433, apache/lucene#14262
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:308-363, (at 645d4fe14a20) 372-434, 541-556, 603-609, 638-644
  • lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:86-94, (at 645d4fe14a20) 649-685
  • lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:38-44 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/boost/DelimitedBoostTokenFilter.java:26-58 (at 645d4fe14a20)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:129-146 (at 645d4fe14a20)
Why it looks unintended
  • lucene/CHANGES.txt:5925-5926, (at 645d4fe14a20) 5965-5966, 6549-6550
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/boost/DelimitedBoostTokenFilter.java:26-58; (at 645d4fe14a20) DelimitedBoostTokenFilterFactory.java:34-39
  • lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:86-94 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/BoostAttribute.java:24-35 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/util/TestQueryBuilder.java:572-605; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestSynonymQuery.java:107

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and probes on the main jars.

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.