This is the index-based n-gram SpellChecker of lucene-suggest, used with a reader and a field; DirectSpellChecker is a different class.
relieve fills the one-slot queue and the cutoff rises to 0.857, so receive (0.714, 50 documents) is rejected unread; with two slots both get in and receive ranks first. Select the still to open it at full size. Generated with AI, checked against the code.- Index
receivein 50 documents andrelievein 1. Build aSpellCheckerwithLevenshteinDistanceandSuggestWordFrequencyComparator("Frequency first, then score") and ask for suggestions forrecieve, restricted to that field. - Ask for 2: you get
[receive, relieve], frequency first. Both pass the default accuracy 0.5:receivescores 0.714,relieve0.857. - Ask for 1: you expect
[receive], the first entry of that list. You get[relieve], with no error. - Why: the n-gram search returns
relievefirst. Once the 1-slot queue is full, the minimum score rises to the score of the queue's weakest word, 0.857. That is a safe bound for a score-first order, but here the weakest word is the least frequent one, soreceive(0.714) is rejected before its 50 documents are counted. - It also happens at the recommended
numSug=5: five of six rare one-edit neighbours ofabcdefgfill the queue (the sixth ties and is not kept) andabcdfeg(50 documents) is missing. On an English test corpus 65-76% of lookups return a set other than the comparator's topnumSug, but the intended word is lost in only 0.2-0.4%.
Applications that use the index-based SpellChecker with SuggestWordFrequencyComparator and pass a reader and field (a user-written comparator that is not score-first may be hit without them). The default comparator, DirectSpellChecker, and the frequency comparator with SUGGEST_ALWAYS or a null reader or field are not affected. It shows even in tiny indexes; the visible harm is mild.
Mechanism, terms and numbers692 words
The index-based n-gram SpellChecker of lucene-suggest can rank suggestions with a pluggable comparator; the shipped SuggestWordFrequencyComparator orders "Frequency first, then score". Once suggestSimilar has collected numSug words, it raises its accuracy cutoff to the score of the queue's head, the word the queue evicts when a better one arrives. That shortcut is a valid lower bound only for the default score-first order. Under the frequency order the head is the least frequent word, so a later word that is more frequent but scores a little lower is rejected before its frequency is read, although the queue would have kept it.
In the smallest case, receive (50 documents) and relieve (1 document) for the typo recieve: asking for 2 returns [receive, relieve], asking for 1 returns [relieve]. At the javadoc-recommended numSug=5 a frequent word can be left out entirely. No error is raised, and the returned list is still sorted by the comparator: what goes wrong is which words get into it. With the frequency comparator it needs a reader and field (restricted mode) and a full queue; a user-written comparator that is not score-first may not need restricted mode. Every release from 3.1.0 to 10.6.0 (10.6.0 by reading) and main is affected; the default comparator, DirectSpellChecker, and the frequency comparator with SUGGEST_ALWAYS are not. Measured on an English test corpus, the user-visible harm is mild: the set differs from the comparator's top numSug in most lookups, but the typo's intended word is lost in only 0.2-0.4%.
SpellChecker: lucene-suggest's index-based spell checker. It keeps a separate spell index of words split into n-grams.suggestSimilarruns an n-gram query against it, fetches up to10 * numSughits, scores each with aStringDistance(hereLevenshteinDistance: 1 - edits / longer length) and keeps the bestnumSugin a bounded queue.- Restricted mode: pass the user index's reader and a field (with
SUGGEST_WHEN_NOT_IN_INDEXorSUGGEST_MORE_POPULAR). Each candidate's document frequency (freq) is then read from that field. - Comparator: the order of the queue, set through the constructor or
setComparator. The default,SuggestWordScoreComparator, is score, then frequency;SuggestWordFrequencyComparatoris frequency, then score. - Accuracy: the minimum score a word needs (default 0.5).
- For each hit,
suggestSimilarcomputes the score and skips the word ifscore < accuracy, before it reads the word's frequency (SpellChecker.java:382-385). - After each insertion, once the queue holds
numSugwords, it setsaccuracy = sugQueue.top().score(:396-399, comment "if queue full, maintain the minScore score"). top()is the smallest element under the configured comparator (SuggestWordQueue.java:52). With the default order that is the lowest score, and any later word scoring below it would lose to it anyway: a valid shortcut. With the frequency order it is the least frequent word; its score says nothing about which later words the queue would keep.- So a later word that is more frequent but scores lower than that word is rejected at step 1, unread. The returned list is still sorted by the comparator; the defect is which words get into it.
receive in 50 documents, relieve in 1, query recieve: scores 1 - 2/7 = 0.714 and 1 - 1/7 = 0.857. The n-gram search returns relieve first. With numSug=1 the queue is full after relieve, accuracy becomes 0.857 and receive is skipped: [relieve]. With numSug=2 both get in: [receive, relieve]. With the default comparator the answers are [relieve] and [relieve, receive], consistent with each other.
At numSug=5: abcdfeg in 50 documents (two edits from abcdefg, 0.714) and six one-edit words with 1 document each (0.857). The six are reached first: five fill the queue (the sixth ties the head and is not kept), the cutoff rises to 0.857, and abcdfeg is dropped: [abcdefx, abcdefy, abcdefz, abcdexg, abcdeyg]. It fails for numSug 1-6 and is right from 7, when abcdfeg arrives before the queue is full.
The outcome depends on n-gram hit order: adding retrieve and reprieve (1 document each) to the first index makes the search reach receive first, and numSug=1 then returns [receive].
Exposure and likelihood340 words
- Who is exposed. Opt-in: the constructor
SpellChecker(Directory, StringDistance, Comparator)orsetComparator, withSuggestWordFrequencyComparatorin restricted mode (a user-written comparator that is not score-first may not need restricted mode). By reading downstream source (nothing run there): Solr's index-based spell checker mapscomparatorClass=freqtoSuggestWordFrequencyComparatorand callssuggestSimilarwithmax(count, 5)suggestions (oralternativeTermCount, when set, for a token already in the index), the reader when a field is configured, and the field; the Elasticsearch frequency sort usesDirectSpellChecker, which does not run this code. - What it takes. A full queue (
numSugsmaller than the number of qualifying hits) before a more frequent, lower-scored word is reached. Deterministic; it shows even with a 7-word spell index;numSug=5still fails. - How often. Measured on main (
-da;-eaidentical) over the 1,166 English lines of the Europarl sample in Lucene's test framework (11,181 terms), 2,000 typos per draw (one substitution, deletion, insertion or adjacent transposition), each looked up atnumSug5 and 10,LevenshteinDistance, accuracy 0.5. Reference: the comparator's topnumSugamong the same hits (the fix).
| typo draw, numSug | set differs | first suggestion less frequent | intended word lost by the cutoff | intended word in the result: today / with the fix |
|---|---|---|---|---|
| uniform, 5 | 76.1% | 36.5% | 0.2% | 95.9% / 74.0% |
| uniform, 10 | 67.5% | 26.6% | 0.3% | 97.9% / 87.8% |
| frequency-weighted, 5 | 73.2% | 25.1% | 0.4% | 96.5% / 88.0% |
| frequency-weighted, 10 | 64.8% | 19.2% | 0.2% | 98.0% / 94.9% |
- Reading the table. For users who opt in, a result that differs from the comparator's top
numSugis the normal case, but the intended word is rarely lost because of it; for examplewihch(fromwhich, 690 documents,numSug=5) returns[with, wish, watch, rich, witch]instead of[with, which, wish, watch, rich]. On this corpus selecting by the comparator (the fix) would include the intended word less often than today. With the default comparator, removing the cutoff changes nothing (0 of 8,000 lookups). - Silent. The returned words look plausible and all pass the documented filters; search results are not involved.
The test shows that SpellChecker.suggestSimilar with SuggestWordFrequencyComparator ("frequency first, then score") drops a more frequent word once the queue is full, because it raises the accuracy cutoff to the score of the least frequent queued word (receive in 50 docs, relieve in 1, query recieve, numSug 1).
/*
* 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.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestSpellCheckerFrequencyComparator extends LuceneTestCase {
public void testMostFrequentWordRanksFirst() throws Exception {
try (Directory userDir = new ByteBuffersDirectory();
Directory spellDir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(userDir, new IndexWriterConfig())) {
for (int i = 0; i < 51; i++) {
Document doc = new Document();
doc.add(new StringField("body", i < 50 ? "receive" : "relieve", Field.Store.NO));
w.addDocument(doc);
}
}
try (DirectoryReader reader = DirectoryReader.open(userDir);
SpellChecker checker =
new SpellChecker(
spellDir, new LevenshteinDistance(), new SuggestWordFrequencyComparator())) {
checker.indexDictionary(
new LuceneDictionary(reader, "body"), new IndexWriterConfig(), false);
String[] top =
checker.suggestSimilar(
"recieve", 1, reader, "body", SuggestMode.SUGGEST_WHEN_NOT_IN_INDEX);
// expected "receive" (50 docs) first under "frequency first", actual "relieve" (1 doc)
assertEquals("[receive]", Arrays.toString(top));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-566/, 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.TestSpellCheckerFrequencyComparator
- Expected vs actual
- Expected
[receive]; actual[relieve](expected:<[re[cei]ve]> but was:<[re[lie]ve]>), with-eaand-da.
More files, environment, full output32 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:<[re[cei]ve]> but was:<[re[lie]ve]>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.search.spell.TestSpellCheckerFrequencyComparator.testMostFrequentWordRanksFirst(TestSpellCheckerFrequencyComparator.java:52)- The comparator is "Frequency first, then score"; LUCENE-2479 added it so users could "sort by freq first".
- If the comparator selects, the top 1 must head the top 2: here 2 gives
[receive, relieve], 1 gives[relieve]. The default comparator never does this. - The cutoff is from 2004, when score-first was the only order; LUCENE-2479 left it unchanged.
The full argument316 words
- Contract. Constructor javadoc (
SpellChecker.java:129-132): "the given java.util.Comparator for sorting the results";getComparator(:178): "the comparator in use for ranking suggestions";SuggestWordFrequencyComparator.java:21: "Frequency first, then score.";lucene/CHANGES.txt:16164-16165: "Added ability to provide a sort comparator for spelling suggestions along with two implementations. The existing comparator (score, then frequency) is the default". None of these says outright that the comparator chooses which words are returned; that reading rests on the comparator's own rule, "ranking", the request below, and the fact that the comparator-ordered queue is what selects (SpellChecker.java:366, 395). - Intent. The LUCENE-2479 (#3553) request: "there are situations where it would be useful to sort by freq first, instead of the current 'sort by edit distance, and then subsort by freq if edit distance is equal'". In the same thread the author did not fold frequency into the score because "this would mean having to look up the freq. for every suggestion, which I think would be pretty bad performance-wise": about the scoring, not the cutoff, but avoiding
docFreqlookups was part of the design. - Prefix check. If the comparator picks the top
numSug, then with every candidate retrieved and passing the filters,numSug=kmust be the first k of the full list under any fixed order. The frequency comparator breaks it ([relieve]vs[receive, relieve]; an independent case returns[abcdefghijkl, abcdefgx]for 2 but[abcdefghijkl, abxdefyh, abcdefgx]for 3); the default comparator never does. - History. The 2004 sandbox import wrote
min=((SuggestWord) sugqueue.top()).scorewhenSuggestWord.compareTo(score, then frequency) was the only order, so it was correct. LUCENE-2479 (2010) built the queue with the comparator and left the cutoff; later commits only renamed or reformatted it. - Tests.
testComparator(TestSpellChecker.java:153-171) uses "five and fvei", which "have the same score", so it cannot reach the cutoff. Nothing pins the current behaviour: with either fix every suite in the module passes.
The devil's-advocate check344 words
The strongest case for "works as designed" (a cross-model reviewer with the intended lens, the independent reproducer, the devil's advocate, the adjudication): the comparator is "for sorting the results" and the 6-argument @return says "first criteria: the edit distance, second criteria (only if restricted mode): the popularity", so the comparator may only order a set chosen by score; the javadoc warns "if numSug == 1, don't count on that suggestion being the best one ... set this value to at least 5"; DirectSpellChecker also builds a bounded pool by score before it applies the comparator; LUCENE-2479's author wanted to avoid a frequency lookup for every suggestion; and the measured harm is small.
Why it fails: the javadoc caveat names its own cause, n-gram retrieval ("the Lucene similarity that is used to fetch the most relevant n-grammed terms is not the same as the edit distance"), while here every candidate is retrieved and then rejected by the cutoff, and numSug=5 fails too. Today's output follows neither reading: the receive case contradicts "the comparator selects", and an order probe contradicts "score selects" (query abcdefg: abcdefgxy, 50 documents, 0.778, beats abcdefh, 1 document, 0.857, at numSug=1, because the n-gram search reaches it first). The @return text is the 2004 description of the only order that existed, and the returned list is already ordered frequency first, against it. The weaker reading "score picks a heuristic subset, the comparator only sorts it" is not refuted by one call, but no score-first precondition is documented and the hit-order dependence fits no rule; made consistent and documented, it is option C. DirectSpellChecker is no precedent for today's code: it sorts its pool with the comparator and then trims it to numSug (DirectSpellChecker.java:386-391), so there the comparator selects too, and its score cutoff applies only inside its score-ordered pool queue (:435). The performance remark is about the scoring, not the cutoff. The small harm bears on which consistent rule to choose (see Suggested fix), not on whether today's behaviour is coherent. Verdict: not intended.
Recommended A: drop the full-queue raise
Drop the full-queue raise (accuracy = sugQueue.top().score): insertWithOverflow already keeps the comparator's best numSug words. It removes 4 lines; the repro and all 245 suggest-module tests pass with assertions on and off. Results with the default comparator do not change; the cost is at most one extra docFreq per examined hit (not benchmarked).
}
+ // insertWithOverflow keeps the best numSug words under the comparator. Do not raise
+ // accuracy to sugQueue.top().score once the queue is full: that is a lower bound only for
+ // a score-first comparator, and a frequency-first one can still prefer a lower score.
sugQueue.insertWithOverflow(sugWord);
- if (sugQueue.size() == numSug) {
- // if queue full, maintain the minScore score
- accuracy = sugQueue.top().score;
- }
sugWord = new SuggestWord();All options and trade-offs4 options · 644 words
Two fixes were built and run in a scratch worktree at a9779ef3c193; A is recommended if the comparator is meant to select. Option C is the other consistent semantics a maintainer may prefer (it was not run); the javadoc does not settle which one is meant.
Remove the if (sugQueue.size() == numSug) { accuracy = sugQueue.top().score; } block after insertWithOverflow (SpellChecker.java:396-399) and leave a comment. On every call that reaches the candidate loop the result becomes the comparator's top numSug among the qualifying 10 * numSug hits, for every comparator (a word already in the field still returns itself under SUGGEST_WHEN_NOT_IN_INDEX, as today). +3/-4, comment only besides the removed block.
}
+ // insertWithOverflow keeps the best numSug words under the comparator. Do not raise
+ // accuracy to sugQueue.top().score once the queue is full: that is a lower bound only for
+ // a score-first comparator, and a frequency-first one can still prefer a lower score.
sugQueue.insertWithOverflow(sugWord);
- if (sugQueue.size() == numSug) {
- // if queue full, maintain the minScore score
- accuracy = sugQueue.top().score;
- }
sugWord = new SuggestWord();Trade-offs
Simplest; it makes the comparator select, for any comparator. With the default comparator the results are unchanged (0 differences in 8,000 measured lookups), but the shortcut is gone: after the queue fills, each examined hit may also do one docFreq lookup and one queue offer, on top of the stored-field read and distance computation the loop already does per hit; still at most 10 * numSug hits. Not benchmarked. On the measured corpus, frequency-first selection includes the typo's intended word less often than today (74.0-94.9% vs 95.9-98.0%).
What the checks validated
Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: the whole :lucene:suggest:test task with the 3 repro classes added, 34 classes, 245 tests, 0 failures (7 skipped by the module itself), with assertions on and off. :lucene:suggest:check -x test (format, ecj lint, forbidden APIs) passes. Earlier, on ea57360912ed: the finder's equivalent guard passes upstream TestSpellChecker 18 of 18 (3 iterations) and leaves 0 of the harness's 5,721 mismatches; the likelihood probe finds it identical to "raise removed" on all 8,000 lookups.
Guard the raise with comparator.getClass() == SuggestWordScoreComparator.class. Same results as A for every comparator; the default keeps its shortcut. +5/-2.
if (sugQueue.size() == numSug
&& comparator.getClass() == SuggestWordScoreComparator.class) {
accuracy = sugQueue.top().score;
}Trade-offs
A user-written score-first comparator loses the shortcut (speed only). An instanceof check, as in the finder's sketch, is weaker: SuggestWordScoreComparator is not final, so a subclass that changes the order would still get the raise.
What the checks validated
Gradle with assertions on: the whole :lucene:suggest:test task with the 3 repro classes, 245 tests, 0 failures; :lucene:suggest:check -x test passes. Not run with assertions off; not benchmarked.
Select the top numSug by score with the existing shortcut, then sort the chosen words with the configured comparator, and say so in the constructor and @return javadoc. This matches the literal "for sorting the results" and the 2004 @return text. DirectSpellChecker is not a precedent for it: its comparator sorts a pool of up to numSug * maxInspections terms and trims it to numSug, so it selects.
Trade-offs
Its set equals today's default-comparator set, which included the intended word in 96.4-98.1% of the measured lookups. But SuggestWordFrequencyComparator would no longer select by frequency in SpellChecker, results change even where today's output already follows the comparator, and the repro (which asserts A's semantics) would need a different test. Not run.
Describe the cutoff in the javadoc.
Trade-offs
Not recommended: the procedure can be described, but that would codify a mixed rule whose result depends on n-gram hit order.
Solr's index-based spell checker exposes this comparator as comparatorClass=freq (read from its source, not run), so its users can see the drop; the Elasticsearch frequency sort uses DirectSpellChecker and is not affected.
Translation validation: a harness compared the stock suggestSimilar with a reference written from its documentation, read as "the comparator selects" (exact n-gram retrieval, the filters, then the configured comparator's top numSug). Over 544,485 checks on random dictionaries it found 5,721 mismatches, all with the frequency comparator in restricted mode, none with the default comparator and none with the fix.
The method in full169 words
Method: bounded translation validation (the hunt's "semi-formal" tier: a tool witnessed the mismatch on the real jar against an exact reference; no model checker, solver or proof).
- The finder wrote a reference for
SpellChecker.suggestSimilarfrom its documented behaviour: every spell-index word that shares a gram with the query is retrieved, the self, accuracy and suggest-mode filters apply, then the configured comparator picks the topnumSug. Cases with more retrievable words than10 * numSugwere skipped, so the javadoc's retrieval caveat cannot apply. - Over 25 random dictionaries, 5 distances, 2 shipped comparators, 363 queries and 6 configurations (544,485 checks) the stock main jar disagreed 5,721 times, all with
SuggestWordFrequencyComparatorin restricted mode; 0 times with the default comparator and 0 times with the fix shadowed. A deliberately wrong reference was caught (55 mismatches in 130,680 checks), so the check is not vacuous. - The finder reduced it to a JUnit test; an independent reproducer wrote its own prefix test with other words; this doc's two-word test comes from those scenarios.
No prior report found (278 queries over Lucene and Solr issues, PRs, commits, JIRA, mailing lists, downstream repositories and the web, 2026-10-26). The closest item, open Solr issue SOLR-9060, is about a SolrCloud shard merge ignoring the comparator: different code.
Searches3 searches
Registry verdict new: nobody else has reported or discussed this bug. 28 hits were read; none is this bug. Nearest items: #3553 (LUCENE-2479, the introducing feature), SOLR-2053 (wires comparatorClass=freq into Solr), SOLR-9060 (open: frequency order lost after a SolrCloud shard merge), #1653 (LUCENE-575, 2006: the same line, a different and fixed bug), and a 2007 java-user thread on numSug-dependent results (the n-gram caveat, before the comparator existed). No open PR changes the cutoff; the Lucene.NET port has the same code and no report.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@, solr-user@, solr-dev@; LUCENE JIRA archive; downstream repositories; web | 2026-10-26 | a scripted prior-report search, three runs (45, 93 and 61 queries) | no report |
| JIRA (LUCENE, SOLR), lists.apache.org (Solr lists), GitHub (apache/lucene-solr, apache/solr, apache/lucenenet), Q&A archives, git history | 2026-10-26 | 79 manual searches and checks; 28 hits read | closest: SOLR-9060 (different code, fix site in Solr) |
| Open PRs | 2026-10-26 | changed files of the 407 open apache/lucene PRs (the list of #16911, which decommissions branch_10x, was readable only for 3,000 of its 7,630 files) | #13951 and #16847 touch SpellChecker.java elsewhere; no fix in flight |
Introduced by LUCENE-2479 (2010-08-17), which made the comparator pluggable but kept a 2004 cutoff that assumed score-first order; first released in 3.1.0. Every release run from 3.1.0 to 10.5.2, and main, reproduces with the same output; 3.0.3 and the parent commit have no comparator API.
Versions tested7 rows
Introduced by LUCENE-2479 (#3553) "Add support for alternate comparators for spelling" (Grant Ingersoll, 2010-08-17): a63af850b07 on branch_3x (first released in 3.1.0) and f39ad7f02ee on trunk (4.0.0-ALPHA); svn era, a JIRA patch, no PR. It added the Comparator constructor, setComparator and both comparators and built the queue with the comparator; the cutoff from the 2004 sandbox import 57cd0765650 was left as it was. Later commits only renamed (1620366b5ca, LUCENE-2608: min became accuracy) or reformatted. Not fixed on main, branch_10x or branch_10_6 as of 2026-10-26.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | 2 of 3 repro tests fail through gradle, assertions on and off; also with three seeds in the repro runner |
main ea57360912ed | affected | finder, independent reproducer, devil's advocate, adjudication, follow-up and audit: fail with -ea and -da, several seeds |
| 10.5.2, 9.12.3 | affected | this doc's test, the finder's and the independent JUnit, and the portable main(): fail |
| 3.1.0 to 10.0.0 (14 releases) | portable main() | 3.1.0, 3.4.0, 3.5.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0: identical wrong output |
a63af850b07 on the 3.1.0 jars | commit | the introducing commit's spell package reproduces |
3.0.3, parent d793234 | no API | no Comparator API (the repro does not compile); the default-order control is consistent |
fixes A and B on a9779ef3c193 | not affected | all 245 suggest-module tests pass (the 9 repro tests included); A with assertions on and off |
An independent reproducer, an intended-behaviour devil's advocate, cross-model reviews, an adjudication and a credibility audit. Every re-run on main failed, with assertions on and off; every run with a fix passed. A cross-model review could not run code and voted not real on procedure only while agreeing on the root cause.
Checks and verdicts326 words
Each check ran or read on its own; the registration check, the audit and this doc re-ran the tests with fresh seeds and through gradle on a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder (translation validation) | candidate | 5,721 mismatches in 544,485 checks, all with the frequency comparator; JUnit fails on main, 10.5.2 and 9.12.3; passes 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; it voted not real on procedure only. It agreed with the root cause and found the inputs in contract; a separate re-run failed. |
| independent reproducer | real | Own prefix test with other words fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3; its own guard turns 3 failures into 0. |
| cross-model review, intended lens | real, medium | Rated the intended-behaviour argument moderate (the stale @return text); ran no code; a separate re-run failed. |
| intended-behaviour devil's advocate | real | Showed with an order probe that the output follows neither "the comparator selects" nor "score selects"; corrected two overclaims in the finder's text. |
| adjudication | confirmed, high | Re-ran the independent reproducer's JUnit (seed C0FFEE01, -ea and -da) and the finder's (seed DEADBEEF, -ea): fail; required five text corrections, applied here. |
| security probe | security none | 369,792 randomized paired lookup cases on main and 10.5.2 (each calls suggestSimilar with both comparators, 739,584 calls): every returned word in the documented eligible set, the same result size and number of distance computations as the default comparator; a fail-open mutant is caught. |
| credibility audit | keep | Re-ran with new seeds (fail; pass with the fix). |
| this doc's gradle runs | confirmed | Stock main: 7 of 9 repro tests fail with assertions on and off (the 2 controls pass). Fix A: the whole suggest module, 245 tests, 0 failures, both modes. Fix B: 245 tests, 0 failures. Lint passes with A and with B. |
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 536 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,482 words
- Registry title
- SpellChecker.suggestSimilar with SuggestWordFrequencyComparator drops more frequent suggestions: once the queue is full it raises the accuracy cutoff to the score of the least frequent queued word, which is a valid bound only for a score-first order (since 3.1, LUCENE-2479)
- Area
- suggest / spell: SpellChecker.suggestSimilar (index-based n-gram spell checker; comparator vs. full-queue accuracy cutoff)
- Priority
- Low, rank 449: SpellChecker with the frequency comparator returns a set that is not the documented frequency-first selection, losing the correction in only 0.2-0.4% of lookups, so it follows LFV-518.
- Impact class
- silent wrong results
- Impact note
- Applications that use the index-based n-gram SpellChecker of lucene-suggest with SuggestWordFrequencyComparator (through the constructor or setComparator; Solr's index- and file-based spell checkers map comparatorClass=freq to it) and restrict suggestions to a field (a reader and field with SUGGEST_WHEN_NOT_IN_INDEX or SUGGEST_MORE_POPULAR) get, with no error and at any index size, a suggestion set that is not the frequency-first selection the comparator documents whenever numSug qualifying words fill the queue before a more frequent, slightly lower-scored word is reached; on a small English vocabulary that was most lookups (65-76% returned a different set, 19-37% a less frequent first suggestion), but the misspelled word's intended correction was lost in only 0.2-0.4% of lookups, and the documented frequency-first selection would lose it more often (5-26% against 2-4% today), so the user-visible harm is mild; the default SuggestWordScoreComparator, a null reader or field, DirectSpellChecker and search results are not affected, and every release from 3.1.0 to 10.6.0 (10.6.0 by reading) is affected.
- Summary
- You build an index-based SpellChecker with the shipped SuggestWordFrequencyComparator ('Frequency first, then score') and ask for suggestions restricted to a field, expecting the most frequent qualifying words among the retrieved candidates. Once the queue holds numSug words, suggestSimilar raises its accuracy cutoff to the score of the queue's head, which under this comparator is the least frequent queued word, so a later candidate that is more frequent but scores slightly lower is rejected before its frequency is even read. With 'receive' in 50 documents and one-edit neighbours such as 'relieve', 'reprieve' and 'retrieve' in one document each, suggestSimilar('recieve', 1, reader, field, SUGGEST_WHEN_NOT_IN_INDEX) returns [relieve] instead of receive (numSug 1-3 all leave it out; numSug 4 ranks it first).
- Introduced
- a63af850b076831ccd601c8e3ec8085a0df53fd5 (branch_3x, svn r986482, first shipped in 3.1.0) and its trunk twin f39ad7f02eecad81fb78ebf2a7723ee69babaae5 (svn r986477, first shipped in 4.0.0-ALPHA), both 'LUCENE-2479: Add support for alternate comparators for spelling' (JIRA LUCENE-2479, now GitHub issue #3553; svn era, no pull request) [read: git blame, git show and git tag --contains in the full-history clone; exec: the spell package at a63af850b07 compiled as shadow classes over the 3.1.0 jars reproduces, while its parent d793234 has no Comparator constructor (compile error) and its default-order control is consistent]. The commit added the Comparator constructor, setComparator, SuggestWordScoreComparator and SuggestWordFrequencyComparator and built the queue with the comparator, but left the full-queue cutoff unchanged. The cutoff itself comes from the 2004 sandbox import 57cd0765650 ('min=((SuggestWord) sugqueue.top()).score'), when the only order was score then frequency and the cutoff was a valid lower bound; later commits on those lines only renamed or reformatted them (895f763d214 2006, 3619c380d6f LUCENE-1968 insertWithOverflow, 5b83cc59b2e LUCENE-1257 generics, 1620366b5ca LUCENE-2608 min renamed to the accuracy parameter, 4c724b958b1 SOLR-2257 term-text tie-break in both comparators, 2cbf261032d LUCENE-9570 reformatting). Latent in the feature since it shipped, not a regression of the cutoff code (2010-08-17 (trunk 20:40:58 UTC, branch_3x 21:02:43 UTC); the cutoff line dates from 2004-11-02 (57cd0765650))
- First affected version
- 3.1.0 [exec] (tagged 2011-03-30, the first release containing a63af850b07; contrib/spellchecker, artifact lucene-spellchecker); trunk line: 4.0.0-ALPHA [read: git tag --contains f39ad7f02ee; the harness cannot fetch -ALPHA artifacts], 4.0.0 [exec]; no earlier release is affected because the Comparator API did not exist: on 3.0.3 the repro does not compile (no SuggestWordFrequencyComparator) and the default-order control is consistent [exec]
- Affected versions
- every release from 3.1.0 to 10.6.0 (10.6.0 by reading), and main ea57360912ed. [exec] the follow-up's version-portable main() (scenario A: 'recieve' -> [relieve] at numSug=1; scenario B: numSug=2 -> [abcdefghijkl, abcdefgx] instead of the prefix [abcdefghijkl, abxdefyh]; default-comparator control) fails with the same outputs on 3.1.0, 3.4.0, 3.5.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea; also -da on 9.12.3, 10.5.2 and main), with the default-comparator control consistent in every run; the finder's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2, 10.0.0 and 9.12.3; the independent reproduction's JUnit fails 3 of 4 on main (-ea and -da), 10.5.2, 10.0.0 and 9.12.3; the finder's portable main() fails on 8.0.0, 8.11.4 and 9.0.0. [read] 4.0.0-ALPHA and 4.0.0-BETA (tags contain f39ad7f02ee) and the releases between the executed ones (the cutoff and the comparator plumbing are unchanged since 2010); upstream origin/main a9779ef3c193 has SpellChecker.java, SuggestWordQueue.java and SuggestWordFrequencyComparator.java identical to ea57360912ed, and branch_10x 1309534296e3 and branch_10_6 2763db11b0a7 identical to 10.5.2 (2026-10-26). Not executed: 4.0.0-ALPHA/BETA, x86
- 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, read-only; the prior-report record: 278 logged queries, namely the finder's scripted prior-report search (45) and two follow-up scripted prior-report searches (93 and 61) over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@, commits@, solr-user@ and solr-dev@ lists, the LUCENE JIRA archive, downstream repositories and the web, plus 79 manual searches (JIRA REST for LUCENE and SOLR, lists.apache.org for the Solr lists, GitHub search of apache/lucene-solr, apache/solr and apache/lucenenet, archived Q&A pages, git pickaxe over the full history); 28 hits read, none the same bug; the independent reproduction and the intended-behaviour check searched too). Not fixed upstream: the last change to SpellChecker.java on main is 378ca8d1bbf (2026-01-15, javadoc markup), on branch_10x and branch_10_6 de3b294be44 (2023-10-10, formatting); the changed-file lists of all 407 open apache/lucene PRs show none that changes the cutoff (#13951 and #16847 touch SpellChecker.java elsewhere). Closest items, not reports of this bug: #3553 / LUCENE-2479 (2010, the feature that introduced it; its thread never discusses the cutoff), SOLR-2053 (wires comparatorClass=freq into Solr's spell checkers; exposure path), SOLR-9060 (open since 2016: comparatorClass=freq is ignored in SolrCloud because Solr's shard merge re-sorts with the default comparator; different code, fix site in Solr) and its 2015 solr-user and Q&A mirrors, #1653 / LUCENE-575 (2006: the raised minimum persisted across calls; same line, different defect, fixed before the comparator existed), a 2007 java-user thread on numSug-dependent results (the n-gram caveat, before the comparator existed). The Lucene.NET port has the same cutoff, with no report there
- Lucene commit tested
- main
a9779ef3c193(gradle in a scratch worktree, assertions on and off; the standalone JUnit runner with three seeds; a trace probe) and mainea57360912ed(hunt, independent checks, adjudication, follow-up, audit, likelihood and security probes;SpellChecker.javais identical at both commits); the release jars 3.1.0, 3.4.0, 3.5.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, and 3.0.3; the introducing commita63af850b07and its parent on the 3.1.0 jars; fixes A and B ona9779ef3c193. JDK 25.0.3, Linux aarch64.
- Finding record
lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:129-144 (at ea57360912ed) (constructor javadoc: 'the given java.util.Comparator for sorting the results'), :168-184 (setComparator; getComparator 'the comparator in use for ranking suggestions'), :280-306 (suggestSimilar javadoc: :284-290 n-gram caveat and 'at least 5', :303-305 @return 'first criteria: the edit distance, second criteria (only if restricted mode): the popularity'), :361-372 (maxHits = 10 * numSug, queue with the comparator, loop bound), :382-385 (reject sugWord.score < accuracy before the docFreq read), :387-393 (restricted-mode freq filters), :395-399 (insertWithOverflow; 'if queue full, maintain the minScore score'; accuracy = sugQueue.top().score), :68 (DEFAULT_ACCURACY 0.5)lucene/suggest/src/java/org/apache/lucene/search/spell/SuggestWordQueue.java:28-53 (at ea57360912ed) (DEFAULT_COMPARATOR score then frequency; lessThan = comparator.compare < 0, so top() is the smallest element under the configured comparator)lucene/suggest/src/java/org/apache/lucene/search/spell/SuggestWordFrequencyComparator.java:21-48 (at ea57360912ed) ('Frequency first, then score.'; freq, then score, then term text); lucene/suggest/src/java/org/apache/lucene/search/spell/SuggestWordScoreComparator.java:21 ('Score first, then frequency'; not final)
- The finding in 30 seconds
a Lucene main checkout (a9779ef3c193) lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:307-413 (suggestSimilar; :361-366, :372-399)a Lucene main checkout (a9779ef3c193) lucene/suggest/src/java/org/apache/lucene/search/spell/SuggestWordQueue.java:28-53; SuggestWordFrequencyComparator.java:21-49; SuggestWordScoreComparator.java:21-50
- Why it looks unintended
a Lucene main checkout (a9779ef3c193) lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:129-132, 178, 284-290, 303-305; SuggestWordFrequencyComparator.java:21-27a Lucene main checkout (a9779ef3c193) lucene/CHANGES.txt:16164-16165; lucene/suggest/src/test/org/apache/lucene/search/spell/TestSpellChecker.java:153-171github.com/apache/lucene/issues/3553 (LUCENE-2479 body, read-only gh issue view, 2026-10-28)a Lucene main checkout (a9779ef3c193) lucene/suggest/src/java/org/apache/lucene/search/spell/DirectSpellChecker.java:67, 359-366, 386-391, 435
- Suggested fix
a Lucene main checkout (a9779ef3c193) lucene/suggest/src/java/org/apache/lucene/search/spell/SuggestWordScoreComparator.java:22 (not final)
- TestSpellCheckerFrequencyComparator.java (JUnit test):
repro/LFV-566/src/org/apache/lucene/search/spell/TestSpellCheckerFrequencyComparator.java(56 lines)
A step-by-step picture of the two-word example, made in parallel with this doc from a trace probe on main.
assets/LFV-566/bug.png(536 KB)
![Query recieve against a spell index built from a field with receive (50 documents, Levenshtein score 0.714) and relieve (1 document, 0.857), with SuggestWordFrequencyComparator. The n-gram search returns relieve first. With numSug 1 the queue fills with relieve, the accuracy cutoff rises from 0.5 to 0.857, and receive is rejected before its frequency is read: the result is [relieve]. With numSug 2 both words are queued and the result is [receive, relieve].](../assets/LFV-566/bug.png)