All findings · Lucene finding · LFV-1163 · intsreffstenum-negative-target-int-collides-with-end-label

IntsRefFSTEnum.seekCeil returns a term below the target when a negative int follows a term prefix

On a BYTE4 FST with keys [5] and [5, 3], seekCeil([5, Integer.MIN_VALUE]) returns [5], a key below the target, instead of [5, 3]: the seek compares the negative int with the FST's hidden end-of-key marker, -1. Only application-built seek targets reach it; every release since 3.3.

Summary

Priority
Low, rank 930IntsRefFSTEnum seekCeil, seekFloor and seekExact silently give wrong answers when an application-built target holds a negative int right after a prefix that is itself a term, in every release since 3.3. It needs that sentinel-style target on the expert, experimental enum, which no FST key and no Lucene caller has, so it is contrived.
Impact
minor
Since
3.3 (LUCENE-2843 / LUCENE-3140, 2011)introduced 2011-01-05 · over 15 years
Affected
3.3 to 10.6.0 and main (12 releases run, all fail); not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of IntsRefFSTEnum's seek contract, witnessed by an executable repro

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

Background: FST enumerators and the end-of-key arc

  1. An FST maps sorted keys to outputs. With input type BYTE4 a key is a sequence of int labels, each of which must be >= 0 (FSTCompiler asserts it). Lucene's own BYTE4 FSTs store Unicode code points.
  2. IntsRefFSTEnum walks the keys in order, like a SortedMap iterator. seekCeil(t) moves to the smallest key >= t, seekFloor(t) to the largest key <= t, and seekExact(t) to t or returns null. The target t does not have to be a key.
  3. When a key is also a prefix of longer keys, like [5] of [5, 3], the FST reader shows the node after it with an extra first arc labelled -1 (FST.END_LABEL), meaning "a key ends here". The real arcs (here 3) come after it.
Diagram of the bug: a BYTE4 FST with keys [5] (output 1) and [5, 3] (output 2). After label 5 the node starts with a hidden end-of-key arc labelled -1, followed by the arc labelled 3. seekCeil([5, Integer.MIN_VALUE]) compares MIN_VALUE with the end arc's label -1, treats -1 as the ceiling, and returns [5], which is below the target, instead of [5, 3].
The FST {[5] -> 1, [5, 3] -> 2}: the node after label 5 starts with a hidden end-of-key arc labelled -1, then the real arc 3. seekCeil([5, MIN_VALUE]) compares MIN_VALUE with that -1, finds -1 larger, and stops at [5], a key below the target, instead of going on to [5, 3]. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a BYTE4 FST with two keys, [5] -> 1 and [5, 3] -> 2, with the default FSTCompiler settings. [5] is a key and also a prefix of [5, 3].
  2. On a fresh IntsRefFSTEnum, call seekCeil([5, Integer.MIN_VALUE]). In IntsRef's signed order, [5] < [5, -2147483648] < [5, 3].
  3. You expect [5, 3] with output 2, the smallest key >= the target; seekCeil([5, 0]) returns exactly that.
  4. Instead you get [5] with output 1, a key below the target. No error, with assertions on and off. On the same FST seekFloor([5, -7]) returns null instead of [5], and with assertions off seekExact([5, -1, 9]) finds [5].
  5. Why: because [5] is a key, the FST reader starts the node after it with a hidden arc labelled -1 (FST.END_LABEL, "a key ends here"), before the real arc 3. The seek compares the target's next int with that -1 as if it were a normal label; every negative int is <= -1, so the seek stops at [5].

Who is affected

Only application code that builds its own FST and seeks an IntsRefFSTEnum with a target holding a negative int right after a prefix that is itself a key, such as Integer.MIN_VALUE as the smallest next label or a -1 sentinel. Two keys, default settings. No Lucene code path seeks this enum, and BytesRefFSTEnum cannot hit it.

Mechanism, terms and numbers637 words

