All findings · Lucene finding · LFV-347 · suffixing-ngram-supplementary-chars-no-grams

Monitor's RegexpQueryHandler silently skips regexp queries on short words that contain an emoji or other supplementary character

With the optional RegexpQueryHandler, a Monitor skips a regexp query when only a short word with a character outside the BMP could select it: /hel.*/ matches the document hello😀, yet Monitor.match runs 0 queries and reports nothing. Every lucene-monitor release since 8.2.0 is affected; the fix is 3 lines.

Summary

Priority
Medium, rank 68This is a second deterministic silent miss in the opt-in Monitor RegexpQueryHandler, triggered by ordinary text such as an emoji glued to a short word, but it hits fewer inputs than LFV-322.
Impact
silent wrong results
Since
8.2.0 (LUCENE-8766, 2019)introduced 2019-06-13 · about 7 years
Affected
8.2.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
semi-formal: code reading, then a bounded-exhaustive check; JUnit-confirmed

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

RegexpQueryHandler lets the Monitor pre-select regexp queries: it indexes each query under its longest literal plus XX, and its SuffixingNGramTokenFilter turns each short document word into all of its substrings plus XX. The filter measures substrings in code points but starts their size from the UTF-16 length, so a word with a surrogate pair gets none.

Summary card of the bug: a Monitor with RegexpQueryHandler and the query /hel.*/, indexed as helXX. The token hello😀 is 7 UTF-16 chars but 6 code points; SuffixingNGramTokenFilter starts the gram size at 7, checks it against 6 and emits only the token itself, so the query is not selected and Monitor.match reports no match, silently, while the control hello matches. The card also names who is affected and the 3-line fix (curCodePointCount instead of curTermLength at lines 89, 108 and 117).
The word hello😀 through the presearcher: hello yields 16 terms including helXX and /hel.*/ runs; hello😀 (7 UTF-16 chars, 6 code points) yields only itself, so the query is skipped; with the fix it yields 22 terms. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You give a Monitor's TermFilteredPresearcher the RegexpQueryHandler, register /hel.*/ and match the one-word document hello😀 (WhitespaceAnalyzer). The handler indexes the query under its literal plus a suffix, helXX, and turns each document word into all of its substrings plus XX.
  2. You expect a match: the regexp matches the word, and a Monitor without filtering reports it. For hello the handler emits 16 terms, among them helXX, so the query is selected and runs.
  3. Instead hello😀 gives 1 term, the word itself. The emoji is 2 UTF-16 chars but 1 code point, so the word is 7 chars but 6 code points: the filter starts its substring size at 7, checks it against 6, and gives up on the word.
  4. Monitor.match runs 0 queries and reports no match and no error, with assertions on or off. With the 3-line fix the word gives 22 terms, including helXX, and the query matches.
  5. It hits any word of up to 30 UTF-16 chars with an emoji, a CJK Extension B ideograph or a letter of a script such as Adlam, also under StandardAnalyzer for such scripts. Another word of the field that supplies the literal, a word over 30 chars in the field, or such a word in another document of the same match batch hides the miss. Term queries and the default presearcher are not affected.

Who is affected

Applications that give TermFilteredPresearcher the opt-in RegexpQueryHandler and register regexp queries. A token of up to 30 UTF-16 chars with an emoji, a CJK Extension B ideograph or an Adlam or Osage letter silently loses the regexp queries only its substrings could select; another token of the field or document of the batch can hide the miss. No false matches.

Mechanism, terms and numbers702 words

RegexpQueryHandler is the optional handler that lets a Monitor's TermFilteredPresearcher pre-select RegexpQuerys: it indexes each regexp under its longest literal plus XX and runs each document field that is not in its excludedFields through SuffixingNGramTokenFilter, which emits each short token and its substrings plus XX (skipping substrings already emitted in the same field). The filter measures substrings in code points but starts and resets their size from the token's UTF-16 length, so for any token that contains a supplementary character (an emoji, a CJK Extension B ideograph, a letter of Adlam, Osage or Gothic) the first bound check fails and the token gets no substrings at all. A regexp query that only one of them could select is not selected: /hel.*/ is not reported for hello😀, with 0 queries run and no error, while a Monitor without filtering reports it. It affects tokens of at most 30 UTF-16 chars (the default maxTokenSize) unless another token of the field supplies the substring, a token of the field is longer than 30 chars (it emits the wildcard token), or another document of the same match batch does either; also under StandardAnalyzer for words in supplementary-plane scripts; it never causes false matches and leaves term queries and the default presearcher alone. It is in every lucene-monitor release, 8.2.0 to 10.6.0 (10.6.0 by reading), and on main; the fix is three lines.

