All findings · Lucene finding · LFV-710 · nori-builder-right-id-not-range-checked

Nori's DictionaryBuilder rejects a left context id of 8192 or more but silently accepts such a right id, so a custom dictionary changes segmentation or crashes KoreanTokenizer

Nori's DictionaryBuilder rejects a left context id of 8192 or more but silently accepts such a right id, which does not round-trip through the compiled format: right id 16385 reads back as 1 and changes segmentation, and 8192 makes KoreanTokenizer throw. Custom system dictionaries only, in every release since 7.4.0.

Summary

Priority
Low, rank 523Nori's DictionaryBuilder silently wraps right context ids of 8192 or more only for an opt-in custom system dictionary far larger than any known Korean model, which is rarer than LFV-459, so it follows that kuromoji/nori rare-input row.
Impact
silent wrong results
Since
7.4.0 (LUCENE-8231, 2018), nori's first releaseintroduced 2018-04-13 · about 8 years
Affected
7.4.0 to 10.6.0 (10.6.0 by reading) and main; custom system dictionaries only
Status
documented
Found by
code reading during a bounded-exhaustive round trip of the bundled dictionary

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 word in a nori dictionary has a left and a right context id; the tokenizer adds a matrix cost for each pair of neighbouring words. Each id is stored in a 14-bit field and read back as a signed short, so only 0 to 8191 round-trip. The builder checks that limit for the left id only.

Diagram of nori's DictionaryBuilder writing a custom dictionary row 가나 with left id 1 and right id 16385: the left id is checked against 8192 at TokenInfoDictionaryEntryWriter.java:130, the right id is written unchecked at :137, only 14 of its bits are stored, and it reads back as 1. With the cost matrix of the repro, the tokenizer splits 가나다 as [가나, 다] instead of [가, 나다]. Right ids 8192 to 16383 and 24576 to 32767 read back as about 1.07 billion and make KoreanTokenizer throw IndexOutOfBoundsException; a negative id either throws the same way (-1) or silently becomes another id (-8193 becomes 8191). The fix rejects right ids outside 0 to 8191 at build time.
A row with right id 16385 goes through the builder: only 14 bits are stored, so it reads back as 1 and 가나다 is split [가나, 다], not [가, 나다]. Ids 8192-16383 and 24576-32767 read back as about 1.07 billion and make the tokenizer throw; negative ids wrap the same way. Then the three-line fix. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You compile your own Korean dictionary with nori's DictionaryBuilder from MeCab-format files. Every word has a left and a right context id, and matrix.def gives the cost of each word pair by those ids. One row gives the word 가나 right id 16385; the matrix declares 16,386 right ids and makes any word after 가나 cost 20000.
  2. You expect the build to reject the row, as it rejects a left id of 8192 (leftId >= 8192: 8192), or the dictionary to return right id 16385, so that 가나다 is split [가, 나다] (cost 2000).
  3. The build succeeds. The right id is packed into a 14-bit field with no check (TokenInfoDictionaryEntryWriter.java:137); 16385 needs 15 bits, so it reads back as 1. The tokenizer uses the costs of right id 1 and outputs [가나, 다].
  4. A right id from 8192 to 16383 or from 24576 to 32767 reads back as about 1.07 billion: the build still succeeds, and tokenizing any text with that word throws IndexOutOfBoundsException. A negative id has one of the two outcomes (-1 throws; -8193 silently becomes 8191). The bundled dictionary (3,822 right ids) and the default KoreanAnalyzer are not affected.

Who is affected

Applications that compile their own nori system dictionary with DictionaryBuilder and load it through the custom-dictionary KoreanTokenizer constructor, when the source has a right id of 8192 or more or below 0. Ids 16384-24575 (and some negative ids) silently change segmentation; the other out-of-range ids make tokenization throw. The bundled dictionary and default analyzer are unaffected.

Mechanism, terms and numbers666 words

Nori's DictionaryBuilder compiles a custom Korean system dictionary from MeCab-format sources; the custom-dictionary KoreanTokenizer constructor is documented as the entry point for such dictionaries (@lucene.experimental). Each entry stores its left and its right context id shifted left by 2 in a 16-bit field (TokenInfoDictionaryEntryWriter.java:136-137), so each id gets a 14-bit field; the reader decodes it from a sign-extended short (TokenInfoMorphData.java:54, :59), so only 0 to 8191 round-trip. The builder rejects leftId >= 8192 with IllegalArgumentException (:130-132) but writes the right id unchecked (:137). Right ids 16384 to 24575 come back as id - 16384 (16385 becomes 1), which silently changes connection costs and segmentation (가나다 becomes [가나, 다] instead of [가, 나다] in the repro); right ids 8192 to 16383 and 24576 to 32767 come back as about 1.07 billion, and tokenizing text with the word throws IndexOutOfBoundsException from ConnectionCosts.get; a negative id has one of the two outcomes (-1 throws, -8193 becomes 8191). Word rows and unk.def rows go through the same writer. The bundled dictionary (3,822 right ids) and the default analyzer are not affected. Present since nori's first commit (7.4.0) and reproduced on every release tried up to 10.5.2 and main; a three-line IllegalArgumentException next to the left-id check fixes it, and the bundled dictionary rebuilds byte-identically with it.

