HyphenationCompoundWordTokenFilter splits compound words at the hyphenation points of a grammar. The grammar's patterns live in a ternary tree, and that tree silently loses some of them once its key array passes 65,535 chars.
HyphenationCompoundWordTokenFilterdecompounds words such as "forfatter" at hyphenation points found with a FOP-format XML grammar (Liang's algorithm). Lucene's docs send users to the OFFO project for these files.- A pattern is letters with digits between them, e.g.
.fo2r: the letters.forare the key and the digits (00020) its values.findPattern(".for")returns them; hyphenating a word combines every pattern that occurs in it. - The grammar is loaded into a
HyphenationTree, a ternary search tree: one node per character, except that when the rest of a key belongs to that key alone, the rest (its tail) is stored once in a shared char array, the key array, and the node keeps the tail's start offset.

- Load the Norwegian grammar
nb.xml(27,448 patterns) from the OFFO download that Lucene's compound docs link to, withHyphenationCompoundWordTokenFilter.getHyphenationTree. It loads with no error; the tree reaches 52,404 nodes while loading, inside the javadoc's "room for about 65000 nodes". - You expect every pattern back: for the pattern
.aa4,findPattern(".aa")should return"0004". - Instead 1,158 patterns return wrong values, 1,111 of them nothing (
findPattern(".aa")is""), andsize()says 27,358. With the older OFFO 1.2no.xml(309 of 26,017 wrong), "forfatter" splits as for-f-at-ter instead of for-fat-ter, and the filter adds the subwords forf, atter and at. - Why: the tree stores the unshared end of each pattern (its tail) once in a key array and keeps the tail's start offset in a 16-bit
char. While Norwegian loads, the key array reaches 70,280 chars; past 65,535 the offset wraps to the start of the array, so a new tail overwrites an older one. - Any grammar with that much tail text is hit: 2,600 random 30-letter patterns (4,771 nodes) lose 556. 69 of the 71 current OFFO grammars stay below the limit and load correctly.
Users of the opt-in HyphenationCompoundWordTokenFilter (directly, via getHyphenationTree/loadPatterns, or the factory's hyphenator file) whose grammar's tails pass 65,535 chars while loading: in practice the Norwegian grammars (OFFO nb.xml, nn.xml, 1.2 no.xml, TeX nb/nn). Words are then decompounded differently from the grammar at index and query time, with no error.
Mechanism, terms and numbers674 words
TernaryTree, the tree behind HyphenationCompoundWordTokenFilter's grammars, stores the unshared end of each pattern (its tail) in one key array and keeps the tail's start offset in a 16-bit char (lo[p] = (char) kv.alloc(len + 1)), with no range check. Once the key array passes 65,535 chars, offsets wrap and new tails overwrite old ones. The grammar loads without error, but some patterns then return wrong values or are not found. The Norwegian grammars from the OFFO download that Lucene's docs point to are hit (nb.xml/nn.xml: 1,158 of 27,448 patterns wrong; OFFO 1.2 no.xml: 309 of 26,017, and 29 of 104 real words hyphenate differently), far below the documented node capacity. In every release from 2.4.0 to 10.6.0 and on main; not fixed upstream.
- Storage.
TernaryTreekeeps four parallelchar[]node arrays and achar freenode(TernaryTree.java:66lo,:69hi,:72eq,:85sc,:92freenode). For a compressed node (sc[p] == 0xFFFF)lo[p]holds the offset of the key's tail in the key arraykv, not a node pointer (thelojavadoc,:62-65). - The narrowing.
insertallocates the tail withlo[p] = (char) kv.alloc(len + 1)(:151) and copies it withstrcpy(kv.getArray(), lo[p], key, start)(:152).CharVector.allocreturns anintand has no 16-bit check; repeated allocations grow the array past 65,535 (CharVector.java:102-112), so only the stored offset is cut to 16 bits: offset 65,600 becomes 64. The new tail is written at 64, over an earlier key's tail, and the slot reserved at 65,600 stays empty. - What lookups see.
findcompares the rest of the query with the tail atlo[p](:272), so the earlier key now reads a foreign tail: it is not found, or another string matches. Later inserts that split such a node read the wrong characters (:169). The first lost key appears exactly at the first insert whose offset exceeds 0xFFFF (key lengths 12, 40 and 100); with plaininsertand no compaction, 327 of 2,000 keys are already lost. - Loading makes it permanent.
loadPatterns(HyphenationTree.java:101-115) ends withtrimToSize()(:109), which rebuilds the tree from the keys it can read back (balance(),TernaryTree.java:354-372; from the code, some corrupted keys now read the same, which is whysize()drops to 27,358) and compacts the key array (compact(),:398-418).compact()narrows again at:408(map.insert(kx.getArray(), k, (char) k)) and:410(lo[p] = (char) k): the repro's compacted array is 66,302 chars, so it wraps a second time. - No check. Nothing compares an offset with 0xFFFF; the loaders document only
IOException(HyphenationTree.java:99,HyphenationCompoundWordTokenFilter.java:164,:175). - The other 16-bit limits. Node indexes are chars as well (
freenode++,:145,:164): past 65,535 nodes they wrap, and the four TeX lists that get there fail withStackOverflowError, the symptom Apache FOP's FOP-2569 reports.HyphenationTree.addPatternstores the value offset as(char) k(HyphenationTree.java:415,:417); a probe for the fix shows that it also loses values silently on a synthetic grammar (790 of 5,832 patterns, value space 75,817 bytes), while Norwegiannb.xmlneeds only 6,045 bytes.
| grammar | patterns | result |
|---|---|---|
OFFO 2.2 nb.xml, nn.xml (the documented download) | 27,448 each; while loading 52,404 nodes and a 70,280-char key array (nb.xml: 47,375 nodes once loaded) | 1,158 wrong, 1,111 of them not found; size() 27,358 |
OFFO 1.2 no.xml | 26,017 | 309 wrong; 29 of 104 real words hyphenate differently |
TeX hyph-nb, hyph-nn | 27,448 | the same 1,158 wrong |
| synthetic repro | 2,600 random 30-letter patterns; 4,771 nodes | 556 lose their values |
| TeX German 1901/1996/Swiss, Hungarian | past 65,535 nodes | StackOverflowError (FOP-2569) |
| 69 of 71 OFFO 2.2, 20 of 21 OFFO 1.2, 68 of 75 TeX lists (e.g. Latin, 26,076 patterns) | correct |
The trigger is the length of the key array that holds the stored tails, not the number of patterns.
Exposure and likelihood286 words
- Who is hit. Applications that use the opt-in
HyphenationCompoundWordTokenFilter(in no default analyzer) directly, throughgetHyphenationTreeorHyphenationTree.loadPatterns, or throughHyphenationCompoundWordTokenFilterFactory, whose mandatoryhyphenatorfile is documented as "path to the FOP xml hyphenation pattern" with a link to OFFO (:38-39, loaded at:119). - Which grammars. In practice Norwegian: OFFO 2.2
nb.xmlandnn.xml, OFFO 1.2no.xml, and the TeXhyph-nb/hyph-nnlists. 69 of the 71 OFFO 2.2 grammars, 20 of 21 OFFO 1.2 grammars and 68 of 75 TeX lists load correctly. - What goes wrong. Wrong decompounding with no error. With OFFO 1.2
no.xml, 29 of 104 real Norwegian words hyphenate differently and the filter emits different subwords (forfatter adds forf, atter, at; altertavle splits alt-er-tav-le instead of al-ter-tav-le). Some of those words contain no lost pattern: overwritten tails also create false matches. With the TeXnblist in a grammar with classes, 32 of 235 words from Lucene's own Norwegian stemmer test data differ. - Consistent, so easy to miss. Index time and query time use the same corrupted tree. After a fix, already indexed documents keep the old subwords until they are reindexed (inference, not run).
- Masked case. None of the 71 OFFO 2.2 files has the
<classes>element that Lucene's parser needs to hyphenate anything, so with 2.2nb.xmlthe loss shows only throughfindPattern(0 of 300 words differ end to end). That missing-classes gap is a separate issue. - Louder neighbours. Grammars past 65,535 nodes (the newer TeX German and Hungarian lists) fail at load time with
StackOverflowError(FOP-2569); TeX Afrikaans throws the related LFV-894 exception. - Workaround. Only a grammar whose tails stay under 65,535 chars, e.g. split or trimmed (not tried).
One JUnit test, one method, public API only: 2,600 distinct random 30-letter patterns (fixed seed) loaded with getHyphenationTree, then findPattern for each. It fails on main and 10.6.0 with assertions on and off and on 9.12.3 and 9.1.0 (assertions on), and passes with the fix. Synthetic, because the real grammars cannot be bundled (LPPL license).
/*
* 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.compound;
import java.io.StringReader;
import java.util.LinkedHashSet;
import java.util.Random;
import java.util.Set;
import org.apache.lucene.analysis.compound.hyphenation.HyphenationTree;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.xml.sax.InputSource;
public class TestHyphenationTreeLongKeyArray extends LuceneTestCase {
public void testEveryPatternKeepsItsValues() throws Exception {
// 2,600 distinct 30-letter patterns such as "q1mdkiasnm...": the tree needs only 4,771 nodes
// (the TernaryTree javadoc gives "room for about 65000 nodes"), but its key array passes
// 65,535 chars while the patterns are loaded.
Random random = new Random(7);
Set<String> words = new LinkedHashSet<>();
while (words.size() < 2600) {
char[] word = new char[30];
for (int i = 0; i < word.length; i++) {
word[i] = (char) ('a' + random.nextInt(26));
}
words.add(new String(word));
}
StringBuilder xml = new StringBuilder("<hyphenation-info><patterns>\n");
for (String w : words) {
xml.append(w.charAt(0)).append('1').append(w, 1, w.length()).append('\n');
}
xml.append("</patterns></hyphenation-info>\n");
HyphenationTree tree =
HyphenationCompoundWordTokenFilter.getHyphenationTree(
new InputSource(new StringReader(xml.toString())));
String expected = "01" + "0".repeat(29); // a hyphen after the first letter, nowhere else
int lost = 0;
for (String w : words) {
if (!expected.equals(tree.findPattern(w))) {
lost++;
}
}
assertEquals("patterns that lost their values", 0, lost);
}
}
cd $LUCENE
cp $REPRO/TestHyphenationTreeLongKeyArray.java lucene/analysis/common/src/test/org/apache/lucene/analysis/compound/
./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.compound.TestHyphenationTreeLongKeyArray -Ptests.seed=DEADBEEF
# with seed DEADBEEF this runs with assertions off; with assertions on it fails the same way: add -Ptests.asserts=true
- Expected
- All 2,600 patterns return their values,
01followed by 29 zeros. - Observed
- 556 patterns lose their values (
expected:<0> but was:<556>), and loading throws nothing.
More files, environment, full output268 words
The test sits in org.apache.lucene.analysis.compound like the module's own tests but uses only public API. Each pattern is written as x1yyyy... (a hyphen after the first letter), so every one must return 01 followed by 29 zeros. The grammar comes from its own java.util.Random(7), so the result does not depend on the test seed. Single thread, deterministic, about 1 s; compiles against 9.1 and later (org.apache.lucene.tests first exists in 9.1.0; against 9.0.0 it does not compile); for 2.4.0 to 9.0.0 a version-portable main() with the same grammar covers the older releases.
The loaded tree has 4,771 nodes (the javadoc promises room for about 65,000) and, even after compaction, a key array of 66,302 chars. The same test is the suggested regression test; it passes with the recommended fix.
Lucene's build picks tests.asserts at random from the seed when it is not given: for -Ptests.seed=DEADBEEF on :lucene:analysis:common it is off (the failure's "Reproduce with" line prints -Ptests.asserts=false), so add -Ptests.asserts=true for a run with assertions on; it fails the same way.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT), 10.6.0, 9.12.3, 9.1.0: fail; 9.0.0: does not compile |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
# gradle, main 5adff78bb168, seed DEADBEEF (assertions off by this seed's randomization)
TestHyphenationTreeLongKeyArray > testEveryPatternKeepsItsValues FAILED
java.lang.AssertionError: patterns that lost their values expected:<0> but was:<556>
at org.junit.Assert.assertEquals(Assert.java:647)
at org.apache.lucene.analysis.compound.TestHyphenationTreeLongKeyArray.testEveryPatternKeepsItsValues(TestHyphenationTreeLongKeyArray.java:59)
# gradle with -Ptests.asserts=true: the same failure
# JUnit runner, seed DEADBEEF: the same failure on main (-ea and -da), 10.6.0 (-ea and -da), 9.12.3 and 9.1.0 (-ea)
# main with the recommended fix: passes (gradle, assertions off and on; JUnit runner -ea)- The class calls itself "a map with strings as keys and char as value" with "room for about 65000 nodes"; the Norwegian tree peaks at 52,404 nodes while loading.
- No key-array limit is documented or checked; loaders document only
IOException. - Lucene's docs point to OFFO files that load corrupted; a size limit would justify an error, not wrong values.
The full argument233 words
- Documented design. The class javadoc is a design note, not an error contract, but what it describes is a map ("All said, this is a map with strings as keys and char as value",
TernaryTree.java:48) with one capacity, in nodes: "Using java's char type as pointer ... This gives room for about 65000 nodes" (:42-45). The Norwegian tree peaks at 52,404 nodes while loading (47,375 once loaded); the repro's has 4,771. No javadoc, package doc, CHANGES or MIGRATE entry mentions a key-array limit or that wrong values can result.loadPatterns: "@throws IOException In case the parsing fails" (HyphenationTree.java:99). - Lucene points to these files. "You can find the XML based grammar files at the ... (OFFO) Sourceforge project (direct link to download the pattern files: ...offo-hyphenation.zip). The files you need are in the subfolder offo-hyphenation/hyph/" (
package-info.java:88-96). That zip is OFFO 2.2 and contains the affectednb.xmlandnn.xml. - Silent by construction.
CharVector.allocreturns anintwith no 16-bit check, so the array grows past 65,535 (CharVector.java:102-112); the(char)cast is the only limit, and nothing checks it. - Precedent. LUCENE-8124 (#9172, fixed in 7.3.0): grammars the parser accepts but that gave wrong values (pattern values >= 7) were fixed as a bug.
- Tests. The module's compound tests load only small grammars (
da_UTF8.xmland two small LUCENE-8124/8183 grammars); they all pass with the fix.
The devil's-advocate check222 words
The strongest case for "intended": the 2008 javadoc deliberately trades generality for 16-bit pointers, expects "from 5000 to 15000 hyphenation patterns" that "are usually small (from 2 to 5 characters)" and says "Pretty limited!" (TernaryTree.java:38-48); the lo javadoc says lo also holds the pointer "to rest of the key" and lo is a char[], so a careful reader could infer 16-bit key offsets; a committer of Apache FOP (where the class comes from) wrote on FOP-2569 that it is designed "for a maximum number of hyphenation patterns of around 65535"; and downstream docs say to use OFFO v1.2 files only.
Why it fails: the only stated capacity is in nodes, and the corrupted trees are inside it (and inside "around 65535 patterns"); "usually small (from 2 to 5 characters)" is the 2008 author's expectation of typical patterns, followed by their own test numbers ("so I think we are safe"), not a documented precondition or limit; the "v1.2 only" notes are about the missing <classes> element, and v1.2 no.xml is affected too; and a size limit would justify an exception at load time, not a tree that loads and then answers wrongly. The works-as-designed reviewer rated the argument "moderate" and rejected it. A maintainer may still answer "documented as limited"; the fix is the same either way: widen, or throw.
Recommended A. Widen the key-array offsets to int, fail clearly at the other 16-bit limits (validated)
Store key-array offsets in an int[] lo (casting back where lo holds a node pointer), dedupe tails in compact() with a HashMap<String, Integer>, and throw a clear IllegalStateException instead of wrapping past 65,535 nodes or value-space bytes. The repro, the Norwegian nb.xml/nn.xml and all 1,910 analysis-common tests pass.
protected int[] lo; // was char[]: lo also holds offsets into the key array kv
lo[p] = kv.alloc(len + 1); // insert(): was (char) kv.alloc(len + 1)
HashMap<String, Integer> map = new HashMap<>(); // compact(): offsets kept as int
// node pointers read from lo are cast back: insert((char) lo[p], ...), p = (char) lo[p]
if (freenode + len > Character.MAX_VALUE) { // insert(String, char) and insert(char[], int, char)
throw new IllegalStateException("TernaryTree is full: it can hold at most 65535 nodes"); // caveat 1
}
if (k > Character.MAX_VALUE) { // HyphenationTree.addPattern, before (char) k
throw new IllegalStateException(
"Hyphenation values do not fit: value space offset " + k + " exceeds 65535");
}All options and trade-offs3 options ยท 888 words
Three options. A was built and validated on main 5adff78bb168; a key-offset guard like B's was run by a verifier as a cause check; C was not built. All of them run at grammar load time only.
A. Widen the key-array offsets to int, fail clearly at the other 16-bit limits (validated) (recommended)
lo becomes int[], so the offset from kv.alloc is stored whole; reads of lo as a node pointer are cast back to char. compact() dedupes tails with a HashMap<String, Integer> instead of a TernaryTree with char values. Two guards replace the remaining silent wraps: the node index (in both public insert methods; exact on the loader path, see the caveats below) and the value-space offset in HyphenationTree.addPattern. +35/-19 in TernaryTree.java and HyphenationTree.java.
protected int[] lo; // was char[]: lo also holds offsets into the key array kv
lo[p] = kv.alloc(len + 1); // insert(): was (char) kv.alloc(len + 1)
HashMap<String, Integer> map = new HashMap<>(); // compact(): offsets kept as int
// node pointers read from lo are cast back: insert((char) lo[p], ...), p = (char) lo[p]
if (freenode + len > Character.MAX_VALUE) { // insert(String, char) and insert(char[], int, char)
throw new IllegalStateException("TernaryTree is full: it can hold at most 65535 nodes"); // caveat 1
}
if (k > Character.MAX_VALUE) { // HyphenationTree.addPattern, before (char) k
throw new IllegalStateException(
"Hyphenation values do not fit: value space offset " + k + " exceeds 65535");
}Trade-offs
logrows from 2 to 4 bytes per node (about 100 KB more for the Norwegian tree at its 52,404-node peak while loading), at load time only; the analysis path only gains casts. No benchmark was run.lois aprotectedfield of the publicTernaryTree, andHyphenationTreeis public and subclassable, so the type change is an API change for subclasses that readlo(a MIGRATE note on main; consider B for a 10.x backport).- Grammars past 65,535 nodes still cannot be loaded, but now fail with a clear message.
- Caveat 1:
freenodestarts at 1 and the guard isfreenode + len > Character.MAX_VALUE, so the tree stops at 65,534 nodes; the message's "at most 65535 nodes" is an upper bound. Say 65534, or "node indexes must fit in a char", in the final patch. - Caveat 2: in
insert(char[], int, char)the new guard, like that method's existing capacity check, sizes the key withstrlen(key)instead ofstrlen(key, start)(the LFV-894 defect,TernaryTree.java:131on main). It is exact on the loader path (insert(String, char)); direct callers with a NUL beforestartstill need LFV-894's own fix.
What the checks validated
- Gradle in a clean scratch worktree of
5adff78bb168, OpenJDK 25.0.3, seed DEADBEEF: stock, the repro fails (556) with assertions off (this seed's default) and on (-Ptests.asserts=true); with the fix it passes with assertions off and on. The assertions-on gradle runs were made by the independent fact check (2026-11-04) in its own scratch worktree; the JUnit runner with the fix and-eapasses too. org.apache.lucene.analysis.compound.*with the fix: 5 classes, 26 tests (the repro included), 0 failures, assertions off and on; the whole:lucene:analysis:common:test: 1,910 tests, 18 skipped, 0 failures (assertions off by the seed);:lucene:analysis:common:check -x test(formatting, ecj lint, forbidden APIs, license headers) passes.- The OFFO
nb.xmlandnn.xmlload with 0 wrong patterns (stock: 1,158 each); the TeXhyph-de-1996list now throwsTernaryTree is full: it can hold at most 65535 nodesinstead ofStackOverflowError(fixed classes over main's jars, assertions off). - A probe grammar whose value space passes 65,535 bytes now throws instead of loading with 790 of 5,832 patterns wrong; a 40,000-pattern probe grammar written to pass 65,535 nodes (the probe prints no node count), which on stock throws
ArrayIndexOutOfBoundsException: Index 40000 out of bounds for length 40000while loading, now stops at the node guard withTernaryTree is full. - Side effect:
compact()no longer callsinsert(char[], int, char), so the related LFV-894 repro passes too. That public method itself is not fixed by this (caveat 2).
Throw a clear exception when kv.alloc (:151) or compact()'s kx.alloc returns more than 0xFFFF, plus A's two guards. A verifier's guard of this kind makes OFFO 1.2 no.xml fail with "key-array offset 65536 does not fit in a char" while its node-index guard never fires, and nl.xml and en_GB.xml still load.
Trade-offs
No API change and easy to backport, but the Norwegian grammars then fail to load at all instead of loading about 96% right.
lo, hi, eq, root, freenode and the value offset become int, so grammars of any size load, including the newer TeX German and Hungarian lists. Apache FOP's unapplied FOP-2569 patch does most of this but keeps the key-offset casts, so it would have to be extended.
Trade-offs
A node grows from 8 to 14 bytes (lo, hi, eq become int; sc stays a char, as in FOP-2569's patch; root, freenode and the value offset are not per node), about 0.3 MB more at the Norwegian tree's 52,404-node peak; touches TernaryTree, its iterator and HyphenationTree.searchPatterns. Not built.
Downstream: Elasticsearch's hyphenation_decompounder docs say only FOP v1.2 grammars are supported, and OFFO 1.2 no.xml is one of the affected files; Solr setups use Lucene's factory, which loads through the same getHyphenationTree (from reading, not run).
Semi-formal: TernaryTree and HyphenationTree were checked against an exact reference map. A bounded-exhaustive pass over small trees found no mismatch; the same exact check on 75 real TeX pattern lists flagged the Norwegian lists (1,158 wrong, no error). A measurement probe tied it to the 65,535-char key array, and it was reduced to the synthetic test.
The method in full151 words
Method: semi-formal (translation validation against an exact reference).
- A bounded-exhaustive phase compared
TernaryTree/HyphenationTreewith an exact reference on small inputs: 3,305,820 checks, 0 mismatches (small trees never reach the limit). - The same exact oracle was then run on the stock jar with 75 real TeX pattern lists loaded through the public API: every pattern's
findPatternwas compared with the values written in the pattern text. The Norwegiannbandnnlists returned 1,158 wrong values with no load error; four large lists threwStackOverflowError. - A same-package measurement probe (node count and key-array length during insertion) tied the loss to the key array passing 65,535 chars (Norwegian at the end of insertion: a 70,280-char key array and 52,404 nodes).
- It was then reduced to a public-API JUnit test on a synthetic grammar.
The first signal came from the exact oracle on real lists, not from the bounded-exhaustive phase itself.
New: no Lucene issue, PR, JIRA entry, list thread or commit, and nothing downstream, describes this defect (re-checked 2026-11-03 and 2026-11-04). Related: Apache FOP's FOP-2569 reports a StackOverflowError when node indexes overflow past about 65,000 nodes, a different defect, and its patch keeps these casts; #2243 is the 2008 contribution that added the code.
Searches3 searches
No report of this defect (re-checked 2026-11-03 in two independent passes, again for this doc, and on 2026-11-04). Related prior items, none of them this defect:
- FOP-2569 (Apache FOP tracker, open since 2016; FOP is the project the class was taken from):
StackOverflowErrorinTernaryTree.inserton very large grammars (a German one of about 70,000 patterns fails even with-Xss1024m). A maintainer quotes the "room for about 65000 nodes" javadoc, and a 2020 patch, never applied, widenslo/hi/eq,root,freenodeand the values toint. It is about node indexes overflowing beyond the documented capacity, a loud failure. It never mentions key-array offsets, silent pattern loss or any failure inside the node capacity, and its patch keepslo[p] = (char) keyVector.alloc(len + 1)and thecompact()casts, so it would not fix this defect. - #2243 (LUCENE-1166, 2008): the contribution that added the hyphenation package. Its 33 comments do not discuss the pointer width or any size limit; it is the origin of the code, not a report.
- History, not reports: #9172 (LUCENE-8124, values >= 7), #4120 (LUCENE-3047, one German word), #4950, #16135, #9231 and #14624 (token output), elastic/elasticsearch#13935 (a hand-made pattern with value 7), german-decompounder#4, a 2009 fop-users thread and Stack Overflow 46410258 (
StackOverflowErrorcured by a larger stack), FOP-1272, FOP-1773. iText's copy of the class madeinsertiterative but kept thecharpointers. - The same casts are present in Apache FOP trunk, iText 7, OpenPDF and Lucene.NET.
A maintainer may still put this and FOP-2569 in one family: 16-bit pointers in a class that calls itself "Pretty limited!". What separates them: this defect hits trees well inside the documented node capacity, it is about key-array offsets, not node indexes, it is silent (wrong values, no error), and FOP-2569's patch keeps these casts.
| Where | When | How | Found |
|---|---|---|---|
| GitHub apache/lucene issues, PRs and commits (incl. the migrated JIRA archive); Elasticsearch, OpenSearch, Solr, Lucene.NET, german-decompounder, OpenPDF, iText, FOP; dev@, java-user@, issues@, commits@, fop-users@, fop-dev@; JIRA (FOP, SOLR, LUCENE, LUCENENET); web | 2026-10-28 | the scripted prior-report search, 2 runs of 57 queries; 53 manual GitHub searches; 18 list queries; 14 web queries; FOP-2569 read with all comments and its patch; open PR file lists | only the related items above |
| Re-check for this doc: GitHub issues, PRs and commits, downstream trackers, the four Lucene lists, JIRA | 2026-11-03 | the scripted prior-report search, 45 queries (16 GitHub, 3 commit, 2 downstream, 20 list, 4 JIRA), 105 hits; GitHub searches for TernaryTree, HyphenationTree, hyphenation items created or updated since 2026-10-27, open PRs on hyphenation; the latest commits on the package | nothing new; no hyphenation item since 2026-10-27; the last change to the package is #16713 (2026-09-25, formatter) |
| GitHub apache/lucene issues, PRs and commits; downstream trackers; dev@, java-user@, issues@, commits@; JIRA | 2026-11-04 | the scripted prior-report search twice (the independent fact check, and once more without the cache for this pass), 45 queries each, 105 distinct hits; read-only GitHub searches for TernaryTree, hyphenation and HyphenationTree | nothing new: the same related items (#2243, the LUCENE-4570 commit thread, the LUCENE-8124 thread); none describes this defect |
Introduced with the class in 2008 by 7a27cdcbc901 (LUCENE-1166, #2243), code taken from Apache FOP; first released in 2.4.0. The same grammar loses 556 of 2,600 patterns on every release run from 2.4.0 to 10.6.0 and on main; 2.3.2 has no such API. Not fixed upstream.
Versions tested7 rows
Introduced by 7a27cdcbc901 (svn r657027, 2008-05-16; author date = commit date), "LUCENE-1166: Added token filter for decomposing compound words" (issue #2243). It added the compound/hyphenation package, "taken from the Apache FOP project" (TernaryTree.java:50-51), with the char pointers and the casts now at :151, :408 and :410. Pre-GitHub, so no PR. git blame -w -M -C -C attributes the defect lines to it, and a pickaxe over all refs finds no later change on the main lineage; the 28 later commits to the file are moves, formatting and cleanups. On main the last commits on the package are #16713 (2026-09-25, formatter), #16255 (2026-06-14, typos check) and #16135 (2026-05-30, external entities in PatternParser); none touches these lines.
| Version or commit | Verdict | Result |
|---|---|---|
| 2.3.2 | no API | no compound package; the repro does not compile |
7a27cdcbc901 sources over the 2.4.0 jars | introducing commit | 556 of 2,600 patterns lost; 4,771 nodes, key array 66,302 chars |
| 2.4.0, 2.4.1, 2.9.4, 3.0.3, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 | affected | 556 of 2,600 lost (a version-portable main() with the same grammar); a 1,500-pattern control passes on 2.4.0; the JUnit test needs 9.1 or later (it fails on 9.1.0 and does not compile on 9.0.0) |
| 9.12.3, 10.5.2 | affected | 556 lost with assertions on and off; OFFO nb.xml 1,158 wrong |
| 10.6.0 | affected | the minimal repro fails, assertions on and off |
main 5adff78bb168 | affected | the minimal repro fails (gradle and the JUnit runner, assertions on and off); OFFO nb.xml 1,158 wrong |
main 5adff78bb168 + fix A | not affected | the repro passes; nb.xml/nn.xml load intact; 1,910 analysis-common tests pass |
A cross-model refute review (OpenAI GPT-5.6), an independent reproducer, a works-as-designed adversary (GPT-5.6) and a devil's-advocate verifier each rated it real with their own runs; the adjudication confirmed it (high confidence). This doc reproduced it on main 5adff78bb168, 10.6.0 and 9.12.3 and validated the fix on the repro, the Norwegian grammars and the module tests.
Checks and verdicts400 words
The reviews, verifiers and judge ran on main ea57360912ed or a9779ef3c193; the hyphenation package is identical up to 5adff78bb168. A credibility audit (2026-10-28) and a re-verification on e1faeba08bde (2026-11-02) reproduced it.
| Check | Verdict | What it found |
|---|---|---|
| finder | found | Exact oracle on 75 real TeX lists (Norwegian 1,158 wrong); public-API JUnit with the synthetic grammar fails (556 of 2,600) on main, branch_10_6, 10.5.2 and 9.12.3 with assertions on, and also with assertions off on main and 10.5.2. |
| refute review (OpenAI GPT-5.6, executed) | real (high) | Own public-loader test: 700 patterns of 120 letters with fewer than 2,048 nodes lose patterns, its below-limit control passes; root cause agreed, inputs in contract. |
| independent reproducer | real | Own test: 544 of 2,000 lost through the loader, 327 of 2,000 after plain insert, the control passes; the first loss comes exactly at the first offset above 0xFFFF; the unmodified OFFO nb.xml gives 1,158 wrong on main, 10.5.2 and 9.12.3; its widening sketch fixes all of them. |
| works-as-designed adversary (OpenAI GPT-5.6, executed) | real (high) | Own test (258 of 700 lost) fails on main and 10.5.2; all 71 OFFO 2.2 grammars checked: only nb.xml and nn.xml wrong; rated the intended-behaviour argument moderate. |
| devil's-advocate verifier | real (high) | OFFO 1.2 no.xml: 309 wrong, 29 of 104 real words hyphenate differently; a guard shows a key-offset wrap and no node wrap; split off the StackOverflowError part. |
| adjudication | confirmed (high), Medium | Re-ran the independent test (2 of 3 fail, assertions on and off; the control passes) and the finder's test with assertions off (556 of 2,600). |
| this doc | reproduced; fix validated | One-method repro fails on main 5adff78bb168 (the JUnit runner with assertions on and off; gradle with assertions off, and on in the fact check's run), 10.6.0 and 9.12.3; fix A passes it, the OFFO Norwegian grammars and all 1,910 analysis-common tests. |
| fact checks and diagram check (2026-11-04) | real as described; wording fixed | An independent fact check (it re-ran the repro on main with the JUnit runner and gradle, assertions on and off, the fix, and 9.0.0/9.1.0) and a cross-model fact check (OpenAI GPT-5.6) found the bug, its mechanism, scope, fix and novelty sound, and found wording and labelling errors (assertion mode of the gradle runs, the 9.1 compile floor, insertion-phase vs loaded node counts, fix C's memory figure, fix A's guard caveats). All were fixed. |
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 560 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,651 words
- Registry title
- Hyphenation grammars whose TernaryTree key array grows past 65,535 chars load without error but silently lose or corrupt patterns, because TernaryTree stores key-array offsets in 16-bit chars (the Norwegian OFFO grammars the Lucene docs point to: nb/nn 1,158 of 27,448 patterns wrong, 1.2 no.xml 309 of 26,017)
- Area
- analysis-common / compound hyphenation (TernaryTree insert and compact via HyphenationTree.loadPatterns and HyphenationCompoundWordTokenFilter)
- Priority
- Medium, rank 142: The Norwegian grammars the Lucene docs point to load into a silently corrupted tree for the opt-in HyphenationCompoundWordTokenFilter, but only about one language is affected.
- Impact class
- silent wrong results
- Impact note
- Hits applications that use the opt-in HyphenationCompoundWordTokenFilter (directly, through getHyphenationTree or HyphenationTree.loadPatterns, or through the factory's hyphenator file) with a grammar whose stored key tails pass 65,535 chars while loading, such as the Norwegian grammars of the OFFO project the Lucene docs point to (2.2 nb.xml and nn.xml, 1.2 no.xml) and the TeX nb/nn lists: they load with no error and far below the documented 'room for about 65000 nodes' (52,404 nodes), but 1-4% of their patterns are lost or wrong, so with OFFO 1.2 no.xml or the TeX nb list some real Norwegian words are hyphenated and decompounded differently at index and query time (29 of 104 and 32 of 235 words tested), with -ea and -da, in every release from 2.4.0 to 10.6.0 and on main; 69 of the 71 current OFFO grammars and 68 of 75 TeX lists load correctly, and grammars past 65,535 nodes (newer German and Hungarian TeX lists) fail loudly with StackOverflowError instead.
- Summary
- You load a well-formed FOP-format hyphenation grammar with HyphenationCompoundWordTokenFilter.getHyphenationTree (or HyphenationTree.loadPatterns, or the factory's hyphenator file) and expect findPattern to return every pattern's values afterwards, or an exception if the grammar does not fit. For a grammar whose stored key tails pass 65,535 chars, such as the Norwegian nb.xml from the OFFO download the Lucene docs link to (27,448 patterns, 52,404 nodes), loading succeeds but 1,158 patterns return wrong values (1,111 are not found at all), and with the OFFO 1.2 Norwegian grammar real words such as 'forfatter' are hyphenated and decompounded differently. The cause: TernaryTree keeps the offset of each key tail in its key array in a 16-bit char (lo[p] = (char) kv.alloc(len + 1), TernaryTree.java:151, and (char) k in compact() at :408/:410) without a range check, so past 65,535 the offset wraps and the new tail is written over an earlier one; grammars past 65,535 nodes fail with StackOverflowError instead, which Apache FOP's open FOP-2569 reports, a related but different defect.
- Introduced
- 7a27cdcbc901cd5e5ebb44065e3158866156dc08 (svn r657027), LUCENE-1166 / #2243 'A tokenfilter to decompose compound words' (pre-GitHub, no PR; CHANGES 2.4.0) [read: git blame -w -M -C -C attributes TernaryTree.java:66 (char[] lo), :92 (char freenode), :151 and :408/:410 on main to this commit; git log -S over all refs finds no other change on the main lineage; release boundary executed]. The commit added the compound/hyphenation package copied from Apache FOP (the class javadoc says so), with the char-typed pointers and the (char) casts of key-array offsets; the 33-comment LUCENE-1166 discussion does not mention the pointer width or any size limit. When the casts entered FOP was not traced. (2008-05-16 (author date = commit date 2008-05-16T12:22:50Z); first released in 2.4.0 (tagged 2008-10-08))
- First affected version
- 2.4.0 [exec] (the first release containing 7a27cdcbc901 and the first that ships org.apache.lucene.analysis.compound: the version-portable main() repro loses 556 of 2,600 patterns there, the introducing commit's own 9 hyphenation sources compiled over the 2.4.0 jars give the same counts, and the 1,500-pattern control loads intact). 2.3.2 and earlier are not affected because the API is absent [exec: compile error, package does not exist].
- Affected versions
- Every release from 2.4.0 to 10.6.0 (10.6.0 executed as the branch_10_6 head 4ae946e5a9b9), branch_10x and main a9779ef3c193; not fixed upstream (main c5ab3e57b9ba has the same lines) [read: no commit on the main lineage changed the defect lines (pickaxe over all refs), per-tag grep of the 2.4.0, 3.6.2, 4.0.0, 5.5.5, 6.6.6, 7.7.3, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 trees; upstream branch_10_6 2763db11b0a and branch_10x 1309534296e differ from main only in a comment]. [exec: the main() repro fails identically (556 of 2,600 patterns lost, 4,771 nodes, key array 66,302 chars) on the introducing commit's sources and on 2.4.0, 2.4.1, 2.9.4, 3.0.3, 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.12.3, 10.0.0, 10.5.2 and main (-ea; also -da on 9.12.3, 10.5.2 and main); the finder's JUnit fails on 9.12.3, 10.0.0, 10.5.2, branch_10_6 4ae946e5a9b9 and main (-ea; also -da on 10.5.2 and main); the independent reproduction's JUnit fails on main, 10.5.2 and 9.12.3 (-ea and -da); the unmodified OFFO nb.xml loads corrupted on main, 10.5.2 and 9.12.3 and OFFO 1.2 no.xml on main and 10.5.2].
- 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
- no Lucene report; related item outside Lucene (searched 2026-10-28, plus the finder's run and the independent reproduction's, the intended-behaviour check's and the adjudication's checks, all read-only; refreshed the same day; re-checked 2026-11-03): two scripted prior-report searches (57 queries each: GitHub issues and PRs, commits, downstream repositories, the Lucene lists, JIRA, web; the closest hit, elastic/elasticsearch#13935, is not this bug), 53 manual GitHub searches (apache/lucene, lucene-solr, Lucene.NET, Solr, Elasticsearch, OpenSearch, german-decompounder, OpenPDF, iText, Apache FOP; updated since 2026-10-25, closed since 2026-10-26), JIRA (FOP/XGC, SOLR, LUCENE, LUCENENET), 18 lists.apache.org queries (fop-users@, fop-dev@, Solr and Lucene lists), 14 web queries. No Lucene, Solr, Elasticsearch, OpenSearch or Lucene.NET issue, PR, list thread or forum post reports a large grammar that loads but loses or corrupts patterns, a 65,535-char key-array limit, or a TernaryTree StackOverflowError. Related: FOP-2569 in the Apache FOP tracker (the project this class was taken from; open since 2016) reports StackOverflowError in TernaryTree.insert on large grammars (a German one of about 70,000 patterns fails even with -Xss1024m), a maintainer quotes the 'room for about 65000 nodes' javadoc, and a 2020 patch, never applied, widens lo/hi/eq, root, freenode and the values to int; it is about that StackOverflowError (node indexes past about 65,000 nodes), a different defect, and never mentions key-array offsets, silent pattern loss or any failure within the node capacity, and its patch keeps the (char) casts of key-array offsets (patch line 107 and the unchanged compact() lines), so from code reading it would not fix this bug. Others, not the same bug: fop-users 2009 thread and Stack Overflow 46410258 (StackOverflowError cured by a larger stack), iText b1281a42c0 (iterative insert in iText's copy of the class, char pointers kept), #2243 (LUCENE-1166, the contribution that added the code), #9172 (LUCENE-8124, values >= 7), #4120 (LUCENE-3047), #4950, #16135, #9231, elastic/elasticsearch#13935, german-decompounder#4, FOP-1272, FOP-1773. Not fixed: upstream main c5ab3e57b9ba still has TernaryTree.java:151, :408 and :410; the last commits under analysis/compound are #16680 (author date = commit date 2026-09-28, CompoundWordTokenFilterBase and its test only) and #16713 (2026-09-25, formatter); of the open PRs updated since 2026-10-24 only #16911 ('Decommission branch 10x', which deletes the branch_10x files) lists TernaryTree.java; the refresh (the 40 most recently updated PRs, all updated since 2026-10-26, up to #16926, first 100 changed files each; the 22 issues and PRs created since 2026-10-26, up to #16928; 'hyphenation' updated since 2026-10-26: 0 hits; 'TernaryTree': only #4950) found nothing related. The same casts are unfixed in Apache FOP trunk, iText 7, OpenPDF and Lucene.NET. Gaps: searches cannot prove absence; GitHub search reads the first page only; the iText tracker is not public; private reports are not searchable.
- Lucene commit tested
- main
5adff78bb168(gradle in a scratch worktree, assertions off by the seed and on with-Ptests.asserts=true; the JUnit runner with-eaand-da), the released 10.6.0, 9.12.3 and 9.1.0 jars, and the OFFO Norwegiannb.xml/nn.xmlwith and without the fix, 2026-11-03 and 2026-11-04. Earlier runs: maina9779ef3c193ande1faeba08bde, branch_10_6, every release run from 2.4.0 to 10.5.2, and the introducing commit's sources.
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/TernaryTree.java:38-50 (class javadoc: 5000 to 15000 patterns 'usually small (from 2 to 5 characters)', char pointers, 'room for about 65000 nodes' at :44, 'a map with strings as keys and char as value. Pretty limited!' at :48), :66 (protected char[] lo), :92 (protected char freenode), :96 (BLOCK_SIZE = 2048), :145 and :164 (freenode++), :151 (lo[p] = (char) kv.alloc(len + 1)), :152 (strcpy at the narrowed lo[p]), :382-396 (trimToSize -> compact), :398-418 (compact: map.insert(kx.getArray(), k, (char) k) at :408, lo[p] = (char) k at :410) @ a9779ef3c193lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/CharVector.java:102-110 (alloc returns an int offset and grows the char[] past 65,535); lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/HyphenationTree.java:96-115 (loadPatterns documents only '@throws IOException In case the parsing fails'; trimToSize() at :109), :411-417 (addPattern: ivalues.insert(ivalue, (char) k) at :415 and insert(pattern, (char) k) at :417, the value-space offset, read only)lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/HyphenationCompoundWordTokenFilter.java:158-182 (getHyphenationTree(String|InputSource) -> loadPatterns, '@throws IOException If there is a low-level I/O error'); lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/HyphenationCompoundWordTokenFilterFactory.java:38-39 ('hyphenator' mandatory, 'path to the FOP xml hyphenation pattern', links offo); lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/package-info.java:88-97 ('Grammar file': the OFFO download offo-hyphenation.zip, 'The files you need are in the subfolder offo-hyphenation/hyph/')
- The finding in 30 seconds
lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/TernaryTree.java:38-51, (at 5adff78bb168) :62-66, :69, :72, :85, :92, :145-152, :160-191, :272, :354-372, :382-418lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/CharVector.java:102-112 (at 5adff78bb168)lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/HyphenationTree.java:96-115, (at 5adff78bb168) :411-418lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/HyphenationCompoundWordTokenFilter.java:159-182 (at 5adff78bb168)
- Who is affected
lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/HyphenationCompoundWordTokenFilterFactory.java:38-39, (at 5adff78bb168) :107-123lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/package-info.java:88-96 (at 5adff78bb168)
- Why it looks unintended
lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/TernaryTree.java:38-51, (at 5adff78bb168) :62-66lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/HyphenationTree.java:95-100; (at 5adff78bb168) CharVector.java:102-112lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/package-info.java:88-96 (at 5adff78bb168)
- Diagrams
lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/TernaryTree.java:151-152, (at 5adff78bb168) :272
- TestHyphenationTreeLongKeyArray.java (one test method):
repro/LFV-897/TestHyphenationTreeLongKeyArray.java(61 lines)
Made from the code at 5adff78bb168 and the numbers on this page; the PNG is the summary still.
assets/LFV-897/bug.png(560 KB)
