The converter writes each token's text with (char) label. A label above U+FFFF loses its high bits and becomes another character.
- A Lucene
Automatonlabels each edge with anint. The text constructors (Automata.makeString,makeStringUnion,RegExp) use Unicode code points, from 0 to 0x10FFFF. - 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. AutomatonToTokenStream.toTokenStreamturns an acyclic automaton into a token graph: one token per edge, whose text is the edge's label.
(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.- 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. - Convert it with
AutomatonToTokenStream.toTokenStream, which turns each edge into a token. You expect one token with the textπ. - 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. - 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. - 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.
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.
- Labels are code points.
Automata.makeStringstoress.codePointAt(i)as each edge's label (Automata.java:555);makeStringUnionis "codepoint based (full unicode codepoints on transitions)" (:638-639);makeAnyChar,makeCharandmakeCharRangespeak in code points (:104,:116,:137). - One token per label value.
toTokenStreamwalks the states in topological order and adds oneEdgeTokenper 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 anint(:174). - The cast.
TopoTokenStream.incrementTokenwrites the text withcharAttr.append((char) currentEdge.value)(:143); the shipped bytecode isi2cthenCharTermAttribute.append(char).(char) 0x10400is0x0400. - 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.
| Input automaton | Tokens expected | Main 645d4fe14a20 |
|---|---|---|
Automata.makeString(U+10400) (the repro) | U+10400 | U+0400 |
Automata.makeString(U+10000) | U+10000 | U+0000 (a NUL char) |
Automata.makeString(U+10FFFF) | U+10FFFF | U+FFFF |
new RegExp("a" + U+1F600).toAutomaton() | a, U+1F600 | a, U+F600 |
Automata.makeStringUnion({"A", U+10041}) | A, U+10041 | A, A |
Automata.makeString("a" + U+4E2D + U+FFFD) (control) | a, U+4E2D, U+FFFD | same |
Automata.makeBinaryStringUnion({"abc"}) (byte labels, control) | a, b, c | same |
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.toTokenStreamwhose 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) andTestFlattenGraphFilter(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.
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.
/*
* 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());
}
}
}
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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on every release from 8.10.0 to 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
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- 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:
makeStringstorescodePointAt;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.stepdocuments "@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 hunspellTrigramAutomatonlabels with a char minus an offset, so at most 0xFFFF,TrigramAutomaton.java:55), and a byte label is at most 255. The raw-intentry points (Automata.makeString(int[], int, int),makeChar,makeCharRange,Automaton.addTransition,Automaton.Builder.addTransition) take anyintwith no range check, but theAutomataones 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.internalor@lucene.experimentaltag and sits in a package lucene-core exports (module-info.java:26). Its input type is experimental:Automaton,Automataand 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
TestAutomatonToTokenStreamuseAutomata.makeStringUnion(:34,:51,:68), with ASCII strings only; the other three useAutomaton.Builderwith char-literal labels (:80-130). - Elsewhere Lucene gets it right.
Operations.getCommonPrefixappends each label withappendCodePoint(Operations.java:1176), andTestAutomaton.testCommonPrefixUnicodechecks that emoji survive (:188-192). The siblingTokenStreamToAutomatonreads code points withcodePointAtwhenunicodeArcsis 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.
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.
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
Stringper token, negligible for this utility. - A label that is not a code point (negative or above U+10FFFF) now throws
IllegalArgumentExceptioninstead of becoming a wrong char. String and regex constructors never make one; a caller can still pass such anintthrough the raw-intentry 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 theCharacter.toCharsvariant 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 upstreamTestAutomatonToTokenStream6/6).:lucene:analysis:common:test --tests ...TestFlattenGraphFilter, the only other in-tree caller: 25 tests, 0 failures.:lucene:core:check -x testwith the repro in the tree passes (google-java-format, ecjLint main and test, forbiddenApis, licenses, checksums).
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.
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.
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.
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
AutomatonToTokenStreamon 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, apache/lucene-solr) | 2026-10-31 | two scripted prior-report search runs plus 18 manual phrase searches, e.g. AutomatonToTokenStream, TopoTokenStream, "low 16 bits", "(char)" label automaton, U+10000 automaton | only #157 (introduced the class), its backport and refactors #11813, #13328, #13368, #13406, #13422, #15704 |
| Open PRs | 2026-10-31 | changed files of all 410 open PRs; titles and bodies of the 97 issues and PRs updated since 2026-10-27 | none touches AutomatonToTokenStream, its test, TokenStreamToAutomaton or FlattenGraphFilter |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-31 | the class and method names, symptom wording, a 14-day window | no message names the class on dev@ or java-user@; the issues@ and commits@ hits are PR mirrors |
| JIRA archive | 2026-10-31 | class and symptom queries | only LUCENE-9963, the introducing issue |
| Elasticsearch, OpenSearch, Solr; global GitHub code and web search | 2026-10-31 | issue and code search | no report; code hits are copies of Lucene's source and two ports |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | token U+0400 instead of U+10400, assertions on and off |
| 10.0.0-10.6.0 (13 releases) | affected | the same failure |
| 9.0.0-9.12.3 (21 releases) | affected | the same failure |
| 8.10.0, 8.10.1, 8.11.0-8.11.4 (7 releases) | affected | the same failure |
| 8.9.0 | no class | compile error: AutomatonToTokenStream does not exist |
fix (Character.toString) on 645d4fe14a20 | not affected | repro passes; 169 core analysis tests and 25 TestFlattenGraphFilter tests pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | real | Its 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 | real | Built 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 only | The 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, high | Re-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 downgraded | Reproduced 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 main | The 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, high | New 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 corrected | An 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 492 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,166 words
- 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 of645d4fe14a20. Upstream main2f424fd428da(2026-10-30) has the same file.
- 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, :174lucene/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.javalucene/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-47lucene/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, :1176lucene/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-192lucene/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
- TestAutomatonToTokenStreamSupplementaryLabel.java (one test, public API):
repro/LFV-869/src/org/apache/lucene/analysis/TestAutomatonToTokenStreamSupplementaryLabel.java(35 lines)
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.
assets/LFV-869/bug.png(492 KB)
