Word-break suggestions split an unknown term into indexed words, ranked by fewest breaks, then word frequency. The search keeps the best N in a bounded queue and, since 9.12.0, stops early when the queue is full. As written, that shortcut assumes a breadth-first search, and this one is depth-first across branches.
abcde over the words a ab b c d e with a one-slot queue: the branch starting with a fills the queue with a b c d e; the branch starting with ab then stops at the full-queue check before it reaches ab c d e. Select the still to open it at full size. Generated with AI, checked against the code.- Index the words
a ab b c d eand askWordBreakSpellCheckerwithsetMaxChanges(4)(the default is 1) for one word-break suggestion for the unknown termabcde. Two splits exist:ab c d e(3 breaks) anda b c d e(4 breaks). - You expect
ab c d e: splits rank by fewest breaks first, and asking for 2 suggestions does return[ab c d e, a b c d e]. - You get
[a b c d e], with both sort orders and even with an unlimitedmaxEvaluations. No error. WithsetMaxChanges(3)you getab c d e, so raising the limit made the answer worse. - Why: the search follows the branch that starts with
aall the way down and queuesa b c d e, which fills the one-slot queue. In the branch that starts withab, a full-queue check added in 9.12.0 returns beforecdeis split intoc d e, assuming everything not yet generated has more breaks. - On real words, the index of Lucene's own word-break test (the English number words 900-1111 and a few more):
thousandonehundredeightwithmaxChanges4 returns the 4-breakthou sand one hundred eightin its top 3 instead of the 3-breakthousand one hundred eight. WithmaxChanges3,thousandninehundredeightwith 2 suggestions gets a lower-frequency second split (highest docFreq 110 instead of 112).
Code that calls suggestWordBreaks after setMaxChanges(3) or more, directly or through a downstream word-break spell-check component. The default maxChanges 1, and 2, are not affected, and only splits into 4 or more words can be lost. The wrong split still looks plausible, and raising maxChanges can make it worse. Query matching and the index are not affected.
Mechanism, terms and numbers889 words
WordBreakSpellChecker.suggestWordBreaks splits an unknown term into indexed words and should return the best maxSuggestions splits by Lucene's own order: fewest breaks first, then the highest (or summed) word frequencies. Since 9.12.0 (#12100) it returns early once its result queue is full, on the stated assumption that every split it has not generated yet has more breaks than every queued one. That holds for a breadth-first search, but the search finishes one sibling branch's whole subtree before the next, so the queue can already hold splits with as many or more breaks. With setMaxChanges(3) or more, a better split can then be lost: on the words a ab b c d e, abcde with one suggestion gives a b c d e (4 breaks) instead of ab c d e (3 breaks), while two suggestions give both in the right order and maxChanges 3 gives the right one. It happens with an unlimited evaluation budget, the default maxChanges 1, and 2, are not affected, and only splits into 4 or more words can be lost. The releases run from 9.12.0 to 10.5.2 and main fail (the others in that range carry the same file); 9.11.1 passes. Returning early only when the queue's worst split has fewer words than any deeper split can have fixes it (2 code lines replace 1), and the suggest module's tests pass with it.
WordBreakSpellChecker(lucene-suggest,org.apache.lucene.search.spell) suggests splitting an unknown term into words that are in the index (icecream->ice cream), or combining terms.suggestWordBreaks(term, maxSuggestions, reader, suggestMode, sortMethod)returns at mostmaxSuggestionssplits. A split's score is its number of breaks.BreakSuggestionSortMethodorders them "by Number of word breaks, then by the Maximum [or Sum] of all the component term's frequencies" (WordBreakSpellChecker.java:54-62).setMaxChanges(n)caps the breaks per split (default 1,:37,:441-449).setMaxEvaluations(n)caps the work (default 1000) and "might improve result quality" when raised (:451-459).
- The best splits are kept in a bounded priority queue whose head is the worst split; when it overflows the worst is dropped (
:91-97,:283-287). - Each call of
generateBreakUpSuggestions(:241-330) first queues the two-word splits of its text whose words pass the frequency threshold and records the valid first words (phase 1,:265-290, within themaxEvaluationsbudget). - Then it returns at once if the queue is full (
:299-303), "because any subsequent suggestions are guaranteed to have more changes then anything currently in the queue, and not be competitive" (:295-296). - Otherwise it recurses into the rest of the text after each valid first word, finishing one sibling's whole subtree before the next (phase 2,
:305-327). - So when a call that runs after an earlier sibling subtree (its own earlier sibling's or an ancestor's) reaches its check, the queue may hold splits from that subtree with as many or more breaks than the call's own deeper splits. The call returns before it generates them, although they can rank higher.
At the top level the comment is right (only 1-break splits are queued, and everything later has 2 or more). Every call runs its phase 1 before the check, and the top level returns only when its queue is full of 2-word splits, so no competitive split of 2 or 3 words is lost: maxChanges 1 and 2 are not affected (a finite maxEvaluations can still cut any search short, as documented). With maxChanges 3 a split is lost only to one with the same 3 breaks and lower frequency; with 4 or more, also to one with more breaks.
Default NUM_CHANGES_THEN_MAX_FREQUENCY, maxEvaluations 1000, stock main:
| index / term | maxChanges | top 1 (or top N) | Lucene's own longer list |
|---|---|---|---|
a ab b c d e / abcde | 3 | ab c d e | ab c d e |
| same | 4 or 10 | a b c d e (4 breaks) | ab c d e, a b c d e |
a bc c d e ab, ab, ab / abcde | 3 | a bc d e (docFreqs 1, 1, 1, 1) | ab c d e (ab in 3 docs), a bc d e |
number words / thousandonehundredeight | 4 | top 3: thousand one hundredeight, thou sand one hundredeight, thou sand one hundred eight | top 4 adds the 3-break thousand one hundred eight |
number words / thousandninehundredeight | 3 | top 2: thousand nine hundredeight, thou sand nine hundredeight (110) | top 10: ..., thousand nine hundred eight (112) second |
- Top level, phase 1 on
abcde: valid first wordsaandab; no two-word split. The queue is empty, so phase 2 starts. - Branch
a:bcde->b+cde->c+de-> phase 1 queuesa b c d e. The queue is full (1 of 1). - Branch
ab: phase 1 oncdefinds no two-word split (deis not indexed). The queue is full, so it returns;ab c d e, one level further down, is never generated. A docFreq-lookup trace shows the search ending with the lookupsab,c,de,cd. - With
maxChanges3, branchastops at depth 3 without queuing anything, and branchabfindsab c d e.
Exposure and likelihood250 words
- Who. Users of lucene-suggest's
WordBreakSpellChecker.suggestWordBreakswithmaxChanges3 or more, set directly or through a downstream component that forwards a configuredmaxChangesand the request's suggestion count. UpstreamTestWordBreakSpellChecker.testBreakingWordsitself usesmaxChanges3, 4 and 5. - Not affected. The default
maxChanges1, and 2;suggestWordCombinations(no such shortcut; 400,000 combination checks found no wrong result); terms whose competitive splits have at most 3 words. Lucene's query matching and the index are not affected; the immediate effect is the suggestion list. - Effect. Silent: no error, and the returned split is still a valid split into indexed words. With
maxChanges3 a split with the same breaks but lower frequencies replaces a better one; with 4 or more, one with more breaks can too. RaisingmaxChangescan make the answer worse. It happens with an unlimitedmaxEvaluationstoo, and needs no minimum index size (one document suffices); the index's words and docFreqs decide whether it triggers. - How often. It needs a term whose competitive splits have 4 or more words and an earlier branch that fills the queue first. In an oracle over dense random dictionaries of 1-3-letter words with an unlimited budget, about 0.1-0.3% of checks were wrong at
maxChanges3 and 1.5-3.8% at 4-6. Natural vocabularies were not measured; registry likelihood: rare. - Downstream (from reading source, nothing run there). Apache Solr's
WordBreakSolrSpellCheckerforwards its configuredmaxChangesand the request's count, and a Solr sample configuration setsmaxChangesto 10. GitHub code search found noWordBreakSpellCheckeruse in Elasticsearch or OpenSearch.
The test shows that WordBreakSpellChecker.suggestWordBreaks with setMaxChanges(4) and maxSuggestions 1 returns a split with more breaks than the best one, because the full-queue early return fires in a later sibling branch (index {a, ab, b, c, d, e}, term abcde).
/*
* 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 java.util.stream.Collectors;
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.search.spell.WordBreakSpellChecker.BreakSuggestionSortMethod;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestWordBreakFullQueue extends LuceneTestCase {
public void testFewestBreaksSplitFirst() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
for (String word : new String[] {"a", "ab", "b", "c", "d", "e"}) {
doc.add(new StringField("f", word, Field.Store.NO));
}
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
WordBreakSpellChecker checker = new WordBreakSpellChecker();
checker.setMaxChanges(4);
SuggestWord[][] splits =
checker.suggestWordBreaks(
new Term("f", "abcde"),
1,
reader,
SuggestMode.SUGGEST_WHEN_NOT_IN_INDEX,
BreakSuggestionSortMethod.NUM_CHANGES_THEN_MAX_FREQUENCY);
String top = Arrays.stream(splits[0]).map(s -> s.string).collect(Collectors.joining(" "));
// expected the 3-break "ab c d e" (fewest breaks first), actual the 4-break "a b c d e"
assertEquals("ab c d e", top);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-538/, 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.TestWordBreakFullQueue
- Expected vs actual
- Expected the 3-break split
ab c d e(fewest breaks first); actual the 4-breaka b c d e(expected:<a[]b c d e> but was:<a[ ]b c d e>), with-eaand-da.
More files, environment, full output39 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
=> org.junit.ComparisonFailure: expected:<a[]b c d e> but was:<a[ ]b c d e>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.search.spell.TestWordBreakFullQueue.testFewestBreaksSplitFirst(TestWordBreakFullQueue.java:56)- The code comment claims the shortcut is lossless: later splits are "guaranteed to have more changes ... and not be competitive" (
WordBreakSpellChecker.java:295-296); #12100, which added it, says the same. - Lucene's own ranking disagrees: with 2 suggestions the dropped split comes first, and 9.11.1 returns it.
- It fails with an unlimited
maxEvaluations, so the budget trade-off does not explain it.
The full argument279 words
- The order is defined.
BreakSuggestionSortMethod: "Sort by Number of word breaks, then by the Sum [Maximum] of all the component term's frequencies" (:54-62); both comparators rank fewer words first, then higher frequency (:461-485), and the queue keeps the bestmaxSuggestionsby that order. - The shortcut was meant to be lossless. Code comment: "any subsequent suggestions are guaranteed to have more changes then anything currently in the queue, and not be competitive" (
:295-296); the method calls itself a "Two phase breadth first search" (:260). Issue #12100 (hossman): "we know that candidates with fewerNUM_CHANGES...are _always_ better, a breadth first approach can can actually short-circuit and return much earlier then I originally suggested, if thesuggestionsqueue is already "full"". - The change promised better matches. CHANGES 9.12.0, Optimizations: "WordBreakSpellChecker.suggestWordBreaks now does a breadth first search, allowing it to return better matches with fewer evaluations". Upstream
testSmallMaxEvaluationscalls the fewest-changes result "optimal". - The behaviour contradicts it. The top 1 is not the head of Lucene's own top 2; 9.11.1 returned the expected top N, by the ranking keys, on every input tried; an unlimited budget fails the same way; raising
maxChangesfrom 3 to 4 makes the answer worse. - Why the tests missed it. The two
maxEvaluationstests as #12100 left them (it addedtestSmallMaxEvaluationsand rewrotetestMaxEvaluations, which #12077 had added) use "plenty"maxSuggestions(maxEvals * 2,maxEvals * 10), so the full-queue return never fires in them, and themaxChanges3-5 cases oftestBreakingWordshave no earlier sibling that fills the queue. No upstream test asserts the current output; with the fix every suite in the module passes.
The devil's-advocate check191 words
The strongest works-as-designed case (the cross-model lenses, the independent reproducer, the devil's advocate and the adjudication): the suggestWordBreaks javadoc says only that "a lower score is generally preferred over a higher score" (:64-67); BreakSuggestionSortMethod "Determines the order to list word break suggestions", which could mean the order of the returned list rather than which splits are selected; setMaxEvaluations admits that "A higher value might improve result quality", so quality is openly heuristic; #12100 is filed under Optimizations and changed results on purpose; and maxChanges 3 or more is opt-in.
Why it fails: the loss happens with the budget unused, so the setMaxEvaluations trade-off does not cover it. The only justification the code gives, the comment at :295-296, is false for the committed algorithm, and #12100 argues for the shortcut on exactly the property the repro breaks. The top N is not a prefix of Lucene's own top N+1, and 9.11.1 returned the expected top N. The results #12100 changed on purpose are budget-limited ones; this loss happens with the budget unused, and nothing in #12100 suggests it was known or accepted. Verdict: not intended; a regression of #12100.
Recommended A: return on a full queue only when no deeper split can beat it
Return on a full queue only if its worst split has fewer words than any split this call could still generate: add && suggestions.peek().suggestWords.length <= newNumberBreaks (2 code lines replace 1). It keeps the 9.12.0 pruning wherever it is safe. All 245 suggest-module tests pass with it, the repros included, with assertions on and off.
// if we are about to exceed our maxChanges *OR* we have a full list of
// suggestions that all have fewer words than any deeper suggestion can have
// (at least newNumberBreaks + 1), we can return now.
final int newNumberBreaks = numberBreaks + 1;
if (totalEvaluations >= maxEvaluations
|| newNumberBreaks > maxChanges
|| (suggestions.size() >= maxSuggestions
&& suggestions.peek().suggestWords.length <= newNumberBreaks)) {
return totalEvaluations;
}All options and trade-offs4 options · 755 words
Two fixes were built and run in a scratch worktree at a9779ef3c193. A narrows the shortcut and is recommended (the finder's guard, with a rewritten comment); C drops the shortcut. A real breadth-first rewrite and a javadoc-only change were not run.
A: return on a full queue only when no deeper split can beat it (validated by the checks) (recommended)
In generateBreakUpSuggestions, require that the queue's worst split (suggestions.peek()) has at most newNumberBreaks words before returning on a full queue, and replace the comment that claimed the return is always lossless. 2 code lines replace 1 in WordBreakSpellChecker.java.
// if we are about to exceed our maxChanges *OR* we have a full list of
// suggestions that all have fewer words than any deeper suggestion can have
// (at least newNumberBreaks + 1), we can return now.
final int newNumberBreaks = numberBreaks + 1;
if (totalEvaluations >= maxEvaluations
|| newNumberBreaks > maxChanges
|| (suggestions.size() >= maxSuggestions
&& suggestions.peek().suggestWords.length <= newNumberBreaks)) {
return totalEvaluations;
}Trade-offs
- Why it is right.
peek()is the worst queued split (both comparators put more words first, then lower frequency, and the queue polls its head). A recursion from this call yields splits of at leastnewNumberBreaks + 1words; if the worst queued split has at mostnewNumberBreakswords, every deeper split ranks strictly below it and would be dropped at once, so skipping phase 2 loses nothing. WithnewNumberBreaks + 1or more words, a deeper split can tie on words and win on frequency, so the bound is tight. "Loses nothing" is by the ranking keys: tied splits have no defined order (both comparators return 0 on equal keys), as on 9.11.1. - Keeps #12100's gain where it is valid: at the top level and in every call whose queue holds only shorter splits, it prunes exactly as today.
- Cost. It explores the subtrees the shortcut wrongly skipped. A docFreq-lookup trace (made with the finder's guard, the same condition) counted 26 lookups with the fix vs 23 stock (21 on 9.11.1) for the minimal case, and 103 vs 90 (102 on 9.11.1) for the number-word top 3; these are lookups, not
maxEvaluationsunits, which count phase-1 split positions. Work stays capped bymaxEvaluationsandmaxChanges. Not benchmarked. With a finitemaxEvaluationsresults stay budget-dependent, assetMaxEvaluationsdocuments; the exhaustive checks used an unlimited budget. - Open draft PR #16847 changes only the frequency types, in other hunks of this file, not the shortcut; fix A applies cleanly to the file at its current head (
6aea7b57d734, checked 2026-10-28), so the two do not conflict.
What the checks validated
- Gradle in a scratch worktree at
a9779ef3c193, seed DEADBEEF: the whole:lucene:suggest:testwith the 3 repro classes added, 34 classes, 245 tests, 0 failures (7 skipped by the module itself), with assertions on and off; the 8 repro tests that fail on stock pass. - Upstream
TestWordBreakSpellChecker(which has a random test) and the repro classes, 10 iterations, seed 5EED0538: 140 tests, 0 failures. :lucene:suggest:check -x testwith the fix and the brief's test (format, ecj lint, forbidden APIs): BUILD SUCCESSFUL.- On
ea57360912edthe finder's identical condition: the hunt's bounded-exhaustive check drops from 984 wrong rankings atmaxChanges3-4 to 0, a separate oracle from 3,822 of 269,760 to 0, and upstreamTestWordBreakSpellCheckerpasses 25 of 25. Not benchmarked.
The condition becomes totalEvaluations >= maxEvaluations || newNumberBreaks > maxChanges, with the comment updated.
Trade-offs
Simplest and plainly exact (by the ranking keys) with an unlimited budget, but it gives up #12100's short-circuit everywhere: a full queue no longer saves any work, and under the default budget of 1000 deep branches spend evaluations the shortcut used to save. Not benchmarked.
What the checks validated
Gradle, assertions on, seed DEADBEEF: the 3 repro classes and upstream TestWordBreakSpellChecker, 14 tests, 0 failures. The whole module was not run with C.
Finish all splits with k breaks before any with k + 1, for example with a frontier of (prefix, rest) states per level; the original shortcut is then valid as written.
Trade-offs
Matches #12100's intent, but it is a larger rewrite, keeps a frontier in memory, and changes which splits a finite budget reaches. Not run.
A javadoc note on suggestWordBreaks.
Trade-offs
Leaves the regression from 9.11.1 and contradicts the code comment and #12100. Not recommended.
Apache Solr's word-break spell checker forwards its configured maxChanges (a Solr sample configuration sets 10), so such deployments can hit this; this is from reading Solr's source, not from a run.
A bounded-exhaustive check of suggestWordBreaks on the stock jar against a brute-force reference: every split of the term into indexed words, ranked by the documented order, over many small random indexes. 984 of 1,224,000 break checks were wrong, all at maxChanges 3 or 4. A search on the upstream test's index then found the number-word example.
The method in full167 words
Method: translation validation against an exact reference (the hunt's "semi-formal" tier).
- The reference enumerates every split of the term into indexed words with at most
maxChangesbreaks, applies the documented word-length, frequency andSuggestModerules, orders the splits by theBreakSuggestionSortMethodkeys (ties as sets) and keeps the topmaxSuggestions. - Bounds: 200 random indexes over the 126 strings of length 1-6 over
{a, b}, all 510 queries of length 1-8, 12 random configurations each (maxChanges0-4, both sort methods, severalmaxSuggestionsand modes, unlimitedmaxEvaluations): 1,224,000 break checks. 984 were wrong, all atmaxChanges3 or 4, and 0 with the fix shadowed. (A separatemaxChanges=0discrepancy class in the same run is a different question, not part of this bug.) A mutant reference was used to show the check is not vacuous. - A second search on the index of upstream
TestWordBreakSpellCheckerfound the natural examplesthousandonehundredeightandthousandninehundredeight; the finder turned them and a minimal case into a public-API JUnit test.
No prior report found (191 queries over Lucene issues, PRs, commits, mailing lists, the JIRA archive, Solr, Elasticsearch, OpenSearch and the web, 2026-10-26). The nearest item is #12100 itself, whose patch added the shortcut and which states the assumption the repro breaks; nobody raised this case there or since.
Searches4 searches
Registry verdict new: nobody else has reported or discussed this bug. 21 hits were read and classified; none is this bug. Nearest items: #12100 (hossman, opened 2023-01-22, closed 2024-07-10; the origin, its two comments only post the patch, no later comment, cross-reference or reopen), #12077 (maxEvaluations accounting in the same method, fixed in 9.5.0; a different defect), the open draft #16847 (frequency types only), #7299 (LUCENE-6237, a different secondary sort), #4597 (LUCENE-3523, the class's contribution), #5652, #6557, SOLR-6297 (2014, distributed merge) and SOLR-18311 (the same pattern in Solr's own collator, different code).
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, issues@, commits@, java-user@; JIRA archive; downstream repositories; web | 2026-10-26 | a scripted prior-report search, two runs with different terms, 51 queries each (every source answered) | no report; nearest #12100 and #12077 |
| GitHub (apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, GitHub-wide) | 2026-10-26 | part of about 90 manual searches and reads (4 GitHub searches hit the rate limit and succeeded on retry) | no report; GitHub-wide mentions are citations or upgrade PRs |
| Solr JIRA and the Lucene and Solr lists since 2024-06 | 2026-10-26 | JIRA and lists.apache.org queries | only the commit mails of the introducing change; SOLR-6297 and SOLR-18311 are different code |
| Open PRs and git history | 2026-10-26 | touched files of all 407 open apache/lucene PRs; git log -S over the full history | only the draft #16847 touches the file (frequency types); the shortcut never changed after cc854f408fd |
Introduced by #12100 (cc854f408fd, hossman, 2024-07-10; branch_9x 3eadfe6031c), first released in 9.12.0 and 10.0.0. Every release since fails and 9.11.1 passes; on 9.12.0 jars, taking only this class from before the commit makes the repro pass. Still unfixed on main, branch_10x and branch_10_6.
Versions tested7 rows
Introduced by cc854f408fd06553de4780b1874992c16b71f26e "WordBreakSpellChecker.suggestWordBreaks now does a breadth first search (#12100)" (hossman, 2024-07-10, committed from a patch attached to the issue, no pull request) and its same-day branch_9x cherry-pick 3eadfe6031c. Before it, from the API's first release (4.0.0-ALPHA) to 9.11.1, the method was a depth-first enumeration with the same length, frequency, maxChanges, maxSuggestions and maxEvaluations limits, and with no full-queue return. Plain git blame puts the shortcut and its comment on cc854f408fd (line 295 has a later typo fix, 54283150a81, #16255), and git log -S finds no other change to it. On otherwise identical 9.12.0 jars, WordBreakSpellChecker from 3eadfe6031c^ passes the repro and the file from 3eadfe6031c fails it. 9.12.1, 9.12.2 and 10.1.0-10.5.1 carry the same file as a tested release. Upstream main a9779ef3c19, branch_10x and branch_10_6 have the unguarded return (read 2026-10-26; origin/main c5ab3e57b9ba still has the same file on 2026-10-28).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the brief's 3 tests fail through gradle, assertions on and off |
main ea57360912ed | affected | finder, independent reproducer, devil's advocate, judge and audit: fail with -ea and -da, several seeds |
| 10.5.2, 10.0.0, 9.12.3, 9.12.0 | affected | the finder's JUnit fails 3 of 3 (published jars); the independent reproducer's fails on 10.5.2 and 9.12.3 |
| 9.12.1, 9.12.2, 10.1.0-10.5.1 | by code identity | the same file as 9.12.0 or 10.0.0; not run |
9.12.0 jars + this class from 3eadfe6031c^ / 3eadfe6031c | commit-level check | passes without the commit, fails with it |
| 9.11.1, 9.11.0, 9.5.0, 9.4.2, 9.0.0, 8.11.4 | not affected | the finder's JUnit passes (9.0.0 and 8.11.4 with an import-adapted copy whose controls fail) |
fix A on a9779ef3c193 | not affected | the whole suggest module, repros included: 245 tests, 0 failures, assertions on and off |
An independent reproducer, an intended-behaviour devil's advocate, cross-model reviews (one with an executed brute-force oracle), an adjudication and a credibility audit. Every re-run failed on main with assertions on and off and passed with the fix and on 9.11.1; the refute review could not run code and voted not real on procedure only while agreeing on the root cause.
Checks and verdicts404 words
Each check ran or read on its own; the registration check and the audit re-ran the tests with fresh seeds, and this doc re-ran them through gradle on a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder (translation validation, JUnit) | candidate | 984 wrong rankings in 1,224,000 break checks, all at maxChanges 3-4, 0 with the fix; JUnit fails 3 of 3 on main (5 seeds with -ea, one with -da), 10.5.2, 10.0.0, 9.12.3 and 9.12.0, passes on 9.11.1 and with the fix. |
| 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; a separate re-run failed on main. |
| independent reproducer | real | Own test (top 1 must be the head of top 10) with other words: 2 of 3 fail on main (-ea, -da), 10.5.2 and 9.12.3, each in 4 of 4 configurations; the maxChanges=2 control passes; passes on 9.11.1 and with the fix. |
| cross-model review, intended lens + oracle | real, medium | The budget trade-off does not cover a loss with the budget unused; a separate brute-force oracle found 3,822 wrong results in 269,760 checks on main, all at maxChanges 3 or more, and 0 on 9.11.1 and with the fix. |
| intended-behaviour devil's advocate | real | Built the strongest intent case and showed why it fails; probes: maxChanges 3 gives [ab c d e], 4 and 10 give [a b c d e]; unlimited budget and SUGGEST_ALWAYS fail the same way; 9.12.0 fails, 9.11.1 passes. |
| adjudication | confirmed, high | Re-ran both tests with new seeds (fail), and the independent reproducer's test with the fix and on 9.11.1 (pass); hand-traced the maxChanges 3 case; checked that the fix's bound is tight. |
| credibility audit | keep | Re-ran with an unused seed (finder -da 3 of 3, independent -ea 2 of 3 fail; fix 0 of 3) and hand-traced the minimal case. |
| this doc's gradle runs | stock fails, fixes pass | Stock main: 8 of 9 repro tests fail with assertions on and off (the control passes), 15 of 15 over 5 iterations of the brief's test. Fix A: all 245 suggest-module tests pass, on and off, 140 of 140 over 10 iterations, static checks pass; fix C: 14 of 14. |
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 464 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,188 words
- Registry title
- WordBreakSpellChecker.suggestWordBreaks drops better suggestions when maxChanges >= 3: the full-queue early return added in 9.12.0 (GITHUB#12100) assumes a breadth-first order that the depth-first recursion does not follow
- Area
- suggest / spell (WordBreakSpellChecker word breaks)
- Priority
- Low, rank 450: WordBreakSpellChecker drops better suggestions only with opt-in maxChanges >= 3 and a small fraction of inputs, a suggestion-quality issue like LFV-240, so it follows LFV-566.
- Impact class
- silent wrong results
- Impact note
- Applications on Lucene 9.12.0 through 10.5.2 or main that raise WordBreakSpellChecker.setMaxChanges to 3 or more (the default is 1; directly, or through a downstream word-break spellcheck component's maxChanges setting, which one downstream sample configuration sets to 10) silently get a worse word-break suggestion list for terms whose competitive splits have four or more words, whenever an earlier branch of the search has already filled the maxSuggestions queue: a split with the same number of breaks but a lower frequency (maxChanges >= 3) or with more breaks (maxChanges >= 4) is returned in place of a better one, at any index size and for any maxEvaluations, while maxChanges 1 and 2 and suggestWordCombinations are not affected.
- Summary
- You index some words and call WordBreakSpellChecker.suggestWordBreaks with setMaxChanges(3) or more, and you expect the maxSuggestions splits of the term that rank best by Lucene's own order (fewest breaks, then highest frequency). Instead, once an earlier branch of the search has filled the queue, a later branch returns before it looks for its own splits, so a better split can be missing: with the index {a, ab, b, c, d, e}, the term 'abcde', maxChanges=4 and maxSuggestions=1 the method returns [a b c d e] (4 breaks) instead of [ab c d e] (3 breaks), while asking for 2 suggestions returns both in the right order. No error is raised, raising maxChanges from 3 to 4 makes the answer worse, and 9.11.1 returns the right split.
- Introduced
- cc854f408fd06553de4780b1874992c16b71f26e (main) and its same-day branch_9x cherry-pick 3eadfe6031cbba1c150569bfb8669027347cb1bd, 'WordBreakSpellChecker.suggestWordBreaks now does a breadth first search (#12100)' by hossman, committed directly from a patch attached to issue #12100 (no pull request) [exec: on otherwise identical 9.12.0 jars, shadowing only WordBreakSpellChecker at 3eadfe6031c^ makes the repro pass and at 3eadfe6031c makes it fail; read: git blame of main lines 292-325 and git log -S'suggestions.size() >= maxSuggestions' give only these two commits, apart from the comment typo fix 54283150a81 (#16255) on line 295] (2024-07-10)
- First affected version
- 9.12.0 [exec] (9.x line, tagged 2024-09-28); on the 10.x line 10.0.0 [exec] (tagged 2024-10-14); last unaffected release 9.11.1 [exec]
- Affected versions
- main ea57360912ed (-ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.12.0 fail the finder's JUnit 3 of 3 [exec]; 9.12.1, 9.12.2 and 10.1.0-10.5.1 carry the same file blob (b3a84e9aaf on 9.12.x, 58d68a66bd on all 12 10.x tags before 10.6.0) [read]; not fixed upstream: the unguarded early return is unchanged on origin/main a9779ef3c19, branch_10x and branch_10_6 [read, 2026-10-26]; unaffected: 4.0.0-ALPHA (first release of the API) through 9.11.1, where the early return does not exist [read], executed passes on 8.11.4, 9.0.0 (import-adapted copy, with shadow controls that fail), 9.4.2, 9.5.0, 9.11.0 and 9.11.1 [exec]
- 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, the prior-report record verdict new, all read-only: scripted prior-report search twice with different terms, 51 queries each 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; about 90 manual searches and reads, 191 queries in total, among them #12100 and #12077 with their comments and patches, the Solr JIRA project, the Lucene and Solr lists from 2024-06 on, the Elasticsearch, OpenSearch and Solr repositories, and the touched-file lists of all 407 open apache/lucene PRs; 4 GitHub searches hit the rate limit and succeeded on retry; 21 hits, none the same bug); related prior items: #12100 (hossman, closed 2024-07-10: the issue whose patch added the early return; it states the invariant the early return relies on, 'candidates with fewer NUM_CHANGES... are always better ... short-circuit ... if the suggestions queue is already full', and has no later comment, cross-reference or reopen), #12077 (maxEvaluations accounting in the same method, fixed in 9.5.0; a different defect), #16847 (open draft; changes only the freq types in this method, not the early return, so a fix may need a trivial rebase), #7299 (LUCENE-6237, a different secondary sort), #4597 (LUCENE-3523, created the class), #5652, #6557, SOLR-6297 (2014, distributed merge, a different mechanism), SOLR-18311 (the same pattern in Solr's collator, different code)
- Lucene commit tested
- main
a9779ef3c193(gradle in a scratch worktree: the brief's test stock with assertions on and off and over 5 iterations; fix A on the whole suggest module with assertions on and off, 10 iterations of the word-break tests, and the module's static checks; fix C on the repro and word-break tests) and mainea57360912ed(hunt, independent reproducer, intended-behaviour devil's advocate, cross-model reviews with an executed oracle, adjudication, audit; several seeds, assertions on and off); the release jars 10.5.2, 10.0.0, 9.12.3 and 9.12.0 (affected) and 9.11.1, 9.11.0, 9.5.0, 9.4.2, 9.0.0 and 8.11.4 (not affected); 9.12.0 jars with only this class taken from the introducing commit or its parent. JDK 25.0.3, Linux aarch64.
- Finding record
a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:37 (maxChanges = 1 default), :54-62 (BreakSuggestionSortMethod: 'Sort by Number of word breaks, then by ...'), :64-67 (suggestWordBreaks javadoc: 'a lower score is generally preferred'), :109-118 (top-level call with numberBreaks=1), :260 ('Two phase breadth first search'), :292-303 (the comment 'any subsequent suggestions are guaranteed to have more changes then anything currently in the queue, and not be competitive' and the unguarded 'suggestions.size() >= maxSuggestions' return), :305-327 (phase 2 recursion over the valid left splits, one sibling at a time), :461-485 (both comparators rank fewer words first)
- The finding in 30 seconds
a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:37-38, 54-62, 91-97, 109-123, 241-330 (260, 265-290, 283-287, 292-303, 305-327), 441-459, 461-485
- Who is affected
a Lucene main checkout lucene/suggest/src/test/org/apache/lucene/search/spell/TestWordBreakSpellChecker.java:296, 308, 319 (maxChanges 3, 4, 5)
- Why it looks unintended
a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:54-67, 260, 295-296, 451-453, 461-485a Lucene main checkout lucene/CHANGES.txt:2520, 2601, 2622-2623 (GITHUB#12100 under 9.12.0 Optimizations)a Lucene main checkout lucene/suggest/src/test/org/apache/lucene/search/spell/TestWordBreakSpellChecker.java:95-96, 128-129, 153-154https://github.com/apache/lucene/issues/12100 (body, read 2026-10-28 with gh issue view)
- Suggested fix
a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/WordBreakSpellChecker.java:283-287, 292-303, 461-485
- Prior reports
https://github.com/apache/lucene/issues/12100 (created 2023-01-22, closed 2024-07-10; read 2026-10-28)
- TestWordBreakFullQueue.java (JUnit test):
repro/LFV-538/src/org/apache/lucene/search/spell/TestWordBreakFullQueue.java(60 lines)
A step-by-step picture of the minimal example, made in parallel with this doc from a docFreq-lookup trace of the stock code, the fix and 9.11.1.
assets/LFV-538/bug.png(464 KB)
