All findings · Lucene finding · LFV-706 · fstcompletionlookup-ignores-exactmatchfirst-false

FSTCompletionLookup ignores exactMatchFirst=false for by-weight lookups after build() and load(), so an exact match is always ranked first

FSTCompletionLookup built with exactMatchFirst=false still ranks an exact match first in by-weight lookups: for star (weight 1), star wars (100) and start (50), lookup("star", true, 3) returns [star/0, star wars/6, start/3], not [star wars/6, start/3, star/0], with no error. Every release since 3.6.0 is affected.

Summary

Priority
Low, rank 384Ranking-only, and it needs the non-default exactMatchFirst=false in the experimental FSTCompletionLookup.
Impact
silent wrong results
Since
3.6.0 (SOLR-2888, 2012)introduced 2011-12-01 · over 14 years
Affected
3.6.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive differential against the javadoc

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

FSTCompletionLookup keeps two views of one FST: a by-weight view for lookup(key, true, num) and an alphabetical view for lookup(key, false, num). build() and load() pass exactMatchFirst only to the alphabetical view, where an exact match sorts first anyway. The by-weight view always keeps its "exact first" default.

Summary diagram. Example: new FSTCompletionLookup(tempDir, "fst", 10, false), build() from star (weight 1), star wars (100), start (50), then lookup("star", true, 3). The flag exactMatchFirst = false reaches normalCompletion (alphabetical lookups, exactFirst = false) but not higherWeightsCompletion (by-weight lookups), which keeps exactFirst = true. Expected, the javadoc's order by discretized weight: [star wars/6, start/3, star/0]. Actual, the exact match first: [star/0, star wars/6, start/3]; with num = 1 only [star/0]. The same after store() and load(). Control: the pre-built-completion constructor passes the flag to both views and returns the documented order. Fix: new FSTCompletion(fst, true, exactMatchFirst) in build() and load().
The flag reaches only the alphabetical view; the by-weight view keeps "exact first", so lookup("star", true, 3) returns [star/0, star wars/6, start/3] instead of [star wars/6, start/3, star/0]. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You create new FSTCompletionLookup(dir, "fst", 10, false) to rank suggestions purely by popularity and build() it from star (weight 1), star wars (weight 100) and start (weight 50). With 10 buckets they land in buckets 0, 6 and 3.
  2. The user types "star" and you ask for the most popular completions: lookup("star", true, 3). The javadoc of exactMatchFirst says that with false exact matches "appear in the order of discretized weight", so you expect [star wars/6, start/3, star/0].
  3. Instead you get [star/0, star wars/6, start/3]: the least popular entry comes first, only because it equals the typed text. With lookup("star", true, 1) the only suggestion is star/0. No error, and exactMatchFirst=true returns the same lists.
  4. Why: the lookup keeps two views of one FST. build() and load() pass the flag only to the alphabetical view; the by-weight view is made with new FSTCompletion(fst), which means "higher weights first and exact first".
  5. It is the same after store() and load() into an instance built with false. The other constructor, which takes a pre-built FSTCompletion, passes the flag to both views and returns [star wars/6, start/3, star/0].

Who is affected

Applications that build the experimental FSTCompletionLookup with the non-default exactMatchFirst=false and ask for by-weight suggestions, lookup(key, true, num). Any typed prefix that is itself a less popular entry is ranked first, every time; with a small num it pushes out a correct entry. The default true and alphabetical lookups are unaffected.

Mechanism, terms and numbers598 words

FSTCompletionLookup has a constructor argument exactMatchFirst; its javadoc says that with false exact matches "appear in the order of discretized weight and alphabetical within the bucket". The lookup keeps two FSTCompletion views over one FST: a by-weight view for lookup(key, true, num) and an alphabetical view for lookup(key, false, num). build() and load() pass the flag only to the alphabetical view; the by-weight view is created with the one-argument FSTCompletion(FST), "higher weights first and exact first". So by-weight lookups always put the exact match first, whatever its weight, with no error, and on distinct keys the argument has no visible effect at all. With star (1), star wars (100), start (50) and 10 buckets, lookup("star", true, 3) returns [star/0, star wars/6, start/3] instead of [star wars/6, start/3, star/0]. It came with the class in SOLR-2888 (3.6.0), which replaced FSTLookup, a class that honoured the flag; every release since is affected. Two statements fix it: create the by-weight view with new FSTCompletion(fst, true, exactMatchFirst) in build() and load(), as the pre-built-completion constructor already does.

