All findings · Lucene finding · LFV-525 · simplepattern-eof-unaccepted-lookahead-ends-scan

SimplePatternTokenizer stops at an unclosed quote and drops every later token; SimplePatternSplitTokenizer merges them into one

With the pattern '[^']*'|[a-z]+ (a quoted phrase or a word), SimplePatternTokenizer turns don't stop me into just don, so a search for stop misses the document. The scan from the unclosed quote reaches the end of the text without a match, and the tokenizer stops instead of retrying.

Summary

Priority
Medium, rank 144The opt-in simple-pattern tokenizers silently drop all later tokens after an unclosed opener, but only with an uncommon delimited-span pattern shape, so it closes the opt-in analysis text-loss group.
Impact
silent wrong results
Since
6.5.0 (LUCENE-7465, committed 2017-02-13)introduced 2017-02-13 · about 9 years
Affected
6.5.0 to 10.5.2 and main
Status
documented
Found by
bounded-exhaustive translation validation

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

SimplePatternTokenizer tries its pattern at each position and keeps the longest match. When nothing matches, it should move one character on and try again. It does, unless the failed attempt ran all the way to the end of the text: then it stops, and every later token is lost.

Summary card in three panels. 1: you index don't stop me with CustomAnalyzer simplePattern and the pattern '[^']*'|[a-z]+, a quoted phrase or a word; the apostrophe opens a quote that never closes; the contract says the longest match at a start point is the next token and the tokenizer otherwise skips one character and tries again. 2: the text as 13 tiles; a bar marks don, a second bar runs from the apostrophe to the end of the text, where ch is -1; nothing is accepted from the apostrophe on, line 152 returns false, while a dead state would retry one char later at line 155. 3: expected tokens don, t, stop, me against actual don only; TermQuery body:stop finds 1 document expected, 0 actual; the split tokenizer with separators \<[^\>]*\>|[ ]+ (the RegExp string, with < and > escaped) gives x, < y instead of x, <, y; silent, no exception. Right column: the hunt's end-of-input fix candidate (drop lines 151-152) restores all four tokens and passes the 29 upstream tests, with its cost to state (64,000 chars of '< ' through the split tokenizer: 0.39 ms on main, 5.35 s with that candidate); patterns that cannot stay open are unaffected.
The scan from the apostrophe of don't stop me runs off the end of the text without a match; line 152 ends tokenization, so t, stop and me are never tried, while a dead state would retry one character later. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index don't stop me with SimplePatternTokenizer (factory name simplePattern) and the pattern '[^']*'|[a-z]+: a single-quoted phrase or a word. The apostrophe at offset 3 opens a quote that never closes.
  2. You expect don, t, stop, me: the javadoc says the longest match at each start point is the next token, and the class's own comment says that where nothing matches it skips one character and tries again. PatternTokenizer with the same regex gives exactly these four.
  3. You get only don. From the apostrophe the scan stays alive to the end of the text without ever matching, and the end-of-input branch returns false instead of retrying at t. A TermQuery for stop finds 0 documents instead of 1.
  4. The smallest case: pattern abc|b on ab gives no token instead of b. On abx, where the scan dies at x before the end, it correctly gives b.
  5. SimplePatternSplitTokenizer has the same branch: separators \<[^\>]*\>|[ ]+ (< and > are escaped in Lucene's RegExp syntax) on x < y give x, < y instead of x, <, y. Tokenization raises no exception, and the stored value still has all the text.

Who is affected

Users of the opt-in, experimental SimplePatternTokenizer or SimplePatternSplitTokenizer (factory names simplePattern, simplePatternSplit) whose pattern pairs a quote, bracket or tag alternative with a word or separator alternative. Ordinary text then triggers it, such as don't, a < b or <3, and every later token of that value is silently lost or merged.

Mechanism, terms and numbers780 words

SimplePatternTokenizer runs its DFA from each start point and keeps the longest accepted match; when nothing matches, it pushes back all but the first character and retries one character later (SimplePatternTokenizer.java:153-156). That retry runs only when the lookahead dies before the end of the input. When the lookahead stays alive to the end without ever accepting, as an unclosed quote does in '[^']*'|[a-z]+, the branch } else if (ch == -1) { return false; } (:151-152) ends tokenization, so don't stop me yields only don and say "hello world (with \"[^\"]*\"|[a-z]+) only say. SimplePatternSplitTokenizer has the same branch (SimplePatternSplitTokenizer.java:156-162) and emits everything from the current token start to the end as one token: separators \<[^\>]*\>|[ ]+ on x < y give x, < y. (Patterns are written as the Lucene RegExp strings that were run: in that syntax ", < and > are reserved and escaped; unescaped, those two patterns throw before tokenizing anything.) Tokenization raises no exception or warning, and end() still reports the full final offset. The branch dates from the first commit of both classes (LUCENE-7465, 6.5.0) and fails on every release run, up to 10.5.2 and main. A fix that retries at the end of the input too, with a small memo that kept many unclosed openers linear on the measured inputs, passes the module's tests.

