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.
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.- Index six words:
lucen,lucena,lucenb,lucenc,lucend,lucenes. CallsetDistance(new LevenshteinDistance()), keep every other default, and ask for 1 suggestion forlucene. - You expect
lucenes: it scores 0.857 underLevenshteinDistance, every other word 0.833, and it is among the 5 candidates (1 suggestion x 5 inspections) that the edit-distance draw keeps. - 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. lucenesarrives 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 dropsluceneswithout scoring it; the enumerator, already told that nothing at 0.833 can compete, then stops.JaroWinklerDistancealso returnslucen(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 returnlucenes.
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.
DirectSpellCheckersuggests corrections straight from the term dictionary. For a query word it walks the terms within 1 edit (then, if it found too few, withinmaxEdits, default 2) with aFuzzyTermsEnumand keeps the bestnumSug * maxInspectionscandidates (defaultmaxInspections5) in a bounded queue. The survivors are sorted by score and the topnumSugare returned.- Internal similarity (the enumerator's boost):
1 - edits / min(term length, query length). Forlucene(6 letters):lucena0.833 (1 edit / 6),lucen0.800 (1 edit / 5),lucenes0.833 (1 edit / 6). - The configured metric (
setDistance):LevenshteinDistanceis1 - edits / max(length), solucenesscores 0.857 (1 / 7) andlucen..lucend0.833.JaroWinklerDistanceandNGramDistanceuse 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 reaches1 - 1 / query length, the best any 1-edit term can score: no 1-edit term could beat it.
- For a metric other than the internal one, the loop overwrites
scorewith the metric's value and stores it asst.boost(DirectSpellChecker.java:452-459,:467). The field's javadoc says boost is "the similarity from the FuzzyTermsEnum (internal similarity score)" (:487). - The uncompetitive check compares the next term's internal similarity with that metric-scale number (
:435), andsetMaxNonCompetitiveBoosthands it to the enumerator (:474-475), which reads it on its own1 - maxEdits / termLengthscale (FuzzyTermsEnum.java:204-210). - 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.
Default settings, one document per word, query lucene, LevenshteinDistance:
| term (term order) | internal similarity | LevenshteinDistance | what main does |
|---|---|---|---|
| lucen | 0.800 | 0.833 | queued with 0.833 |
| lucena, lucenb, lucenc, lucend | 0.833 | 0.833 | queued with 0.833; the queue (5 slots) is full |
| lucenes | 0.833 | 0.857 | 0.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
LuceneLevenshteinDistanceinstance (same scale). By reading downstream source (nothing run there): Solr'sDirectSolrSpellCheckermapsdistanceMeasuretosetDistance; the term and phrase suggesters of Elasticsearch and OpenSearch mapstring_distancelevenshtein,jaro_winklerandngramto these classes, and theirdamerau_levenshteinto the unaffectedLuceneLevenshteinDistance. - 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 smallmaxInspectionsare hit most. RaisingmaxInspectionsmakes 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,
numSug1), not for lookups in general. - Examples from that run:
saxo(from "saxon") givessalo(Levenshtein 0.750) althoughsaxon(0.800) is in the pool, andsax(0.942) instead ofsaxon(0.960) with JaroWinkler;toiltegivestildeinstead oftoilets. On Lucene's Reuters test text, withnumSug1, 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") givescosinstead ofcrops. - Silent. The wrong word looks plausible; there is no error and no search hit is involved.
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.
/*
* 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);
}
}
}
}
# 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[]>).
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:<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)setDistancesays 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
sqrtof the internal distance changes 74 of 8,000 results (all atmaxInspections1).
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 with1 - 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().boostandst.boost = boost; 6.2.1, 6.3.0 and 6.6.6 returnlucenes.faf3bc3134c("LUCENE-7439: clean up FuzzyQuery/FuzzyTermsEnum sources") removed the enumerator's scale factor and changed the loop tost.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/callsDirectSpellChecker.setDistance; the other metrics are used as spellchecker metrics only with the separate-indexSpellChecker(the metric classes have their own unit tests). With the fix every suite in the module passes. - Both scales agree. In the
maxInspections1 case the returnedlucenis worse thanlucenesunder the internal similarity and under each metric, so the expectation does not depend on how "the pool" is read. - Metamorphic check.
(float) Math.sqrtofLuceneLevenshteinDistancekeeps 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 returnsabcinstead ofabcdeand differs in 74 of 8,000 random checks, all atmaxInspections1 (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 theluceneandabcdexamples. - Design intent, not a formal guarantee. "The documented pool" is read from the
setDistanceandScoreTerm.boostjavadocs;suggestSimilarpromises a list "sorted ... according to the comparator", not the globally best word. ThemaxInspections1 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.
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 rankingAll 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 rankingTrade-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.
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.
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.
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.
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
suggestSimilarfrom its documentation: a pool ofnumSug * maxInspectionsterms drawn by the internal Damerau-Levenshtein similarity, the 1-edit thenmaxEditspasses, the docFreq, accuracy and suggest-mode filters, then ranking by the configuredStringDistance. - While writing it, they noticed that
ScoreTerm.boostis 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.
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).
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; web | 2026-10-26 | a 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-26 | 51 GraphQL searches (setDistance, setMaxNonCompetitiveBoost, competitive boost, LUCENE-7439, metric names) | no report |
| Downstream and all of GitHub (Elasticsearch, OpenSearch, Solr) | 2026-10-26 | 29 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&A | 2026-10-26 | 8 JQL, 14 list and 8 web searches; 4 Q&A pages read | no report; the list threads use the internal distance or predate 7.0 |
| Open PRs | 2026-10-26 | changed files of all 413 open apache/lucene PRs | #16847 touches the file but not these lines; no fix in flight |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | 6 of 8 repro tests fail through gradle, assertions on and off; the repro runner reproduces too |
main ea57360912ed | affected | finder, 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 | affected | the 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 affected | pass 3 of 3 |
| 4.0.0, 4.10.4, 5.5.5 | read only | contain st.boost = boost |
7.0.0 jars + faf3bc3134c vs its parent | bisect | the commit's two files fail 2 of 3; the parent's pass; a 3-line revert passes |
fix A on a9779ef3c193 | not affected | all 244 suggest-module tests pass (the 8 repro tests included), assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (translation validation) | candidate | 412 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 | real | Own 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, medium | Intended-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 | real | Found the LUCENE-7439 and LUCENE-2507 record; rebutted the works-as-designed readings; measured the Reuters change rate. |
| adjudication | confirmed, high | Re-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 none | 54,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 | confirmed | Stock 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, fixed | Re-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), fixed | An 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. |
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 514 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,324 words
- 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 mainea57360912ed(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 ona9779ef3c193. JDK 25.0.3, Linux aarch64.
- 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-512a 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, 496a 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
- TestDirectSpellCheckerLevenshteinDistance.java (JUnit test):
repro/LFV-240/src/org/apache/lucene/search/spell/TestDirectSpellCheckerLevenshteinDistance.java(49 lines)
A step-by-step picture of the repro's default-settings example, made in parallel with this doc.
assets/LFV-240/bug.png(514 KB)
