All findings · Lucene finding · LFV-897 · ternarytree-key-array-offsets-wrap-at-64k-silent-pattern-loss

Large hyphenation grammars load without error but silently lose patterns: TernaryTree key-array offsets wrap at 65,536

Hyphenation grammars load without error but silently lose patterns once their key array passes 65,535 chars: the Norwegian nb.xml from the OFFO download Lucene's docs link to gets 1,158 of 27,448 patterns wrong. TernaryTree stores tail offsets in 16-bit chars, which wrap; affected since 2.4.0.

Summary

Priority
Medium, rank 142The 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
silent wrong results
Since
2.4.0 (2008, LUCENE-1166, #2243)introduced 2008-05-16 · over 18 years
Affected
every release from 2.4.0 through 10.6.0, and main
Status
documented
Found by
exact-oracle check of 75 real pattern lists (semi-formal)

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

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.

Background in three steps

  1. HyphenationCompoundWordTokenFilter decompounds 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.
  2. A pattern is letters with digits between them, e.g. .fo2r: the letters .for are the key and the digits (00020) its values. findPattern(".for") returns them; hyphenating a word combines every pattern that occurs in it.
  3. 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.
Four boxes connected by arrows show a hyphenation grammar file feeding a loading step, which builds a TernaryTree whose shared first letters sit in nodes and whose remaining letters sit end to end in one key array, with each node holding where its rest starts as a 16-bit number (circled in red with a cross), and the tree feeding the decompounding filter that splits words at hyphen points at index and query time; below, a red note says that once the key array passes 65,535 chars a new start wraps and the new rest is written over an older one, and two panels compare the expected split of the Norwegian word forfatter (for, fat, ter) with the actual split under the OFFO 1.2 Norwegian grammar (for, f, at, ter, with extra subwords forf, atter and at).
A grammar's patterns become keys of a TernaryTree whose key-tail starts are 16-bit numbers, so past 65,535 chars of tails a new start wraps, overwrites an older tail, and words split differently with no error. Generated with AI, checked against the code.
Diagram of the bug: pattern tails are stored one after another in the TernaryTree key array, and each node keeps its tail's start offset in a 16-bit char. Once the array passes 65,535 chars, a new tail's offset is cut to 16 bits, wraps to the beginning and overwrites an earlier tail, so that earlier pattern is no longer found.
The key array as one long strip of pattern tails: past 65,535 chars, the next tail's 16-bit offset wraps to the start and overwrites an earlier pattern's tail. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Load the Norwegian grammar nb.xml (27,448 patterns) from the OFFO download that Lucene's compound docs link to, with HyphenationCompoundWordTokenFilter.getHyphenationTree. It loads with no error; the tree reaches 52,404 nodes while loading, inside the javadoc's "room for about 65000 nodes".
  2. You expect every pattern back: for the pattern .aa4, findPattern(".aa") should return "0004".
  3. Instead 1,158 patterns return wrong values, 1,111 of them nothing (findPattern(".aa") is ""), and size() says 27,358. With the older OFFO 1.2 no.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.
  4. 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.
  5. 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.

Who is affected

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.

The mechanism (main 5adff78bb168)

  1. Storage. TernaryTree keeps four parallel char[] node arrays and a char freenode (TernaryTree.java:66 lo, :69 hi, :72 eq, :85 sc, :92 freenode). For a compressed node (sc[p] == 0xFFFF) lo[p] holds the offset of the key's tail in the key array kv, not a node pointer (the lo javadoc, :62-65).
  2. The narrowing. insert allocates the tail with lo[p] = (char) kv.alloc(len + 1) (:151) and copies it with strcpy(kv.getArray(), lo[p], key, start) (:152). CharVector.alloc returns an int and 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.
  3. What lookups see. find compares the rest of the query with the tail at lo[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 plain insert and no compaction, 327 of 2,000 keys are already lost.
  4. Loading makes it permanent. loadPatterns (HyphenationTree.java:101-115) ends with trimToSize() (: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 why size() 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.
  5. No check. Nothing compares an offset with 0xFFFF; the loaders document only IOException (HyphenationTree.java:99, HyphenationCompoundWordTokenFilter.java:164, :175).
  6. 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 with StackOverflowError, the symptom Apache FOP's FOP-2569 reports. HyphenationTree.addPattern stores 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 Norwegian nb.xml needs only 6,045 bytes.

The numbers

grammarpatternsresult
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.xml26,017309 wrong; 29 of 104 real words hyphenate differently
TeX hyph-nb, hyph-nn27,448the same 1,158 wrong
synthetic repro2,600 random 30-letter patterns; 4,771 nodes556 lose their values
TeX German 1901/1996/Swiss, Hungarianpast 65,535 nodesStackOverflowError (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, through getHyphenationTree or HyphenationTree.loadPatterns, or through HyphenationCompoundWordTokenFilterFactory, whose mandatory hyphenator file 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.xml and nn.xml, OFFO 1.2 no.xml, and the TeX hyph-nb/hyph-nn lists. 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 TeX nb list 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.2 nb.xml the loss shows only through findPattern (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).

Minimal repro

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).

TestHyphenationTreeLongKeyArray.java (one test method)61 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.analysis.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);
  }
}
Command
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, 01 followed by 29 zeros.
Observed
556 patterns lose their values (expected:<0> but was:<556>), and loading throws nothing.

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

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.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT), 10.6.0, 9.12.3, 9.1.0: fail; 9.0.0: does not compile
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