Terms

  • Buckets: build() sorts the entries by weight and splits them into buckets equal ranges, bucket = (int) (line * buckets / inputLineCount) (FSTCompletionLookup.java:222-227). A result shows the bucket, not the weight: star wars/6. With 3 entries and 10 buckets: star (weight 1) gets 0, start (50) gets 3, star wars (100) gets 6.
  • Two views: higherWeightsCompletion (by weight, highest bucket first) and normalCompletion (alphabetical), two FSTCompletion wrappers over the same FST (FSTCompletionLookup.java:96-100). lookup(key, contexts, higherWeightsFirst, num) picks the first when higherWeightsFirst is true (:268-272); Lookup.lookup(key, onlyMorePopular, num) delegates to it (Lookup.java:193-195).
  • Exact first: an FSTCompletion with exactFirst set looks for the key itself, puts it at the head of the results and removes it from the rest (FSTCompletion.java:233-283).

The bug

  1. The buckets constructor FSTCompletionLookup(Directory, String, int, boolean) stores exactMatchFirst (FSTCompletionLookup.java:130-136).
  2. build() ends with this.higherWeightsCompletion = builder.build(); (:242) and this.normalCompletion = new FSTCompletion(higherWeightsCompletion.getFST(), false, exactMatchFirst); (:243-244). FSTCompletionBuilder.build() returns new FSTCompletion(automaton) (FSTCompletionBuilder.java:197), and that constructor is "Defaults to higher weights first and exact first", this(automaton, true, true) (FSTCompletion.java:124-131).
  3. load() does the same with new FSTCompletion(new FST<>(readMetadata(...), input)) (FSTCompletionLookup.java:305-306); again only normalCompletion gets the flag (:307-308).
  4. In the alphabetical view the flag cannot show: when more than one bucket is in use the results are sorted before they are cut (FSTCompletion.java:202-206), and an exact match is a prefix of all its completions, so it sorts first anyway; with one bucket the depth-first walk already emits it first. On stock main, instances built with true and with false gave 0 different results over about 300,000 lookups in each mode on random dictionaries of distinct keys; with the fix about 37,000 by-weight lookups differ.
  5. The fourth constructor, FSTCompletionLookup(Directory, String, FSTCompletion, boolean), creates both views with the flag (FSTCompletionLookup.java:152-153) and returns the documented order.

The numbers

star (1), star wars (100), start (50), 10 buckets, exactMatchFirst=false: lookup("star", true, 3) = [star/0, star wars/6, start/3], expected [star wars/6, start/3, star/0]; lookup("star", true, 1) = [star/0], expected [star wars/6]. The same after store() + load(). The alphabetical lookup("star", false, 3) = [star/0, star wars/6, start/3], which is correct UTF-8 order. Another dictionary, {foo: 1, bar: 5, fool: 200, food: 500, foobar: 1000}: lookup("foo", true, 1) = [foo/0] instead of [foobar/8]. The smallest case: {a: 0, aa: 1}, lookup("a", true, 1) = [a/0] instead of [aa/5].

Exposure and likelihood287 words
  • Who. Code that calls the buckets constructor (Directory, String, int, boolean) with exactMatchFirst=false, for example to rank suggestions purely by popularity, and then lookup(key, true, num), after build() or after load().
  • When. For distinct keys and num > 0: whenever the typed text is itself an entry and another of its completions is in a higher bucket: ordinary autocomplete input, such as "star" while "star wars" is far more popular. Deterministic; no scale, concurrency or unusual data needed.
  • What goes wrong. Silent ranking error. If num covers all completions only the order is wrong; if num is small the exact match takes a slot and the lowest-ranked correct entry drops out, so the top-1 suggestion can be the least popular entry.
  • Not affected. The default (FSTCompletionLookup(dir, prefix) uses true, FSTCompletionLookup.java:116-118), alphabetical lookups, get(key) (it reads normalCompletion, :287-290), and the pre-built-completion constructor. The class is @lucene.experimental (:71).
  • Size of the check. A bounded-exhaustive differential on the stock jars over every set of 1-3 distinct keys from the 39 strings of length 1-3 over {a, b, c}, with weights {0, 1, 2, 2^31-1} (596,908 dictionaries, about 4.76 billion lookups) found 265,860 order mismatches each for 3 and 10 buckets with false, and none with true or with 1 bucket. That full scope was not re-run with the fix; a smaller exhaustive run with the fix shadowed (keys of length 1-2, sets of 1-4 keys: 141,904 dictionaries, about 1.13 billion lookups) found no FSTCompletionLookup mismatch.
  • Distinct keys only. Every check used dictionaries of distinct keys; a dictionary holding the same key twice, with weights in different buckets, was not covered, so the doc makes no claim about it.

