All findings · Lucene finding · LFV-479 · fstcompletionlookup-negative-weights-ranked-first

FSTCompletionLookup sorts weights as unsigned bytes, so negative weights get the highest buckets and are suggested before positive ones

FSTCompletionLookup sorts weights by their raw bytes as unsigned numbers, so negative weights are treated as larger than every weight of 0 or more and take the top buckets: built from apple (weight 10) and apricot (weight -10), lookup("ap") suggests apricot first, with no error. Every release from 3.6.0 to 10.6.0 (10.6.0 by reading) and main.

Summary

Priority
Low, rank 382Ranking-only, in the opt-in experimental FSTCompletionLookup, and only with weights that mix signs.
Impact
silent wrong results
Since
3.6.0 (LUCENE-3807, 2012)introduced 2012-03-02 · over 14 years
Affected
3.6.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
end-to-end translation validation of the suggesters on the real jar

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 does not keep exact weights. build() sorts the entries by weight, splits them into 10 equal ranges (buckets) and suggests the highest bucket first. The sort compares each weight's 4 bytes as unsigned numbers, and in that order negative ints look bigger than positive ones.

Summary card titled FSTCompletionLookup suggests negative-weight entries before positive ones. Setup: new FSTCompletionLookup(dir, "fst") built from apple = 10 and apricot = -10, then lookup("ap", true, 2). Step 1: build() writes each weight as 4 big-endian bytes in front of its text, 10 as 00 00 00 0A and -10 as FF FF FF F6; every negative int starts with a byte of 0x80 or more. Step 2: OfflineSorter compares the lines as unsigned bytes, so negative weights sort last: line 0 is apple, line 1 apricot. Step 3: bucket = line * buckets / inputLineCount, so apple gets bucket 0 and apricot bucket 5. Step 4: lookup() returns the highest bucket first, so the result is [apricot/5, apple/0], with no error. Expected: apple, then apricot. Actual, in every release 3.6.0 to 10.5.2 and on main: apricot/5, apple/0. Fix, one line: flip the sign bit, output.writeInt(Integer.reverseBytes(encodedWeight ^ 0x80000000)), which gives [apple/5, apricot/0]; the stored format does not change. Side notes: a -1 used to demote an entry puts it first (apple 10, apricot 5, apron -1 give [apron/6, apple/3, apricot/0]); within each sign the order is right and the same entries match; WFSTCompletionLookup and AnalyzingSuggester reject negative weights; lookups saved before a fix keep their wrong buckets until rebuilt. Two number lines compare the numeric order of the weights (MIN, -10, -1, 0, 10, MAX) with the sorter's byte order (0, 10, MAX, then MIN, -10, -1).
One concrete example, apple (10) and apricot (-10): build() writes each weight as 4 bytes, the sorter compares them unsigned, so FF FF FF F6 (-10) sorts after 00 00 00 0A (10), gets bucket 5 and is suggested first. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You build an FSTCompletionLookup with the default settings (10 buckets) from two suggestions, apple with weight 10 and apricot with weight -10, and call lookup("ap", true, 2) for the best completions first.
  2. You expect apple first: higher weights mean better suggestions, the javadoc says buckets come from "sorting the input by weight", and the build accepts any int weight.
  3. To sort, build() writes each weight as 4 bytes and compares them as unsigned bytes: 10 is 00 00 00 0A, -10 is FF FF FF F6. So -10 sorts after 10, as if it were larger, and gets bucket 5 while apple gets bucket 0.
  4. You get [apricot/5, apple/0]. Negative weights take the top buckets (with at most 10 entries strictly above every non-negative weight; with more entries, those at the sign boundary can share a bucket); within each sign the order is right. An entry demoted to -1 is suggested first: apple 10, apricot 5, apron -1 give [apron/6, apple/3, apricot/0].
  5. No error; the full set of matches is unchanged but the top-k changes, and a stored lookup keeps the wrong order after reload until it is rebuilt.

Who is affected

Applications that build the opt-in, experimental FSTCompletionLookup from weights that mix signs (net votes, a signed score, -1 to demote an entry), through any weight source. The defaults suffice. Order and top-k change silently; the full set of matches does not. Rare by accident: frequencies and counts are never negative.

Mechanism, terms and numbers594 words

FSTCompletionLookup turns weights into a few buckets (10 by default) and suggests higher buckets first. To find the buckets, build() sorts the entries by weight and cuts the sorted list into equal ranges, as its javadoc says. But it sorts each weight's four big-endian two's-complement bytes as unsigned bytes, where every negative int (first byte 0x80 or more) comes after every non-negative int. So the negative weights take the highest buckets: built from {apple: 10, apricot: -10}, lookup("ap", true, 2) returns [apricot/5, apple/0], with no error. encodeWeight accepts every int and InputIterator#weight() says "higher numbers mean better suggestions". Within each sign the order is right and the full set of matches is unchanged; only the sign boundary is inverted, which changes the top-k. The sign handling was dropped in 2012 (LUCENE-3807), before the class was first released, so every release from 3.6.0 to 10.6.0 (10.6.0 by reading) and main is affected. Flipping the sign bit of the sort key fixes it, with no format change.

