Thai writes many vowels and all tone marks above or below a consonant; together they form one character cluster. A user-dictionary word that ends inside such a cluster is accepted, and the marks after it are thrown away.
{แม่น} cuts แม่น้ำ inside its last cluster, how the restart and the old skip loop then throw away the tone mark and SARA AM, and the same cut in other words. Select the still to open it at full size. Generated with AI, checked against the code.- Give
ThaiTokenizerthe user dictionary{แม่น}("accurate") and tokenizeแม่น้ำ("river", 6 characters). You expect every character in a token,[แม่น้ำ[0,6)], as without the dictionary. - You get
[แม่น[0,4)]: characters 4 and 5, the tone mark U+0E49 and the vowel SARA AM U+0E33, are in no token. "River" and "accurate" are now one term, so the queryแม่น้ำfinds 2 documents instead of 1. - Why: the dictionary loop takes
แม่นwithout looking at the next character, a mark that belongs toน. It restarts word splitting at the mark, and an old loop that skips pieces not starting with a letter or digit throws the mark away. - Short everyday entries hit everyday words:
{ผล}turnsผลิตภัณฑ์("product") into[ผล, ต, ภัณฑ์],{ที}turnsที่นี่("here") into[ที, นี่]. A word that ends between two clusters works as intended:{แม่}gives[แม่, น้ำ]. - No error, and the index is well formed. Skipping a dictionary match whose next character continues the cluster (13 lines) fixes every tested case, and the 1,914 analysis-common tests run with 0 failures (18 skipped).
Only applications that give ThaiTokenizer a user dictionary, through a constructor or the factory's dictionary parameter; the stock ThaiAnalyzer is not affected. Short everyday entries (ที, ผล, สม, แม่น) hit everyday words. Marks vanish and distinct words collapse, at index and query time. First released in 10.6.0 (2026-10-29).
Mechanism, terms and numbers823 words
The new ThaiTokenizer user dictionary (GITHUB#16722, on main, branch_10x and branch_10_6; first released in 10.6.0) accepts a dictionary word as a match even when it ends inside a Thai character cluster, right before a combining vowel or tone mark that belongs to the previous consonant. The tokenizer then restarts its word BreakIterator at that point, the rest of the text starts with the mark, and the old skip loop for segments that do not start with a letter or digit throws the mark away. With {แม่น} ("accurate"), แม่น้ำ ("river") becomes the single token แม่น[0,4), so "river" and "accurate" become the same term; with {ผล}, ผลิตภัณฑ์ ("product") becomes [ผล, ต, ภัณฑ์]. Queries are analyzed the same way, so a search for แม่น้ำ also finds a document that has แม่น but not แม่น้ำ (ยิงแม่นมาก, "shoots accurately"). Nothing throws, Lucene's analysis consistency checks accept the stream and the index is well formed. A 13-line guard in the dictionary loop fixes every tested case, and a character-BreakIterator variant does the same; with either, the analysis-common suite has 0 failures (18 tests skipped).
- Character cluster: a consonant plus the combining vowels and tone marks written above or below it, such as
น้ำ(น + tone mark U+0E49 + SARA AM U+0E33). The marks are Unicode combining marks (category Mn); SARA AM is a letter (Lo) that Unicode's grapheme rules also attach to the cluster before it (Grapheme_Cluster_Break=SpacingMark). - User dictionary: the
CharArraySetpassed toThaiTokenizer(CharArraySet ...)or loaded by thethaitokenizer factory'sdictionaryparameter (GITHUB#16722). A dictionary word "takes precedence over default segmentation boundaries" (class javadoc).
- The dictionary loop (
ThaiTokenizer.java:186-207) tries prefix lengths from longest to shortest and takes the firstuserDictionary.contains(...)hit (:191-192). It never looks at the character after the match. - If the match does not end where the word
BreakIteratorwould end the word, and that point is not a word boundary, it setsreanchor(:200-201). The token[start, start + l)is emitted (:209-212):แม่นforแม่น้ำ. - The re-anchor (
:214-218) hands the rest of the sentence to theBreakIteratoras new text. Forแม่น้ำthe rest is้ำ, which starts with the tone mark. - On the next call the skip loop (
:173-180, from 2014) sees a first segment that starts with U+0E49, not a letter or digit, and skips it whole. At the end of the text that segment is้ำ, so SARA AM is lost too; mid-text, SARA AM can start the next token (ำเจ้าพระยา), and consonants after a mark survive as one-letter tokens (ต,ด,ง).
Once the re-anchor happens, the rest always starts with the mark, whatever the JDK; how the JDK splits that rest decides only what else is lost. In the tested texts the word BreakIterator put no boundary inside a cluster, so without a dictionary they lose nothing. A separate, older effect, not this bug: without a dictionary, text with no space or sentence break for 1,024 characters is cut where the tokenizer's buffer fills, and a mark right at that cut is dropped too (the same on 10.5.2; neither fix changes it).
| dictionary | text (meaning) | no dictionary | with the dictionary | lost |
|---|---|---|---|---|
{แม่น} (accurate) | แม่น้ำ (river) | [แม่น้ำ[0,6)] | [แม่น[0,4)], the same term as แม่น | U+0E49, U+0E33 |
{แม่น} | แม่น้ำเจ้าพระยา (Chao Phraya River) | [แม่น้ำ, เจ้าพระยา] | [แม่น[0,4), ำเจ้าพระยา[5,15)] | U+0E49 |
{ที} | ที่นี่ (here) | [ที่, นี่] | [ที[0,2), นี่[3,6)] | U+0E48 |
{ผล} (result) | ผลิตภัณฑ์ (product) | [ผลิตภัณฑ์] | [ผล[0,2), ต[3,4), ภัณฑ์[4,9)] | U+0E34 |
{สม} | ผมซื้อสมุดใหม่ (I buy a new notebook) | [ผม, ซื้อ, สมุด, ใหม่] | [ผม, ซื้อ, สม, ด, ใหม่] | U+0E38 |
{กล} | กล้ำกลืน | [กล้ำกลืน] | [กล[0,2), ำกลืน[3,8)] | U+0E49 |
{เก} | เขาเก่งมาก (they are very good) | [เขา, เก่ง, มาก] | [เขา, เก, ง, มาก] | U+0E48 |
control {แม่} | แม่น้ำ | [แม่น้ำ] | [แม่[0,3), น้ำ[3,6)] | none |
control {ผล} | ผลไม้ (fruit) | [ผลไม้] | [ผล[0,2), ไม้[2,5)] | none |
control {ผล, ผลิต} | ผลิต (produce) | [ผลิต] | [ผลิต[0,4)] | none |
At each checked cut point (แม่น้ำ, แม่น้ำเจ้าพระยา, แม่น้ำไป at 4; ที่นี่, ผลิต, ผลิตภัณฑ์, คนึงหา, กล้ำกลืน at 2) neither the Thai word BreakIterator nor the character BreakIterator has a boundary; at the cut points of the two between-cluster controls the character BreakIterator does. In a hand-built copy of ThaiAnalyzer's filter chain with the dictionary passed to the tokenizer, the symptoms survive (แม่น้ำ gives [แม่น], แม่น้ำเจ้าพระยา gives [แม่น, ำเจ้าพระยา]), and a clipped stopword escapes the stop filter: ที่ is a default stopword, so ที่นี่ gives [ที, นี่] instead of [นี่]. (เขาเก่งมาก only fragments, [เก, ง] instead of [เก่ง]; เก่ง is not a stopword.)
End to end: index แม่น้ำ (river) and ยิงแม่นมาก (shoots accurately) with {แม่น}; QueryBuilder.createBooleanQuery("f", "แม่น้ำ") is f:แม่น and counts 2 documents instead of 1. The reverse also happens: แม่น้ำไป is indexed as [แม่น, ำไป], so a query for ไป (go) misses it.
Exposure and likelihood373 words
- Who is hit. Users of the
ThaiTokenizer(CharArraySet ...)constructors or of thethaitokenizer factory withdictionary(CustomAnalyzer, factory configs). The stockThaiAnalyzerbuildsnew ThaiTokenizer()without a dictionary. The feature is in 10.6.0 (released 2026-10-29) and on main, branch_10x and branch_10_6; none of the downstream engines searched exposes it yet. - What triggers it. A dictionary entry that is the beginning of a longer word in the text and is directly followed by a combining vowel or tone mark (or SARA AM), with no longer entry covering that word. Thai above and below vowels and all tone marks are combining marks, so short entries hit common words:
ที/ที่,ผล/ผลิต,สม/สมุด,แม่น/แม่น้ำ,เก/เก่ง. Lucene's own 10.6 migration notes listผลamong the "common content words" removed from the default stopwords. Long domain terms (the feature's examples พารากอน, คนขับรถ) should trigger it less often, and a dictionary that also lists the longer words avoids the cut for each word it lists ({ผล, ผลิต}keepsผลิต); both points are reasoning, not measured on a real dictionary. - What goes wrong. Marks disappear from the index; one-letter tokens (
ต,ด,ง) and tokens that start with SARA AM (ำเจ้าพระยา) appear; different words collapse into one term (แม่น้ำandแม่น). Queries go through the same analyzer: a query can over-match ("river" finds "accurate") or miss a word glued after a trigger word; a clipped stopword escapes the stop filter. - Silent and deterministic. No exception or warning; Lucene's analysis consistency checks accept the stream, IndexWriter accepts the offsets,
CheckIndexwith slow checks is clean and stored text is intact (security probe, 13 documents). It happens per token in the analyzer, so it does not depend on the index; seen with-eaand-da. - Timing. First released in 10.6.0 (2026-10-29); before that only users of snapshots or of the branches could have built an index with it.
- Not more than that. In the security probe: no crash or hang (18 adversarial runs, 6 texts x 3 dictionary settings, one of them no dictionary, each finish in under 0.4 s), nothing from other documents.
The test shows that ThaiTokenizer with the user dictionary {แม่น} tokenizes แม่น้ำ ("river") as just [แม่น]: the match ends inside the character cluster, so the tone mark U+0E49 and SARA AM U+0E33 are dropped.
/*
* 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.th;
import java.io.IOException;
import java.io.StringReader;
import java.util.List;
import org.apache.lucene.analysis.CharArraySet;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
public class TestThaiTokenizerUserDictionaryClusterSplit extends BaseTokenStreamTestCase {
public void testDictionaryWordEndingBeforeToneMark() throws IOException {
assumeTrue("needs the Thai BreakIterator", ThaiTokenizer.DBBI_AVAILABLE);
StringBuilder tokens = new StringBuilder();
try (Tokenizer t = new ThaiTokenizer(new CharArraySet(List.of("แม่น"), false))) {
t.setReader(new StringReader("แม่น้ำ"));
CharTermAttribute term = t.addAttribute(CharTermAttribute.class);
t.reset();
while (t.incrementToken()) {
tokens.append(term);
}
t.end();
}
// expected: the tokens hold all of แม่น้ำ; on main the tone mark and SARA AM are dropped
assertEquals("แม่น้ำ", tokens.toString());
}
}
# in a copy of this finding's repro folder (repro/LFV-229/, 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: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.th.TestThaiTokenizerUserDictionaryClusterSplit
- Expected vs actual
- Expected: the tokens together hold all of แม่น้ำ. Actual:
expected:<แม่น[้ำ]> but was:<แม่น[]>(-ea and -da).
More files, environment, full output32 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> org.junit.ComparisonFailure: expected:<แม่น[้ำ]> but was:<แม่น[]>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.analysis.th.TestThaiTokenizerUserDictionaryClusterSplit.testDictionaryWordEndingBeforeToneMark(TestThaiTokenizerUserDictionaryClusterSplit.java:42)- The javadoc lets a dictionary word take "precedence over default segmentation boundaries"; nothing allows dropping text, and the feature exists to stop "single characters" fragments (issue #16721).
- Neither
BreakIterator, word or character, has a boundary at the cut; no upstream dictionary test runs it. - Splits between clusters still work with the fix; Lucene fixed mark-dropping as a bug before (LUCENE-3358).
The full argument271 words
- The contract.
ThaiTokenizer.java:31-33: "Supports an optional user dictionary ({@link CharArraySet}) for custom or domain-specific words. When a user dictionary word matches, it takes precedence over default segmentation boundaries." It gives the dictionary power over where words split, not over whether characters are indexed. The contract is implicit. - The goal of the feature. Issue #16721: domain terms "are frequently fragmented into unnatural sub-words or single characters, making exact search matching and phrase queries difficult or imprecise". This bug makes such fragments (
ต,ด,ง). - No boundary there. At each checked cut point the Thai word
BreakIteratorand the characterBreakIteratorboth reportisBoundary = false; without a dictionary none of the tested texts loses a character (the older buffer-cut loss is a separate path). - Untested branch. All four upstream dictionary cases (
TestThaiTokenizer,TestThaiTokenizerFactory) end on a word boundary or at the end of the text, so the re-anchor branch is never run by them.TestRandomChainscan pass a random dictionary ofa-zstrings to the constructor, but it checks stream consistency only, which these streams pass. CHANGES and the 10.6 migration notes describe the dictionary as an additive feature, without caveats. - The intended part survives a fix.
{แม่}onแม่น้ำgives[แม่, น้ำ]and{ผล}onผลไม้gives[ผล, ไม้], with and without either fix. - Precedent. CHANGES: "LUCENE-3358, LUCENE-3361: StandardTokenizer and UAX29URLEmailTokenizer wrongly discarded combining marks attached to Han or Hiragana characters". On LUCENE-3358 a committer wrote: "we shouldn't be splitting a base character from its combining mark here". The Kuromoji user dictionary rejects illegal segmentation at load time (LUCENE-8933).
The devil's-advocate check312 words
The case for "intended" (raised by the cross-model reviews, which rated it moderate, and examined in detail by the devil's-advocate verifier; none accepted it): CharArraySet.contains is an exact code-unit match, so แม่น does "match" at the start of แม่น้ำ, and a match takes precedence. The code re-anchors on purpose when a match ends where the BreakIterator has no boundary. The mark is then dropped by the 2014 loop that skips non-letter segments, and ThaiNormalizer also removes dangling marks at a token start. ThaiTokenizer promises no grapheme safety, and a short common word that prefixes other words misuses a dictionary meant for "custom or domain-specific words".
Why it fails:
- No reading of "precedence" removes text. Even if the split
ผล|ิตwere allowed, the vowel would have to land in some token; instead it vanishes, and "river" and "accurate" analyze to the same terms. - Nothing in the code, the PR or the tests says the re-anchor is meant for ends inside a cluster. The ends it is seen to handle correctly are between clusters (the controls), and those keep working with a fix.
- The skip loop was written for punctuation and spaces; in the tested texts it sees a word's own mark only because the re-anchor restarts the iterator inside a cluster.
ThaiNormalizer's rule is for marks already detached in the input, and in a copy of theThaiAnalyzerchain it does not repair these tokens (ำเจ้าพระยาsurvives). - Nothing restricts dictionary contents: the factory loads any word list, and Lucene itself lists
ผลamong common content words. Through the factory,{ผล}breaks the long wordผลิตภัณฑ์("product"), which the stock tokenizer keeps whole.
Still open for upstream: whether such a match should be rejected (both fixes) or extended to the end of the cluster is a design choice. Avoid สมิธ and คนึงหา as headline examples: the stock tokenizer already fragments them.
Recommended A: skip a dictionary match whose next character continues the cluster
In the dictionary loop, skip a match whose next character is a combining mark or SARA AM (+13 lines); a shorter entry or the BreakIterator word stands instead. The 1,914 analysis-common tests run with 0 failures (18 skipped). A variant that asks the character BreakIterator for a cluster boundary (+20 lines) gives the same tokens and also passes.
/** true if {@code ch} cannot start a character cluster (combining mark, or Thai SARA AM). */
private static boolean continuesCluster(char ch) {
int type = Character.getType(ch);
return type == Character.NON_SPACING_MARK
|| type == Character.COMBINING_SPACING_MARK
|| type == Character.ENCLOSING_MARK
|| ch == '\u0E33';
}
// in incrementWord, at the top of the dictionary loop:
// a dictionary word must not end inside a character cluster (before a combining mark)
if (l < remaining && continuesCluster(buffer[sentenceStart + start + l])) {
continue;
}All options and trade-offs4 options · 708 words
Fix A and fix B were each validated with gradle in a scratch worktree at ea57360912ed (OpenJDK 25.0.3) and through the repro runner; option C was not run. File the related LFV-225 (the skip loop drops a number after a leading ., # or currency sign) separately: its fix alone does not fix this bug, and the two fixes apply cleanly together.
A: skip a dictionary match whose next character continues the cluster (validated by the checks) (recommended)
Before testing a candidate length l, look at the character right after it; if it is a combining mark (NON_SPACING_MARK, COMBINING_SPACING_MARK, ENCLOSING_MARK) or SARA AM, try the next shorter length. With no dictionary hit left, the word that the BreakIterator found stands. Every symptom row becomes the no-dictionary or control result, and the intended splits ({แม่} on แม่น้ำ, {ผล} on ผลไม้) are unchanged. This is the finder's patch.
/** true if {@code ch} cannot start a character cluster (combining mark, or Thai SARA AM). */
private static boolean continuesCluster(char ch) {
int type = Character.getType(ch);
return type == Character.NON_SPACING_MARK
|| type == Character.COMBINING_SPACING_MARK
|| type == Character.ENCLOSING_MARK
|| ch == '\u0E33';
}
// in incrementWord, at the top of the dictionary loop:
// a dictionary word must not end inside a character cluster (before a combining mark)
if (l < remaining && continuesCluster(buffer[sentenceStart + start + l])) {
continue;
}Trade-offs
- A hand-written rule: three mark categories plus SARA AM, tested per UTF-16
char, so a mark outside the BMP would not be seen (all Thai marks are in the BMP). - Cost: one
Character.getTypeper candidate length; not benchmarked. - Output change only for dictionary configurations that hit the bug; the feature first shipped in 10.6.0, so only indexes built with 10.6.0 or a snapshot can need a reindex.
- Does not change the older no-dictionary loss at buffer cuts.
What the checks validated
- The repro: 4 of 5 fail on stock, 5 of 5 pass with the fix (gradle; the repro runner with
-eaand-da). org.apache.lucene.analysis.th.*: 10 suites, 61 tests (56 upstream + 5 new) pass with-ea(seed 1A2B3C4D) and-Ptests.asserts=false(seed 77AA77AA).- All of
:lucene:analysis:common:test(-ea): 1,914 tests including the 5 new ones, 18 skipped, 0 failed;TestRandomChains: 2 tests pass;checkGoogleJavaFormatpasses. - The finder's random dictionary harness (about 156,000 checks): 25 violations on stock, none with the fix; the other verifiers' JUnits pass with it.
- With the LFV-225 fix on top: all 65 Thai tests (56 upstream + both repros) pass.
B: ask the character BreakIterator whether the match ends on a cluster boundary (validated by the checks)
Keep a BreakIterator.getCharacterInstance(th) clone with its own CharArrayIterator, set to the sentence in setNextSentence and again on re-anchor, only when a dictionary is configured. After a dictionary hit, if (l < remaining && !clusterBreaker.isBoundary(start + l)) continue;. Gives the same tokens as A on all 14 probe cases.
if (userDictionary.contains(buffer, sentenceStart + start, l)) {
if (l < remaining && !clusterBreaker.isBoundary(start + l)) {
continue; // the word would end inside a character cluster (before a combining mark)
}
...Trade-offs
- Follows the Unicode grapheme rules as the JDK implements them instead of a list; the cross-model reviewers called this the more general form. Behaviour beyond Thai text was not tested.
- A second
BreakIteratorper tokenizer with a dictionary, and asetTextper sentence and per re-anchor; not benchmarked.
What the checks validated
- The repro 5 of 5 with
-eaand-da; 61 Thai tests with-eaand-da; all 1,914 analysis-common tests with-ea(18 skipped, 0 failed);TestRandomChains;checkGoogleJavaFormat. - The random dictionary harness: 156,356 checks, no violations.
Keep the dictionary match but move its end past the marks.
Trade-offs
The token is then no longer the dictionary word ({ผล} on ผลิต would give ผลิ, ต), so it keeps the text but can still produce fragments. Not recommended.
{ผล, ผลิต} keeps ผลิต whole, because the longest match wins.
Trade-offs
Covers only the words listed; for anyone using the feature on 10.6.0 or a snapshot.
An AI tool ran a seeded property-based check of ThaiTokenizer with random user dictionaries (4,000 iterations, about 156,000 checks) and found characters that ended up in no token. It picked realistic examples by hand and wrote a public-API JUnit. Independent verifiers and a reviewer from another model family (OpenAI GPT-5.6) reproduced it with their own tests.
The method in full234 words
Method: property-based testing with random user dictionaries (the hunt's non-formal tier; no formal tool produced the first signal).
- The finder's harness built random texts and random dictionaries over a small Thai vocabulary and checked two oracles: every letter or digit of the text lies inside some token, and a token that starts where a dictionary word matches is the longest such match that ends on a character-cluster boundary. On stock main: 25 violations in about 156,000 checks (19 lost characters, 6 precedence; the precedence oracle uses the same cluster rule as the fix, so only the 19 are independent of it). With the fix: none. The harness's dictionaries include truncated non-words; the realistic examples were then chosen by hand.
- An independent reviewer from another model family (OpenAI GPT-5.6, refute lens) ran its own test (
{ผล}onผลิต) on main with-eaand-daover seven seeds, with two controls, and the fix. A second GPT-5.6 review (works-as-designed lens) could only read the code; a separate run re-ran the finder's JUnit for it. - Claude verifiers wrote their own tests: the independent reproduction (
ที่นี่,เก่ง,คนึงหา,กล้ำกลืน,แม่น้ำ; 5 of 7 fail) and the intended-behaviour check, a devil's advocate, whose intent probe recorded 11 facts on main, including the factory case and that noBreakIteratorhas a boundary at the cut. An adjudication re-ran both with fresh seeds.
No report found (searched 2026-10-26). The nearest items are the change that added the feature (#16722 / #16721) and the empty-entry crash in the same loop (#16736), which has a different cause and whose fix leaves this bug in place.
Searches5 searches
Novelty new: no issue, PR, commit, JIRA entry, mailing-list thread, downstream issue or web page reports a ThaiTokenizer dictionary match that ends inside a character cluster, or the dropped marks and mark-initial tokens it produces. Downstream engines do not expose the feature yet, so no downstream user could have reported it. Gaps: keyword search only; the web index of very recent GitHub content looked sparse; one GitHub retry hit the rate limit; private security reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | 22 issue/PR and 4 commit queries with the prior-report tool, 9 extra issue searches; all 379 issues and PRs updated since 2026-09-20 by title; 17 items read in full | #16722 / #16721 (the feature), #16736 (empty entry, different cause), #16727 / #10153 (buffer end); none reports this |
| Open PRs and branch history | 2026-10-26 | changed files of all 413 open apache/lucene PRs; ThaiTokenizer.java history on main, branch_10x, branch_10_6 | no PR touches the Thai package; nothing after b304eeae024 and its backport 361da20c595 |
| Downstream repositories | 2026-10-26 | Elasticsearch, OpenSearch and Solr issues | the user dictionary is not exposed yet (#160853, #160854; OpenSearch #23151 is a plan) |
| Mailing lists and JIRA | 2026-10-26 | 20 queries over dev@, java-user@, issues@, commits@; two months read; 4 JIRA queries | only notification mirrors of the Thai PRs; JIRA predates the feature (LUCENE-3358 is a precedent) |
| Web | 2026-10-26 | 6 + 2 queries | nothing |
Introduced with the feature itself by GITHUB#16722 (6b44b4c2a3af, merged 2026-09-28; backport 2ba75780cd6d). On main, branch_10x and branch_10_6; first released in 10.6.0 (2026-10-29). 10.5.2 and older have no user-dictionary API.
Versions tested6 rows
GITHUB#16722 (issue #16721, "Support user dictionary in ThaiTokenizer and ThaiTokenizerFactory", by kamthorn, merged by rmuir per the PR record) added the dictionary loop and the re-anchor: main 6b44b4c2a3af (2026-09-28T00:58:58Z) and the backport 2ba75780cd6d on branch_10x and branch_10_6 the same day (git blame; git log -S 'reanchor' returns only this commit). Later commits do not change which non-empty matches are accepted: dda6bd36b81 (#16727, buffer size) and b304eeae024 (#16736: the shortest length tried is now at least 1, Math.max(1, minLen), plus an assert).
The skip loop that finally drops the mark dates from LUCENE-4984 (2014); without a dictionary it loses nothing on the tested texts (it can drop a mark where a 1,024-character buffer cut falls inside a cluster, a separate, older effect). CHANGES.txt lists GITHUB#16722 under 10.6.0 New Features; 10.6.0, released on 2026-10-29, is the first release with the bug. branch_10x and branch_10_6 have the same ThaiTokenizer.java blob as main (read, not run).
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | this page's repro fails 4 of 5 with -ea and -da; the finder's JUnit 3 of 4 |
main a9779ef3c193 | later main | the finder's JUnit fails 3 of 4, identical messages |
6b44b4c2a3af (GITHUB#16722) | introducing | its ThaiTokenizer.java compiled over the main jars fails 3 of 4, like main |
| branch_10x, branch_10_6 (10.6.0) | same source, not run | ThaiTokenizer.java byte-identical to main (read-only API check) |
| 10.5.2, 9.12.3 and earlier | not affected | no ThaiTokenizer(CharArraySet): the repro does not compile; the no-dictionary control passes |
| main with fix A or fix B | fix | the repro passes 5 of 5; the 1,914 analysis-common tests: 0 failed, 18 skipped |
Reproduced on main with and without assertions; confirmed by independent verifiers, cross-model reviews and an adjudication, each from its own runs or reading; both fixes validated with gradle on the whole analysis-common module.
Checks and verdicts308 words
Each check worked from the code or its own runs. This page's runs added a new repro, a probe of 14 cases, a second fix and the LFV-225 composition check.
| Check | Verdict | What it found |
|---|---|---|
| Finder's JUnit and random harness | candidate | 3 of 4 fail on main with -ea and -da; 25 harness violations on stock, none with the fix; 10.5.2 lacks the API. |
| Cross-model refute review (OpenAI GPT-5.6, executed) | real, high confidence | Own test ({ผล} on ผลิต) fails on main in seven runs over seven seeds, -ea and -da, both controls pass; the fix passes, upstream TestThaiTokenizer 8 of 8 and TestThaiAnalyzer 12 of 12. |
| Independent reproducer | real | Own JUnit: 5 of 7 fail on main, -ea and -da; 7 of 7 with the fix; compile error on 10.5.2 and 9.12.3. |
| Works-as-designed review (OpenAI GPT-5.6, reading only) | real, medium confidence | Root cause agreed, inputs in contract, intended argument moderate; a separate run re-ran the finder's JUnit on main -ea (3 of 4 fail). |
| Intended-behaviour devil's advocate | real, high confidence | No intended, misuse or reachability argument survives; intent probe 11 of 11 facts on main; with the fix exactly the 3 symptom facts change. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's test (seeds 5EED0101 -ea, 5EED0102 -da: 7 run, 5 failed each), the finder's (4 run, 3 failed) and the independent reproduction's with the fix (7 of 7). |
| Security screen | none | Wrong terms only: adversarial texts terminate, CheckIndex clean, stored text intact, nothing from other documents. |
| This page's runs | fails 4 of 5 on main; fixes A and B pass | Gradle and the repro runner, -ea and -da; full analysis-common with each fix (1,914 tests, 0 failed); LFV-225's fix alone still fails 4 of 5; both fixes together pass 65 Thai tests. |
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 428 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,344 words
- Registry title
- ThaiTokenizer with a user dictionary silently drops the vowel or tone marks that follow a dictionary word ending inside a character cluster (with {แม่น}, 'แม่น้ำ' (river) is tokenized as just [แม่น] (accurate)), because the match is accepted at a non-boundary and the remainder, which now starts with a mark, is skipped
- Area
- analysis / Thai (ThaiTokenizer#incrementWord user-dictionary loop and re-anchor, GITHUB#16722; ThaiTokenizerFactory 'dictionary')
- Priority
- Medium, rank 96: Silent wrong tokens and false matches on ordinary Thai text, but only with the opt-in ThaiTokenizer user dictionary new in 10.6.0 and short entries.
- Impact class
- silent wrong results
- Impact note
- Only applications that opt into the new ThaiTokenizer user dictionary (the ThaiTokenizer(CharArraySet ...) constructors or the ThaiTokenizerFactory 'dictionary' parameter, GITHUB#16722, first released in 10.6.0 on 2026-10-29; the stock ThaiAnalyzer passes no dictionary and is not affected) are hit, whenever a dictionary entry is a prefix of the text at a token start and is directly followed by a combining vowel or tone mark (or SARA AM) with no longer entry covering it, which is easy with short everyday entries (ผล, สม, คน, ที, แม่น) and rarer with long domain terms: the marks vanish from the token stream, single-letter or mark-initial garbage tokens appear, and distinct words can collapse to one term on both the index and the query side, deterministically at any size, with -ea and -da, with no exception and a well-formed index.
- Summary
- You configure ThaiTokenizer with a user dictionary that contains an ordinary short word such as แม่น ('accurate') or ผล ('result') and expect it to change only where words split. Instead, when the dictionary word is followed in the text by a combining vowel or tone mark, as in แม่น้ำ ('river') or ผลิต ('produce'), the tokenizer cuts the character cluster and throws the mark away: แม่น้ำ becomes the single token แม่น, and ผลิต becomes [ผล, ต]. Because queries are analyzed the same way, a search for แม่น้ำ also matches documents that only contain แม่น, with no error.
- Introduced
- 6b44b4c2a3afadee94c035d680d8dc9856ee93d7 on main (GITHUB#16722 for issue #16721, 'Support user dictionary in ThaiTokenizer and ThaiTokenizerFactory', author kamthorn, merged by rmuir) and its backport 2ba75780cd6d on branch_10x and branch_10_6; the commit added the longest-prefix dictionary loop and the BreakIterator re-anchor [read: git blame of ThaiTokenizer.java:186-189, 191-207 and 213-218 on main; line 190 (assert minDictWordLen > 0) is from b304eeae024, #16736; exec: the ThaiTokenizer.java of 6b44b4c2a3af compiled as a shadow class over the main jars fails 3 of 4 like main]. The non-letter skip loop that finally discards the mark-initial remainder (ThaiTokenizer.java:173-180, LUCENE-4984, 2014) is harmless without a dictionary (no-dictionary control passes on 9.12.3, 10.5.2 and main [exec]) (2026-09-28 (main, 00:58:58Z; branch_10x / branch_10_6 backport 2026-09-28T15:37:57Z))
- First affected version
- 10.6.0 (released 2026-10-29; CHANGES.txt lists GITHUB#16722 under 10.6.0 New Features)
- Affected versions
- main ea57360912ed [exec: -ea and -da, finder's, the independent reproduction's, the refute reviewer's and the intended-behaviour check's JUnits]; branch_10_6 (10.6.0, released 2026-10-29) and branch_10x [read: ThaiTokenizer.java byte-identical to main by read-only gh api, not executed]; not affected: 10.5.2, 9.12.3 and every earlier release, which do not have the ThaiTokenizer(CharArraySet) API (the repro is a compile error [exec on 10.5.2 and 9.12.3]); not fixed upstream: no commit on ThaiTokenizer.java after b304eeae024 (2026-09-29) on main, branch_10x or branch_10_6, and no open PR touches the Thai package or SegmentingTokenizerBase [read-only check 2026-10-26]. Executed only on JDK 25.0.3 (aarch64); the cut itself does not depend on the JDK's Thai segmentation, because the re-anchored text always starts with the mark
- 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
- non-formal
- Upstream and prior reports
- none found (searched 2026-10-26, read-only: a scripted prior-report search with 60 queries (22 GitHub issue/PR, 4 commit, 4 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 6 web; 280 distinct hits), 9 extra GitHub issue searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr, all 379 apache/lucene issues and PRs updated since 2026-09-20 filtered by title, the changed files of all 413 open apache/lucene PRs, the ThaiTokenizer.java commit history on main, branch_10x and branch_10_6, git log -S over the history, two months of the four mailing lists, 2 extra web queries; 17 GitHub items read in full with comments and reviews; the finder's run (45 queries) and the intended-behaviour check's run (37 queries) found nothing either). Not fixed upstream: no commit on ThaiTokenizer.java after b304eeae024 (2026-09-29) on any branch and no open PR touches the Thai package or SegmentingTokenizerBase. Nearest items, none a report of this bug: #16722 / #16721 (introduced the feature; the thread covers the backport and the 10.6 output changes, nothing on clusters or dropped marks), #16736 / b304eeae024 (same loop, different defect: an empty dictionary entry made BreakIterator.following(-1) throw; its fix leaves this bug in place), #16727 / #10153 (words split at the buffer end, different mechanism), LUCENE-3358 / #4431 (2011: StandardTokenizer separating a combining mark from its base was fixed as a bug; precedent, not a report). Downstream, Elasticsearch has not exposed the user dictionary (#160853, #160854) and the OpenSearch work (#23151) is still a plan, so no downstream user could have reported it yet. Gaps: keyword search only, the web engine's index of very recent GitHub content looked sparse, one GitHub search retry hit the rate limit, and private reports to security@lucene.apache.org are not visible.
- Lucene commit tested
- main
ea57360912ed(commit 2026-10-24; tested 2026-10-26) on OpenJDK 25.0.3, Linux aarch64: this page's JUnit repro through the repro runner with -ea and -da and through gradle in a scratch worktree, stock, with fix A and with fix B (the worktree was removed after the runs); the analysis-common test suite and TestRandomChains with each fix; the 10.5.2 jars (compile error: no user-dictionary API); the introducing commit'sThaiTokenizer.javaover the main jars (history step); a later main,a9779ef3c193, with the finder's JUnit (still reproduces).
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiTokenizer.java:28-33 (class javadoc: optional user dictionary 'for custom or domain-specific words'; 'When a user dictionary word matches, it takes precedence over default segmentation boundaries'), :173-180 (skip loop 'skipping over non-tokens', first code point only), :186-207 (dictionary loop: longest CharArraySet.contains prefix, no cluster check; :201 sets reanchor when the end is not a word boundary), :214-218 (re-anchor of the word BreakIterator at the match end)lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiTokenizerFactory.java:34 (example dictionary="custom_words.txt"), :53, :68, :74 (the 'dictionary' word set is passed to the same constructor)lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiAnalyzer.java:102 (stock analyzer: new ThaiTokenizer() without a dictionary)lucene/CHANGES.txt:469 (GITHUB#16722 under 10.6.0 New Features) and lucene/MIGRATE.md 10.6 Thai section (user dictionary listed as additive; ผล called a common content word at :771-772 per the intended-behaviour check and the refute reviewer)
- The finding in 30 seconds
lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiTokenizer.java:166-221 (incrementWord: skip loop 173-180, dictionary loop 186-207, contains 191-192, reanchor flag 200-201, term 209-212, re-anchor 214-218)lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiTokenizer.java:28-33 (class javadoc)lucene/analysis/common/src/resources/org/apache/lucene/analysis/th/stopwords.txt (ที่, เขา, มาก listed; เก่ง, เก, ง not)
- Who is affected
lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiTokenizer.java:84-151 (dictionary constructors)lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiTokenizerFactory.java:53, 68, 74 (dictionary parameter, getWordSet, create)lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiAnalyzer.java:102 (stock analyzer without a dictionary)lucene/MIGRATE.md:771-773 (ผล among the 'common content words' removed from the stopwords)
- Why it looks unintended
lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiTokenizer.java:28-33 (class javadoc), :198-201, :214-218 (re-anchor), :173-180 (skip loop)GitHub issue #16721 body (read-only, 2026-10-26)GitHub issue #4431 (LUCENE-3358), comment by rmuir 2011-08-03 (read-only)lucene/CHANGES.txt:469 (GITHUB#16722), :17152-17156 (LUCENE-3358, LUCENE-3361)lucene/MIGRATE.md:757-777 (10.6 Thai section), :771-773 (ผล), :1291-1294 (Kuromoji LUCENE-8933)lucene/analysis/common/src/test/org/apache/lucene/analysis/th/TestThaiTokenizer.java:42-67, TestThaiTokenizerFactory.java:35-44lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiNormalizer.java:38, 63-66 (dangling marks)lucene/analysis.tests/src/test/org/apache/lucene/analysis/tests/TestRandomChains.java:233-242 (random CharArraySet of TestUtil.randomSimpleString entries)
- History
lucene/CHANGES.txt:469
- TestThaiTokenizerUserDictionaryClusterSplit.java (JUnit test):
repro/LFV-229/src/org/apache/lucene/analysis/th/TestThaiTokenizerUserDictionaryClusterSplit.java(44 lines)
A summary diagram (PNG), produced separately from this text from the code at ea57360912ed and executed probe logs. A lone combining mark is drawn on a dotted circle, the usual convention; the circle is not a character of the text.
assets/LFV-229/bug.png(428 KB)
