All findings · Lucene finding · LFV-519 · wordbreak-combinations-maxevaluations-not-enforced

WordBreakSpellChecker.suggestWordCombinations ignores maxEvaluations: the limit stops one left term, then every later one is evaluated

setMaxEvaluations(n) promises at most n word combinations evaluated, but suggestWordCombinations skips only the rest of the left word where its count first reaches n and evaluates every later one: with a limit of 1, a 4-word query gets 3 suggestions. The #12077 fix in 9.5.0 covered only suggestWordBreaks.

Summary

Priority
Low, rank 1124WordBreakSpellChecker word combinations ignore maxEvaluations, causing only a few extra cheap lookups, the same expansion-limit-not-enforced class as LFV-462.
Impact
minor
Since
the method's first version: LUCENE-3523 (2012), first released in 4.0.0-ALPHAintroduced 2012-05-30 · over 14 years
Affected
every release 4.0.0-ALPHA to 10.6.0 (133 versions), and main
Status
documented
Found by
semi-formal: a bounded check counting term lookups over a grid of limits

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

Lowering maxEvaluations does not cap how many word joins suggestWordCombinations tries.

How word combinations are suggested

  1. WordBreakSpellChecker.suggestWordCombinations suggests joining adjacent query words into one word that is in the index, for example ice cream into icecream.
  2. For each query word (the left term) it tries joining it with the next 1 to maxChanges words. Each join costs one term lookup (docFreq) and can give one suggestion.
  3. setMaxEvaluations(n) is documented as "The maximum number of word combinations to evaluate" (default 1,000): a lower value is meant to trade suggestion quality for speed.
Diagram of the bug: WordBreakSpellChecker.suggestWordCombinations on the query a b c d with maxChanges 2 and maxEvaluations 1, against an index holding ab, bc and bcd. The joins ab, abc, bc, bcd and cd are possible. The counter equals the limit after ab, so only abc is skipped; bc, bcd and cd are still looked up, 4 lookups in all, and 3 suggestions (bc, ab, bcd) come back where at most 1 is allowed. With the >= check before each lookup, 1 lookup is made and ab is returned.
The minimal example join by join: query a b c d, maxChanges 2, maxEvaluations 1. The counter reaches the limit after ab and only abc is skipped; bc, bcd and cd are still looked up and 3 suggestions come back, while the fix stops after 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document with the terms ab, bc and bcd. Call setMaxChanges(2) and setMaxEvaluations(1), then suggestWordCombinations for the query words a b c d.
  2. You expect 1 join to be tried, so at most 1 suggestion: ab, the first one.
  3. Instead 4 of the 5 possible joins are looked up (ab, bc, bcd, cd; only abc is skipped) and 3 suggestions come back: bc, ab, bcd.
  4. Why: one counter counts the whole call, but it is compared with == after each lookup, and the break leaves only the inner loop. Past the limit it never equals it again, so every later left word runs in full; a limit of 0 means no limit at all.
  5. At scale, with the defaults (limit 1,000, maxChanges 1), a 100,000-word query makes 99,999 lookups instead of 1,000. Nothing fails; the work stays linear in the query length, only the cap is lost.

Who is affected

Anyone who lowers maxEvaluations to bound spellcheck work, directly or through a downstream setting such as Solr's word-break spellchecker, on every query with more joins than the limit. The default 1,000 hides it below about 1,000 joins. Effect: extra term lookups and possibly extra suggestions, still capped by maxSuggestions; nothing fails.

Mechanism, terms and numbers672 words

WordBreakSpellChecker.suggestWordCombinations suggests joining adjacent query words into one indexed word. setMaxEvaluations is documented as "The maximum number of word combinations to evaluate" (default 1000), and the method keeps one counter for the call, but it compares the counter with == maxEvaluations after each lookup and its break leaves only the inner loop. So the limit skips only the rest of the one left term at which the counter first equals it; every later left term is evaluated in full, and a limit of 0 or less never matches. With the terms ab, bc, bcd indexed, the query a b c d, maxChanges = 2 and maxEvaluations = 1, 4 of the 5 possible joins are looked up and 3 suggestions come back instead of at most 1. The code is unchanged since the class was added (LUCENE-3523, 2012), so every lucene-suggest release from 4.0.0-ALPHA to 10.6.0 and main are affected. #12077 fixed the same symptom in 9.5.0, but only in the word-break path. The work stays bounded by the query length times maxChanges; a small change (+5/-4 lines) that checks >= before each lookup and leaves both loops restores the cap and passes all lucene/suggest tests (237 run, 7 skipped, 0 failures).