Terms

  • Weight: the number an InputIterator (or a Dictionary such as FileDictionary or DocumentDictionary) attaches to each suggestion; "higher numbers mean better suggestions" (InputIterator.java:35). A signed long; FSTCompletionLookup accepts any value in the int range (encodeWeight, FSTCompletionLookup.java:254-259).
  • Bucket: FSTCompletionLookup keeps no exact weights. It sorts the entries by weight, cuts the sorted list into buckets equal ranges (10 by default) and stores each suggestion under its range number. lookup(key, true, num) returns the highest bucket first, and LookupResult.value is the bucket number, not the weight (:278).
  • Unsigned byte order: the offline sorter compares byte strings as unsigned bytes (0x00 < ... < 0xFF). A negative int in two's complement starts with a byte of 0x80 or more, a non-negative one with 0x7F or less.

What happens, step by step

  1. For each entry build() writes one line: the weight as 4 big-endian two's-complement bytes (output.writeInt(Integer.reverseBytes(encodedWeight)), FSTCompletionLookup.java:187-189, through the little-endian ByteArrayDataOutput.writeInt), then the suggestion bytes. 10 becomes 00 00 00 0A, -10 becomes FF FF FF F6.
  2. OfflineSorter sorts the lines with its default comparator, BytesRef#compareTo, which is Arrays.compareUnsigned (:199; OfflineSorter.java:195, :202-206; BytesRef.java:161-162). The order is 0, 1, ..., Integer.MAX_VALUE, Integer.MIN_VALUE, ..., -1.
  3. Buckets follow that order: bucket = line * buckets / inputLineCount (:226), equal weights share a bucket (:223-224). With 2 entries, line 0 (apple, 10) gets bucket 0 and line 1 (apricot, -10) bucket 5.
  4. Higher buckets are "presented before suggestions placed in smaller buckets" (FSTCompletionBuilder.java:169-171; FSTCompletion.java:147), so lookup("ap", true, 2) returns [apricot/5, apple/0].

Within the negative weights, and within the non-negative weights, unsigned order equals numeric order, so those comparisons are right: {apple: 10, apricot: 5} and {apple: -1, apricot: -10} both give [apple/5, apricot/0]. A stale comment sits right above the loop (:174-175): "For now, assume they are non-negative. If negative floats are allowed some trickery needs to be done to find their byte order."

More examples (executed)

weightsresult
apple 10, apricot 5, apron -1 (demotion)[apron/6, apple/3, apricot/0]; with the fix [apple/6, apricot/3, apron/0]
Integer.MAX_VALUE vs Integer.MIN_VALUE[xmin/5, xmax/0]
-10..9 (20 weights)weight 0 gets bucket 0, weight -10 bucket 5
DocumentDictionary over a signed numeric field: apple 100, apricot 50, april 20, aphid -1[aphid/7, apple/5, apricot/2, april/0]; with the fix [apple/7, apricot/5, april/2, aphid/0]
{cat: 10, car: -10, cab: 3}, then store() and load()[car/6, cat/3, cab/0] before and after
Exposure and likelihood445 words
  • Who is exposed. Users of FSTCompletionLookup (opt-in, @lucene.experimental, FSTCompletionLookup.java:71) whose weights mix negative and non-negative values. Every public weight source can deliver them: a custom InputIterator, FileDictionary (weights parsed as a signed long, FileDictionary.java:207-213), DocumentDictionary over a signed numeric field (executed) or DocumentValueSourceDictionary. The default constructor and the default 10 buckets suffice.
  • What goes wrong. Negative-weight suggestions come before non-negative ones (with more entries than buckets, the entries at the sign boundary may instead share a bucket), so the top-k changes; an entry an application demotes with -1 is suggested first. The full set of matching suggestions is unchanged (0 mismatches in 168 full-set lookups over a 3,000-entry mixed-sign dictionary; the top 5 differed from an order-preserving control for 84 of 84 prefixes). One probe on 100,000 entries saw the same result counts and FST ramBytesUsed() as that control, with comparable single-run timings (not a benchmark). Nothing throws (executed on main with -ea and -da, and on 10.5.2).
  • When it shows. In lookup(key, true, num) (higher weights first). With higherWeightsFirst = false the results are sorted by text when there is more than one bucket (FSTCompletion.java:202-206), though LookupResult.value and get() still report the inverted buckets (:278, :287-288). With the default exactMatchFirst, an exact match of the key is returned first whatever its bucket (FSTCompletion.java:235-280); no example here has one (by reading).
  • It persists. The buckets are baked into the FST that store() writes (:293-300), so a stored lookup keeps the inverted order after load() until it is rebuilt on a fixed version (executed).
  • Scale. Every mixed-sign build sorts the negative weights above the non-negative ones; it shows as an inversion whenever the bucket ranges separate them, which is always the case with at most buckets entries (the two-entry repro fails every time). With more entries a bucket is a range of lines: 19 entries with weights 0 to 18 plus one of weight -1 put the -1 entry in bucket 9 with weight 18 (above weights 0 to 17); 100 entries, one of weight 5 and 99 of weight -1, all land in bucket 0, so no inversion shows (executed on main). A 600-trial random property check (1 to 60 random weights per build) found at least one bucket inversion in 206 of 206 mixed-sign builds and in 0 of 394 single-sign builds; with the fix in 0.
  • Likelihood. Rare by accident: common weight sources (term or document frequencies, popularity counts) are never negative, and WFSTCompletionLookup, AnalyzingSuggester and SuggestField reject negative weights. No user report was found since 3.6.0 (2012), though a silent mis-ranking may go unnoticed.