# 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)

Why it looks unintended

  • 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 affected nb.xml and nn.xml.
  • Silent by construction. CharVector.alloc returns an int with 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.xml and 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.

Suggested fix

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

  • lo grows 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.
  • lo is a protected field of the public TernaryTree, and HyphenationTree is public and subclassable, so the type change is an API change for subclasses that read lo (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: freenode starts at 1 and the guard is freenode + 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 with strlen(key) instead of strlen(key, start) (the LFV-894 defect, TernaryTree.java:131 on main). It is exact on the loader path (insert(String, char)); direct callers with a NUL before start still 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 -ea passes 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.xml and nn.xml load with 0 wrong patterns (stock: 1,158 each); the TeX hyph-de-1996 list now throws TernaryTree is full: it can hold at most 65535 nodes instead of StackOverflowError (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 40000 while loading, now stops at the node guard with TernaryTree is full.
  • Side effect: compact() no longer calls insert(char[], int, char), so the related LFV-894 repro passes too. That public method itself is not fixed by this (caveat 2).

B. Guards only: keep char[] lo and throw when an offset does not fit

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.

C. Widen every pointer and the values to int

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 note

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).

How it was found

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/HyphenationTree with 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 findPattern was compared with the values written in the pattern text. The Norwegian nb and nn lists returned 1,158 wrong values with no load error; four large lists threw StackOverflowError.
  • 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.

Prior reports, history and checks

Prior reports

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): StackOverflowError in TernaryTree.insert on 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, widens lo/hi/eq, root, freenode and the values to int. 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 keeps lo[p] = (char) keyVector.alloc(len + 1) and the compact() 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 (StackOverflowError cured by a larger stack), FOP-1272, FOP-1773. iText's copy of the class made insert iterative but kept the char pointers.
  • 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.

What was searched, when, and what turned up
WhereWhenHowFound
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); web2026-10-28the 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 listsonly the related items above
Re-check for this doc: GitHub issues, PRs and commits, downstream trackers, the four Lucene lists, JIRA2026-11-03the 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 packagenothing 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@; JIRA2026-11-04the 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 HyphenationTreenothing new: the same related items (#2243, the LUCENE-4570 commit thread, the LUCENE-8124 thread); none describes this defect

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
2.3.2 no APIno compound package; the repro does not compile
7a27cdcbc901 sources over the 2.4.0 jars introducing commit556 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 affected556 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 affected556 lost with assertions on and off; OFFO nb.xml 1,158 wrong
10.6.0 affectedthe minimal repro fails, assertions on and off
main 5adff78bb168 affectedthe minimal repro fails (gradle and the JUnit runner, assertions on and off); OFFO nb.xml 1,158 wrong
main 5adff78bb168 + fix A not affectedthe repro passes; nb.xml/nn.xml load intact; 1,910 analysis-common tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder foundExact 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 realOwn 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), MediumRe-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 validatedOne-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 fixedAn 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.

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

Finding record

LFV-897, snapshot of 2026-11-06

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 -ea and -da), the released 10.6.0, 9.12.3 and 9.1.0 jars, and the OFFO Norwegian nb.xml/nn.xml with and without the fix, 2026-11-03 and 2026-11-04. Earlier runs: main a9779ef3c193 and e1faeba08bde, branch_10_6, every release run from 2.4.0 to 10.5.2, and the introducing commit's sources.

Lucene and upstream 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) @ a9779ef3c193
  • lucene/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-418
  • lucene/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-418
  • lucene/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-123
  • lucene/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-66
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/compound/hyphenation/HyphenationTree.java:95-100; (at 5adff78bb168) CharVector.java:102-112
  • lucene/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

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

Repro files

About the diagrams

Made from the code at 5adff78bb168 and the numbers on this page; the PNG is the summary still.

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.