All findings · Lucene finding · LFV-869 · automaton-to-token-stream-truncates-supplementary-labels

AutomatonToTokenStream cuts transition labels above U+FFFF to 16 bits: U+10400 comes out as U+0400

AutomatonToTokenStream.toTokenStream writes each transition label as one char, so a character above U+FFFF comes out as a different one: Automata.makeString("𐐀") (U+10400) gives the token Π€ (U+0400), with no error. Every release since 8.10.0 has it; only two Lucene tests use the class.

Summary

Priority
Low, rank 926Silent wrong token text only for labels above U+FFFF in the AutomatonToTokenStream utility that nothing in production calls.
Impact
minor
Since
8.10.0 (LUCENE-9963, 2021)introduced 2021-08-09 · about 5 years
Affected
8.10.0 to 10.6.0 and main (41 releases, all run)
Status
documented
Found by
bounded-exhaustive translation validation (semi-formal)

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

The finding in 30 seconds

The converter writes each token's text with (char) label. A label above U+FFFF loses its high bits and becomes another character.

Background: automaton labels and UTF-16

  1. A Lucene Automaton labels each edge with an int. The text constructors (Automata.makeString, makeStringUnion, RegExp) use Unicode code points, from 0 to 0x10FFFF.
  2. Java strings are UTF-16. A code point up to U+FFFF is one char; one above it (emoji, historic scripts, CJK Extension B) is two chars, a surrogate pair: U+10400 is \uD801\uDC00.
  3. AutomatonToTokenStream.toTokenStream turns an acyclic automaton into a token graph: one token per edge, whose text is the edge's label.
Diagram of the bug: Automata.makeString of U+10400 gives one edge labelled 0x10400; AutomatonToTokenStream writes the token text with (char) label, which keeps the low 16 bits, so the token is U+0400 instead of the surrogate pair D801 DC00. Labels up to U+FFFF are unchanged; the fix appends the whole code point.
How one edge labelled U+10400 becomes the token U+0400: the label, the (char) cast that drops its high bits, and the expected surrogate pair. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a one-character automaton with Automata.makeString("𐐀"). The character is U+10400, two chars in Java (\uD801\uDC00), and the automaton has one edge labelled 0x10400.
  2. Convert it with AutomatonToTokenStream.toTokenStream, which turns each edge into a token. You expect one token with the text 𐐀.
  3. You get Π€ (U+0400, a Cyrillic letter). The text is written with (char) label, which keeps only the low 16 bits: 0x10400 becomes 0x0400. No exception.
  4. Every label above U+FFFF is hit: U+10000 becomes a NUL char, the emoji U+1F600 becomes U+F600, and U+10041 becomes A, the same token as U+0041. Labels up to U+FFFF are exact.
  5. All 41 releases from 8.10.0 to 10.6.0 and main fail the test. Nothing in Lucene calls the class outside tests, so no Lucene index, query or analyzer path is affected; the fix is one line.

Who is affected

Code that calls the public AutomatonToTokenStream.toTokenStream on an automaton with characters above U+FFFF, such as emoji or CJK Extension B, gets wrong token text silently. No setting is involved. In Lucene only two tests use the class and no downstream use was found, so the reach is small.

Mechanism, terms and numbers377 words

AutomatonToTokenStream.toTokenStream(Automaton) turns an acyclic automaton into a token graph; its javadoc says the stream "releases edges from the automaton as tokens". Lucene's text automata label their edges with Unicode code points (Automata.makeString, makeStringUnion, RegExp), but the token text is written with charAttr.append((char) currentEdge.value), which keeps only the low 16 bits of the int label. A label above U+FFFF therefore becomes a different, usually valid character: U+10400 comes out as U+0400, U+1F600 as U+F600, U+10000 as a NUL char, and U+0041 and U+10041 give the same token. Nothing fails.

The cast has been there since the class was added for FlattenGraphFilter tests (LUCENE-9963, 8.10.0), and all 41 releases that have the class fail the one-line test. The reach is small: in Lucene only TestAutomatonToTokenStream and TestFlattenGraphFilter use the class. A one-line fix, charAttr.append(Character.toString(currentEdge.value)), passes the repro, the core analysis tests, TestFlattenGraphFilter and :lucene:core:check -x test.

