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.
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.- You build an
FSTCompletionLookupwith the default settings (10 buckets) from two suggestions, apple with weight 10 and apricot with weight -10, and calllookup("ap", true, 2)for the best completions first. - 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.
- To sort,
build()writes each weight as 4 bytes and compares them as unsigned bytes: 10 is00 00 00 0A, -10 isFF FF FF F6. So -10 sorts after 10, as if it were larger, and gets bucket 5 while apple gets bucket 0. - 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]. - 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.
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.
- Weight: the number an
InputIterator(or aDictionarysuch asFileDictionaryorDocumentDictionary) attaches to each suggestion; "higher numbers mean better suggestions" (InputIterator.java:35). A signedlong;FSTCompletionLookupaccepts any value in the int range (encodeWeight,FSTCompletionLookup.java:254-259). - Bucket:
FSTCompletionLookupkeeps no exact weights. It sorts the entries by weight, cuts the sorted list intobucketsequal ranges (10 by default) and stores each suggestion under its range number.lookup(key, true, num)returns the highest bucket first, andLookupResult.valueis 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 of0x80or more, a non-negative one with0x7For less.
- 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-endianByteArrayDataOutput.writeInt), then the suggestion bytes. 10 becomes00 00 00 0A, -10 becomesFF FF FF F6. OfflineSortersorts the lines with its default comparator,BytesRef#compareTo, which isArrays.compareUnsigned(:199;OfflineSorter.java:195,:202-206;BytesRef.java:161-162). The order is0, 1, ..., Integer.MAX_VALUE, Integer.MIN_VALUE, ..., -1.- 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. - Higher buckets are "presented before suggestions placed in smaller buckets" (
FSTCompletionBuilder.java:169-171;FSTCompletion.java:147), solookup("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."
| weights | result |
|---|---|
| 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 customInputIterator,FileDictionary(weights parsed as a signedlong,FileDictionary.java:207-213),DocumentDictionaryover a signed numeric field (executed) orDocumentValueSourceDictionary. 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-eaand-da, and on 10.5.2). - When it shows. In
lookup(key, true, num)(higher weights first). WithhigherWeightsFirst = falsethe results are sorted by text when there is more than one bucket (FSTCompletion.java:202-206), thoughLookupResult.valueandget()still report the inverted buckets (:278,:287-288). With the defaultexactMatchFirst, 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 afterload()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
bucketsentries (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,AnalyzingSuggesterandSuggestFieldreject negative weights. No user report was found since 3.6.0 (2012), though a silent mis-ranking may go unnoticed.
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.
/*
* 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());
}
}
}
# 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) beforeapricot(weight -10) forlookup("ap", true, 2); actual[apricot/5, apple/0], with-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> 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)- The javadoc assigns buckets by "sorting the input by weight";
InputIteratorsays higher weights are better suggestions. encodeWeightexplicitly accepts every int (its lower bound isInteger.MIN_VALUE), whileWFSTCompletionLookup, changed in the same 2012 diff, kept avalue < 0check.- 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.
encodeWeightthrows only outsideInteger.MIN_VALUE..MAX_VALUE(:254-259). The LUCENE-3807 diff that introduced it wrote that explicitInteger.MIN_VALUEbound, whileWFSTCompletionLookup.encodeWeight, changed in the same diff, kept its existingvalue < 0 || value > Integer.MAX_VALUEcheck (todayWFSTCompletionLookup.java:262-267). No source records why the two bounds differ. - Upstream tests.
TestFSTCompletion#testLargeInputConstantWeightsbuilds anFSTCompletionLookupfrom 5,000 entries of weight -1 (TestFSTCompletion.java:185,:192);TestPersistenceasserts 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 withFloatMagic.toSortable, "a directly sortable unsigned integer" that sorted negatives below non-negatives. LUCENE-3807 (f303bcd4650, 2012; backport71bd966a92e) switched weights tolong, used(int) valueand deletedFloatMagic. Its CHANGES entry says that weights became 64-bit signed integers (it also covers the disk-based sort and the move toCharSequence) and nothing about negative-weight order; its 38 comments never mention negative weights. The parents pass theFileDictionaryprobe, the two commits fail it, and each passes with the one-line sign-bit patch. - Siblings that were checked.
WFSTCompletionLookup,AnalyzingSuggesterandSuggestFieldreject negative weights loudly;TSTLookupandAnalyzingInfixSuggesteraccept 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.
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.
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]");TestFSTCompletionandTestPersistencepass (16 tests, 1@Nightlyskipped). - With the fix: the repro passes 2 of 2 with assertions on and with assertions off;
TestFSTCompletionplusTestPersistencepass (16 tests, 0 failures); all of:lucene:suggest:testpasses with the repro class added (32 classes, 238 tests, 0 failures, 7 skipped). ecjLintMain,ecjLintTestandcheckGoogleJavaFormatpass for suggest;fix.diffandfix-with-test.diffapply 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
TestFSTCompletionplusTestPersistence(15 of 15) pass; the same patch makes both introducing commits pass. - Not run: other modules (outside
lucene/suggestonlyCHANGES.txtnames the class); no benchmark (one XOR per entry at build time).
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).
A javadoc note only.
Trade-offs
Leaves the silent inversion in place for everyone who does not read it.
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.
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 5InputIteratorwrappers (90,000 builds and 292,350 lookups in all), and compared each result with a reference written from the javadoc (forFSTCompletionLookup: a key with a higher weight never gets a lower bucket). TheFSTCompletionLookupsources drew weights from{Integer.MIN_VALUE, -5, -1, 0, 1, 5, Integer.MAX_VALUE}; the other suggesters used non-negative weight sets. - For
FSTCompletionLookupit 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 mainea57360912ed; 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), Solr, Elasticsearch, OpenSearch; dev@, java-user@, issues@, commits@; the JIRA archive; the web | 2026-10-26 | two 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 GitHub | 2026-10-26 | 62 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 PRs | 2026-10-26 | changed-file lists | none touches FSTCompletionLookup.java, FSTCompletion.java or FSTCompletionBuilder.java |
| JIRA (LUCENE, SOLR) and the Lucene and Solr mailing lists | 2026-10-26 | 8 JQL searches; 80 list queries over 10 lists; comments of SOLR-2888, SOLR-2761, LUCENE-3618, LUCENE-3714, LUCENE-6339 and others read in full | no report; #7400 (LUCENE-6339): the document suggester rejects negative weights, a precedent for the reject option |
| the finder's and the verifiers' searches | 2026-10-26 | the finder's prior-report run (51 queries); direct GitHub searches by the reproducer and the devil's advocate | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the JUnit fails through gradle, assertions on and off, and through the classpath runner (-ea, -da); passes with the fix |
main ea57360912ed | affected | the 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.0 | the FileDictionary probe gives [apricot/5, apple/0]; controls ordered correctly |
f303bcd4650 (trunk) and 71bd966a92e (branch_3x) vs parents 2c94c522fd8, 2fa71777a72 | bisect | introducing commits fail, parents pass ([apple/5.0, apricot/0.0]); each passes with the sign-bit patch |
| 3.5.0 | not affected | no FSTCompletionLookup (compile error); the predecessor FSTLookup orders negative weights correctly |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Translation-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, medium | Rated 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 | real | Own 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, medium | Root 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 | real | Built and refuted the works-as-designed case; corrected the comment's history; its FileDictionary probe fails on 3.6.2 through main. |
| adjudication | confirmed, high | Re-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 | keep | Re-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 validated | Gradle 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 520 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,244 words
- 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, plusTestFSTCompletion,TestPersistenceand all oflucene/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 mainea57360912ed(identicallucene/suggestcode), 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.
- 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:35lucene/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:75lucene/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:166lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestFSTCompletion.java:185, 192; TestPersistence.java:55, 76-77, 91-102lucene/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
- TestFSTCompletionLookupNegativeWeights.java (JUnit test):
repro/LFV-479/src/org/apache/lucene/search/suggest/fst/TestFSTCompletionLookupNegativeWeights.java(38 lines)
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.
assets/LFV-479/bug.png(520 KB)
![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).](../assets/LFV-479/bug.png)