All findings · Lucene finding · LFV-566 · spellchecker-accuracy-raise-assumes-score-comparator

SpellChecker with SuggestWordFrequencyComparator can drop the most frequent suggestion once its queue is full

With SuggestWordFrequencyComparator, SpellChecker drops a more frequent suggestion once its queue is full: for recieve, asking for 2 gives [receive, relieve], but asking for 1 gives [relieve], although receive is in 50 documents and relieve in 1. Since 3.1.0; the default comparator is not affected.

Summary

Priority
Low, rank 449SpellChecker 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
silent wrong results
Since
3.1.0 (LUCENE-2479, 2010)introduced 2010-08-17 · over 16 years
Affected
3.1.0 to 10.6.0 and main; 16 releases run, 10.6.0 by reading
Status
documented
Found by
translation validation against a comparator-selects reference

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

This is the index-based n-gram SpellChecker of lucene-suggest, used with a reader and a field; DirectSpellChecker is a different class.

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].
The two-word example step by step: 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.
  1. Index receive in 50 documents and relieve in 1. Build a SpellChecker with LevenshteinDistance and SuggestWordFrequencyComparator ("Frequency first, then score") and ask for suggestions for recieve, restricted to that field.
  2. Ask for 2: you get [receive, relieve], frequency first. Both pass the default accuracy 0.5: receive scores 0.714, relieve 0.857.
  3. Ask for 1: you expect [receive], the first entry of that list. You get [relieve], with no error.
  4. Why: the n-gram search returns relieve first. 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, so receive (0.714) is rejected before its 50 documents are counted.
  5. It also happens at the recommended numSug=5: five of six rare one-edit neighbours of abcdefg fill the queue (the sixth ties and is not kept) and abcdfeg (50 documents) is missing. On an English test corpus 65-76% of lookups return a set other than the comparator's top numSug, but the intended word is lost in only 0.2-0.4%.

Who is affected

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%.

Terms

  • SpellChecker: lucene-suggest's index-based spell checker. It keeps a separate spell index of words split into n-grams. suggestSimilar runs an n-gram query against it, fetches up to 10 * numSug hits, scores each with a StringDistance (here LevenshteinDistance: 1 - edits / longer length) and keeps the best numSug in a bounded queue.
  • Restricted mode: pass the user index's reader and a field (with SUGGEST_WHEN_NOT_IN_INDEX or SUGGEST_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; SuggestWordFrequencyComparator is frequency, then score.
  • Accuracy: the minimum score a word needs (default 0.5).

The bug

  1. For each hit, suggestSimilar computes the score and skips the word if score < accuracy, before it reads the word's frequency (SpellChecker.java:382-385).
  2. After each insertion, once the queue holds numSug words, it sets accuracy = sugQueue.top().score (:396-399, comment "if queue full, maintain the minScore score").
  3. 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.
  4. 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.

The numbers

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) or setComparator, with SuggestWordFrequencyComparator in 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 maps comparatorClass=freq to SuggestWordFrequencyComparator and calls suggestSimilar with max(count, 5) suggestions (or alternativeTermCount, when set, for a token already in the index), the reader when a field is configured, and the field; the Elasticsearch frequency sort uses DirectSpellChecker, which does not run this code.
  • What it takes. A full queue (numSug smaller 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=5 still fails.
  • How often. Measured on main (-da; -ea identical) 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 at numSug 5 and 10, LevenshteinDistance, accuracy 0.5. Reference: the comparator's top numSug among the same hits (the fix).
typo draw, numSugset differsfirst suggestion less frequentintended word lost by the cutoffintended word in the result: today / with the fix
uniform, 576.1%36.5%0.2%95.9% / 74.0%
uniform, 1067.5%26.6%0.3%97.9% / 87.8%
frequency-weighted, 573.2%25.1%0.4%96.5% / 88.0%
frequency-weighted, 1064.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 numSug is the normal case, but the intended word is rarely lost because of it; for example wihch (from which, 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.

Minimal repro

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).

TestSpellCheckerFrequencyComparator.java (JUnit test)56 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search.spell;

import java.util.Arrays;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.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));
      }
    }
  }
}
Command
# 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 -ea and -da.

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

More files, environment, full output32 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • 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 docFreq lookups was part of the design.
  • Prefix check. If the comparator picks the top numSug, then with every candidate retrieved and passing the filters, numSug=k must 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()).score when SuggestWord.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.

Suggested fix

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.

A: drop the full-queue raise (validated by the checks) (recommended)

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.

B: keep the raise only for exactly SuggestWordScoreComparator (validated by the checks)

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.

C: score selects, the comparator only sorts (document it)

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.

Document the current behaviour

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.

Downstream note

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.

How it was found

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.suggestSimilar from 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 top numSug. Cases with more retrievable words than 10 * numSug were 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 SuggestWordFrequencyComparator in 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@, solr-user@, solr-dev@; LUCENE JIRA archive; downstream repositories; web2026-10-26a 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 history2026-10-2679 manual searches and checks; 28 hits readclosest: SOLR-9060 (different code, fix site in Solr)
Open PRs2026-10-26changed 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

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected2 of 3 repro tests fail through gradle, assertions on and off; also with three seeds in the repro runner
main ea57360912ed affectedfinder, independent reproducer, devil's advocate, adjudication, follow-up and audit: fail with -ea and -da, several seeds
10.5.2, 9.12.3 affectedthis 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 committhe introducing commit's spell package reproduces
3.0.3, parent d793234 no APIno Comparator API (the repro does not compile); the default-order control is consistent
fixes A and B on a9779ef3c193 not affectedall 245 suggest-module tests pass (the 9 repro tests included); A with assertions on and off

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (translation validation) candidate5,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 realOwn 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, mediumRated the intended-behaviour argument moderate (the stale @return text); ran no code; a separate re-run failed.
intended-behaviour devil's advocate realShowed 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, highRe-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 none369,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 keepRe-ran with new seeds (fail; pass with the fix).
this doc's gradle runs confirmedStock 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,482 words

Finding record

LFV-566, snapshot of 2026-11-06

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 main ea57360912ed (hunt, independent checks, adjudication, follow-up, audit, likelihood and security probes; SpellChecker.java is 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 commit a63af850b07 and its parent on the 3.1.0 jars; fixes A and B on a9779ef3c193. JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

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-27
  • a Lucene main checkout (a9779ef3c193) lucene/CHANGES.txt:16164-16165; lucene/suggest/src/test/org/apache/lucene/search/spell/TestSpellChecker.java:153-171
  • github.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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

A step-by-step picture of the two-word example, made in parallel with this doc from a trace probe on main.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.