All findings · Lucene finding · LFV-248 · analyzingsuggester-duplicate-surface-counts-toward-maxsurfaceforms

AnalyzingSuggester counts skipped duplicate copies toward maxSurfaceFormsPerAnalyzedForm, so a lower-ranked variant silently disappears

Build a default AnalyzingSuggester or FuzzySuggester from 256 documents saying Apple iPhone and one saying apple iphone, and apple iphone is never suggested, not even when typed exactly: repeated copies are skipped as duplicates but still use up the 256-form limit. Releases 4.1.0 (2013) through 10.5.2 and main are affected.

Summary

Priority
Low, rank 268Default suggester settings, but it needs 256 copies of one text plus a lower-ranked variant of the same analyzed form, and the dominant form is still suggested.
Impact
silent wrong results
Since
4.1.0 (2013; the dedup check was added in 2012)introduced 2012-11-05 · over 14 years
Affected
4.1.0 to 10.5.2 and main (10 releases and main run)
Status
documented
Found by
Bounded-exhaustive comparison with a reference model written from the javadoc (semi-formal)

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

The finding in 30 seconds

AnalyzingSuggester keeps up to 256 spellings (surface forms) of texts that its analyzer turns into the same thing (the analyzed form), and it drops repeated copies of a spelling. But each dropped copy still uses up one of the 256 slots, so a rarer spelling disappears once another one repeats 256 times.

Three-panel diagram. 1, You build suggestions from your documents: 256 documents Apple iPhone with weights 100 to 355 and 1 document apple iphone with weight 5, a default AnalyzingSuggester with StandardAnalyzer built from a DocumentDictionary, and lookup("apple ip", false, 2); both texts analyze to one form, apple, separator, iphone. 2, build() counts each copy before it skips it: AnalyzingSuggester.java lines 545 (dedup++), 546 (the limit check) and 551 (the copy check); the limit runs before the copy check, so every copy uses a slot; of the 256 slots, slot 0 holds Apple iPhone/355 and slots 1 to 255 go to skipped copies that are counted but never indexed, so apple iphone/5 has no slot left and is cut. 3, Result on main with -ea and -da: lookup("apple ip", false, 2) expected [Apple iPhone/355, apple iphone/5], actual [Apple iPhone/355]; lookup("apple iphone", false, 2) expected [apple iphone/5, Apple iPhone/355], actual [Apple iPhone/355]; limit 2 with entries a/10, a/5, A/3 expected [a/10, A/3], actual [a/10]; build() returns normally (getCount() = 257) with no exception or warning; with 255 copies both forms come back; the fix (check for a copy first, count only kept forms, still at most 256 forms) makes all of these pass. Footer: since 2012, commit 37cdda05f50 (the LUCENE-4533 fix) added the copy check below the existing limit; fails on 4.1.0 to 10.5.2 and main.
The 256 documents, how build() gives each skipped copy one of the 256 slots so apple iphone is cut, and the three lookups, expected against actual on main. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 256 documents whose suggestion field is Apple iPhone (weights 100 to 355) and one whose field is apple iphone (weight 5). Build the default AnalyzingSuggester with StandardAnalyzer from a DocumentDictionary, which here yields one entry per document (one stored value each). Both texts analyze to apple iphone.
  2. You expect lookup("apple ip", false, 2) to return both. The limit, maxSurfaceFormsPerAnalyzedForm (default 256), counts distinct surface forms per analyzed form, and here there are 2. Repeated copies are meant to be dropped, keeping the highest weight.
  3. You get only [Apple iPhone/355]. Typing apple iphone exactly does not return it either (EXACT_FIRST is on by default), and FuzzySuggester does the same. With 255 documents both come back.
  4. Why: build() walks the entries of one analyzed form best first. For each entry it raises a counter, applies the limit, and only then skips a repeated copy. The first copy is kept; the other 255 are skipped but still take slots, so apple iphone gets counter 256 and is cut.
  5. Smallest case: with the limit set to 2, the entries a/10, a/5, A/3 give [a/10] instead of [a/10, A/3]. Checking for the repeat before counting fixes it; with that change the whole suggest module passes (243 tests, 0 failures, 7 skipped by their own markers).

Who is affected

Applications that build AnalyzingSuggester or FuzzySuggester, default settings included, from input where one text repeats at least 256 times (a DocumentDictionary over a field such as a product name, brand or tag) and a less popular variant analyzes to the same form, such as a case or accent variant. With a lowered limit k, k copies suffice.

Mechanism, terms and numbers574 words

