All findings · Lucene finding · LFV-712 · kuromoji-builder-reading-length-byte-overflow

Kuromoji's DictionaryBuilder silently truncates readings of 128 or more chars

Kuromoji's DictionaryBuilder stores reading and pronunciation lengths in 7 bits unchecked, so in a custom dictionary a 136-char reading silently comes back as 8 chars, a 128-char one as "", and the pronunciation after it as garbage; long pronunciations wrap too. The bundled dictionary is unaffected; releases since 3.6.0 are.

Summary

Priority
Low, rank 521Kuromoji's DictionaryBuilder silently truncates readings of 128 or more chars, but only in an opt-in custom system dictionary with very long entries that nobody has reported in 14 years, so it follows the runtime ICU row LFV-720.
Impact
silent wrong results
Since
3.6.0 (2012); public API in the jar since 8.2.0 (2019)introduced 2012-01-12 · over 14 years
Affected
3.6.0 to 10.5.2 and main; 9 versions executed
Status
documented
Found by
a bounded-exhaustive round trip of the bundled dictionary against its source, then a review of each field's encoding (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

Each dictionary entry can store a reading and a pronunciation, each behind a one-byte header: 7 bits of length plus a kana flag. The writer never checks the length, and the reader trusts the header, so it returns length mod 128 chars, and after a wrapped reading it looks for the pronunciation in the wrong place.

Diagram of the bug: a custom kuromoji dictionary row with a 64-char surface and a 136-char reading; DictionaryBuilder writes the length into one byte as 136 << 1 | 1 = 273, which keeps 17 and decodes as 8, so JapaneseTokenizer returns the 8-char reading コクリツケンキュ and a 3-char garbage pronunciation read from the reading's own bytes, while a 16-char base form is rejected at build time.
Diagram of the bug, made in parallel with this doc: the CSV row, the one-byte length header that keeps 136 mod 128 = 8, and what the tokenizer returns. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You build a custom system dictionary with DictionaryBuilder from one CSV row: a 64-char proper noun (国立研究開発法人, the name of a class of public research agencies, eight times), its 136-char reading コクリツケンキュウカイハツホウジン × 8, and its 136-char pronunciation (the same with ー for long vowels).
  2. You expect the build to fail, as it does for a base form of 16 or more chars ("Length of base form ... is >= 16"), or the reading to come back as written.
  3. Instead the build succeeds. The writer keeps each length in 7 bits of one byte, so 136 is stored as 136 mod 128 = 8, and 128 as 0.
  4. JapaneseTokenizer gives the reading コクリツケンキュ (8 chars) and a pronunciation of 3 unrelated chars (U+0B04 U+2F24 U+3B06) decoded from the reading's own bytes; JapaneseReadingFormFilter emits the 8 chars as the term. A 128-char reading comes back as "" (an empty term); 127 chars round-trip.

Who is affected

Only applications that build their own system dictionary with DictionaryBuilder and load it with the custom-dictionary JapaneseTokenizer constructor, and only rows whose stored reading or pronunciation has 128 or more chars: very long entries, with kanji or with a pronunciation that differs from the reading. The default analyzer, the japanese factory and user dictionaries are not affected.

Mechanism, terms and numbers670 words

Kuromoji lets an application build its own system dictionary from IPADIC- or UniDic-format CSV files with the public, @lucene.experimental DictionaryBuilder, and load it with the JapaneseTokenizer constructor that takes custom dictionaries. The builder stores each entry's reading and pronunciation behind a one-byte header, (byte) (length << 1 | kanaFlag), and never checks the length. A stored reading or pronunciation of 128 or more chars therefore keeps only its length mod 128: 128 chars come back as "" and 136 as the first 8, each field on its own, and a pronunciation stored after a wrapped reading is decoded from the reading's own bytes. ReadingAttribute passes the wrong values on, and JapaneseReadingFormFilter indexes a wrapped reading as the term. The same method rejects a stored base form of 16 or more chars and the ids its format cannot hold with an IllegalArgumentException; the reading and pronunciation lengths are the unguarded limits. The bundled dictionary (longest reading 32 chars) and the default analyzer are not affected. Present since kuromoji's first release, 3.6.0; an 8-line check fixes it.