The bug, step by step (main 645d4fe14a20)

  1. Labels are code points. Automata.makeString stores s.codePointAt(i) as each edge's label (Automata.java:555); makeStringUnion is "codepoint based (full unicode codepoints on transitions)" (:638-639); makeAnyChar, makeChar and makeCharRange speak in code points (:104, :116, :137).
  2. One token per label value. toTokenStream walks the states in topological order and adds one EdgeToken per value of each edge's range: for (int val = t.min; val <= t.max; val++) ... edges.add(new EdgeToken(destLayer, val)) (AutomatonToTokenStream.java:97-101). The value stays an int (:174).
  3. The cast. TopoTokenStream.incrementToken writes the text with charAttr.append((char) currentEdge.value) (:143); the shipped bytecode is i2c then CharTermAttribute.append(char). (char) 0x10400 is 0x0400.
  4. Only the text is wrong. Positions, lengths and offsets come from the layers (:145-149), so the token graph has the right shape. Labels 0 to 0xFFFF (BMP code points or bytes) are exact.

The numbers

Input automatonTokens expectedMain 645d4fe14a20
Automata.makeString(U+10400) (the repro)U+10400U+0400
Automata.makeString(U+10000)U+10000U+0000 (a NUL char)
Automata.makeString(U+10FFFF)U+10FFFFU+FFFF
new RegExp("a" + U+1F600).toAutomaton()a, U+1F600a, U+F600
Automata.makeStringUnion({"A", U+10041})A, U+10041A, A
Automata.makeString("a" + U+4E2D + U+FFFD) (control)a, U+4E2D, U+FFFDsame
Automata.makeBinaryStringUnion({"abc"}) (byte labels, control)a, b, csame

Assertions on and off give the same result; with the fix every row is as expected.

Exposure and likelihood161 words
  • Who is hit. Any caller of AutomatonToTokenStream.toTokenStream whose automaton has labels above U+FFFF: emoji, historic scripts, CJK Extension B and later. It is a public class in an exported package of lucene-core, with no setting.
  • What goes wrong. The token text is a different character, usually a valid one, so nothing looks broken. Distinct labels can give the same token (U+0041 and U+10041 both give A).
  • Reach. In Lucene only TestAutomatonToTokenStream (core) and TestFlattenGraphFilter (analysis-common) use the class; no query parser, analyzer, filter, query or codec calls it. A GitHub code search (2026-10-28) found no use in Elasticsearch, OpenSearch or Solr; the other hits were copies of Lucene's source and ports.
  • Limits. Deterministic; no exception, hang or resource growth. No in-tree index, query, analyzer or codec path is affected; an outside caller could still index or compare the wrong tokens. Labels up to U+FFFF are exact.
  • Priority. Low: a silent wrong result, but in a utility nothing in production calls.

Minimal repro

One JUnit test on the public API: build Automata.makeString("𐐀"), convert it, and compare the one token's text with the input. Deterministic, single thread, no index. Put it in core's test tree and run it with gradle.

TestAutomatonToTokenStreamSupplementaryLabel.java (one test, public API)35 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.analysis;

import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.automaton.Automata;

public class TestAutomatonToTokenStreamSupplementaryLabel extends LuceneTestCase {