AnalyzingSuggester and FuzzySuggester keep at most maxSurfaceFormsPerAnalyzedForm surface forms (default 256) for texts that the analyzer maps to the same analyzed form, and they remove repeated copies of a surface form, keeping the highest-weighted one. build() applies the limit before it checks for a repeat (AnalyzingSuggester.java:544-554), so every skipped copy still uses up a slot. With StandardAnalyzer, the default constructor and a DocumentDictionary over 256 documents Apple iPhone (weights 100 to 355) plus one apple iphone (weight 5), lookup("apple ip", false, 2) returns only [Apple iPhone/355], and the exact text apple iphone is not returned either; 255 documents return both. With the limit set to 2, a/10, a/5, A/3 gives [a/10]. Nothing throws. 4.1.0 to 10.5.2 and main are affected (10 releases and main run; the same code order in every release tag of that range that was read), and checking for the repeat before counting fixes it.

Terms

  • Surface form: the suggestion text as given, for example Apple iPhone. Analyzed form: what the analyzer makes of it, here apple iphone for both Apple iPhone and apple iphone. Lookups match on the analyzed form and return surface forms.
  • maxSurfaceFormsPerAnalyzedForm (constructor argument, default 256, at most 256): how many surface forms are kept per analyzed form; the javadoc says the lowest-weighted ones are discarded beyond that.
  • Repeated copies: the same surface form given more than once, for example by a DocumentDictionary, which yields one entry per stored value of the field in each live document (one per document for a single-valued field). build() keeps the highest-weighted copy and skips the rest.

The bug

  1. build() sorts all entries by analyzed form, then by weight (highest first), then by surface bytes (AnalyzingComparator, AnalyzingSuggester.java:337-385).
  2. It walks them with a counter dedup per analyzed form. The counter is also a one-byte suffix that keeps each kept form's FST key unique (:570-571), which is why the limit is capped at 256 (:229-238).
  3. For each further entry of the same analyzed form it runs dedup++ (:545), then cuts the entry if dedup >= maxSurfaceFormsPerAnalyzedForm (:546-550), and only then skips it if the surface form was already kept (:551-553). A skipped copy is never added to the FST, but it has already raised the counter.
  4. A new analyzed form resets the counter and the seen-set (:555-559). FuzzySuggester extends AnalyzingSuggester and does not override build() (FuzzySuggester.java:66).

Worked example (limit 2, entries a/10, a/5, A/3, all analyzed to a)

entrycounterwhat build() doescorrect
a/100first entry: keptkept
a/511 < 2; a copy of a: skippedskipped, not counted
A/322 >= 2: cutkept: only 2 distinct forms

lookup("a", false, 5) returns [a/10] instead of [a/10, A/3].

The numbers at the default limit

256 documents Apple iPhone (weights 100 to 355) and one apple iphone (weight 5), StandardAnalyzer, default constructor, DocumentDictionary: the 256 copies take counter values 0 to 255, apple iphone gets 256 and is cut. lookup("apple ip", false, 2) returns [Apple iPhone/355], and lookup("apple iphone", false, 2) returns [Apple iPhone/355] instead of [apple iphone/5, Apple iPhone/355] (the exact-match-first pass, :716-781, cannot find a form that is not in the FST). With 255 documents the variant gets counter 255 and both are returned. FuzzySuggester gives the same results.

