All findings · Lucene finding · LFV-536 · concatenategraph-maxgraphexpansions-javadoc-misdescribes-limit

ConcatenateGraphFilter's maxGraphExpansions javadoc promises an exception, but extra paths are silently dropped (and CompletionAnalyzer's "no limit" is 10000)

The javadoc says ConcatenateGraphFilter throws TooComplexToDeterminizeException above maxGraphExpansions paths, but the code keeps the first maxGraphExpansions paths and silently drops the rest; CompletionAnalyzer's "no limit" constructors cap at 10000. That truncation is intended, so the javadoc needs fixing, not the code.

Summary

Priority
Low, rank 1234The maxGraphExpansions javadoc promises an exception where the code silently truncates paths, a documentation defect that misleads only users who rely on it.
Impact
minor
Since
7.4.0 (filter and factory text); 5.3.0 (CompletionAnalyzer text)introduced 2018-06-04 · about 8 years
Affected
5.3.0 / 7.4.0 to 10.6.0, and main (documentation only)
Status
documented
Found by
bounded-exhaustive check of the stock jar against the javadoc (semi-formal)

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

maxGraphExpansions is used twice in the code, and neither use matches the javadoc: it caps how many paths are emitted, and it is the determinize work limit.

Background: token graphs and paths

  1. A synonym filter can put two tokens at one position, for example wifi and wireless. The token stream is then a graph with 2 paths.
  2. ConcatenateGraphFilter joins each path into one token: wifi network, wireless network. The document suggester's CompletionAnalyzer uses it to index completions.
  3. Paths multiply: two positions with two choices each give 4 paths, fourteen give 16384. maxGraphExpansions (default 10000) bounds this.
Diagram of the bug. A token graph with two positions, a or b then c or d, has 4 paths. With maxGraphExpansions = 3 the javadoc promises TooComplexToDeterminizeException; the code emits the tokens a c, a d and b c and silently drops b d. The same value 3 is also the determinize work limit, so a 2-path value (wifi or wireless, 32 characters) is rejected although it has fewer paths than the limit.
A 4-path synonym graph at maxGraphExpansions = 3: the javadoc promises an exception, the code emits 3 tokens and silently drops the fourth; the same 3 also caps determinize work, so a 2-path value can throw. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You wrap a synonym graph (a|b) (c|d), which has 4 paths, in new ConcatenateGraphFilter(graph, true, true, 3). The javadoc says: more than 3 paths, so TooComplexToDeterminizeException.
  2. You get 3 concatenated tokens, a␟c, a␟d and b␟c (␟ is the separator), and no exception. The fourth path, b␟d, is silently dropped (which one is dropped follows an iteration order the API leaves unspecified).
  3. The same 3 is also the work limit for determinizing the graph. So a value with only 2 paths, wifi network adapter for laptops with the synonym wifi -> wireless (32 characters), throws at limit 3. A longer value of the same wifi|wireless shape first throws at 94 characters at limit 10 and at 994 at limit 100.
  4. new CompletionAnalyzer(analyzer) is documented as "no limit on graph expansions". A 14-word value where every word has a synonym has 16384 paths; you get 10000 of them.
  5. Keeping the first N is the suggester's deliberate design (LUCENE-4971), so the fix is to describe it in the javadoc.

Who is affected

Users of ConcatenateGraphFilter (or the concatenateGraph factory) and of the document suggester's CompletionAnalyzer who trust the javadoc; both are opt-in and experimental. At the default 10000, truncation needs over 10000 paths in one value. With a small explicit limit, extra paths vanish silently and long values can be rejected. The recorded indexing probe saw no corruption and no hang.

Mechanism, terms and numbers728 words

ConcatenateGraphFilter turns a token graph (for example the output of a synonym filter) into one concatenated token per path; the document suggester's CompletionAnalyzer uses it to index completions. Its 4-argument constructors and ConcatenateGraphFilterFactory document maxGraphExpansions as "If the tokenStream graph has more than this many possible paths through, then we'll throw TooComplexToDeterminizeException". The code never counts the total number of paths in the graph. It reads the paths through LimitedFiniteStringsIterator, which by its own javadoc returns "the first limit strings found", so extra paths are dropped without any signal: a 4-path graph at limit 3 gives 3 tokens. It also passes the same value to Operations.determinize as its work limit (an effort budget of 10 x the value), so a 2-path graph whose alternatives share a prefix throws once the input is long enough (in the probe's wifi|wireless shape, from 94 characters at limit 10 and 994 at limit 100). CompletionAnalyzer(Analyzer) and CompletionAnalyzer(Analyzer, boolean, boolean) say "no limit on graph expansions" but pass DEFAULT_MAX_GRAPH_EXPANSIONS = 10000: a value with 16384 paths gets 10000. Keeping the first N is a deliberate design (LUCENE-4971), so this is a documentation defect: the fix is a javadoc-only change in three files, validated on main. The filter and factory text dates from 7.4.0, the CompletionAnalyzer text has been stale since 5.3.0, and nobody has reported either.