Minimal repro

The test shows that FSTCompletionLookup(dir, "fst", 10, false) still promotes the exact match in by-weight lookups after build(): for star (weight 1) and star wars (weight 100), lookup("star", true, 2) puts star first.

TestFSTCompletionLookupExactMatchFirstFalse.java (JUnit test)35 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.fst;

import java.io.StringReader;
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 TestFSTCompletionLookupExactMatchFirstFalse extends LuceneTestCase {

  public void testExactMatchNotPromotedAfterBuild() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      FSTCompletionLookup lookup = new FSTCompletionLookup(dir, "fst", 10, false);
      lookup.build(new FileDictionary(new StringReader("star\t1\nstar wars\t100\n")));
      // exactMatchFirst=false: by discretized weight, no promotion; actual: [star/0, star wars/5]
      assertEquals("[star wars/5, star/0]", lookup.lookup("star", true, 2).toString());
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-706/, 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.fst.TestFSTCompletionLookupExactMatchFirstFalse
Expected vs actual
Expected [star wars/5, star/0] (order of discretized weight, as the exactMatchFirst javadoc says); actual [star/0, star wars/5], with -ea and -da; passes with the two-site fix (by-weight view built with exactMatchFirst).

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

More files, environment, full output36 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:<[star[ wars/5, star/0]]> but was:<[star[/0, star wars/5]]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.suggest.fst.TestFSTCompletionLookupExactMatchFirstFalse.testExactMatchNotPromotedAfterBuild(TestFSTCompletionLookupExactMatchFirstFalse.java:32)

Why it looks unintended

  • The javadoc: with false, exact matches "appear in the order of discretized weight and alphabetical within the bucket".
  • The pre-built-completion constructor passes the flag to both views and returns that order; TestFSTCompletion asserts that FSTCompletion(fst, true, false) does not promote.
  • A regression: the replaced FSTLookup honoured false (3.5.0: [star wars, start, star]); 3.6.0 FSTCompletionLookup does not.
The full argument224 words
  • The contract. FSTCompletionLookup.java:126-128: "@param exactMatchFirst If <code>true</code> exact matches are promoted to the top of the suggestions list. Otherwise they appear in the order of discretized weight and alphabetical within the bucket." "Order of discretized weight" only describes the by-weight path, the one that ignores the flag. The same text is on the pre-built-completion constructor (:142-144), which honours it (:152-153).
  • The lower-level switch is documented and tested. FSTCompletion's exactFirst: "Find and push an exact match to the first position of the result list if found" (FSTCompletion.java:109-110). TestFSTCompletion.testRequestedCount asserts that new FSTCompletion(fst, true, false) does not promote the low-weight "one" (TestFSTCompletion.java:136-139). The sibling WFSTCompletionLookup honours exactFirst=false (TestWFSTCompletion.testNonExactFirst). No upstream test builds FSTCompletionLookup with false.
  • History. SOLR-2378 added exact-match promotion "(weighted mode)" to the old FSTLookup, and SOLR-2762 fixed it there, so the flag was a feature of the weighted path. SOLR-2888 replaced FSTLookup with FSTCompletionLookup and switched Solr's FSTLookupFactory from new FSTLookup(buckets, exactMatchFirst) to new FSTCompletionLookup(buckets, exactMatchFirst) as a drop-in; its CHANGES entry announces no change in behaviour. Executed on the same input: 3.5.0 FSTLookup(10, false) returns [star wars, start, star]; 3.6.0 FSTCompletionLookup(10, false) returns [star/0, star wars/6, start/3]. Three days after the class was committed, its author added the "Otherwise ..." javadoc (3408defc8db, "JavaDoc clarifications").
The devil's-advocate check299 words

The strongest works-as-designed case (built by the intended-behaviour verifier, reviews by a model from another family, OpenAI GPT-5.6, and the adjudication): FSTCompletionBuilder's class javadoc says "If there is an exact match in the automaton, it is returned first on the results list (even with by-weight sorting)" (FSTCompletionBuilder.java:77-78), its no-argument constructor makes an FSTCompletion "with default options: 10 buckets, exact match promoted to first position" (:127-131), and FSTCompletion(FST) defaults to exact first; the class is @lucene.experimental; nobody complained in about 15 years; and for load(), the no-argument constructor "should only be used to read a previously saved suggester" (FSTCompletionLookup.java:105), defaults to true, and store() does not record the flag. Nearby javadoc is also loose: FSTCompletion says higherWeightsFirst=false "has no effect" (FSTCompletion.java:106-108), yet lookup then sorts alphabetically when more than one bucket is in use (:202-206), and Lookup documents the by-weight switch as onlyMorePopular, "return only more popular results" (Lookup.java:189).

Why it fails: the builder and one-argument docs describe the default view, which the explicit argument exists to override, and its own javadoc says so; experimental status allows API changes, not a documented argument that does nothing; few users set false, and Solr's modern suggest component uses the unaffected alphabetical path; load() itself applies this.exactMatchFirst to normalCompletion, nothing forbids loading into a configured instance, Solr does exactly that, and the build() path alone proves the bug. The exactMatchFirst text, unlike the loose sentences, is specific to this class and argument, was added by the class's author three days after the class, and is implemented by the pre-built-completion constructor. Every check rated the intended argument weak; the cross-model fact check of this doc rated it weak for build() and moderate for load().

Suggested fix

Recommended Build the by-weight view with the flag in build() and load()

Create the by-weight view with the flag, new FSTCompletion(fst, true, exactMatchFirst), in build() and load(), as the pre-built-completion constructor already does: two statements. Validated: the repro and a new TestFSTCompletion test fail on stock and pass with it, assertions on and off, and the whole suggest module passes (240 tests).

       // The two FSTCompletions share the same automaton.
-      this.higherWeightsCompletion = builder.build();
+      this.higherWeightsCompletion =
+          new FSTCompletion(builder.build().getFST(), true, exactMatchFirst);
 ...
     this.higherWeightsCompletion =
-        new FSTCompletion(new FST<>(readMetadata(input, NoOutputs.getSingleton()), input));
+        new FSTCompletion(
+            new FST<>(readMetadata(input, NoOutputs.getSingleton()), input), true, exactMatchFirst);
All options and trade-offs4 options ยท 538 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF), together with a regression test in TestFSTCompletion; the alternatives were not run.