Terms

  • System dictionary: compiled by DictionaryBuilder.build(Path, Path, String, boolean) (ko/dict/DictionaryBuilder.java:24-28, public) from MeCab-format word rows (*.csv), unk.def, char.def and matrix.def; loaded with the Path constructors of TokenInfoDictionary, UnknownDictionary and ConnectionCosts and passed to the custom-dictionary KoreanTokenizer constructor, "an entry point for users that want to construct custom language models that can be used as input to {@link DictionaryBuilder}" (ko/KoreanTokenizer.java:138-152, @lucene.experimental).
  • Context ids: matrix.def gives the cost of each (right id of a word, left id of the next word) pair. The tokenizer adds costs.get(lastRightID, leftID) between words (morph/Viterbi.java:471) and costs.get(lastRightID, 0) at the end of input (:389), and keeps the cheapest path.

The bug

  1. putEntry parses both ids with Short.parseShort (TokenInfoDictionaryEntryWriter.java:63-64), so 32768 and above already fail (with NumberFormatException).
  2. if (leftId >= ID_LIMIT) throw new IllegalArgumentException(...) (:130-132, ID_LIMIT = 8192 at :33); there is no such check for the right id.
  3. buffer.putShort((short) (rightId << 2 | flags)) (:137): bits 0-13 of the id survive; bit 13 (8192) lands in the sign bit and bit 14 (16384) is cut off.
  4. getRightId is buffer.getShort(morphId + 2) >>> 2 (TokenInfoMorphData.java:58-60) on the sign-extended short: 16385 reads back as 1, 8192 as 1,073,733,632, -1 as 1,073,741,823.
  5. So 0 to 8191 round-trip; 8192 to 16383 are written intact and only the signed read loses them (an unsigned read, (getShort(...) & 0xffff) >>> 2, as kuromoji's reader does with its 3 flag bits at ja/dict/TokenInfoMorphData.java:72, :77, returns them exactly); 16384 and above lose bit 14 on write. A negative id behaves like its value modulo 16384 (-8193 like 8191, -1 like 16383).
  6. ConnectionCosts.get reads buffer.getShort((backwardId * forwardSize + forwardId) * 2) (morph/ConnectionCosts.java:61-64) from a bounds-checked direct buffer: a wrapped id reads another id's costs; a huge id throws IndexOutOfBoundsException.
  7. TokenInfoDictionaryWriter (:30) and UnknownDictionaryWriter (:35) both use this entry writer, so unk.def rows are affected too.

What each right id becomes (executed probes, one-word dictionaries)

source right idread backtokenizing the word
1, 81911, 8191[가나]
8192, 12000, 163831,073,733,632; 1,073,737,440; 1,073,741,823IndexOutOfBoundsException
16384, 16385, 20000, 245750, 1, 3616, 8191[가나], with the costs of the wrong id
24576, 327671,073,733,632; 1,073,741,823IndexOutOfBoundsException
-1, -81921,073,741,823; 1,073,733,632IndexOutOfBoundsException
-8193, -12000, -163848191, 4384, 0[가나], with the costs of the wrong id
-16385, -245761,073,741,823; 1,073,733,632IndexOutOfBoundsException
-24577, -327688191, 0[가나], with the costs of the wrong id
32768build fails (NumberFormatException)
left id 8192 (control)build fails (IllegalArgumentException: leftId >= 8192: 8192)
Exposure and likelihood287 words
  • Who is exposed. Builders of custom or retrained nori system dictionaries. Before 8.5.0 such a dictionary could only be used by putting its files on the classpath ahead of the nori jar; from 8.5.0 the custom-dictionary constructor is the public path. User dictionaries do not go through this writer (UserDictionary uses fixed right ids, UserDictionary.java:41-45).
  • Default vs opt-in. Opt-in. The bundled dictionary has 3,822 right ids and 2,693 left ids (read from its ConnectionCosts.dat header), so the default KoreanTokenizer and KoreanAnalyzer never reach the bug.
  • What the damage looks like. Right ids 16384 to 24575, and negative ids from -16384 to -8193 or from -32768 to -24577: the dictionary builds and loads, and every connection cost after the word comes from another id's row, so segmentation (and the indexed terms) can change with no error. Right ids 8192 to 16383 and 24576 to 32767, and negative ids from -8192 to -1 or from -24576 to -16385: the build still succeeds, and the failure comes later, wherever the tokenizer runs, as a bare IndexOutOfBoundsException that does not name the dictionary row.
  • How likely. Rare, and contrived for the silent effect alone. MeCab context ids are dense (0 to N-1). A model with 8,193 to 16,384 right ids only crashes; a model big enough to reach the silent band also has ids in the crash band, so its user most likely sees the exception first. Larger MeCab models exist: an open kuromoji UniDic PR (#12517) shows Japanese context ids above 15,000. The documented searches found no prior report (2026-10-27, best effort).
  • Scale and modes. One row reproduces it; assertions on and off behave the same (there is no assert either). The fix runs only at build time.

Minimal repro

The test shows that nori's DictionaryBuilder accepts a lexicon row with right context id 16385 (it rejects a left id of 8192) and the compiled dictionary reads it back wrapped to 14 bits.

TestNoriBuilderRightIdLimit.java (JUnit test)50 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.ko.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 TestNoriBuilderRightIdLimit extends LuceneTestCase {

  public void testRightId16385() throws Exception {
    Path dir = createTempDir();
    Files.writeString(dir.resolve("test.csv"), "가나,1,16385,100,NNG,*,F,가나,*,*,*,*\n");
    Files.writeString(dir.resolve("char.def"), "DEFAULT 0 1 0\n");
    Files.writeString(dir.resolve("unk.def"), "DEFAULT,1,1,0,SY,*,*,*,*,*,*,*\n");
    Files.writeString(dir.resolve("matrix.def"), "16386 2\n");
    try {
      DictionaryBuilder.build(dir, dir, "utf-8", false);
    } catch (IllegalArgumentException e) {
      return;
    }
    String base = TokenInfoDictionary.class.getName().replace('.', '/');
    TokenInfoDictionary dict =
        new TokenInfoDictionary(
            dir.resolve(base + BinaryDictionary.TARGETMAP_FILENAME_SUFFIX),
            dir.resolve(base + BinaryDictionary.POSDICT_FILENAME_SUFFIX),
            dir.resolve(base + BinaryDictionary.DICT_FILENAME_SUFFIX),
            dir.resolve(base + TokenInfoDictionary.FST_FILENAME_SUFFIX));
    IntsRef ids = new IntsRef();
    dict.lookupWordIds(0, ids);
    // expected: the row is rejected or right id 16385 reads back; actual: it reads back as 1
    assertEquals(16385, dict.getRightId(ids.ints[ids.offset]));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-710/, 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/nori/build/libs/lucene-analysis-nori-$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.ko.dict.TestNoriBuilderRightIdLimit
# the module(s) analysis-nori also need their third-party runtime jars
Expected vs actual
Expected the build to reject the row or getRightId to return 16385; actual 1 (expected:<16385> but was:<1>), with -ea and -da.

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

More files, environment, full output34 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
    => java.lang.AssertionError: expected:<16385> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.analysis.ko.dict.TestNoriBuilderRightIdLimit.testRightId16385(TestNoriBuilderRightIdLimit.java:48)

Why it looks unintended

  • Both ids use the same 14-bit packing, and the builder already rejects an overlarge left id; upstream testPutException expects // id too large to fail but sets both ids to 8192.
  • In LUCENE-8816 committers asked for exceptions on "leftID (and rightID)", "since we would get garbage".
  • The right-id bound was lost when the code was ported from kuromoji.
The full argument314 words
  • The builder's own rule. Both ids are written as (short) (id << 2 | bits) (TokenInfoDictionaryEntryWriter.java:136-137) and decoded as getShort(...) >>> 2 (TokenInfoMorphData.java:54, :59); with that signed read only 0 to 8191 round-trip, and the builder enforces that limit (ID_LIMIT, :33) for the left id (:130-132). No javadoc, CHANGES or MIGRATE entry states an id range, and nothing says an id may wrap.
  • Upstream tests. TestTokenInfoDictionary.testPutException (:92-99): // id too large expects newDictionary("HANGUL,8192,8192,1,NNG,*,*,*,*,*,*,*") to throw; with both ids at 8192 only the left check is exercised. testPut (:45-64) uses ids 5000 with a 1 1 matrix, so the tests rely on there being no check against matrix.def.
  • What the committers said. #9860 (LUCENE-8816, kuromoji), @msokolov: "we restrict incoming leftID (and rightID) to be <4096 ... change the asserts there to be IllegalArgumentException so they are raised whenever the tool is run, since we would get garbage if this limit is exceeded, and (I think) nothing else will catch it"; @rmuir: "The use of assert was laziness on my part: we should treat it as technical debt and fix it"; @mocobeta: "change the all assertions to some Exceptions so that users can figure out the problem with their customized dictionary". #9947 (LUCENE-8904), which added nori's left-id exception, notes "left context ID == right context ID / Nori : can be different / Kuromoji : always same".
  • History. Kuromoji's writer had assert leftId == rightId; assert leftId < 4096;, so its bound covered both ids. Nori's first commit (e851b89cbeb, LUCENE-8231) dropped the equality, because nori's ids differ, and kept only assert leftId < 8192; LUCENE-8904 (2ac5fb668a8 on master; backport 70854dc1efb in 8.3.0) turned that into today's exception and added nothing for the right id. Kuromoji today checks leftId != rightId before its limit (ja/dict/TokenInfoDictionaryEntryWriter.java:117-122).
The devil's-advocate check210 words

The case for intended behaviour (the intended-behaviour check and the adjudication: weak to moderate; the cross-model fact check: moderate): DictionaryBuilder is a lightly validating, @lucene.experimental build tool for trusted input; it never checks ids against matrix.def; and in #9860 @mocobeta wrote "There is another possibility that users give large values to leftIDs (and rightIDs) in their customized dictionary by hand, however I don't think we should take care about that."

Why it fails: that remark is about whether to support larger ids, and the same comment asks for exceptions so users can find the problem in their dictionary. The limit here is the builder's own ID_LIMIT, the range its format reads back correctly, which only the builder can enforce and which it already enforces for the other id stored the same way. The outcome is not a clear error but a dictionary that silently means something else, or crashes later. What the case gets right: the reach is narrow. Also, 8192 is a limit of the signed read, not of the stored bits, so maintainers may prefer to support 8192 to 16383 with an unsigned read (the second fix option) instead of rejecting them; ids of 16384 and more and negative ids need the check either way.

Suggested fix

Recommended Reject a right id outside [0, 8192) at build time

Reject a right id outside [0, 8192) with IllegalArgumentException, next to the existing left-id check (+3 lines in TokenInfoDictionaryEntryWriter.java), and add two cases to testPutException. The new tests and the whole nori module (14 suites, 93 tests) pass with assertions on and off, and the bundled dictionary rebuilds byte-identically.

    if (leftId >= ID_LIMIT) {
      throw new IllegalArgumentException("leftId >= " + ID_LIMIT + ": " + leftId);
    }
+   if (rightId < 0 || rightId >= ID_LIMIT) {
+     throw new IllegalArgumentException("rightId out of range [0, " + ID_LIMIT + "): " + rightId);
+   }
All options and trade-offs4 options · 658 words

Keep the fix to the builder's own limit (ID_LIMIT), or read the field unsigned (the second option). A check against the matrix.def dimensions is a separate, wider gap that the upstream testPut would trip. Side observation: a negative left id already fails the build, but with IndexOutOfBoundsException: Index -1 out of bounds for length 0 from the part-of-speech table (TokenInfoDictionaryEntryWriter.java:94-101), before the left check at :130 is reached; moving both checks right after the parse would give it a clear message too (not run).

Reject a right id outside [0, 8192) at build time (validated by the checks) (recommended)

Add the check after the left-id check in TokenInfoDictionaryEntryWriter.putEntry (:130-132), and two right-id-only cases to TestTokenInfoDictionary.testPutException. The finder's fix; the lower bound was added after the cross-model review.

    if (leftId >= ID_LIMIT) {
      throw new IllegalArgumentException("leftId >= " + ID_LIMIT + ": " + leftId);
    }
+   if (rightId < 0 || rightId >= ID_LIMIT) {
+     throw new IllegalArgumentException("rightId out of range [0, " + ID_LIMIT + "): " + rightId);
+   }

Trade-offs

  • Build time only: no cost at analysis time.
  • A dictionary in the silent band that seems to work today stops building; it was already compiled wrong.
  • Covers word rows and unk.def rows (one writer). It does not check ids against matrix.def.
  • It also rejects 8192 to 16383, which the stored field holds and only the signed read loses; the unsigned-read option would accept them instead.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, JDK 25: on stock the new test fails 4 of 6 (assertions on, seed 0B1D0710, and off, seed 0B1D0711) and the extended testPutException fails; with the fix, org.apache.lucene.analysis.ko.dict.*: 5 suites, 20 tests, 0 failures, assertions on and off; the whole :lucene:analysis:nori:test: 14 suites, 93 tests, 4 skipped, 0 failures, assertions on and off; checkGoogleJavaFormat passes.
  • Standalone runner with the fix shadowed: 6 of 6 (-ea, -da); the probe rejects the 11 out-of-range ids from -8192 to 32767 with IllegalArgumentException and still builds 1 and 8191.
  • Earlier: the bundled mecab-ko-dic 2.1.1 rebuilt with the fix is byte-identical (9 files); the finder's test 4 of 4, the independent reproduction's 7 of 7, the cross-model reviewers' tests, and the upstream TestTokenInfoDictionary + TestUnknownDictionary 6 of 6 pass.

Read the field unsigned and raise the limit to 16384

Ids 8192 to 16383 are written intact; only the signed read loses them. Read (buffer.getShort(...) & 0xffff) >>> 2 in getLeftId and getRightId (TokenInfoMorphData.java:54, :59), as kuromoji's reader does with its 3 flag bits (ja/dict/TokenInfoMorphData.java:72, :77), and raise ID_LIMIT to 16384. Evaluated with the same Java expressions, not with Lucene: this read returns every id in [0, 16383] exactly and, below 8192, the same value as today, so existing dictionaries read back the same and the on-disk layout does not change. The first option's check is still needed with the new limit: 16384 and above lose bit 14 on write, and a negative id would read back as id mod 16384 (-1 as 16383). Not run in Lucene.

Trade-offs

Supports larger models instead of rejecting them, so it is closer to a feature. A dictionary that uses the new range is misread by older Lucene versions. The cost matrix is right ids x left ids shorts in one direct buffer (morph/ConnectionCosts.java:46-49), about 88 MB for 16,384 x 2,693. Ids beyond 16383 need a wider field and a DictionaryConstants.VERSION bump.

Not recommended: check every id against the matrix.def dimensions

Not run.

Trade-offs

Catches more broken sources, but the upstream testPut (ids 5000 with a 1 1 matrix) would fail, and it is a separate, wider gap.

Not recommended: guard at read time

A bounds check in getRightId or ConnectionCosts.get. Not run.

Trade-offs

Adds work on the analysis hot path and cannot detect the silent band, where the decoded id (16385 -> 1) is valid.

Downstream note

Lucene treats dictionary sources as trusted configuration; a product that compiles system dictionaries supplied by its own users should check right ids (0 to 8191) itself until a fix ships.

How it was found

An AI finder was checking that the bundled nori dictionary decodes back to its mecab-ko-dic source (12.7 million checks), which cannot show this bug because every bundled id is below 8192. Reading the encoding, the finder saw that the left id is range-checked and the right id is not, and confirmed it with a tiny custom dictionary.

The method in full124 words

Method: bounded-exhaustive translation validation on the stock jars, then code reading (the study's "semi-formal" tier). A hunt unit, "CJK compiled-dictionary source round trip", 2026-10-27.

  • The harness compared every field of every entry of the shipped nori dictionary with its mecab-ko-dic 2.1.1 source row, with assertions on and off: 12,723,657 checks, clean apart from compound rows of another hunt bug and reading rows that are harmless by construction.
  • Reading the encoding of each checked field showed the asymmetry: an explicit 8192 limit for the left id and none for the right id, packed the same way.
  • The hypothesis was turned into a public-API JUnit that builds a tiny custom dictionary with DictionaryBuilder; independent AI tools then reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (best-effort search, 2026-10-27): 51 tool queries and 30 manual GitHub searches over issues, PRs, commits, JIRA, four mailing lists, downstream projects and the web, plus all 409 open PRs. Related threads added the left-id check (LUCENE-8904) and discussed kuromoji's id limits (LUCENE-8816), never nori's right id.

Searches5 searches

Nothing reports the unchecked right id, the wrapped id or the exception. TokenInfoDictionaryEntryWriter.java is byte-identical on upstream main a95c754ee003 (2026-10-26), branch_10x and branch_10_6; no open PR touches it.

Related, not the same bug: #9947 / LUCENE-8904 and lucene-solr #767 (added the left-id check); #9860 / LUCENE-8816 (open; kuromoji id limits, "garbage if this limit is exceeded"; kuromoji enforces left == right); #9906 / LUCENE-8863 (kuromoji counterpart); #10293 / LUCENE-9253 (the custom-dictionary constructor); #9278 / LUCENE-8231 (introduced nori); #740 / LUCENE-10393 and #9861 / LUCENE-8817 (builder unification); #12517 (open kuromoji UniDic PR, context ids above 8192); SOLR-12655 (a user building nori from an edited mecab-ko-dic, a different error); #9649 / LUCENE-8603 and #13427 (user-dictionary right ids, other code).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, downstream search servers)2026-10-2716 issue/PR, 3 commit and 2 downstream queries with a scripted prior-report search; 30 manual searches (right id, rightId, right context, ID_LIMIT, the writer class, IndexOutOfBoundsException with ConnectionCosts, Viterbi or KoreanTokenizer, unk.def, matrix.def); a title sweep of 85 nori/Korean itemsno report; related items listed above
Open PRs2026-10-27the changed files of all 409 open apache/lucene PRsnone touches TokenInfoDictionaryEntryWriter.java
Mailing lists and JIRA2026-10-2720 tool queries over dev@, java-user@, issues@ and commits@, and 15 manual queries over dev@, java-user@ and issues@; 4 tool and 6 manual JQL queriesno report
Web2026-10-276 tool queries and 2 manual queries, one in Koreanno report
The finder's own run2026-10-2743 queries (GitHub, commits, lists, JIRA)no report

History

Present since nori's first commit (e851b89cbeb, LUCENE-8231, 2018), first released in 7.4.0; the line never changed. It reproduces on every release run from 7.4.0 to 10.5.2 and on main. The fully public path (the custom-dictionary constructor) exists from 8.5.0.

Versions tested7 rows

Introduced by e851b89cbeb1 (master, 2018-04-13, LUCENE-8231 "Add a new analysis module (nori) similar to Kuromoji but to handle Korean"; JIRA patch workflow, now #9278) and its same-day branch_7x backport 8651fbaa8bc6, shipped in 7.4.0. The original put() had only assert leftId < 8192; the kuromoji code it was ported from asserted leftId == rightId and leftId < 4096. Later changes near the check, none of which fixed it: LUCENE-8904 (2ac5fb668a8 on master, first released in 9.0.0; its branch_8x backport 70854dc1efb shipped in 8.3.0) turned the left-id assert into the exception; LUCENE-8934 (8.3.0) put DictionaryBuilder in the jar; LUCENE-9253 (8.5.0) added the public custom-dictionary constructor; LUCENE-10400 (9.1.0) the Path constructors; LUCENE-10393 and LUCENE-10475 (10.0.0) moved the writer to its current name. On 7.4.0-8.2.0 the left id is also only assert-checked. Not a regression; never fixed.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test 4 of 6 fail (gradle and the standalone runner, assertions on and off)
10.5.2 affectedthe brief's test 4 of 6 fail (assertions on and off); the finder's test 3 of 4
9.12.3, 9.1.0, 10.0.0 affectedthe finder's JUnit (9.x variant on 9.x) 3 of 4 fail; the independent reproduction's 4 of 7 on 9.12.3
8.5.0, 8.11.4, 9.0.0 affectedpublic-API JUnit (ResourceScheme constructors) 3 of 4 fail
7.4.0, 8.3.0 affectedhistory driver: 16385 reads back as 1, segmentation changes, 8192 crashes tokenization; on 7.4.0 with -da a left id of 8192 is accepted too
7.3.1 no nori moduleno nori artifact for 7.3.1
main + fix not affectedall repros pass; the nori module's 14 suites, 93 tests, 0 failed

Independent checks

An independent reproducer, a devil's advocate, an adjudication and reviews by another model family (all ran their own tests) found it real; the adjudication confirmed it with high confidence. A registry re-run, a credibility audit and this doc's runs reproduced it with new seeds.

Checks and verdicts465 words

Every check wrote or ran its own test against the stock jars, with assertions on and off unless noted. The cross-model reviews ran on main ea57360912ed, whose lucene/analysis/nori is identical to a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnit fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3; with the fix 4 of 4 pass; the bundled dictionary rebuilds byte-identically.
cross-model review, refute lens real (high)An independent reviewer from another model family (OpenAI GPT-5.6) ran its own test: right id 8192 decodes as 1073733632 and tokenizing throws; controls pass; it passes with the fix. It noted that negative right ids are accepted too, which the fix now rejects.
Independent reproducer realOwn 7-test JUnit with a MeCab-consistent matrix fails 4 of 7 on main (-ea, -da), 10.5.2 and 9.12.3; its 3 controls pass; 7 of 7 with the fix.
Cross-model review, intended lens real (high)OpenAI GPT-5.6: the intended-behaviour argument is weak; its contract test fails 2 of 3 on main and passes 3 of 3 with the fix.
Devil's advocate realOwn test fails 3 of 4 on main and 10.5.2: right ids 32767 and -1 are accepted, and an unk.def row with right id 16385 reads back as 1.
adjudication confirmed (high)Re-ran the independent reproduction's test with new seeds: 4 of 7 fail with -ea and -da, 7 of 7 pass with the fix. Class: silent wrong results.
registry re-run and audit kept; score loweredThe finder's test fails 3 of 4 with fresh seeds (-ea, -da) and passes with the fix; the audit kept the class and lowered the likelihood for the silent effect.
fact check of this doc bug, repro and fix confirmed; wording fixedRe-ran the gradle repro in a fresh worktree (4 of 6 fail, assertions on and off and a random seed), the fix (the nori module's 93 tests pass) and a probe of its own over 20 right ids. It found 2 low and 4 trivial wording errors, chiefly that only some negative ids throw; all six are fixed.
cross-model fact check real; wording correctedOpenAI GPT-5.6, reading the code and the saved runs: the bug is real, and the 8192 limit comes from the signed read, not from the stored bits (8192 to 16383 are written intact). That correction, the negative-id bands, a commit/release pairing and an overclaim are fixed, and the unsigned read is now a fix option.
this doc's runs reproduced; fix validatedThe paste-ready test fails 4 of 6 on main (gradle and standalone, -ea, -da) and 10.5.2; with the fix the nori module's 93 tests pass.

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

Finding record

LFV-710, snapshot of 2026-11-06

Registry title
Nori's DictionaryBuilder rejects a left context id of 8192 or more but silently accepts such a right context id, which does not round-trip through the compiled format, so the compiled dictionary returns a wrapped right id (changing segmentation) or makes KoreanTokenizer throw IndexOutOfBoundsException
Area
analysis / nori system dictionary builder (DictionaryBuilder, TokenInfoDictionaryEntryWriter; custom system dictionaries)
Priority
Low, rank 523: Nori's DictionaryBuilder silently wraps right context ids of 8192 or more only for an opt-in custom system dictionary far larger than any known Korean model, which is rarer than LFV-459, so it follows that kuromoji/nori rare-input row.
Impact class
silent wrong results
Impact note
Hits only applications that compile their own nori system dictionary with the public DictionaryBuilder and load it through the @lucene.experimental custom-dictionary KoreanTokenizer constructor (opt-in), and only when the source uses a right context id of 8192 or more (or a negative one): ids in [16384, 24575] silently wrap to id - 16384 and change connection costs and segmentation, while ids in [8192, 16383] and [24576, 32767] make tokenizing any text that contains the word throw IndexOutOfBoundsException, and a negative id has one of the two outcomes (it behaves like its value mod 16384: -1 throws, -8193 silently reads back as 8191); the bundled mecab-ko-dic 2.1.1 (3,822 right ids), and so the default KoreanTokenizer and KoreanAnalyzer, are not affected; no scale needed, deterministic with -ea and -da, every release 7.4.0 to 10.6.0 (10.6.0 by reading) and main.
Summary
You compile a custom nori system dictionary with DictionaryBuilder from MeCab-format sources in which a lexicon row (or an unk.def row) has right context id 16385, with a matrix.def that declares 16386 right ids, and expect the row either to be rejected, as a left id of 8192 is ('leftId >= 8192'), or to read back as 16385. Instead the builder accepts it and packs the id into a 14-bit field without any check (TokenInfoDictionaryEntryWriter.java:137), so Dictionary.getRightId returns 1 and KoreanTokenizer uses the connection costs of right id 1: in the repro '가나다' is split [가나, 다] instead of [가, 나다]. A right id in [8192, 16383] decodes to about 1.07 billion, and tokenizing any text that contains the word throws IndexOutOfBoundsException from ConnectionCosts.get.
Introduced
e851b89cbeb1f55edc0f2c1276e2ae812eca2643 (LUCENE-8231 'Add a new analysis module (nori) similar to Kuromoji but to handle Korean', by jimczi; JIRA patch workflow, no pull request; the issue is now apache/lucene#9278) and its same-day branch_7x backport 8651fbaa8bc65fdfec397ad4e7ebe2b5acf78d7b, which shipped in 7.4.0 [read: git blame of TokenInfoDictionaryEntryWriter.java:137 on main a9779ef3c193 points at e851b89cbeb1; the original put() had 'assert leftId < 8192; // there are still unused bits' and the unchecked right-id write; the Kuromoji writer it was ported from asserted leftId == rightId and leftId < 4096, which bounded both ids, and the port dropped the equality because nori's ids differ; LUCENE-8904 (#9947; 2ac5fb668a8 master, 70854dc1efb branch_8x; 8.3.0) turned the left-id assert into today's IllegalArgumentException and added nothing for the right id; git log --all -G over lucene/analysis/nori finds no commit that ever added a right-id bound]. Not a regression: the gap dates from the module's first commit (2018-04-13)
First affected version
7.4.0 [exec] (nori's first release; 7.3.1 has no nori module [exec: no artifact on Maven Central]). Reach by version: 7.4.0-8.2.0 ship the builder only as a source-release tool, and a custom dictionary can be loaded only by putting it on the classpath ahead of the jar; 8.3.0 puts DictionaryBuilder in the jar (LUCENE-8934); 8.5.0 adds the public custom-dictionary KoreanTokenizer constructor (LUCENE-9253), the first fully public path
Affected versions
Every release from 7.4.0 through 10.5.2 (latest 9.x 9.12.3), 10.6.0 (by reading) and main a9779ef3c193; never fixed. [exec] fails on 7.4.0 (-ea and -da; history driver with the 7.4.0 source-release builder; there the left id is also only assert-checked, so with -da a left id of 8192 is accepted too), 8.3.0 (-ea and -da; history driver), 8.5.0 (-ea and -da), 8.11.4, 9.0.0 (-ea and -da), 9.1.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da): from 8.5.0 on a public-API JUnit variant fails 3 of 4 with its left-id control passing; the independent reproduction's test fails 4 of 7 on main, 10.5.2 and 9.12.3. [read] every local release tag from 7.4.0 to 8.8.1 and from 9.0.0 to 10.5.2 has the unchecked write; 8.8.2-8.11.3 are not tagged locally (between 8.8.1 [read] and 8.11.4 [exec], both affected). TokenInfoDictionaryEntryWriter.java is byte-identical on upstream main a95c754ee003 (2026-10-26), branch_10x and branch_10_6, and no open PR touches it. All runs on JDK 25, aarch64
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 (2026-10-27, plus the finder's run and its gh checks, read-only; the prior-report record, verdict new): a scripted prior-report search with 51 queries (152 distinct hits, 0 high) over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 30 manual GitHub searches (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr); a GraphQL title sweep of 85 nori/Korean items and the changed files of all 409 open apache/lucene PRs; 6 JQL and 15 mailing-list queries; the finder's own 43-query run. Related, not this bug: #9947 / LUCENE-8904 and lucene-solr#767 (added today's left-id check; the description says nori's left and right ids 'can be different'; nobody mentions the right id), #9860 / LUCENE-8816 (open, kuromoji: committers agreed that id-limit asserts should become exceptions because overlarge ids give 'garbage'; kuromoji enforces leftId == rightId, so the thread never identifies nori's unchecked right id), #9906 / LUCENE-8863 (kuromoji counterpart), #10293 / LUCENE-9253 (added the custom-dictionary KoreanTokenizer constructor), #9278 / LUCENE-8231 (introduced nori), #740 / LUCENE-10393 and #9861 / LUCENE-8817 (builder unification), #12517 (open kuromoji UniDic PR; shows MeCab dictionaries with context ids above 8192), SOLR-12655 (a user building nori from an edited mecab-ko-dic; a different error), #9649 / LUCENE-8603 and #13427 (user-dictionary right ids, other code). Not fixed: TokenInfoDictionaryEntryWriter.java is byte-identical on upstream main a95c754ee003, branch_10x and branch_10_6, and no open PR touches it. Gaps: lexical, first-page GitHub search under a shared rate limit; Korean forums covered only by general web search; private trackers not visible
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-28) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 release jars (the brief's test, assertions on and off). Earlier runs of the hunt, its checks and the version-history runs (2026-10-27): 7.4.0, 8.3.0, 8.5.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; 7.3.1 has no nori module. Fix A was validated in the scratch worktree (the new test, the dictionary suites and the whole nori module with assertions on and off, checkGoogleJavaFormat) and earlier by rebuilding the bundled dictionary (byte-identical). Only Linux aarch64 with JDK 25 was run.

Lucene and upstream sources

Finding record
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoDictionaryEntryWriter.java:33 (at a9779ef3c193) (ID_LIMIT = 8192), :63-64 (left and right id parsed with Short.parseShort), :130-132 (only leftId >= ID_LIMIT rejected), :136-137 (putShort((short) (leftId << 2 | posType.ordinal())) and putShort((short) (rightId << 2 | flags)), no right-id check); TokenInfoMorphData.java:53-60 (getLeftId and getRightId decode getShort(..) >>> 2 of a sign-extended short); TokenInfoDictionaryWriter.java:30 and UnknownDictionaryWriter.java:35 (both use TokenInfoDictionaryEntryWriter); DictionaryBuilder.java:24-28 (public class, public static build(Path, Path, String, boolean)); ko/KoreanTokenizer.java:138-152 (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/common/src/java/org/apache/lucene/analysis/morph/ConnectionCosts.java:61-64 (get indexes the matrix buffer with the decoded id); morph/Viterbi.java:389 (end-of-input cost costs.get(lastRightID, 0)); lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/dict/TestTokenInfoDictionary.java:95-98 (testPutException '// id too large' with left = right = 8192)
The finding in 30 seconds
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoDictionaryEntryWriter.java:33, (at a9779ef3c193) :62-64, :130-137
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoMorphData.java:53-60; (at a9779ef3c193) lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/TokenInfoMorphData.java:71-78 (at a9779ef3c193) (masked read)
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoDictionaryWriter.java:30; (at a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UnknownDictionaryWriter.java:35 (at a9779ef3c193)
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/DictionaryBuilder.java:24-28; (at a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:138-154 (at a9779ef3c193)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/morph/ConnectionCosts.java:33-64; (at a9779ef3c193) morph/Viterbi.java:389, :471, :530-538
Who is affected
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/UserDictionary.java:40-45; (at a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:138-152 (at a9779ef3c193)
Why it looks unintended
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoDictionaryEntryWriter.java:130-137; (at a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoMorphData.java:54, (at a9779ef3c193) :59
  • lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/dict/TestTokenInfoDictionary.java:45-64, (at a9779ef3c193) :80, :92-99
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/TokenInfoDictionaryEntryWriter.java:117-122 (at a9779ef3c193)
Suggested fix
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/morph/ConnectionCosts.java:46-49; (at a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoDictionaryEntryWriter.java:94-101 (at a9779ef3c193)

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

Repro files

About the diagrams

A light, step-by-step picture of the repro (the row, the bits, the two segmentations, every right id of 8192 or more, the fix), made in parallel with this doc from the code at a9779ef3c193 and executed runs. The caption and alt text were written from the same executed values; check them against the final frames.

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.