Minimal repro

The test shows that FSTCompletionLookup sorts weights as unsigned bytes, so a negative weight lands in the highest bucket and is suggested before a positive one.

TestFSTCompletionLookupNegativeWeights.java (JUnit test)38 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 java.util.List;
import org.apache.lucene.search.suggest.FileDictionary;
import org.apache.lucene.search.suggest.Lookup.LookupResult;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFSTCompletionLookupNegativeWeights extends LuceneTestCase {

  public void testNegativeWeightRankedBelowPositive() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      FSTCompletionLookup lookup = new FSTCompletionLookup(dir, "fst");
      lookup.build(new FileDictionary(new StringReader("apple\t10\napricot\t-10\n")));
      List<LookupResult> results = lookup.lookup("ap", true, 2);
      // expected apple (weight 10) before apricot (weight -10)
      assertEquals("results " + results, "apple", results.get(0).key.toString());
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-479/, 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.TestFSTCompletionLookupNegativeWeights
Expected vs actual
Expected apple (weight 10) before apricot (weight -10) for lookup("ap", true, 2); actual [apricot/5, apple/0], with -ea and -da.

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

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
    => org.junit.ComparisonFailure: results [apricot/5, apple/0] expected:<ap[ple]> but was:<ap[ricot]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.apache.lucene.search.suggest.fst.TestFSTCompletionLookupNegativeWeights.testNegativeWeightRankedBelowPositive(TestFSTCompletionLookupNegativeWeights.java:35)

Why it looks unintended

  • The javadoc assigns buckets by "sorting the input by weight"; InputIterator says higher weights are better suggestions.
  • encodeWeight explicitly accepts every int (its lower bound is Integer.MIN_VALUE), while WFSTCompletionLookup, changed in the same 2012 diff, kept a value < 0 check.
  • Before that change the class ordered negative weights correctly (bisected), and upstream's own test builds it from weight -1.
The full argument285 words
  • The contract. Class javadoc (FSTCompletionLookup.java:59-61): "The range of weights for bucketing/ discretization is determined by sorting the input by weight and then dividing into equal ranges." :63-65: the "overall distinction between \"classes\" of weights will be preserved regardless of the distribution of weights." InputIterator.java:35: "A term's weight, higher numbers mean better suggestions." No sign restriction anywhere in the javadoc, CHANGES or MIGRATE.
  • The input is accepted. encodeWeight throws only outside Integer.MIN_VALUE..MAX_VALUE (:254-259). The LUCENE-3807 diff that introduced it wrote that explicit Integer.MIN_VALUE bound, while WFSTCompletionLookup.encodeWeight, changed in the same diff, kept its existing value < 0 || value > Integer.MAX_VALUE check (today WFSTCompletionLookup.java:262-267). No source records why the two bounds differ.
  • Upstream tests. TestFSTCompletion#testLargeInputConstantWeights builds an FSTCompletionLookup from 5,000 entries of weight -1 (TestFSTCompletion.java:185, :192); TestPersistence asserts bucket values grow with the weight (TestPersistence.java:91-102), for weights 0..n. No test mixes signs.
  • History. SOLR-2888 (92de7e88486, 2011) added the class with FloatMagic.toSortable, "a directly sortable unsigned integer" that sorted negatives below non-negatives. LUCENE-3807 (f303bcd4650, 2012; backport 71bd966a92e) switched weights to long, used (int) value and deleted FloatMagic. Its CHANGES entry says that weights became 64-bit signed integers (it also covers the disk-based sort and the move to CharSequence) and nothing about negative-weight order; its 38 comments never mention negative weights. The parents pass the FileDictionary probe, the two commits fail it, and each passes with the one-line sign-bit patch.
  • Siblings that were checked. WFSTCompletionLookup, AnalyzingSuggester and SuggestField reject negative weights loudly; TSTLookup and AnalyzingInfixSuggester accept them and order them by numeric value (tst/TSTLookup.java:195-196, analyzing/AnalyzingInfixSuggester.java:166). Other suggesters were not checked for this.
The devil's-advocate check235 words

The strongest works-as-designed case (intended-behaviour checker, adjudication, cross-model intended lens): the comment "For now, assume they are non-negative" (:174-175), sibling suggesters that reject negative weights, @lucene.experimental, and lossy bucketing.

It fails: the comment is internal, never enforced, and was stale from the day it was written (it came with SOLR-2888 in the same commit as FloatMagic, which did order negatives correctly, and it still talks about floats); encodeWeight explicitly admits negative ints (its lower bound is Integer.MIN_VALUE) while WFSTCompletionLookup rejects them; the module has no single convention; @lucene.experimental is about API stability, not wrong results; and the lossy-bucketing caveat is about merging nearby weights, while the same paragraph promises the classes are preserved. A reviewer may add that the class javadoc sends users who need "fine-grained control over which weights are assigned to which buckets" to FSTCompletion or TSTLookup (:67-68), which is about precision within the lossy scheme, not order, and that FSTCompletionBuilder.add requires a non-negative bucket (FSTCompletionBuilder.java:169-176), which constrains the bucket number build() computes from the line number, not the weight. Silently inverting accepted input is not a design; rejecting it would be, and a committer may reasonably prefer that to supporting negative weights. The cross-model refute lens said "not real" at low confidence only because it could not run the test; it agreed with the root cause and that the inputs are in contract.

Suggested fix

Recommended Flip the sign bit of the 4-byte sort key

Flip the sign bit of the sort key: Integer.reverseBytes(encodedWeight ^ Integer.MIN_VALUE) in build(), so unsigned byte order equals numeric order, and drop the stale comment. No format change (the key lives only in the temporary sort file). The repro passes, and all of lucene/suggest passes (238 tests).

-    // Push floats up front before sequences to sort them. For now, assume they are non-negative.
-    // If negative floats are allowed some trickery needs to be done to find their byte order.
+    // Write each weight before its sequence, so that sorting the lines sorts them by weight.
 ...
         final int encodedWeight = encodeWeight(iterator.weight());
-        // write bytes for comparing in lexicographically order
-        output.writeInt(Integer.reverseBytes(encodedWeight));
+        // write big-endian bytes for comparing in lexicographic (unsigned) order; flipping the sign
+        // bit makes that order equal to the numeric order of the weights, negative ones included
+        output.writeInt(Integer.reverseBytes(encodedWeight ^ Integer.MIN_VALUE));
All options and trade-offs3 options · 574 words

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

Flip the sign bit of the 4-byte sort key (validated by the checks) (recommended)

In FSTCompletionLookup.build, write Integer.reverseBytes(encodedWeight ^ Integer.MIN_VALUE) instead of Integer.reverseBytes(encodedWeight), and replace the stale "assume they are non-negative" comment. XOR with 0x80000000 maps Integer.MIN_VALUE..MAX_VALUE monotonically onto 0x00000000..0xFFFFFFFF: -10 becomes 7F FF FF F6, 10 becomes 80 00 00 0A. The value read back (:220) is only compared for equality (:223), and XOR is a bijection, so ties still share a bucket. FloatMagic applied the same sign-bit XOR to non-negative floats before 2012. Integer.MIN_VALUE is 0x80000000: the diagram writes the same constant in hex.

-    // Push floats up front before sequences to sort them. For now, assume they are non-negative.
-    // If negative floats are allowed some trickery needs to be done to find their byte order.
+    // Write each weight before its sequence, so that sorting the lines sorts them by weight.
 ...
         final int encodedWeight = encodeWeight(iterator.weight());
-        // write bytes for comparing in lexicographically order
-        output.writeInt(Integer.reverseBytes(encodedWeight));
+        // write big-endian bytes for comparing in lexicographic (unsigned) order; flipping the sign
+        // bit makes that order equal to the numeric order of the weights, negative ones included
+        output.writeInt(Integer.reverseBytes(encodedWeight ^ Integer.MIN_VALUE));

Trade-offs

  • No index or file-format change: store() writes the entry count and the FST (:293-300); the key exists only in the temporary sort file.
  • Lookups stored by affected versions keep their inverted buckets until they are rebuilt.
  • Builds whose weights all have the same sign get the same buckets as today (unsigned order within one sign is unchanged; by reading).
  • No cost: one XOR per entry at build time.

What the checks validated

  • Stock main a9779ef3c193 (gradle, seed DEADBEEF): the repro fails 1 of 2 with assertions on and off ("got [apricot/5, apple/0]"); TestFSTCompletion and TestPersistence pass (16 tests, 1 @Nightly skipped).
  • With the fix: the repro passes 2 of 2 with assertions on and with assertions off; TestFSTCompletion plus TestPersistence pass (16 tests, 0 failures); all of :lucene:suggest:test passes with the repro class added (32 classes, 238 tests, 0 failures, 7 skipped).
  • ecjLintMain, ecjLintTest and checkGoogleJavaFormat pass for suggest; fix.diff and fix-with-test.diff apply cleanly to a clean tree.
  • Through the classpath runner, the fix shadowed onto the main jars: 2 of 2 pass.
  • Earlier, in the hunt (the same sign flip, shadowed onto the stock jars): the finder's JUnit, the reproducer's 6 tests, the 600-trial property check (0 violations), the security probe and TestFSTCompletion plus TestPersistence (15 of 15) pass; the same patch makes both introducing commits pass.
  • Not run: other modules (outside lucene/suggest only CHANGES.txt names the class); no benchmark (one XOR per entry at build time).

Reject negative weights, like WFSTCompletionLookup (not run)

Change encodeWeight's bound to value < 0 || value > Integer.MAX_VALUE, as in WFSTCompletionLookup and AnalyzingSuggester.

Trade-offs

  • Loud instead of silent, and consistent with three sibling suggesters.
  • A behaviour change for callers whose negative weights work today (all-negative or constant weights order correctly); one bad entry fails the whole build; needs a CHANGES/MIGRATE note.
  • Breaks upstream TestFSTCompletion#testLargeInputConstantWeights, which builds from weight -1 (by reading).

Document that weights must be non-negative (not recommended)

A javadoc note only.

Trade-offs

Leaves the silent inversion in place for everyone who does not read it.

Downstream note

By code reading only (not executed), Solr's SolrSuggester (apache/solr 0d6b2e32f598) falls back to FSTLookupFactory, which builds an FSTCompletionLookup, when no lookupImpl is configured (other Solr suggester classes and versions not checked); its default dictionaries give non-negative weights, so it is reached only with a dictionary file, weight field or weight expression that can go negative.

How it was found

End-to-end translation validation of six suggesters on the real jar: 3,000 random sources, the FSTCompletionLookup ones with weights from {MIN, -5, -1, 0, 1, 5, MAX}, checked against the javadoc rule that a higher weight never gets a lower bucket. All 1,768 inversions per wrapper had a negative weight; with the sign-bit fix, 0. Code reading then found the cause.

The method in full197 words

Method: end-to-end translation validation on the stock jar (semi-formal tier), in a hunt unit on how the suggesters preserve the InputIterator (term, weight) tuples.

  • The harness built six suggesters (AnalyzingSuggester, FuzzySuggester, AnalyzingInfixSuggester, FSTCompletionLookup, WFSTCompletionLookup, TSTLookup) from 3,000 seeded random sources of 1-6 entries over 3 keys, passed through 5 InputIterator wrappers (90,000 builds and 292,350 lookups in all), and compared each result with a reference written from the javadoc (for FSTCompletionLookup: a key with a higher weight never gets a lower bucket). The FSTCompletionLookup sources drew weights from {Integer.MIN_VALUE, -5, -1, 0, 1, 5, Integer.MAX_VALUE}; the other suggesters used non-negative weight sets.
  • For FSTCompletionLookup it reported 1,768 bucket-order inversions per wrapper, every one in a source that contains a negative weight, and 0 with the sign-bit fix shadowed onto the jar.
  • The root cause was then located by reading build() and its history, and reduced to a two-test public-API JUnit. Found 2026-10-26 on main ea57360912ed; then checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a history, prior-report and security follow-up, and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26) in two runs of the hunt's prior-report tool, 62 manual GitHub searches, 8 JQL searches, 80 mailing-list and 16 web queries, and the changed files of all 409 open PRs. The introducing change's 38 comments never mention negative weights.