Build the by-weight view with the flag in build() and load() (validated by the checks) (recommended)

Two statements in FSTCompletionLookup.java (+4/-2 lines after wrapping): create higherWeightsCompletion as new FSTCompletion(fst, true, exactMatchFirst), as the pre-built-completion constructor already does (:153). The FST and the stored format do not change.

       // The two FSTCompletions share the same automaton.
-      this.higherWeightsCompletion = builder.build();
+      this.higherWeightsCompletion =
+          new FSTCompletion(builder.build().getFST(), true, exactMatchFirst);
 ...
     this.higherWeightsCompletion =
-        new FSTCompletion(new FST<>(readMetadata(input, NoOutputs.getSingleton()), input));
+        new FSTCompletion(
+            new FST<>(readMetadata(input, NoOutputs.getSingleton()), input), true, exactMatchFirst);

Trade-offs

  • The default exactMatchFirst=true behaves exactly as before.
  • Behaviour change for users who set false: by-weight lookups now follow the documented order; worth a CHANGES line. Anyone who relied on the old order can pass true, which gives it for distinct keys (duplicate keys were not checked).
  • builder.build() still makes one throwaway FSTCompletion (it caches at most one root arc per bucket), negligible next to building the FST.

What the checks validated

  • Stock main a9779ef3c193 (gradle, seed DEADBEEF): the repro fails 2 of 3 and the new TestFSTCompletion#testExactMatchFirstFalse fails, with assertions on and off; the control and the 13 upstream TestFSTCompletion tests that run pass (testRandom is @Nightly).
  • With the fix: the repro and TestFSTCompletion pass (18 tests, 0 failures, 1 @Nightly skipped), with assertions on and with assertions off.
  • With the fix, the whole :lucene:suggest:test: 32 classes, 240 tests, 0 failures, 7 skipped (31 upstream classes, 236 upstream tests, plus the repro and the new test); this run had no -Ptests.asserts, so it ran without assertions, the default for seed DEADBEEF.
  • ecjLint and checkGoogleJavaFormat pass for lucene/suggest with both tests in place; both diffs apply cleanly to a clean tree.
  • Through the classpath runner the fix shadowed onto the stock jars passes the repro 3 of 3; in the hunt, the finder's, the independent reproducer's, the cross-model reviewers' and the adjudication's tests passed with this change or with an independently written equivalent of it.
  • Not run: other modules (nothing outside lucene/suggest uses the class); no benchmark (the change only passes a flag).

