All findings · Lucene finding · LFV-024 · fuzzyquery-empty-term

FuzzyQuery on the empty term matches only the empty term

new FuzzyQuery(new Term("f", ""), 1) should match every indexed term within one edit of the empty string, but it matches only the empty term: 1 hit instead of 4 in a six-term index, with no error. Every release since 6.3.0 is affected; non-empty query terms work.

Summary

Priority
Low, rank 917FuzzyQuery 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
silent wrong results
Since
6.3.0 (LUCENE-7439, 2016)introduced 2016-09-15 · over 10 years
Affected
6.3.0 to 10.5.2 (all 87 releases) and main; 5.0.0 to 6.2.1 follow the older rule (all run)
Status
documented
Found by
Jazzer fuzzing against a brute-force edit-distance oracle

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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

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

Diagram of the bug: the six indexed terms "", a, b, ab, abc, x with their edit distance from the empty string; what FuzzyQuery(new Term("f", ""), 2) should match ("", a, b, ab, x: 5 hits; abc is 3 edits away) and what it matches (only "": 1 hit; maxEdits 1, which should give 4 hits, also gives only ""); and the FuzzyTermsEnum pruning step in which 1 - maxEdits/0 = -Infinity ties with the empty queue's -Infinity and lowers maxEdits to 0, so only the exact-match automaton runs.
The six indexed terms, what 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.
  1. 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.
  2. Run new FuzzyQuery(new Term("f", ""), 1) with the default settings. You expect 4 hits ("", a, b, x), and 5 with maxEdits 2 (plus ab).
  3. You get 1 hit, "", for both, and no error. The constant-score rewrite and a FuzzyTermsEnum used directly give the same. A one-character term works: "a"~1 gets all 5 of its terms, including "".
  4. Why: FuzzyTermsEnum lowers maxEdits while 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 term 1 - 2/0 is -Infinity too. The tie drops maxEdits to 0: exact match only.

Who is affected

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.

Terms

  • 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: FuzzyTermsEnum scores a match as 1 - 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 maxExpansions terms (50). FuzzyTermsEnum.bottomChanged lowers maxEdits (and switches to a smaller automaton) once even the best boost possible at that distance cannot beat the worst term in the full queue.

The bug, step by step (code at ea57360912ed)

  1. FuzzyQuery.getTermsEnum sends only maxEdits == 0 to SingleTermsEnum (FuzzyQuery.java:203-209); ""~1 and ""~2 get a FuzzyTermsEnum.
  2. FuzzyAutomatonBuilder sets termLength to the code-point count of the term: 0 (FuzzyAutomatonBuilder.java:50-51). Its automata are correct: automata[n] accepts exactly the terms of at most n code points, and getAutomata()/visit() report that set.
  3. The constructor sets maxEdits = automata.length - 1, reads the shared queue state, and calls bottomChanged(null) (FuzzyTermsEnum.java:153-159). Nothing has been collected yet, so the state is the sentinel bottom = Float.NEGATIVE_INFINITY, bottomTerm = null (MaxNonCompetitiveBoostAttributeImpl.java:30-31).
  4. In bottomChanged (FuzzyTermsEnum.java:199-210), termAfter is true because bottomTerm == null. The loop computes maxBoost = 1 - maxEdits/termLength = 1 - 2/0 = -Infinity. bottom < maxBoost is -Inf < -Inf, false, and the tie clause bottom == maxBoost && termAfter == false is off. So maxEdits drops 2 -> 1 -> 0.
  5. getAutomatonEnum(0, null) intersects the terms with automata[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.

The numbers

queryexpectedmain, 10.5.2, 9.12.3
"a"~1 (control)5: "" a ab b x5
""~14: "" a b x1: ""
""~25: "" a ab b x1: ""

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 FuzzyQuery or FuzzyTermsEnum with maxEdits 1 or 2, on a field that has terms of 1 to maxEdits code points: for example new FuzzyQuery(new Term(field, userInput)) with blank input, or new 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 through Analyzer.normalize (QueryParserBase.java:727-731); whether a normalizer can return an empty term was not checked. DirectSpellChecker skips words shorter than minQueryLength (default 4).
  • Default, not opt-in. The plain-Java repro uses the default FuzzyQuery settings 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_REWRITE and a direct FuzzyTermsEnum.
  • 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 maxEdits code points, the only terms within maxEdits of "" (with maxEdits capped at 2, one- and two-code-point terms). A direct FuzzyTermsEnum and CONSTANT_SCORE_REWRITE lose all of them; the default rewrite keeps at most maxExpansions terms (50), so it loses the ones that cap would have kept.
  • Lucene disagrees with itself. For the same term and distance, FuzzyQuery.getAutomata() (what visit() reports to a QueryVisitor) and Intervals.fuzzyTerm accept the full set.
  • How common. Probably rare: it needs an empty term text, and no report was found since 6.3.0.

Minimal repro

The test shows that FuzzyQuery(new Term("f", ""), 1) on an index with the terms "" and "a" matches only "".

TestFuzzyQueryEmptyTermText.java (JUnit test)46 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;

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)));
      }
    }
  }
}
Command
# 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 (-ea and -da): expected:<2> but was:<1>.

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