The loop

WordBreakSpellChecker.suggestWordCombinations on main 645d4fe14a20:

int thisTimeEvaluations = 0;                                            // :168  one counter for the call
for (int i = 0; i < terms.length - 1; i++) {                            // :169  left term
  ...
  for (int j = i + 1; j < terms.length && j - i <= maxChanges; j++) {  // :186  right end of the join
    ...
    int combinedTermFreq = ir.docFreq(combinedTerm);                    // :204  one evaluation
    ...                                                                 // :206-226 offer a suggestion
    thisTimeEvaluations++;                                              // :228
    if (thisTimeEvaluations == maxEvaluations) {                        // :229  '==', not '>='
      break;                                                            // :230  leaves only the inner loop
    }
  }
}
  • The counter is declared once, outside both loops, and never reset: it counts the whole call.
  • It is checked after the lookup, with ==, and the unlabeled break leaves only the inner loop. After the first match the counter is past the limit and never equal to it again, so the outer loop carries on and every later left term is evaluated in full.
  • Exactly the rest of the left term at which the counter first equals the limit is skipped. A limit of 0 or less never equals the counter after an increment, so nothing is skipped.
  • The method is otherwise bounded: the inner loop stops after maxChanges right terms (:186), at a SEPARATOR_TERM (:187-189) and when the joined text passes maxCombineWordLength (default 20, :194-196), so a call makes at most about (terms - 1) x maxChanges lookups.
  • The sibling path generateBreakUpSuggestions (word breaks) checks totalEvaluations >= maxEvaluations before each evaluation (:269-272; this check came with #12077 in 9.5.0), and also before recursing (:299) and after each recursion (:324-326), two checks that came with the breadth-first rewrite #12100 (9.12.0).

The minimal example, join by join

Index {ab, bc, bcd}, query a b c d, maxChanges = 2, maxEvaluations = 1, SUGGEST_ALWAYS:

left termjoinlookup no.in indexcounter == 1?
aab1yes, suggestionyes: inner loop left, abc skipped
bbc2yes, suggestionno
bbcd3yes, suggestionno
ccd4nono

Result: 4 lookups and 3 suggestions, [bc, ab, bcd]. A cap per call allows 1; a budget per left term would allow ab and bc (2). With the fix: 1 lookup, [ab].

Larger numbers

setupallowedstock
every 2-6 letter run of abcdef indexed, query a b c d e f, maxChanges = 5, maxEvaluations = 1111 of 15 joins evaluated and returned
the same, maxEvaluations = 3313
the same, maxEvaluations = 0015 (no limit at all)
defaults (maxEvaluations = 1000, maxChanges = 1), 100,000 one-letter words1,00099,999 lookups (168 ms vs 2.6 ms fixed)
Exposure and likelihood279 words
  • Who. Code that calls setMaxEvaluations with a value small enough to bind, directly or through a downstream component. Apache Solr's WordBreakSolrSpellChecker maps its maxEvaluations parameter onto setMaxEvaluations and calls suggestWordCombinations on every request it serves, even with combineWords=false; its sample config sets maxChanges = 10 (read from its source, not run).
  • Default vs opt-in. With the default limit of 1,000 it shows only for queries with more than 1,000 joins: more than 1,001 words at the default maxChanges = 1, or roughly 100 short words at maxChanges = 10. With a smaller limit it shows on every query with more joins than the limit, deterministically.
  • Effect. More combined-term lookups (term-dictionary seeks) than configured and possibly more suggestions, still capped by maxSuggestions; the skipped subset is irregular. No exception, no index change.
  • Scale (a 702-term in-memory index, one host, medians of 5; the lookup counts are exact, the times indicative): defaults, 100,000 one-letter words: 99,999 lookups in 168 ms vs 1,000 in 2.6 ms fixed; maxChanges = 10: 999,945 lookups in 1.5 s vs 1,000 in 3.3 ms. The 100,000 per-word lookups that every mode except SUGGEST_ALWAYS does first take 129 ms.
  • Size of the problem. Without the fix the combined-term lookups grow with the query length times maxChanges (at most (words - 1) x maxChanges); the fix caps them at maxEvaluations whatever the query length. Every mode except SUGGEST_ALWAYS also looks up each query word once (:155-161), so the whole call stays linear in the query length either way; the bug removes the fixed cap the caller asked for. Few deployments tune the setting, and nobody reported it in 14 years.

Minimal repro

One JUnit test, public API only: an index with ab, bc and bcd, the query a b c d, maxChanges 2 and maxEvaluations 1. It asserts at most 1 suggestion; stock Lucene returns 3.

TestWordBreakCombineMaxEvaluations.java (one test class)60 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.search.spell;

import java.util.Arrays;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestWordBreakCombineMaxEvaluations extends LuceneTestCase {

  public void testMaxEvaluationsLimitsCombinations() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter writer = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        for (String word : new String[] {"ab", "bc", "bcd"}) {
          doc.add(new StringField("f", word, Field.Store.NO));
        }
        writer.addDocument(doc);
      }
      try (IndexReader reader = DirectoryReader.open(dir)) {
        Term[] terms = {
          new Term("f", "a"), new Term("f", "b"), new Term("f", "c"), new Term("f", "d")
        };
        WordBreakSpellChecker spellChecker = new WordBreakSpellChecker();
        spellChecker.setMaxChanges(2);
        spellChecker.setMaxEvaluations(1);
        CombineSuggestion[] suggestions =
            spellChecker.suggestWordCombinations(terms, 10, reader, SuggestMode.SUGGEST_ALWAYS);
        // expected: 1 combination evaluated, so at most 1 suggestion; actual: ab, bc and bcd
        assertTrue(
            "expected at most 1 suggestion with maxEvaluations=1, got "
                + Arrays.toString(Arrays.stream(suggestions).map(s -> s.suggestion().string).toArray()),
            suggestions.length <= 1);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:suggest:test --tests org.apache.lucene.search.spell.TestWordBreakCombineMaxEvaluations -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
At most 1 combination evaluated, so at most 1 suggestion (ab).
Observed
3 suggestions, [bc, ab, bcd], from 4 combined-term lookups; with the fix, 1 lookup and [ab].

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

More files, environment, full output262 words

One test class, one method, single thread, nothing random: StringField, ByteBuffersDirectory and the default IndexWriterConfig. It asserts only the setter's contract: each evaluated combination yields at most one suggestion, so maxEvaluations = 1 allows at most one. Pass -Ptests.asserts explicitly, because Lucene's build otherwise picks it at random from the seed; the result is the same both ways.

Formatting: ./gradlew tidy (google-java-format) wraps the Arrays.toString(...) line. With that the test passes the module's format, ECJ and forbidden-apis checks.

Regression test: this same test, as its own class or as a method of TestWordBreakSpellChecker with its own directory (that class shares a different index).

Older releases: the test compiles on 10.0.0 and later (CombineSuggestion is a record there); on 9.x write s.suggestion.string.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0 and 10.5.2: fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestWordBreakCombineMaxEvaluations.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/spell/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests org.apache.lucene.search.spell.TestWordBreakCombineMaxEvaluations -Ptests.seed=DEADBEEF -Ptests.asserts=true
# the same failure with -Ptests.asserts=false

Observed output

# stock main 645d4fe14a20, gradle, -Ptests.seed=DEADBEEF -Ptests.asserts=true
TestWordBreakCombineMaxEvaluations > testMaxEvaluationsLimitsCombinations FAILED
    java.lang.AssertionError: expected at most 1 suggestion with maxEvaluations=1, got [bc, ab, bcd]
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:AAB441975C67B46F]:0)
        ...
        at org.apache.lucene.search.spell.TestWordBreakCombineMaxEvaluations.testMaxEvaluationsLimitsCombinations(TestWordBreakCombineMaxEvaluations.java:53)