Searches5 searches

Verdict new: no report by anyone. One caveat: the in-code comment "For now, assume they are non-negative" is not a report, but a reviewer may point to it. Keyword search cannot prove absence; review comments and closed unmerged PRs by file are not searchable, and private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), Solr, Elasticsearch, OpenSearch; dev@, java-user@, issues@, commits@; the JIRA archive; the web2026-10-26two runs of the hunt's prior-report tool (61 and 44 queries: class and method names, "negative weight", bucket, signed/unsigned, FloatMagic, the comment text)no report; related only: #4880 (LUCENE-3807), #4788 (LUCENE-3714), #10089 (LUCENE-9047)
apache/lucene, apache/solr, Elasticsearch, OpenSearch, Lucene.NET and all of GitHub2026-10-2662 manual issue/PR searches, open and closed (FSTCompletionLookup, encodeWeight, FloatMagic, "negative weights" suggester, the exact comment text)nothing on point
all 409 open apache/lucene PRs2026-10-26changed-file listsnone touches FSTCompletionLookup.java, FSTCompletion.java or FSTCompletionBuilder.java
JIRA (LUCENE, SOLR) and the Lucene and Solr mailing lists2026-10-268 JQL searches; 80 list queries over 10 lists; comments of SOLR-2888, SOLR-2761, LUCENE-3618, LUCENE-3714, LUCENE-6339 and others read in fullno report; #7400 (LUCENE-6339): the document suggester rejects negative weights, a precedent for the reject option
the finder's and the verifiers' searches2026-10-26the finder's prior-report run (51 queries); direct GitHub searches by the reproducer and the devil's advocateno report

