All findings · Lucene finding · LFV-694 · stempel-row-store-count-mismatch

Stempel tables compiled with Gener (`G`) can fail to load or lose stems: `Row.store` counts the empty cells it does not write

A stempel stemmer table compiled with the G (Gener) reduction fails to load with EOFException, or loads silently with wrong or missing stems, whenever Gener leaves an empty cell: Row.store counts the empty cells but does not write them. Every release since 3.1.0; the bundled Polish table is unaffected.

Summary

Priority
Low, rank 462This stempel sibling of LFV-693 needs the undocumented G (Gener) reduction in a custom table and usually fails loudly with EOFException at load with only occasional silent wrong stems.
Impact
silent wrong results
Since
3.1.0 (2011), the first release with stempel; in the module's first import (LUCENE-2298, 2010)introduced 2010-05-03 · over 16 years
Affected
3.1.0 to 10.6.0 and main; 13 releases run, 10.6.0 by reading
Status
documented
Found by
bounded store/load round trip of reduced tries

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's Compile tool turns a dictionary into a stemmer table; each letter of its method string applies a reduction, and G (Gener) removes rarely used patch commands. StempelStemmer.load reads the table back.

Example A, the dictionary x xa … xt and a ab compiled with method G: the rows of the 64-byte table file. As written, the root row declares 2 cells but holds one record (a); the next row declares 1 cell and holds one record (b). As read back, the root row gets a and a junk second record made from the next row's bytes, and the next row reads its count as 0. stem("ab"): expected a, actual null, no error. Example B (a ab, ac aca … act): StempelStemmer.load throws EOFException.
Example A's 64-byte table file row by row: as Row.store wrote it (root row "2 cells", one record) and as Row(DataInput) reads it (the second record taken from the next row, which then reads as empty). Select the still to open it at full size. Generated with AI, checked against the code.
  1. You compile a 2-line dictionary, x xa xb … xt (20 forms of x) and a ab, with stempel's Compile tool and method G. Gener drops every command whose use count is below the row's total divided by 10 (rounded down): each x? command is used once out of 20, so all 20 go, and the root cell x is left empty.
  2. You expect StempelStemmer.load to read the 64-byte table back and stem ab to a, like the trie that was stored and like the same dictionary compiled without a reduction.
  3. Instead it loads without error and stems ab to null. Row.store wrote the root row as "2 cells" but only 1 record of 18 bytes, so the reader took the second record from the first 18 bytes of the next row, which then came back empty.
  4. With a ab and ac aca … act instead, the short row is the last one in the file: load throws java.io.EOFException, looking for a record past the end.
  5. The bundled Polish table (method -0ME2, 0 empty cells) and PolishAnalyzer are fine. The fix is one line: write getCells(), the number of cells actually written.

Who is affected

Only users who compile their own stempel table with G, or store a Gener-reduced trie through org.egothor.stemmer, and only when Gener leaves an empty cell; every release 3.1.0 to 10.6.0 (10.6.0 by reading) and main. The bundled Polish table and PolishAnalyzer are not affected. In the tested cases most fail to load; some load with words unstemmed or wrongly stemmed.

Mechanism, terms and numbers680 words

Stempel's Compile tool turns a dictionary into a stemmer table, and StempelStemmer.load reads it back. With the G reduction (Gener) in the method string, the table can fail to load with java.io.EOFException, or load without error and leave some words unstemmed. The cause is a writer/reader mismatch: Row.store writes the number of all cells of a row and then skips the empty cells that Gener can leave behind, while Row(DataInput) reads exactly the declared number of records and so runs into the next row. Two 2-line dictionaries show both outcomes; the bundled Polish table (-0ME2, no G) is not affected. Present since the module's first import; the fix is one line.

Terms

  • Stemmer table: a trie stored as a list of rows. A row maps a character to a cell; a cell holds a patch command (how to turn the word into its stem, for example Da: delete one letter) and/or a reference to the next row. StempelStemmer.stem walks the trie and applies the last command on the path.
  • Compile: org.egothor.stemmer.Compile "is used to compile a stemmer table" (Compile.java:67). Its first argument is a method string; the letters G, L, E, 2 and 1 apply reductions in order (:145-172), then the trie is written with Trie.store (:174-179).
  • Gener (G): "Return a Trie with infrequent values occurring in the given Trie removed" (Gener.java:71-76). In each row it drops every command whose use count is below frame = sum / 10, the row's total use count divided by 10 and rounded down (:106-109, :116; c.cmd = -1, :120-123), drops rows left with nothing, and clears references to them (c.ref = -1, :110-114).
  • Empty cell: a cell with no command and no reference (both -1). Gener leaves them; public Row.setCmd(way, -1) makes one too (Row.java:108-118), and so does Row.setRef(way, -1) on a new cell or a cell with only a reference (:126-135).