1 test completed, 1 failed

# the same message with asserts off, and on releases 10.6.0 and 10.5.2

# lookup counts on the same input (a counting reader, assertions off; evidence, not the repro; log lines, labelled stock:/fix:)
stock: maxEvaluations=1    -> combinedLookups=4 suggestions=[bc, ab, bcd]
stock: maxEvaluations=1000 -> combinedLookups=5 suggestions=[bc, ab, bcd]
fix:   maxEvaluations=1    -> combinedLookups=1 suggestions=[ab]
fix:   maxEvaluations=1000 -> combinedLookups=5 suggestions=[bc, ab, bcd]

Why it looks unintended

  • The setter promises "The maximum number of word combinations to evaluate"; the method itself counts and checks it.
  • The counter is never reset, so it is no per-word budget either: that would allow 2 suggestions here, not 3.
  • #12077 fixed the same symptom for word breaks in 9.5.0 with >= checks; this method was never changed.
The full argument225 words
  • The contract. setMaxEvaluations: "The maximum number of word combinations to evaluate. Default=1000. A higher value might improve result quality. A lower value might improve performance." (WordBreakSpellChecker.java:451-453); getMaxEvaluations: "Returns the maximum number of word combinations to evaluate." (:404-405). "Word combinations" names exactly what this method does.
  • The method tries to apply it. It declares, increments and checks the counter itself (:168, :228-231). An == test with an inner-only break can fire at most once per call and has no coherent other meaning.
  • Not a budget per left term. The counter is never reset. With maxChanges = 5 and maxEvaluations = 1 on six words, such a budget allows 5 lookups; the stock code makes 11.
  • The project treats it as a bug. #12077 (hossman, 2023) reported the same symptom for generateBreakUpSuggestions(); 9007f746a39 fixed it with >= guards and changed only that method. CHANGES.txt:3869 (9.5.0): "GITHUB#12077: WordBreakSpellChecker now correctly respects maxEvaluations (hossman)".
  • Tests. TestWordBreakSpellChecker.testMaxEvaluations and testSmallMaxEvaluations treat the limit as a hard bound for word breaks: "if maxEvaluations is respected, we can't possibly have more suggestions than that". No upstream test sets maxEvaluations for combinations.
  • History. The == check and inner-only break are in the method's first version (LUCENE-3523, 2012) and were never changed; no comment, test, issue or list thread describes the current behaviour as wanted.