History

Introduced by LUCENE-3807 (f303bcd4650, 2012-03-02; backport 71bd966a92e, first in 3.6.0), which replaced a sign-correct encoding with (int) value. Bisected on both branches. Executed and failing on 3.6.0 through 10.5.2 and main; never fixed.

Versions tested6 rows

Introduced by LUCENE-3807 (now GitHub #4880, "Cleanup suggester API"; an SVN-era commit, so no PR): trunk f303bcd4650 "Cleanup Suggest / Lookup API" (2012-03-02) and the branch_3x backport 71bd966a92e "Cleanup Suggest API" (2012-03-07), which shipped first, in 3.6.0. It replaced output.writeInt(FloatMagic.toSortable(weight)) with output.writeInt(encodeWeight(weight)), encodeWeight = (int) value, and deleted FloatMagic, whose mapping had made unsigned byte order equal numeric order.

FSTCompletionLookup itself came with SOLR-2888 (92de7e88486, 2011-12-01), which ordered negative weights correctly but was never released; its released predecessor FSTLookup (3.5.0) also did. So no release of FSTCompletionLookup ever ordered negative weights correctly. Later rewrites of the same line in 9.0.0 (b84e0c272b6 LUCENE-9047, 0e1f9fcf31b LUCENE-10193) keep the same bytes. On 2026-10-28 the last change to the file on main, branch_10x and branch_10_6 was #14228 (2025-02-24, a cleanup) and on branch_9_12 #12951 (2024-02-04); branch_9x was tombstoned in 2024 (#13882), so the file no longer exists there.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe JUnit fails through gradle, assertions on and off, and through the classpath runner (-ea, -da); passes with the fix
main ea57360912ed affectedthe finder's, the reproducer's, the devil's advocate's, the adjudication's and the audit's tests fail, -ea and -da
10.5.2, 9.12.3, 9.0.0, 8.11.4 affected[apricot/5, apple/0]; the JUnits fail (on 9.0.0 only the FileDictionary probe ran; 10.5.2 and 9.12.3 re-run for this doc)
3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6 first release 3.6.0the FileDictionary probe gives [apricot/5, apple/0]; controls ordered correctly
f303bcd4650 (trunk) and 71bd966a92e (branch_3x) vs parents 2c94c522fd8, 2fa71777a72 bisectintroducing commits fail, parents pass ([apple/5.0, apricot/0.0]); each passes with the sign-bit patch
3.5.0 not affectedno FSTCompletionLookup (compile error); the predecessor FSTLookup orders negative weights correctly

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own repros, controls passing; reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication and an audit re-ran it with new seeds, and this doc's gradle runs reproduce it on a9779ef3c193.

Checks and verdicts385 words

Every executing check wrote or re-ran its own repro on stock main; every control passed and every fix check passed. The cross-model reviews were reading-only, so all execution evidence comes from the hunt's own runs.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTranslation-validation harness, then the two-test public-API JUnit: fails on main (-ea, -da), 10.5.2 and 9.12.3; the control and the fix pass; upstream TestFSTCompletion 13 of 13 with the fix.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real, low confidence (procedural)Could not run code; agreed with the root cause and that the inputs are in contract; pointed to the upstream test that builds from weight -1. A separate run re-ran the repro for the review: it failed.
cross-model review, intended lens (OpenAI GPT-5.6, reading only) real, mediumRated the internal comment plus the rejecting siblings a moderate counter-argument, outweighed by an undocumented, unenforced assumption with the full int range accepted.
independent reproducer realOwn 6-test JUnit: 3 of 6 fail on main (-ea, -da), 10.5.2, 9.12.3 and 8.11.4 ({cat:10, car:-10} -> [car/5, cat/0]; MAX vs MIN; -10..9); 3 controls pass; its own one-line fix passes 6 of 6.
root-cause review and property check (the cross-model review, OpenAI GPT-5.6 reading; runs by the harness) real, mediumRoot cause agrees. 600-trial property check: 206 of 206 mixed-sign trials violate bucket order, 0 of 394 single-sign, 0 with the fix; TestFSTCompletion and TestPersistence 15 of 15 with the fix.
intended-behaviour devil's advocate realBuilt and refuted the works-as-designed case; corrected the comment's history; its FileDictionary probe fails on 3.6.2 through main.
adjudication confirmed, highRe-ran the independent reproduction's JUnit with seed 5EED1234 (-ea, -da: 3 of 6 fail; with the independent reproduction's fix 0 of 6).
credibility audit keepRe-ran the finder's JUnit (fails -ea and -da, passes with the fix); own FileDictionary probe: {0,-1}, {MAX,MIN} and {100,50,-1} inverted, sorted controls correct.
this doc (gradle, main a9779ef3c193) reproduced; fix validatedGradle in a clean worktree at a9779ef3c193, seed DEADBEEF: the repro fails 1 of 2 with assertions on and off; with the fix it passes 2 of 2, and all of lucene/suggest passes (238 tests, 0 failures). The classpath runner gives the same on main, 10.5.2 and 9.12.3.

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