The bug

  1. Row.store writes os.writeInt(cells.size()), the number of all cells, including empty ones (Row.java:232), then skips every empty cell (if (e.cmd < 0 && e.ref < 0) continue;, :237-239).
  2. Row(DataInput) reads exactly the declared number of 18-byte records (a char and four ints, :78-88). The format has no byte length or end marker per row, only that count: Trie(DataInput) reads the rows one after another from the same stream (Trie.java:84-93).
  3. So for every skipped cell the reader takes the next 18 bytes of the stream as one more record. If the stream ends first, load throws EOFException; otherwise the row gets a junk cell, the next row's count is read from the wrong offset, and later rows can be misread too. Lookups through those rows return wrong or no commands.
  4. Row.getCells(), "Return the number of cells in use" (:137-153), counts exactly the cells store writes, and Compile prints it as cells after every reduction (Compile.java:171, Trie.java:372-385).

The numbers

Example A, x xa xb … xt and a ab, method G: Gener empties root cell x; the 64-byte file declares the root row as 2 cells but holds 1 record (a). On load the root row gets a junk second cell (key \u0000, command 65,634) made from the next row's count and record, and the next row reads its count as 0: stem("ab") is null instead of a, with no error. Example B, a ab and ac aca … act: the short row (b plus an empty c) is the last one, and load throws EOFException at Row.java:80. For both, Compile prints cells 2 while the file declares 3. Without a reduction letter both load (446 bytes) and stem ab to a. The bundled table (stemmer_20000.tbl) has 0 empty cells among 121,822 and loads; reduced with Gener it has 38,322 and throws EOFException.