The text (main 645d4fe14a20)

  • ConcatenateGraphFilter.java:106-110: @param maxGraphExpansions If the tokenStream graph has more than this many possible paths through, then we'll throw {@link TooComplexToDeterminizeException} to preserve the stability and memory of the machine. and @throws TooComplexToDeterminizeException if the tokenStream graph has more than {@code maxGraphExpansions} expansions. The boolean constructor (:126-143) "Calls" this one.
  • ConcatenateGraphFilterFactory.java:35-37: the same sentence; the factory passes any integer through (:62-63, :77-78).
  • CompletionAnalyzer.java:90-93 and :102-105: "... no limit on graph expansions"; both constructors pass ConcatenateGraphFilter.DEFAULT_MAX_GRAPH_EXPANSIONS (:99, :112), which is Operations.DEFAULT_DETERMINIZE_WORK_LIMIT (ConcatenateGraphFilter.java:61) = 10000 (Operations.java:63). The private field doc says "Defaults to -1 (no limit)" (:62-67).

Use 1: a silent cap on emitted paths

incrementToken() builds the automaton and wraps it: finiteStrings = new LimitedFiniteStringsIterator(automaton, maxGraphExpansions); (ConcatenateGraphFilter.java:163-166). LimitedFiniteStringsIterator.next() returns null once count >= limit ("Abort on limit.", LimitedFiniteStringsIterator.java:57-61), as its class javadoc documents: "If more than limit strings are accepted, the first limit strings found are returned" (:21-23). incrementToken() then returns false (ConcatenateGraphFilter.java:172-175). No exception, no flag. The only count is the iterator's count of the strings it has returned (LimitedFiniteStringsIterator.java:36-37, :65); since the automaton is determinized first, these are distinct accepted strings (FiniteStringsIterator.java:33), so strictly the cap is on distinct concatenated strings, which can be fewer than the graph's paths when two paths concatenate to the same bytes. For (a|b) (c|d) at limit 3 the tokens are a␟c, a␟d, b␟c (␟ = the separator byte) on this build; which paths come first is not an API guarantee ("the iteration order is implementation dependent and may change across releases", LimitedFiniteStringsIterator.java:28).

Use 2: the determinize work limit

toAutomaton() ends with return Operations.determinize(automaton, maxGraphExpansions); (ConcatenateGraphFilter.java:249-250). determinize returns a deterministic automaton unchanged (Operations.java:654-657); otherwise it adds up "effort" (the size of each state set it visits) against effortLimit = workLimit * 10 (:695) and throws TooComplexToDeterminizeException when it reaches it (:707-709). Its own javadoc calls the argument a "Maximum amount of \"work\"" (:645-651), not a path count.

A 2-path graph whose alternatives start with the same bytes (wifi / wireless) is not deterministic, and its effort grows with the input length. In the intended-behaviour check probe (wifi with the synonym wireless, lengthened with short filler words) the first throw came at 43, 94, 193, 493, 994 and 9994 characters for limits 5, 10, 20, 50, 100 and 1000, roughly 10 x the limit for that shape (a measurement, not a general rule); at 10000 the filter did not throw up to 60,004 bytes (one term that long is over IndexWriter.MAX_TERM_LENGTH, 32766 bytes, anyway). The (a|b) (c|d) graph is already deterministic, so use 2 never fires for it (4 tokens at limits 4 and 100, 3 at limit 3).

No code path counts the total number of paths and throws when it is above maxGraphExpansions.

