All findings · Lucene finding · LFV-693 · stempel-optimizer-second-pass-remaps-to-row-zero

Stempel: a second Optimizer2 or Optimizer pass over an already merge-reduced stemmer trie can silently lose words

Reducing a stempel trie that a merge pass already reduced, with Compile -22 or -M22 or a second reduce(new Optimizer2()), can silently corrupt it: all 9 training words of TestCompile's data lose their stem, and a one-word trie loses its only word. The default Polish analyzer is not affected.

Summary

Priority
Low, rank 461A second Optimizer or Optimizer2 pass silently corrupts a whole stempel table, but only for self-compiled tables with an undocumented two-merge-pass method string or a re-reduced table while the bundled Polish table is unaffected, matching the custom-dictionary silent-stemming Hunspell profile.
Impact
silent wrong results
Since
3.1.0 (2011); in the code since stempel was added (LUCENE-2298, 2010)introduced 2010-05-03 · over 16 years
Affected
3.1.0 to 10.5.2 and main; 19 releases run
Status
documented
Found by
bounded-exhaustive translation validation of trie reductions

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

Stempel stores its stemming rules in a trie of rows; each cell of a row has a patch command and/or a reference to a child row. A merge pass joins compatible rows. Doing that a second time breaks the trie.

Step-by-step diagram of the one-word example: the backward stemmer trie for aab -> aa (patch Da) before and after each Optimizer2 pass, the remap array during the second pass, and the lookup of aab, which returns Da after one pass and an empty result after two.
The one-word trie for aab -> aa before and after each Optimizer2 pass, and the lookup of aab: Da after one pass, nothing after two. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You build a one-word stemmer trie, read backward like the bundled Polish table: aab stems to aa (patch Da). One Optimizer2 pass shrinks it from 3 rows to 2, and getLastOnPath("aab") is still Da.
  2. You run Optimizer2 again on the result, as Compile -22 does, or as code does on a loaded table. You expect Da again: the javadoc says Optimizer2 keeps "words for which the original trie was built".
  3. You get null, with no error. After pass 1, row R1 points back to row R0. Pass 2 places rows from the last one down, so it remaps R1's reference before R0 is placed; the unset slot reads 0, R1's own new index. R1 now points to itself, and the walk never reaches Da.
  4. At table scale: Compile -2 and -M2 stem all 9 training words of TestCompile's data; -22 stems all 9 to null and -M22 returns all 9 unchanged. One more pass over the bundled Polish table changes 8,356 of 21,147 sampled stored keys.
  5. Not affected: PolishAnalyzer and StempelPolishStemFilterFactory load the bundled table, compiled with -0ME2 (one merge pass) and never reduced again.

Who is affected

Only people who build or modify their own stempel tables: a Compile method string with two merge letters (-22, -M22, -0ME22, -0M11), or reduce(new Optimizer2()) / reduce(new Optimizer()) on a trie a merge pass already reduced, such as the bundled table. One word is enough; no error; every release since 3.1.0. Rare: the recipes found use one merge pass.

Mechanism, terms and numbers726 words

The stempel table compiler Compile shrinks a stemmer trie with reduction passes named by letters (1 = Optimizer, 2 = Optimizer2, the two "merge" passes); the same reducers are public API (Trie.reduce(new Optimizer2())). One merge pass keeps the training words in every case tested. A second merge pass over a trie that a merge pass already reduced can silently corrupt it: Optimizer.optimize (inherited by Optimizer2) walks the rows from the last one down and remaps each child reference through an int[] remap whose slots are read before they are filled (Java zero-fills the new array), so a reference to a row it has not placed yet becomes 0. That is only right when every child row comes after its parent, which holds for a freshly built trie but not for the output of a merge pass. A one-word trie loses its only word; Compile -22 and -M22 lose all 9 training words of TestCompile's data; one more pass over the bundled Polish table changes 8,356 of 21,147 sampled stored keys. No error, with assertions on and off, in every release since 3.1.0. The default PolishAnalyzer and StempelPolishStemFilterFactory are not affected.

Terms

  • Stemmer trie: stempel stores each training word with a patch command that turns it into its stem (aab -> aa is Da, delete one character). The trie is a list of rows; a row maps a character to a cell with a command and/or a reference to a child row. Polish tables are read backward, from the last character.
  • Merge passes: Optimizer (1) and Optimizer2 (2) join rows that do not conflict (Optimizer2 only when the shared cells are identical) to shrink the table. Compile applies the reduction letters of its method string in turn: the bundled table used -0ME2.

