All findings · Lucene finding · LFV-229 · thaitokenizer-user-dictionary-match-splits-character-cluster

ThaiTokenizer user dictionary: a word that ends inside a character cluster makes the vowel or tone mark after it disappear

With a ThaiTokenizer user dictionary, a dictionary word ending right before a vowel or tone mark written above or below a letter drops that mark from the index: with {แม่น} ("accurate"), แม่น้ำ ("river") is indexed as แม่น, so searching "river" finds "accurate". Only the new user-dictionary feature (first released in 10.6.0) is affected.

Summary

Priority
Medium, rank 96Silent 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
silent wrong results
Since
GITHUB#16722 (2026-09-28), first released in 10.6.0 (2026-10-29)introduced 2026-09-28
Affected
10.6.0 (released 2026-10-29), main, branch_10x and branch_10_6; no earlier release
Status
documented
Found by
Property-based check with random user dictionaries, then a JUnit (no formal tool)

AI-assisted page: this finding was first signalled by code reading or testing 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

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.

Diagram of the bug: with the user dictionary {แม่น} (accurate), ThaiTokenizer tokenizes แม่น้ำ (river, 6 characters) as the single token แม่น[0,4). The dictionary match ends inside the cluster น้ำ, right before the tone mark U+0E49; the word BreakIterator is restarted there, and the skip loop drops the rest, the tone mark and SARA AM U+0E33. Without a dictionary, or with the fix, the token is แม่น้ำ[0,6).
How {แม่น} 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.
  1. Give ThaiTokenizer the user dictionary {แม่น} ("accurate") and tokenize แม่น้ำ ("river", 6 characters). You expect every character in a token, [แม่น้ำ[0,6)], as without the dictionary.
  2. 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.
  3. 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.
  4. Short everyday entries hit everyday words: {ผล} turns ผลิตภัณฑ์ ("product") into [ผล, ต, ภัณฑ์], {ที} turns ที่นี่ ("here") into [ที, นี่]. A word that ends between two clusters works as intended: {แม่} gives [แม่, น้ำ].
  5. 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).

Who is affected

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).

Terms

  • 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 CharArraySet passed to ThaiTokenizer(CharArraySet ...) or loaded by the thai tokenizer factory's dictionary parameter (GITHUB#16722). A dictionary word "takes precedence over default segmentation boundaries" (class javadoc).

The bug (ThaiTokenizer.incrementWord, main ea57360912ed)

  1. The dictionary loop (ThaiTokenizer.java:186-207) tries prefix lengths from longest to shortest and takes the first userDictionary.contains(...) hit (:191-192). It never looks at the character after the match.
  2. If the match does not end where the word BreakIterator would end the word, and that point is not a word boundary, it sets reanchor (:200-201). The token [start, start + l) is emitted (:209-212): แม่น for แม่น้ำ.
  3. The re-anchor (:214-218) hands the rest of the sentence to the BreakIterator as new text. For แม่น้ำ the rest is ้ำ, which starts with the tone mark.
  4. 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).

The numbers (main, tokens as term[start,end))

dictionarytext (meaning)no dictionarywith the dictionarylost
{แม่น} (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 the thai tokenizer factory with dictionary (CustomAnalyzer, factory configs). The stock ThaiAnalyzer builds new 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, CheckIndex with 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 -ea and -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.

Minimal repro

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.

TestThaiTokenizerUserDictionaryClusterSplit.java (JUnit test)44 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.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());
  }
}
Command
# 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).

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

More files, environment, full output32 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => 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)

Why it looks unintended

  • 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 BreakIterator and the character BreakIterator both report isBoundary = 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. TestRandomChains can pass a random dictionary of a-z strings 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 the ThaiAnalyzer chain 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.

Suggested fix

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.getType per 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 -ea and -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; checkGoogleJavaFormat passes.
  • 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 BreakIterator per tokenizer with a dictionary, and a setText per sentence and per re-anchor; not benchmarked.

What the checks validated

  • The repro 5 of 5 with -ea and -da; 61 Thai tests with -ea and -da; all 1,914 analysis-common tests with -ea (18 skipped, 0 failed); TestRandomChains; checkGoogleJavaFormat.
  • The random dictionary harness: 156,356 checks, no violations.

C: extend the match to the end of the cluster (not run)

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.

Workaround: list the longer words too

{ผล, ผลิต} 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.

How it was found

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 -ea and -da over 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 no BreakIterator has a boundary at the cut. An adjudication re-ran both with fresh seeds.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-2622 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 history2026-10-26changed files of all 413 open apache/lucene PRs; ThaiTokenizer.java history on main, branch_10x, branch_10_6no PR touches the Thai package; nothing after b304eeae024 and its backport 361da20c595
Downstream repositories2026-10-26Elasticsearch, OpenSearch and Solr issuesthe user dictionary is not exposed yet (#160853, #160854; OpenSearch #23151 is a plan)
Mailing lists and JIRA2026-10-2620 queries over dev@, java-user@, issues@, commits@; two months read; 4 JIRA queriesonly notification mirrors of the Thai PRs; JIRA predates the feature (LUCENE-3358 is a precedent)
Web2026-10-266 + 2 queriesnothing

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis page's repro fails 4 of 5 with -ea and -da; the finder's JUnit 3 of 4
main a9779ef3c193 later mainthe finder's JUnit fails 3 of 4, identical messages
6b44b4c2a3af (GITHUB#16722) introducingits ThaiTokenizer.java compiled over the main jars fails 3 of 4, like main
branch_10x, branch_10_6 (10.6.0) same source, not runThaiTokenizer.java byte-identical to main (read-only API check)
10.5.2, 9.12.3 and earlier not affectedno ThaiTokenizer(CharArraySet): the repro does not compile; the no-dictionary control passes
main with fix A or fix B fixthe repro passes 5 of 5; the 1,914 analysis-common tests: 0 failed, 18 skipped

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder's JUnit and random harness candidate3 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 confidenceOwn 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 realOwn 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 confidenceRoot 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 confidenceNo 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, highRe-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 noneWrong 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 passGradle 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.

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

Finding record

LFV-229, snapshot of 2026-11-06

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's ThaiTokenizer.java over the main jars (history step); a later main, a9779ef3c193, with the finder's JUnit (still reproduces).

Lucene and upstream sources

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-44
  • lucene/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

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

Repro files

About the diagrams

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.

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.