Exposure and likelihood245 words
  • Who. Users of ConcatenateGraphFilter (directly, or as the concatenateGraph SPI factory) and of the document suggester (CompletionAnalyzer behind SuggestField / ContextSuggestField). Both classes are @lucene.experimental.
  • At the default (10000). Truncation needs more than 10000 paths in one value, for example 14 positions that each carry a synonym (10000 of 16384 kept). The determinize throw needs a very large automaton. A single value did not throw in the filter up to 60,004 bytes, but one term that long could not be indexed anyway (IndexWriter.MAX_TERM_LENGTH is 32766 bytes). The realistic case is many contexts: about 1000 shared-prefix contexts on one ContextSuggestField (about 104 KB of contexts) throw; about 54 KB pass.
  • With a small explicit limit. Paths beyond the limit vanish silently (fewer suggestions than the analyzer produced), and a long enough value with a shared-prefix alternative is rejected although it has fewer paths than the limit (in the intended-behaviour check probe's wifi|wireless shape: from 94 characters at limit 10, from 994 at limit 100).
  • Effect. Silently missing completions, or one document rejected with a non-aborting exception. In the recorded indexing probe only that document fails, the IndexWriter stays open and records no tragic exception, the next document indexes, and the index passes the test framework's CheckIndex on close: no corruption and no hang for that input (not a proof for every input).
  • Scale. Every release since 7.4.0 (filter and factory text) and since 5.3.0 (CompletionAnalyzer text); unreported in that time.

Minimal repro

One JUnit test, public API only: a canned 4-path graph (a|b) (c|d) through ConcatenateGraphFilter with maxGraphExpansions = 3, expecting the exception the javadoc promises. It fails today. It is evidence for the javadoc fix, not a regression test: no correct code change should make it pass.

TestConcatenateGraphMaxGraphExpansionsJavadoc.java (one test, public API)42 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.miscellaneous;

import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.tests.analysis.CannedTokenStream;
import org.apache.lucene.tests.analysis.Token;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.automaton.TooComplexToDeterminizeException;

public class TestConcatenateGraphMaxGraphExpansionsJavadoc extends LuceneTestCase {

  public void testMorePathsThanMaxGraphExpansions() throws Exception {
    TokenStream graph =
        new CannedTokenStream(
            new Token("a", 1, 0, 1), new Token("b", 0, 0, 1),
            new Token("c", 1, 2, 3), new Token("d", 0, 2, 3));
    try (TokenStream ts = new ConcatenateGraphFilter(graph, true, true, 3)) {
      ts.reset();
      // (a|b) (c|d) = 4 paths > 3: javadoc says TooComplexToDeterminizeException; actual: 3 tokens
      expectThrows(
          TooComplexToDeterminizeException.class,
          () -> {
            while (ts.incrementToken()) {}
          });
    }
  }
}
Command
# $LUCENE: a Lucene checkout; put the test in lucene/analysis/common/src/test/org/apache/lucene/analysis/miscellaneous/
cd $LUCENE
./gradlew :lucene:analysis:common:test --tests "org.apache.lucene.analysis.miscellaneous.TestConcatenateGraphMaxGraphExpansionsJavadoc" -Ptests.seed=DEADBEEF
# assertions off, same result: add -Ptests.asserts=false
Expected
TooComplexToDeterminizeException, as the javadoc says for 4 paths at limit 3.
Observed
No exception: 3 tokens are emitted and the fourth path is dropped (Expected exception TooComplexToDeterminizeException but no exception was thrown), with assertions on and off, on main and on 10.6.0 (on 10.6.0 the expectThrows frames are LuceneTestCase.java:2940/2926).

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

More files, environment, full output218 words

The graph is built with CannedTokenStream (the same graph a synonym map a -> b, c -> d gives for a c). It is deterministic, so the determinize work limit plays no part: a side check gives 4 tokens at limits 4 and 100 and 3 at limit 3.

The other two facets are covered by the finder's JUnit (re-run on fresh seeds in the registration check and the audit): 2 paths (wifi|wireless) network adapter for laptops at limit 3 throw Determinizing automaton with 40 states and 40 transitions would require more than 3 effort; CompletionAnalyzer(Analyzer) on a 16384-path value gives expected:<16384> but was:<10000>.

Gradle form (file in $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/miscellaneous/): ./gradlew :lucene:analysis:common:test --tests "org.apache.lucene.analysis.miscellaneous.TestConcatenateGraphMaxGraphExpansionsJavadoc" -Ptests.seed=DEADBEEF fails with the same message, with -Ptests.asserts=true and false.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the 10.6.0 release jars give the same failure
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (single-threaded analysis code; the platform cannot change the result)

Observed output

# main 645d4fe14a20, Gradle, seed DEADBEEF, -Ptests.asserts=true (false identical; the 'app/...' frame prefixes are left out). The 10.6.0 jars, run standalone, give the same message, with the expectThrows frames at LuceneTestCase.java:2940/2926
TestConcatenateGraphMaxGraphExpansionsJavadoc > testMorePathsThanMaxGraphExpansions FAILED
    junit.framework.AssertionFailedError: Expected exception TooComplexToDeterminizeException but no exception was thrown
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:BB5D7E37299F529F]:0)
        at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
        at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
        at org.apache.lucene.analysis.miscellaneous.TestConcatenateGraphMaxGraphExpansionsJavadoc.testMorePathsThanMaxGraphExpansions(TestConcatenateGraphMaxGraphExpansionsJavadoc.java:35)
1 test completed, 1 failed

Why it looks unintended

  • The @param text defines the value only as a path count and promises an exception above it; the code drops paths.
  • CompletionAnalyzer's "no limit on graph expansions" has been false since 5.3.0 changed the default from -1 to 10000.
  • The text was written for moved code that already truncated; no Lucene test checks a graph over the limit.