IntsRefFSTEnum.seekCeil promises the "smallest term that's >= target" (IntsRefFSTEnum.java:59 at 5adff78bb168), and IntsRef order is signed (IntsRef.java:104-114), so in a BYTE4 FST {[5] -> 1, [5, 3] -> 2} the ceiling of [5, Integer.MIN_VALUE] is [5, 3]. A fresh enum returns [5], a key below the target, with no exception and with assertions on or off. Inside the FST, the node after a key that is also a prefix of longer keys starts with a "fake" arc labelled FST.END_LABEL = -1 (FST.java:697-712). getTargetLabel hands the seek scan the target's raw int (IntsRefFSTEnum.java:91-98), and the scan compares it with that arc as if it were a normal label (FSTEnum.java:274-288): -1 is equal, any lower int is smaller, and either way the seek stops at the prefix. seekFloor and seekExact go wrong the same way. On a fresh enum, no target without a negative int was answered wrongly in the checks run (the separate LFV-1147 overflow on a reused enum aside). The code dates from 2011 (LUCENE-2843) and is in every release from 3.3 to 10.6.0. No Lucene code path seeks an IntsRefFSTEnum, and FST labels must be >= 0 (asserted when the FST is built), so only application-built seek targets reach it.

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/util/fst/)

  1. seekCeil stores the target and calls doSeekCeil (IntsRefFSTEnum.java:60-65); nothing checks the target's ints. getTargetLabel returns FST.END_LABEL once the target is used up and otherwise the raw int (IntsRefFSTEnum.java:91-98).
  2. After matching label 5, the scan reads the next node with readFirstTargetArc (FSTEnum.java:285). Because [5] is a key, the arc is final, and readFirstTargetArc inserts a "fake" first arc with label END_LABEL = -1 (FST.java:697-712; FST.java:138-139: "If arc has this label then that arc is final/accepted"). The real arc 3 follows it.
  3. The scan loop sends this end arc to the list scan (FSTEnum.java:147 and :159). doSeekCeilList (FSTEnum.java:274-288) compares the end arc's label -1 with the target int. For MIN_VALUE, -1 > MIN_VALUE takes the "ceiling found" branch (:286-288) and calls pushFirst(), which stops at once on an END_LABEL arc (:714-716): the enum is left on [5]. A target int of exactly -1 matches the arc (:277-282) and also ends on [5].
  4. seekFloor: the walk-back in doSeekFloorList (FSTEnum.java:600-624) looks for an arc with a label below the target int and never finds the end arc below an int < -1, so it backs up past [5] (null here, or a smaller key). seekExact: findTargetArc(-1, ...) (FSTEnum.java:663) matches the end arc, and :674 reports success. With assertions on, assert upto == 1 + target.length (IntsRefFSTEnum.java:84) fires instead.
  5. The same -1 shows outside the enum (context; fix A does not change it): Util.get passes each int to FST.findTargetArc (Util.java:47-67), which treats END_LABEL as a request for the end-of-key arc (FST.java:1013-1029), so Util.get(fst, [5, -1, 3]) returns 2, the output of [5, 3], instead of null, with assertions on and off.

Exactly when it goes wrong

A bounded-exhaustive check against a TreeMap oracle (8,191 BYTE4 FSTs over labels {0, 1, 2}, every target over {MIN, -2, -1, 0, 1, 2, 3, MAX} up to length 3, all three seeks, plus list, continuous, direct-addressing and binary-search node encodings; a fresh enum per seek; 14,376,393 checks): 0 mismatches for targets without a negative int, including positive ints outside the alphabet; mismatches exactly when the first negative int follows a prefix that is a key: seekCeil for every negative int, seekFloor for ints below -1, seekExact for -1; 0 mismatches when that prefix is not a key; the same for all four encodings. With fix A: 0 mismatches.

Exposure and likelihood228 words
  • Who. Application code that builds its own FST and seeks an IntsRefFSTEnum with a target that holds a negative int right after a prefix that is itself a key: for example Integer.MIN_VALUE as "the smallest possible next label" in a ceil-based prefix scan, or a -1 "unknown" sentinel. Two keys suffice; default FSTCompiler settings; no scale.
  • Effect. A silent wrong lookup: a ceiling below its target, a floor that skips an existing key, an exact match for a key that is not there (assertions off). Ceiling and floor are wrong with assertions on too.
  • Not affected. No Lucene code path: the only non-test IntsRefFSTEnum user, hunspell WordFormGenerator, calls only next() (WordFormGenerator.java:68-71 in lucene/analysis/common). BytesRefFSTEnum cannot hit it: its target labels are bytes & 0xFF (BytesRefFSTEnum.java:96). In the checks (a fresh enum per seek), targets without a negative int, and a negative int after a prefix that is not a key, were answered correctly; the separate LFV-1147 overflow on a reused enum aside.
  • Opt-in part. IntsRefFSTEnum, FSTEnum and FST are @lucene.experimental (IntsRefFSTEnum.java:26, FSTEnum.java:28, FST.java:62).
  • Likelihood: contrived. No valid key contains a negative int, no caller found builds such a target, and no public report of it was found since 2011. It is deterministic, though: whoever builds such a target after a key prefix gets the wrong answer every time.

Minimal repro

One JUnit test method on the public FST classes: a two-key BYTE4 FST, one seekCeil on a fresh enum, one assertion. Deterministic, single-threaded, about 1 s; it fails with assertions on and off.