More files, environment, full output34 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
    => 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)

Why it looks unintended

  • The constructor "will match terms with an edit distance of at most maxEdits"; since 6.3.0 that includes "short terms" (LUCENE-7439).
  • "a"~1 matches "", but ""~1 misses "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: TestFuzzyTermOnShortTerms asserts 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 most maxEdits to term." The constructor validates maxEdits and prefixLength (:95-101) but puts no condition on the term text.
  • FuzzyTermsEnum constructor 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-56 asserts that short terms match since LUCENE-7439. No test builds a fuzzy query on "": TestFuzzyQuery.testRandom draws query terms of 1 to 8 characters (TestFuzzyQuery.java:585-592) and calls random().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".

Suggested fix

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

B: score the empty term like a one-character term (validated by the checks) (recommended)

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 the maxExpansions cap fixes A and B keep different terms.
  • With more candidates than maxExpansions, the closest terms are kept: over "" aa ab b c with maxExpansions 3, it keeps "", b, c (0, 1, 1 edits).
  • An empty indexed term matched by a non-empty query keeps its -Infinity boost, 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.testEmptyTerm passes; the top-terms probe passes 2 of 2.
  • :lucene:core:test on org.apache.lucene.search.* and org.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:checkGoogleJavaFormat passes on the patch; TestFuzzy* with seed 1234ABCD and 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.

A: skip the pruning loop for an empty term (validated by the checks)

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 "" aa ab b c with maxExpansions 3 it keeps "", aa, ab (2 edits) and drops b, c (1 edit), against the class javadoc's "scored according to their edit distance".
  • No pruning for "": it enumerates every term of at most maxEdits code 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).

C: reject an empty term text

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.

D: document exact-only matching for the empty term (not recommended)

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.

How it was found

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 to bottomChanged and confirmed it with the one-line guard, under which all 9 pass and Lucene's own TestFuzzyQuery, TestFuzzyTermOnShortTerms and TestMultiTermQueryRewrites still 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2628 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 commentsno 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 PRs2026-10-26the 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-2620 searches; the 28-message 2010 dev@ empty-terms thread and six more threads readthe 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, web2026-10-265 commit, 4 + 5 JIRA, 7 downstream and 7 web searchesno report; one downstream draft PR rejects an empty fuzzy value without giving a reason

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
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/Bback to the old rule
main ea57360912ed with fix B fix4 and 5 hits; the JUnit repro passes 6 of 6

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (fuzzing/PBT) new bugIts 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 b83101cdd29 tests, 6 failures, the same tests as the study.
Hunt verification realThe 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 confidenceAn 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.0121 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 correctedThe 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 fixedA 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 validatedGradle 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.

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 notes516 words

Finding record

LFV-024, snapshot of 2026-11-06

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.java at ea57360912ed is byte-identical to the study base 7cecf795236; the public-list rerun reproduced the bug on b83101cdd2.

Lucene and upstream sources

The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:153-159, 194-220, 250-258
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/MaxNonCompetitiveBoostAttributeImpl.java:30-31
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyAutomatonBuilder.java:50-51
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FuzzyQuery.java:170-173, 196-209
  • Lucene 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-85
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:727-731
  • Lucene main ea57360912ed: lucene/suggest/src/java/org/apache/lucene/search/spell/DirectSpellChecker.java:79, 328
  • Lucene 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-101
  • Lucene 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-56
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestFuzzyQuery.java:585-592, 620
  • Lucene 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)

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

Repro files

About the diagrams

A summary diagram (PNG). The diagram is produced separately from this text.

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.