Terms

  • Monitor (lucene/monitor): stores queries and reports which of them a document matches. Its presearcher turns the document's terms into a query over the stored queries, so that only queries that can match are run (package-info.java:55-63).
  • RegexpQueryHandler: an optional CustomQueryHandler for TermFilteredPresearcher. It indexes /hel.*/ under its longest literal plus the suffix, helXX, and under the wildcard token __WILDCARD__ (RegexpQueryHandler.java:96-114), and wraps each document field not in its excludedFields in SuffixingNGramTokenFilter (:90-93; defaults XX, 30, __WILDCARD__ at :41-47). The query is selected if the document yields helXX or __WILDCARD__.
  • Supplementary character: a code point above U+FFFF (an emoji, a CJK Extension B ideograph, Adlam, Osage, Gothic letters). Java stores it as two chars, a surrogate pair, so such a word has more UTF-16 chars than code points.

The bug (file:line at ea57360912ed)

  1. TermFilteredPresearcher.buildQuery (TermFilteredPresearcher.java:100-113, final) reads each field's distinct terms as a TermsEnumTokenStream (:110) and wraps it with every handler (:112). That stream carries only a CharTermAttribute (TermsEnumTokenStream.java:27-30), so the filter's keyword pass-through (:84) never fires in the Monitor.
  2. SuffixingNGramTokenFilter.incrementToken (SuffixingNGramTokenFilter.java:76-136) first returns the token itself (:96), after setting curTermLength = UTF-16 length (:87), curCodePointCount (:88) and curGramSize = curTermLength (:89).
  3. Each substring must pass (curPos + curGramSize) <= curCodePointCount (:110) and is sliced with Character.offsetByCodePoints (:112-114), both in code points; the size is reset to curTermLength - curPos (:108) and the suffix check uses curTermLength - curPos (:117), in UTF-16 chars.
  4. For hello😀: 7 chars, 6 code points, so the first check is 0 + 7 <= 6, false. The filter drops to :134 and takes the next token, having emitted only hello😀. One surrogate pair anywhere in a token is enough; not even XX is emitted.
  5. The document yields neither helXX nor __WILDCARD__, so /hel.*/ is not selected and Monitor.match never runs it (Monitor.java:224, :294-299).
  6. Tokens longer than 30 chars take the wildcard path (:99-104), which every regexp query collects (RegexpQueryHandler.java:113), so they are safe (executed: 30 chars missed, 31 reported), and any such token in the same field hides the miss. The presearcher query is built once per match(Document[]) batch (Monitor.java:218-225), so a token of another document in the batch can also select the query (executed).

Executed on main (-ea and -da), one token per document: hello gives 16 terms including helXX and the query runs; hello😀 gives 1 term and 0 queries run; with the fix hello😀 gives 22 terms and the query matches; a😀 gives [a😀] instead of {a😀, a😀XX, aXX, 😀XX, XX}.

Exposure and likelihood333 words
  • Who is exposed. Monitors built with TermFilteredPresearcher or MultipassTermFilteredPresearcher (which inherits the final buildQuery; executed with the same misses) plus RegexpQueryHandler, that register RegexpQuerys on fields not in its excludedFields. Code search found no public user outside copies of Lucene's own source; Solr's open Monitor integration PR passes no custom handlers.
  • Default vs opt-in. The handler is opt-in; the default presearcher does not use this filter and reported every probe document. Inside the opt-in configuration nothing special is needed: new RegexpQueryHandler(), stock analyzers, assertions on or off.
  • Which text. Executed: WhitespaceAnalyzer with hello😀, say hello𠀀 now (U+20000), x😀hello (/.*hello/) or the Gothic word 𐌰𐌱; KeywordAnalyzer with great product 😀; StandardAnalyzer with short Adlam or Osage words, hello𝐀 (U+1D400) or hello plus a variation selector (U+E0100) or tag character (U+E0041), default-ignorable code points that are normally not rendered. StandardAnalyzer splits an emoji or a CJK ideograph off a Latin word, so hello😀 is found there.
  • Bounds. Only tokens of at most maxTokenSize UTF-16 chars (default 30); another token of the same field that yields the needed substring hides the miss, and so does any token of the field longer than 30 chars (it emits __WILDCARD__) or such a token in another document of the same match(Document[]) batch (executed; a token in another field does not help); term queries are unaffected because the token itself is always emitted.
  • Scale. None: one token of two code points is enough. Exhaustive check of the stock jar over all 1,560 inputs of 1-2 tokens of 1-3 code points over {a, b, U+1F600}: 1,350 miss required substrings (each input on its own), exactly those with U+1F600; 0 of the 210 BMP-only inputs; 0 with the fix.
  • Damage. Silent false negatives for regexp queries only: no error, 0 queries run. Never false matches (0 in an exhaustive probe of 1,764 documents x 10 queries, where 2,478 of 4,520 true pairs were missed), no hang, less work rather than more.