TestIntsRefFSTEnumNegativeTarget.java (lucene/core, package org.apache.lucene.util.fst, 1 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.util.fst;

import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.IntsRef;

public class TestIntsRefFSTEnumNegativeTarget extends LuceneTestCase {

  public void testSeekCeilNegativeIntAfterTermPrefix() throws Exception {
    FSTCompiler<Long> compiler =
        new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE4, PositiveIntOutputs.getSingleton()).build();
    compiler.add(new IntsRef(new int[] {5}, 0, 1), 1L);
    compiler.add(new IntsRef(new int[] {5, 3}, 0, 2), 2L);
    FST<Long> fst = FST.fromFSTReader(compiler.compile(), compiler.getFSTReader());
    IntsRef target = new IntsRef(new int[] {5, Integer.MIN_VALUE}, 0, 2);
    // expected [5, 3] (smallest term >= [5, MIN_VALUE]); actual [5], a term below the target
    assertEquals(
        new IntsRef(new int[] {5, 3}, 0, 2), new IntsRefFSTEnum<>(fst).seekCeil(target).input);
  }
}
Command
cp $REPRO/TestIntsRefFSTEnumNegativeTarget.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestIntsRefFSTEnumNegativeTarget -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure
Expected
[5, 3], the smallest key >= [5, Integer.MIN_VALUE].
Observed
[5], a key below the target: java.lang.AssertionError: expected:<[5 3]> but was:<[5]>.

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

More files, environment, full output222 words

TestIntsRefFSTEnumNegativeTarget (one method, extends LuceneTestCase, package org.apache.lucene.util.fst of the lucene/core tests). It uses the public FSTCompiler, FST and IntsRefFSTEnum and builds its keys as IntsRef, the type the enum's seek methods take. It builds {[5] -> 1, [5, 3] -> 2} with FSTCompiler.Builder(BYTE4, PositiveIntOutputs) and asserts that seekCeil([5, Integer.MIN_VALUE]) on a fresh enum returns [5, 3].

The target uses Integer.MIN_VALUE rather than -1 on purpose: -1 is the value of FST.END_LABEL, and any int below -1 shows the same wrong answer without it. As the regression test, the same method fits in TestFSTs. seekFloor([5, -7]) (null instead of [5]) and seekExact([5, -1, 9]) ([5] instead of null, assertions off) show the same defect and have no tests of their own here.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.12.3, 10.0.0, 10.6.0: the same input fails; 3.3.0 to 8.11.4 (8 releases): an older-API form fails; 3.2.0: no FST package
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)
Command (environment)
cp $REPRO/TestIntsRefFSTEnumNegativeTarget.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestIntsRefFSTEnumNegativeTarget -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure

Observed output

TestIntsRefFSTEnumNegativeTarget > testSeekCeilNegativeIntAfterTermPrefix FAILED
    java.lang.AssertionError: expected:<[5 3]> but was:<[5]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E60ACA35E1B7E3E0]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:120)
        at org.junit.Assert.assertEquals(Assert.java:146)
        at org.apache.lucene.util.fst.TestIntsRefFSTEnumNegativeTarget.testSeekCeilNegativeIntAfterTermPrefix(TestIntsRefFSTEnumNegativeTarget.java:32)

(gradle on main 5adff78bb168, seed DEADBEEF, assertions on; with -Ptests.asserts=false the same assertion)

Why it looks unintended

  • seekCeil promises the "smallest term that's >= target", with no rule on the target's ints, and IntsRef order is signed.
  • Positive off-alphabet ints were answered correctly in every check (fresh enum); only negative ones failed.
  • Labels >= 0 is only a build-time assert, and ints below -1 fail too. Contested: negative targets may be judged out of domain.