Finding record

LFV-479, snapshot of 2026-11-06

Registry title
FSTCompletionLookup sorts weights as unsigned bytes, so negative weights get the highest buckets and are suggested before positive ones
Area
suggest / FST completion suggester (FSTCompletionLookup.build weight bucketing)
Priority
Low, rank 382: Ranking-only, in the opt-in experimental FSTCompletionLookup, and only with weights that mix signs.
Impact class
silent wrong results
Impact note
Applications that build the opt-in, @lucene.experimental FSTCompletionLookup suggester (default constructor and default 10 buckets suffice; by code reading, Solr's suggester falls back to it when no lookupImpl is configured, and no use was found in Elasticsearch or OpenSearch) from weights that mix negative and non-negative values, for example a signed score or vote field, a negative weight used to demote an entry, or a FileDictionary or DocumentDictionary over signed numbers, get every negative-weight suggestion ranked above every non-negative one with no error, on every release from 3.6.0 through 10.6.0 (10.6.0 by reading) and on main; the order within the negative and within the non-negative weights is correct, the result set is unchanged, no scale is needed, and stored lookups keep the inverted buckets until they are rebuilt on a fixed version.
Summary
You build an FSTCompletionLookup from two suggestions, apple with weight 10 and apricot with weight -10, and ask lookup("ap", true, 2) for the best completions first. You expect apple first, because the class javadoc says buckets come from 'sorting the input by weight', InputIterator says 'higher numbers mean better suggestions', and encodeWeight accepts every int. Instead you get [apricot/5, apple/0], with no error: build() sorts the weights by their unsigned big-endian bytes, where every negative int (first byte 0x80 or above) comes after every non-negative int, so all negative weights land in the highest buckets and are suggested first.
Introduced
f303bcd4650ac96d88b84dade253fbf4cb407158 (trunk, SVN r1296268, 2012-03-02) and its branch_3x backport 71bd966a92e6abe6aefa929d07194ab1ce95ca0e (SVN r1297946, 2012-03-07; the backport shipped first, in 3.6.0), LUCENE-3807 'Cleanup Suggest / Lookup API' (now GitHub issue #4880; an SVN commit, no pull request): build() changed output.writeInt(FloatMagic.toSortable(weight)) to output.writeInt(encodeWeight(weight)) with encodeWeight = (int) value over the whole int range, and deleted FloatMagic, whose mapping (negatives to ~v, non-negatives to v ^ 0x80000000) had made unsigned byte order equal numeric order [read: git show, git blame] [exec: bisected on both branches; the parent commits 2c94c522fd8 (trunk) and 2fa71777a72 (branch_3x) pass, the introducing commits fail, and a one-line sign-bit patch on each introducing commit passes]. Later rewrites of the same line (b84e0c272b6 LUCENE-9047, 0e1f9fcf31b LUCENE-10193, both 9.0.0) keep the same bytes. FSTCompletionLookup itself came with SOLR-2888 (92de7e88486, 2011-12-01), which ordered negative weights correctly but was never released (2012-03-02 (trunk); 2012-03-07 (branch_3x backport))
First affected version
3.6.0 [exec], the first release that contains FSTCompletionLookup at all. No earlier release has the class (3.5.0: compile error [exec]); the released predecessor FSTLookup (3.5.0, deprecated in 3.6.x, removed in 4.0) orders negative weights correctly [exec on 3.5.0 and 3.6.0]
Affected versions
every release that ships FSTCompletionLookup: 3.6.0-3.6.2 and 4.0.0-alpha through 10.6.0 (including 8.8.2-8.11.4 from branch_8x; 10.6.0 by reading), plus main ea57360912ed; never fixed. Executed (all fail, controls pass): 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main (-ea and -da on main) [exec]; the other releases by code reading [read]. Upstream main f4cc6ac2097 (2026-10-26): the commits since ea57360912ed do not touch lucene/suggest, and no branch (main, branch_10x, branch_10_6, branch_9x) has changed the weight encoding since 2021 [read]. Executed only on aarch64 Linux with JDK 25
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; also reached by code reading
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, plus the finder's scripted prior-report search): two scripted prior-report search query sets (GitHub issues, PRs and commits, Solr, Elasticsearch and OpenSearch, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 62 manual GitHub searches over apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet and all of GitHub, 8 JQL searches in LUCENE and SOLR, 80 Pony Mail queries over the Lucene and Solr lists, and the changed files of all 409 open apache/lucene PRs (none touches FSTCompletionLookup.java, FSTCompletion.java or FSTCompletionBuilder.java). Related, not this bug: gh#4880 (LUCENE-3807, the introducing change; 38 comments, none about negative weights or the dropped sign handling), SOLR-2888 (added FSTCompletionLookup with the sign-correct FloatMagic and the 'assume they are non-negative' comment; 21 comments, none about negative weights), gh#10089 (LUCENE-9047, PRs #98 and #107, rewrote the same line with the same bytes), gh#7400 (LUCENE-6339, the separate document suggester rejects negative weights), gh#4788 (LUCENE-3714, negative FST outputs in the WFST design), gh#11575 and gh#11576 (FSTCompletion lookup API), gh#4692 (LUCENE-3618, persistence).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the repro JUnit through gradle in a clean worktree, stock and with the fix, assertions on and off, plus TestFSTCompletion, TestPersistence and all of lucene/suggest (32 classes, 238 tests) with the fix (2026-10-28); through the hunt's classpath runner, the same JUnit on main (-ea, -da, fix shadowed), 10.5.2 and 9.12.3. Hunt runs on main ea57360912ed (identical lucene/suggest code), on 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 8.11.4, 9.0.0, 9.12.3 and 10.5.2, and a bisection of the introducing commits on trunk and branch_3x with their parents.

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:59-61 (at ea57360912ed) (class javadoc: 'The range of weights for bucketing/ discretization is determined by sorting the input by weight and then dividing into equal ranges'), :63-65 ('the overall distinction between "classes" of weights will be preserved regardless of the distribution of weights'), :71 (@lucene.experimental), :174-175 (internal comment 'For now, assume they are non-negative. If negative floats are allowed some trickery needs to be done to find their byte order.'), :187-189 (encodedWeight written as Integer.reverseBytes(encodedWeight) through the little-endian DataOutput.writeInt, i.e. big-endian two's-complement bytes, then sorted by OfflineSorter's unsigned byte order), :220-226 (bucket = line * buckets / inputLineCount over that order), :254-259 (encodeWeight accepts Integer.MIN_VALUE..Integer.MAX_VALUE)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/InputIterator.java:35-36 (at ea57360912ed) ('A term's weight, higher numbers mean better suggestions.'; signed long, no range restriction)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/WFSTCompletionLookup.java:262-267 (at ea57360912ed) and lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:912-917 (siblings reject value < 0 with UnsupportedOperationException), lucene/suggest/src/java/org/apache/lucene/search/suggest/document/SuggestField.java:76-82 (weight must be >= 0)
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:185-195 (at ea57360912ed) (testLargeInputConstantWeights builds from weight -1), lucene/suggest/src/test/org/apache/lucene/search/suggest/TestPersistence.java:91-104 (asserts bucket values monotone in weight, non-negative weights only)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:59-65, 174-175, 186-191, 199, 220-226, 254-259, 268-269, 278 (a9779ef3c193)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionBuilder.java:169-171; fst/FSTCompletion.java:75, 147; InputIterator.java:35
  • lucene/core/src/java/org/apache/lucene/store/ByteArrayDataOutput.java:81-85; util/OfflineSorter.java:195, 202-206; util/BytesRef.java:161-162
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:71, 116-118, 293-300; FileDictionary.java:207-213; fst/FSTCompletion.java:75
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletion.java:202-206, 235-280; FSTCompletionLookup.java:278, 287-288
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:59-65, 174-175, 254-259; InputIterator.java:35; fst/WFSTCompletionLookup.java:262-267; analyzing/AnalyzingSuggester.java:912-917; document/SuggestField.java:76-82; tst/TSTLookup.java:195-196; analyzing/AnalyzingInfixSuggester.java:166
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:185, 192; TestPersistence.java:55, 76-77, 91-102
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:67-68; fst/FSTCompletionBuilder.java:169-176
Suggested fix
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:220-226, 293-300; fst/WFSTCompletionLookup.java:262-267; lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:185, 192
History
  • read-only GitHub API, 2026-10-28: last commit touching lucene/suggest/.../fst/FSTCompletionLookup.java on main ce50d3a135d (#14228), branch_10x and branch_10_6 1d55d2383ce (#14228), branch_9_12 3da32a257be (#12951), branch_9x 4f358f76bc9 (Tombstone branch_9x, #13882); issue #4880 title

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; every value on screen comes from an executed probe on main a9779ef3c193 (stock and with the sign-bit fix shadowed in) and on 10.5.2, or from the code at a9779ef3c193.

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.