Kuromoji and nori turn a run of characters the dictionary does not know, of one class or script, into one unknown word and stop it at 1,024 UTF-16 chars. Neither checks whether char 1023 is the first half of a surrogate pair. Both halves belong to the run's class, so only the cap ends it.
- You analyze 1,200 chars of Thai without spaces with an emoji, U+1F600 (two UTF-16 chars), at char 1023, through
JapaneseTokenizeror thejapanesetokenizer factory with defaults; or 400 Γ ποΈ (U+1F441 U+FE0F) through the defaultKoreanAnalyzer. - Both tokenizers read the run as one unknown word and stop it at 1,024 chars, a safety cap. You expect the cut to fall between whole characters, as it does when the emoji is at char 1022 or 1024.
- Instead the cut lands between the emoji's two halves: term [0,1024) ends with U+D83D and term [1024,1200) starts with U+DE00. The index stores U+FFFD in both terms, and no term contains the emoji.
- In EXTENDED mode (unknown words as single characters) a
TermQueryfor the emoji finds 0 of 1 documents, against 1 when it sits at char 1022 or 1024. No error:CheckIndexis clean and the stored text keeps the emoji.
Chains on kuromoji's JapaneseTokenizer (any mode, the japanese factory with defaults) and nori's KoreanTokenizer, default KoreanAnalyzer and korean factory. Not the default JapaneseAnalyzer, whose stop tags drop these words. It needs over 1,023 chars with no break, such as space-free Thai or emoji runs: one character per cut, a TermQuery miss only in the unigram modes.
Mechanism, terms and numbers614 words
Kuromoji's JapaneseTokenizer and nori's KoreanTokenizer group a run of characters that their dictionary does not know into one unknown word and stop it at MAX_UNKNOWN_WORD_LENGTH = 1024 UTF-16 chars, a limit commented "For safety". Neither checks whether char 1023 is the first half (high surrogate) of a supplementary character such as an emoji or a CJK Extension B ideograph. When it is, the character is split across two terms: one ends with a lone high surrogate, the next starts with the lone low surrogate, and the index stores both as U+FFFD. In the single-character modes (kuromoji EXTENDED, nori outputUnknownUnigrams=true) a TermQuery for the character then misses the document. It needs a run of more than 1,023 chars that the tokenizer cannot break, such as long space-free Thai through kuromoji or long emoji runs; the default JapaneseAnalyzer drops these words and is not affected, the default KoreanAnalyzer is. Present since kuromoji's first release (3.6.0, 2012) and nori's (7.4.0, 2018); a 5-line guard per tokenizer fixes it.
- Supplementary character, surrogate pair: Java strings are UTF-16. Characters above U+FFFF, such as most emoji (π is U+1F600) and CJK Extension B ideographs (U+20000), take two chars: a high surrogate (U+D800-U+DBFF) followed by a low surrogate (U+DC00-U+DFFF). Either half alone is not a character.
- Unknown word: when the dictionary has no entry, kuromoji and nori make a word from the run of following chars that share the first char's character class (kuromoji) or Unicode script group (nori).
- Unigram modes: kuromoji's EXTENDED mode and nori's
outputUnknownUnigrams=trueemit an unknown word as one token per character; that code keeps a surrogate pair together on purpose.
- The cap:
// For safety:protected static final int MAX_UNKNOWN_WORD_LENGTH = 1024;in the base class both tokenizers share (morph/Viterbi.java:41-42). - Kuromoji's loop (
ja/ViterbiNBest.java:137-149) and nori's (ko/Viterbi.java:92-126, the same headerfor (int posAhead = pos + 1; unknownWordLength < MAX_UNKNOWN_WORD_LENGTH; posAhead++)) add one UTF-16 char at a time. When the length reaches 1024 they stop, and nothing looks at the last char. - Both surrogate halves are class DEFAULT in both tokenizers, like Thai letters and U+FE0F; nori sees each half as script UNKNOWN and joins it to Common or Inherited characters such as U+FE0F. So a long Thai run (kuromoji) or an emoji run with U+FE0F (nori) continues through the emoji, and only the cap ends it. A pair-aligned run such as 600 Γ π never splits; it takes an odd number of BMP chars before the cut.
- When the emoji's high surrogate is char 1023 (or 2047, 3071, ...), one word ends with it and the next starts with the low surrogate. The unigram code works inside one word and cannot rejoin them.
- At indexing,
UnicodeUtil.UTF16toUTF8writes each lone half as U+FFFD (UnicodeUtil.java:172-176), asIndexWriterdocuments (IndexWriter.java:1483-1485). The index stays well-formed, so nothing fails.
| input and analyzer | tokens on main | with the fix |
|---|---|---|
| Thai + π at 1023, SEARCH mode | [0,1024) ends with U+D83D, [1024,1200) starts with U+DE00: both invalid UTF-16 | [0,1023), [1023,1200) starting with π |
| the same, EXTENDED | [1023,1024) = U+D83D, [1024,1025) = U+DE00 | [1023,1025) = π |
the same, default JapaneseAnalyzer | 0 tokens (not affected) | 0 tokens |
400 Γ (U+1F441 U+FE0F), default KoreanAnalyzer | [0,1024), [1024,1200): both invalid | [0,1023), [1023,1200) |
Indexed: with π at char 1023, no SEARCH-mode term contains it and two terms contain U+FFFD (at char 1023 of the 1,024-char term and char 0 of the 176-char term); EXTENDED-mode TermQuery(π) gets 1, 0 and 1 hits with π at chars 1022, 1023 and 1024, and 1 at 1023 with the fix.
Exposure and likelihood353 words
- Who is exposed.
JapaneseTokenizerin SEARCH (the default), NORMAL and EXTENDED mode, withdiscardPunctuationtrue or false, and thejapanesetokenizer factory with default parameters, in any chain that keeps these words: the hunt's sweep flagged 15 of the 16 kuromoji-based factory chains it built (with reading, base-form, katakana-stem, number and other filters); the 16th, with the part-of-speech stop filter, emitted no broken term, which fits the default stop tags dropping these words.KoreanTokenizer,new KoreanAnalyzer()and thekoreanfactory with defaults on emoji runs joined by U+FE0F, and nori's unigram mode. Both nori classes are@lucene.experimental. - Not exposed: the default
JapaneseAnalyzer. Kuromoji tags these words θ¨ε·-δΈθ¬ (symbol, general), and the default stop tags drop that tag; it emits 0 tokens for the probe inputs. - What it takes. More than 1,023 UTF-16 chars of one unknown-word class with no break, and a supplementary character whose first half lands exactly on a cut (char 1023, 2047, ...). Ordinary Japanese and Korean prose rarely does this: kanji and kana are other classes, and Korean text has spaces. Realistic cases are long space-free Thai (or other text kuromoji has no class for) in a Japanese chain, and long emoji runs for nori. How often real text hits a cut was not measured.
- What the user sees. The index stores U+FFFD in place of the character, in two terms, and token offsets point inside the pair. In the unigram modes a search, or a term-based exclusion filter, for that character misses the document. In SEARCH, NORMAL and the default
KoreanAnalyzeraTermQueryfor the character never matched the 1,024-char run token anyway, so its hit count does not change (prefix, wildcard and query-parser queries were not tested); what remains there is the U+FFFD terms, exact-phrase matches between two long runs that differ only in the character at the cut, and lone surrogates in highlight snippets for a U+FFFD query. - Scale. One character per 1,024-char cut in that field value. The executed probes found no exception and no measured extra cost (timings 0.88-1.00Γ a shifted control on about 262,144 chars),
CheckIndexclean, stored value intact.
The test shows that JapaneseTokenizer and KoreanTokenizer cut an unknown word at 1024 UTF-16 chars even between the two halves of a supplementary character, so a term ends in a lone high surrogate.
/*
* 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;
import java.io.StringReader;
import org.apache.lucene.analysis.ja.JapaneseTokenizer;
import org.apache.lucene.analysis.ko.KoreanTokenizer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.UnicodeUtil;
public class TestUnknownWordCapSurrogatePair extends LuceneTestCase {
private static void assertTermsValidUTF16(Tokenizer t, String text) throws Exception {
CharTermAttribute term = t.addAttribute(CharTermAttribute.class);
t.setReader(new StringReader(text));
t.reset();
while (t.incrementToken()) {
// expected: every term is valid UTF-16; actual: the 1024-char unknown-word cut splits a pair
assertTrue(
"term of length " + term.length() + " is not valid UTF-16",
UnicodeUtil.validUTF16String(term));
}
t.end();
t.close();
}
public void testJapaneseTokenizer() throws Exception {
// 1023 x U+0E01 (Thai), then U+1F600: its high surrogate is char 1023
assertTermsValidUTF16(
new JapaneseTokenizer(null, true, JapaneseTokenizer.Mode.SEARCH),
"\u0E01".repeat(1023) + "\uD83D\uDE00");
}
public void testKoreanTokenizer() throws Exception {
// 342 x (U+1F441 U+FE0F), 3 chars each: the high surrogate of the last U+1F441 is char 1023
assertTermsValidUTF16(new KoreanTokenizer(), "\uD83D\uDC41\uFE0F".repeat(342));
}
}
# in a copy of this finding's repro folder (repro/LFV-459/, 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:$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.TestUnknownWordCapSurrogatePair
# the module(s) analysis-kuromoji, analysis-nori also need their third-party runtime jars
- Expected vs actual
- Expected every term to be valid UTF-16; actual
term of length 1024 is not valid UTF-16in both tests, with -ea and -da.
More files, environment, full output64 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 2 test(s) run, 2 failed
=> java.lang.AssertionError: term of length 1024 is not valid UTF-16
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.apache.lucene.analysis.TestUnknownWordCapSurrogatePair.assertTermsValidUTF16(TestUnknownWordCapSurrogatePair.java:34)
org.apache.lucene.analysis.TestUnknownWordCapSurrogatePair.testKoreanTokenizer(TestUnknownWordCapSurrogatePair.java:51)
JUnit Vintage:TestUnknownWordCapSurrogatePair:testJapaneseTokenizer
MethodSource [className = 'org.apache.lucene.analysis.TestUnknownWordCapSurrogatePair', methodName = 'testJapaneseTokenizer', methodParameterTypes = '']
=> java.lang.AssertionError: term of length 1024 is not valid UTF-16
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.apache.lucene.analysis.TestUnknownWordCapSurrogatePair.assertTermsValidUTF16(TestUnknownWordCapSurrogatePair.java:34)
org.apache.lucene.analysis.TestUnknownWordCapSurrogatePair.testJapaneseTokenizer(TestUnknownWordCapSurrogatePair.java:44)- Kuromoji's own tests ("ensuring we don't ever split supplementaries") require every term to be valid UTF-16, but use 100-char inputs that never reach the cap.
- Both tokenizers' single-character modes keep surrogate pairs together on purpose.
- Lucene fixed the same split as a bug in smartcn (LUCENE-8325) and TruncateTokenFilter (#15899). A length cap justifies ending a word, not splitting a character.
The full argument226 words
- Kuromoji's tests state the contract.
TestJapaneseTokenizer.testSurrogates2(:414-431) andTestExtendedMode.testSurrogates2(:58-72), "random test ensuring we don't ever split supplementaries", assertUnicodeUtil.validUTF16String(termAtt)for every term. They came with the original contribution (LUCENE-3305), before the cap existed, and their 100-char random inputs never reach 1,024. - The same code keeps pairs together. The unigram paths pair a low surrogate with its high surrogate (
ja/ViterbiNBest.java:442,ko/Viterbi.java:218); in kuromoji this predates the cap: it came with the original contribution (LUCENE-3305, 2012) and was kept when the cap was added. - Precedents.
lucene/CHANGES.txt:7479(7.4.0): "LUCENE-8325: Fixed the smartcn tokenizer to not split UTF-16 surrogate pairs", where the nori author wrote "the tokenizer should not split surrogate pairs into multiple tokens";:1198-1200(10.5.0): TruncateTokenFilter "no longer produce half surrogates" (#15899, #15900). In the LUCENE-3767 discussion that added the cap, one comment names "don't separate a lead surrogate from a trail surrogate" as a general rule. - No javadoc we examined allows it. The javadocs of both tokenizers and their factories say nothing about the cap or supplementary characters (grep for 1024, surrogate, supplementary). IndexWriter's U+FFFD note covers malformed input; here the input is well-formed and the tokenizer makes the halves.
- For nori the contract is weaker: no nori test asserts it; it rests on nori's pair-preserving unigram code, the loop it copied from kuromoji and the statement above.
The devil's-advocate check120 words
The strongest case (the intended-behaviour checker and the cross-model reviewer): the cap is a deliberate safety bound counted in UTF-16 chars ("For safety" above both 1,024 constants; on #4970 the sibling forced-backtrace limit is described as "for safety / prevent adversary"); both tokenizers classify each char separately; nori is @lucene.experimental; the input is unusual; and no javadoc promises well-formed terms.
Why it fails: the cap explains why a word is cut, not why a character is, and backing off by one char keeps the bound (the fix). The per-char design is exactly what the unigram code compensates for, and kuromoji's tests state the property. Experimental status and rare input are about impact, not intent. Verdict: not intended, a defect.
Recommended Back off one char when the cap would split a surrogate pair
After each unknown-word loop (kuromoji ViterbiNBest, nori Viterbi): if the word reached 1,024 chars and ends with a high surrogate, shorten it to 1,023 (5 lines each). The cap still holds and the word is never empty. The repro passes, and so do the whole kuromoji (197 tests) and nori (89 tests) test tasks, assertions on and off.
// after the unknown-word loop, in ja/ViterbiNBest.java and in ko/Viterbi.java:
// do not cut a surrogate pair in half when the word reaches its maximum length
if (unknownWordLength == MAX_UNKNOWN_WORD_LENGTH
&& Character.isHighSurrogate((char) buffer.get(pos + unknownWordLength - 1))) {
unknownWordLength--;
}All options and trade-offs3 options Β· 642 words
The recommended option is the finder's 5-line guard per tokenizer, re-made as a git diff at a9779ef3c193 (the guard lines are identical; only the code comment is shortened from "when the unknown word reaches" to "when the word reaches") and validated in a scratch worktree with the whole kuromoji and nori test tasks plus the paste-ready tests, assertions on and off, and the modules' lint and format checks. The other options were not run.
The word ends at 1,023 chars and the next unknown word starts with the whole emoji: [0,1023) + [1023,1200); EXTENDED emits the emoji as [1023,1025); the indexed term contains the emoji and the EXTENDED-mode TermQuery finds the document.
// after the unknown-word loop, in ja/ViterbiNBest.java and in ko/Viterbi.java:
// do not cut a surrogate pair in half when the word reaches its maximum length
if (unknownWordLength == MAX_UNKNOWN_WORD_LENGTH
&& Character.isHighSurrogate((char) buffer.get(pos + unknownWordLength - 1))) {
unknownWordLength--;
}Trade-offs
- The bound still holds (at most 1,024 chars) and the length becomes 1,023, never 0, so the lattice always moves forward. It fires only when the cap is reached and the last char is a high surrogate; all other text tokenizes as before. No cost change in the security probe (0.98-1.00Γ with the fix).
- Documents indexed before the fix keep their U+FFFD terms until they are reindexed.
- The guard is duplicated in two classes; a PR could put a small protected helper next to the constant in the shared
morph/Viterbi.java(not run). - In nori the loop may already have updated
characterIdfrom the dropped char when the run so far was all Common or Inherited (ko/Viterbi.java:120-125); the halves and U+FE0F are all class DEFAULT, so nothing changes in the probed cases. Reviewed but not run: a run of 1,023 Common or Inherited chars whose next char is the high surrogate, where the shortened word keeps the dropped char's class. - A PR could add the tests as methods of
TestJapaneseTokenizer/TestExtendedModeandTestKoreanTokenizer/TestKoreanAnalyzer; the defaultKoreanAnalyzeron an emoji run is the default-configuration path worth covering.
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, seed DEADBEEF: the two paste-ready tests fail on stock main, 4 of 4, assertions on and off; with the fix the whole:lucene:analysis:kuromoji:testtask passes, 31 classes, 197 tests, 0 failures (7 skipped), and the whole:lucene:analysis:nori:testtask, 14 classes, 89 tests, 0 failures (4 skipped), with assertions on and off. The skips are pre-existing module tests. The closest suites TestJapaneseTokenizer (45), TestExtendedMode (5), TestKoreanTokenizer (17) and TestKoreanAnalyzer (7) pass. ecjLintMain,ecjLintTestandcheckGoogleJavaFormatof both modules pass with the fix and the tests.- With the repro runner and the two fixed classes first on the classpath, the paste-ready tests pass on main with assertions on and off, and the finder's 6-test JUnit passes; earlier, the independent reproducer's 12 tests and a 300-text random corpus (0 lone-surrogate terms) passed with the same change.
- The gradle runner ran in two rounds: round 1 ran the two stock stages, then stopped on a shell error in the script (an empty bash array) before the fix stages; round 2 ran both fix stages and the lint stage.
Extend by one char instead of backing off.
Trade-offs
The cap is then no longer a strict 1,024 bound, and nothing checked whether other code relies on it. More risk than the back-off for no gain. Not run.
Count a supplementary character as one in the loop.
Trade-offs
Moves the cut in every long run with supplementary characters (up to 2,048 chars), not only in the failing case: a bigger behaviour change than the defect needs. Not run.
Search servers built on Lucene that expose these tokenizers inherit the behaviour; for a U+FFFD query on the single-character fields, UnifiedHighlighter returned snippets containing a lone surrogate (downstream handling of such snippets was not tested).
Semi-formal. A bounded-exhaustive sweep of factory-built analysis chains (filler texts Γ probe characters Γ buffer-edge positions) checked that no term from well-formed input has an unpaired surrogate. It flagged 15 of the 16 kuromoji-based chains (not the part-of-speech stop chain) at chars 1023, 2047, 4095, 8191 and 16383; a follow-up probe of the same cap found the nori variant.
The method in full196 words
- Sweep. The hunt unit on analysis chains built through the SPI factories ran a bounded-exhaustive buffer-edge sweep: 13 filler texts Γ 13 probes Γ buffer boundaries Γ positions, with the oracle "no term with an unpaired surrogate from well-formed input". It reported 900 violations: 60 in each of 15 kuromoji-based chain configurations, at the cuts 1023, 2047, 4095, 8191 and 16383 (a few chars earlier for multi-code-point probes); the 16th kuromoji chain, with the part-of-speech stop filter, reported none. The hits came from the Thai, Hangul, emoji and Extension B fillers with the emoji, Extension B, flag and ZWJ-sequence probes, and with a BMP Hangul jamo probe that shifts an emoji or Extension B filler run by an odd number of chars. No nori configuration reported this violation: its Thai and Hangul fillers break at the emoji (script change), and its emoji and Extension B filler runs stay pair-aligned.
- Follow-up. A probe of the same cap in nori found the emoji + U+FE0F run, which keeps one script group up to the cap.
- Reduction. The finder reduced both to a public-API JUnit (6 tests). No formal tool (model checker, SMT solver or proof) produced a signal.
No prior report found: 164 logged queries over GitHub issues, PRs and commits, JIRA, the mailing lists and the web (2026-10-26), and a GitHub re-check on 2026-10-28. Nearest: #9976 (emoji in a user dictionary, a different cause) and #4840, which added the cap. Not fixed upstream.
Searches4 searches
No report of this defect was found, not even of the cap cutting a long unknown word at char 1,024 without surrogates. The closest items each have a different root cause and fix; three same-symptom fixes in other components are useful precedents. The two loops have not changed upstream since 2025-02 (kuromoji) and 2024-09 (nori).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, web | 2026-10-26 | the scripted prior-report search, 83 queries (comments of the top 20 candidates read), plus 45 manual GitHub, 13 JIRA, 21 list and 2 Japanese-language web queries; about 20 issues read in full; plus 24 and 28 earlier logged queries by the finder and the intended-behaviour checker | only nearby items: #9976 / LUCENE-8933 (user-dictionary offsets with emoji, fixed 2019), #4840 / LUCENE-3767 (added the cap), the forced-backtrace issues #11097, #11124, #9722, #4970, the loop changes #9594, #10009, #11529 |
| Open PRs | 2026-10-26 | all 408 open apache/lucene PRs listed; changed files of the 16 analysis-related ones read | only #14969 touches nori's Viterbi, in backtrace() only |
| GitHub issues and PRs (apache/lucene), re-check | 2026-10-28 | 7 queries (surrogate kuromoji, surrogate nori, MAX_UNKNOWN_WORD_LENGTH, JapaneseTokenizer emoji, KoreanTokenizer emoji, "unknown word" surrogate, unpaired surrogate tokenizer); 2 more hit the rate limit; last upstream commits touching the two files | only known items (#4840, #4995, #9976, #3259); the two files last changed upstream in 2025-02 and 2024-09 |
| Precedents (same symptom, other components) | 2026-10-26 | read | #9372 / LUCENE-8325 (smartcn), #15899 / #15900 (TruncateTokenFilter), #3259 / LUCENE-2183 (CharTokenizer) |
Added with the cap in 2012 (430365f7cce, LUCENE-3767; first release 3.6.0); nori copied the loop in 2018 (e851b89cbeb, LUCENE-8231; 7.4.0). Reproduced on 18 releases from 3.6.0 to 10.5.2 and on main; 10.6.0 has the same code (by reading); never fixed.
Versions tested6 rows
Introduced (kuromoji) by 430365f7cce on trunk (2012-03-04), "Kuromoji now produces both compound words and the segmentation of those words in search mode (LUCENE-3767)" (#4840), which added MAX_UNKNOWN_WORD_LENGTH = 1024 and the loop; its branch_3x backport e7ab11a9ddf (2012-03-10) shipped in 3.6.0. Introduced (nori) by e851b89cbeb (2018-04-13, LUCENE-8231), which copied the loop; its branch_7x copy 8651fbaa8bc shipped in 7.4.0. Later changes to the loops added no surrogate check: LUCENE-3940 (3.6.1 and 4.0.0), LUCENE-8548 (nori groups by script, 7.7.0; from then on only emoji runs with U+FE0F trigger nori, not Thai), LUCENE-8966 (8.3.0) and LUCENE-10493 (#805, the shared base class, 10.0.0).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | paste-ready tests fail 4 of 4 (gradle and runner, assertions on and off); the finder's JUnit fails 5 of 6 |
| 10.5.2, 9.12.3 | affected | paste-ready tests fail with the same messages; probe output identical to main |
| 3.6.0 to 10.0.0 (16 releases: 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.3.1, 7.4.0, 7.6.0, 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0) | affected | history probe: kuromoji 8 of 8 bug checks; nori (from 7.4.0) 4 of 4 to 7.6.0, 2 of 4 from 7.7.0; all controls clean |
| 3.5.0 | no API | no kuromoji module |
| 7.3.1 (nori) | no API | no nori module (kuromoji affected) |
| main + the fix | not affected | paste-ready tests and the finder's JUnit pass; whole kuromoji (197 tests) and nori (89 tests) test tasks pass |
The finder, an independent reproducer, an intended-behaviour checker, the adjudication and a credibility audit all ran code and said real. A reviewer from another model family (OpenAI GPT-5.6) could only read code; it agreed with the root cause. This doc's gradle runs confirm the repro and the fix.
Checks and verdicts377 words
Every verdict except the cross-model reviews rests on executed runs on main ea57360912ed; the main refresh and this doc re-ran the repro on a9779ef3c193. The cross-model reviewer only read code: its refute-lens "not real, low" is procedural (its notes: "static evidence strongly favors a genuine defect").
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Buffer-edge sweep (900 violations over 15 kuromoji-based chains), the nori follow-up probe, a 6-test public-API JUnit (5 of 6 fail on main with assertions on and off, on 10.5.2 and 9.12.3) and the 5-line fix per tokenizer (6 of 6 pass; upstream TestJapaneseTokenizer and TestKoreanTokenizer pass). |
| cross-model review, refute lens (OpenAI GPT-5.6) | not real (low), procedural | Read-only; root cause agreed, inputs in contract, intended argument weak; a separate run re-ran the finder's JUnit: 5 of 6 fail. |
| independent reproducer | real | Own 12-test JUnit written from the claim: 8 of 12 fail on main (on and off), 10.5.2 and 9.12.3; all three kuromoji modes, both factories, KoreanTokenizer and KoreanAnalyzer split the pair; EXTENDED TermQuery 0 hits at 1023, 1 at 1022 and 1024; controls pass; the fix makes all 12 pass. Noted that in SEARCH mode a TermQuery for the emoji never matched the long run token anyway. |
| cross-model review, intended lens (OpenAI GPT-5.6) | real (medium) | Read-only; intended argument weak ("That case ultimately fails here"); root cause agreed. |
| intended-behaviour checker | real (high) | Built the strongest works-as-designed case and showed why it fails; own probes on main, 10.5.2 and 9.12.3; default JapaneseAnalyzer not affected; character-class table; 28 logged GitHub queries. |
| adjudication | confirmed (high) | Re-ran the independent reproducer's JUnit: 8 of 12 fail with assertions on and off, 0 with the fix. Judged the finder's impact estimate too high for default configurations. |
| credibility audit | keep | Re-ran the finder's JUnit (5 of 6 fail, on and off) and its own probe: EXTENDED TermQuery 0 hits only at 1023 (1 at 1021, 1022, 1024, 1025); the default KoreanAnalyzer emits two invalid terms. |
| this doc | repro and fix confirmed | Paste-ready tests fail 4 of 4 on main (gradle and runner, on and off), 10.5.2 and 9.12.3; with the fix the whole kuromoji and nori test tasks pass, on and off; lint and format pass. |
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 503 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,658 words
- Registry title
- JapaneseTokenizer and KoreanTokenizer cut an unknown word at the 1024-char cap (MAX_UNKNOWN_WORD_LENGTH) even in the middle of a surrogate pair, so a supplementary character (an emoji, a CJK Extension B ideograph) at char 1023 of a long same-class run is emitted as two unpaired surrogate halves and indexed as U+FFFD
- Area
- analysis / kuromoji and nori Viterbi unknown-word length cap (JapaneseTokenizer and the 'japanese' factory; KoreanTokenizer, KoreanAnalyzer and the 'korean' factory)
- Priority
- Low, rank 515: Kuromoji and nori split a surrogate pair at the 1024-char unknown-word cap, but it needs a run of more than 1023 chars of one unknown-word class (emoji spam, or Thai without spaces in a Japanese chain), and only the opt-in unigram modes turn it into a TermQuery miss, so it is far narrower than its Medium siblings LFV-233 and LFV-445.
- Impact class
- silent wrong results
- Impact note
- Applications that analyze text with kuromoji's JapaneseTokenizer outside the default JapaneseAnalyzer chain (the 'japanese' factory with default parameters, SEARCH, NORMAL or EXTENDED mode; the default JapaneseAnalyzer is not affected because its part-of-speech stop tags drop these unknown words) or with nori's KoreanTokenizer or default KoreanAnalyzer get, for every run of more than 1023 UTF-16 chars of one unknown-word class with no break (Thai or other DEFAULT-class text or an emoji run for kuromoji; an emoji run joined by U+FE0F for nori) whose char 1023 is a high surrogate, a term ending in a lone high surrogate and a next term starting with the lone low surrogate: the index stores U+FFFD halves, token offsets fall inside a surrogate pair, and an exact-text phrase also matches a text that differs only in that character; a TermQuery miss for the character itself appears only in the unigram modes (kuromoji EXTENDED, nori outputUnknownUnigrams=true), because in the default modes the character sits inside a 1024-char run token that a short query never matches, bug or not; nothing throws, CheckIndex is clean and the stored value is intact, with -ea and -da, in every release from 3.6.0 (kuromoji) or 7.4.0 (nori) to 10.6.0 (10.6.0 by reading).
- Summary
- You analyze a long run of text that kuromoji or nori treat as one unknown word, for example 1,200 chars of Thai without spaces through the 'japanese' tokenizer, or 400 x (U+1F441 U+FE0F) emoji through the default KoreanAnalyzer, and the run holds an emoji or a rare ideograph such as U+20000; you expect every term to consist of whole characters, as it does when that character sits one position earlier or later. Instead both tokenizers stop the unknown word at exactly 1024 UTF-16 chars without checking for a surrogate pair, so when the character's first half is char 1023, term 0 ends in a lone high surrogate and term 1 starts with the lone low surrogate. The index silently stores both halves as U+FFFD, and in the unigram modes a TermQuery for the character finds 0 documents instead of 1.
- Introduced
- 430365f7cce48e54b3d92cbf427d4b93a0b2bccb (trunk, svn r1296805; LUCENE-3767 / GitHub #4840, commit 'Kuromoji now produces both compound words and the segmentation of those words in search mode'; CHANGES credit Robert Muir, Mike McCandless via Christian Moen; pre-GitHub, no PR), which added MAX_UNKNOWN_WORD_LENGTH = 1024 ('For safety') and the unknown-word loop without a surrogate check to the kuromoji tokenizer, and its branch_3x backport e7ab11a9ddf4e89705aab7daf4186f58f17e4a44 (svn r1299213), which shipped in 3.6.0; for KoreanTokenizer, e851b89cbeb1f55edc0f2c1276e2ae812eca2643 (master, LUCENE-8231, nori added with the loop copied from kuromoji) and its branch_7x copy 8651fbaa8bc65fdfec397ad4e7ebe2b5acf78d7b, which shipped in 7.4.0 [read: git log -S MAX_UNKNOWN_WORD_LENGTH over the kuromoji, nori and analysis/morph paths finds only these commits and c89f8a7ea1e (LUCENE-10493 / #805, which moved the constant into the shared morph/Viterbi.java, first in 10.0.0); git blame -w -M -C -C on main traces the loop lines to 430365f7cce; no commit ever added a surrogate check at the cut; exec: 3.5.0 has no kuromoji and 7.3.1 has no nori, 3.6.0 and 7.4.0 are affected] (2012-03-04 (trunk commit); 2012-03-10 (branch_3x backport); 2018-04-13 (nori copy, master and branch_7x))
- First affected version
- 3.6.0 [exec] for JapaneseTokenizer (kuromoji's first release; 3.5.0 has no kuromoji [exec]); 7.4.0 [exec] for KoreanTokenizer and KoreanAnalyzer (nori's first release; 7.3.1 has no nori [exec])
- Affected versions
- JapaneseTokenizer: every release from 3.6.0 to 10.5.2, 10.6.0 (by reading, see the end) and main ea57360912ed (unreleased 11.0): a public-API main() probe is affected on 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.3.1, 7.4.0, 7.6.0, 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (8 of 8 bug checks, controls clean) [exec]. KoreanTokenizer and KoreanAnalyzer: every release from 7.4.0 to 10.5.2 and main: the probe is affected on 7.4.0, 7.6.0, 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main [exec]; 7.4.0-7.6.0 also split pairs in Thai runs, while from 7.7.0 (LUCENE-8548, Unicode-script grouping) only the emoji + U+FE0F run triggers. The finder's JUnit fails 5 of 6 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (main with -ea and -da) [exec, also re-run by the independent reproduction and the registration check]; the independent reproduction's JUnit fails 8 of 12 on main (-ea and -da), 10.5.2 and 9.12.3 [exec]. Releases not run are inferred from the code: every change to the loop between releases (LUCENE-3940 in 3.6.1/4.0.0, LUCENE-8548 in 7.7.0, LUCENE-8966 in 8.3.0, LUCENE-10493 in 10.0.0) is bracketed by executed versions, and none added a surrogate check [read]. Not affected: 3.5.0 (no kuromoji) and, for nori, 7.3.1 (no nori) [exec]. Not fixed upstream: the loops are unchanged on upstream main 49483e82b357 and branch_10x as of 2026-10-26 [read-only check]; at the 10.6.0 tag commit 4ae946e5a9b the files with the loops (ja/ViterbiNBest.java, ko/Viterbi.java) are identical to main ea57360912ed [read, 2026-10-30], so the released 10.6.0 is affected.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence βnot realβ is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, plus the finder's run and the intended-behaviour check's run, read-only; the prior-report record: 164 logged queries, namely the scripted prior-report search (83: 30 GitHub issue/PR queries on apache/lucene, 3 commit, 2 downstream, 32 mailing-list queries over dev@, java-user@, issues@ and commits@, 10 JIRA, 6 web; comments of the top 20 candidates read), 45 manual GitHub searches over apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr and atilika/kuromoji, 13 manual JIRA (LUCENE, SOLR), 21 manual list and 2 Japanese-language web queries; about 20 issues read in full; all 408 open apache/lucene PRs listed and the changed files of the 16 analysis-related ones read; plus the finder's 24 non-GitHub queries and the intended-behaviour check's 29 GitHub queries). Not fixed upstream: the loops are unchanged on main head 49483e82b357 and branch_10x, and the only open PR that touches ko/Viterbi.java (#14969) changes backtrace() only. Nearest items, none reporting this bug: #9976 / LUCENE-8933 (JapaneseTokenizer corrupt offsets with emoji in a user dictionary; different root cause, fixed 2019 by validating dictionary entries), #4840 / LUCENE-3767 (introduced the cap; one design comment states the general rule 'don't separate a lead surrogate from a trail surrogate' without connecting it to the cap), the separate MAX_BACKTRACE_GAP limit (#11097 / LUCENE-10059, #11124 / LUCENE-10086 open, #9722 / LUCENE-8676, #4970 / LUCENE-3897; AssertionErrors, no surrogate content), the changes to the same loops (#9594 / LUCENE-8548, #10009 / LUCENE-8966, #11529 / LUCENE-10493), and same-class surrogate fixes in other components (LUCENE-8325 / #9372 smartcn, #15899 / #15900 TruncateTokenFilter, LUCENE-2183 / #3259 CharTokenizer). Gaps: keyword search cannot rule out differently worded reports (for example a CI failure mail that names neither kuromoji/nori nor surrogates); private reports to security@lucene.apache.org are not visible; downstream coverage is GitHub search and the SOLR JIRA, not forums.
- Lucene commit tested
- main
a9779ef3c193(gradle in a scratch worktree, assertions on and off, and the standalone JUnit runner); the release jars 10.5.2 and 9.12.3 (this doc's tests and probe); 18 releases from 3.6.0 to 10.5.2 (the history probe, kuromoji, and nori from 7.4.0); the fix on main (gradle, the whole kuromoji and nori test tasks, assertions on and off). The hunt and its checks ran on mainea57360912ed; among the files cited here onlylucene/CHANGES.txtchanged between the two commits.
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/morph/Viterbi.java:41-42 ('// For safety:' MAX_UNKNOWN_WORD_LENGTH = 1024)lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/ViterbiNBest.java:131-150 (unknown-word loop: groups same-class UTF-16 chars up to the cap, no surrogate check) and :436-446 (EXTENDED-mode unigrams keep a low surrogate with its high surrogate)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:86-127 (unknown-word loop: groups by Unicode script up to the cap, no surrogate check) and :214-221 (outputUnknownUnigrams keeps pairs together)lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseTokenizer.java:414-427 (testSurrogates2, 'random test ensuring we don't ever split supplementaries', asserts UnicodeUtil.validUTF16String on 100-char inputs)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:52 and KoreanAnalyzer.java:33 (@lucene.experimental)lucene/CHANGES.txt:1182-1184 (GITHUB#15899/#15900, TruncateTokenFilter no longer produces half surrogates) and :7463 (LUCENE-8325, smartcn no longer splits surrogate pairs)
- The finding in 30 seconds
lucene/analysis/common/src/java/org/apache/lucene/analysis/morph/Viterbi.java:41-43 (at a9779ef3c193) (the cap, 'For safety')lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/ViterbiNBest.java:122 (at a9779ef3c193) (processUnknownWord), :137-149 (unknown-word loop), :158-169 (lattice add, return), :436-446 (EXTENDED unigrams keep pairs, :442)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:79 (at a9779ef3c193) (processUnknownWord), :92-126 (unknown-word loop, header :96; script update :120-125), :214-221 (outputUnknownUnigrams keeps pairs, :218), :453-458 (isSameScript)lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java:172-176; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1483-1485
- Who is affected
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:52, (at a9779ef3c193) KoreanAnalyzer.java:33 (@lucene.experimental)
- Why it looks unintended
lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseTokenizer.java:414-431; (at a9779ef3c193) TestExtendedMode.java:58-72lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/ViterbiNBest.java:442; (at a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:218 (at a9779ef3c193)lucene/CHANGES.txt:1198-1200, (at a9779ef3c193) :7479
- Suggested fix
lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:120-125 (at a9779ef3c193) (characterId update for Common or Inherited runs), :453-458 (isSameScript)
- TestUnknownWordCapSurrogatePair.java (JUnit test):
repro/LFV-459/src/org/apache/lucene/analysis/TestUnknownWordCapSurrogatePair.java(53 lines)
A step-by-step picture made in parallel with this doc from the executed probe on main a9779ef3c193; every token and count drawn should come from the probe logs and the source at that commit. The PNG is the final summary frame.
assets/LFV-459/bug.png(503 KB)
