All findings · Lucene finding · LFV-240 · directspellchecker-custom-distance-competitive-boost

DirectSpellChecker with a custom StringDistance prunes candidates on the wrong scale and returns worse suggestions

With a non-default StringDistance such as LevenshteinDistance, DirectSpellChecker prunes candidates by comparing the metric's score with the fuzzy enumerator's own edit-distance similarity, so a better word can be dropped unscored: lucene gets lucen (0.833) instead of lucenes (0.857). Since 7.0.0; the default distance is unaffected.

Summary

Priority
Low, rank 438Since 7.0.0 DirectSpellChecker with a non-default StringDistance (setDistance, Solr's distanceMeasure, the string_distance option of the Elasticsearch and OpenSearch suggesters) prunes candidates by comparing the metric's score with FuzzyTermsEnum boosts and silently returns a changed or worse suggestion on roughly 0.1-1% of lookups, a real regression behind mainstream options but a mild quality loss in an opt-in setting.
Impact
silent wrong results
Since
7.0.0 (LUCENE-7439, 2016)introduced 2016-09-12 · over 10 years
Affected
7.0.0 to 10.6.0 (10.6.0 by reading) and main; 4 affected releases run (7 run in total)
Status
documented
Found by
translation validation against the documented candidate draw

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

A lookup keeps a short list of the best candidates and prunes the rest. With a custom metric it prunes by comparing two different kinds of score.

Query lucene against the terms lucen, lucena, lucenb, lucenc, lucend, lucenes with LevenshteinDistance and default settings, on two scales: the enumerator's internal similarity (lucen 0.800, the others 0.833) and LevenshteinDistance (lucenes 0.857, the others 0.833). On main the 5-slot list fills with Levenshtein scores of 0.833, lucenes (internal 0.833) fails the check 0.833 <= 0.833 and is skipped, the enumerator drops to 0 edits, and the answer is lucen. With the fix the list's floor is lucen's internal 0.800, lucenes gets in and is returned with 0.857.
The example step by step: the five-slot candidate list fills with Levenshtein scores of 0.833, lucenes arrives with the enumerator's 0.833 and is dropped unscored; with the fix the list's floor is lucen's 0.800 and lucenes gets in. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index six words: lucen, lucena, lucenb, lucenc, lucend, lucenes. Call setDistance(new LevenshteinDistance()), keep every other default, and ask for 1 suggestion for lucene.
  2. You expect lucenes: it scores 0.857 under LevenshteinDistance, every other word 0.833, and it is among the 5 candidates (1 suggestion x 5 inspections) that the edit-distance draw keeps.
  3. You get lucen (0.833) and no error. The first five words in term order fill the 5-slot candidate list, each stored with its Levenshtein score, 0.833.
  4. lucenes arrives with the enumerator's own similarity, 0.833 (1 edit in 6 letters). The code tests 0.833 <= 0.833 as if both were the same kind of score and drops lucenes without scoring it; the enumerator, already told that nothing at 0.833 can compete, then stops.
  5. JaroWinklerDistance also returns lucen (0.972 instead of 0.981); the default distance is unaffected. With the 6.x logic restored (prune on the enumerator's score, rank by the metric) both return lucenes.

Who is affected

Applications that pass LevenshteinDistance, JaroWinklerDistance, NGramDistance or their own metric to setDistance (Solr's distanceMeasure, the string_distance suggest option of Elasticsearch and OpenSearch), on 7.0.0 to 10.6.0 (10.6.0 by reading) and main. It is rare: 0.02-0.12% of single-typo lookups over a 50,000-word list got a worse suggestion, such as saxo giving salo, not saxon.

Mechanism, terms and numbers622 words

DirectSpellChecker draws spelling candidates from the term dictionary with FuzzyTermsEnum (Damerau-Levenshtein) and, when you call setDistance, is documented to rank them with your metric. Since 7.0.0 it stores the metric's score in ScoreTerm.boost, a field documented as the enumerator's internal similarity, and then uses it twice as if it were that similarity: to skip later candidates as uncompetitive and to tell the enumerator to lower its edit distance. The two scores are on different scales, so a better word can be skipped before it is scored and the enumeration can end early. With default settings, the index {lucen, lucena, lucenb, lucenc, lucend, lucenes} and the query lucene, LevenshteinDistance returns lucen (0.833) instead of lucenes (0.857). Lucene's default distance and a separate LuceneLevenshteinDistance instance are not affected. Changing 3 code lines restores the 4.0-6.6.6 logic.

Terms

  • DirectSpellChecker suggests corrections straight from the term dictionary. For a query word it walks the terms within 1 edit (then, if it found too few, within maxEdits, default 2) with a FuzzyTermsEnum and keeps the best numSug * maxInspections candidates (default maxInspections 5) in a bounded queue. The survivors are sorted by score and the top numSug are returned.
  • Internal similarity (the enumerator's boost): 1 - edits / min(term length, query length). For lucene (6 letters): lucena 0.833 (1 edit / 6), lucen 0.800 (1 edit / 5), lucenes 0.833 (1 edit / 6).
  • The configured metric (setDistance): LevenshteinDistance is 1 - edits / max(length), so lucenes scores 0.857 (1 / 7) and lucen..lucend 0.833. JaroWinklerDistance and NGramDistance use other formulas. The default, INTERNAL_LEVENSHTEIN, is the internal similarity itself.
  • Pruning. Once the queue is full, a new term whose boost is not above the queue's worst entry is skipped, and the worst entry's value is passed to FuzzyTermsEnum.setMaxNonCompetitiveBoost. The enumerator then drops from 1 edit to 0 when that value reaches 1 - 1 / query length, the best any 1-edit term can score: no 1-edit term could beat it.

The bug

  1. For a metric other than the internal one, the loop overwrites score with the metric's value and stores it as st.boost (DirectSpellChecker.java:452-459, :467). The field's javadoc says boost is "the similarity from the FuzzyTermsEnum (internal similarity score)" (:487).
  2. The uncompetitive check compares the next term's internal similarity with that metric-scale number (:435), and setMaxNonCompetitiveBoost hands it to the enumerator (:474-475), which reads it on its own 1 - maxEdits / termLength scale (FuzzyTermsEnum.java:204-210).
  3. So a better candidate can be skipped before it is scored, and the enumeration can stop early. The queue, ordered by the metric (:503-511), then holds neither the documented pool nor the metric's top N.

The numbers

Default settings, one document per word, query lucene, LevenshteinDistance:

term (term order)internal similarityLevenshteinDistancewhat main does
lucen0.8000.833queued with 0.833
lucena, lucenb, lucenc, lucend0.8330.833queued with 0.833; the queue (5 slots) is full
lucenes0.8330.8570.833 <= 0.833: skipped, never scored; the enumerator drops to 0 edits

Main returns lucen, which is not even among the 5 best by internal similarity. With the fix the queue's worst entry is lucen at 0.800, lucenes (0.833) gets in, scores 0.857 and is returned. With JaroWinklerDistance main returns lucen (0.972) instead of lucenes (0.981). With maxInspections 1 and the index {lucen, lucenes}, all three shipped metrics return lucen, although lucenes is better under the internal similarity and under each metric. A trace of the term reads on main shows lucenes read from the term dictionary but given no docFreq and no getDistance call, and the enumerator re-opened at 0 edits.

Exposure and likelihood303 words
  • Who. Opt-in: the default distance is not affected, and neither is a separate LuceneLevenshteinDistance instance (same scale). By reading downstream source (nothing run there): Solr's DirectSolrSpellChecker maps distanceMeasure to setDistance; the term and phrase suggesters of Elasticsearch and OpenSearch map string_distance levenshtein, jaro_winkler and ngram to these classes, and their damerau_levenshtein to the unaffected LuceneLevenshteinDistance.
  • When. Default settings otherwise; the index size does not matter (the example has six terms): it shows when the 1-edit candidate queue (numSug * maxInspections) fills before a better term comes up in term order, so dense neighbourhoods and small maxInspections are hit most. Raising maxInspections makes it rarer; the comparison stays on the wrong scale.
  • How often (default settings, the suggest module's 50,000-word test vocabulary, 20,000 queries per row): a suggestion strictly worse than a member of the documented pool in 0.12% / 0.04% / 0.02% of single-typo queries (Levenshtein / JaroWinkler / NGram) and 0.46% / 0.51% / 0.12% of double-typo queries; none with the fix. The original word came back 0.1 to 2.5 percentage points less often. The rates hold for that query generator (words of 5-12 letters, one or two random edits that keep the first letter, queries not in the list, numSug 1), not for lookups in general.
  • Examples from that run: saxo (from "saxon") gives salo (Levenshtein 0.750) although saxon (0.800) is in the pool, and sax (0.942) instead of saxon (0.960) with JaroWinkler; toilte gives tilde instead of toilets. On Lucene's Reuters test text, with numSug 1, 8 (JaroWinkler) to 23 (Levenshtein) of 2,000 single-edit lookups got a different top suggestion than with the documented behaviour (0 for the internal control); cops (from "crops") gives cos instead of crops.
  • Silent. The wrong word looks plausible; there is no error and no search hit is involved.

Minimal repro

The test shows that DirectSpellChecker with LevenshteinDistance and setMaxInspections(1) never inspects the better candidate, because it compares the metric's score with FuzzyTermsEnum's internal-similarity boosts to prune candidates.

TestDirectSpellCheckerLevenshteinDistance.java (JUnit test)49 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.List;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDirectSpellCheckerLevenshteinDistance extends LuceneTestCase {

  public void testBestCandidateIsInspected() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        w.addDocument(List.of(new StringField("f", "abc", Field.Store.NO)));
        w.addDocument(List.of(new StringField("f", "abcde", Field.Store.NO)));
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        DirectSpellChecker sc = new DirectSpellChecker();
        sc.setDistance(new LevenshteinDistance());
        sc.setMaxInspections(1);
        SuggestWord[] s =
            sc.suggestSimilar(new Term("f", "abcd"), 1, r, SuggestMode.SUGGEST_ALWAYS);
        // expected: abcde (closer to abcd than abc under both similarities); actual: abc
        assertEquals("abcde", s[0].string);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-240/, 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.TestDirectSpellCheckerLevenshteinDistance
Expected vs actual
Expected: suggestSimilar('abcd', 1, SUGGEST_ALWAYS) on the index {abc, abcde} returns abcde (closer under both similarities). Actual: abc (expected:<abc[de]> but was:<abc[]>).

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

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:<abc[de]> but was:<abc[]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.spell.TestDirectSpellCheckerLevenshteinDistance.testBestCandidateIsInspected(TestDirectSpellCheckerLevenshteinDistance.java:45)

Why it looks unintended

  • setDistance says candidates are drawn "using Damerau-Levenshtein" for "your metric to rank"; ScoreTerm.boost, documented as the "internal similarity score", now holds the metric's score.
  • 4.0 to 6.6.6 pruned on the enumerator's score, and 6.6.6 returns lucenes; a 2016 FuzzyQuery clean-up (LUCENE-7439) changed it.
  • An order-preserving sqrt of the internal distance changes 74 of 8,000 results (all at maxInspections 1).
The full argument520 words
  • Contract. Class javadoc (DirectSpellChecker.java:39-41): "Candidates are presented directly from the term dictionary, based on Levenshtein distance. This is an alternative to SpellChecker if you are using an edit-distance-like metric such as Levenshtein or JaroWinklerDistance." setDistance (:279-284): "because this spellchecker draws its candidates from the term dictionary using Damerau-Levenshtein, it works best with an edit-distance-like string metric. If you use a different metric than the default, you might want to consider increasing setMaxInspections(int) to draw more candidates for your metric to rank." ScoreTerm.boost (:487): "The boost representing the similarity from the FuzzyTermsEnum (internal similarity score)".
  • The enumerator's side. setMaxNonCompetitiveBoost "may allow switching to a lower max-edit automaton" (FuzzyTermsEnum.java:162-166) and compares the value with 1 - maxEdits / termLength (:205). On #10608 (LUCENE-9568) a committer explained that changing that scale "will break most of the top-N optimizations".
  • Design. On #3581 (LUCENE-2507, 2010) the class's author wanted "re-ranking against the existing StringDistance metrics" and wrote "if you are ranking by an edit-distance like function such as Levenshtein or Jaro-Winkler, it makes more sense to get your candidates via the same or similar algorithm!".
  • History. 4.0.0-alpha to 6.6.6 kept final float boost = boostAtt.getBoost(), boost <= stQueue.peek().boost and st.boost = boost; 6.2.1, 6.3.0 and 6.6.6 return lucenes. faf3bc3134c ("LUCENE-7439: clean up FuzzyQuery/FuzzyTermsEnum sources") removed the enumerator's scale factor and changed the loop to st.boost = score, which is neutral for the internal distance only. On #8491 the author called the patch "just this cleanup; it doesn't change the behavior on short terms" (the issue's topic); nobody mentioned the spellchecker, the only LUCENE-7439 CHANGES entry is about FuzzyQuery and short terms and sits under 6.3.0 (the clean-up commit itself was master-only and first released in 7.0.0), and 7.0.0's MIGRATE.txt has no note. On the 7.0.0 jars the parent's two files pass, the commit's fail and a 3-line revert passes.
  • Tests. No test under lucene/ calls DirectSpellChecker.setDistance; the other metrics are used as spellchecker metrics only with the separate-index SpellChecker (the metric classes have their own unit tests). With the fix every suite in the module passes.
  • Both scales agree. In the maxInspections 1 case the returned lucen is worse than lucenes under the internal similarity and under each metric, so the expectation does not depend on how "the pool" is read.
  • Metamorphic check. (float) Math.sqrt of LuceneLevenshteinDistance keeps the order of the internal similarity, and on the test's short words distinct scores stay distinct after the float rounding; with accuracy 0 it must return what the internal distance returns. On main it returns abc instead of abcde and differs in 74 of 8,000 random checks, all at maxInspections 1 (0 of the 4,000 at the default 5); the identity control differs in 0. It supports the semantics argument; the default-settings failure rests on the lucene and abcd examples.
  • Design intent, not a formal guarantee. "The documented pool" is read from the setDistance and ScoreTerm.boost javadocs; suggestSimilar promises a list "sorted ... according to the comparator", not the globally best word. The maxInspections 1 example does not depend on that reading.
The devil's-advocate check185 words

The strongest works-as-designed case (the cross-model lenses, the independent reproducer, the devil's advocate and the adjudication): the class is @lucene.experimental; candidate selection is a documented heuristic budget; setDistance itself advises raising maxInspections for another metric, and the maxInspections 1 repro ignores that advice; the class's author discouraged n-gram metrics here ("if you use NGramDistance, use the existing spellchecker!"); ScoreTerm is "for internal usage"; and st.boost = score could be read as a deliberate choice to order the queue by the metric.

Why it fails: the default-settings case fails too, and the returned word is worse under both scales. If the queue were meant to hold the metric's top N, the pruning would compare metric with metric; it compares metric with the enumerator's boost and stops the walk on that number, so the queue is the top N of a truncated walk. The change came from a clean-up commit with no note about spell checking, it contradicts the field's own javadoc, and an order-preserving transform of an internal-equivalent metric changes the results (at maxInspections 1). NGramDistance is the weak part. Verdict: not intended, a regression.

Suggested fix

Recommended A: keep the enumerator's boost in ScoreTerm.boost (the 4.0-6.6.6 logic)

Keep the enumerator's similarity in ScoreTerm.boost, so the pruning compares like with like, and leave the metric's score in ScoreTerm.score for the final ranking: 3 code lines changed back to the 4.0-6.6.6 logic. With it the repro returns lucenes and all 244 suggest-module tests pass, assertions on and off.

// the internal similarity (1 - ed/minTermLength); for FuzzyQuery, boost is the score
final float boost = e.getBoost();
float score = boost;
// ignore uncompetitive hits: compare internal similarity with internal similarity
if (stQueue.size() >= numSug && boost <= stQueue.peek().boost) {
  continue;
}
...
st.boost = boost;   // was: st.boost = score
st.score = score;   // the metric's score, used for the final ranking
All options and trade-offs3 options · 533 words

Two fixes were built and run in a scratch worktree at a9779ef3c193; A restores the documented behaviour and is recommended.

A: keep the enumerator's boost in ScoreTerm.boost (the 4.0-6.6.6 logic) (validated by the checks) (recommended)

Remember e.getBoost() as boost, use it for the uncompetitive check and store it in st.boost; the metric's value stays in score and st.score, which the final sort uses. The queue order and setMaxNonCompetitiveBoost then use the internal similarity again, as the field's javadoc says. For the internal distance nothing changes (boost == score). +6/-5 in DirectSpellChecker.java; the finder's and the independent reproducer's separately written fixes are the same logic (with other comments and variable names).

// the internal similarity (1 - ed/minTermLength); for FuzzyQuery, boost is the score
final float boost = e.getBoost();
float score = boost;
// ignore uncompetitive hits: compare internal similarity with internal similarity
if (stQueue.size() >= numSug && boost <= stQueue.peek().boost) {
  continue;
}
...
st.boost = boost;   // was: st.boost = score
st.score = score;   // the metric's score, used for the final ranking

Trade-offs

Restores the documented pool (the numSug * maxInspections best by Damerau-Levenshtein, ranked by the metric), so a metric that is not edit-distance-like can still miss candidates outside that pool, as setDistance documents. Nothing changes for the internal distance. For another metric it changes the suggestions existing users get (back to the pre-7.0 behaviour) and can read more or fewer terms than main: in the security probe's randomized lookups both directions occurred. Not benchmarked.

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, 244 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. With the fix as a shadow class on main: the three repro tests pass with several seeds, upstream TestDirectSpellChecker 6 of 6, the finder's harness reports no mismatch in 900,000 lookups, and the 50,000-word measurement finds no worse-than-pool suggestion. Not benchmarked.

B: do not prune when another metric is set (validated by the checks)

Skip the uncompetitive check and the setMaxNonCompetitiveBoost call unless distance == INTERNAL_LEVENSHTEIN, and keep st.boost = score. +8/-3.

Trade-offs

The queue becomes the metric's top N of the eligible terms within 1 edit (those that pass the exact-match, docFreq and accuracy filters), or within maxEdits when that gives fewer than N: arguably a better pool, but a different contract from the javadoc, and each custom-metric 1-edit pass runs its automaton to the end, with no early switch to fewer edits (bounded by the unpruned enumeration). A separate LuceneLevenshteinDistance instance loses the pruning too.

What the checks validated

Gradle: the 3 repro classes and TestDirectSpellChecker, 14 tests, 0 failures, with assertions on (this doc's run) and off (the independent fact check's run). The whole module and the cost were not run.

Document the current behaviour

Rewrite the ScoreTerm.boost and setDistance javadocs to describe metric-ordered pruning.

Trade-offs

Does not fix it: the enumerator would still read a metric score on its own scale, so the walk stops on a number with no meaning there. Not recommended.

Downstream note

Elasticsearch and OpenSearch let a suggest request pick the metric (string_distance) and Solr maps distanceMeasure to it, so their users can hit this, but it only lowers the quality of that request's own suggestion.

How it was found

Translation validation: a harness compared the stock spellchecker with a reference written from its documented candidate draw (a Damerau-Levenshtein pool, then ranking by the metric) on 30,000 random indexes. The idea came from reading the code while writing that reference; the harness then found 412 strictly worse results, all with non-default metrics, and none with the fix.

The method in full180 words

Method: bounded translation validation (the hunt's "semi-formal" tier: a hypothesis formed while modelling, then witnessed by a tool on the real jar; no model checker, solver or proof).

  • The finder wrote a reference model of suggestSimilar from its documentation: a pool of numSug * maxInspections terms drawn by the internal Damerau-Levenshtein similarity, the 1-edit then maxEdits passes, the docFreq, accuracy and suggest-mode filters, then ranking by the configured StringDistance.
  • While writing it, they noticed that ScoreTerm.boost is assigned the custom score. The harness then compared the stock main jar with the reference over 30,000 random indexes and 900,000 lookups: 412 strictly dominated results (a returned term worse than an unreturned candidate under both the internal similarity and the metric: 109 Levenshtein, 170 JaroWinkler, 133 NGram) and no mismatch for the internal distance.
  • With the fix shadowed the harness reported no mismatch at all, and a deliberately wrong reference was caught, so the check is not vacuous.
  • The finder reduced it to a JUnit test; an independent reproducer wrote its own test with other words, which is the primary repro here.

Prior reports, history and checks

Prior reports

No prior report found (189 queries over Lucene issues, PRs, commits, JIRA, mailing lists, downstream trackers and the web, 2026-10-26). The closest item, an open 2017 Elasticsearch issue, discusses the documented candidate-coverage limit and does not identify the scale mix-up.

Searches5 searches

Registry verdict new: no report or discussion of this bug by anyone else was found. 33 hits were read and classified; none is this bug. Nearest items: #8491 (LUCENE-7439, the introducing change), elastic/elasticsearch#24655 (the documented limit for metrics that are not edit-distance-like), #3581 (LUCENE-2507, the design), #10608 (LUCENE-9568, the enumerator's pruning scale), and #16847 (open PR, the only one touching DirectSpellChecker.java; it leaves these lines alone).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; web2026-10-26a scripted prior-report search, 70 queries (class, method and metric names, symptoms)no report; hits predate the 2016 change or are unrelated
GitHub (apache/lucene, apache/lucene-solr)2026-10-2651 GraphQL searches (setDistance, setMaxNonCompetitiveBoost, competitive boost, LUCENE-7439, metric names)no report
Downstream and all of GitHub (Elasticsearch, OpenSearch, Solr)2026-10-2629 searches (string_distance, jaro_winkler, max_inspections, distanceMeasure, DirectSolrSpellChecker)closest: elastic/elasticsearch#24655 (documented coverage limit, not this bug)
JIRA (LUCENE, SOLR), lists.apache.org, web and Q&A2026-10-268 JQL, 14 list and 8 web searches; 4 Q&A pages readno report; the list threads use the internal distance or predate 7.0
Open PRs2026-10-26changed files of all 413 open apache/lucene PRs#16847 touches the file but not these lines; no fix in flight

History

Introduced by faf3bc3134c (LUCENE-7439, 2016-09-12), a FuzzyQuery clean-up, first released in 7.0.0. The repro fails on 7.0.0, 8.11.4, 9.12.3, 10.5.2 and main and passes on 6.2.1, 6.3.0 and 6.6.6; a commit-level A/B on the 7.0.0 jars confirms the commit.

Versions tested7 rows

Introduced by faf3bc3134c6e5ba3e2caa15762524872e083152 "LUCENE-7439: clean up FuzzyQuery/FuzzyTermsEnum sources" (@mikemccand, 2016-09-12), a JIRA patch (now #8491) committed to master only, with no PR. It removed the enumerator's scale factor and changed st.boost = boost to st.boost = score and the uncompetitive check to score <= stQueue.peek().boost. DirectSpellChecker and setDistance date from LUCENE-2507 (4.0.0-alpha). Later commits on the loop are mechanical (9d72bfc1afe LUCENE-9068, which shipped in 8.5.0 through its branch_8x commit 32af73511f9; 2cbf261032d reformatting). Not fixed on main; nothing was run on newer branches, but in the local upstream clone origin/branch_10_6 and origin/branch_10x have the same two files as a9779ef3c193, and so does the 10.6.0 release tag (4ae946e5a9b, 2026-10-29), so 10.6.0 is affected by reading. The finder's per-release test stops at the first failing metric (Levenshtein); the independent reproducer's test reports Levenshtein, JaroWinkler and NGram failing on 7.0.0, 8.11.4, 9.12.3 and 10.5.2, as on main.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected6 of 8 repro tests fail through gradle, assertions on and off; the repro runner reproduces too
main ea57360912ed affectedfinder, independent reproducer, metamorphic test and adjudication: fail with -ea and -da, several seeds
10.5.2, 9.12.3, 8.11.4, 7.0.0 affectedthe finder's and the independent tests fail 2 of 3 (published jars); the independent test's failure lists all three metrics
6.6.6, 6.3.0, 6.2.1 not affectedpass 3 of 3
4.0.0, 4.10.4, 5.5.5 read onlycontain st.boost = boost
7.0.0 jars + faf3bc3134c vs its parent bisectthe commit's two files fail 2 of 3; the parent's pass; a 3-line revert passes
fix A on a9779ef3c193 not affectedall 244 suggest-module tests pass (the 8 repro tests included), assertions on and off

Independent checks

An independent reproducer, a metamorphic test, an intended-behaviour devil's advocate, cross-model reviews and an adjudication. Every re-run on main failed, with assertions on and off; every run with the 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 verdicts430 words

Each check ran or read on its own; the registration check 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) candidate412 strictly dominated results in 900,000 lookups, none for the internal distance, none with the fix; JUnit fails on 7.0.0-10.5.2 and main, passes on 6.x.
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 2 of 3.
independent reproducer realOwn test (the primary repro) and own fix; fails on main, 10.5.2, 9.12.3, 8.11.4, 7.0.0, passes on 6.6.6; measured the 50,000-word rates.
cross-model review, intended lens + metamorphic test real, mediumIntended-behaviour argument rated moderate; a separate sqrt test fails on main (74 of 8,000) and passes with the fix.
intended-behaviour devil's advocate realFound the LUCENE-7439 and LUCENE-2507 record; rebutted the works-as-designed readings; measured the Reuters change rate.
adjudication confirmed, highRe-ran with seed CAFEBABE (-ea, -da: fail; with the fix: pass); called it a suggestion-quality regression and lowered the finder's score.
security probe security none54,000 randomized lookups on main and 10.5.2: every result from the documented eligible set, never more term reads than without pruning.
this doc's gradle runs confirmedStock main: 6 of 8 repro tests fail with assertions on and off (the 2 controls pass). Fix A: the whole suggest module, 244 tests, 0 failures, both modes. Fix B: 14 tests, 0 failures. Lint (:lucene:suggest:check -x test) passes with fix A.
fact check of this doc 3 side-statement errors, fixedRe-ran the repro itself: the doc's gradle command fails 2 of 3 on a9779ef3c193 with assertions on and off, the three repro classes fail 6 of 8 as shown, fix A passes 20 of 20 and fix B 14 of 14 with assertions off. Its three errors (release coverage of the JaroWinkler and NGram failures, the Reuters numSug 5 wording, the release count) are corrected.
cross-model fact check sound on the main claim; 7 errors (0 high), fixedAn independent reviewer from another model family (OpenAI GPT-5.6), reading only, checked the claims against the saved logs. Its 7 factual errors, 7 overclaims and 5 missing caveats (for example the sqrt float-rounding wording, the 2.5 percentage points, the LUCENE-8051 and LUCENE-9068 release attribution, fix A's cost) are corrected.

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

Finding record

LFV-240, snapshot of 2026-11-06

Registry title
DirectSpellChecker with a non-default StringDistance prunes candidates by comparing the metric's score with FuzzyTermsEnum boosts, so better candidates are never inspected (regression since 7.0)
Area
suggest / spell checking (DirectSpellChecker.suggestSimilar with a non-default StringDistance; FuzzyTermsEnum competitive-boost pruning)
Priority
Low, rank 438: Since 7.0.0 DirectSpellChecker with a non-default StringDistance (setDistance, Solr's distanceMeasure, the string_distance option of the Elasticsearch and OpenSearch suggesters) prunes candidates by comparing the metric's score with FuzzyTermsEnum boosts and silently returns a changed or worse suggestion on roughly 0.1-1% of lookups, a real regression behind mainstream options but a mild quality loss in an opt-in setting.
Impact class
silent wrong results
Impact note
Applications on Lucene 7.0.0 through 10.6.0 (10.6.0 by reading) or main that give DirectSpellChecker a StringDistance whose scores are not on its internal 1 - edits/min(length) scale (LevenshteinDistance, JaroWinklerDistance, NGramDistance or their own metric, set through setDistance; Solr's distanceMeasure and the string_distance option of the Elasticsearch and OpenSearch term and phrase suggesters map to it) get a worse spelling suggestion and no error, with otherwise default settings and at any index size, whenever the numSug*maxInspections candidate queue fills with one-edit terms before a better term is reached (measured: a suggestion strictly worse than a member of the documented candidate pool on 0.02-0.12% of single-typo and 0.1-0.5% of double-typo queries over a 50,000-word vocabulary, and a changed suggestion on 0.4-1.2% of single-edit lookups over Reuters text), while the default internal distance and a separate LuceneLevenshteinDistance instance are not affected and no search hits are involved.
Summary
You give DirectSpellChecker a metric such as LevenshteinDistance or JaroWinklerDistance and expect what setDistance documents: candidates drawn from the term dictionary by Damerau-Levenshtein (the internal similarity), then ranked by your metric. Since 7.0 the code stores your metric's score in ScoreTerm.boost, a field documented as the internal similarity, and then skips later candidates and tells FuzzyTermsEnum to lower its edit distance by comparing their internal similarity with that number, which is on a different scale, so better terms are never looked at. With default settings, the index {lucen, lucena, lucenb, lucenc, lucend, lucenes} and the query 'lucene', LevenshteinDistance returns 'lucen' (0.833) instead of 'lucenes' (0.857), and with maxInspections=1 and the index {lucen, lucenes} all three shipped metrics return 'lucen' although 'lucenes' scores higher under the internal similarity and under each metric.
Introduced
faf3bc3134c6e5ba3e2caa15762524872e083152 (LUCENE-7439 'clean up FuzzyQuery/FuzzyTermsEnum sources' by mikemccand, a JIRA patch now tracked as GitHub issue #8491, committed to master on 2016-09-12 with no pull request and not backported to branch_6x) [read: git blame, git show and git tag --contains in the full-history clone; exec: commit-level A/B on the 7.0.0 jars, where shadowing the parent's DirectSpellChecker.java and FuzzyTermsEnum.java passes 3 of 3 and shadowing the commit's versions fails 2 of 3, and a 3-line revert of DirectSpellChecker on 7.0.0 passes 3 of 3]: the commit removed FuzzyTermsEnum's scale factor, changed 'st.boost = boost' (the enum boost) to 'st.boost = score' and the uncompetitive check from 'boost <= stQueue.peek().boost' to 'score <= stQueue.peek().boost'; with the scale factor gone this is neutral for the internal distance only. Later commits on the loop are mechanical: 9d72bfc1afe (LUCENE-9068, 9.0.0) replaced the attributes with e.getBoost()/e.setMaxNonCompetitiveBoost(), and 2cbf261032d reformatted it (2016-09-12)
First affected version
7.0.0 [exec] (the first release containing faf3bc3134c; tag releases/lucene-solr/7.0.0 dated 2017-09-08 in the history clone); 6.6.6, the last release without it, passes [exec]; DirectSpellChecker and setDistance exist since 4.0.0-alpha (LUCENE-2507, 5190ea52329) and kept 'st.boost = boost' through 6.6.6 [exec: 6.2.1, 6.3.0 and 6.6.6 pass; read: 4.0.0, 4.10.4, 5.5.5]
Affected versions
every 7.x, 8.x, 9.x and 10.x release, 7.0.0 through 10.6.0 (10.6.0 by reading), and main ea57360912ed [read: git tag --contains faf3bc3134c; exec: the finder repro fails 2 of 3 on 7.0.0, 8.11.4, 9.12.3, 10.5.2 and main (-ea and -da), and the independent reproduction's own test fails 2 of 3 on the same releases and on main -da]; not affected: 4.0.0-alpha to 6.6.6 [exec: 6.2.1, 6.3.0 and 6.6.6 pass; read: 4.x and 5.x]; not fixed on main (the defect lines are unchanged and no open PR changes them); release branches after 10.5.2 were not run, but branch_10_6 and the 10.6.0 tag (4ae946e5a9b) have the same DirectSpellChecker.java and FuzzyTermsEnum.java as main [read]. The per-release runs of the finder's maxInspections=1 test stop at the first failing metric (LevenshteinDistance); the JaroWinklerDistance and NGramDistance failures are executed on main only
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check, code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record: 189 recorded queries, namely a scripted prior-report search with 70 queries over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web; 51 read-only GitHub GraphQL searches in apache/lucene and apache/lucene-solr; 29 downstream and global searches (Elasticsearch, OpenSearch, Solr, all of GitHub); 8 JIRA queries over LUCENE and SOLR; 14 direct mailing-list searches; 8 web searches; a snapshot of the changed files of all 413 open apache/lucene PRs; 6 local git-history searches; 33 hits read and classified, none the same bug; the finder, the independent reproduction and the intended-behaviour check found nothing either); not fixed upstream (main ea57360912ed has the same lines, no CHANGES entry covers it, and the only open PR that touches DirectSpellChecker.java, #16847, leaves them alone); closest items, not reports: elastic/elasticsearch#24655 (open since 2017-05; the documented limitation that a metric that is not edit-distance-like may miss candidates drawn by Damerau-Levenshtein; nobody there identifies the scale mix-up, and this bug also hits LevenshteinDistance and JaroWinklerDistance with default settings), #8491 / LUCENE-7439 (the introducing change; its thread never mentions the spellchecker), #3581 / LUCENE-2507 (design origin), #10608 / LUCENE-9568 (a committer explains that FuzzyTermsEnum's top-N pruning relies on the 1 - ed/minTermLength scale)
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off; the standalone JUnit runner after the main refresh; a trace probe) and main ea57360912ed (hunt, independent checks, adjudication, several seeds, assertions on and off); the release jars 6.2.1, 6.3.0, 6.6.6, 7.0.0, 8.11.4, 9.12.3 and 10.5.2; the 7.0.0 jars with the introducing commit's two files, its parent's, and a 3-line revert; 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/DirectSpellChecker.java:36-49 (at ea57360912ed) (class javadoc: 'based on Levenshtein distance', 'an edit-distance-like metric such as Levenshtein or JaroWinklerDistance', @lucene.experimental), :278-288 (setDistance javadoc: candidates drawn 'using Damerau-Levenshtein', 'draw more candidates for your metric to rank'), :358-368 (inspections = numSug * maxInspections; ed=1 pass, then maxEdits pass if fewer), :410-479 (candidate loop: :433 score = e.getBoost(), :435 uncompetitive check, :452-459 score = distance.getDistance for a non-internal metric, :467 st.boost = score, :474-475 setMaxNonCompetitiveBoost), :481-512 (ScoreTerm: :487 boost javadoc 'the similarity from the FuzzyTermsEnum (internal similarity score)', :496 score, :503-511 compareTo by boost)
  • lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:162-168 (at ea57360912ed) (setMaxNonCompetitiveBoost), :194-220 (bottomChanged lowers maxEdits while bottom >= 1 - maxEdits/termLength), :250-257 (boost = 1 - ed/minTermLength)
The finding in 30 seconds
  • a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/DirectSpellChecker.java:359-368, 410-479, 481-512
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:194-220, 250-257
Why it looks unintended
  • a Lucene main checkout lucene/suggest/src/java/org/apache/lucene/search/spell/DirectSpellChecker.java:39-41, 48, 279-284, 481, 487, 496
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:162-166, 204-210
History
  • a Lucene clone (local upstream clone): DirectSpellChecker.java and FuzzyTermsEnum.java blobs on origin/branch_10_6, origin/branch_10x, origin/main equal to a9779ef3c193; the releases/lucene/10.6.0 tag (4ae946e5a9b, 2026-10-29) has the same two blobs

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 repro's default-settings example, made in parallel with this doc.

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.