All findings · Lucene finding · LFV-749 · cjk-morph-tokenizers-discard-punctuation-keeps-supplementary-symbols

JapaneseTokenizer and KoreanTokenizer keep emoji and other punctuation outside the BMP despite discardPunctuation

With the default discardPunctuation=true, kuromoji's JapaneseTokenizer and nori's KoreanTokenizer drop 、 and ★ but keep 𐄀 (U+10100) and emoji such as 😀 as tokens, because the punctuation test reads only the first UTF-16 half of a character outside the BMP. Affected since 3.6.0 (kuromoji) and 7.4.0 (nori).

Summary

Priority
Low, rank 516The default kuromoji and nori tokenizers keep supplementary-plane punctuation and emoji as tokens, costing only an extra term and a position shift that JapaneseAnalyzer hides.
Impact
silent wrong results
Since
3.6.0 (2012) for kuromoji, 7.4.0 (2018) for noriintroduced 2012-01-10 · over 14 years
Affected
3.6.0 to 10.5.2, 10.6.0 (by reading) and main (nori from 7.4.0); 16 releases reproduced
Status
documented
Found by
a bytecode scan for char-only Character calls, then a metamorphic probe (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

With discardPunctuation=true both tokenizers should drop every punctuation or symbol token. They do for characters inside the BMP, but not for the same kinds of characters above U+FFFF, such as emoji.

Diagram of the bug: JapaneseTokenizer with discardPunctuation=true on two inputs. '日本 ★ 日本': the symbol U+2605 is one UTF-16 char, Character.getType gives OTHER_SYMBOL, and it is dropped, output [日本, 日本]. '日本 😀 日本': the emoji U+1F600 is two UTF-16 chars, U+D83D U+DE00; isPunctuation looks only at U+D83D, whose type is SURROGATE, so the emoji is kept, output [日本, 😀, 日本]. With the fix the test reads the whole code point and the emoji is dropped too.
日本 ★ 日本 and 日本 😀 日本 side by side: ★ is one UTF-16 char, classified So and dropped; 😀 is two chars, the test sees only the first half (SURROGATE) and keeps it; made in parallel with this doc. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You tokenize 日本 、 日本 and 日本 𐄀 日本 with JapaneseTokenizer, or 한국 ★ 한국 and 한국 😀 한국 with KoreanTokenizer(); discardPunctuation=true is the default. 、 (U+3001) and 𐄀 (U+10100) are both Unicode punctuation (Po); ★ (U+2605) and 😀 (U+1F600) are both symbols (So).
  2. Both tokenizers drop a token whose character is punctuation or a symbol by Unicode category. You expect [日本, 日本] and [한국, 한국] for all four inputs.
  3. Instead only the BMP characters are dropped: you get [日本, 𐄀, 日本] and [한국, 😀, 한국]. Java stores 𐄀 and 😀 as two 16-bit halves, and the test checks only the first half, a surrogate, which is in no punctuation category.
  4. Measured on main, 10.5.2 and 9.12.3: kuromoji keeps 24 of 24 supplementary cases (three modes, with and without n-best), nori 8 of 8, and every BMP control is dropped. The default KoreanAnalyzer indexes 😀 as a term; the default JapaneseAnalyzer drops it later but leaves a position gap (+2 instead of +1). No error.

Who is affected

Users of JapaneseTokenizer or KoreanTokenizer with the default discardPunctuation=true, every mode: bare tokenizers, factory chains without a part-of-speech stop filter and the default KoreanAnalyzer keep emoji and other supplementary symbols as terms; the default JapaneseAnalyzer shows only a position gap. In the tested inputs, one extra token per character, no error.

Mechanism, terms and numbers608 words

Kuromoji's JapaneseTokenizer and nori's KoreanTokenizer drop punctuation and symbol tokens when discardPunctuation=true, the default of the two KoreanTokenizer constructors without the flag (KoreanTokenizer.java:92-94, :104-110), both tokenizer factories, KoreanAnalyzer and JapaneseAnalyzer (every JapaneseTokenizer constructor takes the flag explicitly). They decide with a private isPunctuation(char) that switches on Character.getType(char) over Unicode categories such as Po, So and Sm, and they pass it the token's first UTF-16 char. For a character above U+FFFF that char is a high surrogate, whose type is SURROGATE, so emoji such as U+1F600 and punctuation such as U+10100 (Po) stay in the output as tokens, while BMP characters of the same category (U+2605 So, U+3001 Po) are dropped. Every kuromoji mode, the n-best output and nori's default path (outputUnknownUnigrams=false) are affected; the default KoreanAnalyzer indexes the character as a term, the default JapaneseAnalyzer removes it later with its part-of-speech stop filter and leaves a position increment of 2. Present since kuromoji's first release (3.6.0, 2012) and nori's (7.4.0, 2018); testing the code point in the discard tests and stepping the unknown-word grouping by code point fixes it.

Terms

  • Supplementary character, surrogate pair: Java strings are UTF-16. Characters above U+FFFF, the BMP's end, take two chars: a high surrogate (U+D800-U+DBFF) and a low surrogate (U+DC00-U+DFFF). Most emoji (😀 is U+1F600), many historic scripts' punctuation (𐄀 U+10100) and math alphanumerics (𝛁 U+1D6C1) are supplementary. Character.getType(char) of either half is SURROGATE; Character.getType(int) of the code point gives its real category.
  • discardPunctuation: "true if punctuation tokens should be dropped from the output" (the javadoc of both tokenizers). Default true.
  • Unknown word: when the dictionary has no entry, both tokenizers group the following chars of the same character class (kuromoji) or script (nori) into one word, split where "is punctuation" changes.

The bug

  1. The predicate isPunctuation(char ch) (ja/ViterbiNBest.java:705-727; nori's copy ko/Viterbi.java:416-446) returns true for Zs, Zl, Zp, Cc, Cf, Pd, Ps, Pe, Pc, Po, Sm, Sc, Sk, So, Pi and Pf. SURROGATE is not in the list.
  2. The discard sites pass the token's first UTF-16 char: kuromoji's backtrace !isPunctuation(fragment[offset]) (:465), n-best registerNode !isPunctuation(fragment[left]) (:639) and EXTENDED-mode unigrams !isPunctuation(fragment[offset + i]) (:448); nori's shouldFilterToken, isPunctuation(token.getSurfaceForm()[token.getOffset()]) (:412-414).
  3. The EXTENDED loop just above (:440-445) deliberately steps back over a surrogate pair so the unigram is the whole emoji, then classifies only its high half.
  4. Grouping has the same blind spot (ja/ViterbiNBest.java:122-150, ko/Viterbi.java:79-127): every surrogate half is "not punctuation", so 😀𠮷 (emoji + Extension B letter) becomes one word, where ★吉 splits and drops ★.
  5. Scope: nori's default path, outputUnknownUnigrams=false, where the symbol's token goes through shouldFilterToken (ko/Viterbi.java:248-250). With outputUnknownUnigrams=true the unigram branch (:214-236) adds unknown-word unigrams without calling shouldFilterToken at all, so it keeps unknown-word punctuation from either plane; that is a separate bug with its own cause, not this one.

The numbers (main, assertions on and off; the same on 10.5.2 and 9.12.3)

categoryBMP charactertokenssupplementary charactertokens
PoU+3001 、[日本, 日本]U+10100 𐄀[日本, 𐄀, 日本]
PoU+FF01 ![日本, 日本]U+1E95E 𞥞[日本, 𞥞, 日本]
SoU+2605 ★[日本, 日本]U+1F600 😀[日本, 😀, 日本]
SmU+2207 ∇[日本, 日本]U+1D6C1 𝛁[日本, 𝛁, 日本]

JapaneseTokenizer SEARCH shown; NORMAL, EXTENDED and setNBestCost(2000) give the same (24 of 24 supplementary cases kept). KoreanTokenizer() and KoreanAnalyzer() (default outputUnknownUnigrams=false): [한국, 𐄀, 한국], [한국, 😀, 한국], ... (8 of 8 kept). JapaneseAnalyzer on 日本X日本 gives the terms [日本, 日本], with position increment +2 for the four supplementary characters and +1 for the BMP ones.

Exposure and likelihood327 words
  • Who is exposed. JapaneseTokenizer in NORMAL, SEARCH and EXTENDED mode and with n-best output, and japanese factory chains without the part-of-speech stop filter: the character becomes a token. KoreanTokenizer(), the korean factory with defaults and the default KoreanAnalyzer: the character becomes a token tagged SY, which is not a default stop tag, and is indexed: in an in-memory index, a TermQuery for 😀 finds the 한국 😀 한국 document (executed). (The factories by code: they build the same tokenizers with the same default.) KoreanTokenizer and KoreanAnalyzer are @lucene.experimental.
  • The default JapaneseAnalyzer hides it. The kept token is tagged 記号-一般 (symbol, general), a default stop tag, so the stop filter removes it; it keeps the position, so 日本😀日本 gives increments +1, +2 where 日本★日本 gives +1, +1.
  • What changes for the user. Emoji and other supplementary symbols become searchable terms while ★, ♥ or ∇ in the same text do not; positions after such a character shift, so an exact phrase across it stops matching: the phrase "日本 日本" with JapaneseAnalyzer and "한국 한국" with KoreanAnalyzer match the ★ and 、 texts but not the 😀 or 𐄀 texts, and all of them match with the fix (executed; proximity queries were not run). Supplementary punctuation proper (Po, used in historic and minority scripts) is kept, which the javadoc rules out word for word.
  • How likely. Emoji are common in user-written Japanese and Korean text, and each one is kept every time; supplementary Po characters are rare in CJK text. The visible harm per character is small, and nobody reported it since 2012.
  • Scale (the repeated-input probe). 1,200,000 chars of 😀 give 400,000 tokens through either tokenizer, one per emoji, where ★ gives 0. No hang, no cost amplification beyond ordinary text, every term well-formed UTF-16. This covers the tested default paths, not user dictionaries, non-default nori modes or mixed supplementary text: next to a supplementary letter the emoji stays inside that word (😀𠮷).

Minimal repro

JapaneseTokenizer and KoreanTokenizer with discardPunctuation=true keep the supplementary-plane punctuation character U+10100 (General_Category Po) as a token (one test per tokenizer).

TestDiscardSupplementaryPunctuation.java (JUnit test)40 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;

import java.io.StringReader;
import org.apache.lucene.analysis.ja.JapaneseTokenizer;
import org.apache.lucene.analysis.ko.KoreanTokenizer;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;

public class TestDiscardSupplementaryPunctuation extends BaseTokenStreamTestCase {

  // U+10100 is General_Category Po: expected [X, X], actual [X, U+10100, X]
  private static final String PUNCT = new String(Character.toChars(0x10100));

  public void testJapaneseTokenizer() throws Exception {
    Tokenizer t = new JapaneseTokenizer(null, true, JapaneseTokenizer.Mode.SEARCH);
    t.setReader(new StringReader("日本 " + PUNCT + " 日本"));
    assertTokenStreamContents(t, new String[] {"日本", "日本"});
  }

  public void testKoreanTokenizer() throws Exception {
    Tokenizer t = new KoreanTokenizer();
    t.setReader(new StringReader("한국 " + PUNCT + " 한국"));
    assertTokenStreamContents(t, new String[] {"한국", "한국"});
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-749/, 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.TestDiscardSupplementaryPunctuation
# the module(s) analysis-kuromoji, analysis-nori also need their third-party runtime jars
Expected vs actual
Expected [日本, 日本] / [한국, 한국] for 日本 U+10100 日本 / 한국 U+10100 한국; actual [日本, U+10100, 日本] / [한국, U+10100, 한국].

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

More files, environment, full output60 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 2 test(s) run, 2 failed
    => org.junit.ComparisonFailure: term 1 expected:<[한국]> but was:<[𐄀]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:262)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:598)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:622)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:689)
       org.apache.lucene.analysis.TestDiscardSupplementaryPunctuation.testKoreanTokenizer(TestDiscardSupplementaryPunctuation.java:38)
  JUnit Vintage:TestDiscardSupplementaryPunctuation:testJapaneseTokenizer
    MethodSource [className = 'org.apache.lucene.analysis.TestDiscardSupplementaryPunctuation', methodName = 'testJapaneseTokenizer', methodParameterTypes = '']
    => org.junit.ComparisonFailure: term 1 expected:<[日本]> but was:<[𐄀]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:262)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)