Exposure and likelihood409 words
  • Who is exposed. Users who build their own stempel table with Compile and a method string containing G (for example G, -EG, -0ME2G), or who reduce a trie with Gener, or clear a command or reference with Row.setCmd(way, -1) or Row.setRef(way, -1), and store it through the exported package org.egothor.stemmer (module-info.java:25). Custom tables are an intended use: StempelStemmer says "there is nothing language-specific here" (StempelStemmer.java:32-33).
  • Opt-in. The bundled table stemmer_20000.tbl was compiled with -0ME2 (its header) and has no empty cells, so PolishAnalyzer (PolishAnalyzer.java:49, :88-89) and StempelPolishStemFilterFactory (same table, :50) are not affected.
  • Not size-dependent. Small and large tables fail when Gener leaves an empty cell: two-line dictionaries, 11 Polish lemmas compiled with -0ME2G (the bundled method plus G) and the bundled table reduced with Gener (38,322 empty cells). Not every G table is hit: -0MG and -0MLG on the same lemmas leave no empty cell and load. In the finder's random sweep of 3,000 small tries, Gener left empty cells after 1G, 2G, EG and LG in 4,025 (trie, sequence) cases (one trie can count under several sequences), and every one failed the store/load round trip (3,294 EOFException, 731 silent; the finder's figure only).
  • What the user sees. Either an EOFException when the application loads its table (most cases of the finder's synthetic sweep), with no hint that G is the cause (Compile reports success). Or no error at all, and some words come back unstemmed or wrongly stemmed, so matching on stems silently changes.
  • Run-to-run variation (9.11.0 and later). Since #13420 (in 9.11.0 and 10.0.0) Row.store writes cells in the key order of an HPPC hash map whose iteration seed depends on how many maps the JVM created before, so whether a given table throws or loads silently can change between runs (executed with an independent reproducer's dictionary; the cause is read from the code). The repro's examples are built not to depend on it.
  • Old files stay broken. The format has no byte length or end marker per row, so it gives a reader no general, deterministic way to find where a short row ends; tables written with empty cells must be recompiled after the fix.
  • Likelihood: rare. Lucene does not document G, the bundled table does not use it, and no prior report was found in the searches listed below (the code is 16 years old).

Minimal repro

The test shows that Row.store writes the count of all cells but skips an emptied cell (the state Gener leaves), so the next stored row reads back misaligned.

TestRowStoreEmptyCell.java (JUnit test)45 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.egothor.stemmer;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestRowStoreEmptyCell extends LuceneTestCase {

  public void testRowWithEmptyCellRoundTrip() throws Exception {
    Row first = new Row();
    first.setCmd('a', 0);
    first.setCmd('b', 1);
    first.setCmd('b', -1);
    Row second = new Row();
    second.setCmd('x', 3);

    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    DataOutputStream out = new DataOutputStream(bytes);
    first.store(out);
    second.store(out);

    DataInputStream in = new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()));
    new Row(in);
    // Expected 3, actual -1: store() counts the emptied cell 'b' (as Gener leaves it) but skips it.
    assertEquals("command of 'x' in the second stored row", 3, new Row(in).getCmd('x'));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-694/, 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.egothor.stemmer.TestRowStoreEmptyCell
Expected vs actual
Expected getCmd('x') == 3 for the second of two rows read back with Row(DataInput); actual -1 (the second row loses its cell).

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

More files, environment, full output41 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
    => java.lang.AssertionError: command of 'x' in the second stored row expected:<3> but was:<-1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.egothor.stemmer.TestRowStoreEmptyCell.testRowWithEmptyCellRoundTrip(TestRowStoreEmptyCell.java:43)

Why it looks unintended

  • Row.store and Row(DataInput) are two halves of one format; a row declared as 2 records with 1 written cannot be read back.
  • The right count already exists: getCells(), "the number of cells in use", which Compile prints after every reduction.
  • Compile handles G and stores the result; Gener's javadoc promises infrequent values removed, not an unreadable table.
The full argument271 words
  • One format, two halves. Row.store is "Write the contents of this Row to the given output stream" (Row.java:225-230) and Row(DataInput) is "Construct a Row object from input carried in via the given input stream" (:72-77). A count that declares more records than are written produces data that the only reader cannot parse.
  • The right count exists. getCells() counts exactly the written cells (:137-153) and Compile prints it as cells after every reduction (Compile.java:171, Trie.java:372-385). The skip at :237-239 is deliberate; the count at :232 is the part that disagrees.
  • The trigger is the tool's purpose. Compile "is used to compile a stemmer table" (:67), handles G (:148-151) and stores the result (:174-179); StempelStemmer.load is "Load a stemmer table from an inputstream" (StempelStemmer.java:57). Gener, Trie.reduce, Trie.store, Row and Row.setCmd are public, in an exported package with no @lucene.internal or @lucene.experimental mark.
  • Gener is meant to keep a usable table. Its javadoc says it removes infrequent values (Gener.java:71-76), and TestStemmer asserts that a Gener-reduced trie answers its keys correctly in memory (TestStemmer.java:157); only the stored form breaks.
  • No test or discussion says otherwise. No upstream test stores a Gener-reduced trie (TestCompile uses test, -test and Mtest, TestCompile.java:79, 93, 107: Compile reads the leading - and M, but none of them holds a reduction letter G, L, E, 2 or 1, Compile.java:88-100, :147-169); the count and the skip are unchanged since 2010 with no issue or comment about them.
The devil's-advocate check205 words

The strongest case (built by the intended-behaviour checker and the cross-model reviews): org.egothor.stemmer is forked code, "taken virtually unchanged from the Egothor project" (overview.html:40-41), and on #8841 a maintainer (@jpountz) wrote "I think we should avoid applying these kinds of modifications to forked code". Lucene does not document G: Compile's javadoc points to a list "in the package description" (Compile.java:77) that does not exist (package-info.java is one line). The bundled table does not use G, no test stores a Gener-reduced trie, the Lucene.NET port and the original Egothor2 code have the same lines, and no prior report was found in 16 years of history.

Why it fails: all of that is about priority, not intent. A writer that declares N records and writes fewer has no intended reading, and the module's own getCells() and printInfo say which count is right. The #8841 comment was about widening an API (Trie.add visibility), not a correctness fix, and the module is maintained (#13420 rewrote Row's map in 2024 and kept the count and the skip). Verdict: not intended, a defect (the cross-model reviews rated the intended argument weak). Not security: only whoever builds the table can trigger it.

Suggested fix

Recommended Write the number of cells that are written (recommended)