Minimal repro

The test shows that a Monitor with RegexpQueryHandler never runs a regexp query against a token that contains a supplementary character, because SuffixingNGramTokenFilter emits no ngrams for that token.

TestRegexpQueryHandlerSupplementaryChar.java (JUnit test)42 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.monitor;

import java.util.List;
import java.util.Set;
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.RegexpQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestRegexpQueryHandlerSupplementaryChar extends LuceneTestCase {

  public void testRegexpMatchesTokenWithEmoji() throws Exception {
    Presearcher presearcher = new TermFilteredPresearcher(TermWeightor.DEFAULT, List.of(new RegexpQueryHandler()), Set.of());
    try (Monitor monitor = new Monitor(new WhitespaceAnalyzer(), presearcher)) {
      monitor.register(new MonitorQuery("q", new RegexpQuery(new Term("f", "hel.*"))));
      Document doc = new Document();
      doc.add(new TextField("f", "hello\uD83D\uDE00", Field.Store.NO));
      MatchingQueries<QueryMatch> matches = monitor.match(doc, QueryMatch.SIMPLE_MATCHER);
      // expected: /hel.*/ matches the one token "hello" + U+1F600; actual: the presearcher never runs the query, 0 matches
      assertEquals("queries run: " + matches.getQueriesRun(), 1, matches.getMatchCount());
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-347/, 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/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/monitor/build/libs/lucene-monitor-$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.monitor.TestRegexpQueryHandlerSupplementaryChar
Expected vs actual
Expected: /hel.*/ matches the document whose only token is "hello" + U+1F600 (1 match). Actual (-ea and -da): 0 matches, and 0 queries are run by the presearcher.

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

More files, environment, full output36 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: queries run: 0 expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.monitor.TestRegexpQueryHandlerSupplementaryChar.testRegexpMatchesTokenWithEmoji(TestRegexpQueryHandlerSupplementaryChar.java:39)

Why it looks unintended

  • The handler "generates ngrams from Document tokens to match them"; the filter documents one exclusion: tokens longer than maxTokenLength.
  • The filter bounds and slices substrings in code points but sizes them in UTF-16 chars: a token with a surrogate pair gets nothing.
  • Lucene fixed this mix-up in its n-gram filters (LUCENE-5042) and TruncateTokenFilter (#15899), and missed Monitor matches (#14886).
The full argument355 words
  • Contracts. RegexpQueryHandler class javadoc: "A query handler implementation that matches Regexp queries by indexing regex terms by their longest static substring, and generates ngrams from Document tokens to match them" (RegexpQueryHandler.java:29-31). SuffixingNGramTokenFilter constructor: "@param maxTokenLength tokens longer than this will not be ngrammed" (SuffixingNGramTokenFilter.java:60), the filter's only documented exclusion; the handler also documents excludedFields, "a Set of fields to ignore when generating ngrams" (RegexpQueryHandler.java:64, applied at :91), and the filter passes keyword-marked tokens through (:84, undocumented; never the case in the Monitor path). None covers short tokens with supplementary characters. Package doc: queries that match the document disjunction "are then run against the document. In this way, the Monitor can avoid running queries that have no chance of matching" (package-info.java:60-61).
  • The code's own unit. :88 computes curCodePointCount, :110 bounds substrings by it and :112-114 slice with Character.offsetByCodePoints (whose curTermLength argument is the char-array bound, correctly in chars); of the gram arithmetic, only :89, :108 and :117 use the UTF-16 length. :99 also compares the UTF-16 length with maxTokenLength, which only over-selects and is a separate question (option "maxlength-codepoints"). The mix is not an alternative design: it makes the first check fail for every token with a surrogate pair.
  • Tests. TestSuffixingNGramTokenizer and TestRegexpQueryHandler use ASCII only; no upstream test covers non-BMP text. TestWildcardTermPresearcher runs RegexpQueryHandler through the shared PresearcherTestBase suite (TestWildcardTermPresearcher.java:25, :93), so it is a supported configuration.
  • History. The lines came with the module (LUCENE-8766, 251dbe7cea8, 2019), from the donated Luwak code, whose master still has them; neither LUCENE-8766's 28 comments nor its import PR discusses Unicode.
  • Precedent. LUCENE-5042: "The n-gram and edge n-gram tokenizers and filters now correctly handle supplementary characters" (lucene/CHANGES.txt:13522-13524); LUCENE-5269: "Fix bug in NGramTokenFilter where it would sometimes count unicode characters incorrectly" (:13215-13216); #15899/#15900, TruncateTokenFilter (10.5.0, :1182-1184), where a maintainer wrote: "We modified all other similar filters in the past (like ngrams), they all work correctly." Missed Monitor matches fixed as bugs: LUCENE-9791 (:5370-5372), GITHUB#14886 (:1743-1746). The UTF-16-versus-code-point pattern itself is not new.
The devil's-advocate check187 words

The strongest case for "intended or best effort" (built by the devil's-advocate verifier; rated weak by it, the independent reproducer, the GPT-5.6 "works as designed" reviewer and the adjudication): the package doc says RegexpQueryHandler "gives an example of a different method of indexing automaton-based queries" (package-info.java:93-95); it is opt-in; the filter is package-private; the tests are ASCII-only and nothing promises Unicode support; and RegexpQuery itself is tagged @lucene.experimental (RegexpQuery.java:40).

Why it fails: these limit reach, not intent. The handler is public API with public defaults and constructors, has no @lucene.experimental tag and runs in the upstream presearcher test suite; the experimental tag on RegexpQuery marks an API that may change, not licence for the presearcher to drop a query the document matches (under Presearcher.NO_FILTERING the same query matches); the regexps in the repro (a literal prefix plus .*) are the shape the handler's own tests cover; every short token with a surrogate pair gets no substrings at all, which is not an edge of regexp syntax; and the filter's own code counts code points, as the project expects of n-gram filters (#15899).

Suggested fix

Recommended Count the substring size in code points (3 lines)

Use the code-point count in the three places the filter uses the UTF-16 length (SuffixingNGramTokenFilter.java:89, :108, :117; 3 lines). This doc's test and a new TestSuffixingNGramTokenizer case pass, the whole monitor module (185 tests) passes with assertions on and off, and the format and lint checks pass.

@@ -86,7 +86,7 @@ final class SuffixingNGramTokenFilter extends TokenFilter {
         curCodePointCount = Character.codePointCount(termAtt, 0, termAtt.length());
-        curGramSize = curTermLength;
+        curGramSize = curCodePointCount;
@@ -105,7 +105,7 @@ final class SuffixingNGramTokenFilter extends TokenFilter {
         ++curPos;
-        curGramSize = curTermLength - curPos;
+        curGramSize = curCodePointCount - curPos;
@@ -114,7 +114,7 @@ final class SuffixingNGramTokenFilter extends TokenFilter {
-        if ((curGramSize == curTermLength - curPos)
+        if ((curGramSize == curCodePointCount - curPos)
             && !seenSuffixes.add(termAtt.subSequence(0, termAtt.length()))) {
All options and trade-offs4 options · 587 words

One fix was built and validated in a scratch worktree at ea57360912ed (gradle, JDK 25; removed afterwards); an alternative was run against this doc's test. Workaround in every affected release: do not use RegexpQueryHandler (the default presearcher selects every regexp query), or use a copy of the handler with the fix.

Count the substring size in code points (3 lines) (validated by the checks) (recommended)

Replace curTermLength by curCodePointCount at :89, :108 and :117, so the size uses the unit the bound check (:110) and the slicing (:112-114) already use. BMP tokens are unchanged, since both units agree. Diffs: codepoint-fix.diff (code only) and fix-with-test.diff (with both tests; applies cleanly at ea57360912ed).

@@ -86,7 +86,7 @@ final class SuffixingNGramTokenFilter extends TokenFilter {
         curCodePointCount = Character.codePointCount(termAtt, 0, termAtt.length());
-        curGramSize = curTermLength;
+        curGramSize = curCodePointCount;
@@ -105,7 +105,7 @@ final class SuffixingNGramTokenFilter extends TokenFilter {
         ++curPos;
-        curGramSize = curTermLength - curPos;
+        curGramSize = curCodePointCount - curPos;
@@ -114,7 +114,7 @@ final class SuffixingNGramTokenFilter extends TokenFilter {
-        if ((curGramSize == curTermLength - curPos)
+        if ((curGramSize == curCodePointCount - curPos)
             && !seenSuffixes.add(termAtt.subSequence(0, termAtt.length()))) {

Trade-offs

  • Tokens with supplementary characters now get their substrings, so the presearcher query grows for them; the per-token count stays within the bound BMP tokens already reach (1 + c(c+1)/2 + 1 for c code points): a 30-char token of 15 different emoji gives 122 terms, a 30-char BMP token already 457.
  • No performance benchmark was run (only tokens with surrogate pairs change); not reviewed by a committer.

What the checks validated

  • Stock: the doc's 3 tests and the new tokenizer case fail (4 of the 8 tests in the two classes), assertions on and off; with the fix all 8 pass, assertions on and off (gradle).
  • The whole monitor module with the fix: 33 classes, 185 tests (181 upstream + 4 new), 0 failures, 1 skipped, assertions on and off.
  • :lucene:monitor:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • The harness with the fixed class shadowed: the doc's test 3 run, 0 failed, -ea and -da. The class is byte-identical to the hunt's, with which the finder's, the independent reproducer's, the GPT-5.6 reviewer's and the devil's advocate's tests pass, the exhaustive check finds 0 missing substrings, and 8.2.0 passes.

Also compare maxTokenLength in code points (aside)

:99 compares the UTF-16 length with maxTokenLength, so a token of 16 emoji (32 chars) takes the wildcard path. That only over-selects (the query runs and is checked), so it is safe; the javadoc ("the maximum length of an input token") names no unit.

Trade-offs

Not needed for correctness; the tokens of 31-60 UTF-16 chars that have at most 30 code points would be ngrammed instead of wildcarded (more terms, fewer queries run); a 32-char token with one surrogate pair has 31 code points and would still be wildcarded. Not validated.

Emit the wildcard token for any token with a supplementary character (validated by the checks)

if (curTermLength > maxTokenLength || curTermLength != curCodePointCount) at :99 (option-wildcard.diff).

Trade-offs

Every regexp query on that field then runs for every document batch with such a token, which defeats the handler's filtering for emoji-rich text; the recommended fix is as small and keeps the filtering.

What the checks validated

Both Monitor tests of this doc pass; testShortTokenIsNgrammed fails by design (it asserts the substrings; got [a😀, __WILDCARD__]).

Document that the handler supports BMP text only

Not recommended.

Trade-offs

Leaves silent misses for ordinary text in the opt-in configuration.

Downstream note

The document author controls the trigger: one supplementary character attached to a short word hides it from the regexp rules whose literal only that word supplies; under StandardAnalyzer a default-ignorable variation selector (U+E0100), normally not rendered, works. Only regexp rules fail open: no false matches, hang or data exposure. Out of scope under SECURITY.md; no exploit or affected deployment known.

How it was found

Semi-formal. An AI tool modelling the Monitor's presearcher noticed by code reading that the filter mixes UTF-16 length and code-point count. A bounded-exhaustive check of the stock jar (all 1,560 inputs of 1-2 words of 1-3 code points over a, b and 😀) found missing substrings in exactly the 1,350 inputs with the emoji. A public-API JUnit confirmed it.

The method in full176 words

Method: semi-formal (a code-reading hypothesis witnessed by bounded-exhaustive translation validation), a hunt unit (monitor and presearcher, 2026-10-26).

  • While modelling the document side of the presearcher, the finder (an AI tool) read SuffixingNGramTokenFilter and saw the UTF-16 length and the code-point count mixed in one loop. No formal tool produced the first signal.
  • It then ran every input of 1-2 tokens of 1-3 code points over {a, b, U+1F600} (1,560 inputs) through RegexpQueryHandler.wrapTermStream on the stock jar against an exact reference (every code-point substring plus XX): 1,350 inputs miss required substrings, exactly those that contain U+1F600, and 0 of the 210 BMP-only inputs; with the fix, 0.
  • It minimised this into a public-API JUnit on the stock jars. Then: a refute review by an independent reviewer from another model family (OpenAI GPT-5.6, reading only), an independent reproducer, a GPT-5.6 "works as designed" review whose test design was executed, a devil's advocate with stock analyzers, an adjudication, a release sweep, a prior-report search, a security screen, a credibility audit, and this doc's own test and fix validation.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, 213 recorded queries and reads): GitHub issues and PRs, every open PR's files, commits, the dev@, java-user@, issues@ and commits@ lists, JIRA, the original Luwak project and downstream projects. Precedents for the same kind of fix: LUCENE-5042, LUCENE-5269, #15899.

Searches5 searches

Verdict new. No issue, PR, commit, list thread, JIRA entry, Luwak issue or downstream report describes SuffixingNGramTokenFilter emitting no ngrams for tokens with supplementary characters or the resulting RegexpQueryHandler misses; no issue or PR anywhere on GitHub names either class. Precedents, not reports: LUCENE-5042 (#6106) and LUCENE-5269 (#6333), 2013, the same mix-up fixed in analysis-common's n-gram filters; #15899/#15900 (2026), TruncateTokenFilter. Luwak PR #149 (2017) reports presearcher false negatives with a query-side cause; it belongs to the sibling LFV-322. Limits: private security reports are not searchable; GitHub search does not index code or review comments (the relevant threads were read directly); searches cannot prove absence.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs, commits, downstream repositories, mailing lists, JIRA, web2026-10-26a scripted prior-report search in 4 runs, 127 queries (class and field names, supplementary character, surrogate, code point, emoji, ngram, presearcher, luwak, false negative)0 high hits; medium hits are analysis-common n-gram and Unicode work (LUCENE-5042, -5269 and others) and monitor feature requests
GitHub search (apache/lucene, Elasticsearch, OpenSearch, Solr, Luwak, all of GitHub)2026-10-2666 GraphQL queries (GitHub's search rate limit was saturated)no issue or PR names either class; #10988 (a flaky test of another monitor class) only
Open PRs and branch history2026-10-26the changed files of all 411 open apache/lucene PRs; commits by path on main, branch_10x, branch_10_6, branch_9x9 open PRs touch lucene/monitor, none either file; only the import and cosmetic commits
Introducing change and predecessor2026-10-26LUCENE-8766 (28 comments) and its import PR; the 191 Luwak issues and PRs of the follow-up's list (Luwak has 192; the one not listed, PR #188, is a dependency bump) and Luwak's file historyno mention of Unicode or surrogates; Luwak master has the same lines; Luwak #149 is query-side
Downstream and web2026-10-26apache/solr#2382 read; a Monitor-based grep tool's 216 issues and PRs; 11 web and 3 Stack Exchange searchesnothing about this bug

History

In the filter since the monitor module was added (LUCENE-8766, 2019; first released in 8.2.0), from the donated Luwak code, and never changed in behaviour. Executed on 14 releases from 8.2.0 to 10.5.2 and on main: all affected. Not a regression; not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-8766 "Add monitor subproject" (by @romseygeek, 2019-06-13): 251dbe7cea8 on the main line and b8773d13ad8 on branch_8x the same day, with an identical file; GitHub does not record the import PR apache/lucene-solr#648 as merged, but its one comment says "This has been merged."; the code landed through these commits. git blame puts :89, :108 and :110 on 251dbe7cea8 and :117 on the LUCENE-9570 reformat, which only rewrapped it (251dbe7cea8 already has the same three UTF-16 uses). The later commits (LUCENE-9570, #13413, #16063) change no behaviour. The code is older than Lucene: Luwak's master has the same lines (file last changed 2016). The 48 release tags of the history clone that have the file (8.2.0-8.8.1, 9.0.0-10.5.2) all contain the four lines; the others were executed. Upstream main, branch_10x and branch_10_6 have no behaviour change to either file (read, not built).

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis doc's test 3/3 fail (gradle and harness, -ea and -da); finder 2/2, the independent reproduction 2 of 3, the cross-model review 2/2, the intended-behaviour check 2/2
10.0.0-10.5.2 affected10.0.0 and 10.5.2 executed (main() repro); this doc's test 3/3 fail on 10.5.2
9.0.0-9.12.3 affected9.0.0 and 9.12.3 executed; this doc's test 3/3 fail on 9.12.3
8.2.0-8.11.4 affected8.2.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4 executed (main() repro)
8.1.1 and older no moduleno lucene-monitor artifact
fix (code-point gram size) on main and 8.2.0 not affectedthis doc's test, the new tokenizer case and 185 monitor-module tests pass on main, -ea and -da; the main() repro passes on 8.2.0

Independent checks

An independent reproducer, a devil's advocate and an adjudication, each running tests on main, said real, as did a "works as designed" review from another model family (OpenAI GPT-5.6, reading only); an earlier GPT-5.6 review said "not real" only because it could not run code. An audit and this doc re-ran it on releases and with the fix.

Checks and verdicts385 words

Every check ran on main ea57360912ed; the GPT-5.6 reviews could read code but not run it, and a separate run re-ran the finder's repro (it failed both times).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive tier: 1,350 of 1,560 inputs miss substrings, exactly those with U+1F600; JUnit 2/2 fail on main (-ea, -da), 10.5.2 and 9.12.3; passes with the fix.
refute review (GPT-5.6, reading only) not real, low (procedural)Could not run any command, so it voted not real on procedure only; it agreed with the root cause and the inputs and rated the intended argument weak. A separate run re-ran the repro: it failed.
Independent reproducer realOwn JUnit in another package from the claim alone, ground truth Presearcher.NO_FILTERING: 2 of 3 fail on main (-ea, -da), 10.5.2 and 9.12.3 (the BMP control passes); its main() fails on 8.2.0 and 8.11.4; passes with the fix.
"works as designed" review (GPT-5.6, reading only) real, highIntended argument weak. The reviewer's test design (Q🚀Z, control QAZ) was executed: 2/2 fail on main, 10.5.2 and 9.12.3; passes with the fix.
Devil's advocate realBuilt the strongest intended case ("an example", opt-in, ASCII-only tests) and rejected it; stock-analyzer test (StandardAnalyzer on Adlam and Osage, KeywordAnalyzer with an emoji) 2/2 fail, passes with the fix; showed StandardAnalyzer splits an emoji off a Latin word but not U+1D400.
adjudication confirmed, highRe-ran the independent reproduction's test on main -ea and -da (3 run, 2 failed) and with the fix (0 failed).
security screen out of scope of SECURITY.mdExhaustive probe: 0 false matches, 2,478 of 4,520 true pairs missed, all regexp; corrected the earlier note that the trick fails under StandardAnalyzer (the default-ignorable U+E0100 and U+E0041 and the letter U+1D400 still hide a word).
credibility audit keep4th independent re-run: finder JUnit 2/2 on main -ea, -da and 10.5.2 -da; the intended-behaviour check's test 2/2 on main -da and 0 with the fix; distinct from LFV-322.
this doc fails on stock, passes with the fixOwn 3-test JUnit: 3/3 fail on main (gradle and harness, -ea and -da), 10.5.2 and 9.12.3; with the fix it, a new tokenizer case and the whole monitor module (185 tests) pass, -ea and -da.

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

Finding record

LFV-347, snapshot of 2026-11-06

Registry title
Monitor with RegexpQueryHandler silently misses regexp matches on any short token that contains a supplementary character (an emoji, a CJK Extension B ideograph, a Gothic or Adlam letter), because SuffixingNGramTokenFilter emits no ngrams for that token
Area
monitor / presearcher (RegexpQueryHandler document-side ngram filter SuffixingNGramTokenFilter)
Priority
Medium, rank 68: This is a second deterministic silent miss in the opt-in Monitor RegexpQueryHandler, triggered by ordinary text such as an emoji glued to a short word, but it hits fewer inputs than LFV-322.
Impact class
silent wrong results
Impact note
Applications whose Monitor uses TermFilteredPresearcher (or, by code reading, MultipassTermFilteredPresearcher) with the opt-in RegexpQueryHandler: any document token of at most maxTokenSize UTF-16 units (default 30) that contains a supplementary character gets no ngrams, so Monitor.match silently skips every RegexpQuery that needs a substring of that token (0 queries run, no error) unless another term of the same field supplies the gram; ordinary text triggers it (an emoji glued to a word under whitespace or keyword tokenization; a word in a supplementary-plane script such as Adlam or Osage, a mathematical letter or a supplementary variation selector even under StandardAnalyzer, which does split an emoji off a Latin word), at tiny inputs, with -ea and -da, in every lucene-monitor release from 8.2.0 to 10.6.0 (10.6.0 by reading) and main; it never causes false matches, term queries and BMP-only tokens are unaffected, and the default presearcher without the handler is not affected.
Summary
You build a Monitor whose presearcher uses RegexpQueryHandler, register the query /hel.*/ and match a document whose text is 'hello😀' (WhitespaceAnalyzer, one token). You expect a match, because the regexp matches that token and the presearcher may only skip queries that cannot match. Instead SuffixingNGramTokenFilter, which should emit every substring of the token with the suffix 'XX' so that the query's index term 'helXX' is found, starts its gram size at the token's UTF-16 length (7) but checks it against the token's code-point count (6), gives up on the token without emitting a single ngram, and the query is silently never run.
Introduced
251dbe7cea81 (LUCENE-8766 'Add monitor subproject', by GitHub romseygeek, main) and its branch_8x twin b8773d13ad8e (same date; SuffixingNGramTokenFilter.java and RegexpQueryHandler.java byte-identical), released in 8.2.0 [read: git blame of SuffixingNGramTokenFilter.java:89, :108, :110 and :117 on main gives 251dbe7cea81, and git log -S 'curGramSize = curTermLength' finds only the import; exec: fails on 8.2.0, the first lucene-monitor release]; not a regression: the defect is in the file's first Lucene version, and the later commits 2cbf261032dc (LUCENE-9570 reformat), 40f674c5d3d3 (#13413 imports) and c2825cfc4b2a (#16063 final modifiers) are behaviour-neutral; the same lines are in the donated Luwak class (flaxsearch/luwak master, file last changed 2016-11-28) [read] (2019-06-13 (251dbe7cea81 on main and b8773d13ad8e on branch_8x the same day; first released in 8.2.0))
First affected version
8.2.0 [exec] (the first lucene-monitor release; 8.1.1 has no monitor module: the lucene-monitor 8.1.1 artifact does not exist on Maven Central, setup error [exec]); 9.0.0 [exec] on the main line
Affected versions
every lucene-monitor release, 8.2.0 to 10.6.0 (57 versions to 10.5.2; 10.6.0 by reading), and main ea57360912ed; no fixed release. [exec, follow-up version-portable main() repro, -ea] 8.2.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0, 8.11.1, 8.11.2, 8.11.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main; the finder's JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's own JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3 and its main() variant on 8.2.0 and 8.11.4; a separate JUnit fails on main, 10.5.2 and 9.12.3; the security probe gives identical results on main (-ea, -da), 10.5.2 (-da) and 9.12.3 (-da); [read] all four defect lines are present at each of the 48 release tags 8.2.0-8.8.1 and 9.0.0-10.5.2 in the history clone; the fix sketch makes 8.2.0 and main pass [exec]; upstream main, branch_10x and branch_10_6 have no behaviour change to either file (commits by path, read 2026-10-26), but those branches were not built
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check, code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new, 213 recorded queries and reads: a scripted prior-report search in 4 runs, 127 queries over GitHub issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and 6 web queries, 0 high hits; 66 GitHub GraphQL searches of apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, flaxsearch/luwak and all of GitHub (GitHub's search rate limit was saturated); the changed files of all 411 open apache/lucene PRs (9 touch lucene/monitor, none touches SuffixingNGramTokenFilter.java or RegexpQueryHandler.java); commits by path on upstream main, branch_10x, branch_10_6 and branch_9x (only the import and cosmetic commits); LUCENE-8766's 28 JIRA comments and its import PR apache/lucene-solr#648; all 191 flaxsearch/luwak issues and PRs (Luwak master still has the defect); apache/solr#2382 and the 216 issues and PRs of a Monitor-based grep tool; 5 more web and 3 Stack Exchange searches). Not fixed upstream. Gaps: private reports to security@lucene.apache.org cannot be searched; GitHub search does not index code or review comments (the relevant threads were read directly); searches cannot prove absence
Lucene commit tested
main ea57360912ed (2026-10-26): this doc's test through gradle with assertions on and off and through the repro harness, also on the 10.5.2 and 9.12.3 release jars; a version-portable main() on 14 releases from 8.2.0 to 10.5.2 and on main; the recommended fix in a scratch worktree at ea57360912ed (removed afterwards): this doc's test, a new TestSuffixingNGramTokenizer case and the whole monitor module (185 tests) with assertions on and off, and the module's static checks; an alternative fix against this doc's test.

Lucene and upstream sources

Finding record
  • lucene/monitor/src/java/org/apache/lucene/monitor/SuffixingNGramTokenFilter.java:60, :84-96, :99-104, :106-134 (read at ea57360912ed)
  • lucene/monitor/src/java/org/apache/lucene/monitor/RegexpQueryHandler.java:29-31 (class javadoc), :43-47 (defaults), :90-93 (wrapTermStream), :96-114 (handleQuery)
  • lucene/monitor/src/java/org/apache/lucene/monitor/TermFilteredPresearcher.java:100-113 (buildQuery wraps each field's document terms with every handler)
  • lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:60-61 ('avoid running queries that have no chance of matching'), :91-95 (RegexpQueryHandler 'gives an example')
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/SuffixingNGramTokenFilter.java:60, :76-136 (read)
  • Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/RegexpQueryHandler.java:29-31, :41-47, :90-93, :96-114 (read)
  • Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/TermFilteredPresearcher.java:100-113; Monitor.java:224, :294-299; package-info.java:55-63 (read)
  • Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/TermsEnumTokenStream.java:27-30; RegexpQueryHandler.java:91, :113; Monitor.java:218-225 (read)
Who is affected
  • Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/TermFilteredPresearcher.java:100 (final buildQuery); MultipassTermFilteredPresearcher.java:55, :75
Why it looks unintended
  • Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/RegexpQueryHandler.java:29-31; SuffixingNGramTokenFilter.java:60, :88-89, :108-117; package-info.java:60-61, :93-95 (read)
  • Lucene main ea57360912ed: lucene/monitor/src/test/org/apache/lucene/monitor/TestSuffixingNGramTokenizer.java, TestRegexpQueryHandler.java, TestWildcardTermPresearcher.java:25, :93 (read)
  • Lucene main ea57360912ed: lucene/CHANGES.txt:1182-1184, :1743-1746, :5370-5372, :13215-13216, :13522-13524 (read)
  • Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/RegexpQueryHandler.java:64, :91; SuffixingNGramTokenFilter.java:84, :99; lucene/core/src/java/org/apache/lucene/search/RegexpQuery.java:40 (read)
Suggested fix
  • Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/SuffixingNGramTokenFilter.java:99-104; RegexpQueryHandler.java:62-63, :83

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

Repro files

About the diagrams

Made in parallel with this doc from an executed probe on main ea57360912ed (-ea, -da, and with the fix): the emitted terms of hello, hello😀 and a😀, the query's index terms and the match results. The PNG is the final summary card.

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.