Why it looks unintended

  • The javadoc: "true if punctuation tokens should be dropped from the output", with no BMP limit; U+10100 and U+1E95E are Unicode punctuation (Po).
  • The EXTENDED-mode loop deliberately steps over a surrogate pair, then tests only its first half.
  • ★ (So) and ∇ (Sm) are dropped, the same categories above U+FFFF kept; nothing exempts them.
The full argument224 words
  • The contract. JapaneseTokenizer.java:109, KoreanTokenizer.java:119 and KoreanTokenizerFactory.java:61: "true if punctuation tokens should be dropped from the output". A grep of both modules for supplementary, surrogate and BMP finds no restriction. For U+10100 and U+1E95E (Po) the sentence applies word for word; for emoji (So) and math symbols (Sm) it rests on the implementation's own category list, under which ★ and ∇ are dropped.
  • Maintainers state the behaviour upstream: "Punctuations are removed by default" (#10020) and "The tokenizer removes punctuations so it's expected that some characters are filtered ..." (#9739).
  • The same code handles surrogate pairs on purpose. The EXTENDED unigram loop steps over the pair (it descends from 685035575b3, 2012, "LUCENE-3305: don't split surrogates in extended mode (unless the input is already screwed, GIGO)"), and kuromoji's testSurrogates2 is a "random test ensuring we don't ever split supplementaries". Classifying only the high half right after stepping over the pair reads as an oversight.
  • No exemption anywhere. No javadoc, comment, test, CHANGES or MIGRATE entry or upstream discussion exempts supplementary characters; the modules' only supplementary test characters are Extension B letters (Lo), which the predicate does not touch.
  • Precedents. Lucene fixed char-wise handling of supplementary characters as bugs elsewhere: LUCENE-8325 (smartcn) and #15899 / #15900 (TruncateTokenFilter), both under Bug Fixes in CHANGES. Earlier, CharTokenizer moved to code points as a 3.1 new feature (LUCENE-2183).
The devil's-advocate check161 words

The strongest case (the intended-behaviour checker and the cross-model reviewer): both tokenizers inherit MeCab-style character tables (a 65,536-entry CharacterDefinition; MeCab's char.def has no supplementary ranges), so supplementary characters are simply not modelled; emoji may be worth keeping (StandardTokenizer and ICUTokenizer emit <EMOJI> tokens: LUCENE-8527 and LUCENE-8125); KoreanTokenizer is @lucene.experimental; JapaneseTokenizerFactory calls the flag an "Expert option" in a code comment; in #10430 maintainers leaned towards a token filter instead of this flag.

Why it fails: the discard test is Lucene's own Unicode-category predicate, not a char.def feature, and Java gives the category per code point; the module handles surrogate pairs deliberately elsewhere; neither "experimental" nor "Expert option" documents an input restriction, and the flag defaults to true wherever it has a default (both analyzers, both factories, the two KoreanTokenizer constructors without the flag). Whether emoji should be kept decides the fix direction, not whether the same category treated differently by plane is a defect. Verdict: not intended, a defect.

Suggested fix

Recommended Classify by code point in the discard tests and the unknown-word grouping, with a cap guard

Test the code point, not the first char, at the discard sites (kuromoji's three, nori's shouldFilterToken), step the unknown-word grouping loops by code point, and add a guard so a surrogate pair never crosses the 1,024-char cap. The repro passes, and so do the whole kuromoji (198 tests) and nori (89 tests) test tasks, assertions on and off.

// discard tests (kuromoji :448, :465, :639; nori shouldFilterToken): the code point, not the first char
!isPunctuation(Character.codePointAt(fragment, offset, offset + length))
private static boolean isPunctuation(int ch) { switch (Character.getType(ch)) { ... } }

// unknown-word loops (kuromoji processUnknownWord, nori processUnknownWord): step by code point
final int len = codePointLenAt(posAhead);
if (unknownWordLength + len > MAX_UNKNOWN_WORD_LENGTH) {
  break; // the cap would split a surrogate pair: end the word before it
}
if (... && isPunctuation(codePointAt(posAhead)) == isPunct) {
  unknownWordLength += len;
  posAhead += len;
}
All options and trade-offs4 options · 913 words

The recommended option is the hunt's full code-point fix plus a 4-line length guard in each unknown-word loop, as one git diff at a9779ef3c193 (2 files, +75/-25), formatted with gradlew tidy and validated in a scratch worktree: the whole kuromoji and nori test tasks with the paste-ready tests, assertions on and off, plus the modules' lint, forbidden-API and format checks. Option B was run and regresses; C and D were not run.

Classify by code point in the discard tests and the unknown-word grouping, with a cap guard (validated by the checks) (recommended)

日本 𐄀 日本 -> [日本, 日本], 한국 😀 한국 -> [한국, 한국], JapaneseAnalyzer position increments +1, and 日本 😀𠮷 日本 -> [日本, 𠮷, 日本], like 日本 ★吉 日本 -> [日本, 吉, 日本].

// discard tests (kuromoji :448, :465, :639; nori shouldFilterToken): the code point, not the first char
!isPunctuation(Character.codePointAt(fragment, offset, offset + length))
private static boolean isPunctuation(int ch) { switch (Character.getType(ch)) { ... } }

// unknown-word loops (kuromoji processUnknownWord, nori processUnknownWord): step by code point
final int len = codePointLenAt(posAhead);
if (unknownWordLength + len > MAX_UNKNOWN_WORD_LENGTH) {
  break; // the cap would split a surrogate pair: end the word before it
}
if (... && isPunctuation(codePointAt(posAhead)) == isPunct) {
  unknownWordLength += len;
  posAhead += len;
}

Trade-offs

  • Behaviour change by design. Supplementary punctuation and symbols are now dropped, as their BMP analogues are, so emoji stop being searchable through these tokenizers with the default flag; existing indexes need a reindex to be consistent. Keeping emoji would then need discardPunctuation=false, which keeps all punctuation. Worth a CHANGES or MIGRATE note.
  • Unknown-word scan by code point. The loop steps by code point and tests punctuation (nori: also script and digit) on the code point, so it splits an unknown word where a supplementary symbol meets a supplementary letter (😀𠮷 -> [𠮷]), and nori now uses the real script and digit property of a supplementary character instead of UNKNOWN / not-a-digit for its high half. That matches the BMP. It does not make all of the grouping code-point based: the character-class and group lookups in the 65,536-entry CharacterDefinition still read one UTF-16 char, as on stock code. Beyond the module tests and the probes, mixed supplementary text was not surveyed.
  • The guard keeps the 1,024-char cap strict when the loop steps by two chars, so a pair is never split at the cap (without it the word could reach 1,025 chars). It covers the coupled cap-split bug in these loops too; one guard is enough if both are fixed. That bug's EXTENDED-mode test expects the emoji kept as a token with discardPunctuation=true and fails with this fix; it would need discardPunctuation=false or a supplementary letter.
  • The patch keeps the hunt's helper style (codePointLenAt, codePointAt); a PR could fold them into one codePointAt plus Character.charCount. The extra buffer reads per char were not measured.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: the two paste-ready test classes fail on stock main, 5 of 5, assertions on and off; with the fix the whole :lucene:analysis:kuromoji:test task passes, 31 classes, 198 tests, 0 failures (7 skipped), and the whole :lucene:analysis:nori:test task, 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, incl. testSurrogates2), TestExtendedMode (5), TestSearchMode (2), TestJapaneseAnalyzer (12), TestKoreanTokenizer (17) and TestKoreanAnalyzer (7) pass.
  • ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest and checkGoogleJavaFormat of 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, the finder's 4-test JUnit and the independent reproducer's 8-test JUnit pass.
  • Coupled cap-split bug: with this fix its paste-ready tests give 3 of 4 passing; the failing one expects the emoji kept ("emoji at char 1022 is not a token"). Its finder's JUnit: 4 of 6 pass, including an Extension B letter at char 1023; the 2 failures say the emoji is no longer a term, and none is an unpaired surrogate.

Change only the discard tests (not recommended: regresses) (validated by the checks)

Classify Character.codePointAt(...) at the three kuromoji sites and in shouldFilterToken; leave the grouping loops alone. It fixes the single-character cases, but 😀𠮷 is still one unknown word, which it now drops whole, letter included.

Trade-offs

Smallest diff, but loses a letter that stock code keeps; do not propose it alone.

What the checks validated

Run with the paste-ready tests (assertions on): testLetterAfterSupplementarySymbolIsKept fails, NORMAL and SEARCH [日本, 日本], nori [한국, 한국] (EXTENDED passes); the other 3 tests pass. The hunt's cross-model review and intended-behaviour check probes found the same.

One whole-token predicate shared with #10430 (not run)

#10430 (open) asks for the discard decision on all characters of a token, not the first. A predicate that walks the token by code point would fix both bugs at the shared kuromoji sites.

Trade-offs

Also changes #10430's behaviour (for example (株)), which maintainers discussed there without agreement; keep it a separate decision. The never-merged PR for #10430 tests UTF-16 chars and would not fix this bug.

Keep supplementary symbols on purpose (not recommended, not run)

Document that discardPunctuation only covers the BMP, or stop discarding So and Sm in the BMP too.

Trade-offs

The first contradicts the javadoc for Po characters such as U+10100; the second changes long-standing output for every BMP symbol.

Downstream note

Search servers that expose these tokenizers with the default discard setting inherit the behaviour: an emoji or other supplementary symbol in a document or query becomes a searchable term, where a BMP symbol of the same category is dropped.

How it was found

Semi-formal. A scan of the main jars' bytecode for calls of char-only Character methods, which cannot see characters above U+FFFF, flagged both isPunctuation(char) sites. A metamorphic probe then checked that word X word tokenizes the same whether X, of a given Unicode category, is in the BMP or not. It does not.

The method in full140 words
  • Scan. The hunt unit on supplementary-plane handling scanned the class files of the main jars for calls of the char overloads of Character (such as Character.getType(C)), which classify one UTF-16 char and so cannot see a supplementary character. It listed ja.ViterbiNBest.isPunctuation and ko.Viterbi.isPunctuation, among others.
  • Metamorphic probe. For four pairs of characters with the same Unicode general category, one in the BMP and one outside it (Po U+3001/U+10100, Po U+FF01/U+1E95E, So U+2605/U+1F600, Sm U+2207/U+1D6C1), the token list of <word> X <word> must not depend on the plane of X. On main every supplementary X was kept and every BMP X dropped.
  • Reduction. The finder reduced it to a public-API JUnit (4 tests: 3 fail, the BMP control passes). No formal tool (model checker, SMT solver or proof) produced the signal; the first signal was a static bytecode scan.

Prior reports, history and checks

Prior reports

No prior report found: 269 logged queries over GitHub issues, PRs and commits, JIRA, the mailing lists, forums and the web, plus the changed files of all 408 open PRs (2026-10-27), and a GitHub re-check on 2026-10-28. Nearest: #10430, same kuromoji line, a different bug. Not fixed upstream.

Searches3 searches

No report of this defect was found. The closest items have other root causes; #10430 shares the kuromoji discard lines and should be cross-referenced. The two files have not changed upstream since 2025-02 (kuromoji) and 2024-09 (nori).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, downstream search servers, atilika/kuromoji, Lucene.NET, all of GitHub), mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, forums, web2026-10-27269 recorded queries: three runs of the scripted prior-report search (147, comments of the top 20 hits read), 73 GitHub searches incl. Japanese and Korean terms, 11 JIRA, 12 list, 9 forum and 7 web queries (259), plus 10 upstream-state and coverage checks; 18 issues read in full28 items, none the same bug: #10430 / LUCENE-9390 (first-character test drops dictionary nouns; open) and its unmerged PR lucene-solr#1577, #9976 / LUCENE-8933 (emoji in user-dictionary offsets), #10020, #9739 (support the expected behaviour), #4378 / LUCENE-3305
Open PRs2026-10-27changed-file lists of all 408 open apache/lucene PRs6 touch kuromoji, nori or morph paths; none changes a punctuation line
GitHub issues and PRs (apache/lucene), re-check2026-10-2812 queries, 9 issue and 3 PR searches over 11 distinct strings (discardPunctuation emoji, isPunctuation surrogate, kuromoji emoji, nori emoji, discardPunctuation surrogate, JapaneseTokenizer supplementary, KoreanTokenizer supplementary, ...); last upstream commits touching the two filesonly known items; the two files last changed upstream in 2025-02 and 2024-09

History

Present since each tokenizer's first release: kuromoji's discard option (LUCENE-3305, 2012; 3.6.0) and nori's copy of the predicate (LUCENE-8231, 2018; 7.4.0). Reproduced on 16 releases from 3.6.0 to 10.5.2 and on main; never fixed.

Versions tested10 rows

Introduced (kuromoji) by 6d830c7da673 (2012-01-10, "LUCENE-3305: ... option omit punctuation"), which added isPunctuation(char ch) on the token's first char; it reached trunk with the module import cd372bdc8383 and branch_3x as 8c9485fb6642, first released in 3.6.0. The streaming rewrite (LUCENE-3767, 430365f7cce4, 2012-03) kept the check in the backtrace and EXTENDED unigrams, and LUCENE-6837 (df9c6a835f65, 6.0.0) copied it into the n-best path. Introduced (nori) by e851b89cbeb1 (2018-04-13, LUCENE-8231; branch_7x 8651fbaa8bc6, 7.4.0), which always dropped punctuation; LUCENE-8784 (#9828) added the documented option around the same predicate: main-branch commit a556925eb8aa (first release tag 9.0.0) and its branch_8x backport 990f62aca47, which shipped in 8.2.0. LUCENE-10493 (c89f8a7ea1e7, #805, 10.0.0) moved the code unchanged. Not a regression.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedpaste-ready tests fail 5 of 5 (gradle and runner, assertions on and off); history probes affected for all three paths
10.5.2, 9.12.3 affectedpaste-ready tests fail 5 of 5 with identical text; history probes affected
kuromoji: 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 affectedhistory probe: every supplementary character kept in NORMAL, SEARCH and EXTENDED, every BMP control dropped
kuromoji n-best: 6.0.0, 10.0.0 affectedhistory probe with setNBestCost(2000): affected
nori: 7.4.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 10.0.0 affectedhistory probe with KoreanTokenizer(): affected
every release tag 3.6.0-10.5.2 in the history clone (124 tags) affectedread, not run: the same char-wise isPunctuation(char ch), no code-point variant (nori from 7.4.0); the clone has no tags 8.8.2-8.11.4 (8.11.4 was run, 8.8.2-8.11.3 neither read nor run)
3.5.0 no APIno kuromoji module
7.3.1 (nori) no APIno nori module
5.5.0 (n-best) no APIno setNBestCost
main + the fix not affectedpaste-ready tests, the finder's and the independent reproducer's JUnit pass; whole kuromoji (198 tests) and nori (89 tests) test tasks pass

Independent checks

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 three times. This doc's gradle runs confirm the repro and the fix.

Checks and verdicts406 words

Every verdict except the cross-model reviews rests on executed runs on main a9779ef3c193. The cross-model reviewer only read code: its refute-lens "not real" and root-cause-lens "uncertain" are procedural (both agreed with the root cause, found the inputs in contract and rated the intended-behaviour case weak).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateBytecode scan, metamorphic probe and a 4-test public-API JUnit: 3 of 4 fail on main (assertions on and off), 10.5.2 and 9.12.3; the BMP control passes; 64 upstream tests pass with stock code and both fixes.
cross-model review, refute lens (OpenAI GPT-5.6) not real (medium), proceduralRead-only; it votes not real when it cannot run Java. Root cause agreed, inputs in contract, intended argument weak; a separate run re-ran the finder's JUnit: 3 of 4 fail.
cross-model review, intended lens (OpenAI GPT-5.6) real (medium)Read-only; built the MeCab-table, emoji-useful, experimental and Expert-option case and judged it weak.
independent reproducer realOwn 8-test JUnit: 3 of 8 fail on main (on and off), 10.5.2 and 9.12.3; 48 Japanese, 48 n-best and 16 Korean supplementary cases kept, every BMP case dropped; a predicate-only change passes 8 of 8; corrected the reach (the default JapaneseAnalyzer hides the token).
cross-model review, root-cause lens (OpenAI GPT-5.6) and separate runs uncertain, proceduralRead-only; root cause agreed. Separate runs: the finder's JUnit fails 3 of 4 on main (on and off), 82 upstream tests pass with stock code and both fixes, and a probe found that the predicate-only fix drops 😀𠮷 whole.
intended-behaviour checker real (medium-high)Built the strongest works-as-designed case and showed why it fails; measured the JapaneseAnalyzer position gap and the KoreanAnalyzer token; confirmed the predicate-only regression.
adjudication confirmed (high)Re-ran the independent reproducer's JUnit with a new seed: 8 run, 3 fail with assertions on and off, 0 with the predicate change. Asked for the full fix with a length guard and the corrected reach.
credibility audit keepRe-ran the finder's JUnit (3 of 4 fail, assertions off) and its own analyzer probe: JapaneseAnalyzer +2 for U+10100 and U+1F600, KoreanAnalyzer keeps both.
this doc repro and fix confirmedPaste-ready tests fail 5 of 5 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, forbidden-API and format checks pass; the predicate-only fix fails the guard test.

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

Finding record

LFV-749, snapshot of 2026-11-06

Registry title
JapaneseTokenizer and KoreanTokenizer with discardPunctuation=true (the default) keep punctuation and symbols outside the BMP (U+10100, U+1E95E, emoji such as U+1F600) as tokens, because isPunctuation classifies a single UTF-16 unit (the high surrogate), while BMP characters of the same general category are dropped (released since 3.6.0 and 7.4.0)
Area
analysis / kuromoji and nori Viterbi tokenizers, discardPunctuation (ja.ViterbiNBest.isPunctuation and its three discard sites, ko.Viterbi.shouldFilterToken)
Priority
Low, rank 516: The default kuromoji and nori tokenizers keep supplementary-plane punctuation and emoji as tokens, costing only an extra term and a position shift that JapaneseAnalyzer hides.
Impact class
silent wrong results
Impact note
Hits every user of JapaneseTokenizer or KoreanTokenizer with the default discardPunctuation=true (constructors, factories and the default KoreanAnalyzer; every Mode and the n-best output; any scale; -ea and -da) whose text holds a supplementary-plane punctuation or symbol character, in practice mostly an emoji: the character comes out as an extra token (Korean POS SY, which the default KoreanPartOfSpeechStopFilter keeps), so it becomes a searchable term and shifts later positions by one, while BMP characters of the same Unicode category (U+3001, U+2605, U+2207) are dropped; with the default JapaneseAnalyzer the token is removed later by its part-of-speech stop filter (POS 記号-一般), and only a position increment of 2 instead of 1 remains; nothing throws and no other token changes.
Summary
You tokenize '日本 😀 日本' or '한국 𐄀 한국' (U+1F600, U+10100) with JapaneseTokenizer or KoreanTokenizer and discardPunctuation=true, the default, and expect [日本, 日本] and [한국, 한국], because the javadoc says punctuation tokens are dropped and both tokenizers drop every BMP character of the same Unicode general category (Po U+3001, So U+2605, Sm U+2207). Instead the supplementary character is kept as a token ([日本, 😀, 日本], [한국, 𐄀, 한국]): isPunctuation(char) calls Character.getType on the high surrogate alone, whose type is SURROGATE, so the discard test at the token start never sees the real category. No error is raised; the default KoreanAnalyzer indexes the extra token, and the default JapaneseAnalyzer removes it only later, through its part-of-speech stop filter, which leaves a position gap.
Introduced
6d830c7da67373339589c60f0222041c1dccb2b8 (LUCENE-3305 development branch, 2012-01-10, by rmuir: 'option omit punctuation', which added the char-wise isPunctuation(char ch) test on the token's first UTF-16 unit), landed on trunk as cd372bdc8383 (2012-01-12) and on branch_3x as 8c9485fb6642 (2012-01-14); kept in the released streaming design by LUCENE-3767 (trunk 430365f7cce4, branch_3x e7ab11a9ddf4, 2012-03), which wrote the backtrace and EXTENDED-unigram checks on fragment[offset] / fragment[offset + i], and copied into the n-best path by LUCENE-6837 (df9c6a835f65, 2015-12-03). For KoreanTokenizer: e851b89cbeb1 (LUCENE-8231, 2018-04-13, by jimczi; branch_7x 8651fbaa8bc6), which copied the predicate into shouldFilterToken, wrapped by the documented option in a556925eb8aa (LUCENE-8784, 2019-05-24). c89f8a7ea1e7 (LUCENE-10493, PR #805, 2022-04-25) moved the code unchanged into ja/ViterbiNBest.java and ko/Viterbi.java [read: git log -S 'isPunctuation(char ch)' --all, git log --follow, git blame -w -M on c89f8a7ea1e^, git tag --contains in the history clone]. No pull request exists for either introduction (JIRA patch and SVN era). Not a regression: the predicate has classified one UTF-16 unit since its first version. (2012-01-10 (kuromoji, development branch; trunk 2012-01-12, branch_3x 2012-01-14); 2018-04-13 (nori))
First affected version
3.6.0 for JapaneseTokenizer (tagged 2012-04-11) [exec]; 6.0.0 for its n-best path [exec]; 7.4.0 for KoreanTokenizer (tagged 2018-06-22) [exec], whose documented discardPunctuation option exists from 8.2.0 (7.4.0-8.1.1 always dropped punctuation, with the same plane-dependent result). 3.5.0 and 7.3.1 have no kuromoji or nori artifact [exec: setup_error], so no release with either tokenizer is unaffected
Affected versions
every release with kuromoji (3.6.0 through 10.6.0) or nori (7.4.0 through 10.6.0), 10.6.0 by reading, and main a9779ef3c193 (unreleased 11.0); never fixed. [exec] test-framework-free main() probes ('X c X' for four BMP/supplementary pairs of the same general category, U+3001/U+10100 Po, U+FF01/U+1E95E Po, U+2605/U+1F600 So, U+2207/U+1D6C1 Sm; the BMP control was dropped in every run) report the bug for JapaneseTokenizer (NORMAL, SEARCH, EXTENDED) on 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); for its n-best path (setNBestCost(2000)) on 6.0.0, 9.12.3, 10.0.0, 10.5.2 and main; and for KoreanTokenizer() on 7.4.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); on main with the candidate's full fix shadowed every probe passes. The finder's JUnit fails 3 of 4 on main (-ea and -da), 10.5.2 and 9.12.3 (it does not compile on 9.0.0 because the test framework moved packages); the independent reproduction's JUnit fails 3 of 8 on main (-ea and -da), 10.5.2 and 9.12.3. [read] every other release tag from 3.6.0 to 10.5.2 in the history clone has the same char-wise isPunctuation(char ch) and no code-point variant (8.8.2-8.11.3 are not tagged there; 8.11.4 was executed); upstream main f7bcc0abd816 (2026-10-27), branch_10x and branch_10_6 are unchanged. All runs with JDK 25 on aarch64; the defect does not depend on the JDK's Unicode version, since a surrogate unit has type SURROGATE on every JDK (U+10100 is Po since Unicode 4.0).
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 “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; also reached by fuzzing/PBT
Upstream and prior reports
none found (2026-10-27, read-only, plus a re-read of the finder's run; the prior-report record, verdict new: 269 recorded queries, namely 147 from three scripted prior-report searches (the finder's, A: identifiers, B: emoji and symptom terms; GitHub issue and PR, commit, downstream, dev@, java-user@, issues@ and commits@ list and JIRA-archive queries, with the comments of the top 20 hits read), 73 GitHub GraphQL searches (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, atilika/kuromoji, Lucene.NET and all of GitHub, including Japanese and Korean terms), 11 JIRA REST queries (LUCENE and SOLR), 12 direct lists.apache.org queries, 9 forum searches, 7 web searches (3 more throttled), the changed-file lists of all 408 open apache/lucene PRs (6 touch kuromoji, nori or morph paths, none changes a punctuation line) and 18 issues read in full; 28 items recorded, none the same bug). Closest items, not reports: #10430 (LUCENE-9390, open; same kuromoji predicate line ViterbiNBest.java:465 but the opposite symptom: the first-character-only test drops dictionary nouns such as (株)) and its never-merged PR apache/lucene-solr#1577 (its isAllCharPunctuation still tests UTF-16 units, so it would not fix this); #9976 (LUCENE-8933, closed; emoji and surrogates in user-dictionary offsets, another cause); #10020 (LUCENE-8977) and #9739 (LUCENE-8693), where maintainers wrote 'Punctuations are removed by default' and 'The tokenizer removes punctuations so it's expected'; #4378 (LUCENE-3305; commit 685035575b3 stopped extended-mode unigrams from splitting surrogate pairs but kept classifying the high surrogate). Not fixed upstream (main f7bcc0abd816, branch_10x, branch_10_6)
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); 16 releases from 3.6.0 to 10.5.2 (the history probe: kuromoji 13, nori 8); the fix on main (gradle, the whole kuromoji and nori test tasks, assertions on and off, lint, forbidden-API and format checks). The hunt's cross-model reviews read main ea57360912ed; no kuromoji, nori or morph file differs between the two commits.