Also say "by default" in FSTCompletionBuilder's class javadoc (with the fix; not run)

"If there is an exact match in the automaton, it is returned first on the results list (even with by-weight sorting)" (FSTCompletionBuilder.java:77-78) describes the default FSTCompletion that build() returns; adding "by default" removes the only text that reads as the opposite contract.

Trade-offs

Doc-only.

Add the regression test to TestFSTCompletion (with the fix; validated)

fix-with-test.diff adds TestFSTCompletion#testExactMatchFirstFalse: build and store/load with false, assert [star wars/6, start/3, star/0]. It fails on stock main and passes with the fix.

Trade-offs

None; no upstream test builds FSTCompletionLookup with false today.

Document the current behaviour instead (not recommended)

Change the javadoc to say that false only affects alphabetical lookups.

Trade-offs

Makes the argument a no-op on distinct keys and keeps the regression from FSTLookup. Note also open #11576, which proposes removing the alphabetical mode: without the fix, the flag would then reach no view at all.

Downstream note

By code reading only, Solr's FSTLookupFactory passes its exactMatchFirst setting into this constructor, but Solr's SuggestComponent asks for alphabetical lookups, which the bug does not reach.

How it was found

A bounded-exhaustive differential check ran the suggester Lookup implementations (all except FreeTextSuggester) on the real jars against a reference written from each one's javadoc. For FSTCompletionLookup with exactMatchFirst=false it found 265,860 order mismatches each for 3 and 10 buckets and none with true; a smaller exhaustive run with the fix found none. Reading build() and load() gave the cause.

The method in full190 words

Method: bounded-exhaustive differential translation validation on the stock jars (semi-formal tier), in a hunt unit that compared the suggest module's Lookup implementations (all except FreeTextSuggester).

  • The harness builds every implementation from the same dictionary and compares each lookup with an independent reference written from that class's javadoc; for FSTCompletionLookup: bucket descending, then UTF-8 order, the exact match first only when exactMatchFirst is true.
  • Scope: every set of 1-3 distinct keys over the 39 strings of length 1-3 over {a, b, c}, with weights {0, 1, 2, 2^31-1}: 596,908 dictionaries, 7,759,804 builds and 4,763,325,840 lookups, assertions off. The class "exact=false, order" appeared 265,860 times for 3 buckets and as often for 10, never for true or 1 bucket. With the fix shadowed onto the jar (141,904 dictionaries including 4-key sets, about 1.13 billion lookups) it appeared 0 times.
  • The class was reduced to a public-API JUnit (found 2026-10-27 on main ea57360912ed, whose lucene/suggest equals a9779ef3c193), then checked by an independent reproducer, an intended-behaviour devil's advocate, two executed reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a history and prior-report follow-up and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27) in GitHub issues, PRs and commits, the LUCENE and SOLR JIRA archives, the Lucene and Solr lists, downstream repositories, Stack Overflow and the web, nor in the changed files of all 409 open PRs. The file is unchanged on upstream main and branch_10x.

Searches4 searches

Verdict new: no report by anyone. Keyword search cannot prove absence; GitHub search does not index every review comment, and private reports are invisible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; Lucene and Solr lists; JIRA archive; the web2026-10-27two runs of the hunt's prior-report tool, 76 queriesno report; related only: #11328, #4788
JIRA (LUCENE, SOLR), lists, downstream GitHub, Stack Overflow2026-10-279 JQL queries and the comments of 14 issues; 7 list searches, 72 mails read; 11 searches in apache/solr, apache/lucene-solr, Lucene.NET, Elasticsearch, OpenSearch; 3 Stack Overflow searchesbackground only: SOLR-2378, SOLR-2762, SOLR-2888 (the introducing change), PR #844; solr-user@ threads about the flag set to true
all 409 open apache/lucene PRs2026-10-27changed-file listsnone touches FSTCompletionLookup, FSTCompletion or FSTCompletionBuilder; open issue #11576 matters for the fix design
registry re-check2026-10-27GitHub search for exactMatchFirst and FSTCompletionLookup, issues since 2026-10-20nothing new

History

Born with the class in SOLR-2888 (92de7e88486, 2011-12-01; branch_3x d3e380f824a, first release 3.6.0), which replaced FSTLookup, a class that honoured the flag. Executed: 3.6.0 to 10.5.2 (23 releases) and main fail; 3.5.0 has no such class. Never fixed.

