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.
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.- You compile a 2-line dictionary,
x xa xb … xt(20 forms ofx) anda ab, with stempel'sCompiletool and methodG. Gener drops every command whose use count is below the row's total divided by 10 (rounded down): eachx?command is used once out of 20, so all 20 go, and the root cellxis left empty. - You expect
StempelStemmer.loadto read the 64-byte table back and stemabtoa, like the trie that was stored and like the same dictionary compiled without a reduction. - Instead it loads without error and stems
abtonull.Row.storewrote 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. - With
a abandac aca … actinstead, the short row is the last one in the file:loadthrowsjava.io.EOFException, looking for a record past the end. - The bundled Polish table (method
-0ME2, 0 empty cells) andPolishAnalyzerare fine. The fix is one line: writegetCells(), the number of cells actually written.
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.
- 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.stemwalks 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 lettersG,L,E,2and1apply reductions in order (:145-172), then the trie is written withTrie.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 belowframe = 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 doesRow.setRef(way, -1)on a new cell or a cell with only a reference (:126-135).
Row.storewritesos.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).Row(DataInput)reads exactly the declared number of 18-byte records (acharand fourints,: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).- So for every skipped cell the reader takes the next 18 bytes of the stream as one more record. If the stream ends first,
loadthrowsEOFException; 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. Row.getCells(), "Return the number of cells in use" (:137-153), counts exactly the cellsstorewrites, andCompileprints it ascellsafter every reduction (Compile.java:171,Trie.java:372-385).
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
Compileand a method string containingG(for exampleG,-EG,-0ME2G), or who reduce a trie withGener, or clear a command or reference withRow.setCmd(way, -1)orRow.setRef(way, -1), and store it through the exported packageorg.egothor.stemmer(module-info.java:25). Custom tables are an intended use:StempelStemmersays "there is nothing language-specific here" (StempelStemmer.java:32-33). - Opt-in. The bundled table
stemmer_20000.tblwas compiled with-0ME2(its header) and has no empty cells, soPolishAnalyzer(PolishAnalyzer.java:49,:88-89) andStempelPolishStemFilterFactory(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 plusG) and the bundled table reduced withGener(38,322 empty cells). Not everyGtable is hit:-0MGand-0MLGon the same lemmas leave no empty cell and load. In the finder's random sweep of 3,000 small tries, Gener left empty cells after1G,2G,EGandLGin 4,025 (trie, sequence) cases (one trie can count under several sequences), and every one failed the store/load round trip (3,294EOFException, 731 silent; the finder's figure only). - What the user sees. Either an
EOFExceptionwhen the application loads its table (most cases of the finder's synthetic sweep), with no hint thatGis the cause (Compilereports 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.storewrites 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).
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.
/*
* 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'));
}
}
# 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).
More files, environment, full output41 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> 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)Row.storeandRow(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", whichCompileprints after every reduction. CompilehandlesGand stores the result; Gener's javadoc promises infrequent values removed, not an unreadable table.
The full argument271 words
- One format, two halves.
Row.storeis "Write the contents of this Row to the given output stream" (Row.java:225-230) andRow(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) andCompileprints it ascellsafter every reduction (Compile.java:171,Trie.java:372-385). The skip at:237-239is deliberate; the count at:232is the part that disagrees. - The trigger is the tool's purpose.
Compile"is used to compile a stemmer table" (:67), handlesG(:148-151) and stores the result (:174-179);StempelStemmer.loadis "Load a stemmer table from an inputstream" (StempelStemmer.java:57).Gener,Trie.reduce,Trie.store,RowandRow.setCmdare public, in an exported package with no@lucene.internalor@lucene.experimentalmark. - Gener is meant to keep a usable table. Its javadoc says it removes infrequent values (
Gener.java:71-76), andTestStemmerasserts 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 (
TestCompileusestest,-testandMtest,TestCompile.java:79, 93, 107:Compilereads the leading-andM, but none of them holds a reduction letterG,L,E,2or1,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.
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.
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
Gstore 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 stockRow.storealready 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:
getCntreturns -1 for them instead of their count (Row.java:209-212), andprintor a later reduction can see that. Lookups answer the same (getCmdandgetRefreturn -1 for a missing cell,Row.java:197-200,:220-223). - One extra pass over a row's cells per stored row;
storeruns 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:testtask 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,ecjLintTestandcheckGoogleJavaFormatpass. - 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.
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.
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.
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.
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.
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,EGandLG, 3,294 withEOFExceptionand 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.Compileon 11 Polish lemmas:-0ME2G,-0M2Gand-0MEGfailed,-0MGand-0MLGleft 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists, JIRA archive, web | 2026-10-27 | three scripted prior-report search runs, 51 queries each (identifiers, how users hit it, serialization wording), plus 90 queries by the intended-behaviour checker | 0 high-overlap hits |
| GitHub (apache/lucene and all of GitHub), by hand | 2026-10-27 | stempel (33 results, all triaged), egothor (11), "Row.store", Gener, "stempel EOFException" (0 relevant); all of GitHub: stempel Gener, egothor stemmer EOFException and similar | nothing relevant |
| Open PRs | 2026-10-27 | the changed files of all 409 open apache/lucene PRs | only #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@ lists | 2026-10-27 | stempel (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 forks | 2026-10-27 | the Lucene.NET port, an Egothor2 fork, other outside ports, downstream stempel plugins | the same count and skip in the Lucene.NET port and the Egothor2 code; no report |
| GitHub issues (apache/lucene), re-check | 2026-10-28 | stempel EOFException, Row.store, Gener stempel; newest issues matching stempel | 0 results; the newest stempel issue is from 2021 (#11326, unrelated); further queries hit the search rate limit |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | paste-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 | affected | paste-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 | probe | portable 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 API | no stempel module: the probe does not compile (package org.egothor.stemmer does not exist) |
| main + fix A | not affected | every repro test passes; the module's 19 upstream tests pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Round-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, high | Its 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 | real | Own 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, high | Intended 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 results | Re-ran the reproducer's test: 3 of 3 fail with assertions on and off, 3 of 3 pass with the fix. |
| credibility audit | keep | Fourth independent re-run under new seeds; existing stempel tests pass with the fix. |
| this doc | reproduced; fixes A and B validated | A 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 477 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,738 words
- 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:testtask. JDK 25.0.3, Linux aarch64.
- 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:50lucene/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:57lucene/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
- TestRowStoreEmptyCell.java (JUnit test):
repro/LFV-694/src/org/egothor/stemmer/TestRowStoreEmptyCell.java(45 lines)
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.
assets/LFV-694/bug.png(477 KB)