Exposure and likelihood313 words
  • Who. Users of AnalyzingSuggester and FuzzySuggester (both @lucene.experimental). DocumentDictionary yields one entry per stored value of the field in each live document, so a field whose values repeat (product names, brands, categories, tags, popular titles) reaches 256 copies easily; a FileDictionary with repeated lines does too.
  • What else it takes. A distinct variant of the same analyzed form that ranks below the copies (a lower weight, or the same weight and later in byte order, AnalyzingSuggester.java:369-384; the equal-weight case was read, not executed): in practice a case, accent or punctuation variant under a lowercasing or folding analyzer. By reading (not executed), also texts that a stop-word or synonym analyzer maps to the same bytes.
  • Default settings. The 3-argument constructors (limit 256, EXACT_FIRST and PRESERVE_SEP) and StandardAnalyzer are enough. With a lowered limit k, k copies are enough.
  • Silent. No exception and no log; the suggester is valid, just smaller. The lookup returns fewer results than asked for (or, by reading, the next suggestion moves up into the variant's place), and a user who types the variant exactly does not see it.
  • The threshold is exact. A distinct form is dropped once at least limit entries of its analyzed form, copies included, sort ahead of it: 255 copies keep it, 256 drop it. Distinct forms that rank above the copies, and other analyzed forms, are not affected.
  • Downstream (read, not tested): Solr's FuzzyLookupFactory and AnalyzingLookupFactory build these suggesters with this limit (default 256, configurable). The Elasticsearch completion suggester uses the separate suggest.document package, which this bug does not touch; for OpenSearch the search found no production use of these two classes, but it was not exhaustive.
  • How likely. Rated rare: the variant must share the analyzed form and rank below 256 copies, the main spelling is still suggested, so the loss is easy to miss, and nothing has been reported since 2013.

Minimal repro

The test shows that AnalyzingSuggester with maxSurfaceFormsPerAnalyzedForm=2 and the entries a/10, a/5, A/3 (a lowercasing analyzer maps all three to a) counts the skipped duplicate copy a/5 toward the limit and drops the distinct surface form A/3.

TestAnalyzingSuggesterDuplicateCopyTakesSlot.java (JUnit test)39 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.suggest.analyzing;

import java.io.StringReader;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.search.suggest.FileDictionary;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestAnalyzingSuggesterDuplicateCopyTakesSlot extends LuceneTestCase {

  public void testDuplicateCopyDoesNotUseSurfaceFormSlot() throws Exception {
    try (Directory tempDir = new ByteBuffersDirectory();
        Analyzer analyzer = new StandardAnalyzer()) {
      AnalyzingSuggester suggester =
          new AnalyzingSuggester(tempDir, "suggest", analyzer, analyzer, 0, 2, -1, true);
      suggester.build(new FileDictionary(new StringReader("a\t10\na\t5\nA\t3\n")));
      // expected: both surface forms of "a" fit in maxSurfaceFormsPerAnalyzedForm=2; actual: [a/10]
      assertEquals("[a/10, A/3]", suggester.lookup("a", false, 5).toString());
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-248/, 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.suggest.analyzing.TestAnalyzingSuggesterDuplicateCopyTakesSlot
Expected vs actual
Expected: lookup("a") = [a/10, A/3]. Actual: [a/10], with -ea and -da.

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

More files, environment, full output33 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<[a/10[, A/3]]> but was:<[a/10[]]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.suggest.analyzing.TestAnalyzingSuggesterDuplicateCopyTakesSlot.testDuplicateCopyDoesNotUseSurfaceFormSlot(TestAnalyzingSuggesterDuplicateCopyTakesSlot.java:36)

Why it looks unintended

  • The javadoc caps surface forms, not entries: "Maximum number of surface forms to keep for a single analyzed form".
  • #5599 added the dedup to "de-dup the user's input ... before adding to the FST", below an older counter; the limit was not discussed.
  • Skipped copies never use the one-byte suffix the limit protects. No test asserts today's behaviour.
The full argument356 words
  • Contract. Constructor javadoc (AnalyzingSuggester.java:205-206, the same in FuzzySuggester.java:138-139): "Maximum number of surface forms to keep for a single analyzed form. When there are too many surface forms we discard the lowest weighted ones." Field javadoc (:141): "Maximum number of dup surface forms (different surface forms for the same analyzed form)." Comment above the seen-set (:511-514): "Used to remove duplicate surface forms (but we still index the highest-weight one)." The repro has 2 distinct surface forms.
  • Intent. #5599 (LUCENE-4533, 2012, "if you pass dups (same surface form) to AnalyzingSuggester it can fail to return enough results"), by @mikemccand: "I think we should de-dup the user's input (keeping highest weight for each surface form) before adding to the FST." Their fix comment bounds the seen-set ("worst case is it holds 256 keys") but does not discuss how copies count toward the limit.
  • History. The counter and the cut date from LUCENE-3842 (f2f91bae46c, 2012-09-28), when every counted entry was indexed with its own suffix. The LUCENE-4533 fix (37cdda05f50, 2012-11-05) put the seen-set check below the existing cut.
  • Tests. No test asserts the current behaviour. testMaxSurfaceFormsPerAnalyzedForm uses 3 distinct forms and no copies; testDupSurfaceFormsMissingResults3 feeds repeated entries on purpose, with 5 entries; testRandom avoids repeated surface forms. A weaker analogy: the sibling document suggester's testExtremeDeduplication indexes at least 500 copies of abc plus one abd and expects both, so copies are not meant to crowd out a different suggestion there; but it is different code, and abc and abd are different analyzed forms, so it does not test a per-form cap.
  • Legal input. Nothing in InputIterator, Lookup.build, DocumentDictionary, FileDictionary, CHANGES or MIGRATE requires unique input.
  • The exact-match comment. A comment in lookup (:737-741) says the cap may have "pruned our exact match": the authors accepted that the cap can remove a form someone types exactly. That fits a cap on distinct forms and does not say identical copies count; here the cut happens with only 2 distinct forms, so the exact-match loss is a consequence of the wrong cut, not a separate defect.
The devil's-advocate check262 words

The strongest case for "works as designed" (the cross-model reviews, the reproducers and the adjudication looked for it): the limit is also an implementation bound, the one-byte suffix ("this is just an implementation limitation"), and the cut's comment reads "More than maxSurfaceFormsPerAnalyzedForm dups: skip the rest", so the counter could be read as a cap on raw entries; the classes are @lucene.experimental; callers could be expected to pre-aggregate (strongest for a FileDictionary or a custom iterator, weakest for a DocumentDictionary, whose entries come from the index); the authors knew the cap can prune an exact match (:737-741); today's order also skips the seen-set lookup for every entry after the cut; and the lost forms are low-ranked variants.

Why it fails: skipped copies never use a suffix byte, so counting them protects nothing the bound exists for, and the fix keeps the suffixes below the limit and the seen-set bounded by it; the saved lookups are not a documented contract; the javadoc defines the capped items as different surface forms; #5599 asked for dedup before the FST; the exact-match comment fits a cap on distinct forms, and here the cut happens with only 2 of them; and the outcome flips at an arbitrary copy count (255 kept, 256 lost). The cross-model reviews and the independent reproduction rated the intended-behaviour argument weak explicitly, and the intended-behaviour check and the adjudication rejected it; a later cross-model fact check of this doc rated it moderate for exact repeats from a FileDictionary or custom iterator and weak for DocumentDictionary. Verdict: a defect.

Suggested fix

Recommended A: skip copies before counting; count only kept forms

Check for a repeated copy before counting, and count only the forms that are kept (cut when dedup + 1 >= limit, then dedup++). The repro passes 5 of 5, a new limit check 2 of 2, and the whole suggest module passes (243 tests, 0 failures, 7 skipped); the 256-form and seen-set bounds stay.

} else if (analyzed.get().equals(previousAnalyzed.get())) {
  if (seenSurfaceForms.contains(surface)) {
    // a lower weighted copy of a surface form we already kept: it does not count
    continue;
  }
  if (dedup + 1 >= maxSurfaceFormsPerAnalyzedForm) {
    // More than maxSurfaceFormsPerAnalyzedForm
    // dups: skip the rest:
    continue;
  }
  dedup++;
  seenSurfaceForms.add(BytesRef.deepCopyOf(surface));
All options and trade-offs4 options · 801 words

Option A was built and checked in a scratch worktree; B, a smaller variant, was checked on the repro, the fix check and the analyzing tests; D is a workaround for released versions.

A: skip copies before counting; count only kept forms (validated by the checks) (recommended)

The finder's change, +6/-5 in AnalyzingSuggester.build. A copy of a kept surface form is skipped first and does not count; a new distinct form is cut if it would exceed the limit, otherwise it takes the next counter value. Its comment should say "lower- or equal-weighted", since equal-weight copies are skipped too.

} else if (analyzed.get().equals(previousAnalyzed.get())) {
  if (seenSurfaceForms.contains(surface)) {
    // a lower weighted copy of a surface form we already kept: it does not count
    continue;
  }
  if (dedup + 1 >= maxSurfaceFormsPerAnalyzedForm) {
    // More than maxSurfaceFormsPerAnalyzedForm
    // dups: skip the rest:
    continue;
  }
  dedup++;
  seenSurfaceForms.add(BytesRef.deepCopyOf(surface));

Trade-offs

  • Behaviour change on rebuild: repeated input now keeps up to limit distinct forms per analyzed form, as documented; the FST grows by at most limit - 1 entries per analyzed form, the same bound as for unique input. Input whose analysis yields no repeated (analyzed form, surface form) record builds exactly as before. Distinct input alone does not guarantee that: a graph analyzer can yield repeated analyzed paths (build does not determinize, and FiniteStringsIterator may return duplicates for a non-determinized automaton), and fix A stops counting those too (read, not executed).
  • No format change: store/load are untouched; kept forms get contiguous suffix bytes 0 to k-1 instead of gaps. Stored suggesters load as before and keep their old content until rebuilt.
  • Bounds kept: the seen-set holds only kept forms (at most limit) and suffixes stay below the limit.
  • Cost: the seen-set lookup now runs for every entry of an analyzed form, also past the limit, where stock code skips it (the cut at :546-549 comes first): more hash lookups for analyzed forms with more than limit entries. The set stays bounded; the offline sort has already handled every entry. Not benchmarked.
  • Payloads as today: the sort and the seen-set compare surface bytes, not payloads (:369-384, :551), so when equal-weight copies of one surface form carry different payloads, which payload is kept depends on the sorted order. Fix A does not change that.

What the checks validated

  • The repro: 4 of 5 fail on stock, 5 of 5 pass with the fix (gradle with assertions on and off; the repro runner with -ea and -da).
  • The fix check: 2 of 2 fail on stock, 2 of 2 pass (-ea and -da).
  • :lucene:suggest:test --tests 'org.apache.lucene.search.suggest.analyzing.*': 96 tests (89 upstream + 7 new), 0 failed, 1 skipped, with assertions on (seed 5EED7248) and off (seed 77AA2480).
  • All of :lucene:suggest:test: 243 tests (236 upstream + 7 new), 0 failed, 7 skipped (6 @Ignore, 1 @Nightly, so 229 upstream tests ran), in both modes, repeated by the fact check at a fresh seed; :lucene:suggest:check -x test (format, ecjLint, forbiddenApis) passes.
  • Earlier checks: the finder's and the reproducers' tests pass with it, also in a separate re-run; the bounded-exhaustive harness finds 0 mismatches in its 3-entry lowercase scope; the 4.1.0 source with the fix passes the finder's repro.

B: move the seen-set check above dedup++ (validated, smallest diff)

Swap the two if blocks (+3/-3). The repro passes 5 of 5 and the fix check 2 of 2 (-ea, and -da in the fact check), and the 96 analyzing tests with assertions on; format passes.

} else if (analyzed.get().equals(previousAnalyzed.get())) {
  if (seenSurfaceForms.contains(surface)) {
    continue;
  }
  dedup++;
  if (dedup >= maxSurfaceFormsPerAnalyzedForm) {
    continue;
  }
  seenSurfaceForms.add(BytesRef.deepCopyOf(surface));

Trade-offs

Past the limit the counter keeps growing for every entry that is not a copy of a kept form (further distinct forms, and copies of cut forms, which never enter the seen-set; copies of kept forms are still skipped without counting), so it no longer equals the number of kept forms; by reading, it could misbehave only after about 2^31 entries of one analyzed form (int overflow). A keeps the counter exact. The whole suggest module was not run with B.

C: document that the limit counts input entries (not recommended)

Change the javadoc instead of the code.

Trade-offs

Keeps today's output but contradicts the field javadoc and the intent of #5599, and leaves the 255/256 cliff that depends on how often another text repeats.

D: workaround for released versions: drop repeats before build (validated)

Wrap the InputIterator so each text is passed once with its highest weight, which is what build() keeps anyway. Validated on main, 10.5.2 and 9.12.3.

Trade-offs

Memory proportional to the number of distinct texts. The sketch drops payloads and contexts; a real wrapper must keep the payload of the highest-weighted copy. Alternatively aggregate at indexing time, one document per suggestion.

How it was found

An AI tool compared the real class with a reference model written from its javadoc on every dictionary of up to 3 entries (bounded-exhaustive translation validation). Every mismatch traced to this one cause; with the fix, the rerun AnalyzingSuggester scope had none. The suspicion came from reading the code; no model checker or prover was used.

The method in full212 words

Method: bounded-exhaustive translation validation (the hunt's "semi-formal" tier), a hunt unit (suggesters), 2026-10-26, main ea57360912ed.

  • The finder wrote a reference from the javadoc: per analyzed form, keep the maxSurfaceFormsPerAnalyzedForm highest-weighted distinct surface forms; repeated copies keep their highest weight. The hypothesis came from reading build() while writing it, and a probe confirmed it.
  • On separate build machines the harness compared the real AnalyzingSuggester and FuzzySuggester with the reference for every dictionary of at most 3 entries over 12 strings and weights {0, 1, 2}, every key of up to 3 characters and num 1 to 7: 330,258 mismatches in 161,239,680 lookups (lowercasing analyzer, 12 AnalyzingSuggester configurations), 29,184 with a plain analyzer, and 409,488 in 644,958,720 (plain analyzer, 48 FuzzySuggester configurations). All were of this one class and none unexplained; there were none with 2 or fewer entries.
  • 20,000 random dictionaries of up to 6 entries: 1,746,904 (lowercase) and 469,544 (plain) mismatches, all of this class. With the fix shadowed: 0 mismatches in the 3-entry lowercasing AnalyzingSuggester scope; the FuzzySuggester scope was not re-run with the fix. A seeded mutant of the reference (a flipped tie-break) was caught (31,752 unexplained mismatches), so the comparison is not vacuous.
  • Exhaustive only within these bounds. The failing case was then written as a public-API JUnit.

Prior reports, history and checks

Prior reports

No prior report found: 98 queries over GitHub, JIRA, the mailing lists, downstream projects, Stack Overflow and the web, plus the changed files of all 413 open PRs (2026-10-26). The closest item, #5599 (2012), is where the dedup code came from; it never discusses the limit.

Searches4 searches

No report of this defect was found; novelty is "new". #5599 / LUCENE-4533 is the origin of the dedup code.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; LUCENE JIRA; the web2026-10-2681 queries with a scripted prior-report search (class names, maxSurfaceFormsPerAnalyzedForm, seenSurfaceForms, surface/analyzed form, duplicate/dedup, missing suggestions, case-insensitive, DocumentDictionary); comments of the top 20 candidates read333 distinct hits; none is this bug
GitHub-wide, SOLR and LUCENE JIRA, Stack Overflow, git history2026-10-2617 manual searches; the comment threads of 12 related LUCENE issues read and grepped for maxSurface, dedup, dups, 256 and limitno report
Open apache/lucene PRs2026-10-26the changed files of all 413 open PRsnone touches AnalyzingSuggester.java or FuzzySuggester.java
Related items (read)2026-10-2623 hits read in full#5599 / LUCENE-4533 (origin of the dedup; limit not discussed), #4915 / LUCENE-3842 (introduced the limit), elastic/elasticsearch#7170 (a fork that keeps one copy per document by design), elastic/elasticsearch#10406 (the limit working as documented)

History

Introduced in 2012 by 37cdda05f50, the fix for #5599 (LUCENE-4533), which put the duplicate check below an older counter; first released in 4.1.0, the first release with these classes. All 10 releases run, from 4.1.0 to 10.5.2, and main fail the repro; none of them kept the variant.

Versions tested6 rows

Introduced by 37cdda05f50 (2012-11-05, by @mikemccand; branch_4x backport d9a88eb0e12), the fix for #5599 / LUCENE-4533 (its message says LUCENE-4534, which is the 0-byte fix committed just before). It added the seen-set check below the counter and cut, which came with AnalyzingSuggester itself in f2f91bae46c (LUCENE-3842, 2012-09-28), when every counted entry was indexed. Subversion era, no PR.

Isolation is partial: the 4.1.0 source with only the dedup lines removed also loses the variant on these inputs, but there the copy that took the slot was itself indexed as a duplicate result (the LUCENE-4533 bug). All 120 release tags from 4.1.0 to 10.5.2 in the history clone keep the count-then-cut-then-dedup order (the clone has no 8.x tags after 8.8.1, so later 8.x maintenance tags were not read; 8.11.4 was run), and upstream main still has it. The trunk hashes give the main-line origin; 4.1.0 got the code through branch_4x backports (of the commits named here only d9a88eb0e12 is an ancestor of the 4.1.0 tag).

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis doc's repro 4 of 5 fail with -ea and -da; the finder's 2 of 3
10.5.2, 9.12.3 affectedthis doc's repro 4 of 5 fail (10.5.2 -ea, 9.12.3 -da); the finder's repro fails too
10.0.0, 9.0.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4 affectedthe finder's repro (or its API port) fails 2 of 3 with identical messages
4.1.0 first releasefails 2 of 3 (also with -da); passes 3 of 3 with the fix shadowed
4.0.0 API absentno analyzing package; the repro does not compile
fix A / fix B on main not affectedthe repro and the fix check pass; the whole suggest module passes with fix A (243 tests, 0 failures, 7 skipped)

Independent checks

The finder's harness, independent reproducers (one with a realistic DocumentDictionary test), reviews by a model from another family, an adjudication with a fresh seed and a re-run step: confirmed, high confidence. One review returned not real only because it could not run code; it agreed with the root cause.

Checks and verdicts472 words

Each check ran or read independently; a separate run and the later steps re-ran the tests with fresh seeds.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateHarness: every mismatch of this one class, 0 unexplained, 0 with the fix. JUnit fails 2 of 3 on main (-ea, -da), 10.5.2, 9.12.3, 8.11.4 and 6.6.6; with the fix 3 of 3 and the 37 upstream TestAnalyzingSuggester and TestFuzzySuggester tests pass.
cross-model review, refute lens not real (low), proceduralOpenAI GPT-5.6, read-only, could not run Java, so it voted not real on procedure only; root cause agrees, inputs in contract, intended-behaviour argument weak, 10 of 11 evidence items support the bug. A separate run re-ran the repro: 2 of 3 fail.
Independent reproducer realOwn 7-test JUnit: 5 of 7 fail on main (-ea, -da), 10.5.2 and 9.12.3, including FuzzySuggester, a DocumentDictionary and an exact duplicate; the 255-copy control passes; 7 of 7 pass with the fix.
cross-model review, intended lens real (medium)OpenAI GPT-5.6, read-only; intended-behaviour argument weak; 11 of 12 evidence items support the bug, among them testExtremeDeduplication. A separate run re-ran the repro with seed 1234ABCD: main and 10.5.2 fail, the fix passes.
Devil's advocate real (high)DocumentDictionary + StandardAnalyzer + default constructors: 3 of 4 fail on main (-ea, -da) and 10.5.2, 4 of 4 pass with the fix; rebutted four works-as-designed readings.
adjudication confirmed (high), silent wrong resultsRe-ran the independent reproduction's test with seed 7A11C0DE: 5 of 7 fail with -ea and -da; 7 of 7 pass with the fix.
Follow-up and registration check holdsHistory sweep over 12 targets, prior-report search (new), security (none); fresh seeds 5EED6411 to 5EED6418 for the finder's, the independent reproduction's and the intended-behaviour check's tests: all fail on stock and pass with the fix.
this doc's runs reproduced, fix validatedNew repro 4 of 5 fail on main, 10.5.2 and 9.12.3; fix A validated on the whole suggest module in a scratch worktree; fix B and the workaround checked.
Fact check of this doc (re-ran everything) holds; one command comment fixedRe-ran the doc's gradle command on main (4 of 5 fail, both modes), the repro on main, 10.5.2 and 9.12.3 at fresh seeds, both fixes, the fix check, the workaround and the whole suggest module with fix A (243 tests, 0 failures, 7 skipped). Found that the gradle command cannot run on 10.5.x or 9.12.x with JDK 25; fixed.
Cross-model fact check (OpenAI GPT-5.6, reading only) core bug real; 18 accuracy findingsRoot cause, first release and fix A confirmed; 6 wording errors, 6 overclaims and 6 missing caveats, all handled: 16 fixed, 1 left open, 1 declined with reasons.
Diagram check correct; no errorsEvery value on screen re-run and matched; optional polish only.

Diagram file

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

Sources and provenance

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

Finding record

LFV-248, snapshot of 2026-11-06

Registry title
AnalyzingSuggester and FuzzySuggester count skipped duplicate copies of a surface form toward maxSurfaceFormsPerAnalyzedForm, so distinct lower-weighted surface forms are silently dropped
Area
suggest / analyzing suggester (AnalyzingSuggester.build, inherited by FuzzySuggester)
Priority
Low, rank 268: Default suggester settings, but it needs 256 copies of one text plus a lower-ranked variant of the same analyzed form, and the dominant form is still suggested.
Impact class
silent wrong results
Impact note
Applications that build an AnalyzingSuggester or FuzzySuggester (default constructors and the default limit of 256 suffice; every release from 4.1.0 through 10.5.2 and main) from input that repeats one suggestion text at least maxSurfaceFormsPerAnalyzedForm times, for example a DocumentDictionary over 256 or more documents with the same suggestion text, silently lose every distinct surface form of the same analyzed form that ranks below those copies (a case, accent or punctuation variant, or another text the analyzer maps to the same bytes), including its EXACT_FIRST exact match, with no error; a lowered limit makes it easy to hit, while distinct forms that rank above the copies are unaffected.
Summary
You build an AnalyzingSuggester or FuzzySuggester from entries in which one text repeats, for example 256 documents with 'Apple iPhone' and one with 'apple iphone', which a lowercasing analyzer maps to the same analyzed form, and you expect both surface forms back, because maxSurfaceFormsPerAnalyzedForm (default 256) is documented as the 'maximum number of surface forms to keep for a single analyzed form' and repeated copies are documented as removed. build() increments its per-analyzed-form counter and applies the limit before it checks whether the surface form was already seen, so every skipped copy still uses up a slot: lookup('apple ip') returns only [Apple iPhone], and with a limit of 2 the entries a/10, a/5, A/3 give [a/10] instead of [a/10, A/3]. With 255 copies, or without the repeated copy, both forms are returned.
Introduced
37cdda05f50b1c544efc5aaac4135dbcd7a28cba (mikemccand, trunk svn r1405977, 2012-11-05; branch_4x backport d9a88eb0e12ea157c2092e51ab39b878788c0dfd, r1405978; no pull request, Subversion era). Its message says 'LUCENE-4534: dedup same surface form in Analyzing/FuzzySuggester', but it is the fix for LUCENE-4533 / #5599 (LUCENE-4534 is the 0-byte-key fix 2537836d4ab committed just before). It added the seenSurfaceForms check below the existing counter and cut [read: git blame of main lines 545-554 names exactly this commit and f2f91bae46cb; git log -L 540,560 lists only f2f91bae46c, 2537836d4ab, 37cdda05f50 and the mechanical 94d1b7f5a64 (LUCENE-5864, BytesRef to BytesRefBuilder)]. The counter (dedup++) and the cut come from f2f91bae46cbc4a883324521767e4701fbbbc14a (LUCENE-3842 'add AnalyzingSuggester', 2012-09-28), when there was no surface-form dedup and every counted entry was indexed. Isolation is partial [exec, follow-up history]: the 4.1.0 source with only the dedup lines removed also loses the distinct form on these inputs (with -da; with -ea a TopNSearcher assertion fires, the LUCENE-4533 symptom), but there the copy that took the slot was itself indexed as a duplicate result; 37cdda05f50 removed the copy from the FST and left it counted. Both commits predate the first release of the API (2012-11-05 (the dedup check placed below the cut; the counter and cut date from 2012-09-28))
First affected version
4.1.0 [exec] (the first release that has AnalyzingSuggester and FuzzySuggester at all; the repro fails 2 of 3 there and passes 3 of 3 with the fix shadowed); 4.0.0 has no analyzing package, so the repro does not compile [exec]
Affected versions
every release from 4.1.0 through 10.5.2 and main ea57360912ed; not fixed on upstream main as of 2026-10-26 [exec: the finder's 3-test repro, or its API port, fails 2 of 3 with identical messages and a passing control on 4.1.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (main with -ea and -da; 4.1.0 also with -da); the independent reproduction's own 7-test JUnit fails 5 of 7 on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's DocumentDictionary/StandardAnalyzer test fails 3 of 4 on main (-ea and -da) and 10.5.2; read: all 120 release tags from 4.1.0 to 10.5.2 keep the count-then-cut-then-dedup order, and the upstream main file read on 2026-10-26 still has it]. Caveats: releases before 10.x were run on JDK 25 with API ports of the same test (TermFreqIterator in 4.1-4.5, no temp Directory before 6.0, pre-9.1 test-framework packages); FuzzySuggester was executed on main, 10.5.2 and 9.12.3 only (it does not override build() in 4.1.0 or main); unreleased maintenance branches were not checked
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: 98 recorded queries, namely the scripted prior-report search with 81 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, plus 17 manual GitHub-wide, JIRA (comment threads of 12 related issues read), Stack Overflow, web and git-history searches; the changed files of all 413 open apache/lucene PRs; 23 hits read and classified, none the same bug; the finder, the independent reproduction and the intended-behaviour check found nothing either); not fixed upstream (upstream main still has the 2012 order, read 2026-10-26, last commit touching the file 54283150a816 of 2026-06-14, a build-tooling change; no open PR touches AnalyzingSuggester.java or FuzzySuggester.java); closest items, not reports: #5599 / LUCENE-4533 (closed 2012, 'if you pass dups (same surface form) to AnalyzingSuggester it can fail to return enough results'; its fix added the dedup check that this bug places too late, and neither the issue nor the commit discusses the limit), #4915 / LUCENE-3842 (introduced the limit; 'duplicates' there means different surface forms with the same analyzed form), and elastic/elasticsearch#7170 (2014, a downstream fork that keeps one copy per document by design, where a reviewer asked what happens when many same-value documents exceed the limit; a different design in a different codebase)
Lucene commit tested
main ea57360912ed (commit 2026-10-24; tested 2026-10-26) on JDK 25.0.3: the brief's JUnit repro through the repro runner with -ea and -da and through gradle in a scratch worktree, stock and with fixes A and B (the worktree was removed after the runs), plus the whole suggest module with fix A in both assertion modes; the same repro on the 10.5.2 and 9.12.3 jars (SHA-1 checked); the workaround on main, 10.5.2 and 9.12.3; the history step's runs of the finder's repro on 4.0.0 (API absent), 4.1.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main.

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:98 (at ea57360912ed) (@lucene.experimental), :141 (field javadoc 'Maximum number of dup surface forms (different surface forms for the same analyzed form).'), :169-175 (default constructor: EXACT_FIRST | PRESERVE_SEP, 256), :205-206 (constructor javadoc 'Maximum number of surface forms to keep for a single analyzed form. When there are too many surface forms we discard the lowest weighted ones.'), :229-239 (limit 1..256, one-byte suffix), :511-517 (seen-set comment 'Used to remove duplicate surface forms (but we still index the highest-weight one)' and the counter), :544-554 (dedup++ and the cut run before seenSurfaceForms.contains), :556 (counter reset on a new analyzed form), :571-586 (suffix byte and FST add)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:66 (at ea57360912ed) (final class FuzzySuggester extends AnalyzingSuggester, no build override), :138-139 (same limit javadoc)
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestAnalyzingSuggester.java:804 (at ea57360912ed) (testRandom: 'Don't add same surface form more than once'), :976 (testMaxSurfaceFormsPerAnalyzedForm: 3 distinct forms, no copies), :1226 (testDupSurfaceFormsMissingResults3: repeated entries, limit 256); lucene/suggest/src/test/org/apache/lucene/search/suggest/document/TestSuggestField.java:267 (testExtremeDeduplication)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:229-238, (at ea57360912ed) 337-385, 511-517, 540-560, 570-587, 716-781
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:66 (at ea57360912ed)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/DocumentDictionary.java:147-153, (at ea57360912ed) 205-224
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:98, (at ea57360912ed) 169-176, 205-206; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:64
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/DocumentDictionary.java:36-43 (at ea57360912ed)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/DocumentDictionary.java:147-153, (at ea57360912ed) 205-224
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:141, (at ea57360912ed) 205-206, 229-232, 511-514, 547-548; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:138-139
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestAnalyzingSuggester.java:804, (at ea57360912ed) 976-991, 1226-1243; lucene/suggest/src/test/org/apache/lucene/search/suggest/document/TestSuggestField.java:267-300
  • https://github.com/apache/lucene/issues/5599 (body and comments, read 2026-10-26 with gh issue view)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:737-741 (at ea57360912ed)
Suggested fix
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:511-517, (at ea57360912ed) 540-560, 570-571, 600-621
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:369-384, (at ea57360912ed) 415-419, 864-883; lucene/core/src/java/org/apache/lucene/util/automaton/FiniteStringsIterator.java:33

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), produced separately from this text from its own probe runs of the DocumentDictionary case above and the limit-2 miniature a/10, a/5, A/3. The slot bar is schematic. The alt text was written from the PNG; check it again if the diagram changes.

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.