FuzzyQuery promises every term within maxEdits edits. For the empty query term that is "" plus every term of 1 to maxEdits code points; Lucene returns only "".
FuzzyQuery("", 2) should match (5 terms) and what it matches, and the pruning step that cuts it to exact match. Select the still to open it at full size. Generated with AI, checked against the code.- Index six one-term documents in a
StringField:"",a,b,ab,abc,x. The edit distance from the empty string to a term is the term's length. - Run
new FuzzyQuery(new Term("f", ""), 1)with the default settings. You expect 4 hits ("",a,b,x), and 5 withmaxEdits2 (plusab). - You get 1 hit,
"", for both, and no error. The constant-score rewrite and aFuzzyTermsEnumused directly give the same. A one-character term works:"a"~1gets all 5 of its terms, including"". - Why:
FuzzyTermsEnumlowersmaxEditswhile the best boost an edit can reach,1 - maxEdits/termLength, is no better than the queue's worst entry. Before anything is collected that entry is-Infinity, and for a length-0 term1 - 2/0is-Infinitytoo. The tie dropsmaxEditsto 0: exact match only.
Anyone who builds a FuzzyQuery or FuzzyTermsEnum with empty term text and maxEdits 1 or 2, for example from blank user input or new Term(field). Default settings and codec, no scale, no error. Indexed terms of 1 to maxEdits code points are silently missed (under the default rewrite, those its 50-term cap would keep); non-empty terms are unaffected.
Mechanism, terms and numbers562 words
FuzzyQuery promises to match every term within maxEdits edits of its term, and since 6.3.0 (LUCENE-7439) that includes short terms. For the empty term that means "" plus every indexed term of 1 to maxEdits characters (Unicode code points). Lucene returns only "": in a six-term index ""~1 gets 1 hit instead of 4 and ""~2 1 instead of 5, with no error, while "a"~1 correctly gets 5. The cause is the top-N pruning loop in FuzzyTermsEnum.bottomChanged: for a term of length 0 the best boost an edited term can reach, 1 - maxEdits/termLength, is -Infinity, the same value the empty queue reports, so the loop lowers maxEdits to 0 before any term is collected. Every release from 6.3.0 to 10.5.2 and main is affected.
- Edit distance: the number of single-character insertions, deletions, substitutions (and, by default, transpositions) that turn one term into another. From
""a term can only be built by insertions, so its distance is its length in code points. - Boost:
FuzzyTermsEnumscores a match as1 - edits / min(length of the query term, length of the indexed term)(FuzzyTermsEnum.java:250-258); an exact match gets 1. Negative boosts are clamped to 0 when the query is built (TopTermsRewrite.java:166-170). - Top-N pruning: the default rewrite keeps only the best
maxExpansionsterms (50).FuzzyTermsEnum.bottomChangedlowersmaxEdits(and switches to a smaller automaton) once even the best boost possible at that distance cannot beat the worst term in the full queue.
FuzzyQuery.getTermsEnumsends onlymaxEdits == 0toSingleTermsEnum(FuzzyQuery.java:203-209);""~1and""~2get aFuzzyTermsEnum.FuzzyAutomatonBuildersetstermLengthto the code-point count of the term: 0 (FuzzyAutomatonBuilder.java:50-51). Its automata are correct:automata[n]accepts exactly the terms of at mostncode points, andgetAutomata()/visit()report that set.- The constructor sets
maxEdits = automata.length - 1, reads the shared queue state, and callsbottomChanged(null)(FuzzyTermsEnum.java:153-159). Nothing has been collected yet, so the state is the sentinelbottom = Float.NEGATIVE_INFINITY,bottomTerm = null(MaxNonCompetitiveBoostAttributeImpl.java:30-31). - In
bottomChanged(FuzzyTermsEnum.java:199-210),termAfteristruebecausebottomTerm == null. The loop computesmaxBoost = 1 - maxEdits/termLength = 1 - 2/0 = -Infinity.bottom < maxBoostis-Inf < -Inf, false, and the tie clausebottom == maxBoost && termAfter == falseis off. SomaxEditsdrops 2 -> 1 -> 0. getAutomatonEnum(0, null)intersects the terms withautomata[0], the exact-match automaton (FuzzyTermsEnum.java:212-219). Only""is enumerated.
For any term of length 1 or more, maxBoost is finite (for "a"~2: -1, then 0), so the -Infinity sentinel breaks the loop at once. Only the empty term ties with it.
| query | expected | main, 10.5.2, 9.12.3 |
|---|---|---|
"a"~1 (control) | 5: "" a ab b x | 5 |
""~1 | 4: "" a b x | 1: "" |
""~2 | 5: "" a ab b x | 1: "" |
Edit distance is symmetric, but matching is not: "a"~1 matches "", while ""~1 does not match "a".
Before 9.0 a second path gave the same result: FuzzyQuery.getTermsEnum also sent prefixLength >= term.text().length() (0 >= 0 for "") to SingleTermsEnum. LUCENE-9365 (#10405, 9.0.0) removed that shortcut, and since then the pruning loop alone cuts "" to exact match.
Exposure and likelihood268 words
- Who is exposed. Code that passes an empty term text to
FuzzyQueryorFuzzyTermsEnumwithmaxEdits1 or 2, on a field that has terms of 1 tomaxEditscode points: for examplenew FuzzyQuery(new Term(field, userInput))with blank input, ornew Term(field), whose javadoc describes its empty text as a "pattern for a query" (Term.java:78-85). - Not checked: which applications, downstream engines or analyzers produce an empty fuzzy term. The classic query parser's
term~syntax needs at least one character and passes it throughAnalyzer.normalize(QueryParserBase.java:727-731); whether a normalizer can return an empty term was not checked.DirectSpellCheckerskips words shorter thanminQueryLength(default 4). - Default, not opt-in. The plain-Java repro uses the default
FuzzyQuerysettings and the default codec (new IndexWriterConfig()). The JUnit repro fails the same way under the test framework's randomized codec and similarity, with the default rewrite,CONSTANT_SCORE_REWRITEand a directFuzzyTermsEnum. - Silent. No exception: the result is exactly the exact-match result.
- What is lost. The terms that should match are exactly the indexed terms of 1 to
maxEditscode points, the only terms withinmaxEditsof""(withmaxEditscapped at 2, one- and two-code-point terms). A directFuzzyTermsEnumandCONSTANT_SCORE_REWRITElose all of them; the default rewrite keeps at mostmaxExpansionsterms (50), so it loses the ones that cap would have kept. - Lucene disagrees with itself. For the same term and distance,
FuzzyQuery.getAutomata()(whatvisit()reports to aQueryVisitor) andIntervals.fuzzyTermaccept the full set. - How common. Probably rare: it needs an empty term text, and no report was found since 6.3.0.
The test shows that FuzzyQuery(new Term("f", ""), 1) on an index with the terms "" and "a" matches only "".
/*
* 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;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestFuzzyQueryEmptyTermText extends LuceneTestCase {
public void testEmptyTermMatchesTermOneEditAway() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
for (String text : new String[] {"", "a"}) {
Document doc = new Document();
doc.add(new StringField("f", text, Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader r = DirectoryReader.open(w)) {
// "" and "a" are within 1 edit of "": expected 2 hits, actual 1 (only "")
assertEquals(2, new IndexSearcher(r).count(new FuzzyQuery(new Term("f", ""), 1)));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-024/, 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: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.TestFuzzyQueryEmptyTermText
- Expected vs actual
- Expected: 2 hits, since
"a"is one edit from""(constructor javadoc: "will match terms with an edit distance of at most maxEdits"). Actual (-eaand-da):expected:<2> but was:<1>.
More files, environment, full output34 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
=> java.lang.AssertionError: expected:<2> but was:<1>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.junit.Assert.assertEquals(Assert.java:633)
org.apache.lucene.search.TestFuzzyQueryEmptyTermText.testEmptyTermMatchesTermOneEditAway(TestFuzzyQueryEmptyTermText.java:42)- The constructor "will match terms with an edit distance of at most
maxEdits"; since 6.3.0 that includes "short terms" (LUCENE-7439). "a"~1matches"", but""~1misses"a"(distance 1 both ways); the query's own automaton accepts all 4 terms.- The only supporting text, a class NOTE on short terms, is stale:
TestFuzzyTermOnShortTermsasserts the opposite of both its examples.
The full argument209 words
- Constructor javadoc (
FuzzyQuery.java:70-72): "Create a new FuzzyQuery that will match terms with an edit distance of at mostmaxEditstoterm." The constructor validatesmaxEditsandprefixLength(:95-101) but puts no condition on the term text. FuzzyTermsEnumconstructor javadoc (FuzzyTermsEnum.java:79-82, the public 5-argument constructor declared at:94): "enumeration of all terms ... which share a prefix of length prefixLength with term and which have at most maxEdits edits."- CHANGES, 6.3.0 (
lucene/CHANGES.txt:8855-8856): "LUCENE-7439: FuzzyQuery now matches all terms within the specified edit distance, even if they are short terms". The issue's goal: "edit distance 2 does in fact match all terms with edit distance <= 2". - Class javadoc (
FuzzyQuery.java:34-37): terms "will be collected and scored according to their edit distance". - Tests.
TestFuzzyTermOnShortTerms.java:47-56asserts that short terms match since LUCENE-7439. No test builds a fuzzy query on"":TestFuzzyQuery.testRandomdraws query terms of 1 to 8 characters (TestFuzzyQuery.java:585-592) and callsrandom().nextInt(queryTerm.length())(:620), which would throw for length 0. - History. LUCENE-7439 changed the rule for every other term and did not touch the empty-term paths. The empty term returns only
""in every release from 5.0.0 to main; it became a contract violation in 6.3.0.
The devil's-advocate check254 words
The strongest case for "intended": the FuzzyQuery class javadoc still says (FuzzyQuery.java:46-50, LUCENE-5206, 2013) "For a term to match, the edit distance between the terms must be less than the minimum length term (either the input term, or the candidate term)". For a length-0 input term that allows only exact matches, which is what happens. Fuzzy matching on nothing is also degenerate, the result has been the same since at least 5.0.0, and the boost formula 1 - edits/min(length) has no finite value for a length-0 term, so exact-only avoids inventing a ranking.
Why it fails: the NOTE predates LUCENE-7439, which removed its rule. TestFuzzyTermOnShortTerms.java:49-56 asserts both of its examples the other way round, and by its rule "a"~1 could not match "" either, yet it does. Main follows the NOTE only for the empty query term, and only through an arithmetic tie with the empty-queue sentinel. The matching set is well defined, the query's own automaton accepts it, and Intervals.fuzzyTerm matches it. If exact-only were intended, the constructor would say so or reject the input. The boost only ranks matches: Lucene already matches the empty indexed term ("a"~1) although its boost is 1 - 1/0 = -Infinity, clamped to 0 (FuzzyTermsEnum.java:250-257, TopTermsRewrite.java:170), and fix A keeps those boosts. The stability is a real compatibility cost for a fix (see Suggested fix), not evidence of intent. Verdict: not intended, a defect. The cross-model refute review rated the intended-behaviour argument "moderate" and the bug "real, high confidence".
Recommended B: score the empty term like a one-character term
Score an empty query term like a one-character term in both boost formulas of FuzzyTermsEnum (Math.max(1, termLength), 2 lines). The tie with the empty-queue value disappears, matches follow the contract, the kept top terms are the closest, and non-empty terms are unchanged. 4,028 tests in core and six modules pass, including the repro and a new TestFuzzyQuery test.
// FuzzyTermsEnum.bottomChanged, line 205
float maxBoost = 1.0f - ((float) maxEdits / (float) Math.max(1, termLength));
// FuzzyTermsEnum.next, line 254
int minTermLength = Math.min(codePointCount, Math.max(1, termLength));All options and trade-offs4 options ยท 729 words
Two fixes were built and validated with gradle in a scratch worktree at ea57360912ed; both change nothing for a non-empty query term, because Math.max(1, termLength) == termLength and termLength > 0 whenever termLength >= 1. They differ in how the empty term ranks its matches when there are more candidates than maxExpansions. Options C and D were not run. Both A and B change results that have been exact-only since 5.0.0: callers that use a fuzzy clause on blank input as a filter or a MUST_NOT clause would see more matches or fewer exclusions, so the CHANGES entry should say so. On 9.0.0 and later the FuzzyTermsEnum change alone is enough (executed on 9.0.0 with fix A; for fix B by reading); 6.3-8.x also need the prefixLength >= term.text().length() shortcut in FuzzyQuery.getTermsEnum changed (executed on 6.3.0 and 8.0.0).
In FuzzyTermsEnum, use Math.max(1, termLength) in the pruning bound of bottomChanged (line 205) and in the similarity of next() (line 254). For "" the best boosts become -1 (2 edits) and 0 (1 edit), which never tie with the -Infinity sentinel, so pruning works for "" exactly as for a one-character term.
// FuzzyTermsEnum.bottomChanged, line 205
float maxBoost = 1.0f - ((float) maxEdits / (float) Math.max(1, termLength));
// FuzzyTermsEnum.next, line 254
int minTermLength = Math.min(codePointCount, Math.max(1, termLength));Trade-offs
- Non-exact matches of
""get finite boosts ordered by distance (1 edit: 0, 2 edits: -1). For a kept non-exact term the clause boost stays 0 after the clamp, as for a one-character term; the result set does change (the missing matches are added), and at themaxExpansionscap fixes A and B keep different terms. - With more candidates than
maxExpansions, the closest terms are kept: over""aaabbcwithmaxExpansions3, it keeps"",b,c(0, 1, 1 edits). - An empty indexed term matched by a non-empty query keeps its
-Infinityboost, as today. - The one-character scale is a choice: any positive length gives the same order, and after the clamp only the order matters.
What the checks validated
- The JUnit repro: 6 of 6 pass (stock: 4 of 6 fail);
TestFuzzyQuery.testEmptyTermpasses; the top-terms probe passes 2 of 2. :lucene:core:testonorg.apache.lucene.search.*andorg.apache.lucene.util.automaton.*: 2,577 tests, 0 failures, 51 skipped (stock: the same 2,577 tests with exactly 6 failures, the new tests).:lucene:suggest(search.spell.*),:lucene:sandbox(*FuzzyLikeThis*),:lucene:queries(queries.intervals.*),:lucene:classification,:lucene:queryparser,:lucene:highlighter: 1,451 tests, 0 failures.:lucene:core:checkGoogleJavaFormatpasses on the patch;TestFuzzy*with seed1234ABCDand assertions off: 15 tests pass.- Through the repro runner with the fix shadowed: the repro passes with -ea and -da; the plain-Java program prints 5, 4 and 5 hits.
- Not run: the rest of the test suite and any performance benchmark.
The one-line guard while (maxEdits > 0 && termLength > 0) in bottomChanged (line 204): the smallest change, and it fixes the matching.
while (maxEdits > 0 && termLength > 0) {Trade-offs
- All non-exact matches of
""keep boost-Infinity, so the top-N queue breaks the tie by term order, not distance: over""aaabbcwithmaxExpansions3 it keeps"",aa,ab(2 edits) and dropsb,c(1 edit), against the class javadoc's "scored according to their edit distance". - No pruning for
"": it enumerates every term of at mostmaxEditscode points (bounded; not benchmarked).
What the checks validated
The JUnit repro 6 of 6 and TestFuzzyQuery.testEmptyTerm pass; the same suites as option B (without the probe): 2,575 core tests and 1,451 module tests, 0 failures; format check and seed 1234ABCD with assertions off pass. The top-terms probe fails 1 of 2 (the ranking above).
Throw IllegalArgumentException from FuzzyQuery and FuzzyTermsEnum for an empty term.
Trade-offs
Explicit, but a behaviour change for callers who get exact-match results today, and it contradicts edit-distance semantics that are well defined for "". Not run.
Keep the behaviour and say so in the javadoc.
Trade-offs
Contradicts the constructor javadoc and LUCENE-7439, and leaves getAutomata()/visit() and Intervals.fuzzyTerm inconsistent with the query.
AI-AI-run Jazzer fuzzing of FuzzyQuery against a brute-force edit-distance oracle found it on 2026-10-22. A separate AI verifier found the root cause; a reviewer from another model family (OpenAI GPT-5.6) reproduced it with its own five-term test.
The method in full172 words
Method: fuzzing with an independent oracle (the study's non-formal baseline tier). Jazzer generated query terms and term sets; the oracle was a brute-force optimal-string-alignment edit distance over code points, written independently of Lucene, compared with the terms FuzzyQuery matched.
- An AI verifier wrote its own 9-test JUnit (one document per term
"",a,b,ab,abc,x): 6 failed on main for 5 seeds. It traced the cause tobottomChangedand confirmed it with the one-line guard, under which all 9 pass and Lucene's ownTestFuzzyQuery,TestFuzzyTermOnShortTermsandTestMultiTermQueryRewritesstill pass. - The hunt wrote the minimal 6-test JUnit, re-ran it on main
ea57360912ed, 10.5.2 and 9.12.3, and had it reviewed by an independent reviewer from another model family (OpenAI GPT-5.6), which wrote and ran its own test with the terms"",q,r,qr,qrs. - An AI history checker ran a version probe on all 107 releases from 5.0.0 to 10.5.2 and pinned the introduction with single-change A/B runs.
No report of an empty fuzzy query term was found (searched 2026-10-26). Partial overlap: #10405 (LUCENE-9365, fixed in 9.0.0) removed the code path that caused the same symptom before 9.0, but never mentions an empty term; since 9.0 a different mechanism causes it.
Searches5 searches
No one has reported a FuzzyQuery or FuzzyTermsEnum on an empty query term that returns only the empty term, and no upstream branch has a fix. Partial overlap: #10405 (LUCENE-9365, "Fuzzy query has a false negative when prefix length == search term length", fixed in 9.0.0) removed the code path that caused the same symptom before 9.0; on the current code the cause and the minimal fix (bottomChanged) are not the code #10405 changed, and no one reported or discussed an empty fuzzy query term. The table counts it as new.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | 28 searches: class and method names, "empty term", "empty string", "zero-length", bottomChanged, termLength, maxBoost, Infinity, NEGATIVE_INFINITY, "short terms", LUCENE-7439, LUCENE-9365, Intervals.fuzzyTerm; the top 25 hits read with comments | no report of an empty fuzzy term; #10405 overlaps in part |
| Related items (read in full) | 2026-10-26 | #10405 and its three PRs, #8491, #10608, #6270 and others, 22 JIRA issues | #10405: same title condition (prefix length == term length), non-empty terms only; #10608: negative boosts from the same formula, different symptom; #8491 set the contract; #6270 is the origin of the stale NOTE |
| Open PRs | 2026-10-26 | the 413 open apache/lucene PRs with their changed-file lists (3 lists truncated at 100 files) | none of the 9 fuzzy-related PRs changes the drop logic |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 20 searches; the 28-message 2010 dev@ empty-terms thread and six more threads read | the 2010 thread notes FuzzyQuery dividing by 0 for an empty indexed term (score symptoms, before the 6.3.0 contract; on main that term matches, but its boost is still -Infinity, so it ranks last under top-N pruning); no report of an empty query term |
| Commits, JIRA archive, downstream trackers, web | 2026-10-26 | 5 commit, 4 + 5 JIRA, 7 downstream and 7 web searches | no report; one downstream draft PR rejects an empty fuzzy value without giving a reason |
Introduced as a contract violation by LUCENE-7439 (471f90cf825, 2016-09-15), first released in 6.3.0: all 87 releases from 6.3.0 to 10.5.2 and main fail, while 5.0.0 to 6.2.1 give the same single hit under the older documented short-term rule. Pinned by single-change A/B runs on 6.2.1 and 6.3.0.
Versions tested6 rows
LUCENE-7439 ("FuzzyQuery now matches all terms within the specified edit distance, even if they are short terms", Mike McCandless, 2016-09-15; 471f90cf825 on branch_6x, c7fb49d7b50 on master; issue #8491, no GitHub PR) changed the rule for every other term. The empty term's own result never changed, which left it as the one query term that still follows the old rule. The drop formula is older (b679816a700, 2010).
Every row ran a main() probe that compiles unchanged on every version, on the official jars (SHA-1 checked), seed DEADBEEF, with assertions on (6.2.1, 6.3.0, 10.5.2 and main also with them off). The registry row's introducing commit (LUCENE-2667, 2010) is corrected by these runs. For 8.8.2 to 8.11.4 (9 releases) there is no tag in the local history clone, so they have jar-execution evidence only.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed (unreleased 11.0) | affected | ""~1 and ""~2 return only ""; the JUnit repro fails 4 of 6 with -ea and -da and under 20 seeds |
| 6.3.0 to 10.5.2 (87 releases) | affected | ""~1 and ""~2 return only "", while "a"~1 returns all 5 terms within one edit |
| 5.0.0 to 6.2.1 (20 releases) | older rule | ""~1 returns "" and "a"~1 returns only a: consistent with the then-documented short-term rule |
| 6.2.1 + only the LUCENE-7439 hunks | A/B | "a"~1 matches all 5, ""~1 only "": the bug appears |
| 6.3.0 with LUCENE-7439 reverted | A/B | back to the old rule |
main ea57360912ed with fix B | fix | 4 and 5 hits; the JUnit repro passes 6 of 6 |
Reproduced on main ea57360912ed with and without assertions and under 20 seeds, and on 10.5.2 and 9.12.3; confirmed by the study verifier, a cross-model refute review with its own test, and the full release sweep; both fixes validated with gradle.
Checks and verdicts394 words
Each check worked from the code or its own runs.
| Check | Verdict | What it found |
|---|---|---|
| Study verifier (fuzzing/PBT) | new bug | Its own 9 tests: 6 fail on main for 5 seeds. Root cause in bottomChanged, confirmed by the one-line guard; Lucene's fuzzy tests still pass with it. Cross-version hit counts from 5.0.0 to main. |
| Public-list rerun | reproduces on b83101cdd2 | 9 tests, 6 failures, the same tests as the study. |
| Hunt verification | real | The minimal JUnit fails 4 of 6 on main with -ea and -da, on 10.5.2 with -ea and -da, and on 9.12.3; under 20 seeds each bug test fails 20 of 20; both controls pass. |
| Cross-model refute review | real, high confidence | An independent reviewer from another model family (OpenAI GPT-5.6) wrote and ran its own five-term test: it fails on main with -ea and -da and under 10 seeds, and passes with the guard. The repro driver re-ran it, also on 10.5.2, with the same result. Intended-behaviour argument rated moderate. |
| History follow-up | introduced by LUCENE-7439, first released in 6.3.0 | 121 executed runs: all 107 releases from 5.0.0 to 10.5.2, main, and A/B shadow runs on 6.2.1, 6.3.0, 8.0.0 and 9.0.0. |
| Prior-report follow-up | partially known (#10405) | 67 tool queries plus manual reading; no report of an empty fuzzy term; #10405 overlaps on the pre-9.0 code path. |
| Correction to the registry row | introducing commit corrected | The registry gives LUCENE-2667 (dfed16f2fdd, 2010) and an unknown first version. The executed history gives LUCENE-7439 and 6.3.0; the drop formula is older than dfed16f2fdd. |
| Independent fact checks and diagram check (2026-10-26) | bug, mechanism, contract and history hold; small errors fixed | A checker re-ran the plain-Java repro, the JUnit repro (main -ea/-da, 10.5.2, 5 seeds), both fixes and the top-terms probe and got the stated results. A reviewer from another model family (OpenAI GPT-5.6, file reading only) and a diagram check found wording and attribution errors (a diagram caption, a constructor signature, the codec, overbroad 'what is lost' and 'scores unchanged' lines); all were fixed on this page. |
| This page's own runs | both fixes validated | Gradle in a scratch worktree: stock fails exactly the 6 new tests; fix B passes 4,028 tests in core search and automaton and six modules, fix A 4,026; the top-terms probe separates the two fixes. |
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 419 KB
Finding record, Lucene and upstream sources, repro files and diagram notes516 words
- Registry title
- FuzzyQuery on the empty term matches only the empty term
- Area
- search / multi-term queries
- Priority
- Low, rank 917: FuzzyQuery on an empty term matches only the empty term, which takes blank text passed straight to the constructor, and most callers would not want the alternative anyway.
- Impact class
- silent wrong results
- Impact note
- Default configuration; only FuzzyQuery with an empty term text; released.
- Summary
- You run a FuzzyQuery whose term text is empty. You expect it to match every indexed term within the edit distance, as the LUCENE-7439 contract says. Instead it matches only the empty term.
- Introduced
- LUCENE-7439 (471f90cf825) [exec: single-change A/B runs on 6.2.1 and 6.3.0] (2016-09-15)
- First affected version
- 6.3.0 [exec]
- Affected versions
- 6.3.0 to 10.5.2 (all 87 releases) and main [exec]; 5.0.0 to 6.2.1 follow the older documented short-term rule [exec]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
- Found by
- non-formal baseline: fuzzing/PBT
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
ea57360912ed(commit 2026-10-24; tested 2026-10-26): the JUnit repro through the repro runner with -ea and -da and under 20 random seeds, and through gradle in a scratch worktree, stock and with fixes A and B (the worktree was removed after the runs); the plain-Java snippet on main, 10.5.2 and 9.12.3; the history probe on the official jars of all 107 releases from 5.0.0 to 10.5.2.FuzzyTermsEnum.javaatea57360912edis byte-identical to the study base7cecf795236; the public-list rerun reproduced the bug onb83101cdd2.
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:153-159, 194-220, 250-258Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/MaxNonCompetitiveBoostAttributeImpl.java:30-31Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyAutomatonBuilder.java:50-51Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyQuery.java:170-173, 196-209Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TopTermsRewrite.java:166-170
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/Term.java:78-85Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:727-731Lucene main ea57360912ed: lucene/suggest/src/java/org/apache/lucene/search/spell/DirectSpellChecker.java:79, 328Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:307-340 (the 2-argument fuzzyTerm at 307 delegates to 334-340)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyQuery.java:56, 60-62 (defaultMaxExpansions = 50, default top-terms rewrite)
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyQuery.java:34-37, 46-50, 70-72, 95-101Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:79-82, 94 (public 5-argument constructor; 104-107 document the package-private overload at 120)Lucene main ea57360912ed: lucene/CHANGES.txt:8855-8856 (6.3.0 section, header at line 8793)Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestFuzzyTermOnShortTerms.java:47-56Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestFuzzyQuery.java:585-592, 620Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:250-257 and TopTermsRewrite.java:170 (the empty indexed term's -Infinity boost, clamped to 0)
- Suggested fix
Lucene main ea57360912ed: lucene/suggest/.../DirectSpellChecker.java:474; lucene/sandbox/.../FuzzyLikeThisQuery.java:234; lucene/classification/.../NearestFuzzyQuery.java:169 (callers that set only the boost: why changing termAfter is not suggested)
- TestFuzzyQueryEmptyTermText.java (JUnit test):
repro/LFV-024/src/org/apache/lucene/search/TestFuzzyQueryEmptyTermText.java(46 lines)
A summary diagram (PNG). The diagram is produced separately from this text.
assets/LFV-024/bug.png(419 KB)