In Row.store, write getCells(), the number of cells the loop writes, instead of cells.size() (one line). All repro tests and the module's tests pass; tables without empty cells, such as the bundled one, keep the same count and records (the record order inside a row already varies between stores). Tables already written with empty cells must be recompiled.

   public void store(DataOutput os) throws IOException {
-    os.writeInt(cells.size());
+    os.writeInt(getCells()); // the number of cells written below
     Iterator<CharCursor> i = cells.keys().iterator();
All options and trade-offs4 options · 666 words

Fix A was validated in a scratch worktree at a9779ef3c193 with the whole :lucene:analysis:stempel:test task, assertions on and off, plus the three repro classes; fix B the same with assertions on. The other options were not run.

Write the number of cells that are written (recommended) (validated by the checks)

Row.store writes getCells(), which counts the cells with a command or a reference: exactly the cells the loop writes (+1/-1 in Row.java).

   public void store(DataOutput os) throws IOException {
-    os.writeInt(cells.size());
+    os.writeInt(getCells()); // the number of cells written below
     Iterator<CharCursor> i = cells.keys().iterator();

Trade-offs

  • A row without empty cells gets the same count and the same records as today, so the bundled table and every table compiled without G store the same content and load the same trie, and every existing reader reads the new files. The bytes are not guaranteed identical: since 9.11.0 each key iteration advances the row map's iteration seed (CharObjectHashMap.java:432-434, :530-531), so stock Row.store already varies the record order from one store to the next, and fix A's extra pass moves it again (executed: the bundled table stored twice, stock and with fix A, gives different digests but the same size and the same content).
  • Empty cells are absent after loading, so a loaded row is not identical to the stored one: getCnt returns -1 for them instead of their count (Row.java:209-212), and print or a later reduction can see that. Lookups answer the same (getCmd and getRef return -1 for a missing cell, Row.java:197-200, :220-223).
  • One extra pass over a row's cells per stored row; store runs only when a table is written, never during analysis. Not benchmarked.
  • Tables already written with empty cells must be recompiled: the format has no byte length or end marker per row, so a reader has no general, deterministic way to repair them. Worth a sentence in CHANGES.
  • The fix makes a loaded table answer like the Gener-reduced trie that was stored; it does not undo what Gener itself removes (on the bundled table, Gener in memory changes 193,552 of 366,667 probe answers).

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: the paste-ready test fails 3 of 3 on stock main with assertions on and off; with fix A the whole :lucene:analysis:stempel:test task passes, 8 classes, 29 tests, 0 failures, with assertions on and off: the module's 19 upstream tests plus the 10 repro tests (the paste-ready test 3, the finder's 4, the reproducer's 3).
  • With the paste-ready test, ecjLintMain, ecjLintTest and checkGoogleJavaFormat pass.
  • The repro runner: the paste-ready test 3 of 3 pass (assertions on and off); the finder's round-trip sweep finds 0 failures with it.

Write every cell (drop the skip) (validated by the checks)

Remove if (e.cmd < 0 && e.ref < 0) continue; (Row.java:237-239, -4 lines), so cells.size() is right again.

Trade-offs

Empty cells are stored too, 18 bytes each (about 690 KB more for the bundled table reduced with Gener, 38,322 empty cells); the loaded trie then equals the stored one cell for cell, where fix A drops the empty cells. Both repair the format and give the same lookups; equally format-compatible; it drops the author's deliberate skip.

What the checks validated

The same three repro classes and the whole :lucene:analysis:stempel:test task with assertions on: 8 classes, 29 tests, 0 failures; not run with assertions off.

Drop empty cells where they are made (Gener, or Reduce.Remap that copies them)

Remove a cell when Gener clears its last command or reference.

Trade-offs

Misses the public Row.setCmd(way, -1) path, and Gener.java is also the fix site of a sibling stempel bug (Lift and Gener rewriting their input trie). Not validated.

Repair on load

Make the reader tolerate short rows.

Trade-offs

The format has no byte length or end marker per row, only the wrong count, so it gives the reader no general, deterministic way to tell where a short row ends. Not attempted.

Downstream note

The same count-and-skip code is in the Lucene.NET port's Row.Store, with no report there; products that only use the bundled Polish table are not affected.

How it was found

Semi-formal: translation validation on the stock jar. A harness stored and reloaded the tries the stempel reductions produce (3,000 random small tries under every reduction sequence of length up to 2, Compile methods, the bundled table) and compared every key with the in-memory trie; every Gener result with an empty cell failed. Then reduced to a public-API JUnit.

The method in full179 words
  • Round-trip check. For each trie and each reduction sequence the harness kept the reduced trie in memory, stored it, loaded it back and compared the answer for every key. Sequences without Gener all round-tripped. A random sweep (3,000 tries over 26 letters, keys of length up to 3, up to 80 keys, reduction sequences of length up to 2) found every Gener sequence that left an empty cell failing: 4,025 (trie, sequence) cases after 1G, 2G, EG and LG, 3,294 with EOFException and 731 loading with changed answers. With the one-line fix shadowed in, both counts were 0.
  • Wider inputs. The bundled table: 0 empty cells, round-trips; after Gener 38,322 empty cells and EOFException. Compile on 11 Polish lemmas: -0ME2G, -0M2G and -0MEG failed, -0MG and -0MLG left no empty cell on that table and loaded.
  • Reduction. The hypothesis came while modelling the store/load step from the code and was witnessed by the harness, then reduced to a public-API JUnit on the stock main jar (2026-10-27). This doc's paste-ready test uses two smaller examples.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-27, re-checked 2026-10-28) in GitHub issues, PRs and commits, JIRA, the mailing lists, open PRs' changed files, and ports and forks. Nearest: #6584 (2014), a different stempel Compile failure on tables that load fine, with no G.

Searches6 searches

No report of this defect was found. The same lines exist, unreported, in the Lucene.NET port and in the original Egothor2 code.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists, JIRA archive, web2026-10-27three scripted prior-report search runs, 51 queries each (identifiers, how users hit it, serialization wording), plus 90 queries by the intended-behaviour checker0 high-overlap hits
GitHub (apache/lucene and all of GitHub), by hand2026-10-27stempel (33 results, all triaged), egothor (11), "Row.store", Gener, "stempel EOFException" (0 relevant); all of GitHub: stempel Gener, egothor stemmer EOFException and similarnothing relevant
Open PRs2026-10-27the changed files of all 409 open apache/lucene PRsonly #681 (makes Cell public) and #16911 (decommissions branch_10x, so it removes that branch's files) touch org/egothor/stemmer; neither fixes Row.store
JIRA (LUCENE, SOLR, LUCENENET) and the java-user@, dev@ and issues@ lists2026-10-27stempel (44), egothor (12), StempelStemmer, "stemmer table"; every remaining list subject triaged#6584 / LUCENE-5521 (2014), replayed on main: a Lift / getFully failure on tables that load fine, the same with this fix, no G; context only: #6569, #7678, #8841, #3374
Ports and forks2026-10-27the Lucene.NET port, an Egothor2 fork, other outside ports, downstream stempel pluginsthe same count and skip in the Lucene.NET port and the Egothor2 code; no report
GitHub issues (apache/lucene), re-check2026-10-28stempel EOFException, Row.store, Gener stempel; newest issues matching stempel0 results; the newest stempel issue is from 2021 (#11326, unrelated); further queries hit the search rate limit

History

In the module's first import (LUCENE-2298, 98c47c57e0a5, 2010; shipped in 3.1.0) and unchanged since; #13420 (in 9.11.0 and 10.0.0) changed only the map type. Fails on all 13 releases run from 3.1.0 to 10.5.2 and on main; no release has the API without the bug. Not fixed upstream.

Versions tested5 rows

Introduced with the module: 98c47c57e0a5 "LUCENE-2298: Add stempel, an algorithmic stemmer with included Polish support" (trunk, 2010-05-03, committed by @rmuir; JIRA LUCENE-2298, now #3374; SVN era, no PR), backported to branch_3x as 58328f03790 (2010-05-06), the copy that shipped in 3.1.0. The count, the skip, Gener's empty cells and Compile's G followed by store were all in that first import. git blame of the reader and writer lists only that commit and #13420 (6e2a8fc9b75, 2024-05-27, first in 10.0.0; its branch_9x backport 6f774936ef6 is first in 9.11.0), which swapped the map type and kept the count and the skip. Upstream main a95c754ee003, branch_10x and branch_10_6 are unchanged, and no open PR fixes Row.store (#16911, which decommissions branch_10x, only removes that branch's files).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedpaste-ready test 3 of 3 fail (gradle and runner, assertions on and off); the finder's 4 of 4, the reproducer's 3 of 3, the portable probe 4 of 4
9.12.3, 10.0.0, 10.5.2 affectedpaste-ready test 3 of 3 fail, including the silent case; probe 4 of 4 (all three have the #13420 hash map)
3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.11.1 probeportable main() probe: 4 of 4 checks EOFException, 4 of 4 controls pass (3.1.0 with assertions on and off)
3.0.3 and older no APIno stempel module: the probe does not compile (package org.egothor.stemmer does not exist)
main + fix A not affectedevery repro test passes; the module's 19 upstream tests pass

Independent checks

The finder, an independent reproducer, an intended-behaviour checker, two executed reviews from another model family (OpenAI GPT-5.6) and an adjudication all ran code and said real; a credibility audit kept it. This doc's gradle runs reproduced it with a new test and validated two fixes.

Checks and verdicts399 words

Every check executed code. The finder, the reproducer, the intended-behaviour checker, the adjudication, the audit and this doc ran on main a9779ef3c193; the cross-model reviews ran on main ea57360912ed, where the stempel module is identical (empty diff), and a separate run re-ran its test and the finder's on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateRound-trip sweep (4,025 failing (trie, sequence) cases, 0 with the fix); JUnit 4 of 4 fail on main (40 of 40 over 10 iterations), 10.5.2 and 9.12.3; the fix sketch passes 4 of 4.
refute review (OpenAI GPT-5.6, executed) real, highIts own Compile -EG test on a generated dictionary fails at StempelStemmer.load with assertions on and off after its -E control loads; its own Row round trip fails too; the fix makes its Compile test pass. Intended argument weak.
independent reproducer realOwn test, sharing no dictionary or code with the finder's: 3 of 3 fail on main, 10.5.2 and 9.12.3, including a silent Compile G misread (4 of 33 words); counted 38,322 empty cells after Gener on the bundled table; its own one-line fix passes both test classes.
intended-lens review (OpenAI GPT-5.6, executed) real, highIntended argument weak; its own test fails 2 of 3 (Compile -EG, a cleared Row cell) with its -E control passing; with the fix 3 of 3 pass, and it reports a manual run of the 19 upstream stempel tests passing (no saved log; this doc's gradle runs show the same).
intended-behaviour checker real, high (impact low)Built the strongest intended case (forked code, undocumented G) and refuted it; G, G1, G2 and 2G fail through Compile; a silent misread of 4 of 53 keys (that probe built its trie through a package-private method); with the fix 5 of 5 pass.
adjudication confirmed, high; silent wrong resultsRe-ran the reproducer's test: 3 of 3 fail with assertions on and off, 3 of 3 pass with the fix.
credibility audit keepFourth independent re-run under new seeds; existing stempel tests pass with the fix.
this doc reproduced; fixes A and B validatedA new deterministic test (3 of 3 fail in every run on main, 10.5.2, 10.0.0 and 9.12.3); the whole module test task with fix A (assertions on and off) and fix B.

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

Finding record

LFV-694, snapshot of 2026-11-06

Registry title
Stempel's Row.store writes the count of all cells but skips the empty ones, so a stemmer table reduced with Gener (Compile method letter G) fails to load with EOFException or loads misaligned and gives wrong stems
Area
analysis/stempel / egothor stemmer table serialization (Row.store vs Row(DataInput); Gener, Compile 'G', StempelStemmer.load)
Priority
Low, rank 462: This stempel sibling of LFV-693 needs the undocumented G (Gener) reduction in a custom table and usually fails loudly with EOFException at load with only occasional silent wrong stems.
Impact class
silent wrong results
Impact note
Hits only users who compile their own stempel stemmer table with a method string that contains G (the Gener reduction of the Compile tool, e.g. -EG, -0M2G, -0ME2G) or who store a trie reduced with Gener through the public org.egothor.stemmer API, in every release from 3.1.0 to 10.6.0 (10.6.0 by reading) and on main, with -ea or -da, at any table size: most often StempelStemmer.load throws EOFException, but some such tables load without any error and return wrong or no patch commands for some words (the independent reproduction: 3 or 4 of 33 probe words of a 4-line dictionary compiled with G); the bundled Polish table (-0ME2, no G), PolishAnalyzer and StempelPolishStemFilterFactory are not affected.
Summary
You compile a stemmer table with the stempel Compile tool using a method string that contains G (Gener, the reduction that drops infrequent commands), or reduce a trie with Gener and store it, and expect StempelStemmer.load to read the table back and stem like the trie that was stored. Instead Row.store writes the number of all cells of a row and then skips the empty cells (no command and no reference) that Gener leaves behind, so Row(DataInput) reads too many cell records and runs into the next row: usually load fails with java.io.EOFException (Compile -0ME2G, -0M2G and -0MEG on an 11-lemma Polish table, -EG on a 20-line table, the bundled table reduced with Gener, which then has 38,322 empty cells), and sometimes the table loads without error but answers wrongly (Compile G on a 4-line dictionary: for 'walked' the expected patch is Db and the loaded table gives null). Writing getCells(), the existing count of the non-empty cells, fixes it in one line and changes no byte of any table without empty cells, such as the bundled table and every table compiled without G.
Introduced
98c47c57e0a54ab4aab9d91dd60c33edef130893 (trunk, SVN r940433, 'LUCENE-2298: Add stempel, an algorithmic stemmer with included Polish support', JIRA LUCENE-2298 'Polish Analyzer', now GitHub issue #3374; patch attached by @rmuir, CHANGES credits Andrzej Bialecki via Robert Muir; SVN era, no pull request), branch_3x backport 58328f0379049045b325d2082295836a7a50e9ce ('LUCENE-2298: backport', r941704, the copy that shipped in 3.1.0): the first import of the stempel module already had Row.store write cells.size() and skip cells with cmd < 0 && ref < 0, Row(DataInput) read exactly that count, Gener.eat create such cells, and Compile handle 'G' and then store the trie [read: git show of both commits; git blame -w -M -C of Row.java:78-88 and :228-247 lists only 98c47c57e0a5 plus #13420 (6e2a8fc9b75b, 2024-05-27, first released in 10.0.0), which changed only the iterator type and kept the count and the skip; git log -S 'writeInt(cells.size())' finds only the introducing commit]; not a regression: the bug is as old as the module in Lucene. The same count/skip pattern is in the original Egothor2 v4.0.4 code (as imported by another contributor's fork) and in the Lucene.NET port [read] (2010-05-03 (trunk 98c47c57e0a5); branch_3x backport 2010-05-06 (58328f03790))
First affected version
3.1.0 [exec: the version-portable history probe fails all 4 checks with EOFException with -ea and -da while its 4 controls pass; 3.1.0 is the first release with the stempel module, as lucene-stempel-3.1.0.jar]; 3.0.3 and earlier have no stempel API (lucene-stempel 3.0.3 is not on Maven Central, and the probe does not compile against core + analyzers 3.0.3: 'package org.egothor.stemmer does not exist') [exec], so no release has the API without the bug
Affected versions
every release from 3.1.0 through 10.5.2 and main a9779ef3c193 (unreleased 11.0) [exec: the history probe (public API only: a Row round trip after setCmd(b,-1); MultiTrie2 reduced with Lift then Gener, stored and loaded; Compile -EG on a 20-line table; Compile -0ME2G on 11 Polish lemmas; each with a control) fails all 4 checks on 3.1.0 (-ea, -da), 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.11.1, 9.12.3, 10.0.0, 10.5.2 (-ea, -da) and main (-ea, -da), and passes with the fix; the finder's JUnit fails 4 of 4 on 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's test fails 3 of 3 on main, 10.5.2 and 9.12.3; the intended-behaviour check's probe 5 of 5 on main and 10.5.2]; the releases in between [read: same Row.store / Row(DataInput) / Gener.eat logic, git log on the files]. Results are the same before (9.12.3) and after (10.0.0) the #13420 iterator change. The silent-misread variant was executed on main only, not per release. Not fixed upstream: upstream main a95c754ee003 still writes cells.size() at Row.java:232, branch_10x and branch_10_6 are unchanged, so 10.6.0 (released 2026-10-29 from branch_10_6) is affected too, and no open PR changes Row.store (2026-10-27) [read]. lucene/analysis/stempel is identical at ea57360912ed and a9779ef3c193 (empty git diff). Executed on Linux aarch64 with JDK 25 only.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record verdict new): three scripted prior-report searches (51 queries each over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, with identifier, user-trigger and serialization wording; 0 high-overlap hits) plus the unit-level run; manual GitHub GraphQL searches of apache/lucene (stempel: 33 results, all triaged; egothor: 11; 'Row.store', Gener and 'stempel EOFException': 0) and of all GitHub; the Lucene.NET port and the outside forks and ports buda-base/stemmer, pystempel, blevesearch/stempel and Radixor; elastic/elasticsearch, OpenSearch and Solr; JIRA LUCENE, SOLR and LUCENENET (stempel 44, egothor 12); the java-user@, dev@ and issues@ lists; all 409 open apache/lucene PRs by file list (only #681 and #16911 touch org/egothor/stemmer, neither changes Row.store); and the git history (writeInt(cells.size()) appears only in the introducing commit). The independent reproduction and the intended-behaviour check ran their own searches (the intended-behaviour check: 90 scripted prior-report search queries) and found nothing either. Nearest item, not the same bug: #6584 (LUCENE-5521, open since 2014, 'Egothor Stemmer Bug for Optimizing', TestCompile fails once Compile honours upper-cased method letters); replayed on main, it is a Lift/getFully failure on tables that load fine, identical with the Row.store fix shadowed in, and it involves no G. Context only: #6569 and #7678 (ignored toUpperCase in Compile), #8841 (Trie.add visibility, with a maintainer comment against modifying forked code), #3374 (LUCENE-2298, the introducing change). The same count/skip code is in the Lucene.NET port and in the original Egothor2 code, with no report there either. Not fixed upstream. Gaps: GitHub's search rate limit was exhausted (GraphQL used instead), inline PR review comments were read by hand only for #13420, seven generic web queries were throttled, and the list search matches body text only partly.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, and the standalone JUnit runner with four seeds); the release jars 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.11.1, 9.12.3, 10.0.0 and 10.5.2 (a portable probe; the JUnit tests on 9.12.3, 10.0.0 and 10.5.2); 3.0.3 (no stempel API); fixes A and B with the whole :lucene:analysis:stempel:test task. JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Row.java:73-88 (at a9779ef3c193) (Row(DataInput), javadoc 'Construct a Row object from input carried in via the given input stream': for (int i = is.readInt(); i > 0; i--) reads exactly the stored count of (char, 4 ints) records)
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Row.java:226-246 (at a9779ef3c193) (store, javadoc 'Write the contents of this Row to the given output stream': os.writeInt(cells.size()) at :232, then 'if (e.cmd < 0 && e.ref < 0) continue;' at :237-239)
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Row.java:138-153 (at a9779ef3c193) (getCells, 'Return the number of cells in use': counts e.cmd >= 0 || e.ref >= 0, exactly the cells store writes), :108-118 (setCmd accepts a negative command, c.cnt = (cmd >= 0) ? 1 : 0 at :117)
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Gener.java:72-96 (at a9779ef3c193) (optimize, javadoc 'Return a Trie with infrequent values occurring in the given Trie removed', '@return a new optimized Trie'), :105-129 (eat: c.ref = -1 at :112, c.cmd = -1 at :122, leaving cells with neither)
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Compile.java:67 (at a9779ef3c193) ('The Compile class is used to compile a stemmer table'), :74-83 (main javadoc: method letters 'a list is available in the package description'), :148-150 (case 'G': trie = trie.reduce(g)), :174-179 (os.writeUTF(args[0]); trie.store(os))
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/package-info.java (at a9779ef3c193) (only 'Egothor stemmer API': no list of method letters); lucene/analysis/stempel/src/java/module-info.java:25 (exports org.egothor.stemmer)
  • lucene/analysis/stempel/src/java/org/apache/lucene/analysis/stempel/StempelStemmer.java:57-71 (at a9779ef3c193) (load, 'Load a stemmer table from an inputstream': new Trie(in) or new MultiTrie2(in)); org/apache/lucene/analysis/pl/PolishAnalyzer.java:49, 61, 89 (bundled stemmer_20000.tbl loaded once, method -0ME2)
  • lucene/analysis/stempel/src/test/org/egothor/stemmer/TestStemmer.java:157 (at a9779ef3c193) (Gener only in memory, never stored), TestCompile.java:79, 93, 107 (method strings 'test', '-test', 'Mtest': no reduction letter Compile acts on)
  • lucene/core/src/java/org/apache/lucene/internal/hppc/HashContainers.java:50 (at a9779ef3c193) (global ITERATION_SEED) and CharObjectHashMap.java:110, 432-434, 530-531 (per-map iteration seed, advanced for every key iterator): the order in which Row.store writes cells since #13420
The finding in 30 seconds
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Row.java:72-88 (at a9779ef3c193) (Row(DataInput)), :108-118 (setCmd), :137-153 (getCells), :225-247 (store; :232 writeInt(cells.size()), :237-239 skip)
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Gener.java:71-76 (at a9779ef3c193) (javadoc), :105-129 (eat; :110-114, :116, :120-123)
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Compile.java:67, (at a9779ef3c193) :145-172 (method letters, printInfo at :171), :174-179 (writeUTF + store)
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Trie.java:84-93 (at a9779ef3c193) (Trie(DataInput)), :310-318 (store), :372-385 (printInfo)
Who is affected
  • lucene/analysis/stempel/src/java/module-info.java:25; (at a9779ef3c193) org/apache/lucene/analysis/stempel/StempelStemmer.java:32-33; org/apache/lucene/analysis/pl/PolishAnalyzer.java:49, 88-89; org/apache/lucene/analysis/stempel/StempelPolishStemFilterFactory.java:50
  • lucene/core/src/java/org/apache/lucene/internal/hppc/HashContainers.java:50; (at a9779ef3c193) CharObjectHashMap.java:110, 432-434, 530-531
Why it looks unintended
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Row.java:72-77, (at a9779ef3c193) :137-153, :225-247; Compile.java:67, 77, 148-151, 171, 174-179; Trie.java:372-385; Gener.java:71-76; package-info.java:18; lucene/analysis/stempel/src/java/overview.html:40-41; org/apache/lucene/analysis/stempel/StempelStemmer.java:57
  • lucene/analysis/stempel/src/test/org/egothor/stemmer/TestStemmer.java:157; (at a9779ef3c193) TestCompile.java:79, 93, 107
Suggested fix
  • lucene/analysis/stempel/src/java/org/egothor/stemmer/Row.java:197-200, (at a9779ef3c193) :220-223, :231-247; Trie.java:84-93

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 two examples, made in parallel with this doc from an executed probe on main a9779ef3c193; the PNG is the final summary frame.

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.