  public void testSupplementaryLabel() throws Exception {
    String longI = new String(Character.toChars(0x10400));
    try (TokenStream ts = AutomatonToTokenStream.toTokenStream(Automata.makeString(longI))) {
      CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
      ts.reset();
      assertTrue(ts.incrementToken());
      // expected: token text U+10400 (the edge label); actual: U+0400 (the label cast to char)
      assertEquals(longI, term.toString());
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestAutomatonToTokenStreamSupplementaryLabel.java lucene/core/src/test/org/apache/lucene/analysis/
./gradlew :lucene:core:test --tests org.apache.lucene.analysis.TestAutomatonToTokenStreamSupplementaryLabel
# assertions off fails the same way: add -Ptests.asserts=false
Expected
One token with the text 𐐀 (U+10400, the edge's label).
Observed
One token with the text Π€ (U+0400): expected:<[𐐀]> but was:<[Π€]>, with assertions on and off.

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

More files, environment, full output118 words

The test sits in org.apache.lucene.analysis, the package of the upstream TestAutomatonToTokenStream, but uses only public API. Nothing in it is randomized: seeds DEADBEEF, C0DE0869, 5EED0869, 7B0E0869 and 7B0D0869 give the same failure. On 8.10.0 to 9.0.0 the same test needs only the older import org.apache.lucene.util.LuceneTestCase.

As a regression test it is the same test: it fails on stock, passes with either fix, and passes :lucene:core:check -x test in the tree.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on every release from 8.10.0 to 10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

TestAutomatonToTokenStreamSupplementaryLabel > testSupplementaryLabel FAILED
    org.junit.ComparisonFailure: expected:<[𐐀]> but was:<[Π€]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:B0CDCC61D28C5A40]:0)
        at app//org.junit.Assert.assertEquals(Assert.java:117)
        at app//org.junit.Assert.assertEquals(Assert.java:146)
        at app//org.apache.lucene.analysis.TestAutomatonToTokenStreamSupplementaryLabel.testSupplementaryLabel(TestAutomatonToTokenStreamSupplementaryLabel.java:32)
:lucene:core:test (FAILURE): 1 test, 1  failure

Why it looks unintended

  • The javadoc says the stream "releases edges from the automaton as tokens"; its one precondition is a finite DAG, with no label limit.
  • Lucene's text automata use code points: makeString stores codePointAt; makeStringUnion, used by three of the class's six tests, is "codepoint based".
  • For a label from text, U+0400 for U+10400 is wrong under any reading.
The full argument356 words
  • The contract is code points. The javadoc promises the edges as tokens and states one precondition, a finite DAG (AutomatonToTokenStream.java:40-47). The automaton package has a "Unicode alphabet" (util/automaton/package-info.java:33), Automaton.step documents "@param label codepoint to look up" (Automaton.java:654), and every text constructor labels with code points. No public automaton constructor (Automata, RegExp, ...) labels edges with UTF-16 code units (the package-private hunspell TrigramAutomaton labels with a char minus an offset, so at most 0xFFFF, TrigramAutomaton.java:55), and a byte label is at most 255. The raw-int entry points (Automata.makeString(int[], int, int), makeChar, makeCharRange, Automaton.addTransition, Automaton.Builder.addTransition) take any int with no range check, but the Automata ones are documented in code points ("from the specified unicode code points", Automata.java:614-617). So a label above U+FFFF that comes from text is a supplementary code point.
  • Public API. public class AutomatonToTokenStream (:36) has no @lucene.internal or @lucene.experimental tag and sits in a package lucene-core exports (module-info.java:26). Its input type is experimental: Automaton, Automata and the automaton package carry @lucene.experimental (Automaton.java:43, Automata.java:44, package-info.java:44). That allows the API to change between releases; it does not make silently wrong text intended.
  • Its own tests build code-point automata. Three of the six tests in TestAutomatonToTokenStream use Automata.makeStringUnion (:34, :51, :68), with ASCII strings only; the other three use Automaton.Builder with char-literal labels (:80-130).
  • Elsewhere Lucene gets it right. Operations.getCommonPrefix appends each label with appendCodePoint (Operations.java:1176), and TestAutomaton.testCommonPrefixUnicode checks that emoji survive (:188-192). The sibling TokenStreamToAutomaton reads code points with codePointAt when unicodeArcs is set (:184-202).
  • An oversight, not a decision. The cast came with the class in #157 (LUCENE-9963); its review never discusses char, code points, surrogates or label limits, and one reviewer wanted to run a Levenshtein automaton, which is code-point labelled, through it. The six later commits on the file leave these lines alone.
  • Precedent. #15900 (merged 2026-04-06) fixed the same kind of UTF-16 mistake in TruncateTokenFilter, so it no longer produces half surrogates.
The devil's-advocate check192 words

The strongest case for "intended": the class was written as test infrastructure for FlattenGraphFilter and has no production caller; Automaton, Automata and their package are @lucene.experimental; the javadoc never says how an int label maps to token text, and the one-token-per-range-value loop shows it was not designed for wide Unicode ranges, so the cast may reflect a "one edge, one UTF-16 char" assumption.

Why it fails: an experimental tag allows API changes, not silent corruption; the class is public and ships in lucene-core; the standard constructors, including the one three of its own tests use, make full code points from ordinary strings; and the cast turns U+10400 into an unrelated letter. It is about impact, which the low priority reflects, not about validity. The reviews that rate the argument explicitly rate it weak. The read-only fact check of this doc by another model family rates it moderate: every in-tree use is ASCII or byte input and labels are plain ints, so a maintainer could reasonably prefer a documented BMP-only contract, label validation or moving the class; it still judges the repro real and minor / rare / low right.

Suggested fix

Recommended Append the code point as a string (one line)

Append the whole code point instead of casting it: charAttr.append(Character.toString(currentEdge.value)). It writes one char up to U+FFFF and a surrogate pair above, and throws for a value that is not a code point. It passes the repro, the core analysis tests, TestFlattenGraphFilter and :lucene:core:check -x test.

-      charAttr.append((char) currentEdge.value);
+      // labels are code points: append one char, or a surrogate pair above U+FFFF
+      charAttr.append(Character.toString(currentEdge.value));
All options and trade-offs3 options Β· 507 words

Two fixes were run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF); a third variant was checked earlier by the study.

Append the code point as a string (one line) (validated by the checks) (recommended)

Replace the cast in TopoTokenStream.incrementToken (AutomatonToTokenStream.java:143).

-      charAttr.append((char) currentEdge.value);
+      // labels are code points: append one char, or a surrogate pair above U+FFFF
+      charAttr.append(Character.toString(currentEdge.value));

Trade-offs