Lucene and upstream sources

Finding record
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/ViterbiNBest.java:705-726 (at a9779ef3c193) (isPunctuation(char ch): switch on Character.getType(ch) over Zs, Zl, Zp, Cc, Cf, Pd, Ps, Pe, Pc, Po, Sm, Sc, Sk, So, Pi, Pf; SURROGATE is not listed), :440-448 (EXTENDED unigram loop steps over a surrogate pair, then tests isPunctuation(fragment[offset + i]) on the high surrogate), :465 (backtrace: !isPunctuation(fragment[offset])), :639 (n-best registerNode: !isPunctuation(fragment[left])), :122-150 (processUnknownWord groups per UTF-16 unit)
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:412-414 (at a9779ef3c193) (shouldFilterToken: discardPunctuation && isPunctuation(token.getSurfaceForm()[token.getOffset()])), :416-445 (isPunctuation(char) / isPunctuation(char, int) on Character.getType(ch)), :79-127 (processUnknownWord per UTF-16 unit)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizer.java:109 (at a9779ef3c193) and lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:119 ('@param discardPunctuation true if punctuation tokens should be dropped from the output.'); KoreanTokenizer.java:52 (@lucene.experimental), :92-93 (KoreanTokenizer() passes discardPunctuation=true), :104-110 (the four-argument constructor used by KoreanAnalyzer passes true)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizerFactory.java:90 (at a9779ef3c193) ('Expert option' comment), :127 (default true); lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizerFactory.java:61 (same javadoc), :97 (default true); lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseAnalyzer.java:110 (new JapaneseTokenizer(userDict, true, true, mode)); lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanAnalyzer.java:74
  • lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/stoptags.txt:370 (at a9779ef3c193) (記号-一般 is a default stop tag); lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanPartOfSpeechStopFilter.java:36-68 (DEFAULT_STOP_TAGS has no SY)
