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.
- You build a custom system dictionary with
DictionaryBuilderfrom 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). - 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.
- 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.
JapaneseTokenizergives the reading コクリツケンキュ (8 chars) and a pronunciation of 3 unrelated chars (U+0B04 U+2F24 U+3B06) decoded from the reading's own bytes;JapaneseReadingFormFilteremits the 8 chars as the term. A 128-char reading comes back as "" (an empty term); 127 chars round-trip.
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.
- 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-dictionaryJapaneseTokenizerconstructor (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.
putEntrywrites the reading asbuffer.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 throwsIllegalArgumentException(:139-141).- 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).
- Header
136 << 1 | 1 = 273; one byte keeps273 - 256 = 17;17 >>> 1 = 8, kana. The reading comes back as its first 8 chars, コクリツケンキュ. - 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. - A 128-char reading has the header
257, kept as1: length 0,"".
| row in the CSV | reading | pronunciation | term from JapaneseReadingFormFilter |
|---|---|---|---|
| reading and pronunciation 127 chars (control) | 127 chars | 127 chars | 127 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
JapaneseTokenizerconstructor 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
JapaneseAnalyzerandJapaneseTokenizer(bundled dictionary, longest reading 32 chars over all 392,127 source rows); thejapanesetokenizer 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/getPronunciationandReadingAttribute.JapaneseReadingFormFilteruses only the reading, so it emits a wrong term when the reading wrapped (JapaneseReadingFormFilter.java:63-95);JapaneseCompletionFilteralso readsReadingAttribute(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.
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.
/*
* 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));
}
}
# 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-eaand-da.
More files, environment, full output32 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> 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)- 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() < 128for 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.
putEntrythrowsIllegalArgumentExceptionfor 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 plainassert(: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 main4502065f0365, first released in 9.0.0) replacedassert baseForm.length() < 16, the id asserts and the conflicting-POS assert withIllegalArgumentException, 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 assertedbaseForm.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.mdorCHANGES.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.
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.txtline. - 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:testtask passes, 31 classes, 200 tests, 0 failures (7 skipped), assertions on and off, counting the 4 paste-ready tests and the newTestTokenInfoDictionarytest. ecjLintMain,ecjLintTestandcheckGoogleJavaFormatfor the module pass.
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.
@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.
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).
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.
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
DictionaryBuilderand 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists (dev@, java-user@, issues@, commits@, solr-user@), JIRA archive, web | 2026-10-27 | the 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 tooling | only 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 PRs | 2026-10-27 | the changed-file lists of all 409 open apache/lucene PRs | only #12517 (UniDic support) edits the writer, and it leaves the length bytes unchanged |
| Intended-behaviour angle | 2026-10-27 | about 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-check | 2026-10-28 | kuromoji reading length, DictionaryBuilder reading (3 more queries hit the search rate limit); last upstream commits touching the writer | 0 results; the writer last changed upstream on 2026-06-14 (build tooling), lines 153, 156, 169, 172 unchanged |
| Ports and walkthroughs | 2026-10-27 | the Lucene.NET port's writer; a committer's 2019 slides and a 2019-2020 dictionary checker | the 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) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | paste-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 | affected | the same (10.5.2 also with this doc's test) |
| 9.12.3 | affected | the finder's 9.x JUnit fails 2 of 3 |
| 8.2.0, 8.11.4, 9.0.0 | public builder | 128-char reading back as ""; カン back as カカカカカ; 127-char control round-trips |
| 3.6.0, 8.1.1 | source-release tool | the same, with the builder compiled from the release source |
| 3.5.0 | no API | no kuromoji module |
| main + the fix | not affected | long rows rejected at build time; whole kuromoji test task passes (200 tests) |
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).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | 3-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 | real | Own 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 intended | Probe: 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 | keep | Own 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 confirmed | Paste-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 hold | Re-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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 504 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,457 words
- 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 mainea57360912ed, whose kuromoji sources equala9779ef3c193.
- 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-141lucene/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) :44github.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)
- TestDictionaryBuilderLongReading.java (JUnit test):
repro/LFV-712/src/org/apache/lucene/analysis/ja/dict/TestDictionaryBuilderLongReading.java(53 lines)
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.
assets/LFV-712/bug.png(504 KB)