Terms

  • System dictionary: kuromoji's main dictionary of known words, compiled from CSV rows. Each row has the surface (the word as written), ids and costs, part-of-speech fields, the base form, the reading (how the word is read, in katakana) and the pronunciation (how it is said, for example with ー for a long vowel).
  • Custom system dictionary: one an application compiles itself with DictionaryBuilder.build (ja/dict/DictionaryBuilder.java:58-73) and loads with the custom-dictionary JapaneseTokenizer constructor (ja/JapaneseTokenizer.java:191-217). The bundled dictionary is compiled the same way at Lucene build time.
  • Stored only when needed: a reading is stored only when it differs from the katakana form of the surface, and a pronunciation only when it differs from the reading (ja/dict/TokenInfoDictionaryEntryWriter.java:110-114). Otherwise the reader derives them.

The bug

  1. putEntry writes the reading as buffer.put((byte) (reading.length() << 1 | 1)) plus one byte per char for an all-katakana reading, or (byte) (reading.length() << 1) plus two bytes per char otherwise (TokenInfoDictionaryEntryWriter.java:151-161); the pronunciation the same way (:163-177). No range check. A few lines earlier, a stored base form of 16 or more chars throws IllegalArgumentException (:139-141).
  2. The reader decodes the header as (b & 0xff) >>> 1 (TokenInfoMorphData.java:104-108, :130-134) and finds the pronunciation by skipping the reading by that decoded length (pronunciationOffset, :160-174).

Worked example: the 136-char reading

  1. Header 136 << 1 | 1 = 273; one byte keeps 273 - 256 = 17; 17 >>> 1 = 8, kana. The reading comes back as its first 8 chars, コクリツケンキュ.
  2. The pronunciation really starts after all 136 reading bytes, but the reader skips only 8 and lands on the reading's 9th char, ウ, stored as byte 0x06: a header for 3 UTF-16 chars. The next 6 bytes are the reading's chars 10-15 (カイハツホウ = 0x0B 0x04 0x2F 0x24 0x3B 0x06), read as U+0B04 U+2F24 U+3B06.
  3. A 128-char reading has the header 257, kept as 1: length 0, "".

The numbers (main and 10.5.2, assertions on and off)

row in the CSVreadingpronunciationterm from JapaneseReadingFormFilter
reading and pronunciation 127 chars (control)127 chars127 chars127 chars
reading and pronunciation 136 charsコクリツケンキュ (8 chars)U+0B04 U+2F24 U+3B06 (3 chars)コクリツケンキュ
reading 128 chars (pronunciation equal)""""""
base form 16 chars (control)build fails: ... is >= 16

The checks saw the same pattern for a 128-char hiragana reading (UTF-16 path: ""), the UniDic input format (8-char prefix), a 147-char reading (19-char prefix) and, on every release from 3.6.0 to 9.0.0, a 2-char pronunciation カン after a 128-char reading, which came back as カカカカカ. The fact check added an all-kana row: a 136-char katakana or hiragana surface whose reading equals it (so the reading is not stored) and the 136-char ー pronunciation; the reading comes back intact and the pronunciation as its first 8 chars, コクリツケンキュ.