The bug

  1. Optimizer.optimize (Optimizer.java:79-110, inherited by Optimizer2) allocates int[] remap = new int[orows.size()] and never fills it (:83), so every slot is 0.
  2. It walks the rows from the last one down (:85) and builds new Remap(orows.get(j), remap) (:86), which sets nc.ref = remap[nc.ref] for every child (Reduce.java:121), before row j itself gets its new index (:94, :100).
  3. On a fresh trie that is right: Trie.add appends every new row (Trie.java:351-354), so children always come after their parent and are placed first.
  4. A merge pass breaks that order: removeGaps renumbers rows in pre-order (Reduce.java:88-102), so a row merged with a deeper row, or shared by several parents, can sit at the same or a lower index than a parent. The bundled table has 31,837 such references.
  5. In a second pass, such a reference reads remap[child] before the child is placed, gets 0, and points to whichever row was placed first. Row 0 is a real row, not a sentinel (removeGaps uses -1, Optimizer.java:106).

The one-word example (executed trace)

staterowslookup aab (b, a, a)
freshR0 {b -> R1}, R1 {a -> R2}, R2 {a: Da}Da
after pass 1R0 {a: Da, b -> R1}, R1 {a -> R0} (R2 merged into R0)Da
pass 2R1 is placed first; its reference to R0 reads remap[0] = 0, the new index of R1 itself
after pass 2R0 {a: Da, b -> R1}, R1 {a -> R1} (self-loop)null