  • One small String per token, negligible for this utility.
  • A label that is not a code point (negative or above U+10FFFF) now throws IllegalArgumentException instead of becoming a wrong char. String and regex constructors never make one; a caller can still pass such an int through the raw-int entry points (Automata.makeChar, makeCharRange, makeString(int[], int, int), Automaton.addTransition, Automaton.Builder.addTransition; none checks the range). Which behaviour such labels should get is a choice for the maintainers: reject them, as this fix does, or document code points as the contract.
  • Character.toString(int) exists since Java 11, so the same line backports to 9.x and 10.x, not to 8.x (Java 8), where the Character.toChars variant does the same.
  • Offsets stay graph-layer indices, as for every token today (offAttr.setOffset(currentPos, currentEdge.destination), :149), not offsets into a text: with the surrogate-pair fix shadowed, the diagram probe shows U+10400 as a 2-char term with offsets 0-1; this fix changes only the same line.

What the checks validated

  • Stock: the repro fails with assertions on and off.
  • Fix: the repro passes with assertions on and off.
  • :lucene:core:test --tests 'org.apache.lucene.analysis.*': 22 classes, 169 tests, 0 failures (the upstream TestAutomatonToTokenStream 6/6).
  • :lucene:analysis:common:test --tests ...TestFlattenGraphFilter, the only other in-tree caller: 25 tests, 0 failures.
  • :lucene:core:check -x test with the repro in the tree passes (google-java-format, ecjLint main and test, forbiddenApis, licenses, checksums).

Append the surrogate pair by hand (validated by the checks)

The finder's fix: keep the cast for a BMP label, otherwise append Character.highSurrogate(value) and Character.lowSurrogate(value).

if (Character.isBmpCodePoint(currentEdge.value)) {
  charAttr.append((char) currentEdge.value);
} else {
  charAttr.append(Character.highSurrogate(currentEdge.value));
  charAttr.append(Character.lowSurrogate(currentEdge.value));
}

Trade-offs

No String per token (append(char) writes into the term buffer, which grows only when it is too small, CharTermAttributeImpl.java:57-66, :158-162), but for a value above U+10FFFF the JDK returns unspecified chars, so an invalid label still turns silently into wrong text. Six lines of code (plus a comment) replace one.

What the checks validated

Gradle: the repro 1/1 and TestAutomatonToTokenStream 6/6 pass. Earlier, shadowed onto the stock jars: the repro with assertions on and off, TestFlattenGraphFilter.testPathsNotLost 20/20 iterations, and the 6-case probe 6/6.

Variant: Character.toChars (run in the study) (validated by the checks)

The study's first verifier shadowed the class on main a9779ef3c193 with this line instead of the cast.

charAttr.append(new String(Character.toChars(currentEdge.value)));

Trade-offs

Same behaviour as the recommended option (toChars also throws for a value that is not a code point), and it also compiles on Java 8.

What the checks validated

Shadowed onto the stock jars: the verifier's own 5 tests and the upstream TestAutomatonToTokenStream (6/6) pass. Not run through gradle or with the minimal repro.

Downstream note

Not a Lucene security issue; downstream, code that compares or indexes these tokens would treat distinct characters such as U+0041 and U+10041 as the same token.

How it was found

Semi-formal translation validation: while the hunt planned the check, an AI tool spotted the (char) cast and probed it with an emoji. An AI tool then modelled the documented conversion and compared it with the real class on all 250,000 four-state acyclic automata (in a fixed state numbering) over four characters: every token-text difference was at a label above U+FFFF.

The method in full180 words

Method: bounded-exhaustive translation validation (the study's semi-formal tier), in a study unit on token-graph and automaton conversion.

  • An AI tool wrote an independent model of the construction the javadoc describes, with token text = the label's code point, and ran it next to the stock AutomatonToTokenStream on all 250,000 topologically numbered 4-state automata over the alphabet {a, U+4E2D, U+10400, U+10428}.
  • The model matched the converter's structure everywhere (0 model mismatches). In the token-by-token comparison, token text differed on 187,744 automata, always at a label above U+FFFF. Against a patched class it differed on none (that patch also changed when the class throws: 0 exceptions instead of 54,720).
  • The (char) cast was first spotted, and probed with an emoji ('x' + U+1F600 gives a truncated char), while the hunt planned this unit: the unit's plan already quotes line 143. So the run confirmed and measured a suspected defect; it is not a blind find.
  • A public-API JUnit then witnessed it on the stock main jar; independent verifiers wrote their own tests, and the second adjudication re-ran everything on 645d4fe14a20.

Prior reports, history and checks

Prior reports

No prior report: 163 queries on 2026-10-31 (GitHub, mailing lists, JIRA, downstream projects, web) and all 410 open PRs' changed files; the study's 2026-10-28 search agrees. Every hit naming the class is the PR that added it or a refactor.

Searches5 searches

Nearest item: #15900 fixed the same kind of UTF-16 versus code-point mistake in TruncateTokenFilter, a different class and a good precedent. An outside Kotlin port copies the same cast; it is code, not a report.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, apache/lucene-solr)2026-10-31two scripted prior-report search runs plus 18 manual phrase searches, e.g. AutomatonToTokenStream, TopoTokenStream, "low 16 bits", "(char)" label automaton, U+10000 automatononly #157 (introduced the class), its backport and refactors #11813, #13328, #13368, #13406, #13422, #15704
Open PRs2026-10-31changed files of all 410 open PRs; titles and bodies of the 97 issues and PRs updated since 2026-10-27none touches AutomatonToTokenStream, its test, TokenStreamToAutomaton or FlattenGraphFilter
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-31the class and method names, symptom wording, a 14-day windowno message names the class on dev@ or java-user@; the issues@ and commits@ hits are PR mirrors
JIRA archive2026-10-31class and symptom queriesonly LUCENE-9963, the introducing issue
Elasticsearch, OpenSearch, Solr; global GitHub code and web search2026-10-31issue and code searchno report; code hits are copies of Lucene's source and two ports

History

Introduced with the class by LUCENE-9963 (#157, merged 2021-08-09; 8.x backport), first released in 8.10.0. Not a regression. All 41 releases from 8.10.0 to 10.6.0 and main fail the test; 8.9.0 has no such class. Not fixed upstream.

Versions tested6 rows

Introduced by #157, LUCENE-9963 "Improve FlattenGraphFilter's robustness when handling incoming token graphs with holes" (Geoffrey Lawson; main 647255b4d29, merged 2021-08-09), which added the file with the range loop and the cast already in place. The 8.x backport 75eba3892979 (apache/lucene-solr#2551, 2021-08-16) shipped it in 8.10.0. git blame attributes lines 99-101 and 143 to 647255b4d29; the six later commits on the file are refactors. Upstream main 2f424fd428da (2026-10-30) still has the cast; the newest commit on the file is 7335eb0ad96d (#15704, 2026-02-26).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedtoken U+0400 instead of U+10400, assertions on and off
10.0.0-10.6.0 (13 releases) affectedthe same failure
9.0.0-9.12.3 (21 releases) affectedthe same failure
8.10.0, 8.10.1, 8.11.0-8.11.4 (7 releases) affectedthe same failure
8.9.0 no classcompile error: AutomatonToTokenStream does not exist
fix (Character.toString) on 645d4fe14a20 not affectedrepro passes; 169 core analysis tests and 25 TestFlattenGraphFilter tests pass

Independent checks

Confirmed by the study's judge after an independent reproducer, an intended-behaviour devil's advocate and a cross-model reader, then by a credibility audit (impact cut to minor), a re-run on 645d4fe14a20, a tie-break and fact checks of this doc. The cross-model reviews could not execute code; the reviews that read the code supported "real".

Checks and verdicts377 words

All executing checks ran with one model family; the reviewer from another model family (OpenAI GPT-5.6) could at most read the code, because it could not run any process.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier realIts own public-API test fails 4 of 5 on main, 10.5.2 and 9.12.3 (makeString U+10400, builder U+10000, RegExp emoji, U+0041/U+10041 collision); the BMP control passes; a Character.toChars fix makes all 5 pass.
study intended-behaviour devil's advocate realBuilt the strongest "intended" case and rebutted it: no label limit is documented, three of the six upstream tests use code-point makeStringUnion.
cross-model reviews (OpenAI GPT-5.6) reading onlyThe study's refute review (2026-10-28): procedural "not real", low; it could neither run nor inspect anything. The study's supplementary review with the evidence inlined: real, high. The study's intended-lens review: real, medium. This bug's refute review (2026-10-31): procedural "not real", low, because nothing could run, with root cause agreed and the intended argument weak.
study adjudication confirmed, highRe-ran the independent reproduction's test with a new seed, 4 of 5 fail with assertions on and off.
credibility audit (2026-10-28) real, impact downgradedReproduced with a fresh seed; impact cut from silent wrong results to minor because the class has no in-tree production caller.
reproducer (2026-10-31) real, not fixed on mainThe minimal test fails on 645d4fe14a20 and 10.5.2 with assertions on and off; the fix makes it pass.
tie-break (2026-10-31) real, highNew seeds on main and 10.5.2, javap of the shipped bytecode, its own 6-case probe with 2 controls, the fix and both in-tree callers.
fact checks and diagram check of this doc (2026-10-31) bug holds; wording correctedAn independent fact check re-ran the repro on main (assertions on and off) and on 10.6.0, the gradle command, fix A through gradle and its own public-API probe, and found 6 minor wording errors. A read-only fact check by another model family (OpenAI GPT-5.6) found the bug real as described, 2 errors (more raw-int label entry points; fix B's line count and allocation) and some overclaims, and rated the intended-behaviour case moderate. A diagram check found the visuals correct. All accepted corrections are in this doc and the re-rendered diagrams.

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

Finding record

LFV-869, snapshot of 2026-11-06

Registry title
AutomatonToTokenStream casts each transition label to char, so a label above U+FFFF becomes a different character (U+10400 comes out as U+0400, U+1F600 as U+F600, U+10000 as a NUL char)
Area
core / analysis: AutomatonToTokenStream (automaton to token-graph conversion)
Priority
Low, rank 926: Silent wrong token text only for labels above U+FFFF in the AutomatonToTokenStream utility that nothing in production calls.
Impact class
minor
Impact note
Code that converts a code-point automaton with the public lucene-core utility AutomatonToTokenStream.toTokenStream (no setting involved) gets, for every transition label above U+FFFF (emoji, historic scripts, CJK Extension B and later), a token whose text is the low 16 bits of the label: a different and usually valid character, so distinct labels such as U+0041 and U+10041 give identical tokens, with no exception; labels up to U+FFFF are unaffected, the converter has no in-tree production caller (two test classes only) and no downstream use was found, ordinary scale, every release since 8.10.0 (executed).
Summary
You build an automaton with Lucene's standard code-point constructors, for example Automata.makeString("\uD801\uDC00") (U+10400) or new RegExp("a" + an emoji).toAutomaton(), and convert it with AutomatonToTokenStream.toTokenStream, whose javadoc says the stream releases the automaton's edges as tokens. You expect the token for the edge labelled U+10400 to be that character (the surrogate pair D801 DC00). Instead incrementToken appends (char) label, which keeps only the low 16 bits, so the token is U+0400 (a Cyrillic letter), U+1F600 becomes the private-use U+F600 and U+10000 becomes a NUL char, silently.
Introduced
647255b4d29bb56ddcfdf44bdb6e7d5d0ca76a14 (apache/lucene#157, LUCENE-9963 'Improve FlattenGraphFilter's robustness when handling incoming token graphs with holes', author @glawson0), which created AutomatonToTokenStream as test infrastructure for FlattenGraphFilter with the (char) cast and the one-token-per-label-value loop already in place; 8.x backport 75eba3892979 (apache/lucene-solr#2551, branch_8x) [read: the parent 647255b4d29^ has no such file; git blame on main a9779ef3c193 attributes line 143 and lines 99-101 to 647255b4d29; the six later commits on the file (#11813, #13368, #13406, #13422, #13328, #15704) are refactors that do not touch these lines; exec: 8.9.0 has no class, 8.10.0 has the defect]. Not a regression. (2021-08-09 (647255b4d29, author date 2021-08-10 05:06:53 +0900 = 2021-08-09T20:06:53Z, the merge time of PR #157); 8.x backport 2021-08-16 (75eba3892979, author date))
First affected version
8.10.0 [exec] (the first release with AutomatonToTokenStream, via the branch_8x backport; lucene-core 8.9.0 has no such class [exec: compile error]); 9.0.0 on the main line [exec]
Affected versions
Every release that has the class, all 40 executed with the defect present: 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0-9.12.3 (21 releases), 10.0.0-10.5.2 (12 releases) [exec: the follow-up's core-only main() probe on each finds 4 of 4 supplementary checks wrong (U+10400 -> U+0400, U+10000 -> U+0000, A U+10041 -> A A, RegExp a U+1F600 -> a U+F600) and the BMP control correct; the finder's JUnit fails 3 of 3 on 8.10.0, 8.11.4 and 9.0.0 (variant with the pre-9.1 test-framework package), 9.1.0, 9.12.3, 10.0.0 and 10.5.2; the independent reproduction's JUnit fails 4 of 5 on 10.5.2 and 9.12.3]; branch_10_6 head 4ae946e5a9b9 (10.6.0) [exec]; main a9779ef3c193 [exec, -ea and -da; re-run at the registration check]. Not fixed upstream: the cast is present on main head c5ab3e57b9b (2026-10-28), branch_10x 657014b3c579 and branch_10_6, and the newest commit on the file is 7335eb0ad96 (#15704, author date 2026-02-26, a refactor) [read]. Not affected: 8.9.0 and earlier (class absent) [exec 8.9.0]. All runs with JDK 25 on aarch64 Linux.
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 adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only, 142 recorded queries: 66 GitHub issue/PR, 6 commit-message, 4 downstream (Elasticsearch, OpenSearch, Solr), 40 mailing-list (dev@, java-user@, issues@, commits@), 9 JIRA-archive, 16 web, 1 git; plus the changed files of all 410 open apache/lucene PRs (37 updated in the 48 h before the search) and the commit history of the file by author date). Every hit that names the class is #157 (LUCENE-9963, which introduced it; its review never mentions label ranges, code points or surrogates), its backport apache/lucene-solr#2551, or a later refactor (#11813, #13328, #13368, #13406, #13422, #15704). Related, not this bug: #15900 (merged 2026-04-06) fixed the same family of UTF-16 versus code-point mistakes in TruncateTokenFilter, a different class; #16239 (open) changes GraphTokenStreamFiniteStrings, a different class and defect. Refresh (2026-10-28): GitHub issue/PR search 'AutomatonToTokenStream' returns only #157; the newest commit on the file is still 7335eb0ad96 (#15704, author date 2026-02-26); upstream main head is still c5ab3e57b9b; no open PR updated since 2026-10-26 is about the class (titles read; #16904, updated, changes only collation/package-info.java); one search call hit GitHub's search rate limit and was retried after a back-off. Limits: searches cannot prove absence, and GitHub search cannot list closed unmerged PRs by changed file.
Lucene commit tested
main 645d4fe14a20 (the standalone runner and gradle, assertions on and off); the release jars of all 41 releases from 8.10.0 to 10.6.0 (fail) and 8.9.0 (no such class); both fixes through gradle in a scratch worktree of 645d4fe14a20. Upstream main 2f424fd428da (2026-10-30) has the same file.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/analysis/AutomatonToTokenStream.java:35 (public class, no @lucene.internal), :40-47 (javadoc: 'releases edges from the automaton as tokens', only precondition a finite DAG), :97-101 (one EdgeToken per label value), :143 (charAttr.append((char) currentEdge.value))
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:104, :116, :137 (single 'codepoint' constructors), :638-639 (makeStringUnion 'codepoint based (full unicode codepoints on transitions)')
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:30-32 (labels are UTF-8 bytes or Unicode code points if unicodeArcs is true)
  • lucene/core/src/test/org/apache/lucene/analysis/TestAutomatonToTokenStream.java:34, :51, :68 (upstream tests build input with Automata.makeStringUnion)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/analysis/AutomatonToTokenStream.java:40-47, (at 645d4fe14a20) :97-101, :132-149, :174
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:104, (at 645d4fe14a20) :116, :137, :550-567 (codePointAt at :555), :638-639
Who is affected
  • Lucene main 645d4fe14a20 grep of lucene/ for AutomatonToTokenStream: lucene/core/src/test/org/apache/lucene/analysis/TestAutomatonToTokenStream.java, lucene/analysis/common/src/test/org/apache/lucene/analysis/core/TestFlattenGraphFilter.java
  • lucene/core/src/java/module-info.java:26 (at 645d4fe14a20) (exports org.apache.lucene.analysis)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/analysis/AutomatonToTokenStream.java:36, (at 645d4fe14a20) :40-47
  • lucene/core/src/java/org/apache/lucene/util/automaton/package-info.java:33, (at 645d4fe14a20) :44; Automaton.java:43, :148-175, :654, :789-803; Automata.java:44, :614-630, :638-639; Operations.java:1131, :1176
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/TrigramAutomaton.java:30 (at 645d4fe14a20) (package-private class), :55 (addTransition with s1.charAt(i) - minChar)
  • lucene/core/src/test/org/apache/lucene/analysis/TestAutomatonToTokenStream.java:34, (at 645d4fe14a20) :51, :68 (makeStringUnion), :80-130 (Automaton.Builder); lucene/core/src/test/org/apache/lucene/util/automaton/TestAutomaton.java:188-192
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:30-32, (at 645d4fe14a20) :184-202; lucene/core/src/java/module-info.java:26
Suggested fix
  • lucene/core/src/java/org/apache/lucene/analysis/AutomatonToTokenStream.java:149 (at 645d4fe14a20) (offsets); lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/CharTermAttributeImpl.java:57-66, :158-162; lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:116-141, :614-630; Automaton.java:148-175, :789-803

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

Repro files

About the diagrams

Made from the code at 645d4fe14a20 and the executed probe runs (main with assertions on and off, 10.6.0, and the surrogate-pair fix shadowed); the scene asserts every value it shows against the probe output before drawing it. Re-rendered on 2026-10-31 after the fact checks: two footnotes and the fix line now match this 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.