The devil's-advocate check105 words

The strongest case for "intended": maxEvaluations is an effort knob meant for the exponential break-up recursion; the combine loop is bounded anyway by words x maxChanges; and the suggestWordCombinations javadoc does not mention the limit.

Why it fails: the method counts and checks the limit itself, the setter's text names word combinations, and a bound that grows with the query is not the fixed cap the setter offers (99,999 lookups instead of 1,000 with the defaults). Because the method javadoc is silent, the contract rests on the setter and the #12077 CHANGES line. What remains is an argument for low priority, not for intent.

Suggested fix

Recommended Check >= before each lookup and leave both loops (validated)

Test thisTimeEvaluations >= maxEvaluations before each combined-term lookup, as the word-break path tests before each evaluation since #12077, and leave both loops with a labeled break. With it the repro passes, and so do all lucene/suggest tests (237 run, 7 skipped, 0 failures).

int thisTimeEvaluations = 0;
outer:
for (int i = 0; i < terms.length - 1; i++) {
  ...
    if (thisTimeEvaluations >= maxEvaluations) {
      break outer;
    }
    thisTimeEvaluations++;
    Term combinedTerm = new Term(terms[0].field(), combinedTermText);
    int combinedTermFreq = ir.docFreq(combinedTerm);
    ...   // and remove: thisTimeEvaluations++; if (... == maxEvaluations) break;
All options and trade-offs3 options · 435 words

One fix was validated through gradle in a scratch worktree of 645d4fe14a20; the smaller variant and a javadoc-only change are listed with their trade-offs.

Check >= before each lookup and leave both loops (validated) (recommended)

WordBreakSpellChecker.suggestWordCombinations, +5/-4 lines: label the outer loop, test the counter before the docFreq lookup, and drop the == check after it.

int thisTimeEvaluations = 0;
outer:
for (int i = 0; i < terms.length - 1; i++) {
  ...
    if (thisTimeEvaluations >= maxEvaluations) {
      break outer;
    }
    thisTimeEvaluations++;
    Term combinedTerm = new Term(terms[0].field(), combinedTermText);
    int combinedTermFreq = ir.docFreq(combinedTerm);
    ...   // and remove: thisTimeEvaluations++; if (... == maxEvaluations) break;

Trade-offs

  • At most maxEvaluations combined-term lookups per call, so at most that many suggestions. A limit of 0 or less makes no combined-term lookups, as the word-break path makes no evaluations then.
  • Callers with a small limit get fewer combination suggestions than today, which is what the limit promises. Joins are tried left to right, so a tight limit favours those that start at earlier words; that is already true for the first left word today.
  • Cost: one int compare per join.
  • Open PR #16847 changes the int docFreq types in this method; the two touch nearby lines (a textual conflict only).

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle --no-daemon, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off.
  • With the fix: the repro and TestWordBreakSpellChecker (5 tests) pass, and all :lucene:suggest:test passes (32 suites, 237 tests, 7 skipped, 0 failures) with assertions on and off. checkGoogleJavaFormat (after tidy of the test; the fix itself is clean), ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass. Other modules were not run.
  • Earlier: the finder's, the independent reproduction's and the intended-behaviour check's tests and the history probe (1 / 3 / 1 / 0 where 1 / 3 / 1 / 0 are allowed) pass with the same change; upstream TestWordBreakSpellChecker 25 runs, 0 failures.

Change == to >= and break out of both loops, check still after the lookup (not run)

if (thisTimeEvaluations >= maxEvaluations) break outer; in place of the current check.

Trade-offs

Smaller diff and the same result for a limit of 1 or more, but a limit of 0 or less still makes one lookup before it stops, unlike the word-break path.

What the checks validated

Not run.

Document that maxEvaluations applies only to word breaks (not run)

Javadoc change only.

Trade-offs

Contradicts the setter's text and the counter the method already keeps, and leaves combinations without a fixed cap. Not recommended.

What the checks validated

Not executed (documentation only).

Downstream note

Apache Solr's word-break spellchecker passes its maxEvaluations parameter to setMaxEvaluations and calls suggestWordCombinations on every spellcheck request, so a limit set there is not applied to word joins either.

How it was found

An AI-assisted hunt of Lucene's spellchecker code checked suggestWordCombinations against the setter's bound on the stock jar: a reader that counts term lookups ran limits 0-10 with maxChanges 1-4 on a 5-word query, and 30 of the 44 configurations exceeded the limit (none with the fix).

The method in full135 words

Method: semi-formal (a bounded check on the stock jar against a stated property).

  • The hunt unit for the spellchecker, dictionaries and word-break code first ran an exact-reference translation check of suggestWordCombinations with the limit unbounded, which could not see this defect.
  • A separate bounded check then tested the setter's bound: a counting FilterLeafReader counted the combined-term docFreq lookups of suggestWordCombinations on a 5-word query for every maxEvaluations from 0 to 10 and maxChanges from 1 to 4. 30 of the 44 configurations exceeded the limit; with the fix shadowed in, none did.
  • A public-API JUnit test reproduced it on main, 10.5.2, 9.12.3 and 9.1.0. Independent checkers then reproduced it with their own tests, built the strongest "intended" case and rejected it, an adjudication re-ran the tests, and a history probe ran it on every release.

Prior reports, history and checks

Prior reports

No prior report (2026-10-26: 51 scripted queries over GitHub, commits, the mailing lists, the JIRA archive, downstream repositories and the web, plus 23 manual searches; re-checked 2026-11-01). Closest: #12077, the same symptom fixed only for word breaks in 9.5.0.

Searches2 searches

Verdict new. #12077 (closed 2023-01-22, fixed by 9007f746a39 in 9.5.0) has the same symptom for the same setting, but its title, body, comments, test and patch are about generateBreakUpSuggestions(), with a different mechanism (a counter returned from recursion) and fix site; nobody mentioned the combinations path. Other related items: #12100 (break-up search order), #4597 (LUCENE-3523, created the class), #16847 (open; changes only the docFreq types in this method), #5652 (LUCENE-4587), #7299 (LUCENE-6237). #12077's CHANGES line is worded for the whole class.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), downstream repositories, dev@/issues@/java-user@/commits@ lists, the web2026-10-26scripted prior-report search (51 queries; every source answered, #12077 at rank 1) and 23 manual searches and reads, including #12077 and #12100 with comments and patches, the LUCENE and SOLR JIRA projects, apache/solr, apache/lucenenet and the file lists of all 407 open apache/lucene PRsno report; related: #12077, #12100, #4597, #16847, #5652, #7299
GitHub issues and PRs (apache/lucene)2026-11-01"suggestWordCombinations", "maxEvaluations", "WordBreakSpellChecker"; the file's upstream commit listnothing new; the last upstream commit to the file is from 2026-06-14, before the tested commit

History

In the method since its first version, LUCENE-3523 (2012, first released in 4.0.0-ALPHA), and never changed. All 133 lucene-suggest releases through 10.6.0 and main are affected. #12077 (9.5.0) fixed only the word-break path.

Versions tested6 rows

Introduced by 642b882d5a5 "LUCENE-3523: Add WordBreakSpellChecker to suggest based on word combinations and/or breaks" (James Dyer, 2012-05-30; svn era, no PR; issue now #4597; same-day branch_4x backport 99fa008a71e). git blame of the check gives 642b882d5a5, and git log --all -S'thisTimeEvaluations == maxEvaluations' over the full history lists only these two as adding the line (its only other hit, aad44bc6e36, is a 2021 branch_8x placeholder commit that removed the whole tree). Later edits of the method (927531255d4, LUCENE-4587, 2012; 3b67b174932, LUCENE-5512, 2014, diamond operator; 2cbf261032d, 2021 reformatting) left the limit logic alone. #12077 (9007f746a39, 2023-01-22, 9.5.0) changed only generateBreakUpSuggestions, so this is an incomplete-fix sibling of #12077, not a regression from it.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off: 4 lookups, 3 suggestions where 1 is allowed
10.6.0, 10.5.2 affectedminimal test fails with the same message
4.0.0-ALPHA to 10.5.2 (132 releases) probea public-API probe evaluates 11, 13, 4 and 15 joins where 1, 3, 1 and 0 are allowed, on every release
9.4.2 vs 9.5.0 #12077word breaks fixed (136 suggestions on 9.4.2, 54 on 9.5.0 at a limit of 100); combinations unchanged (11 on both)
3.6.2 not affectedno WordBreakSpellChecker (the probe does not compile)
main with the fix not affected1 lookup, [ab]; the probe gives 1, 3, 1 and 0

Independent checks

Reproduced by the finder, an independent reproducer, an intended-behaviour devil's advocate, the adjudication, the history probe, the minimal repro and this doc. Cross-model reviews (OpenAI GPT-5.6) could only read: one said real, the other a procedural, low-confidence "not real" while agreeing with the root cause.

Checks and verdicts312 words

Every reproduction ran on stock builds; the fix checks shadowed the patched WordBreakSpellChecker. The cross-model reviewers executed nothing; a separate run of the repro on main failed as claimed.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (bounded check + JUnit) candidate30 of 44 configurations exceed the limit, 0 with the fix; JUnit fails on main (-ea, -da), 10.5.2, 9.12.3 and 9.1.0.
Cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real (low, procedural)Could not run code; agreed with the root cause, found the inputs in contract, rated the intended argument weak. A separate run of the repro on main failed.
Independent reproducer realOwn test: 12 of 12 configurations exceed the limit, maxEvaluations = 0 does 5 lookups; vacuity probe and a word-break control pass; same on 10.5.2 and 9.12.3; its own fix passes.
Cross-model adversary, intended lens (OpenAI GPT-5.6, reading only) real (medium)Intended argument weak; a separate run re-ran the repro for it: fails on main with -ea and -da, passes with the fix.
Intended-behaviour devil's advocate realVariant with maxChanges = 2 and three-letter words: 6 lookups and two three-word suggestions at a limit of 1, ruling out per-call and per-left-word readings; fix: 1 lookup.
Adjudication confirmed (high)Re-ran the independent reproduction's test (4 run, 2 failed, -ea and -da) and the finder's test (fails).
History follow-up every release affectedProbe on all 132 releases through 10.5.2 and main; the #12077 boundary table.
Repro minimization verifiedThe one-test repro fails on 645d4fe14a20 with -ea and -da and passes with the fix.
This doc reproduced, fix validatedGradle runs on 645d4fe14a20 (stock fails; with the fix the repro and all suggest tests pass: 237 run, 7 skipped, 0 failures), 10.6.0 and 10.5.2 fail, lookup counts 4 vs 1.

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

Finding record

LFV-519, snapshot of 2026-11-06

Registry title
WordBreakSpellChecker.suggestWordCombinations does not honour maxEvaluations: the counter is compared with == and the break leaves only the inner loop, so every later left term is still evaluated and a limit of 0 or less is ignored (the #12077 fix in 9.5.0 covered only suggestWordBreaks)
Area
suggest / spell (WordBreakSpellChecker word combinations)
Priority
Low, rank 1124: WordBreakSpellChecker word combinations ignore maxEvaluations, causing only a few extra cheap lookups, the same expansion-limit-not-enforced class as LFV-462.
Impact class
minor
Impact note
Applications that lower maxEvaluations below the number of combinations a query produces (directly, or through a downstream word-break spellcheck component whose maxEvaluations parameter maps onto setMaxEvaluations) get more combined-term docFreq lookups, and possibly more suggestions, than the configured cap on every such query, on every release since 4.0; with the default limit of 1000 it shows only for queries of more than about 1000 terms (roughly 100 short terms with maxChanges=10); the work stays bounded by about (terms - 1) x maxChanges lookups, results stay capped by maxSuggestions, and nothing is thrown or corrupted.
Summary
You call WordBreakSpellChecker.setMaxEvaluations(n), documented as 'the maximum number of word combinations to evaluate', and then suggestWordCombinations, and you expect at most n combined-term lookups and so at most n suggestions. Instead the limit cuts off only the rest of the one left term at which the counter first equals n, and every later left term is evaluated in full: with every 2-6 letter run of 'abcdef' indexed, the query [a, b, c, d, e, f], maxChanges=5 and maxEvaluations=1, 11 of the 15 combinations are evaluated and returned, and maxEvaluations=0 evaluates all 15. Nothing is thrown and the work stays bounded by the number of terms times maxChanges; only the configured cap is not applied.
Introduced
642b882d5a50721ab88ade17b06cd9e2927745b1 (trunk, svn r1344318; same-day branch_4x backport 99fa008a71e), LUCENE-3523 'Add WordBreakSpellChecker to suggest based on word combinations and/or breaks' (now #4597, by jdyer1; svn era, no PR) [read: git blame -w -C -C -M of main lines 168, 204 and 228-231, and git log --all -S'thisTimeEvaluations == maxEvaluations', give only this commit and its backport]; the defect is in the method's first version and was never changed (927531255d4, LUCENE-4587, and the 2021 reformatting 2cbf261032d left the limit logic alone); GITHUB#12077 (9007f746a39 on main, ef8c18b43c7 on branch_9x, 9.5.0) fixed the same symptom only in generateBreakUpSuggestions, so this is an incomplete-fix sibling of #12077, not a regression from it (2012-05-30)
First affected version
4.0.0-ALPHA [exec] (the first release with WordBreakSpellChecker; first GA release 4.0.0 [exec]); 3.6.2 has no such API [exec: compile error]
Affected versions
every lucene-suggest version on Maven Central before 10.6.0 (132): 4.0.0-ALPHA, 4.0.0-BETA and all 130 releases 4.0.0 to 10.5.2, plus main ea57360912ed, fail a public-API main() probe with identical numbers (11, 13, 4 and 15 combinations evaluated where 1, 3, 1 and 0 are allowed) [exec, -ea; also -da on 4.0.0, 9.12.3, 10.5.2 and main]; 10.6.0 (released 2026-10-29) fails the one-test minimal repro on its release jars (SHA-1 checked against Maven Central, -ea, 2026-11-01) [exec]; the finder's JUnit fails on 9.1.0, 9.4.2, 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main [exec]; 9.5.0 (#12077) fixed suggestWordBreaks (136 suggestions on 9.4.2, 54 on 9.5.0 at maxEvaluations=100) and left this path unchanged [exec]; not fixed upstream: the line is still on origin/main a9779ef3c19 and on the branch_10x and branch_10_6 tips [read, 2026-10-26]; unaffected: 3.x and earlier (no API) [exec on 3.6.2]
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
Upstream and prior reports
none found (2026-10-26, all read-only: a scripted prior-report search with 51 queries over GitHub issues and PRs, commits, downstream repositories, the dev@, issues@, java-user@ and commits@ lists, the JIRA archive and the web, every source ok and the expected #12077 at rank 1; 23 manual searches and reads, among them #12077 and #12100 with their comments and patches, the LUCENE and SOLR JIRA projects, the list archives, apache/lucenenet and apache/solr, and the file lists of all 407 open apache/lucene PRs); related prior items: #12077 (closed 2023-01-22, fixed by 9007f746a39 in 9.5.0: the same symptom, maxEvaluations not respected, reported and fixed only for generateBreakUpSuggestions / suggestWordBreaks, with a different mechanism and fix site, while its CHANGES line reads 'WordBreakSpellChecker now correctly respects maxEvaluations'), #12100 (break-up BFS only), #4597 (LUCENE-3523, introduced the code), #16847 (open, changes only the docFreq types in this method, not the check), #5652 (LUCENE-4587), #7299 (LUCENE-6237)
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off; with the recommended fix it passes, as do all lucene/suggest tests (32 suites, 237 tests, 7 skipped, 0 failures) with assertions on and off and the module's format, ECJ and forbidden-apis checks. Releases 10.6.0 and 10.5.2 fail the minimal test. The hunt and its checks ran on main ea57360912ed, which has the same lucene/suggest code; a history probe failed on all 132 releases from 4.0.0-ALPHA to 10.5.2.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:168 (int thisTimeEvaluations = 0, outside the outer loop over left terms), :169 (outer loop), :186 (inner loop, j - i <= maxChanges), :194-196 (maxCombineWordLength break), :204 (ir.docFreq(combinedTerm), one evaluation), :228-231 (thisTimeEvaluations++; if (thisTimeEvaluations == maxEvaluations) break; inner loop only), :451-459 (setMaxEvaluations javadoc 'The maximum number of word combinations to evaluate. Default=1000. A higher value might improve result quality. A lower value might improve performance.')
  • a Lucene main checkout lucene/CHANGES.txt:3836 ('GITHUB#12077: WordBreakSpellChecker now correctly respects maxEvaluations (hossman)')
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:43-51, 148-239, 241-330, 404-411, 451-459
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:34-38, 155-161, 186-196
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:128-147, 168, 228-231, 404-411, 451-459
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/test/org/apache/lucene/search/spell/TestWordBreakSpellChecker.java:91-159 (testMaxEvaluations, testSmallMaxEvaluations; comment at :117, :150), :161 (testCombiningWords), :353 (testRandom)
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:3869
  • a Lucene main checkout git show 9007f746a39 (hunks only in generateBreakUpSuggestions)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:269-272, 299-303, 324-326 (word-break guards)
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:168-233

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 the executed runs on the minimal repro's input; see the diagram's own notes for what each frame shows.

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.