WordBreakSpellChecker suggests by splitting one term into indexed words or joining several terms into one. setMaxChanges(n) caps the number of splits or joins. The join method applies the cap from 0 up; the split method applies it only from the second split on.
maxChanges 0, suggestWordCombinations reads the limit before its first join and returns nothing, while suggestWordBreaks offers the split sun | flower before it ever reads the limit. Select the still to open it at full size. Generated with AI, checked against the code.- Index the words
sun,flower,seedandflowerseed. With the defaultmaxChangesof 1,suggestWordBreaks("sunflower")returns[sun, flower](one break, score 1.0) andsuggestWordCombinations([flower, seed])returnsflowerseed(one combination). - Call
setMaxChanges(0)on the same instance. Its javadoc calls both word breaks and combinations "changes", so you expect no suggestion from either method. suggestWordCombinationsreturns none, as expected.suggestWordBreaks("sunflower")still returns[sun, flower]with score 1.0.setMaxChanges(-1)gives the same. No error.- Why:
suggestWordBreaksalways starts at one break and offers the one-break splits first. It readsmaxChangesonly to decide whether to try a second break, so any value below 1 acts like 1.
Direct users of WordBreakSpellChecker who set maxChanges to 0 or below, say to mean "no changes", and still call suggestWordBreaks. The default and every value of 1 or more are not affected by this defect, and stock Solr never passes a value below 1. The only effect is extra suggestions built from words already in the index.
Mechanism, terms and numbers480 words
WordBreakSpellChecker (lucene-suggest) suggests corrections by breaking one term into indexed words (sunflower -> sun flower) or combining several terms into one. setMaxChanges documents "The maximum numbers of changes (word breaks or combinations) to make on the original term(s). Default=1" and accepts any int. With a maximum of 0, suggestWordCombinations returns nothing, but suggestWordBreaks still returns the one-break suggestions that a maximum of 1 would give, scored 1 (one break): with sun, flower, seed and flowerseed indexed, suggestWordBreaks("sunflower") returns [sun, flower]; a negative value behaves the same. The reason is that suggestWordBreaks always starts its search at one break and reads maxChanges only before trying a second one. The default and every value of 1 or more are not affected by this defect, Solr's stock integration never passes a value below 1, and the only effect is extra suggestions. It dates from the class's first commit (LUCENE-3523, 2012), reproduces on 17 releases from 4.0.0-ALPHA to 10.5.2 and on main (10.6.0 has the same file, by reading), and a one-line early return, like the existing one for maxSuggestions < 1, fixes it.
WordBreakSpellChecker(lucene-suggest,org.apache.lucene.search.spell) offers two kinds of suggestions:suggestWordBreaks(term, ...)splits one term into words that exist in the index, andsuggestWordCombinations(terms, ...)joins adjacent terms into one indexed word.maxChanges(default 1): "The maximum numbers of changes (word breaks or combinations) to make on the original term(s)". A suggestion's score is its number of changes: for breaks the javadoc says "The scores returned are equal to the number of word breaks needed".
suggestWordBreakscallsgenerateBreakUpSuggestions(term, ir, 1, ...): the break count starts at 1 whatevermaxChangesis (WordBreakSpellChecker.java:109-118).- Phase 1 of that method tries every split position and offers each pair of indexed words to the result queue (
:268-290).maxChangesis not read here. - Only then does it compute
newNumberBreaks = numberBreaks + 1and return ifnewNumberBreaks > maxChanges(:298-303). That check only stops a second break. suggestWordCombinationsinstead bounds its loop withj - i <= maxChanges(:186): with 0 the loop never runs, so no combination.
Index sun, flower, seed, flowerseed (each a single token); sunflower and sunflowerseed are not indexed:
maxChanges | suggestWordBreaks("sunflower") | suggestWordBreaks("sunflowerseed") | suggestWordCombinations([flower, seed]) |
|---|---|---|---|
| 2 | [sun, flower] (1.0) | [sun, flowerseed] (1.0), [sun, flower, seed] (2.0) | flowerseed |
| 1 (default) | [sun, flower] (1.0) | [sun, flowerseed] (1.0) | flowerseed |
| 0 | [sun, flower] (1.0), expected none | [sun, flowerseed] (1.0), expected none | none |
| -1 | [sun, flower] (1.0), expected none | [sun, flowerseed] (1.0), expected none | none |
Values 1 and 2 cap the breaks as set (rows 1 and 2); every value below 1 returns what 1 returns. (A separate bug in the same check, the full-queue early return, can drop better suggestions at maxChanges 3 or more; it has its own fix.)
Exposure and likelihood239 words
- Who. Direct Lucene API users of
WordBreakSpellCheckerwho setmaxChangesto 0 or a negative value and still callsuggestWordBreaks: for example an application that maps a "no changes" setting onto one instance used for both methods, or that computes the value and can reach 0. The setter accepts it silently. - Not affected by this defect. The default (1) and every value of 1 or more; upstream tests use only values of 1 or more. Solr's word-break spell checker calls
setMaxChangesonly for values above 0 and turns breaks off with a separatebreakWordsoption, so it cannot be triggered through the stock Solr setting (from reading Solr's source); custom code that calls Lucene directly can. - Effect. Extra spelling suggestions: the one-break suggestions that
maxChanges1 would give, each scored 1. The index is unchanged; what the extra suggestions do downstream depends on the caller (for example an application that rewrites queries from them). No exception or hang occurred in any run, and the work is the same as with the default of 1, because phase 1 is bounded by the term length andmaxEvaluations. - Scale. Deterministic at any index size: a call that returns one-break suggestions at
maxChanges1 returns the same ones at 0 or below (the usual mode, frequency, length and result-count limits still apply). - How likely. Rare: it needs a deliberate value below 1, and no matching public report since 2012 was found.
The test shows that WordBreakSpellChecker.suggestWordBreaks ignores setMaxChanges(0) ("the maximum numbers of changes (word breaks or combinations)"): with the terms a and b indexed it still splits ab into [a, b].
/*
* 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 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.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 TestWordBreakMaxChangesZero extends LuceneTestCase {
public void testMaxChangesZeroAllowsNoWordBreak() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(null))) {
Document doc = new Document();
doc.add(new StringField("f", "a", Field.Store.NO));
doc.add(new StringField("f", "b", Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
WordBreakSpellChecker wb = new WordBreakSpellChecker();
wb.setMaxChanges(0);
SuggestWord[][] breaks =
wb.suggestWordBreaks(new Term("f", "ab"), 5, reader, SuggestMode.SUGGEST_ALWAYS, null);
// setMaxChanges(0) = at most 0 word breaks: expected 0 suggestions, actual 1 ([a, b])
assertEquals("suggestWordBreaks(ab) with maxChanges=0", 0, breaks.length);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-800/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/suggest/build/libs/lucene-suggest-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.spell.TestWordBreakMaxChangesZero
- Expected vs actual
- Expected 0 suggestions for
abwithmaxChanges = 0; actual 1 (expected:<0> but was:<1>), with-eaand-da.
More files, environment, full output36 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: suggestWordBreaks(ab) with maxChanges=0 expected:<0> but was:<1>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.search.spell.TestWordBreakMaxChangesZero.testMaxChangesZeroAllowsNoWordBreak(TestWordBreakMaxChangesZero.java:46)- The setter's javadoc: "The maximum numbers of changes (word breaks or combinations) to make on the original term(s)", with no minimum.
- Lucene scores the returned split 1.0, defined as "the number of word breaks needed": one break above a maximum of 0.
- Combinations honour 0, and an upstream test asserts that too low a
maxChangesleaves no suggestion.
The full argument227 words
- Contract.
setMaxChanges: "The maximum numbers of changes (word breaks or combinations) to make on the original term(s). Default=1" (WordBreakSpellChecker.java:441-443);getMaxChanges: "Returns the maximum number of changes to perform on the input" (:395-396). No minimum, no exception for breaks, no validation (:447-449). - Lucene's own score. "The scores returned are equal to the number of word breaks needed" (
:65-67); atmaxChanges0 the returned words have score 1.0. - Sibling method.
suggestWordCombinationstreats the same field as a true upper bound including 0 (:186), unchanged since 2012. - Same method, other limits.
maxSuggestionsbelow 1 returns nothing (:81-83), andmaxEvaluations0 returns before the first split (:269). - Upstream test.
TestWordBreakSpellChecker.java:294-316: "onethousandonehundredeleven" needs 4 breaks; atsetMaxChanges(3)the test asserts 0 suggestions, at 4 one. SomaxChangesis meant to be able to leave nothing. - Explicit floors look different.
minBreakWordLength0 is floored on purpose withMath.max(minBreakWordLength, 1)(:254; in 2012,if (useMinBreakWordLength < 1)). Nothing floorsmaxChanges; its "floor" is only where the check happens to sit. - History. The 2012 original already checked
if (newNumberBreaks <= maxChanges)only around the recursive call, after offering the split; LUCENE-4587, #12077 ("now correctly respects maxEvaluations") and the #12100 rewrite kept that shape. No issue, PR, CHANGES or MIGRATE entry discusses values below 1.
The devil's-advocate check201 words
The strongest works-as-designed case (the cross-model lenses and the devil's advocate rated it moderate): since 2012 maxChanges has acted as a depth limit after a first split that always happens; the class's author also wrote the Solr integration, which never passes a value of 0 or less and has a separate breakWords switch; the class silently floors another knob; and a 2014 solr-user@ mail by the author describes maxChanges loosely as "the maximum number of words it can break 1 word into ... reasonable to set this to 1 or 2". On that reading, values below 1 are unsupported, and a caller who wants no breaks should not call suggestWordBreaks.
Why it fails: no javadoc, test, CHANGES or MIGRATE entry states a minimum; the setter counts word breaks as changes; Lucene scores the returned suggestion as one break; the same field is a true bound at 0 in the sibling method; and the one floor the class has is explicit code. The case does bear on the resolution: documenting and validating maxChanges >= 1 is a reasonable maintainer choice, which is why the bug ranks low. Verdict: not intended; an unvalidated boundary value that one of the two methods ignores.
Recommended A: suggestWordBreaks returns nothing when maxChanges < 1
In suggestWordBreaks, return no suggestion when maxChanges < 1, in the existing maxSuggestions < 1 early return, and add one javadoc sentence. With it the whole suggest module passes, repro tests included, with assertions on and off. Alternative: reject values below 1 in the setter, which also turns today's working 0 for combinations into an exception.
if (maxSuggestions < 1 || maxChanges < 1) {
return new SuggestWord[0][0];
}
...
/**
* The maximum numbers of changes (word breaks or combinations) to make on the original term(s).
* Default=1
*
* <p>A value below 1 allows no change: neither method then returns a suggestion.
*
* @see #getMaxChanges()
*/All options and trade-offs3 options · 557 words
Two code fixes were built and run in a scratch worktree at a9779ef3c193; a javadoc-only option is listed for completeness. A is recommended because it makes the two methods agree without changing anything for values of 1 or more.
Extend the existing early return at the top of suggestWordBreaks (WordBreakSpellChecker.java:81) and say in the setter's javadoc what a value below 1 means. +3 lines.
if (maxSuggestions < 1 || maxChanges < 1) {
return new SuggestWord[0][0];
}
...
/**
* The maximum numbers of changes (word breaks or combinations) to make on the original term(s).
* Default=1
*
* <p>A value below 1 allows no change: neither method then returns a suggestion.
*
* @see #getMaxChanges()
*/Trade-offs
- Values of 1 or more never reach the new condition, so their results are unchanged; values below 1 now do less work than today.
- A caller that relied on 0 still giving one-break suggestions would get none. Nothing upstream or in Solr does.
- For values below 1 it also returns before the
suggestModeandsortMethoddefaults and beforeir.docFreq(term), so the reader is not touched: a closed or failing reader that used to raise an I/O error there would not. The existingmaxSuggestions < 1return already behaves this way.
What the checks validated
Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the whole :lucene:suggest:test with the three repro classes added, 34 classes, 246 tests, 0 failures (7 skipped by the module itself), with assertions on and off; the 5 repro tests that fail on stock pass, and upstream TestWordBreakSpellChecker passes 5 of 5. :lucene:suggest:check -x test with the fix and the primary test added passes (google-java-format, ecj lint, forbidden APIs).
setMaxChanges throws IllegalArgumentException("maxChanges must be at least 1, got " + maxChanges) and its javadoc says "Must be at least 1". A related precedent in the same package: DirectSpellChecker.setMaxEdits documents "This value can be 1 or 2" and range-checks its argument, throwing UnsupportedOperationException("Invalid maxEdits") below 1 or above 2 (DirectSpellChecker.java:107-118). Fix B uses IllegalArgumentException, so it does not copy that exception type.
What was run (not a full validation). Gradle, same worktree and seed, assertions on only: all 31 upstream suggest test classes (236 tests) pass, so no upstream test sets a value below 1. The run as a whole failed (240 tests, 3 failures): in the unchanged primary repro the 3 tests that set 0 or -1, the combinations control included, now get the exception. No test that asserts the new exception was written, and B was not run with assertions off. :lucene:suggest:check -x test passes.
public void setMaxChanges(int maxChanges) {
if (maxChanges < 1) {
throw new IllegalArgumentException("maxChanges must be at least 1, got " + maxChanges);
}
this.maxChanges = maxChanges;
}Trade-offs
Also changes suggestWordCombinations: setMaxChanges(0) works there today as "no combinations", and such callers would start to fail. That needs a CHANGES entry and suits a major release better than a bug-fix release. Solr is unaffected (it never passes values of 0 or less).
Say in the javadoc that suggestWordBreaks always considers one break and that maxChanges limits only further breaks.
Trade-offs
Keeps the two methods inconsistent under one setting. Not run (no code change).
Solr's stock word-break spell checker passes maxChanges to Lucene only when it is above 0, so its setting cannot trigger this; custom code that calls Lucene directly can. That is from reading Solr's source, not from a run.
Semi-formal: a bounded-exhaustive comparison of the real class with an exact reference model, over randomly generated small indexes and configurations (1,224,000 checks), flagged 148,130 mismatches at maxChanges 0. It was first set aside as a degenerate setting, then reduced to a public-API JUnit test that fails on main.
The method in full168 words
Method: bounded-exhaustive translation validation (the hunt's "semi-formal" tier): an independent, exact reference of suggestWordBreaks (every split of the term into indexed words with at most maxChanges breaks, score = number of breaks) compared with the stock jar on every query up to a small length, over randomly generated small indexes and configurations.
- Scope: 200 random indexes over the strings of length 1-6 over {a, b}, all 510 queries of length 1-8, and 12 random configurations with
maxChanges0-4: 1,224,000 checks, run on the earlier mainea57360912ed. - Result: 148,130 mismatches, all at
maxChanges= 0 (this bug), plus 984 atmaxChanges3-4 that are a separate bug (the full-queue early return). A mutated reference produced mismatches as well, so the comparison was not vacuous. - The first unit left the
maxChanges0 lead unpromoted as "degenerate configuration, low value". A later pass over executed leads re-ran it on main and reduced it to a 3-word public-API JUnit test. An independent reproducer wrote its own test from the claim alone.
No prior report found (54 tool queries plus manual searches over Lucene issues, PRs, commits, JIRA, mailing lists, Solr and other downstream projects and the web, 2026-10-27). The nearest items, #12077 and #12100, changed the same method for other reasons and never mention a maxChanges of 0.
Searches4 searches
Registry verdict new: the searches below found no public report or discussion of it by anyone (best-effort: first-page GitHub results, keyword-based list and JIRA search). Nearest items: #12077 (maxEvaluations not respected in this method, fixed in 9.5.0) and #12100 (the breadth-first rewrite, 9.12.0), whose discussion assumes the first split; #4597 / LUCENE-3523 (introduced the class; nothing about values below 1); #7299 and #5652 (other topics); open #16847 (changes only docFreq types in this file); several SOLR issues that quote maxChanges only in configurations (values 3 to 25); a 2014 solr-user@ mail in which the class author describes maxChanges informally. Two other bugs in this class are tracked separately with their own fix sites: suggestWordCombinations ignores maxEvaluations, and the full-queue early return misses better suggestions at maxChanges 3 or more.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA; downstream; web | 2026-10-27 | prior-report tool, 54 queries (16 GitHub, 3 commit, 2 downstream, 20 list, 4 JIRA, 9 web), 199 distinct hits | no report; nearest #12077 and #12100 |
| GitHub (apache/lucene, apache/lucene-solr, apache/solr, Lucene.NET, Elasticsearch, OpenSearch) | 2026-10-27 | manual searches for maxChanges, WordBreakSpellChecker, suggestWordBreaks, setMaxChanges; the changed files of all 409 open apache/lucene PRs | no report; only open #16847 touches the file (docFreq types) |
| ASF JIRA (all projects), lists.apache.org | 2026-10-27 | maxChanges and class-name queries; all 140 list mails that mention maxChanges scanned for 0, negative values, "disable" or "ignore" | no hit; SOLR issues quote maxChanges only in configurations |
| apache/lucene PRs and issues created since 2026-10-25 | 2026-10-27 | refresh (13 PRs, 6 issues) | none touches the spell code |
Present since the class's first commit, 642b882d5a5 (LUCENE-3523, 2012-05-30); first released in 4.0.0-ALPHA. Every later rewrite of the break search kept the unconditional first break. A public-API probe reproduces it on 16 releases from 4.0.0-ALPHA to 10.5.2 and on main; 3.6.2 has no such class.
Versions tested7 rows
Introduced by 642b882d5a50721ab88ade17b06cd9e2927745b1 "LUCENE-3523: Add WordBreakSpellChecker to suggest based on word combinations and/or breaks" (James Dyer, @jdyer1; 2012-05-30, svn r1344318; JIRA LUCENE-3523, now #4597) and its same-day branch_4x commit 99fa008a71e, which 4.0.0-ALPHA ships. The first call already passed numberBreaks = 1, and if (newNumberBreaks <= maxChanges) guarded only the recursion. LUCENE-4587 (4.1.0, as the backport 669fa5d4a6a of trunk 927531255d4), #12077 (9.5.0, ef8c18b43c7, backport of main 9007f746a39) and #12100 (9.12.0, 3eadfe6031c, backport of main cc854f408fd) kept that shape. Not a regression. A static check of all 123 release tags from 4.0.0-alpha to 10.5.2 finds the same first call and no other maxChanges read in the break path; branch_10_6 and branch_10x have the 10.5.2 file, and upstream main (c5ab3e57b9b, fetched 2026-10-28) the same file as a9779ef3c193.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | primary test fails 2 of 4 through gradle, assertions on and off; the controls pass |
| 10.5.2, 9.12.3 | affected | primary test fails 2 of 4 on the published jars; the finder's and the independent reproducer's tests fail too |
| 4.0.0-ALPHA to 10.0.0 (14 releases) | affected | public-API probe: maxChanges 0 and -1 both return [sun, flower] for sunflower; three controls pass (4.0.0-ALPHA, 4.0.0, 4.1.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.4.2, 9.5.0, 9.11.1, 9.12.0, 10.0.0) |
| 8 releases, 9.1.0 to 10.5.2 | finder's JUnit | fails on 9.1.0, 9.4.2, 9.5.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0 and 10.5.2 |
642b882d5a5 (LUCENE-3523, 2012) | introducing commit | the class is born with the unconditional first break (read) |
| 3.6.2 | API absent | no WordBreakSpellChecker; the probe does not compile (older releases lack the class too, by history) |
fix A on a9779ef3c193 | not affected | the whole suggest module, repros included: 246 tests, 0 failures, assertions on and off |
An independent reproducer, an intended-behaviour devil's advocate, cross-model reviews and an adjudication. Every executed re-run failed on main with assertions on and off and passed with the fix; the cross-model reviewers could only read code, and one voted not real on procedure only while agreeing on the root cause.
Checks and verdicts299 words
Each check worked on its own; the registration check and a credibility audit re-ran the tests with fresh seeds, and this doc re-ran them through gradle on a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder (exhaustive comparison, JUnit) | candidate | JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; with the fix 1 of 1 passes and upstream TestWordBreakSpellChecker passes 5 of 5. |
| cross-model review, refute lens | not real, low (procedural) | An independent reviewer from another model family (OpenAI GPT-5.6) could not run code, so it voted not real on procedure only. It agreed with the root cause, found the inputs in contract and rated the intended argument moderate. |
| independent reproducer | real | Own test (sunflower): 2 of 5 fail on main (-ea, -da), 10.5.2, 9.12.3 and 9.11.1; 3 controls pass; 5 of 5 with the fix. |
| cross-model review, intended lens | real, medium | Read-only; intended argument rated moderate. The step re-ran the finder's test on main (fails, -ea and -da) and the independent test with the fix (5 of 5 pass). |
| intended-behaviour devil's advocate | real, Low | Built the depth-limit and Solr case and showed why it fails; own probe: 0 and -3 return exactly what 1 returns, minBreakWordLength 0 is floored by explicit code, maxSuggestions 0 and maxEvaluations 0 mean nothing. |
| adjudication | confirmed, medium | Re-ran the independent test (2 of 5 fail, controls pass); impact class lowered to minor. Medium because upstream may prefer a javadoc or validation fix. |
| security screen | not security-sensitive | A trusted configuration value (SECURITY.md line 42); the effect is extra suggestions only. |
| registry re-run and credibility audit | keep | Finder's test fails with -ea and -da on a clean checkout and passes with the fix; the audit's own probe fails with its controls passing. |
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 492 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,271 words
- Registry title
- WordBreakSpellChecker.suggestWordBreaks ignores a maxChanges below 1: with setMaxChanges(0) it still returns one-break suggestions, while suggestWordCombinations returns none
- Area
- suggest / spell (WordBreakSpellChecker word breaks)
- Priority
- Low, rank 456: WordBreakSpellChecker still returns one-break suggestions only when maxChanges is set to the degenerate value 0 or below, which the default never does.
- Impact class
- minor
- Impact note
- Hits only direct Lucene API users who call WordBreakSpellChecker.setMaxChanges(0) or a negative value (for example to mean 'no changes' on one instance used for both suggest methods) and then call suggestWordBreaks: they get every valid one-break split (score 1) where the documented maximum allows none; the default (1) and every value >= 1 behave correctly, a downstream spellcheck integration (Solr's WordBreakSolrSpellChecker) forwards maxChanges only when it is > 0 so its users cannot reach it, nothing throws, hangs or is corrupted, and it is deterministic at any scale in every release since 4.0.0-ALPHA and on main.
- Summary
- You configure a WordBreakSpellChecker with setMaxChanges(0), documented as 'The maximum numbers of changes (word breaks or combinations) to make on the original term(s)', and expect no word-break and no combination suggestions. suggestWordCombinations indeed returns none, but suggestWordBreaks still returns every one-break split: with the terms a, b and ab indexed, suggestWordBreaks('ab') returns [a, b] with score 1 (one break), and with sun and flower indexed 'sunflower' returns [sun, flower]; negative values behave the same. The method always makes the first break and reads maxChanges only before it tries a second one.
- Introduced
- 642b882d5a50721ab88ade17b06cd9e2927745b1 (trunk, svn r1344318, 'LUCENE-3523: Add WordBreakSpellChecker to suggest based on word combinations and/or breaks', by jdyer1; JIRA LUCENE-3523 = apache/lucene#4597; svn era, no pull request) and its same-day branch_4x commit 99fa008a71ef1600bf4217824f7786682432a48c (svn r1344329, the code 4.0.0-ALPHA ships): the class was born with suggestWordBreaks starting generateBreakUpSuggestions at numberBreaks = 1 and maxChanges read only before the recursion to a second break [read: history clone; a static check of all 123 release tags 4.0.0-alpha..10.5.2 shows the same first call and no other maxChanges read in the break path]; LUCENE-4587 (927531255d4, 4.1.0), #12077 (9007f746a39, 9.5.0) and the #12100 breadth-first rewrite (cc854f408fd, 9.12.0) kept the unconditional first break. Not a regression. (2012-05-30 (trunk 642b882d5a5 at 15:58Z; branch_4x 99fa008a71e at 16:10Z))
- First affected version
- 4.0.0-ALPHA [exec: the version-portable history probe reports the bug, plain classpath, jars SHA-1-checked against Maven Central]; first GA release 4.0.0 [exec, -ea and -da]; 3.6.2 is not affected because it has no WordBreakSpellChecker [exec: compile error]
- Affected versions
- every release that has WordBreakSpellChecker, 4.0.0-ALPHA to 10.5.2, 10.6.0 (by reading) and main a9779ef3c193 (unreleased 11.0) [exec: one public-API probe (index sun, flower, seed, flowerseed; 3 controls must pass) reports maxChanges 0 and -1 both returning [ (sun flower ) ] for 'sunflower' on 4.0.0-ALPHA, 4.0.0, 4.1.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.4.2, 9.5.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.5.2 and main with -ea, and with -da on 4.0.0 and 10.5.2; with the fix shadowed it passes on main; the finder's JUnit fails on 9.1.0, 9.4.2, 9.5.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea, and -da on main); the independent reproduction's JUnit fails on main (-ea, -da), 10.5.2, 9.12.3 and 9.11.1; the intended-behaviour check's on main (-ea, -da) and 10.5.2]; the releases in between by the static check of all 123 release tags [read] (8.8.2-8.11.3 are untagged in the history clone and affected by inference). branch_10_6 and branch_10x heads: same WordBreakSpellChecker.java blob as 10.5.2 [read, not executed]. Not fixed upstream: origin/main 20ced71c1e6 has the same blob as a9779ef3c193, and no open PR changes the check [read, 2026-10-27]. Unaffected: 3.6.2 and earlier (no API) [exec]. Executed on Linux aarch64 with JDK 25 only.
- 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 (the prior-report record verdict new, searched 2026-10-27, read-only; the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches found nothing either): a scripted prior-report search with 54 queries (16 GitHub issue/PR queries on apache/lucene, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 9 web; 199 distinct hits, none the same bug), manual GitHub searches over apache/lucene, apache/lucene-solr, apache/solr, Lucene.NET and downstream engines, the changed-file lists of all 409 open apache/lucene PRs, ASF JIRA (all projects), a scan of every lists.apache.org mail that mentions maxChanges (no mail sets it to 0 or a negative value), web searches and a Stack Overflow question. Nearest items, none the same bug: #12077 (maxEvaluations in generateBreakUpSuggestions, fixed in 9.5.0) and #12100 (breadth-first rewrite, 9.12.0): same method, other parameter, and their discussion never mentions maxChanges 0; #4597 / LUCENE-3523 (introduced the class; never discusses values below 1); #7299 / LUCENE-6237 (open, ordering); #5652 / LUCENE-4587; #16847 (open, changes only the docFreq types in this file); SOLR-2993, SOLR-10263, SOLR-8010, SOLR-6297, SOLR-4489 (maxChanges appears only in pasted configurations); a 2014 solr-user@ thread in which the class author (jdyer1) describes maxChanges informally in words, not breaks. Refresh (2026-10-27, read-only gh): the 13 apache/lucene PRs (#16905-#16923) and 6 issues (#16906-#16922) created since 2026-10-25 do not touch lucene/suggest spell code; searches for maxChanges, WordBreakSpellChecker, suggestWordBreaks and setMaxChanges return only #12077, #12100, #7299, #4597 and #5652. Not fixed upstream (origin/main 20ced71c1e6, branch_10_6, branch_10x). Gaps: GitHub search reads only the first page per query; list and JIRA search is keyword-based; private reports to the security list are not visible.
- Lucene commit tested
- main
a9779ef3c193(gradle in a scratch worktree: the repro tests stock with assertions on and off; fix A on the whole suggest module with assertions on and off, plus:lucene:suggest:check -x test; fix B on the whole suggest module, where the upstream tests pass and the primary repro's 0 and -1 tests then throw); the published 10.5.2 and 9.12.3 jars (the primary test); a public-API probe on 16 releases from 4.0.0-ALPHA to 10.5.2 and on main, and the finder's JUnit on 8 releases from 9.1.0 to 10.5.2 (the hunt, 2026-10-27). JDK 25.0.3, Linux aarch64.
- Finding record
lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:37 (at a9779ef3c193) (default maxChanges = 1), :64-67 (suggestWordBreaks javadoc: 'The scores returned are equal to the number of word breaks needed'), :81-83 (maxSuggestions < 1 returns nothing), :109-118 (generateBreakUpSuggestions called with numberBreaks = 1 whatever maxChanges is; fix site), :186 (suggestWordCombinations loop bound 'j - i <= maxChanges'), :254 (explicit floor Math.max(minBreakWordLength, 1)), :268-290 (phase 1 offers every valid one-break split), :298-303 (maxChanges checked only before phase 2, 'newNumberBreaks > maxChanges'), :395-400 (getMaxChanges javadoc 'the maximum number of changes to perform on the input'), :441-449 (setMaxChanges javadoc 'The maximum numbers of changes (word breaks or combinations) to make on the original term(s). Default=1', no validation)
- The finding in 30 seconds
a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:37, 64-67, 109-118, 186, 268-290, 298-303, 441-449
- Who is affected
a Lucene main checkout lucene/suggest/src/test/org/apache/lucene/search/spell/TestWordBreakSpellChecker.java:102-391 (every setMaxChanges call uses 1 or more)
- Why it looks unintended
a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:65-67, 81-83, 186, 254, 269, 395-396, 441-449a Lucene main checkout lucene/suggest/src/test/org/apache/lucene/search/spell/TestWordBreakSpellChecker.java:294-316a Lucene main checkout lucene/CHANGES.txt:2622-2623, 3852
- Suggested fix
a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/DirectSpellChecker.java:107-118
- TestWordBreakMaxChangesZero.java (JUnit test):
repro/LFV-800/src/org/apache/lucene/search/spell/TestWordBreakMaxChangesZero.java(50 lines)
The PNG is its final summary card.
assets/LFV-800/bug.png(492 KB)
![Summary card in three panels. 1, You set maxChanges = 0: the setMaxChanges javadoc quote, the index terms sun, flower, seed, flowerseed (sunflower is not indexed), the two calls, and the expected result: no breaks and no combinations. 2, Where each method first reads maxChanges: suggestWordCombinations checks j - i = 1 <= 0 before its first change and stops; suggestWordBreaks has no check before change 1, makes the split sun | flower in phase 1, and only then checks 2 > 0 at line 300 and stops. 3, Result on main with -ea and -da: suggestWordBreaks("sunflower") at 0 or -1 expected [] but got [[sun, flower]], score 1 = one break; suggestWordCombinations([flower, seed]) at 0 gives []; maxChanges 1 and 2 bound the breaks; the fix (no break when maxChanges < 1), executed with -ea, gives [] at 0 and -1 and leaves 1 and 2 unchanged.](../assets/LFV-800/bug.png)