How the tokenizer scans

SimplePatternTokenizer.incrementToken() (SimplePatternTokenizer.java:104-162 at a9779ef3c193):

  1. It takes the next code point as a possible token start and steps the pattern's DFA (:116-121). If the DFA can start there (state != -1, :123), an inner loop reads ahead and records the longest accepted length (:126-132), until the DFA dies (:139) or the input ends (ch == -1, then break, :134-137).
  2. If something was accepted, the longest match is the token (:141-150), also when the lookahead then ran into the end of the input: the scanned rest is pushed back. This accepting end-of-input path is correct on stock (its pushBack was fixed in 2017 by 2d03aa21a2b) and both fix options keep it.
  3. If nothing was accepted and the DFA died, it is a "false alarm": pushBack(tokenUpto - 1) returns all but the first scanned character and the loop retries one character later (:153-156).
  4. If nothing was accepted and the input ended, } else if (ch == -1) { return false; } (:151-152) ends tokenization. That assumes that no match before the end means no later token, which is false: a later start point inside the scanned run can still match a shorter string.

The example, start by start

Pattern '[^']*'|[a-z]+, text don't stop me (13 chars; executed trace on main): start 0 accepts d, do, don and dies at the apostrophe, so don is a token. Start 3, the apostrophe, can begin a quoted phrase; [^']* keeps the DFA alive over t stop me to the end of the text, never accepting because no closing quote comes. Step 4 returns false, and starts 4-12 (t, stop, me) are never tried.

The split tokenizer

SimplePatternSplitTokenizer (SimplePatternSplitTokenizer.java:99-170) scans for separators the same way. Its end-of-input branch (:156-162) emits everything from the current token start to the end as one token, instead of the false-alarm retry (:163-166), so the separators after an unclosed < are never found: \<[^\>]*\>|[ ]+ on x < y gives x, < y. (The branch's return false arm cannot be reached from a lookahead: the scanned characters are already appended to the pending token, :213-219.)

Exact trigger

A pattern alternative that can stay alive without accepting up to the true end of the value, plus a later start point inside that run that could match: a quoted-string, bracket or tag alternative next to a word or separator alternative (\"[^\"]*\"|[a-z]+, '[^']*'|[a-z]+, \<[^\>]*\>|[ ]+, in Lucene RegExp syntax), or abc|b, ab*c|b. A pattern made only of a quote alternative loses nothing (no later match exists inside the run). Plain character-class patterns such as [^ ]+ or [ ]+ are unaffected, and so are whitespace classes like the factories' javadoc examples when the tab, CR and LF in them are real characters (executed in the fact check). As the javadoc prints them, with backslash escapes inside the class, [^ \t\r\n]+ and [ \t\r\n]+ are rejected by RegExp ("invalid character class") on main, 10.5.2 and 9.12.3: a separate documentation matter. Tokenization raises no exception or warning; end() still reports the full final offset (:164-169).

Exposure and likelihood279 words
  • Who is exposed. Users of either tokenizer: directly, through the factories (SPI names simplePattern and simplePatternSplit, for example in CustomAnalyzer or a Solr schema), or through downstream tokenizers built on them (Elasticsearch's and OpenSearch's simple_pattern and simple_pattern_split; read in their sources, not run there).
  • Opt-in. Both classes and factories are @lucene.experimental, and no Lucene analyzer uses them by default. The bug needs no special setting beyond a pattern of the affected shape.
  • What it takes. A pattern alternative that can stay open to the end of the value (a quoted string, bracket group or tag) next to one that can match later inside it. With such a pattern, one unclosed opener in ordinary text is enough: the apostrophe in don't, a < b, the emoticon <3. Plain character-class patterns are unaffected.
  • Effect. Every token after the unclosed opener is missing from the index (tokenizer) or merged into one term (split tokenizer), at any index size, with assertions on or off. Tokenization raises no exception or warning; end() reports the full offset; the stored value keeps all the text; other values, fields and documents are unaffected.
  • A sharper case. The split tokenizer's merged rest is one term; past 32,766 UTF-8 bytes IndexWriter rejects the whole document as containing an immense term (non-aborting): "Bob <3 wrote: " plus 40,000 chars of words was rejected on main, 10.5.2 and 9.12.3, and is indexed with either fix option.
  • How likely. Rare, by judgement: the affected pattern shape seems uncommon among simple-pattern patterns, and no report of it since 6.5.0 (2017) was found in the public sources searched. How often it appears in real deployments was not surveyed.

Minimal repro

The test shows that SimplePatternTokenizer stops scanning when a lookahead that never matched reaches the end of the input, instead of retrying at the next character as it does when the lookahead dies earlier.

TestSimplePatternLookaheadAtEnd.java (JUnit test)31 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.pattern;

import java.io.StringReader;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;

public class TestSimplePatternLookaheadAtEnd extends BaseTokenStreamTestCase {

  public void testLookaheadReachesEnd() throws Exception {
    Tokenizer t = new SimplePatternTokenizer("abc|b");
    t.setReader(new StringReader("ab"));
    // expected [b] (as for "abx"); actual [] because the lookahead "ab" hits the end and ends the scan
    assertTokenStreamContents(t, new String[] {"b"});
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-525/, 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.pattern.TestSimplePatternLookaheadAtEnd
Expected vs actual
Expected pattern abc|b on ab to give [b] (as on abx); actual [] (token 0 does not exist), with -ea and -da.

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

More files, environment, full output39 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: token 0 does not exist
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:257)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:598)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:622)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:689)
       org.apache.lucene.analysis.pattern.TestSimplePatternLookaheadAtEnd.testLookaheadReachesEnd(TestSimplePatternLookaheadAtEnd.java:29)

Why it looks unintended

  • Javadoc: "the longest match at a given start point will be the next token"; class comment: "we skip one character forward and try again", which the dead-state branch does.
  • PatternTokenizer with the same regex, and String.split, give the full token lists.
  • One extra non-token character brings tokens back: ab*c|b gives nothing on abb but b, b on abbx.
The full argument359 words
  • Contract. Class javadoc (SimplePatternTokenizer.java:34-35): "The matching is greedy: the longest match at a given start point will be the next token." Factory javadoc (SimplePatternTokenizerFactory.java:41-43): "the matching is greedy such that the longest token matching at a given point is created." The class comment states the algorithm (:44-45): "on failing to match a token, we skip one character forward and try again." The split tokenizer is "just like SimplePatternTokenizer except that the pattern should make valid token separator characters, like String.split" (SimplePatternSplitTokenizer.java:33-34).
  • Two no-match branches of one loop disagree. The dead-state branch retries one character later (:153-156); the end-of-input branch gives up (:151-152). No code comment, javadoc, CHANGES or MIGRATE entry explains the difference.
  • The documented comparisons give the full lists. PatternTokenizer (java.util.regex) with the same regex gives don, t, stop, me and say, hello, world; in split mode (group -1) and with String.split, x < y gives x, <, y.
  • Prefix-dependent output. ab*c|b gives no token on abb but b, b on abbx: whether the tokens of a prefix exist depends on whether one more character, which belongs to no token, follows. That is strong evidence the behaviour is accidental rather than a chosen rule.
  • Tests do not pin it. Two upstream tests reach the no-accept end-of-input branch (a verifier found them): testBigLookahead (the literal pattern of 100 as and a b, over 200 as; start points 0-99 die, start point 100 runs to the end) and testEndLookahead ((ab)+ on aba: the first lookahead accepts ab, then the trailing a takes the no-accept branch). Neither has a later start point that could match; both pass with the fixes, as does the whole analysis-common module.
  • History. The branch is in the first commit (93fa72f77bd, LUCENE-7465) without a comment. The only later fix here, 2d03aa21a2b (2017-02-21, "fix corner case in SimplePattern/SplitTokenizer when lookahead hits end of input"), handled the accepting case and added testEndLookahead; it left this branch alone. The 31 comments of LUCENE-7465 (#8517) never discuss it.
The devil's-advocate check261 words

The strongest case for "intended": the end-of-input branch is explicit, symmetric code from the first commit; the classes are @lucene.experimental; the javadoc only says the longest match at a start point is the token, not that every start point is tried; and the shortcut keeps an unclosed opener to one linear scan, while retrying makes each unclosed opener rescan to the end.

Why it fails: the class's own comment says it skips one character and tries again, and the dead-state path does exactly that; output that changes when one non-token character is appended is strong evidence against a chosen rule; PatternTokenizer and String.split give the full lists; @lucene.experimental marks API instability, not permission for wrong output. The cost point is real for the minimal fix (quadratic on many unclosed openers) but not on the measured inputs of the recommended one. The class TODO (:41-53) says the naive matcher "does have N^2 adversarial cases" because it skips one character and retries, and such cases exist on stock through dead states; it does not say whether rescanning at the end of the input was considered, so it supports the retry design, not a deliberate end-of-input shortcut. None of this proves the shortcut was not a deliberate optimization, but together it makes an accident much more likely. Every check that ran code judged it a defect; the reading-only refute review said "not real" only because it could not run code, and the cross-model fact check rated the intended-behaviour case moderate for the tokenizer and weak to moderate for the split tokenizer.

Suggested fix

Recommended Retry at the end of the input, and remember lookaheads that ran to the end (validated; linear on the measured inputs)

Let a lookahead that reaches the end without a match take the existing retry, stepping one code point, and remember (every 16th) the DFA states such a run passed through, so later retries stop early. All tests pass, the whole module too; 64,000 chars of "< " take 5 ms instead of 5.5-9.4 s with the minimal retry-only fix.

// in the lookahead loop of incrementToken(); the split tokenizer is analogous
if (noAcceptAhead.isEmpty() == false && noAcceptAhead.contains(pair(offset, state))) {
  noMoreAccepts = true; // an earlier lookahead went from here to the end without an accept
  break;
}
ch = nextCodePoint();
if (ch == -1) {
  noMoreAccepts = true;
  break;
}
// after the loop: replay the lookahead, record every 16th (offset, state) after its last accept
if (noMoreAccepts) {
  rememberNoAccept(termAtt.buffer(), 0, tokenUpto, offsetStart, lastAcceptLength);
}
All options and trade-offs4 options · 1,090 words

Two options were built at a9779ef3c193 and validated with gradle (the repro, the pattern package, the whole analysis-common module, assertions on and off) and against the stock jars with the patched classes shadowed. Both also change the retry step from one UTF-16 unit to one code point; without that, sending the end of input into the retry would spread a separate defect of the retry path (a supplementary first character is split) to the new path. Timings: single thread, assertions off, on a shared host; up to 64,000 chars 30 warm-up runs and the best of 20 (WarmCost), the 1,024,000-char scaling 10 warm-up runs and the best of 10 (WarmScale); heap from the thread's allocation counter and used heap after GC (MemProbe).

Retry at the end of the input, and remember lookaheads that ran to the end (validated; linear on the measured inputs) (recommended)

optionB-retry-at-end-linear.diff, +113/-14 over both classes. The minimal option's change, plus a LongHashSet of (offset, state) pairs (from org.apache.lucene.internal.hppc, a package analysis-common already uses; LongHashSet itself is new there):

  • when a lookahead reaches the end of the input (or a recorded pair), the tokenizer replays its buffered code points through the DFA and records every 16th pair after its last accept. No accept is reachable from such a pair: the DFA is deterministic, the rest of the input is fixed, and the earlier lookahead walked it to the end. This needs a deterministic automaton; every instance that can be constructed has one, because the Automaton constructors reject a non-deterministic one (SimplePatternTokenizer.java:97-99, SimplePatternSplitTokenizer.java:87-89) and the factories determinize;
  • every later lookahead checks the set at each step, only while it is non-empty, and stops at a recorded pair as if the input ended there; a lookahead that joins a recorded path after that path's last accept stops within 16 steps;
  • reset() releases the set when it is not empty.

"Linear" is measured, not proven in general: from 64,000 to 1,024,000 chars of many unclosed openers the time grows 1.50-2.15x per doubling (split tag-or-space) and 1.85-2.12x with one 3.25x step (tag-or-word). The set is keyed by (offset, state), so several states can be recorded at one offset, and no general bound on its size is established. The class's other N^2 cases remain.

// in the lookahead loop of incrementToken(); the split tokenizer is analogous
if (noAcceptAhead.isEmpty() == false && noAcceptAhead.contains(pair(offset, state))) {
  noMoreAccepts = true; // an earlier lookahead went from here to the end without an accept
  break;
}
ch = nextCodePoint();
if (ch == -1) {
  noMoreAccepts = true;
  break;
}
// after the loop: replay the lookahead, record every 16th (offset, state) after its last accept
if (noMoreAccepts) {
  rememberNoAccept(termAtt.buffer(), 0, tokenUpto, offsetStart, lastAcceptLength);
}

Trade-offs

  • About 100 more lines than the minimal option, and a small set that lives until the next value.
  • Inputs without an unclosed opener at the end: within noise of stock (plain words 8.7-8.9 ns/char vs 8.3-8.6; closed tags 10.1-10.4 vs 11.0-11.3, one run 15.6).
  • One unclosed opener, then 200,000 chars of words: 22.8-27.4 ns/char (minimal option 13.5-17.9; stock 5.5-5.6 but 0 tokens).
  • Many unclosed openers: "< " repeated to 64,000 chars 5.0 ms (minimal option 9,444 ms in this run, 5.5-9.4 s across runs; stock 0.38 ms with 1 token); to 1,024,000 chars 87 ms, about 2x per doubling.
  • Heap for one 1,000,000-char value with one unclosed opener: 2.1 MB allocated and held until the next reset() (stock and the minimal option: none beyond the term buffer). Recording every pair instead of every 16th held 17.8 MB.
  • The class's existing dead-state N^2 cases are unchanged ([a-z]+\@[a-z]+ on one 64,000-char word: about 10-15 s on stock and on both options).

What the checks validated

  • gradle: the repro 5 of 5; org.apache.lucene.analysis.pattern.* 87 tests; the whole analysis-common module 1,914 tests, 18 skipped, 0 failures; assertions on and off. check -x test (lint, forbidden APIs, formatting) passes; TestRandomChains passes with 3 seeds.
  • The new upstream-suite methods pass; on stock all 4 fail.
  • Shadowed on the stock jars: the hunt's bounded-exhaustive check (1,523,418 runs of the two tokenizers, part of 4,140,572 checks with PatternTokenizer controls) 0 mismatches; its long-input check (1,560 inputs through both tokenizers, 3,120 checks, chunked reads) 0; an opener-dense random differential 0 of 32,000 (stock 6,024); the finder's and a verifier's repros and the hunt's reach probe all pass.

Send the end of the input into the existing retry (validated, minimal)

optionA-retry-at-end.diff, +12/-14: delete the end-of-input branch in both classes so that the lookahead takes the false-alarm path, and push back all but the first code point. The same change as the hunt's combined fix, re-made at a9779ef3c193.

final int firstLen = Character.charCount(ch); // after runDFA.step(0, ch)
...
-        } else if (ch == -1) {
-          return false;
         } else {
-          pushBack(tokenUpto - 1);
+          // false alarm (also when the lookahead reached the end of the input)
+          if (tokenUpto > firstLen) {
+            pushBack(tokenUpto - firstLen);
+          }
           tokenUpto = 0;
         }

Trade-offs

  • Smallest diff.
  • Each unclosed opener whose run reaches the end rescans the rest of the value, so many unclosed openers become quadratic where stock is linear (and wrong): "< " repeated to 64,000 chars takes 5.5-9.4 s on a shared host (9.4 s best of 20 here, 5.53 s in the hunt, 5.56 s in the fact check), about 4x per doubling from 8,000 chars. Quote-or-word patterns on paired quotes stay linear (only the last unpaired quote scans to the end).
  • Proposing it means stating that cost. The class TODO already says the naive matcher has N^2 adversarial cases (it does not discuss the end of the input).

What the checks validated

  • gradle: the repro 5 of 5; the pattern package 87 tests; the whole module 1,914 tests, 0 failures, assertions on and off; the new upstream-suite methods pass.
  • Shadowed: the bounded-exhaustive check and the opener-dense differential find 0 mismatches; the reach probe passes 10 of 10.

A single-pass matcher (the class TODO's idea; not tried)

Compile something like .* | <pattern> so that one pass finds every match, as the TODO (:46-53) suggests.

Trade-offs

Would also remove the dead-state N^2 cases, but the TODO notes it needs sub-group capture that Lucene's regexps lack; a much larger change.

Document the current behaviour (not recommended)

State in the javadoc that tokenization ends when a lookahead reaches the end of the input without a match.

Trade-offs

Keeps the prefix-dependent output and the split tokenizer's merged term; neither fix needs it.

Downstream note

Elasticsearch and OpenSearch build their simple_pattern and simple_pattern_split tokenizers on these classes, so their users with a quote- or tag-style pattern lose the same tokens (read in their sources, not run there).

How it was found

A bounded-exhaustive check ran both real tokenizers on every string of up to 6 code points over a 5-symbol alphabet with 39 patterns (1,523,418 runs) against the javadoc's longest-match rule with the class comment's skip-and-retry. 3,916 tokenizer runs lost their trailing tokens; reading the code for the model then located the end-of-input branch.

The method in full185 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier). The checker ran the real SimplePatternTokenizer and SimplePatternSplitTokenizer on every string of up to 6 code points over the alphabet {a, b, space, -, U+1F600}, for 39 RegExp patterns, and compared each output with a reference written from the class javadoc and its implementation comment: the longest non-empty match at each start point, otherwise skip one code point (membership checked with java.util.regex). Of 761,709 tokenizer runs, 3,916 lost trailing tokens, and 3,916 of 761,709 split runs merged tokens, all on the patterns with a live non-accepting run before a later match. A second mismatch class (lone surrogates) is a separate bug of the retry step. (The checker's total of 4,140,572 checks also covers PatternTokenizer in three modes on the same patterns and a 17-pattern PatternTokenizer pool.)

The finder then wrote a JUnit through the public factories (3 of 3 fail on main with assertions on and off, 10.5.2 and 9.12.3) and a fix, which removes the end-of-input class from the exhaustive check; with the code-point retry as well, the check finds 0 mismatches. Independent checks followed (below).

Prior reports, history and checks

Prior reports

No prior report: 150 tool queries over GitHub issues, PRs and commits, the mailing lists and JIRA, 33 direct GitHub searches (Lucene, Elasticsearch, OpenSearch, Solr), 15 web searches and the files of all 408 open PRs (2026-10-26). Closest: the 2017 follow-up that fixed only the accepting end-of-input case.

Searches5 searches

No report of this bug by anyone was found; the nearest items concern the classes' introduction, the accepting end-of-input case and determinization. Limits: some web and downstream queries were throttled, closed unmerged PRs that never name the classes are not searchable, and private security-list reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists, JIRA archive2026-10-26the hunt's prior-report tool in three runs, 150 queries: class, factory and SPI names, simple_pattern, simple_pattern_split, CharacterRunAutomaton, pushBack, symptom phrasingsno report; the top hit was always #8517
GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr)2026-10-2633 direct searchesno report
Web2026-10-2615 answered searches (forums, Q&A, reference docs)no report; the Elasticsearch and OpenSearch reference pages describe no end-of-input rule
Open PRs2026-10-26the changed files of all 408 open apache/lucene PRsnone touches the SimplePattern* files
Related items (read)2026-10-26issue threads and commits read in full#8517 (LUCENE-7465, added the classes; 31 comments, none on this branch); commit 2d03aa21a2b (2017, the accepting end-of-input case); OpenSearch #20349 / PR #20350 (determinization, a separate bug); Elasticsearch #23363 / PR #25159 (exposing the tokenizers)

History

Introduced with both classes by LUCENE-7465 (93fa72f77bd, 2017-02-13), first released in 6.5.0. Fails on all 19 releases run from 6.5.0 to 10.5.2 and on main; the branch has never changed.

Versions tested7 rows

Introduced by 93fa72f77bd (2017-02-13, LUCENE-7465 "add SimplePatternTokenizer and SimpleSplitPatternTokenizer", @mikemccand; a JIRA patch, no PR; issue migrated as #8517) and the branch_6x backport c24e03e6bf4 (same file blobs). git blame credits 9a1702a8f5b, a byte-identical restore after an accidental merge revert. The follow-up 2d03aa21a2b (2017-02-21) fixed pushBack after the end of input in the accepting case only. The branch is in all 74 release tags from 6.5.0 to 10.5.2 of the history clone (the clone has no tags for 8.8.2 and 8.9.0-8.11.4; those releases are covered by the executed jars, not by the tag scan); no later commit touched it.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 4 of 5 fail (gradle with assertions on and off; standalone -ea and -da); TermQuery body:stop finds 0 documents
main ea57360912ed affectedthe finder's test 3 of 3, a verifier's 6 of 10 (its controls pass), the portable probe 5 of 5 bug cases (-ea and -da)
10.5.2, 9.12.3 affectedthis doc's test 4 of 5 (-ea and -da); the finder's 3 of 3; the portable probe 5 of 5
6.5.0 to 10.0.0 (17 releases: 6.5.0, 6.5.1, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 10.0.0) affectedthe portable probe: 5 of 5 bug cases fail, 0 of 4 controls
6.5.0 with the 93fa72f sources originthe original commit's code fails the same way
6.4.2 not affectedno SimplePattern* classes (the probe does not compile)
main + fix (either option) not affectedthe repro passes; the analysis-common module, 1,914 tests, passes with assertions on and off

Independent checks

An independent reproducer, a devil's advocate and an adjudication said real and re-ran it with assertions on and off. Reviews by another model family (reading only) agreed on the root cause; one said "not real" only because it could not run code. An audit kept it; the doc's fact checks found text errors only, now fixed.

Checks and verdicts532 words

Each check ran or re-ran a repro on the stock jars, except the reading-only reviews, for which a separate run re-ran the finder's repro; the verdicts below are each check's own.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-factory JUnit fails 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3, controls pass; with the fix 3 of 3 pass and the upstream TestSimplePatternTokenizer and TestSimplePatternSplitTokenizer pass 29 of 29.
cross-model review, refute lens not real (low), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6, reading only) could not run code, so it voted not real on procedure only; it agreed with the root cause, judged the inputs in contract and remarked that the retry should step by code point. A re-run of the repro failed (3 of 3).
cross-model review, intended lens real (medium)The same reviewer family, reading only: the works-as-designed case (symmetric branches, experimental API, the existing end-of-input tests) fails because no test puts a valid later start inside the consumed run.
The cross-model review, executed the intended lens's cases realabc|b on ab gives nothing and split xab gives xab on main and 10.5.2, with the dead-state controls correct; PatternTokenizer gives the full lists; both pass with the fix.
Independent reproducer real (high)Own 10-test JUnit: 6 of 10 fail on main (-ea, -da), 10.5.2 and 9.12.3, including a random differential (130 and 131 of 2,000 mismatches on affected shapes, 0 of 4,000 on controls); its own fix and the finder's pass 10 of 10.
Devil's advocate real (high)Built the strongest intended-behaviour case and showed why it fails; its probe found the prefix-dependent output (abb vs abbx).
adjudication confirmed (high)Re-ran the independent reproduction's JUnit with -ea and -da (6 of 10 fail) and with the finder's fix (10 of 10 pass); flagged the fix cost and the code-point retry.
audit keepOwn probe through the Automaton constructors on main and 10.5.2 reproduced every case with correct controls; the finder's JUnit with a fresh seed fails 3 of 3 and passes with the fix.
fact check A (independent AI tool, executed) real; 1 notation error and 9 minor findings, fixedRan the brief's gradle command verbatim (5 tests, 4 failed, assertions on and off), the standalone test on main, 10.5.2 and 9.12.3 with -ea and -da, both options with the new upstream methods (38 tests, 0 failures) and its own probe. Found one text error: the unescaped quote and tag patterns in the doc throw in Lucene's RegExp.
fact check B (OpenAI GPT-5.6, reading only) real; 8 errors, 6 overclaims, 4 caveats, fixed or answeredAn independent reviewer from another model family confirmed the root cause, the traces, both fixes and the counts it could check, and rated the intended-behaviour case moderate. Most findings were fixed in the text; two were partly rejected with evidence from the code and the kept runs.
diagram check (independent AI tool) pass, no errorsChecked every code line, value and order of events on screen against a9779ef3c193 and the executed probe; 4 minor notes, addressed in the caption text.

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

Finding record

LFV-525, snapshot of 2026-11-06

Registry title
SimplePatternTokenizer and SimplePatternSplitTokenizer stop scanning when a non-matching lookahead reaches the end of the input, so later tokens are dropped (or later separators ignored)
Area
analysis / pattern tokenizers (analysis-common: SimplePatternTokenizer, SimplePatternSplitTokenizer and their simplePattern / simplePatternSplit factories)
Priority
Medium, rank 144: The opt-in simple-pattern tokenizers silently drop all later tokens after an unclosed opener, but only with an uncommon delimited-span pattern shape, so it closes the opt-in analysis text-loss group.
Impact class
silent wrong results
Impact note
Hits applications that choose SimplePatternTokenizer or SimplePatternSplitTokenizer (directly, through the simplePattern / simplePatternSplit factories, or a downstream simple_pattern / simple_pattern_split tokenizer) with a pattern in which one alternative can stay live without matching up to the end of the value (typically a quoted-string, bracket or tag alternative such as "[^"]" or <[^>]>) next to an alternative that can match later inside that run: for every value with one unclosed opener (the apostrophe in "don't", 'a < b', '<3') all later tokens are silently not indexed (tokenizer) or merged into one term (split tokenizer), at any scale, with -ea and -da, with no exception and with end() still reporting the full final offset; patterns without such an alternative (plain character classes, a lone quote pattern) are not affected.
Summary
You tokenize 'say "hello world' with SimplePatternTokenizer and the pattern "[^"]"|[a-z]+ (a quoted string or a word) and expect [say, hello, world], because the javadoc says the longest match at each start point is the next token and the class otherwise skips one character and tries again (PatternTokenizer with the same regex gives these three tokens). Instead you get only [say]: the lookahead from the unclosed quote runs to the end of the input without ever matching, and that end-of-input branch ends tokenization instead of retrying at the next character, so 'hello' and 'world' are never indexed (minimal case: abc|b on 'ab' gives [] instead of [b]). SimplePatternSplitTokenizer has the same branch and emits the whole rest as one token: separator <[^>]>|[ ]+ on 'x < y' gives [x, '< y'] instead of String.split's [x, <, y]; nothing throws.
Introduced
93fa72f77bd024aa09eef043c65c64a6524613dc (LUCENE-7465 'add SimplePatternTokenizer and SimpleSplitPatternTokenizer', by GitHub mikemccand; JIRA patch, no pull request; issue migrated as apache/lucene#8517; branch_6x backport c24e03e6bf4d09e6f31eee8192bb6c0c4b2b6d27 with identical file blobs) [exec: 6.5.0 jars with the 93fa72f sources shadowed fail the portable probe 5 of 5; read: git log -S/-G/-L on both files shows no later change to the EOF branch; git blame credits 9a1702a8f5b, a byte-identical restore after the accidental merge revert 325cbf00185; the 2017 follow-up 2d03aa21a2b fixed only the accepting end-of-input case]. Not a regression: the branch is as old as the classes (2017-02-13 (master 93fa72f and branch_6x c24e03e6bf4))
First affected version
6.5.0 [exec] (6.4.2 has no SimplePattern* classes [exec: compile error])
Affected versions
6.5.0 through 10.5.2 (the last release before 10.6.0; latest 9.x 9.12.3, latest 8.x 8.11.4) and main ea57360912ed; never fixed. [exec] the follow-up's portable main() probe (5 bug cases, 4 controls) fails 5 of 5 with all controls passing on 19 releases: 6.5.0, 6.5.1, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, and on main with -ea and -da; the finder's JUnit fails 3 of 3 on 6.5.0, 8.11.4 and 9.0.0 (old test-framework import adapted), 9.12.3, 10.0.0, 10.5.2 and main (-ea, -da); the independent reproduction's JUnit fails 6 of 10 on main (-ea, -da), 10.5.2 and 9.12.3. [read] the exact EOF branch is in both files at all 74 release tags 6.5.0-10.5.2 of the history clone (8.8.2-8.11.4 are untagged there and were executed instead).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new; plus the finder's and the independent reproduction's and the intended-behaviour check's searches): a scripted prior-report search in three runs (51 + 50 + 49 queries) over apache/lucene GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 33 manual GitHub searches on apache/lucene, Elasticsearch, OpenSearch and Solr (class and factory names, simple_pattern, simple_pattern_split, CharacterRunAutomaton tokenizer); 15 answered web searches (1 unanswered); the changed files of all 408 open apache/lucene PRs (none touches the files). Related, not this bug: apache/lucene#8517 (LUCENE-7465, closed: added both classes; all 31 comments read, none about ending the scan at EOF after a non-accepting run), commit 2d03aa21a2b (2017, 'fix corner case ... when lookahead hits end of input': pushBack in the accepting case, testEndLookahead), opensearch-project/OpenSearch#20349 and PR #20350 (determinization at construction, related to LFV-526), elastic/elasticsearch#23363 and PR #25159 (exposing the tokenizers), apache/lucene#8780 (open, unrelated)
Lucene commit tested
main a9779ef3c193 (2026-10-27): this doc's test through gradle in a scratch worktree with assertions on and off, and through the standalone JUnit runner with -ea and -da; the 10.5.2 and 9.12.3 release jars (-da in the brief's runs, -ea and -da in the fact check's re-runs). Earlier pipeline runs (2026-10-26): main ea57360912ed, whose four SimplePattern* files are byte-identical, and a portable probe on 19 releases from 6.5.0 to 10.5.2 (6.4.2 has no such classes). Both fix options were validated with the whole analysis-common test module.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:30-39 (at ea57360912ed) (class javadoc: 'The matching is greedy: the longest match at a given start point will be the next token'; @lucene.experimental), :41-53 (TODO: 'on failing to match a token, we skip one character forward and try again', N^2 adversarial cases accepted), :121-157 (incrementToken lookahead; :135-137 break at end of input; :151-152 'else if (ch == -1) { return false; }'; :153-156 false-alarm pushBack(tokenUpto - 1) and retry)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:30-38 (at ea57360912ed) (javadoc: 'just like SimplePatternTokenizer except that the pattern should make valid token separator characters, like String.split'), :156-162 (EOF branch: fillToken of the whole remainder or return false), :163-166 (false-alarm pushBack(sepUpto - 1))
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizerFactory.java:54, (at ea57360912ed) :62, :71-75 and SimplePatternSplitTokenizerFactory.java:56, :64, :73-77 (@lucene.experimental, SPI names simplePattern and simplePatternSplit, the factories determinize the RegExp)
The finding in 30 seconds
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:104-162 (incrementToken; 116-121 start, 123-139 lookahead, 141-150 accept, 151-152 end-of-input branch, 153-157 false alarm), 164-169 (end)
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:99-170 (incrementToken; 156-162 end-of-input branch, 163-166 false alarm)
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:213-219 (appendToToken: the lookahead's characters join the pending token)
Who is affected
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizerFactory.java:41-43, 54-62 and SimplePatternSplitTokenizerFactory.java:43-45, 56-64 (javadoc, @lucene.experimental, SPI names)
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:38 and lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:37 (@lucene.experimental)
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/resources/META-INF/services/org.apache.lucene.analysis.TokenizerFactory:25-26 (only SPI registration; no analyzer uses the classes)
Why it looks unintended
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:30-53 (javadoc, TODO with the retry algorithm), 151-157
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizerFactory.java:41-43; lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:30-38
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/test/org/apache/lucene/analysis/pattern/TestSimplePatternTokenizer.java:41-56, 207-211 and TestSimplePatternSplitTokenizer.java:49-67, 246-250 (testBigLookahead, testEndLookahead)
Suggested fix
  • a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:41-53 (TODO: 'does have N^2 adversarial cases', the .* | pattern idea); a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternTokenizer.java:97-99 and lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:87-89 (non-deterministic automata rejected)

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

Repro files

About the diagrams

A light summary card, made in parallel with this doc from the code at a9779ef3c193 and probes executed on main. Patterns on screen are the escaped RegExp strings that were run.

The fix panel shows the hunt's first candidate. That is not Option A of this doc: Option A also retries by code point. Its 5.35 s (64,000 chars of "< ", split tokenizer, best of 3 on the hunt's run) and Option A's 5.5-9.4 s measure the same quadratic shape on different runs; the recommended option keeps the corrected output without that cost.

The summary card's code panel is shortened (no .build(), no writer.addDocument(doc)).

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.