The full argument209 words
  • The @param sentence is a definition, not a side remark. It describes the value only as a path count past which the filter throws. Both it and the @throws say "more than N paths, then an exception"; the truncation (4 paths, limit 3, no exception) breaks that under any reading, including reading @throws X if C as a sufficient condition only. The below-the-limit determinize throw is weaker evidence: read as a sufficient condition, the text does not forbid a throw with fewer paths; it counts only because the text never says the value is also a work limit.
  • CompletionAnalyzer. "no limit on graph expansions" was true in 5.1.0 and 5.2.1 (default -1: 16384 of 16384 paths kept). LUCENE-6459 (#7518) changed the default to 10000 in 5.3.0 without touching the javadoc (10000 of 16384 from 5.3.0 on).
  • History. LUCENE-8332 (#9379, 7.4.0) moved the logic of CompletionTokenStream, which already used LimitedFiniteStringsIterator(automaton, maxGraphExpansions) and determinize(automaton, maxGraphExpansions), into the new public ConcatenateGraphFilter and wrote the throw-above-N text for it. The issue's 13 comments and the 18 review comments on lucene-solr#384 never discuss what the parameter means.
  • Tests. TestConcatenateGraphFilter.testValidNumberOfExpansions checks a graph under the limit (256 paths at the default). No test in analysis-common or suggest/document builds a graph over the limit.
The devil's-advocate check168 words

Strongest case that this is intended (the cross-model review, the intended-behaviour check and the adjudication): keeping the first N is a committer decision. On LUCENE-4971 (#6035) Mike McCandless wrote "I'd lean towards returning the first N after the limit is hit; this way at least some expansions are indexed", and Robert Muir answered "+1, the new semantics for the getFiniteStrings seem more useful to me anyway". LimitedFiniteStringsIterator documents the cap; reusing the value for determinize dates from LUCENE-6339; @throws X if C can be read as a sufficient condition only; both classes are @lucene.experimental; the "-1 (no limit)" text sits on a private field.

Why it fails: every point argues that the code is right, which is exactly why the javadoc should change. None makes the public @param text or the public "no limit" constructor docs true. The verifier who built this case concluded that it strengthens the documentation defect instead of refuting it. So this page claims only the javadoc, not a "should throw" behaviour bug.

Suggested fix

Recommended Describe what the code does (javadoc only, validated)

Rewrite the maxGraphExpansions javadoc to say what the code does: the first N paths are emitted, the rest dropped, and the same value is the determinize work limit; replace CompletionAnalyzer's "no limit" with the 10000 default, and qualify the class summary. Javadoc only, three files. Validated without the class-summary line: format, javadoc, lint and 76 module tests pass.

-   * @param maxGraphExpansions If the tokenStream graph has more than this many possible paths
-   *     through, then we'll throw {@link TooComplexToDeterminizeException} to preserve the
-   *     stability and memory of the machine.
-   * @throws TooComplexToDeterminizeException if the tokenStream graph has more than {@code
-   *     maxGraphExpansions} expansions
+   * @param maxGraphExpansions The maximum number of paths through the tokenStream graph that are
+   *     emitted as tokens. If the graph has more paths, only the first {@code maxGraphExpansions}
+   *     are emitted and the others are silently dropped. The same value is also the work limit
+   *     passed to {@link Operations#determinize(Automaton, int)}, so {@link #incrementToken()} may
+   *     throw {@link TooComplexToDeterminizeException} for a graph that is too costly to
+   *     determinize (for example a long input with a synonym that shares a prefix with the original
+   *     token), even if it has fewer paths than this.
All options and trade-offs4 options · 743 words

Option A was validated in a scratch worktree of 645d4fe14a20 (removed afterwards); the other options were not run. Changing the code instead would reverse the LUCENE-4971 decision.

Describe what the code does (javadoc only, validated) (recommended)

The hunk below is ConcatenateGraphFilter; the constructor's @throws line goes because the constructor never throws (in normal token-stream use the exception is thrown during incrementToken(), which calls toAutomaton(); the public toAutomaton() throws it too when called directly, so "incrementToken() (and toAutomaton())" would be more complete). "Only the first" follows the iterator's own wording; which paths come first is not specified. The snippets are abridged. The other two files:

--- ConcatenateGraphFilterFactory.java
- *   <li><code>maxGraphExpansions</code>: If the tokenStream graph has more than this many possible
- *       paths through, then we'll throw {@link TooComplexToDeterminizeException} to preserve the
- *       stability and memory of the machine.
+ *   <li><code>maxGraphExpansions</code>: The maximum number of paths through the tokenStream graph
+ *       that are emitted; further paths are silently dropped. The same value is also the
+ *       determinization work limit, so a graph that is too costly to determinize throws {@link
+ *       TooComplexToDeterminizeException} even if it has fewer paths. Defaults to {@link
+ *       ConcatenateGraphFilter#DEFAULT_MAX_GRAPH_EXPANSIONS}.
--- CompletionAnalyzer.java
-   * <p>Defaults to <code>-1</code> (no limit)
+   * <p>Defaults to {@link ConcatenateGraphFilter#DEFAULT_MAX_GRAPH_EXPANSIONS}
-   * preserving token separation, position increments and no limit on graph expansions
+   * preserving token separation and position increments, with at most {@link
+   * ConcatenateGraphFilter#DEFAULT_MAX_GRAPH_EXPANSIONS} graph expansions
-   * with no limit on graph expansions
+   * with at most {@link ConcatenateGraphFilter#DEFAULT_MAX_GRAPH_EXPANSIONS} graph expansions

Also recommended (not built or checked): the class javadoc's "one output token for every path" (ConcatenateGraphFilter.java:40-41) still contradicts the cap, so add "(at most maxGraphExpansions of them)".

-   * @param maxGraphExpansions If the tokenStream graph has more than this many possible paths
-   *     through, then we'll throw {@link TooComplexToDeterminizeException} to preserve the
-   *     stability and memory of the machine.
-   * @throws TooComplexToDeterminizeException if the tokenStream graph has more than {@code
-   *     maxGraphExpansions} expansions
+   * @param maxGraphExpansions The maximum number of paths through the tokenStream graph that are
+   *     emitted as tokens. If the graph has more paths, only the first {@code maxGraphExpansions}
+   *     are emitted and the others are silently dropped. The same value is also the work limit
+   *     passed to {@link Operations#determinize(Automaton, int)}, so {@link #incrementToken()} may
+   *     throw {@link TooComplexToDeterminizeException} for a graph that is too costly to
+   *     determinize (for example a long input with a synonym that shares a prefix with the original
+   *     token), even if it has fewer paths than this.

Trade-offs

None for users: documentation only. A CHANGES entry is optional.

What the checks validated

  • Format: the diff is the output of applyGoogleJavaFormat on both modules; checkGoogleJavaFormat passes.
  • Module tests: ./gradlew :lucene:analysis:common:test --tests "org.apache.lucene.analysis.miscellaneous.TestConcatenateGraphFilter*" -Ptests.seed=DEADBEEF: 18 tests, 0 failures. ./gradlew :lucene:suggest:test --tests "org.apache.lucene.search.suggest.document.*" -Ptests.seed=DEADBEEF: 58 tests in 6 suites, 0 failures.
  • Javadoc: renderJavadoc and ecjLintMain succeed on both modules; the rendered pages carry the new text and neither "we'll throw" nor "no limit on graph expansions". In the per-module render the cross-module link in CompletionAnalyzer shows as plain code text.
  • Behaviour unchanged: the minimal repro still fails the same way on the patched build, as it must.
  • The new text matches the code: the same minimal test with its expectation flipped (3 tokens, no exception) passes on the patched build with -ea and -da.

Separately: decouple the determinize work limit (behaviour change, not run)

Determinize with Operations.DEFAULT_DETERMINIZE_WORK_LIMIT (or a separate parameter) instead of maxGraphExpansions, so the value means only "number of paths".

Trade-offs

Inputs rejected today under a small limit would be indexed, and determinize could use more memory under a small limit. The adjudication and the audit kept it separate from the docs fix.

Make the code throw past the limit (not recommended, not run)

Iterate with limit + 1 and throw when the extra path appears, matching the current text.

Trade-offs

Reverses the LUCENE-4971 decision ("at least some expansions are indexed"), starts rejecting documents that index today, and needs a migration note.

Regression test (optional)

None is needed for a javadoc change. If a reviewer wants the documented behaviour pinned, use the same minimal test with its expectation flipped (same graph, same limit: assert 3 tokens and no exception); it passes on the patched build, whose code is that of main. Lucene has no test today for a graph over the limit.

Trade-offs

Pins behaviour that LUCENE-4971 chose, so a later deliberate change would have to update it.

Downstream note

A downstream search server wraps this filter as a concatenate_graph token filter, copies the javadoc, and has a test whose comment expects the documented exception for a 4-path graph at limit 3; by the code that exception can only come from the determinize work limit (read, not executed).

How it was found

A semi-formal check: the stock main jar was compared with the javadoc on every small synonym graph, and 42,440 of 129,060 checks expected the exception but got truncation. A directed probe on longer shared-prefix values found the opposite direction, and a cross-model reviewer pointed to CompletionAnalyzer's "no limit" text.

The method in full109 words
  • Translation validation. Every token graph with at least 2 paths over a 6-letter alphabet and up to 3 slots, with maxGraphExpansions = paths - 1, paths and paths + 1; expected (from the javadoc): throw if and only if paths > limit, else all paths. 129,060 checks; 42,440 in the bucket "expected throw, got truncated to limit".
  • Directed probe. Longer shared-prefix synonym values showed the throw below the limit (the determinize work limit).
  • Review. The cross-model reviewer (OpenAI GPT-5.6) pointed to the CompletionAnalyzer "no limit" javadoc; all three facets were then shown by a public-API JUnit on the stock main jar.
  • Tier. Semi-formal (bounded-exhaustive checking against the documented contract).

Prior reports, history and checks

Prior reports

No prior report: GitHub issues and PRs (with the migrated JIRA archive), commits, the Lucene mailing lists, downstream repositories and the web were searched on 2026-10-26 (183 searches); re-checked 2026-11-01. The truncation itself is a known, intended design (LUCENE-4971), not a report of this mismatch.

Searches3 searches

Related, not reports: #9379 / LUCENE-8332 and lucene-solr#384 (wrote the text), #6035 / LUCENE-4971 (the keep-the-first-N decision), #4915 / LUCENE-3842 (origin of maxGraphExpansions), #7400 / LUCENE-6339 (wrote the then-true "no limit"), #7518 / LUCENE-6459 (changed the default), #11020 / LUCENE-9981 (effort limit), #12503 (a different OOM).

What was searched, when, and what turned up
WhereWhenHowFound
Scripted searches, 3 runs, plus manual searches2026-10-26183 distinct searches (class names, maxGraphExpansions, the exact javadoc phrases, the exception message, symptom phrases) over apache/lucene issues, PRs and commits, the JIRA archive, dev@, java-user@, issues@ and commits@, downstream repositories, 8 web searches, git log -S/--grep over the full history, and the changed files of all 407 open PRs25 hits read, none the same bug
Independent verifier search2026-10-2645 queriesno report
Re-check2026-11-01issues and PRs created since 2026-10-26 for maxGraphExpansions, ConcatenateGraphFilter, CompletionAnalyzer, "graph expansions", concatenateGraph; upstream main commits on the three files since 2026-10-20; changed files of the 100 PRs updated since 2026-10-260; 0; none touches the three files

History

Two text changes, no code regression: LUCENE-8332 (f9f5e837450e, 7.4.0) wrote the throw-above-N text for code that already truncated; LUCENE-6459 (be4d58c7740, 5.3.0) changed CompletionAnalyzer's default from -1 to 10000 and left "no limit" in place. Never fixed.

Versions tested6 rows
  • Part A (filter and factory text): f9f5e837450e "LUCENE-8332: New ConcatenateGraphFilter (from CompletionTokenStream)", 2018-06-04; branch_7x copy 9b61121ffb65 in 7.4.0. 7.3.1 has no such class.
  • Part B (CompletionAnalyzer "no limit"): the text came from LUCENE-6339 (b7adb6ac7704 on main; in 5.1.0 through the branch_5x copy 40e94c39ad3), when the default was -1; be4d58c7740 (LUCENE-6459, 2015-05-28; branch_5x copy 380bb29f3959) changed the default to 10000 in 5.3.0. Caveat: in 5.1.0 and 5.2.1 the default -1 was also the determinize state limit, so a 2-path shared-prefix graph threw there; "no limit" was never fully accurate.
  • LUCENE-9981 (#11020; c4cf7aa3e19 on main, first released in 9.0.0; 8.10.0 got it from the 8.x branch, CHANGES entry under 8.10.0) turned the determinize limit from a state count into an effort budget (10 x the value). That moved where the below-the-limit throw starts (the 2-path graph at limit 30 throws on 7.4.0 and 8.9.0 and gives 2 tokens from 8.10.0) but did not introduce it; at limit 3 it throws on every version.
  • 8.9.0 to 8.11.4 were executed from the official jars; their javadoc was not read from source.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; upstream main f815e679bf6c affectedminimal test fails, -ea and -da (upstream main read, not run: no commit on the three files)
10.6.0 affectedminimal test fails (release jars, -da)
7.4.0, 8.9.0, 8.10.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 affectedpart A probe fails: 4 paths at limit 3 give 3 tokens, no exception
5.3.0, 7.3.1, 7.4.0, 8.11.4, 9.12.3, 10.5.2 affectedpart B probe fails: 10000 of 16384 paths with the "no limit" constructors
5.1.0, 5.2.1 part B holds16384 of 16384 paths kept (default -1); part A does not exist yet
7.3.1 and older no such classpart A: ConcatenateGraphFilter does not exist

Independent checks

A finder, an independent reproducer, a reviewer from another model family (OpenAI GPT-5.6) with lenses, a devil's-advocate check, an adjudication and an audit. All reproduced it or agreed on the cause; the adjudication confirmed it with high confidence as a minor documentation defect.

Checks and verdicts233 words

The cross-model reviews could only read the code (they could not run Java); separate runs re-ran the finder's test. The hunt ran on main ea57360912ed, where the five files involved are byte-identical to 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
Finder reproduced (doc)129,060-check translation validation, then a public-API JUnit: 3 of 3 fail with -ea and -da, also on 10.5.2 and 9.12.3.
Refute lens (OpenAI GPT-5.6) not real, low (procedural)Could not run code, so "not reproduced"; agreed on the root cause and pointed to the CompletionAnalyzer text. Separate re-run: 2 of 2 fail.
Reproducer real (doc)Own 8-test JUnit: 5 fail, 3 controls pass on main (-ea, -da), 10.5.2 and 9.12.3; indexing probe: the writer stays open.
Intended lens (OpenAI GPT-5.6) real, mediumIntended argument strong for the truncation, not for the javadoc; both mismatches on 7.4.0, 8.9.0, 9.0.0 and main.
Devil's advocate real (documentation defect), highBuilt the works-as-designed case and found it strengthens the doc defect; thresholds for limits 5 to 1000.
Adjudication and audit confirmed (high), minor; audit agreesRe-ran the independent reproduction's and the finder's tests; the audit re-ran both on fresh seeds and re-read the code.
Brief verified; fix validatedMinimal test fails on main (-ea, -da) and 10.6.0; the javadoc fix passes format, javadoc and lint checks and 76 module tests.

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

Finding record

LFV-536, snapshot of 2026-11-06

Registry title
ConcatenateGraphFilter, its factory and CompletionAnalyzer misdocument maxGraphExpansions: the javadoc promises a TooComplexToDeterminizeException when the token graph has more than maxGraphExpansions paths (and 'no limit on graph expansions' for CompletionAnalyzer's 1- and 3-argument constructors), but the code silently keeps the first maxGraphExpansions paths (default 10000) and also uses the same value as the determinize work limit, so it can throw for a graph with fewer paths than the limit (documentation defect; the truncation is intended)
Area
analysis-common / ConcatenateGraphFilter and ConcatenateGraphFilterFactory (maxGraphExpansions javadoc); also suggest / CompletionAnalyzer
Priority
Low, rank 1234: The maxGraphExpansions javadoc promises an exception where the code silently truncates paths, a documentation defect that misleads only users who rely on it.
Impact class
minor
Impact note
Documentation only; the code behaviour is the suggester's intended design and the fix changes no behaviour. Hit are users of ConcatenateGraphFilter (constructor or the concatenateGraph factory) or of the document suggester's CompletionAnalyzer who rely on the javadoc: where it promises an exception (or no limit), they silently get only the first maxGraphExpansions concatenated tokens, which at the default 10000 needs a value with more than 10000 paths (for example 14 positions that each carry a synonym: 10000 of 16384 kept), and with a small explicit limit a value of about 10 x limit bytes that has a shared-prefix alternative is rejected with a non-aborting, per-document TooComplexToDeterminizeException (limit 10: about 94 chars; limit 100: about 994 chars) although it has fewer paths than the limit; at the default limit that throw needs about 100 KB of automaton (for example 1000 shared-prefix contexts on a ContextSuggestField); no corruption, no hang, the writer stays open; same behaviour in every release since 5.3.0 (CompletionAnalyzer) and 7.4.0 (ConcatenateGraphFilter).
Summary
You set maxGraphExpansions on ConcatenateGraphFilter (or its factory), or use CompletionAnalyzer's 1- or 3-argument constructor, and expect what the javadoc says: a TooComplexToDeterminizeException when the token graph has more than that many paths, and, for CompletionAnalyzer, no limit at all. Instead the filter silently emits only the first maxGraphExpansions paths (a 4-path synonym graph at limit 3 gives 3 tokens and no error; CompletionAnalyzer keeps 10000 of 16384 paths), and because the same number is also passed to determinize as its work limit, a 2-path graph at limit 3 throws. Silent truncation is the suggester's deliberate design (LUCENE-4971), so the fix is to correct the javadoc, not the code.
Introduced
f9f5e837450e082ae7e1a82a0693760af7485a1b (LUCENE-8332 'New ConcatenateGraphFilter (from CompletionTokenStream)', now apache/lucene#9379; committed directly from a JIRA patch by dsmiley; PR apache/lucene-solr#384 carried the same javadoc and was closed unmerged on 2018-06-11; the branch_7x copy 9b61121ffb65 is the commit in 7.4.0) [read: git log -S 'many possible paths' finds only this commit and the LUCENE-9570 reformat 8ef6a0da5687, git grep at its parent finds no such text, git blame on main points at the reformat]. It moved CompletionTokenStream's code, which already kept the first N paths (LimitedFiniteStringsIterator) and passed N to determinize, into a new public class and wrote the throw-above-N javadoc for it; not a code regression. Part B (CompletionAnalyzer 'no limit on graph expansions'): be4d58c7740 (LUCENE-6459, now apache/lucene#7518; branch_5x copy 380bb29f3959, in 5.3.0) changed DEFAULT_MAX_GRAPH_EXPANSIONS from -1 to 10000 without touching the javadoc that LUCENE-6339 (b7adb6ac7704, 5.1.0) had written [read; exec: -1 at 5.1.0 and 5.2.1, 10000 at 5.3.0] (2018-06-04 (part A, ConcatenateGraphFilter and factory javadoc); 2015-05-28 (part B, CompletionAnalyzer default))
First affected version
7.4.0 [exec] for part A (7.3.1 [exec] has no ConcatenateGraphFilter or factory: the probe does not compile); 5.3.0 [exec] for part B (5.1.0 and 5.2.1 [exec] keep all 16384 paths as documented; caveat: there the default -1 was also the determinize state limit, so CompletionAnalyzer(Analyzer) threw on a 2-path shared-prefix graph [exec], i.e. 'no limit' was never fully accurate)
Affected versions
Part A: every release from 7.4.0 through 10.6.0, and main (ea57360912ed, 645d4fe14a20) [exec 2026-11-01: the minimal test fails on the 10.6.0 release jars with -da and on 645d4fe14a20 with -ea and -da]; part B: every release from 5.3.0 through 10.6.0, and main [10.6.0 read 2026-11-02: the release commit 4ae946e5a9b keeps the 'no limit on graph expansions' javadoc and its release jar's 1- and 3-argument CompletionAnalyzer constructors pass 10000]; never fixed (upstream main has no newer commit to the three files, checked through the GitHub API 2026-10-26). [exec] the follow-up's version-portable main() probes: part A fails on 7.4.0, 8.9.0, 8.10.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main (-ea and -da), compile error on 7.3.1; part B fails on 5.3.0, 7.3.1, 7.4.0, 8.11.4, 9.12.3, 10.5.2 and main (-ea and -da), passes on 5.1.0 and 5.2.1, compile error on 5.0.0; controls pass wherever the probe compiles; the finder's JUnit fails 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 5 of 8 (3 controls pass) on main (-ea and -da), 10.5.2 and 9.12.3; a separate probe shows both mismatches on 7.4.0, 8.9.0, 9.0.0 and main. [read] the javadoc text is present at tags 7.4.0, 8.8.1, 9.0.0, 9.12.3 and 10.5.2 and on main. Behaviour note: LUCENE-9981 (c4cf7aa3e19, CHANGES under 8.10.0) turned the determinize limit from a state count into an effort budget (10 x the value), which moves where the below-the-limit throw starts (a 2-path graph at limit 30 throws on 7.4.0 and 8.9.0 and gives 2 tokens from 8.10.0 [exec]); at limit 3 it throws on every version. 8.9.0-8.11.4 were executed from the official jars but their javadoc was not read from source (tags absent from the history clone)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26: 183 distinct searches, namely a scripted prior-report search in three runs (51, 48 and 20 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive), manual GitHub issue, PR and code searches, direct JIRA REST and list-archive queries, 8 web searches, git log -S/--grep over the full history, and the changed files of all 407 open apache/lucene PRs (none touches the three files); 25 hits read, none the same bug; the intended-behaviour check's earlier independent run (45 queries) agrees). Closest items, not reports: #9379 / LUCENE-8332 and apache/lucene-solr#384 (wrote the text; no discussion of the limit), #6035 / LUCENE-4971 (the design decision to keep the first N strings), #4915 / LUCENE-3842, #7400 / LUCENE-6339 (wrote the then-true 'no limit' text), #7518 / LUCENE-6459 (changed the default without touching the text), #11020 / LUCENE-9981 (made the determinize argument an effort limit), #12503 (a different OOM). Downstream: one search server's concatenate_graph factory copies the wrong javadoc and has a test whose comment relies on the documented throw [read, not executed]; nobody there questioned it
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails with assertions on and off, standalone and through ./gradlew :lucene:analysis:common:test; the 10.6.0 release jars fail the same way. The hunt's tests ran on main ea57360912ed (the five files involved are byte-identical), 10.5.2 and 9.12.3; the follow-up's version-portable probes ran on 5.0.0 to main. Fix A was validated in a scratch worktree of 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilter.java:39-46 (at ea57360912ed) (class javadoc: one output token 'for every path through the token stream'), :61 (DEFAULT_MAX_GRAPH_EXPANSIONS = Operations.DEFAULT_DETERMINIZE_WORK_LIMIT), :106-111 (@param maxGraphExpansions 'If the tokenStream graph has more than this many possible paths through, then we'll throw TooComplexToDeterminizeException ...' and the @throws line), :166 (new LimitedFiniteStringsIterator(automaton, maxGraphExpansions)), :249-250 (Operations.determinize(automaton, maxGraphExpansions))
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilterFactory.java:35-37 (at ea57360912ed) (factory javadoc, same throw wording), :62-63 (getInt maxGraphExpansions, any int)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionAnalyzer.java:62-67 (at ea57360912ed) (private field doc 'Defaults to -1 (no limit)'), :90-100 and :102-113 ('no limit on graph expansions' constructors passing DEFAULT_MAX_GRAPH_EXPANSIONS)
  • lucene/core/src/java/org/apache/lucene/util/automaton/LimitedFiniteStringsIterator.java:21-23 (at ea57360912ed) ('the first limit strings found are returned'), :43-61; lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:645-653, :695 (effortLimit = 10 x workLimit), :708-709 (throw)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilter.java:40-41 (class javadoc), :61, :106-110 (@param/@throws), :126-143, :163-166, :172-175, :249-250
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilterFactory.java:35-37, :62-63, :77-78
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionAnalyzer.java:62-67, :90-100, :102-113, :149-157; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionTokenStream.java:60-61
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/LimitedFiniteStringsIterator.java:21-23, :57-61; lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:63, :645-651, :654-657, :695, :707-709
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/LimitedFiniteStringsIterator.java:28 (iteration order), :36-37 and :65 (count of returned strings); lucene/core/src/java/org/apache/lucene/util/automaton/FiniteStringsIterator.java:33 (duplicates only if not determinized); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:252-258, :1480-1481 (MAX_TERM_LENGTH = 32766)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:252-258, :1480-1481 (MAX_TERM_LENGTH); lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseDirectoryWrapper.java:32, :44 (CheckIndex on close by default)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/test/org/apache/lucene/analysis/miscellaneous/TestConcatenateGraphFilter.java:133-161 (testValidNumberOfExpansions); grep for maxGraphExpansions / Expansions in analysis-common and suggest/document tests
Suggested fix
  • scratch worktree of 645d4fe14a20: lucene/analysis/common/build/test-results/test (TestConcatenateGraphFilter 12, TestConcatenateGraphFilterFactory 6), lucene/suggest/build/test-results/test (TestSuggestField 20, TestContextQuery 15, TestPrefixCompletionQuery 9, TestContextSuggestField 6, TestRegexCompletionQuery 5, TestFuzzyCompletionQuery 3); worktree removed afterwards

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 mechanism in the brief; the PNG is the summary frame.

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.