The numbers

  • TestCompile's testRules.txt (4 lines, 9 training words): -2 and -M2 stem all 9; -22 stems all 9 to null; -M22 returns all 9 unchanged. Compile's statistics after the second pass are identical to those after the first.
  • Two-word trie {aa -> Da, ab -> Db}: after two Optimizer passes ab returns Da.
  • 2,000 random small tries: about 930 lose a key after two Optimizer2 passes, about 800 after two Optimizer passes, none after one pass.
  • Bundled table plus one more Optimizer2 pass: 8,356 of 21,147 sampled keys whose whole path is stored change their answer (0 with the fix).
  • An 11-lemma Polish table (94 training forms): -0ME2 stems 93 correctly in every run; -0ME22 about two thirds and -0M22 almost none (67 and 0 in the finder's run, 63 to 74 and 0 to 3 in later runs; the counts depend on hash iteration order).
Exposure and likelihood234 words
  • Who is exposed. People who compile their own stempel tables with a method string that has two of the merge letters 1/2, or whose code calls Trie.reduce(new Optimizer2()) or reduce(new Optimizer()) on a table loaded with StempelStemmer.load that a merge pass built (such as the bundled one) or on any other trie that was already merge-reduced. Optimizer2's javadoc also says the class "allows the stemmer to be self-teaching", which reads like adding words to a table and reducing it again; nobody tested that flow.
  • Not exposed. PolishAnalyzer and StempelPolishStemFilterFactory load the bundled stemmer_20000.tbl (its header names -0ME2, one merge pass) and never reduce it (PolishAnalyzer.java:61, 89, StempelPolishStemFilterFactory.java:50). A table that was never merge-reduced is not affected by one more pass: a -0M table reduced once with Optimizer2 changes 0 forms.
  • What it takes. No scale: one word suffices. Not every twice-reduced trie loses a key (about 930 of 2,000 random tries do with Optimizer2), but every minimal case and every word table tested does. Exact counts vary with hash iteration order (since 9.11.0) and seeds; the failures always occur.
  • Silent. No exception or warning; the table loads and stems, just wrongly. The same with -ea and -da.
  • Likelihood: rare. The recipes found (the bundled -0ME2, a downstream port's -0ME2) use one merge pass, and the searches found no report since 3.1.0 (2011).

Minimal repro

two Optimizer2 passes over the one-word backward trie {aab -> Da} (MultiTrie2, public API) lose the word's patch; a second merge pass on an already merge-reduced trie corrupts it.

TestOptimizer2SecondPass.java (JUnit test)33 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.analysis.stempel;

import org.apache.lucene.tests.util.LuceneTestCase;
import org.egothor.stemmer.MultiTrie2;
import org.egothor.stemmer.Optimizer2;
import org.egothor.stemmer.Trie;

public class TestOptimizer2SecondPass extends LuceneTestCase {

  public void testTwoOptimizer2PassesKeepTheOnlyWord() {
    MultiTrie2 trie = new MultiTrie2(false);
    trie.add("aab", "Da");
    Trie twice = trie.reduce(new Optimizer2()).reduce(new Optimizer2());
    // expected: the word the trie was built for keeps its patch "Da"; actual: "" (lost)
    assertEquals("Da", String.valueOf(twice.getLastOnPath("aab")));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-693/, 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/analysis/stempel/build/libs/lucene-analysis-stempel-$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.analysis.stempel.TestOptimizer2SecondPass
Expected vs actual
Expected getLastOnPath("aab") = "Da" (Optimizer2 keeps the words the trie was built for); actual "" on main 645d4fe14a2 with -ea and -da.

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

More files, environment, full output32 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<[Da]> but was:<[]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.analysis.stempel.TestOptimizer2SecondPass.testTwoOptimizer2PassesKeepTheOnlyWord(TestOptimizer2SecondPass.java:31)

Why it looks unintended

  • Optimizer2's javadoc promises it keeps "words for which the original trie was built", with no condition on the input.
  • Trie.reduce, Optimizer and Optimizer2 are public; Compile runs every method letter in turn, repeats included. Nothing says "one merge pass only".
  • The bad input is Lucene's own output: the first merge pass creates the backward references (the bundled table has 31,837).
The full argument352 words
  • Contract. Optimizer2.java:60-63: "Information loss, resulting in the stemmer not being able to recognize words (as in Optimizer), is curtailed, allowing the stemmer to recognize words for which the original trie was built." Trie.reduce (Trie.java:361-369): "Remove empty rows from the given Trie and return the newly reduced Trie." Neither states a condition on the input. The module's overview.html (lines 175-177) says only: "Several optimization steps are applied in turn to reduce and optimize the initial trie", a chain of steps each run on the previous output, with no recipe and no limit.
  • The API composes reducers. Trie.reduce, MultiTrie2.reduce, Optimizer and Optimizer2 are public in the exported org.egothor.stemmer package; a reducer returns an ordinary Trie. Compile applies every letter 1, 2, E, L, G in turn without rejecting repeats (Compile.java:145-171). The method string is case-sensitive and partly positional: a leading - selects a backward trie, then an optional 0 stores original forms and an optional M selects MultiTrie2 (Compile.java:88-100); of the rest, only the uppercase letters run a reduction and any other character is skipped (Compile.java:168-169); the "list ... in the package description" its javadoc cites (Compile.java:76-78) does not exist (package-info.java: "Egothor stemmer API.").
  • Tests. TestStemmer.assertTrieContents asserts that each reduction keeps every inserted key, on fresh tries only; TestCompile's methods test, -test and Mtest contain no reduction letter. No upstream test composes two merge passes.
  • History. The logic is the 2010 import (LUCENE-2298), never changed; no commit, issue or list thread calls repeated merge passes invalid.
  • Optimizer vs Optimizer2. Optimizer2's javadoc says Optimizer loses information. But one Optimizer pass keeps every key of 2,000 random tries and two lose keys in about 800 of them; and with the finder's fix sketch shadowed (the same idea as fix A), the bounded checks' only remaining getLastOnPath violations are sequences with Lift(false) before an Optimizer pass (E1 in the length-2 run; E1, 2E1, E21, LE1 and others in the length-3 run): the documented loss. The same root cause affects both.
The devil's-advocate check171 words

The intended-behaviour checker's strongest case (moderate; the cross-model reviews rated it weak): these are legacy offline compiler passes with an unstated "fresh trie only" precondition, since Optimizer, Gener and Lift all walk rows downward; every known recipe uses one merge pass; a second pass gains little; and maintainers treat the forked code as frozen (#8841: "I think we should avoid applying these kinds of modifications to forked code").

Why it fails: nothing documents that precondition; the bad input is produced by Lucene's own merge pass and shipped in its own table; on such input Gener's downward walk treats a row it has not visited yet as alive (Gener.java:84) and Lift on the loaded bundled table changes 0 stems, while Optimizer sends the reference to an unrelated row; and the unfilled slot is not a deliberate sentinel (Gener fills its array with 1, alive; removeGaps uses -1, not placed yet). The frozen-fork stance is a triage preference, not a statement of intent. Verdict: not intended, a defect.

Suggested fix

Recommended A. Keep rows with unresolved references unmerged and remap them after the loop (validated)

Fix A: in Optimizer.optimize, fill remap with -1, place a row whose reference points to a row not placed yet without merging it, and remap such rows after the loop (+24/-1 in Optimizer.java and Reduce.java). The repro and the whole stempel test task pass; a first pass over a fresh trie gives byte-identical output.

All options and trade-offs3 options ยท 698 words

Fix A was built and validated in a scratch worktree at a9779ef3c193; options B and C come from the adjudication and the audit and were not built.

A. Keep rows with unresolved references unmerged and remap them after the loop (validated) (recommended)

Reduce.Remap records whether any reference got a negative (unassigned) value. Optimizer.optimize fills remap with -1; a row whose Remap has an unresolved reference is placed as it is, is never used as a merge target, and is remapped once every row has its new index. Every other row goes through the old merge code, except that deferred rows are never merge targets. Same idea as the finder's sketch, written more compactly.

--- a/lucene/analysis/stempel/src/java/org/egothor/stemmer/Optimizer.java
+++ b/lucene/analysis/stempel/src/java/org/egothor/stemmer/Optimizer.java
     int[] remap = new int[orows.size()];
+    Arrays.fill(remap, -1);
+    // Rows are placed from the last one down, so a reference to the same or a lower row is not
+    // resolved yet. A trie that is already reduced has such references: keep those rows unmerged
+    // and remap them once every row is placed.
+    BitSet deferred = new BitSet();
 
     for (int j = orows.size() - 1; j >= 0; j--) {
-      Row now = new Remap(orows.get(j), remap);
+      Remap now = new Remap(orows.get(j), remap);
+      if (now.unresolved) {
+        remap[j] = rows.size();
+        deferred.set(rows.size());
+        rows.add(orows.get(j));
+        continue;
+      }
       boolean merged = false;
 
       for (int i = 0; i < rows.size(); i++) {
+        if (deferred.get(i)) {
+          continue;
+        }
         Row q = merge(now, rows.get(i));
@@ after the loop
+    for (int i = deferred.nextSetBit(0); i >= 0; i = deferred.nextSetBit(i + 1)) {
+      rows.set(i, new Remap(rows.get(i), remap));
+    }
--- a/lucene/analysis/stempel/src/java/org/egothor/stemmer/Reduce.java
+++ b/lucene/analysis/stempel/src/java/org/egothor/stemmer/Reduce.java
   class Remap extends Row {
+    /** true if a reference points to a row that has no new index yet (remap value below 0) */
+    boolean unresolved;
@@ in the constructor
           nc.ref = remap[nc.ref];
+          unresolved |= nc.ref < 0;
(plus import java.util.BitSet; full patch: fix-a.diff)

Trade-offs

  • A second pass now merges little: one more Optimizer2 pass over the bundled table keeps all 6,815 rows of its 8 sub-tries (stock goes from 6,815 to 6,577 rows, with a corrupted result). The fix makes a second pass safe, not useful.
  • It touches Reduce.Remap, which removeGaps also uses (there unresolved is never read). Keeping Reduce untouched with a separate scan of each row's cells (the finder's sketch) passes the same tests, but every iteration of a row's cell map advances that map's iteration seed, so the extra scan changes the order in which later code visits the cells, and the output bytes change even for fresh tries: with the finder's sketch shadowed, all 13 identity-probe hashes differ from stock.
  • Forked code (#8841): a maintainer may prefer B or C.

What the checks validated

  • The repro class: 3 of 3 fail on stock (gradle, assertions on and off); with fix A the whole :lucene:analysis:stempel:test task passes: 6 classes, 22 tests, 0 failures (assertions on), 6 classes, 22 tests, 0 failures (assertions off).
  • ecjLintMain, ecjLintTest, checkGoogleJavaFormat of the module with fix A and the repro class: pass (a first run flagged one line wrap in the test's javadoc; rewrapped).
  • The independent reproducer's 10 tests and the finder's 4 pass with fix A; the bundled-table check reports changed=0 of 21,147 stored-path keys.
  • Byte-identical output on stock and with fix A (SHA-256) for 5,000 random fresh tries after one Optimizer and after one Optimizer2 pass, and for Compile with 11 one-merge method strings, including the bundled -0ME2.
  • The one-word trie: with fix A a second pass returns exactly the pass-1 trie.

B. Reject an already reduced trie

Optimizer.optimize throws IllegalArgumentException when a row refers to the same or a lower row, and Compile rejects a second merge letter.

Trade-offs

Turns silent corruption into an error, but blocks re-reducing loaded tables, which fix A handles correctly in the tested cases. Not built.

C. Document the precondition

State in the Optimizer/Optimizer2 javadoc that the input must be a trie built by add and not merge-reduced yet, and document Compile's method letters (the missing "package description" list).

Trade-offs

Cheapest, but Java callers stay exposed to silent corruption. Not built.

Downstream note

Applications that use PolishAnalyzer or StempelPolishStemFilterFactory get the bundled table and are not affected; only code or tooling that compiles or re-reduces its own stempel tables is.

How it was found

Bounded-exhaustive translation validation: every trie of up to 4 keys over {a,b} (length up to 3), forward and backward, under every sequence of up to two reductions, compared with the unreduced trie (183,540 tries, 21.3M key checks). Every single pass kept every inserted key (by getLastOnPath, the stemmer's lookup); each sequence with two merge passes lost keys on some tries.

The method in full189 words

Method: bounded-exhaustive translation validation on the real jar (the hunt's "semi-formal" tier), unit "stempel trie and diff round trips", main a9779ef3c193, 2026-10-27.

  • The harness built every set of at most 4 keys over {a,b} of length at most 3 with 3 commands, forward and backward, and applied every reduction sequence of length at most 2 over {1, 2, L, E, G} and of length 3 over {1, 2, L, E} on tries of at most 3 keys. The reference was the unreduced trie, itself checked against an independent longest-match reference (0 mismatches).
  • Result (by getLastOnPath): every single pass kept the inserted keys; each sequence with two merge passes (11, 12, 21, 22, 2E2, ...) lost keys on some tries. With the finder's shadow fix of Optimizer.optimize (a one-file sketch of the same idea as fix A), all of those violations disappeared; only sequences with Lift(false) before an Optimizer pass (E1, 2E1, ...: Optimizer's documented loss) remained.
  • An AI finder then wrote the public-API JUnit and traced the cause; independent AI tools reproduced and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (GitHub issues, PRs and commits, JIRA, the Lucene lists, ports and forks; searched 2026-10-27). Closest: #6584, an undiagnosed 2014 report that "optimize" fails on a path that never runs Optimizer. See also #8841 (forked-code stance).

Searches5 searches

Verdict new. #6584 belongs to a sibling bug in Reduce.optimize.

What was searched, when, and what turned up
WhereWhenHowFound
Prior-art tool, two runs2026-10-2757 queries each over apache/lucene GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and ingested web results; plus the finder's run (45 completed queries; its 6 web queries stayed pending)411 and 167 hits, none about this defect
GitHub (apache/lucene), manual2026-10-27Optimizer2, org.egothor.stemmer, egothor, StempelStemmer, MultiTrie2, -0ME2, stemmer table; every stempel issue and PR title; open PRs touching stempelno report; no open PR touches lucene/analysis/stempel
Related items (read)2026-10-27#6584, #6569, #8841, #7678, #9465 and the stempel issues#6584 (LUCENE-5521, open): 'optimize method does not work correctly' after the toUpperCase fix of #6569; that path runs only Lift(false), never Optimizer. #8841: maintainer reply on forked code
Ports, forks, downstream2026-10-27Lucene.NET, buda-base/stemmer, other ports, Elasticsearch, OpenSearch, Solr, the old Egothor trackerLucene.NET has the same loop and no report; a fork replaced Optimizer because the optimizers 'give different results' (single-pass loss on unseen words, not this)
Mailing lists and JIRA2026-10-27dev@, java-user@ (all 52 'egothor' mails read), issues@, solr lists; JIRA 'egothor', 'Optimizer' AND 'stempel', 'stemmer table'no report

History

Present since stempel was added (LUCENE-2298, 98c47c57e0a, 2010-05-03); first release 3.1.0 (2011). Fails on all 19 releases run from 3.1.0 to 10.5.2 and on main; the logic never changed.

Versions tested5 rows

Introduced by 98c47c57e0a (trunk, svn r940433, 2010-05-03), "LUCENE-2298: Add stempel, an algorithmic stemmer with included Polish support" (issue #3374; svn era, no PR), the import of the whole org.egothor.stemmer package; the release line got it through the branch_3x backport 58328f03790 (2010-05-06). The StempelStemmer javadoc says the implementation is "taken verbatim from the Egothor project", and a 2010 copy of the original Stempel source has the same loop, so the defect most likely predates Lucene.

Later commits made only cosmetic changes to these lines (for example 8ac26737913 LUCENE-10003 and dbaf97f5dcc #14965) or changed Row's cell map to a hash map (#13420: main commit 6e2a8fc9b75, first released in 10.0.0; the 9.x backport 6f774936ef6 is in 9.11.0), which changes exact counts, not the defect. A code-identity check finds one variant of the four methods in all 131 release tags from 3.1 to 10.5.2 present in the clones, plus main.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 3 of 3 fail (gradle and runner, assertions on and off); the probe fails 5 of 5 checks
10.5.2, 9.12.3 affectedthis doc's test 3 of 3 fail (the repro runner, assertions on); the finder's and the reproducer's tests fail too
3.1.0 to 10.0.0 (17 releases: 3.1.0, 3.2.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.10.0, 9.11.0, 10.0.0) affectedthe main() probe fails all 5 bug checks (4 on training words; the fifth compares 8 arbitrary words) and passes all 5 controls
3.0.3 no stempelthe stempel module does not exist; the probe does not compile
3.1.0 and main with the finder's fix sketch shadowed fix sketchthe probe passes: 0 of 5 bug checks fail

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate, reviews from another model family (OpenAI GPT-5.6) and an adjudication all ran code and said real; an audit kept it. This doc's gradle runs reproduced it and validated fix A.

Checks and verdicts259 words

Every check executed code. The cross-model reviews ran on main ea57360912ed, whose stempel sources are identical to a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateBounded check (183,540 tries); public-API JUnit fails 4 of 4 on main (-ea, -da), 40 of 40 over 10 iterations, and on 10.5.2 and 9.12.3; passes with the shadow fix.
refute review (OpenAI GPT-5.6, executed) real, highOwn public-API test reproduces; traced a lower-row reference turning into a self-loop; the fix also passed TestStemmer and TestPolishAnalyzer; intended argument weak.
independent reproducer realOwn 10-test JUnit: 6 of 10 fail on main (-ea, -da), 10.5.2 and 9.12.3; 4 controls pass; the bundled stored-path oracle (8,356 of 21,147); 10 of 10 pass with the fix.
intended-lens review (OpenAI GPT-5.6, executed) real, highStructural probe: 0 lower-row references in a fresh trie, 2 after one merge, value lost after the second; intended argument weak.
intended-behaviour devil's advocate real, medium-highStrongest 'fresh trie only' case rated moderate and rejected; TestCompile data loses 9 of 9 with -22 and -M22; a never-merged table reduced once changes 0 forms.
adjudication confirmed, highRe-ran the reproducer's test (6 of 10 fail, -ea and -da), with the fix (10 of 10 pass) and the finder's test (4 of 4 fail).
audit keepRe-ran the finder's test with a new seed (4 of 4 fail, -ea and -da) and an own probe (929 / 786 of 2,000 random tries lose a key after two passes; 0 after one).

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

Finding record

LFV-693, snapshot of 2026-11-06

Registry title
Stempel: running Optimizer or Optimizer2 on an already merge-reduced trie silently corrupts it, because Optimizer.optimize remaps references to rows it has not placed yet to row 0
Area
analysis-stempel / egothor stemmer trie reductions (Optimizer, Optimizer2; Compile tool, Trie.reduce)
Priority
Low, rank 461: A second Optimizer or Optimizer2 pass silently corrupts a whole stempel table, but only for self-compiled tables with an undocumented two-merge-pass method string or a re-reduced table while the bundled Polish table is unaffected, matching the custom-dictionary silent-stemming Hunspell profile.
Impact class
silent wrong results
Impact note
Hits only people who build or modify their own stempel stemmer table, when a trie that a merge pass has already reduced goes through a second Optimizer or Optimizer2 pass (a Compile method string with two of the merge letters 1/2, such as '-22', '-M22' or '-0ME22', or Trie.reduce(new Optimizer()) / reduce(new Optimizer2()) on a loaded or already reduced table); then the failures always occur and need no scale (a one-word table suffices; about half of 2000 random small tries are hit), with -ea and -da, in every release since 3.1.0 (exact counts vary with hash iteration order), and nothing throws: the table loads and stems, but wrongly (Compile '-22' stems every training form of a small table to null, '-M22' leaves them unstemmed, and one more Optimizer2 pass over the bundled Polish table changes 8,356 of 21,147 keys whose whole path is stored); the default PolishAnalyzer and StempelPolishStemFilterFactory load the bundled table (compiled '-0ME2', one merge pass, never reduced again) and are not affected.
Summary
You build a stempel table with the Compile tool and a method string that has two merge passes (for example '-M22' or '-0ME22'), or you call reduce(new Optimizer2()) (or Optimizer) on a table that a merge pass already reduced, such as a loaded table; Optimizer2's javadoc says its row joining keeps the stemmer able to 'recognize words for which the original trie was built', so the training words should keep their stems, as they do after one pass. Instead the second pass silently corrupts the trie: two Optimizer2 passes over the one-word trie {aab -> Da} lose its only entry, Compile '-22' stems every training form of a small table to null and '-M22' leaves every form unstemmed (while '-2' and '-M2' are correct), and one more Optimizer2 pass over the bundled Polish table changes 8,356 of 21,147 keys whose whole path is stored. The cause is Optimizer.optimize (inherited by Optimizer2): it walks the rows from the highest index down and remaps every child reference through a remap[] array that is still 0 for rows it has not placed yet, which is only correct when every child has a higher index than its parent; a freshly built trie has that property, but the output of a merge pass does not (shared rows, self-loops, pre-order renumbering).
Introduced
98c47c57e0a54ab4aab9d91dd60c33edef130893 (trunk, svn r940433, 'LUCENE-2298: Add stempel, an algorithmic stemmer with included Polish support', committed by GitHub rmuir on 2010-05-03; issue LUCENE-2298 = apache/lucene#3374, no pull request in the svn era): the import of the whole org.egothor.stemmer package, whose Optimizer.optimize already leaves remap[] unfilled and walks the rows downward; the release line got it through the branch_3x backport 58328f0379049045b325d2082295836a7a50e9ce (svn r941704, 2010-05-06). Not a regression: the code is the Egothor stemmer (the StempelStemmer javadoc says the implementation is 'taken verbatim from the Egothor project'), and the prior-report search found the same loop in a 2010 copy of the original Stempel source, so the defect most likely predates Lucene. Later commits only reformatted these lines (8ac26737913 LUCENE-10003, dbaf97f5dcc #14965) or changed Row's cell map (6e2a8fc9b75 #13420, backport 6f774936ef6) [read: git blame and git log -G remap in the full-history clone; code-identity check over 131 release tags] (2010-05-03 (trunk 98c47c57e0a; branch_3x backport 58328f03790 on 2010-05-06))
First affected version
3.1.0 [exec] (tagged 2011-03-30; the first release with the stempel module, through the branch_3x backport; the trunk commit first shipped in 4.0.0-ALPHA). No release that has the API is unaffected: 3.0.3 has no stempel module [exec: the probe does not compile, packages org.egothor.stemmer and org.apache.lucene.analysis.stempel do not exist].
Affected versions
Every release that ships stempel, 3.1.0 to 10.5.2, and main a9779ef3c193; no upstream fix. [exec: the history probe (a main() class, public API that exists from 3.1.0 to main) fails all 5 bug checks with all 5 controls passing on 3.1.0 (-ea and -da), 3.2.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.10.0, 9.11.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da); with the candidate fix shadowed it passes on 3.1.0 and on main; the finder's JUnit fails 4 of 4 and the independent reproduction's and the intended-behaviour check's own tests fail on 10.5.2 and 9.12.3] [read: a code-identity check of the normalized Optimizer.optimize, Reduce.removeGaps, Reduce.Remap and Optimizer2.merge finds one variant in all 131 release tags from 3.1 to 10.5.2 present in the clones, plus main (tags 8.8.2-8.11.4 are missing from the clones; 8.11.4 was executed); upstream main a95c754ee00, 2 commits ahead of a9779ef3c193, does not touch lucene/analysis/stempel]. Since 9.11.0 (Row cells in a hash map, the #13420 backport) the exact counts differ (the probe's '-0ME22' check changes 31 instead of 27 of 94 Polish training forms); the defect is the same.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (searched 2026-10-27 plus the finder's run, read-only; the prior-report record, verdict new): two scripted prior-report searches of 57 queries each (apache/lucene GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA, ingested web results; 411 and 167 distinct hits, none rated high) plus the finder's 51-query run; manual GitHub searches on apache/lucene (Optimizer2, org.egothor.stemmer, egothor, StempelStemmer, MultiTrie2, '-0ME2', 'stemmer table', every stempel issue and PR title), GitHub-wide and code search, Lucene.NET, three outside ports or forks, Elasticsearch, OpenSearch and Solr, the Lucene and Solr lists and JIRA. No issue, PR, commit, JIRA entry, list thread, downstream report or fork discussion describes a second merge pass (or any merge pass on an already reduced or loaded trie) sending references to unplaced rows to row 0. Closest: #6584 (LUCENE-5521, open since 2014, no patch) 'Egothor Stemmer Bug for Optimizing (removing holes in the rows) for the given Trie' says only that 'optimize method does not work correctly and TestCompile.java throws error' once Compile's ignored toUpperCase (#6569) is fixed; upper-cased, TestCompile's methods 'test', '-test' and 'Mtest' contain only the reduction letter E (Lift(false)), so no Optimizer code runs on that path; it is the anchor of LFV-692, not of this bug (re-read read-only in the registration check). #8841 (LUCENE-7790, open) asks to make Trie.add public; jpountz replied 'I think we should avoid applying these kinds of modifications to forked code' (a maintainer stance, not a defect report). Another contributor's fork (buda-base/stemmer#1, closed 2018) replaced Optimizer because the stock optimizers 'give different results'; its tests concern Optimizer's documented single-pass loss on unseen words, not this defect, and nothing was filed against Lucene. Lucene.NET's Optimizer.cs has the same loop and no report. Not fixed: no open PR touches lucene/analysis/stempel, and upstream main a95c754ee00 (2 commits ahead of a9779ef3c193) does not touch it. Gaps: the list archive search is keyword-based, the original Stempel site is offline, and private reports are not searchable.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, and the standalone JUnit runner); the release jars 10.5.2 and 9.12.3 with this doc's test; a main() probe on 19 releases from 3.1.0 to 10.5.2 and on main; fix A with the whole :lucene:analysis:stempel:test task (assertions on and off), lint and format checks, and a byte-identity check on tries without backward references. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/egothor/stemmer/Optimizer.java:79-110 (optimize: remap = new int[orows.size()] at :83 left at 0, downward loop at :85, new Remap(orows.get(j), remap) at :86, remap[j] set at :94/:100, removeGaps at :105-107)
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/egothor/stemmer/Reduce.java:88-102 (removeGaps: pre-order renumbering) and :112-127 (Remap: nc.ref = remap[nc.ref] at :121)
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/egothor/stemmer/Optimizer2.java:57-65 (class javadoc: joining rows 'allowing the stemmer to recognize words for which the original trie was built. Use of this class allows the stemmer to be self-teaching.'; extends Optimizer, so it inherits optimize)
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/egothor/stemmer/Compile.java:73-81 (javadoc: args[0] names the algorithm, 'a list is available in the package description'), :104-107 (every char after the flags is a step), :139-172 (letters 1, 2, E, L, G applied in sequence, no repeat check); Trie.java:340-356 (add appends new rows, so children get higher indexes), :361-369 (public reduce, 'Remove empty rows from the given Trie and return the newly reduced Trie'); MultiTrie2.java:195 (public add), :277 (public reduce)
  • a Lucene main checkout lucene/analysis/stempel/src/test/org/egothor/stemmer/TestCompile.java:79, :93, :107 (methods 'test', '-test', 'Mtest': no merge letter); Lucene main ea57360912ed and a Lucene main checkout lucene/analysis/stempel/src are identical (diff -r, registration check)
  • https://github.com/apache/lucene/issues/6584 (read-only gh issue view in the registration check: body 'However optimize method does not work correctly and TestCompile.java throws error', one comment moving the issue to 4.9)
The finding in 30 seconds
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/egothor/stemmer/Optimizer.java:79-110 (:83 remap never filled, :85 downward loop, :86 Remap, :94/:100 remap[j], :106 fill -1)
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/egothor/stemmer/Reduce.java:88-102 (removeGaps, pre-order), :112-127 (Remap, nc.ref = remap[nc.ref] at :121)
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/egothor/stemmer/Trie.java:327-359 (add appends rows at :351-354)
Who is affected
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/egothor/stemmer/Optimizer2.java:60-63 (javadoc, 'self-teaching')
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/apache/lucene/analysis/pl/PolishAnalyzer.java:61, 89; .../stempel/StempelPolishStemFilterFactory.java:50
  • a Lucene main checkout lucene/analysis/stempel/src/resources/org/apache/lucene/analysis/pl/stemmer_20000.tbl (header bytes: writeUTF '-0ME2')
Why it looks unintended
  • a Lucene main checkout lucene/analysis/stempel/src/java/org/egothor/stemmer/Optimizer2.java:60-63; Trie.java:361-369; Compile.java:76-78, 145-171; package-info.java; Gener.java:84; src/java/overview.html:175-177
  • a Lucene main checkout lucene/analysis/stempel/src/test/org/egothor/stemmer/TestStemmer.java:151-168 (assertTrieContents), TestCompile.java:79, 93, 107
  • https://github.com/apache/lucene/issues/8841 (maintainer reply, via the prior-report follow-up)
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/internal/hppc/CharObjectHashMap.java:110 (seed taken when a map is built), :432-434 (nextIterationSeed, called by each iterator at :463, :530, :588)
Prior reports
  • https://github.com/apache/lucene/issues/6584 (read-only, registration check)

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

Repro files

About the diagrams

A step-by-step picture of the one-word example, made in parallel with this doc from an executed trace on main 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.