Versions tested7 rows

Introduced by SOLR-2888, "FSTSuggester refactoring: utf8 storage, external sorts (OOM prevention), code cleanups" (svn era, no PR): trunk 92de7e884868 (svn r1209265, 2011-12-01) and the branch_3x backport d3e380f824a5 (svn r1232289, 2012-01-17), which shipped first, in 3.6.0. The commit created FSTCompletionLookup; both defect sites are in its first version; its 21 JIRA comments never mention exact-match handling. The exactMatchFirst=false javadoc followed three days later in 3408defc8db ("JavaDoc clarifications"), so no release has the code without the contract.

It is a regression of that refactoring: FSTLookup, deprecated in 3.6 and removed in 4.0, applied the flag on the by-weight path. All 126 release tags with the class build the by-weight view the same faulty way (the surrounding lines differ between versions), the 9 untagged 8.x releases 8.8.2 to 8.11.4 were executed instead (135 releases counting the 4.0.0-alpha and 4.0.0-beta prereleases, 133 final releases), and FSTCompletionLookup.java is byte-identical on upstream main a95c754ee003, branch_10x and branch_10_6 (fetched 2026-10-26).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe compact JUnit fails 2 of 3 through gradle, assertions on and off, and through the classpath runner with -ea and -da
10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedthe compact JUnit fails 2 of 3 (10.5.2 also with -da); 9.1.0 is before the 9.2 stream rewrite of FSTCompletion
9.0.0, 8.8.2-8.11.4, 7.7.3, 6.6.6, 6.0.0, 5.5.5, 4.10.4, 4.0.0 affectedmain() repro: build and load return [star/0, star wars/6, start/3]; the control passes
3.6.0, 3.6.1, 3.6.2 first releaseFSTCompletionLookup(10, false) returns [star/0, star wars/6, start/3]
3.5.0, 3.6.0, 3.6.2 FSTLookup predecessor classFSTLookup(10, false) returns [star wars, start, star]: the flag is honoured
3.5.0 no classcompile error: FSTCompletionLookup does not exist
fix (scratch worktree at a9779ef3c193) not affectedthe repro and the new TestFSTCompletion test pass, assertions on and off; the whole suggest module passes (240 tests, 0 failures)

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; two executed reviews by a model from another family (OpenAI GPT-5.6) rated it real with a weak intended argument; an adjudication re-ran it with new seeds; this doc's gradle runs reproduce it on a9779ef3c193 and validate the fix.

Checks and verdicts296 words

Every executing check wrote or re-ran its own JUnit on stock main; every control passed, and every fix check passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive differential, then a public-API JUnit: fails 2 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; 40 of 60 over 20 iterations; passes with the fix.
cross-model review, refute lens (OpenAI GPT-5.6) real, highIts own JUnit fails 2 of 4 on main (-ea, -da), its 2 controls pass, the fix passes; noted the load-path wrinkle.
independent reproducer realOwn JUnit from the claim only, another dictionary: fails 3 of 7 on main, 10.5.2 and 9.12.3; a random differential finds 1,669 mismatches, 0 with true; its own fix passes 7 of 7.
cross-model review, intended lens (OpenAI GPT-5.6) real, high; intended argument weakIts own JUnit fails 2 of 5 on a9779ef3c193 (-ea, -da) and 10.5.2, 3 controls pass, passes 5 of 5 with the fix.
intended-behaviour devil's advocate real (high for build, moderate-to-high for load)Built and rejected the works-as-designed case; showed true and false give identical results on stock; executed the 3.5.0 to 3.6.0 regression.
adjudication confirmed, highRe-ran the reproducer's and the finder's tests with new seeds (3 of 7 and 2 of 3 fail) and the fix check (7 of 7 pass).
Follow-up and credibility audit new; not security-sensitive; keepHistory main() runs on 22 releases (23 releases executed in all, with 9.1.0 by JUnit) and the predecessor class, prior-report searches, a security screen, and fresh-seed re-runs on a9779ef3c193.
this doc reproduced; fix validatedCompact JUnit fails through gradle and the classpath runner on main and 4 releases; the fix passes it, the new regression test and the suggest module.

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

Finding record

LFV-706, snapshot of 2026-11-06