The full argument223 words
  • Contract. IntsRefFSTEnum.java:59: "Seeks to smallest term that's >= target"; :67: "Seeks to biggest term that's <= target"; :76: "Seeks to exactly this term, returning null if the term doesn't exist". None states a precondition on the target's ints. The package doc says the enumerators "behave like SortedMap iterators" (package-info.java:30-32), and IntsRef.compareTo is "Signed int order comparison" (IntsRef.java:104), so the expected answers are well defined for every IntsRef.
  • Other off-alphabet targets work. BYTE1 seekCeil([5, 300]), BYTE4 Integer.MAX_VALUE and every positive out-of-alphabet target of the exhaustive check were answered correctly. No mismatch was found for them in that corpus or those probes, which fits the mechanism: the -1 marker sits below every int >= 0 and at or above every negative int.
  • Tests. FSTTester (test framework) checks IntsRefFSTEnum seeks against the sorted key list (FSTTester.java:420-484), but only with in-alphabet targets, so it never reaches this case.
  • History. The marker, the fake end arc and the raw-int getTargetLabel all arrived together in 1cab313a2b8 (LUCENE-2843, 2011); its issue (#3917) does not discuss negative seek targets. Dawid Weiss remarked in 2011 that END_LABEL "makes having -1 label on a transition impossible" (commit 931c8294064) and that marker values from the domain range made them nervous (#4370): a design concern about labels, not a decision about seek targets.
The devil's-advocate check216 words

The strongest case for "intended" (made by the cross-model reviewer): INPUT_TYPE "Specifies allowed range of each int input label for this FST" (FST.java:68); BYTE4 represents strings as "full unicode codepoints" (FSTCompiler.java:253-255); FSTCompiler asserts v >= 0 and label >= 0 (FSTCompiler.java:581, :1109); END_LABEL = -1 is public, and findTargetArc treats it as the end-of-key marker on purpose (FST.java:1013-1029); the class is experimental with no in-tree seek caller. So a negative target int is garbage in.

Our answer, a judgement call the code does not settle: those lines describe the labels an FST is built from and how arcs are marked, not seek targets, and a ceiling or floor target is normally not a key. The label rule is a build-time Java assert and is never checked on the seek path. Ints below -1 are not END_LABEL and fail too. And "unsupported input" would justify an IllegalArgumentException, not a ceiling below its target. The independent reproducer, the devil's-advocate verifier and the tie-breaker rejected the case; the adjudication called the bug disputed only on this question, and the cross-model reviewer was not persuaded. Whether negative targets are in contract is the maintainer's call; if they are not, this is a hardening request, and fixes B and C fit.

Suggested fix

Recommended Normalise negative target ints at the IntsRefFSTEnum boundary (fix A)

Normalise negative target ints in IntsRefFSTEnum (fix A): valid keys hold only labels >= 0, so seekCeil(p ++ [x<0] ++ rest) can seek p ++ [0], seekFloor can seek p, and seekExact can return null. The repro passes, assertions on and off, and all core FST tests pass. Alternatives: reject such targets, or document the rule.

All options and trade-offs3 options ยท 995 words

Fix A was built and checked through gradle on 5adff78bb168; B and C were not run. The hunt's first version of fix A had one comment line over 100 columns, which failed checkGoogleJavaFormat; the version here only shortens its two comments. Open PR #14477 ("Use Arrays#mismatch in FSTEnum#rewindPrefix", a performance change) edits FSTEnum.java and adds a getMismatch override to IntsRefFSTEnum.java on other lines; it keeps the fresh-enum path of this repro and neither causes nor fixes it. The sibling LFV-1147 (a reused enum after a key with label Integer.MAX_VALUE) has its own one-line fix in FSTEnum.rewindPrefix; this repro still fails with that fix in place. Util.get has the same -1 collision (Util.get(fst, [5, -1, 3]) returns 2); none of the three options changes it.

Normalise negative target ints at the IntsRefFSTEnum boundary (fix A) (validated by the checks) (recommended)

In the three seek methods of IntsRefFSTEnum, find the first negative int of the target. Because every key label of a valid FST is >= 0 (FSTCompiler asserts it), a target p ++ [x] ++ rest with x < 0 has the same ceiling as p ++ [0], the same floor as p, and no exact match. So seekCeil seeks p ++ [0], seekFloor seeks p, and seekExact returns null. +25 lines, one file.

diff --git a/lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java b/lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java
index a14c135dc3c..1582b6eefeb 100644
--- a/lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java
+++ b/lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java
@@ -58,6 +58,13 @@ public final class IntsRefFSTEnum<T> extends FSTEnum<T> {
 
   /** Seeks to smallest term that's &gt;= target. */
   public InputOutput<T> seekCeil(IntsRef target) throws IOException {
+    // FST labels are >= 0, so p ++ [x] ++ rest with x < 0 has the same ceiling as p ++ [0]
+    int neg = firstNegative(target);
+    if (neg != -1) {
+      int[] ints = ArrayUtil.copyOfSubArray(target.ints, target.offset, target.offset + neg + 1);
+      ints[neg] = 0;
+      target = new IntsRef(ints, 0, ints.length);
+    }
     this.target = target;
     targetLength = target.length;
     super.doSeekCeil();
@@ -66,6 +73,11 @@ public final class IntsRefFSTEnum<T> extends FSTEnum<T> {
 
   /** Seeks to biggest term that's &lt;= target. */
   public InputOutput<T> seekFloor(IntsRef target) throws IOException {
+    // FST labels are >= 0, so p ++ [x] ++ rest with x < 0 has the same floor as p
+    int neg = firstNegative(target);
+    if (neg != -1) {
+      target = new IntsRef(target.ints, target.offset, neg);
+    }
     this.target = target;
     targetLength = target.length;
     super.doSeekFloor();
@@ -78,6 +90,10 @@ public final class IntsRefFSTEnum<T> extends FSTEnum<T> {
    * found.
    */
   public InputOutput<T> seekExact(IntsRef target) throws IOException {
+    if (firstNegative(target) != -1) {
+      // no term has a negative label
+      return null;
+    }
     this.target = target;
     targetLength = target.length;
     if (doSeekExact()) {
@@ -88,6 +104,15 @@ public final class IntsRefFSTEnum<T> extends FSTEnum<T> {
     }
   }
 
+  private static int firstNegative(IntsRef target) {
+    for (int i = 0; i < target.length; i++) {
+      if (target.ints[target.offset + i] < 0) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
   @Override
   protected int getTargetLabel() {
     if (upto - 1 == target.length) {

Trade-offs

Preserves the tested behaviour for FSTs that satisfy the non-negative-label invariant, and changes nothing for targets without a negative int. It adds an extra O(target length) scan per seek, which the seek itself may not need: a seek stops at the first label where the target leaves the FST (FSTEnum.java:286-306), and seekExact short-circuits (:664-670), as its javadoc says (IntsRefFSTEnum.java:76-78); not benchmarked. It allocates once only when a negative int is present. A seekExact rejected this way leaves the enum at its previous position; the javadoc does not specify the position after a miss either way. No negative target int reaches FSTEnum's label comparisons any more; the internal END_LABEL (-1) that getTargetLabel returns at the end of the target still does (IntsRefFSTEnum.java:93-94). Caveat: the label rule is only a Java assert (FSTCompiler.java:581, :1109). With assertions off, FSTCompiler accepts a negative label (a BYTE4 key [-2] was built and found by Util.get and next(); with assertions on the build fails). On such an FST, outside the documented label range, main's seekExact([-2]) matches the -2 arc (by FST.findTargetArc), while fix A returns null (fix B would throw). The repro's FST has only labels 5 and 3, so its assertions-off runs build a valid FST and only the seek differs. Fix A is sound for FSTs built within the label range; a maintainer may prefer B, or a build-time check that does not rely on asserts.

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on, and -Ptests.asserts=false) and passes with it (both).
  • With the fix, every org.apache.lucene.util.fst test class in core ran: the repro, TestFSTs (22, which runs FSTTester's random IntsRefFSTEnum seeks), TestUtil 5, TestFSTDirectAddressing 3 and 5 more classes: 38 tests, 0 failures, 5 skipped (among them both 2B tests). :lucene:core:checkGoogleJavaFormat passes with the fix and the repro in place. The full core suite was not run, and only OpenJDK 25.0.3 on Linux aarch64 was used.
  • Earlier: the hunt's shadow-class runs of the same change pass the repro (assertions on and off), the independent reproducer's and the cross-model reviewer's own tests, and the 14,376,393-check exhaustive probe (0 mismatches).

Reject negative target ints (fix B)

Throw IllegalArgumentException from seekCeil, seekFloor and seekExact when a target int is negative.

Trade-offs

The same scan cost as A; a loud failure instead of a silent wrong answer; changes behaviour for any caller that passes such ints today (none in tree). Not run.

Document the restriction (fix C)

Add a javadoc line to the three seek methods (or the class) saying that target ints must be >= 0.

Trade-offs

No code change; the silent wrong answer stays for callers who miss the line. A documentation-only resolution is a possible maintainer answer. Not run.

Downstream note

Downstream code that seeks an IntsRefFSTEnum with sentinel-style negative ints in its targets would silently get wrong ceiling, floor or exact lookups; no Lucene component does this.

How it was found

A proof-obligation audit of IntsRefFSTEnum's seek contract: for every IntsRef target, the three seeks must give the SortedMap answers in signed order. The obligation fails where the target's raw int meets the hidden end-of-key arc labelled -1; a JUnit test witnessed it, and a later exhaustive check of 14.4 million seeks mapped exactly when.

The method in full121 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder checked IntsRefFSTEnum's seeks against the obligation that, for every IntsRef target, seekCeil, seekFloor and seekExact return the SortedMap answers in signed IntsRef order, and found that getTargetLabel's raw int is compared with the end-of-key arc's label -1.
  • The finder wrote a JUnit test (target [5, -1]) and the boundary fix; the adjudication replaced -1 with Integer.MIN_VALUE so the target does not contain END_LABEL, and a final repro check kept that single method.
  • An independent reproducer, an executed cross-model review and a devil's-advocate verifier reproduced it with their own tests; the tie-breaker added the bounded-exhaustive check ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers or the web (last checked 2026-11-03). Nearest: open PR #14477, a speed-up in the same classes that does not touch this path, and Dawid Weiss's 2011 remarks on the -1 marker.

Searches3 searches

No matching public report was found in the documented searches. Nearest items, none a report of this bug: #14477 (open, "Use Arrays#mismatch in FSTEnum#rewindPrefix", other lines), #4370 and commit 931c8294064 (Dawid Weiss, 2011: END_LABEL makes a -1 transition label impossible, and marker values from the domain range made them nervous), a 2011 dev@ mail in which Michael McCandless says END_LABEL was added for the FSTEnums, #3917 (LUCENE-2843, where the code was written) and #4769 (LUCENE-3695: "The INPUT_TYPE really describes the allowed range of the input int labels"). None mentions seek targets or ints below -1. Limits: keyword search; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-03; re-checked twice the same daytwo scripted prior-report searches (51 queries each; 639 distinct hits in the second, none high) and about 37 manual searches: IntsRefFSTEnum, END_LABEL, getTargetLabel, seekCeil/seekFloor/seekExact, negative label, Integer.MIN_VALUE, INPUT_TYPE, BYTE4no report; #14477 is the only open PR on these classes
Open and recent PRs2026-11-03 (twice)the changed files of all 409 open apache/lucene PRs and of the PRs updated since 2026-11-01only #14477 touches FSTEnum or IntsRefFSTEnum; none fixes this
Mailing lists, downstream, web2026-11-03dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch, Solr, Lucene.NET and the archived lucene-solr; GitHub code search; 8 web queriesnothing about this bug; the 2011 dev@ remark on END_LABEL is design background

History

Present since LUCENE-2843 added the -1 end marker and the generic seeks (1cab313a2b8, 2011), first released in 3.3 through the branch_3x copy; its parent commit answers correctly and 3.2.0 has no FST package. Every release run since, through 10.6.0 and main, fails. Not fixed upstream.

Versions tested5 rows

Introduced by LUCENE-2843 "Add variable-gap terms index impl." (Michael McCandless; now #3917): 1cab313a2b8 on trunk, 2011-01-05 (Subversion, no PR). It added END_LABEL = -1, the fake end arc in readFirstTargetArc, the FSTEnum seeks and getTargetLabel. The branch_3x copy came with LUCENE-3140 (#4213), 43c373b207e, 2011-05-29; first release 3.3, and 4.0.0-alpha on the trunk line. seekExact first shipped in 3.6.0 and 4.0.0-alpha. Later commits (931c8294064, da8fe452fd2, LUCENE-8781, LUCENE-8920, the LUCENE-9570 reformat) changed only the form of this code, never the comparison with the end arc.

The two commit-level runs compile each commit's fst package over the 3.3.0 core jar, so they confirm the commit boundary, not a released build. Releases before 9.x run a main() form of the same FST and target with the older API; its control seekCeil([5, 0]) gives [5, 3] everywhere. Upstream main 1c2072f0284 (2026-11-02) has the same IntsRefFSTEnum.java, FSTEnum.java and FST.java.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A
9.12.3, 10.0.0, 10.5.2, 10.6.0 affectedthe same wrong ceiling, [5] instead of [5, 3] (10.5.2: the independent reproducer's own test, [2] instead of [2, 7])
3.3.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 older-API formthe same wrong ceiling, [5] -> 1
trunk 1cab313a2b8 vs its parent c26fd7564a5 commit boundary1cab313a2b8 answers [5]; the parent's ceil-only advance answers [5, 3]
3.2.0 API absentcompile error: no FST package

Independent checks

A finder, an independent reproducer, an executed cross-model review (OpenAI GPT-5.6: reproduced, but argued out-of-domain input), a devil's-advocate verifier, an adjudication (disputed) and a tie-breaker (confirmed, Low) all reproduced it; a final repro check kept one test. This doc's gradle runs reproduced it on main and validated fix A.

Checks and verdicts374 words

The cross-model review ran on the older main ea57360912ed, whose fst sources match 5adff78bb168; its test was then re-run on 5adff78bb168 (3 of 4 methods fail with assertions on and off). Every other check ran on 5adff78bb168 or release jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedseekCeil([5, -1]) gives [5] on main (assertions on and off), 10.6.0 and 9.12.3; a probe shows seekCeil([5, -7]), seekFloor([5, -7]) and seekExact([5, -1, 9]) wrong, and right answers when the prefix is not a key; the boundary fix passes, plus TestFSTs.
independent reproducer realOwn TreeMap-oracle test on {[2] -> 10, [2, 7] -> 20} fails 4 of 5 on main (on, off), 10.6.0, 10.5.2 and 9.12.3; controls pass; the fix passes.
cross-model review, refute lens (OpenAI GPT-5.6, executed) not real (medium): out-of-domain inputReproduced all three wrong answers with its own test and agreed on the root cause and the fix, but argued that negative ints lie outside every input type's label range and that -1 is the public end marker (the devil's advocate above).
devil's-advocate verifier (with an intended-lens cross-model check) real, at most LowNo javadoc, test or upstream decision restricts seek targets; positive out-of-alphabet targets are right, ints below -1 fail too; reproduced on 10.6.0. The intended-lens check (OpenAI GPT-5.6) also said real.
judge and tie-breaker disputed (medium), then confirmed (medium), LowThe adjudication's MIN_VALUE test fails with assertions on and off; the tie-breaker re-ran both tests (main, 10.6.0), ran the 14,376,393-check exhaustive probe (0 mismatches with the fix) and showed the repro still fails with LFV-1147's fix.
separate re-runs, credibility audit and final repro check fails as recorded; confirmed, LowFresh seeds of the adjudication's equivalent test TestIntsRefFSTEnumNegativeTargetJudge (the same FST and target) fail at its line 37 with assertions on and off and pass with the fix; the one-test repro itself fails at TestIntsRefFSTEnumNegativeTarget.java:32 in the hunt's minimal-repro runs; one minimal deterministic test.
this doc reproduced; fix A validatedGradle without the fix fails (assertions on and off); with fix A the repro passes (both) and the 9 core util.fst test classes show 0 failures (38 tests, 5 skipped); the formatting check passes.

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

Finding record

LFV-1163, snapshot of 2026-11-06

Registry title
IntsRefFSTEnum.seekCeil returns a term below the target (and seekFloor and seekExact answer wrongly) when a negative target int follows a prefix that is itself a term, because the seek compares it with the virtual end-of-term arc whose label is FST.END_LABEL = -1
Area
util / FST (IntsRefFSTEnum seek targets vs. FSTEnum's virtual END_LABEL arc)
Priority
Low, rank 930: IntsRefFSTEnum seekCeil, seekFloor and seekExact silently give wrong answers when an application-built target holds a negative int right after a prefix that is itself a term, in every release since 3.3. It needs that sentinel-style target on the expert, experimental enum, which no FST key and no Lucene caller has, so it is contrived.
Impact class
minor
Impact note
Only application code that seeks the expert, @lucene.experimental IntsRefFSTEnum with a target it builds itself is hit, and only when a negative int (for example Integer.MIN_VALUE as the smallest next label, or a -1 sentinel) follows a prefix that is itself a term: seekCeil then silently returns that prefix, a term below the target, for every negative int; seekFloor returns null or a smaller term for every int below -1; seekExact reports the prefix as found for -1 under -da (an internal assert fires under -ea); ceil and floor are wrong under -ea too; tiny input, default FSTCompiler settings, no scale needed; no Lucene code path seeks an IntsRefFSTEnum and BytesRefFSTEnum cannot hit it; every release since 3.3 and main.
Summary
You build a BYTE4 FST with [5] -> 1 and [5, 3] -> 2 and call seekCeil([5, Integer.MIN_VALUE]) on a fresh IntsRefFSTEnum; you expect [5, 3], the smallest term >= the target in IntsRef's signed order ([5] < [5, MIN_VALUE] < [5, 3]). Instead it returns [5], a term below the target, with or without assertions. IntsRefFSTEnum hands the raw target int to FSTEnum's arc scan, and the node after the final arc [5] starts with a virtual end-of-term arc labelled END_LABEL = -1, so a negative int either equals that label (-1) or sorts below it and the scan stops at [5]; seekFloor and seekExact go wrong the same way.
Introduced
1cab313a2b88d3e1d1365af4e30f06f7a0d49547 (LUCENE-2843, now #3917, 'Add variable-gap terms index impl.', Michael McCandless, trunk svn r1055405; no pull request, Subversion era), which added FST.END_LABEL = -1, the 'fake' final first arc in FST.readFirstTargetArc, the FSTEnum base class with seekCeil/seekFloor and IntsRefFSTEnum.getTargetLabel returning the raw target int; branch_3x copy 43c373b207e with LUCENE-3140, now #4213, r1128870 [read: git blame and git log on the full-history clone, no later commit changed the comparison with the end arc; exec: the fst package of the parent c26fd7564a5 (ceil-only advance(target), no END_LABEL) answers [5, 3], the fst package of 1cab313a2b8 answers [5], both compiled over the 3.3.0 core jar, so they confirm the commit boundary, not a released build] (2011-01-05 (author date = commit date of 1cab313a2b8 on trunk); branch_3x copy 43c373b207e: 2011-05-29)
First affected version
3.3 [exec: 3.3.0 fails with -da; 3.2.0 has no FST package (compile error), so no release precedes the defect]; on the trunk line 4.0.0-alpha (4.0.0 executed: fail). seekExact, the third affected method, first shipped in 3.6.0 and 4.0.0-alpha [read]
Affected versions
every release that has IntsRefFSTEnum, 3.3 through 10.6.0 (released 2026-10-29, 4ae946e5a9b), and main 5adff78bb168 (12.0.0-SNAPSHOT); not fixed anywhere (IntsRefFSTEnum.java, FSTEnum.java and FST.java are unchanged between 5adff78bb168 and upstream main 1c2072f0284, 2026-11-02). Executed, fail [exec]: main -ea and -da (finder, the independent reproduction, the cross-model review, the intended-behaviour check, adjudication, tie-breaker and the registration check), 10.6.0 (-da history step and tie-breaker; -ea finder, the independent reproduction, the intended-behaviour check), 10.5.2 (-ea and -da, the independent reproduction), 10.0.0 (-da), 9.12.3 (-da history step; -ea finder, the independent reproduction), and 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4, 4.0.0, 3.6.2 and 3.3.0 (-da, main() adapters with the same FST and the same seekCeil([5, MIN_VALUE]); the control seekCeil([5, 0]) gives [5, 3] everywhere); 3.2.0: compile error (API absent). The other releases by reading. History runs cover seekCeil; seekFloor and seekExact by reading. Not new in 10.6. All runs on Linux aarch64 with JDK 25.0.3.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it, an adjudication that rated it disputed and a tie-breaker that confirmed it (medium confidence), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's, the cross-model review's, the intended-behaviour check's and the tie-breaker's searches; rechecked the same day): two scripted prior-report searches (51 queries each over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 639 distinct hits in the second run, none high), about 37 manual GitHub searches incl. apache/lucene-solr, Elasticsearch, OpenSearch, Solr and Lucene.NET, 8 web searches, GitHub code search, the changed files of all 409 open apache/lucene PRs and of the 40 PRs updated since 2026-11-01 (none of the recent ones touches util/fst), and text scans of #3917, #4218 and #14477. Closest items, none a report: open PR #14477 (vsop-479, 'Use Arrays#mismatch in FSTEnum#rewindPrefix', a performance change) edits FSTEnum.rewindPrefix and adds a getMismatch override to IntsRefFSTEnum, on other lines, and keeps the fresh-enum path of this repro; design background from 2011: Dawid Weiss's commit 931c8294064 says END_LABEL makes a -1 transition label impossible, their #4370 comment dislikes END_LABEL as a marker taken from the label domain, and Michael McCandless on dev@ says END_LABEL was added for the FSTEnums; none concerns seek targets or ints below -1. Not fixed anywhere: upstream main 1c2072f0284 has the same code.
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT), OpenJDK 25.0.3 on Linux aarch64: this doc's gradle runs (the repro without the fix, assertions on and off: fail; with fix A: pass; the 9 core org.apache.lucene.util.fst test classes with fix A: 38 tests, 0 failures, 5 skipped; google-java-format check on core with fix A: pass) and the hunt's runs; release jars 3.3.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (all fail) and 3.2.0 (no FST package). The cross-model review ran on ea57360912ed, whose fst sources match 5adff78bb168, and was re-run on 5adff78bb168.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java:59-89 (at 5adff78bb168) (seekCeil 'Seeks to smallest term that's >= target', seekFloor 'Seeks to biggest term that's <= target', seekExact 'returning null if the term doesn't exist'; no check on target ints; :84 assert upto == 1 + target.length) and :91-98 (getTargetLabel: END_LABEL past the end of the target, else the raw int), lucene/core/src/java/org/apache/lucene/util/fst/FST.java:138-139 (END_LABEL = -1) and :693-716 (readFirstTargetArc inserts the 'fake' final first arc with label END_LABEL), lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:274-288 (doSeekCeilList), :590-624 (doSeekFloorList walk-back), :642-684 (doSeekExact)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:253-255 (at 5adff78bb168) (BYTE4 = full unicode codepoints), :581, :1109 (labels >= 0); lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/SynonymMap.java:244, .../miscellaneous/StemmerOverrideFilter.java:214, .../hunspell/Dictionary.java:648 (in-tree BYTE4 builders); lucene/core/src/java/org/apache/lucene/util/fst/package-info.java:30-32 (SortedMap iterators)
  • lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java:59-98; (at 5adff78bb168) FST.java:138-139, :697-712; FSTEnum.java:147, :159, :274-288, :285, :600-624, :663, :674, :714-716; lucene/core/src/java/org/apache/lucene/util/IntsRef.java:104-114
  • lucene/core/src/java/org/apache/lucene/util/fst/Util.java:47-67; (at 5adff78bb168) FST.java:1013-1029
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/WordFormGenerator.java:68-71; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/util/fst/BytesRefFSTEnum.java:96; IntsRefFSTEnum.java:26, FSTEnum.java:28, FST.java:62 (@lucene.experimental)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java:59, (at 5adff78bb168) :67, :76; package-info.java:30-32; FST.java:68; FSTCompiler.java:253-255, :581, :1109; lucene/core/src/java/org/apache/lucene/util/IntsRef.java:104
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/fst/FSTTester.java:420-484 (at 5adff78bb168)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:286-306, (at 5adff78bb168) :664-670; IntsRefFSTEnum.java:76-78, :93-94; FSTCompiler.java:581, :1109
Prior reports
  • https://github.com/apache/lucene/pull/14477, https://github.com/apache/lucene/issues/4370, https://github.com/apache/lucene/issues/3917, https://github.com/apache/lucene/issues/4769, https://lists.apache.org/thread/bnl28mbxx92lkfsnlwtp061fmw0yfon4
Diagrams
  • lucene/core/src/java/org/apache/lucene/util/fst/FST.java:697-712; (at 5adff78bb168) FSTEnum.java:274-288; IntsRefFSTEnum.java:91-98

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

Repro files

About the diagrams

The diagram illustrates the repro's FST ([5] -> 1, [5, 3] -> 2), the hidden end-of-key arc labelled -1 that the FST reader puts before arc 3, the comparison of the target's MIN_VALUE with that -1, and the two outcomes (returned [5], expected [5, 3]).

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.