Exposure and likelihood353 words
  • Who is hit. Applications with a custom system dictionary (IPADIC or UNIDIC format; both go through the same writer). The JapaneseTokenizer constructor that takes one says it "provides an entry point for users that want to construct custom language models that can be used as input to DictionaryBuilder" (JapaneseTokenizer.java:192-195).
  • Not hit. The default JapaneseAnalyzer and JapaneseTokenizer (bundled dictionary, longest reading 32 chars over all 392,127 source rows); the japanese tokenizer factory, which takes only a user dictionary (JapaneseTokenizerFactory.java:86-88, :125-126); user dictionaries, which are separate code.
  • Which rows. Only rows whose stored reading or pronunciation has 128 or more chars. A reading is not stored when it equals the katakana form of the surface, and a pronunciation is not stored when it equals the reading (TokenInfoDictionaryEntryWriter.java:110-114). So an all-kana entry whose reading and pronunciation both equal its katakana form is not affected. A long all-kana entry is still hit through a pronunciation that differs from its reading (for example ー for long vowels; executed by the fact check), and an entry with kanji through either field, such as a long proper noun or title. No real-world dictionary was measured for such rows.
  • Effect. Silent and deterministic: every match of the row gets an empty or truncated value for the field that wrapped, and a garbage pronunciation when the reading wrapped, through getReading / getPronunciation and ReadingAttribute. JapaneseReadingFormFilter uses only the reading, so it emits a wrong term when the reading wrapped (JapaneseReadingFormFilter.java:63-95); JapaneseCompletionFilter also reads ReadingAttribute (read, not executed). In every executed case there was no error at build or analysis time (a misdecoded header could in principle read past the buffer end and throw; read, not observed); one row is enough.
  • Why nobody noticed. Dictionary build tooling for Lucene filters rows by base-form length, because Lucene rejects long base forms; nothing filters readings. Two community walkthroughs of the format (a committer's 2019 slides and a 2019-2020 dictionary checker) list only the ids and the 16-char base form as size limits; the checker also flags an empty reading. No user report in 14 years.

Minimal repro

The test shows that kuromoji's DictionaryBuilder accepts a row whose reading has 128 chars and stores its length in 7 bits, so the built dictionary returns a wrong reading.

TestDictionaryBuilderLongReading.java (JUnit test)53 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.ja.dict;

import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.lucene.analysis.morph.BinaryDictionary;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.IntsRef;

public class TestDictionaryBuilderLongReading extends LuceneTestCase {

  public void testReadingOf128Chars() throws Exception {
    String reading = "カ".repeat(128);
    Path dir = createTempDir();
    String row = "漢字,1,1,1,名詞,一般,*,*,*,*,*," + reading + "," + reading;
    Files.writeString(dir.resolve("test.csv"), row);
    Files.createFile(dir.resolve("unk.def"));
    Files.createFile(dir.resolve("char.def"));
    Files.writeString(dir.resolve("matrix.def"), "1 1\n");
    try {
      DictionaryBuilder.build(DictionaryBuilder.DictionaryFormat.IPADIC, dir, dir, "utf-8", false);
    } catch (IllegalArgumentException rejected) {
      return; // expected: the row is rejected or its reading reads back; actual: the reading is ""
    }
    String prefix = dir.resolve(TokenInfoDictionary.class.getName().replace('.', '/')).toString();
    TokenInfoDictionary dict =
        new TokenInfoDictionary(
            Path.of(prefix + BinaryDictionary.TARGETMAP_FILENAME_SUFFIX),
            Path.of(prefix + BinaryDictionary.POSDICT_FILENAME_SUFFIX),
            Path.of(prefix + BinaryDictionary.DICT_FILENAME_SUFFIX),
            Path.of(prefix + TokenInfoDictionary.FST_FILENAME_SUFFIX));
    IntsRef wordIds = new IntsRef();
    dict.lookupWordIds(0, wordIds);
    char[] surface = "漢字".toCharArray();
    assertEquals(
        "reading", reading, dict.getMorphAttributes().getReading(wordIds.ints[0], surface, 0, 2));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-712/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/analysis/kuromoji/build/libs/lucene-analysis-kuromoji-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.analysis.ja.dict.TestDictionaryBuilderLongReading
# the module(s) analysis-kuromoji also need their third-party runtime jars
Expected vs actual
Expected the build to reject the row or the 128-char reading to read back; actual: the build succeeds and getReading returns "" (reading expected:<[カ x 128]> but was:<[]>), with -ea and -da.

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

More files, environment, full output32 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: reading expected:<[カカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカカ]> but was:<[]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.apache.lucene.analysis.ja.dict.TestDictionaryBuilderLongReading.testReadingOf128Chars(TestDictionaryBuilderLongReading.java:50)

Why it looks unintended

  • The writer rejects the other limits of its format, a stored base form of 16+ chars and the ids; LUCENE-8863 made those asserts exceptions (8.2.0). The reading length never had a check.
  • The 2012 original asserted baseForm.length() < 128 for the same 7-bit length, but not for the reading.
  • No javadoc, test or changelog states a 127-char limit.
The full argument252 words
  • Fail fast is the writer's pattern. putEntry throws IllegalArgumentException for empty POS fields (:72-74), an empty base form (:104-106), leftId != rightId (:117-119), leftId >= 8192 (:120-122), conflicting POS data (:130-133) and a stored base form of 16 or more chars (:139-141); an empty POS part is still a plain assert (:62). TestTokenInfoDictionary.testPutException (:91-105) asserts the pattern for the ids and the column count.
  • LUCENE-8863 (#9906; in 8.2.0 through the branch_8x commit bdaf10ffa826, on main 4502065f0365, first released in 9.0.0) replaced assert baseForm.length() < 16, the id asserts and the conflicting-POS assert with IllegalArgumentException, and added the empty-POS and empty-base-form checks. In the thread @mocobeta wrote "+1 for changing assertions to \"real\" exceptions", and @rmuir, about an empty base form, "It sounds like a missing check in the code ... I see it as bug in your dictionary, we should fail :)". The reading length had no assert, so it was left out.
  • The limit was known for the base form. The original import (cd372bdc8383, LUCENE-3305, 2012) stored the base form with the same 7-bit header and asserted baseForm.length() < 128; the reading and pronunciation lines next to it had no assert.
  • Nothing documents a limit. Not DictionaryBuilder, JaMorphData (getReading: "Reading of the token"; getPronunciation: "Pronunciation of the token"), JapaneseTokenizer, the package docs, MIGRATE.md or CHANGES.txt; no test covers reading or pronunciation lengths.
The devil's-advocate check170 words

The strongest case for "intended": DictionaryBuilder is @lucene.experimental and its input only "roughly" follows IPADIC (DictionaryBuilder.java:32-35); the compact format was designed around the bundled dictionary (@rmuir in #5128, about another assert: "an optimization that works for ipadic and naist-jdic, and I knew i was making an assumption doing it"; in #9906: "I don't think its good to try to support N different ways of doing things when only one is tested (the ipadic way)"); readings of 128 or more chars are far outside IPADIC (32 at most); and the dictionary is trusted input.

Why it fails: "experimental" allows the API to change, not silently wrong output; the "ipadic way" remark argues for rejecting input the format cannot hold, which the builder does for the base-form length and the ids; nothing documents a 127-char limit; trusted input rules out a security mark, not a correctness bug. The cross-model reviews rated the argument weak; the other reviewers likewise concluded that the behaviour is not intended or recommended rejecting such rows.

Suggested fix

Recommended Reject a stored reading or pronunciation of 128 or more chars

In TokenInfoDictionaryEntryWriter.putEntry, throw IllegalArgumentException when a reading or pronunciation that is actually stored (inside the HAS_READING / HAS_PRONUNCIATION branches) has 128 or more chars, like the base-form check (+8 lines). The repro then passes, and so does the whole kuromoji test task (200 tests), assertions on and off.

if ((flags & TokenInfoMorphData.HAS_READING) != 0) {
  // the length is stored in the upper 7 bits of one byte
  if (reading.length() >= 128) {
    throw new IllegalArgumentException("Length of reading " + reading + " is >= 128");
  }
  ...
if ((flags & TokenInfoMorphData.HAS_PRONUNCIATION) != 0) {
  ...
  if (pronunciation.length() >= 128) {
    throw new IllegalArgumentException(
        "Length of pronunciation " + pronunciation + " is >= 128");
  }
All options and trade-offs4 options · 468 words

One fix was built and validated; the others are listed for the discussion with maintainers. A credibility check expects a committer to prefer rejecting the row over a format change.

Reject a stored reading or pronunciation of 128 or more chars (validated by the checks) (recommended)

Check inside the HAS_READING and HAS_PRONUNCIATION branches, where the base-form check also sits, so rows that store nothing (a katakana surface that is its own reading) still build. The patch also adds TestTokenInfoDictionary.testPutLongReadingOrPronunciation (26 lines): a 128-char katakana reading, a 128-char hiragana reading and a 128-char pronunciation are rejected; 127 chars round-trip; a 128-char katakana surface that is its own reading still builds.

if ((flags & TokenInfoMorphData.HAS_READING) != 0) {
  // the length is stored in the upper 7 bits of one byte
  if (reading.length() >= 128) {
    throw new IllegalArgumentException("Length of reading " + reading + " is >= 128");
  }
  ...
if ((flags & TokenInfoMorphData.HAS_PRONUNCIATION) != 0) {
  ...
  if (pronunciation.length() >= 128) {
    throw new IllegalArgumentException(
        "Length of pronunciation " + pronunciation + " is >= 128");
  }

Trade-offs

  • A custom dictionary that builds today with such rows will fail to build, with a message that names the field and its value, as long base forms have since 8.2.0. Worth a CHANGES.txt line.
  • The bundled dictionary cannot change: a stricter sketch (it threw for any reading or pronunciation of 128 or more chars, stored or not) rebuilt the bundled source with no exception and 9 byte-identical files, and this fix throws in a subset of those cases and writes the same bytes. Inferred, not re-run with this fix.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: the paste-ready test fails 2 of 4 on stock main, assertions on and off; with the patch the whole :lucene:analysis:kuromoji:test task passes, 31 classes, 200 tests, 0 failures (7 skipped), assertions on and off, counting the 4 paste-ready tests and the new TestTokenInfoDictionary test.
  • ecjLintMain, ecjLintTest and checkGoogleJavaFormat for the module pass.

Widen the length field

Store the length as a vInt or in two bytes, so long readings survive.

Trade-offs

A format change: the reader changes, a vInt header would take two bytes for readings of 64-127 chars, so the dictionary version needs a bump and custom dictionaries a rebuild. Not run.

Skip invalid rows with a warning (opt-in)

@mocobeta suggested in #9906 an option to "ignore invalid entries and just output some WARNING then continue".

Trade-offs

Applies to every check, so it is a separate feature; the default should still fail. Not run.

Check the lengths before the flags are known (not recommended)

The finder's first sketch checks every reading and pronunciation, stored or not.

Trade-offs

Rejects rows that work today, such as a 128-char katakana surface whose reading equals it (executed by the independent reproducer).

Downstream note

Any product on Lucene that lets operators load a custom kuromoji system dictionary inherits the silent truncation; Lucene's own japanese tokenizer factory takes only a user dictionary, which is not affected.

How it was found

Semi-formal. A bounded-exhaustive check compared kuromoji's bundled compiled dictionary with its source: 392,127 rows, 0 mismatches, longest reading 32 chars. Reviewing how each field is encoded, the finder saw that the base form has a length limit and the 7-bit reading and pronunciation lengths have none, and confirmed it with a dictionary built through the public builder.

The method in full133 words
  • Round trip. A hunt unit on compiled CJK dictionaries ran a bounded-exhaustive translation validation of kuromoji's bundled dictionary against its mecab-ipadic source: all 392,127 rows, 1,731,856 connection-cost cells, all 65,536 chars and every unknown-word class, with 0 mismatches. The longest source reading is 32 chars, so the bundled dictionary cannot show the bug.
  • Signal. While checking each field's encoding, the finder noticed that the base form has an explicit limit with an exception and the reading and pronunciation lengths, packed into one byte, have none.
  • Witness. The finder built a one-row custom dictionary through the public DictionaryBuilder and reduced it to a 3-test public-API JUnit (2 of 3 fail on main with assertions on and off, and on 10.5.2 and 9.12.3). No formal tool (model checker, SMT solver or proof) produced the signal.

Prior reports, history and checks

Prior reports

No prior report found: about 255 logged queries over GitHub issues, PRs and commits, JIRA, the mailing lists, downstream projects and the web, plus the changed files of all 409 open PRs (2026-10-27), and a re-check on 2026-10-28. Nearest: #9906 (LUCENE-8863), which added the other builder checks. Not fixed upstream.

Searches5 searches

No report of this defect was found. The nearest items are builder housekeeping and general remarks about the compact format; none names the reading or pronunciation length or silent truncation. Upstream main 645d4fe14a20 (2026-10-28) still has the unchecked lines.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists (dev@, java-user@, issues@, commits@, solr-user@), JIRA archive, web2026-10-27the scripted prior-report search, three runs with 110 queries, plus the finder's 43-query run; 60 manual GitHub searches (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr, Lucene.NET, the standalone Kuromoji); 17 list, 9 JIRA and 16 English and Japanese web queries; the NEologd and UniDic build toolingonly related items: #9906 / LUCENE-8863 (asserts to exceptions; reading length not mentioned), #9860 / LUCENE-8816 and #15365 (general remarks on IPADIC-specific assumptions), #5128 / LUCENE-4056 (the leftId == rightId limit)
Open PRs2026-10-27the changed-file lists of all 409 open apache/lucene PRsonly #12517 (UniDic support) edits the writer, and it leaves the length bytes unchanged
Intended-behaviour angle2026-10-27about 30 extra GitHub, list and JIRA queries (kuromoji reading truncated, long word reading, dictionary reading limit, writeKatakana)0 high or medium hits
GitHub issues and PRs (apache/lucene), re-check2026-10-28kuromoji reading length, DictionaryBuilder reading (3 more queries hit the search rate limit); last upstream commits touching the writer0 results; the writer last changed upstream on 2026-06-14 (build tooling), lines 153, 156, 169, 172 unchanged
Ports and walkthroughs2026-10-27the Lucene.NET port's writer; a committer's 2019 slides and a 2019-2020 dictionary checkerthe port has the same unchecked encoding, with no issue; both walkthroughs list only the ids and the 16-char base form as size limits (the checker also flags an empty reading)

History

Present since kuromoji's import (cd372bdc8383, LUCENE-3305, 2012; first release 3.6.0); the builder became public API in the jar in 8.2.0 (LUCENE-8871), the release that turned the base-form assert into an exception. Executed on 9 versions from 3.6.0 to main: all affected; never fixed.

Versions tested7 rows

Introduced with kuromoji itself: LUCENE-3305 (#4378), trunk commit cd372bdc8383 by @rmuir (2012-01-12), the merge of the development branch where the one-byte reading and pronunciation header was written (63700e75cb3a, 2012-01-06, "more efficient tokeninfodictionary"); the branch_3x backport 8c9485fb6642 shipped in 3.6.0. Not a regression: no version ever checked these lengths. Until 8.1.1 the builder was a build tool in the source release only; LUCENE-8871 made it public API in the 8.2.0 jar, and LUCENE-8863 (same release, through the branch_8x commit bdaf10ffa826; 4502065f0365 on main) turned the base-form and id asserts into exceptions without adding a reading check. LUCENE-8971 (8.3.0) added the custom-dictionary JapaneseTokenizer constructor, LUCENE-10400 (9.1.0) the Path constructors, and LUCENE-10393 (#740) moved the lines unchanged into TokenInfoDictionaryEntryWriter.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedpaste-ready test fails 2 of 4 (gradle and runner, assertions on and off); finder's JUnit 2 of 3
10.5.2, 10.0.0 affectedthe same (10.5.2 also with this doc's test)
9.12.3 affectedthe finder's 9.x JUnit fails 2 of 3
8.2.0, 8.11.4, 9.0.0 public builder128-char reading back as ""; カン back as カカカカカ; 127-char control round-trips
3.6.0, 8.1.1 source-release toolthe same, with the builder compiled from the release source
3.5.0 no APIno kuromoji module
main + the fix not affectedlong rows rejected at build time; whole kuromoji test task passes (200 tests)

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate and a credibility audit each ran their own test; the adjudication re-ran the reproducer's test with a new seed; all said real. Reviews by another model family (OpenAI GPT-5.6) ran tests and said real (high). A fact check repeated this doc's gradle runs of the repro and the fix.

Checks and verdicts359 words

Every check below executed code on main (the cross-model reviews on ea57360912ed, whose kuromoji sources equal a9779ef3c193).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate3-test public-API JUnit: 2 of 3 fail on main (assertions on and off), 10.5.2 and 9.12.3; the base-form control passes. First fix sketch passes 3 of 3 but over-rejects (found later by the independent reproduction).
cross-model review, refute lens (OpenAI GPT-5.6) real (high)Executed its own test: a 128-char reading, a 128-char pronunciation and the ReadingAttribute path give ""; the 127-char control passes; intended argument weak.
independent reproducer realOwn 9-test JUnit: 6 of 9 fail on main (on and off), 10.5.2 and 9.12.3; hiragana reading, garbage pronunciation U+0304, the filter's empty term; 3 controls pass. Showed the first fix sketch rejects a row that works today.
cross-model review, intended lens (OpenAI GPT-5.6) real (high)Root cause agreed; its own test re-run on a9779ef3c193: 3 of 5 fail, on and off.
intended-behaviour devil's advocate real; not intendedProbe: the 64-char proper noun with a 136-char reading gives an 8-char prefix, the UniDic format the same, and the filter emits it; built the strongest intended case and showed why it fails.
adjudication confirmed (high)Re-ran the independent reproduction's test with a new seed: 6 of 9 fail, on and off; flagged the fix and citation caveats.
credibility audit keepOwn 4-test JUnit: 2 of 4 fail (128 chars to "", 147 to 19), both controls pass; committer view: reject, not widen.
this doc repro and fix confirmedPaste-ready test fails 2 of 4 through gradle and the runner (main, 10.5.2); with the fix the whole kuromoji test task passes, assertions on and off.
fact check bug, repro and fix holdRe-ran the paste-ready test through gradle in a fresh worktree (2 of 4 fail, on and off; with the fix the whole kuromoji task passes, 200 tests) and through the runner on main and 10.5.2 (assertions off); its all-kana probe showed a long pronunciation that differs from the reading is also cut, and that the fix rejects it.

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

Finding record

LFV-712, snapshot of 2026-11-06

Registry title
Kuromoji's DictionaryBuilder silently accepts a reading or pronunciation of 128 or more chars and stores its length in 7 bits, so the built system dictionary returns an empty or truncated reading and a garbage pronunciation (a too-long base form, by contrast, is rejected)
Area
analysis / kuromoji system dictionary builder (ja.dict.TokenInfoDictionaryEntryWriter reading and pronunciation length bytes; TokenInfoMorphData decoding; custom dictionaries built with DictionaryBuilder)
Priority
Low, rank 521: Kuromoji's DictionaryBuilder silently truncates readings of 128 or more chars, but only in an opt-in custom system dictionary with very long entries that nobody has reported in 14 years, so it follows the runtime ICU row LFV-720.
Impact class
silent wrong results
Impact note
Hits only applications that build their own kuromoji system dictionary with the public, @lucene.experimental DictionaryBuilder (IPADIC or UNIDIC format) and load it through the custom-dictionary JapaneseTokenizer constructor (opt-in; the bundled dictionary's longest reading is 32 chars, so the default analyzer is unaffected), and only rows whose stored reading or pronunciation has 128 or more chars (a reading is stored only when it differs from the katakana form of the surface, a pronunciation only when it differs from the reading): such a row builds without error, and every match of it deterministically gets a reading of the real length mod 128 (empty at 128 chars, an 8-char prefix at 136) and, when the reading wraps, a pronunciation decoded from the reading's own bytes, through getReading/getPronunciation, ReadingAttribute and JapaneseReadingFormFilter (which then indexes an empty or truncated term), with -ea and -da and no scale needed, in every release from 3.6.0 (where the builder was a source-release tool; public in the jar since 8.2.0) to 10.5.2 and on main.
Summary
You build a custom kuromoji system dictionary with DictionaryBuilder from an IPADIC-format CSV that has a row whose reading is 128 katakana chars long (or, more naturally, a 64-char proper noun with a 136-char reading), and you expect either an IllegalArgumentException at build time, as the builder throws for a stored base form of 16 or more chars that its compact format cannot hold, or the reading back unchanged. Instead the build succeeds, because putEntry writes each length as (byte) (length << 1 | kanaFlag) with no check, so the stored length is the real length mod 128: getReading and JapaneseTokenizer's ReadingAttribute return "" for the 128-char reading and only the first 8 chars of the 136-char one, a 128-char pronunciation comes back empty, a pronunciation stored after a wrapped reading is decoded from the reading's own bytes (a 4-char pronunciation came back as the single char U+0304), and JapaneseReadingFormFilter indexes the empty or truncated reading as the term.
Introduced
cd372bdc8383 (svn r1230748, trunk, 2012-01-12, LUCENE-3305 'add Kuromoji Japanese morphological analyzer', GitHub #4378, by rmuir; Subversion era, no pull request), the squash-merge of the lucene3305 development branch, where the 1-byte reading/pronunciation length header was written in 63700e75cb3a (svn r1228000, 2012-01-06, 'more efficient tokeninfodictionary', replacing a 16-bit total feature-size field); its branch_3x backport 8c9485fb6642 (svn r1231536, 2012-01-14) is the commit in 3.6.0 [read: git log -S 'reading.length() << 1', git log --all -G and blame -M -C -C in the full-history clone]. Not a regression: the reading and pronunciation lengths never had an assert or a check (the original import had 'assert baseForm.length() < 128' for the base form only); LUCENE-8863 (4502065f0365 on main, bdaf10ffa826 on branch_8x, in 8.2.0) turned only the base-form and id asserts into IllegalArgumentException, and LUCENE-10393 (bd22f199dec5, #740) moved the lines unchanged into TokenInfoDictionaryEntryWriter (2012-01-12 (trunk cd372bdc8383; the development-branch commit 63700e75cb3a is from 2012-01-06 and the branch_3x commit 8c9485fb6642 that shipped in 3.6.0 from 2012-01-14))
First affected version
3.6.0 [exec] (first release with Kuromoji; 3.5.0 has none). From 3.6.0 to 8.1.1 the builder shipped only as a build tool in the source release (not in the jar [exec: jar tf]); 8.2.0 [exec] is the first release with DictionaryBuilder as public API in the kuromoji jar (LUCENE-8871)
Affected versions
Every release from 3.6.0 through 10.5.2, and main a9779ef3c193 (unreleased 11.0); never fixed. [exec] 3.6.0 and 8.1.1 (builder tool compiled from the release source, dictionary on the classpath), 8.2.0, 8.11.4 and 9.0.0 (public DictionaryBuilder, classpath), 9.12.3 (9.x JUnit variant), 10.0.0, 10.5.2 and main (-ea and -da): all fail the same way (a 128-char reading comes back as ""), and the 127-char controls round-trip. [read] 4.x-7.x not executed; the encoding lines are unchanged (blame -M -C -C, log -G). Upstream main a95c754ee003 and branch_10x still have the unchecked lines (read 2026-10-27).
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: code reading
Upstream and prior reports
none found (searched 2026-10-27, read-only; the prior-report record, verdict new): three scripted prior-report searches (110 queries over GitHub issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@, commits@ and solr-user@ lists, JIRA and the web) plus the finder's 43-query run, 60 manual GitHub GraphQL searches over apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr, Lucene.NET and the standalone Atilika Kuromoji, the changed-file lists of all 409 open apache/lucene PRs, 17 manual list queries, 9 JQL queries over LUCENE, SOLR and LUCENENET, 16 English and Japanese web queries, and the NEologd and UniDic build tooling. No issue, PR, JIRA ticket, list thread, commit or downstream report describes the builder silently truncating a reading or pronunciation of 128 or more chars. Related, not this bug: #9906 (LUCENE-8863, closed, 8.2.0: turned the builder's base-form and id asserts into IllegalArgumentException; the reading length never had an assert and is not mentioned), #9860 (LUCENE-8816, open) and #15365 (open): generic remarks that the compact encoding has ipadic-specific assumptions, naming neither this field nor silent truncation; #12517 (open UniDic PR, the only open PR that edits TokenInfoDictionaryEntryWriter.java: it makes the base-form and id checks format-specific and leaves the reading and pronunciation length bytes unchanged, so it would not fix this); #5128 (LUCENE-4056, open: the leftId == rightId limit). Not fixed upstream: main a95c754ee003 and branch_10x still have the unchecked lines. The Lucene.NET port carries the same unchecked encoding, with no issue there.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, and the standalone JUnit runner); the 10.5.2 release jars (this doc's test); 3.6.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (the history runs); the fix on main (gradle, the whole kuromoji test task, assertions on and off, plus the module's lint and format checks). The cross-model reviews ran on main ea57360912ed, whose kuromoji sources equal a9779ef3c193.

Lucene and upstream sources

Finding record
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/TokenInfoDictionaryEntryWriter.java:103-115 (at a9779ef3c193) (flags: HAS_READING only when the reading differs from toKatakana(surface) at :110-111, HAS_PRONUNCIATION only when it differs from the reading at :113-114), :139-141 (stored base form of 16 or more chars -> IllegalArgumentException 'Length of base form ... is >= 16'), :151-177 (reading and pronunciation headers (byte) (length << 1 | 1) / (byte) (length << 1) at :153, :156, :169, :172, no range check)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/TokenInfoMorphData.java:103-108 (at a9779ef3c193) (getReading decodes (b & 0xff) >>> 1), :129-134 (getPronunciation), :150-158 (readingOffset), :160-173 (pronunciationOffset skips the reading by its decoded, wrapped length)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/DictionaryBuilder.java:24-44 (at a9779ef3c193) (javadoc: input 'roughly following the conventions of IPADIC'; @lucene.experimental at :44), :58-73 (public build)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizer.java:191-217 (at a9779ef3c193) (custom-dictionary constructor: 'an entry point for users that want to construct custom language models that can be used as input to DictionaryBuilder', @lucene.experimental)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseReadingFormFilter.java:63-93 (at a9779ef3c193) (replaces the term by any non-null reading, :92)
The finding in 30 seconds
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/TokenInfoDictionaryEntryWriter.java:103-115 (at a9779ef3c193) (flags; HAS_READING :110-111, HAS_PRONUNCIATION :113-114), :139-141 (base form >= 16 rejected), :151-177 (headers at :153, :156, :169, :172, no check)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/TokenInfoMorphData.java:104-108 (at a9779ef3c193) (getReading), :130-136 (getPronunciation), :150-174 (readingOffset, pronunciationOffset)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/DictionaryBuilder.java:58-73; (at a9779ef3c193) lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizer.java:191-217 (at a9779ef3c193)
Who is affected
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizer.java:191-207 (at a9779ef3c193) (javadoc); lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizerFactory.java:36-52, (at a9779ef3c193) :86-88, :125-126; lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java:122 (at a9779ef3c193)
Why it looks unintended
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/TokenInfoDictionaryEntryWriter.java:72-74, (at a9779ef3c193) :104-106, :117-122, :130-133, :139-141
  • lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/dict/TestTokenInfoDictionary.java:91-105 (at a9779ef3c193)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/JaMorphData.java:31-37, (at a9779ef3c193) :47-53; lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/DictionaryBuilder.java:32-35, (at a9779ef3c193) :44
  • github.com/apache/lucene/issues/9906 (comments by @mocobeta and @rmuir, captured 2026-10-27, re-read and matched 2026-10-28); github.com/apache/lucene/issues/5128 (@rmuir's assumption remark, captured 2026-10-27, re-read and matched 2026-10-28)
Suggested fix
  • github.com/apache/lucene/issues/9906 (@mocobeta: ignore invalid entries with a warning)

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

Repro files

About the diagrams

Made in parallel with this doc from the executed runs on main a9779ef3c193; every value drawn should come from the paste-ready test's output and the source at that commit.

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.