The finding in 30 seconds
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/ViterbiNBest.java:705-727 (at a9779ef3c193) (isPunctuation(char)), :440-448 (EXTENDED unigrams), :465 (backtrace), :639 (n-best registerNode), :122-150 (processUnknownWord)
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/Viterbi.java:412-414 (at a9779ef3c193) (shouldFilterToken), :416-446 (isPunctuation), :79-127 (processUnknownWord), :214-236 (outputUnknownUnigrams branch, no shouldFilterToken), :248-250 (shouldFilterToken call)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizer.java:109; (at a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:119 (discardPunctuation javadoc)
Who is affected
  • lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/stoptags.txt:370; (at a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanPartOfSpeechStopFilter.java:36-68 (DEFAULT_STOP_TAGS has no SY)
  • lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:52 (at a9779ef3c193) (@lucene.experimental), :92-94, :104-110 (default true); lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanAnalyzer.java:33 (@lucene.experimental), :74; lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseAnalyzer.java:110; lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizerFactory.java:127, :163-166; lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizerFactory.java:97, :131-133 (the factories pass the default to the same constructors)
Why it looks unintended
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizer.java:109; (at a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizer.java:119; lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanTokenizerFactory.java:61; lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizerFactory.java:90 ('Expert option')
  • lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseTokenizer.java:410-431; (at a9779ef3c193) TestExtendedMode.java:58-72
  • Lucene main a9779ef3c193 grep -rniE 'supplementar|surrogate|BMP' lucene/analysis/kuromoji/src/java lucene/analysis/nori/src/java (code comments only, no restriction)
  • lucene/CHANGES.txt:7479 (at a9779ef3c193) (LUCENE-8325, Bug Fixes), :1198 (GITHUB#15899, GITHUB#15900, Bug Fixes), :17942 (LUCENE-2183, 3.1 New features), :6895-6896 (LUCENE-8527, StandardTokenizer <EMOJI> token type), :7365 (LUCENE-8125, ICUTokenizer emoji)

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

Repro files

About the diagrams

A step-by-step picture made in parallel with this doc from the executed tests on main a9779ef3c193; every token drawn should come from the test output and the source at that commit. The PNG is the final summary frame.

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.