Registry title
FSTCompletionLookup ignores exactMatchFirst=false for by-weight lookups after build() and load(), so an exact match is always ranked first
Area
suggest / FST completion suggester (FSTCompletionLookup build/load exactMatchFirst wiring)
Priority
Low, rank 384: Ranking-only, and it needs the non-default exactMatchFirst=false in the experimental FSTCompletionLookup.
Impact class
silent wrong results
Impact note
Applications that build the opt-in, @lucene.experimental FSTCompletionLookup with the non-default exactMatchFirst=false (for example to rank suggestions purely by popularity) and then call lookup(key, true, num) after build() or after load() get the exact match first whatever its weight bucket, with no error: when num covers all completions only the order is wrong, and when num is small the exact match pushes out the lowest-ranked correct entry (top-1 can be the least popular entry); alphabetical lookups and the default exactMatchFirst=true are unaffected, no scale is needed, and every release from 3.6.0 through 10.6.0 (10.6.0 by reading) and main is affected (by code reading, Solr's FSTLookupFactory exposes the setting, but its SuggestComponent calls the alphabetical lookup(token, false, count), which the bug does not reach).
Summary
You build an FSTCompletionLookup(dir, "fst", 10, false) from star (weight 1), star wars (100) and start (50) and ask lookup("star", true, 3) for the most popular completions first. You expect [star wars/6, start/3, star/0], because the constructor javadoc says that with exactMatchFirst=false exact matches 'appear in the order of discretized weight and alphabetical within the bucket'. Instead you get [star/0, star wars/6, start/3], with no error, after build() and equally after store() and load(): both paths create the internal by-weight FSTCompletion with its exact-first default, and the flag reaches only the alphabetical completion, where it changes nothing.
Introduced
92de7e884868f91b42689aa344c51b1b8aa8fdbb (trunk, svn r1209265, 2011-12-01) and its branch_3x backport d3e380f824a59857c3cd72e7f789e6f5682e7853 (svn r1232289, 2012-01-17; the backport shipped first, in 3.6.0), SOLR-2888 'FSTSuggester refactoring: utf8 storage, external sorts (OOM prevention), code cleanups' (an svn-era JIRA change, no pull request; its 21 comments never mention exact-match handling). The commit created FSTCompletionLookup with build() keeping higherWeightsCompletion = builder.build() and load() using the one-argument new FSTCompletion(fst), both exact-first by default, while only normalCompletion received exactMatchFirst; the pre-built-completion constructor of the same commit passes the flag to both [read: git log --follow, git log -L on the build() line (only 92de7e88486), git blame -w -M -C; both defect sites are in the class's first version, so no commit-level bisection was needed] [exec: release boundary, the predecessor FSTLookup honours exactMatchFirst=false on 3.5.0, 3.6.0 and 3.6.2, FSTCompletionLookup ignores it on 3.6.0]. The exactMatchFirst=false javadoc followed three days later in 3408defc8db (2011-12-04, 'JavaDoc clarifications'), so no release has the code without the contract (2011-12-01 (trunk); 2012-01-17 (branch_3x backport))
First affected version
3.6.0 [exec], the first release that contains FSTCompletionLookup at all (3.5.0: class absent, compile error [exec]); the class it replaced, FSTLookup (deprecated in 3.6, removed in 4.0), honours exactMatchFirst=false on the same input [exec on 3.5.0, 3.6.0 and 3.6.2], so this is a regression of the refactoring, and no release of FSTCompletionLookup ever honoured the flag on this path
Affected versions
every release that ships FSTCompletionLookup: 3.6.0-3.6.2 and 4.0.0-alpha through 10.5.2 (135 releases, including 8.8.2-8.11.4 from branch_8x), plus main a9779ef3c193; never fixed. Executed, all failing with the control passing: 3.6.0, 3.6.1, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (follow-up main() repros, 10.5.2 also -da), 9.1.0 (the intended-behaviour check, before the 9.2 stream rewrite of FSTCompletion), main with -ea and -da [exec]; the other releases by reading every release tag of the history clone [read]. FSTCompletionLookup.java is byte-identical on upstream origin/main a95c754ee003, branch_10x and branch_10_6 (fetched 2026-10-26) [read], so 10.6.0 (released 2026-10-29 from branch_10_6) is affected too. Executed only on aarch64 Linux with JDK 25.0.3; the defect is pure-Java wiring, so the platform does not matter
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new): two scripted prior-report searches (76 queries over GitHub issues, PRs and commits, downstream repositories, the Lucene and Solr mailing lists, the JIRA archive and the web), 9 JQL queries over LUCENE and SOLR, 7 list searches with 72 mails read, 11 downstream GitHub searches (apache/solr, apache/lucene-solr, Lucene.NET, Elasticsearch, OpenSearch), 3 Stack Overflow searches, the changed-file lists of all 409 open apache/lucene PRs (none touches FSTCompletionLookup.java, FSTCompletion.java or FSTCompletionBuilder.java) and the file history; the finder, the independent reproduction and the intended-behaviour check searched with other phrasings and found nothing either; re-check (2026-10-27, gh read-only): repo:apache/lucene exactMatchFirst still gives only #11328 and #4788, no open PR mentions FSTCompletionLookup, and no issue naming it was created since 2026-10-20. Not fixed upstream: origin/main a95c754ee003 and branch_10x still create the by-weight completion with the defaults. Related, none a report of this bug: #11328 (LUCENE-10292) quotes the build() lines only in a thread-safety comment; #4788 (LUCENE-3714) mentions exactMatchFirst only as a benchmark setting; SOLR-2378 added exact-match promotion for the weighted mode to the old FSTLookup; SOLR-2762 and a review remark on PR #844 were exact-match promotion bugs with the flag true in other code (fixed in 3.5 and 9.2); SOLR-2888 is the introducing change (21 comments, none on the flag); open #11576 (LUCENE-10540) proposes removing the alphabetical completion, the only one that receives the flag today; solr-user threads about exactMatchFirst concern the flag true with other suggesters or SolrCloud shard merging.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the compact JUnit and a regression test added to TestFSTCompletion, through gradle in a clean worktree, stock and with the fix, assertions on and off, plus the whole lucene/suggest module with the fix (2026-10-28); through the hunt's classpath runner, the compact JUnit on main (-ea, -da, fix shadowed, 10 iterations), 10.5.2 (-ea, -da), 10.0.0, 9.12.3 and 9.1.0. The hunt's checks ran its own repros on main ea57360912ed (identical lucene/suggest), on a9779ef3c193, and main() repros on 22 releases from 3.6.0 to 10.5.2 (23 releases executed in all, with 9.1.0 by JUnit), plus the predecessor FSTLookup on 3.5.0, 3.6.0 and 3.6.2.

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:71 (at a9779ef3c193) (@lucene.experimental), :105 (no-arg constructor 'should only be used to read a previously saved suggester'), :110-118 (two-argument constructor: exact matches promoted), :120-136 (four-argument constructor; :126-128 '@param exactMatchFirst If true exact matches are promoted to the top of the suggestions list. Otherwise they appear in the order of discretized weight and alphabetical within the bucket.'), :138-154 (pre-built-completion constructor, same javadoc, passes exactMatchFirst to both completions at :152-153), :241-244 (build: higherWeightsCompletion = builder.build(); only normalCompletion gets exactMatchFirst), :262-272 (lookup uses higherWeightsCompletion when higherWeightsFirst), :303-309 (load: one-argument FSTCompletion constructor at :305-306)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java:102-122 (at a9779ef3c193) (three-argument constructor: exactFirst 'Find and push an exact match to the first position of the result list if found'), :124-131 (one-argument constructor 'Defaults to higher weights first and exact first', this(automaton, true, true))
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionBuilder.java:77-78 (at a9779ef3c193) (class javadoc: an exact match 'is returned first on the results list (even with by-weight sorting)', describing the builder's default), :190-197 (build() returns new FSTCompletion(automaton))
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:116-140 (at a9779ef3c193) (testRequestedCount: FSTCompletion(fst, true, false) does not promote a low-weight exact match); TestWFSTCompletion.java:154-157 (testNonExactFirst, sibling honours false); no upstream test constructs FSTCompletionLookup with exactMatchFirst=false (grep of lucene/suggest/src/test)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:96-100, 130-136, 152-153, 222-227, 241-244, 262-281, 303-310
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java:102-131, 197-210, 233-283; lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionBuilder.java:190-198; lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:193-195
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:71, 116-118, 287-290
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:105, 126-128, 142-144, 152-153; lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java:109-110; lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionBuilder.java:77-78
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:116-144; lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestWFSTCompletion.java:154-179
Suggested fix
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:146-154; lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionBuilder.java:77-78

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

Repro files

About the diagrams

Made in parallel with this doc from a probe run on main a9779ef3c193 and 10.5.2 (stock, and main with the fix); the values on screen come from the probe, which uses the same three entries as the repro.

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.