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.
- A synonym filter can put two tokens at one position, for example
wifiandwireless. The token stream is then a graph with 2 paths. ConcatenateGraphFilterjoins each path into one token:wifi network,wireless network. The document suggester'sCompletionAnalyzeruses it to index completions.- Paths multiply: two positions with two choices each give 4 paths, fourteen give 16384.
maxGraphExpansions(default 10000) bounds this.
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.- You wrap a synonym graph
(a|b) (c|d), which has 4 paths, innew ConcatenateGraphFilter(graph, true, true, 3). The javadoc says: more than 3 paths, soTooComplexToDeterminizeException. - You get 3 concatenated tokens,
a␟c,a␟dandb␟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). - The same 3 is also the work limit for determinizing the graph. So a value with only 2 paths,
wifi network adapter for laptopswith the synonymwifi -> wireless(32 characters), throws at limit 3. A longer value of the samewifi|wirelessshape first throws at 94 characters at limit 10 and at 994 at limit 100. 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.- Keeping the first N is the suggester's deliberate design (LUCENE-4971), so the fix is to describe it in the javadoc.
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.
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-93and:102-105: "... no limit on graph expansions"; both constructors passConcatenateGraphFilter.DEFAULT_MAX_GRAPH_EXPANSIONS(:99,:112), which isOperations.DEFAULT_DETERMINIZE_WORK_LIMIT(ConcatenateGraphFilter.java:61) = 10000 (Operations.java:63). The private field doc says "Defaults to-1(no limit)" (:62-67).
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).
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 theconcatenateGraphSPI factory) and of the document suggester (CompletionAnalyzerbehindSuggestField/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_LENGTHis 32766 bytes). The realistic case is many contexts: about 1000 shared-prefix contexts on oneContextSuggestField(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|wirelessshape: 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
IndexWriterstays 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.
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.
/*
* 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()) {}
});
}
}
}
# $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 theexpectThrowsframes areLuceneTestCase.java:2940/2926).
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the 10.6.0 release jars give the same failure |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (single-threaded analysis code; the platform cannot change the result) |
# 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- The
@paramtext 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
@paramsentence 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@throwssay "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 Cas 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 usedLimitedFiniteStringsIterator(automaton, maxGraphExpansions)anddeterminize(automaton, maxGraphExpansions), into the new publicConcatenateGraphFilterand 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.testValidNumberOfExpansionschecks a graph under the limit (256 paths at the default). No test in analysis-common orsuggest/documentbuilds 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.
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.
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 expansionsAlso 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
applyGoogleJavaFormaton both modules;checkGoogleJavaFormatpasses. - 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:
renderJavadocandecjLintMainsucceed 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 inCompletionAnalyzershows 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
-eaand-da.
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.
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.
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.
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).
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).
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).
| Where | When | How | Found |
|---|---|---|---|
| Scripted searches, 3 runs, plus manual searches | 2026-10-26 | 183 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 PRs | 25 hits read, none the same bug |
| Independent verifier search | 2026-10-26 | 45 queries | no report |
| Re-check | 2026-11-01 | issues 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-26 | 0; 0; none touches the three files |
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 copy9b61121ffb65in 7.4.0. 7.3.1 has no such class. - Part B (CompletionAnalyzer "no limit"): the text came from LUCENE-6339 (
b7adb6ac7704on main; in 5.1.0 through the branch_5x copy40e94c39ad3), when the default was -1;be4d58c7740(LUCENE-6459, 2015-05-28; branch_5x copy380bb29f3959) 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;
c4cf7aa3e19on 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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; upstream main f815e679bf6c | affected | minimal test fails, -ea and -da (upstream main read, not run: no commit on the three files) |
| 10.6.0 | affected | minimal 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 | affected | part 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 | affected | part B probe fails: 10000 of 16384 paths with the "no limit" constructors |
| 5.1.0, 5.2.1 | part B holds | 16384 of 16384 paths kept (default -1); part A does not exist yet |
| 7.3.1 and older | no such class | part A: ConcatenateGraphFilter does not exist |
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.
| Check | Verdict | What 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, medium | Intended 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), high | Built 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 agrees | Re-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 validated | Minimal test fails on main (-ea, -da) and 10.6.0; the javadoc fix passes format, javadoc and lint checks and 76 module tests. |
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 423 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,548 words
- 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 mainea57360912ed(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 of645d4fe14a20.
- 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-250a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilterFactory.java:35-37, :62-63, :77-78a 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-61a 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-709a 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
- TestConcatenateGraphMaxGraphExpansionsJavadoc.java (one test, public API):
repro/LFV-536/src/org/apache/lucene/analysis/miscellaneous/TestConcatenateGraphMaxGraphExpansionsJavadoc.java(42 lines)
Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.
assets/LFV-536/bug.png(423 KB)
