The number filters join consecutive numeral tokens into one number. A keyword marker should keep a token out of that, but the filters look at the flag only on the token that starts the number, never on the tokens they append to it.
- Build the analyzer of the upstream
testNametest:JapaneseTokenizer, thenSetKeywordMarkerFilterprotecting 京一 (a name that can also be read as the number 10000000000000001), thenJapaneseNumberFilter.田中京一gives田中,京一: the name is kept. - Analyze
社員の三京一. The tokenizer gives社員,の,三,京一. You expect社員,の,3,京一: 三 becomes 3 and the protected 京一 stays as it is. - You get
社員,の,30000000000000001(offsets 3-6). The filter checked the keyword flag on 三, which starts the number, then appended 京一 because it is made of numeral characters, and read 三京一 as 3 × 10^16 + 1. - Korean, same chain with 경일 protected: the name
오경일(tokens 오, 경일) gives50000000000000001instead of5,경일;이 구조는 튼튼하다("this structure is sturdy") with 구조 protected gives29000000000000, 는, ... - No error. A
TermQueryfor 京一 misses the document and delete-by-term leaves it live, while aTermQueryfor 30000000000000001 finds it: the terms and offsets are the same as with no keyword marker at all (the number even carries 京一's keyword flag).
Applications that add JapaneseNumberFilter or KoreanNumberFilter (opt-in; the stock analyzers do not use them) and protect a numeral-looking word with a keyword marker, as the javadoc suggests. When that word comes inside a number (typically right after a numeral), it is lost from the terms and a made-up number is indexed instead; ordinary Korean text does that (오경일, 이 구조는).
Mechanism, terms and numbers538 words
JapaneseNumberFilter and KoreanNumberFilter turn numbers written in kanji or hangul into digits by joining consecutive numeral tokens into one number. Their javadoc warns that they may also normalize words that only look like numbers, such as the name 京一, and says the filter "respects the KeywordAttribute, which can be used to prevent specific normalizations from happening". It checks the flag only on the token that starts a number: a protected word met while a number is being composed (typically right after a numeral token) is appended to the number. With 京一 protected, 社員の三京一 gives 30000000000000001 instead of 3, 京一; with 경일 protected, the Korean name 오경일 gives 50000000000000001. The terms and offsets are the same as without the marker, no error is raised, a term query or delete-by-term on the protected word misses the document, and a term query for the made-up number finds it. Both filters are opt-in; both have had the defect since they were added (Japanese 6.0.0, Korean 8.2.0), through 10.5.2 and main. The recommended fix: a keyword token ends the number, and the number keeps the attributes of its own tokens (which also fixes the known #10130).
- Number filter:
JapaneseNumberFilter(kuromoji) andKoreanNumberFilter(nori) rewrite numbers written with kanji or hangul numerals into Arabic digits:三千2百2十三gives3223(a javadoc example). A number may span several tokens; the filter reads ahead and joins them. They do not use context or part of speech, so words made of numeral characters are also rewritten: the name 京一 (京 = 10^16, 一 = 1) becomes10000000000000001, the noun 구조 ("structure"; 구 = 9, 조 = 10^12) becomes9000000000000. - Keyword marker:
SetKeywordMarkerFilter(or another filter that setsKeywordAttribute) flags chosen words so that later keyword-aware filters leave them alone. Both number filters declare themselves keyword-aware for exactly the case above.
For the tokens 三, 京一 with 京一 protected:
JapaneseNumberFilter.java:159-161:if (keywordAttr.isKeyword()) { return true; }runs once, on三, the token that would start a number. It is not a keyword, so a number starts (:177-179:numeralTerm = isNumeral(term)).- The loop (
:181) reads the next token,京一, which carrieskeyword=true(:189), and appends三to the buffer (:203). :205-207decide whether京一continues the number:numeralTerm = isNumeral(term) || isNumeralPunctuation(term);.京一is all numeral characters (isNumeral,:458-475), so it is appended too; a decimal point or thousands separator would continue the number the same way. The keyword flag is not read.:211-225:三京一is normalized to30000000000000001and emitted with the offsets of both tokens;京一is gone.
KoreanNumberFilter has the same code here: keyword check :148-150, predicate :196. The number is emitted with the attributes left by the last read, and only the term and offsets are overwritten (:220-222 / :209-211): the look-ahead token's when another token follows, the last token's at the end of the stream. In 社員の三京一 the stream ends at 京一, so stock emits 30000000000000001 with 京一's keyword=true; in 이 구조는 튼튼하다 the look-ahead is 는, so the number has no flag and 는's part of speech.
Exposure and likelihood317 words
- Who is exposed. Chains that use either number filter (SPI names
japaneseNumber,koreanNumber) with a keyword marker in front of it.JapaneseAnalyzerandKoreanAnalyzerdo not use the number filters. - What it takes. A protected word met while a number is being composed: right after a numeral token, or after a decimal point or thousands separator inside a number (a keyword-marked separator is absorbed the same way). Executed examples: the name
오경일(surname 오, given name 경일, split by the tokenizer without a user dictionary),이 구조는 튼튼하다and社員の三京一(a constructed phrase). The spaced Korean example needs the space token removed (by theSPstop filter of the upstream test chain, or bydiscardPunctuation=true); with punctuation kept and no stop filter the space ends the number. The marker matches whole tokens: protecting 千万 does nothing where the tokenizer emits 千 and 万. Protected words in other positions are unaffected. - What the user sees. Nothing fails: the index and the queries get the terms the filter produces with no marker.
TermQuery(body:京一)misses a document whose body is社員の三京一and finds one with田中京一;deleteDocuments(body:京一)leaves the first one live;TermQuery(body:30000000000000001)finds it, although with the protection applied it would hold3and京一. A search for the numeral (3) misses it too: the index holds no3. An analyzed query goes through the same chain, so query text with the same numeral before the word still matches (inference). Other fields, other values and other documents are unaffected;CheckIndexis clean. - How likely. Rare: it needs two opt-in pieces and a protected all-numeral word right after a numeral; nobody has reported it since 2015. Within that configuration it is deterministic and one document reproduces it.
- Downstream (read, not run). A downstream search server's documentation recommends the same nori chain as the upstream tests, and its keyword-marker filter can precede it.
The test shows that JapaneseNumberFilter folds a keyword-protected numeral word into the number when it directly follows a numeral token (三京一 with 京一 marked by SetKeywordMarkerFilter).
/*
* 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.ja;
import java.io.StringReader;
import java.util.Set;
import org.apache.lucene.analysis.CharArraySet;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.miscellaneous.SetKeywordMarkerFilter;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
public class TestJapaneseNumberFilterKeywordAfterNumeral extends BaseTokenStreamTestCase {
public void testKeywordTokenAfterNumeralIsKept() throws Exception {
Tokenizer tokenizer = new JapaneseTokenizer(null, false, JapaneseTokenizer.Mode.SEARCH);
tokenizer.setReader(new StringReader("三京一"));
CharArraySet keywords = new CharArraySet(Set.of("京一"), false);
// expected: 三 -> 3 and the protected 京一 unchanged; actual: one token 30000000000000001
assertTokenStreamContents(
new JapaneseNumberFilter(new SetKeywordMarkerFilter(tokenizer, keywords)),
new String[] {"3", "京一"});
}
}
# in a copy of this finding's repro folder (repro/LFV-360/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/analysis/kuromoji/build/libs/lucene-analysis-kuromoji-$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.ja.TestJapaneseNumberFilterKeywordAfterNumeral
# the module(s) analysis-kuromoji also need their third-party runtime jars
- Expected vs actual
- Expected the tokens
[3, 京一]; actual the single token30000000000000001(term 0 expected:<3[]> but was:<3[0000000000000001]>), with-eaand-da.
More files, environment, full output38 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> org.junit.ComparisonFailure: term 0 expected:<3[]> but was:<3[0000000000000001]>
org.junit.Assert.assertEquals(Assert.java:117)
org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:262)
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.ja.TestJapaneseNumberFilterKeywordAfterNumeral.testKeywordTokenAfterNumeralIsKept(TestJapaneseNumberFilterKeywordAfterNumeral.java:33)- Both javadocs: the filter "respects the KeywordAttribute, which can be used to prevent specific normalizations from happening", with this very example (京一 / 경일).
- Lucene's analysis docs: keyword-aware filters "skip modification" of keyword tokens.
- Read per token, as the analysis docs describe keyword handling, stock is inconsistent: a protected 천만 survives in
천만 원but not in삼천만 명.
The full argument220 words
- Class javadoc (
JapaneseNumberFilter.java:69-72,KoreanNumberFilter.java:69-72): "This filter may in some cases normalize tokens that are not numbers in their context. For example, is 田中京一 is a name and means Tanaka Kyōichi, but 京一 (Kyōichi) out of context can strictly speaking also represent the number 10000000000000001. This filter respects the KeywordAttribute, which can be used to prevent specific normalizations from happening." - Core analysis docs (
lucene/core/.../analysis/package-info.java:487-490): "Keyword-aware TokenStreams/-Filters skip modification of tokens that return true from this attribute's isKeyword() method." - The documented limits do not include it. The javadoc lists
15,7 becomes 157 (be aware of this weakness)(:60), the punctuation requirement (:40-47), stacked tokens left untouched (:63-64) and attributes taken from the last composing token (:74-82), but not "keyword protection only for the first token of a number". - Tests and history.
testName(TestJapaneseNumberFilter.java:161-195,TestKoreanNumberFilter.java:178-219) protects 京一 / 경일 only after a non-numeral word. The keyword check and the predicate are unchanged since each filter's first commit (LUCENE-3922, LUCENE-8812); neither issue, no CHANGES entry and no comment limits the protection to the first token. - Inconsistent stock behaviour, read per token. With 천만 protected,
천만 원keeps천만, while삼천만 명gives30000000. (Read per run of numeral tokens, the devil's-advocate reading below, this is consistent.)
The devil's-advocate check225 words
The strongest "intended" reading, built by the intended-behaviour checker and rated moderate by the cross-model reviewers: KeywordAttribute only says keyword-aware streams "can decide to modify a token based on the return value of isKeyword()" (KeywordAttribute.java:22-27); the filter composes logical numbers, so the whole run started by an unprotected token could be read as the unit of normalization, and keeping a standalone protected 천만 while composing 삼 + 천만 is then consistent first-token behaviour; and the literal reading costs something: with 천만 protected, stock turns 삼천만 명 into the right amount 30000000, the fix into 3, 천만.
Why it fails: once the filter declares that it respects the attribute, the package contract says keyword-aware filters skip modification of keyword tokens, which is a per-token rule; the "numeric context" or run reading is in no javadoc, comment, test, commit or issue, and the filter "does not use any part-of-speech information" (JapaneseNumberFilter.java:66-67); read per token, stock keeps the same protected word in one position and drops it in another; and the 천만 cost is about the fix direction, not evidence of intent. What the case gets right: a fix changes long-standing output for some real numbers, so documenting the limit is a fair alternative to offer; and it does not settle which attributes the split-off number should carry (see Suggested fix).
Recommended A keyword token ends the number, and the number keeps its own tokens' attributes (A+C)
A keyword token ends the number (!keywordAttr.isKeyword() && in the continuation check), and the number keeps the attributes of its own tokens: restore the last numeral token's state and the first one's position increment before emitting it (the #10130 fix). The new tests and every non-skipped kuromoji and nori test pass. Cost: with 천만 protected, 삼천만 now gives 3, 천만.
State lastNumeralState = null;
final int firstPositionIncrement = posIncrAttr.getPositionIncrement();
...
lastNumeralState = captureState();
moreTokens = input.incrementToken();
...
numeralTerm = !keywordAttr.isKeyword() && (isNumeral(term) || isNumeralPunctuation(term));
...
restoreState(lastNumeralState);
posIncrAttr.setPositionIncrement(firstPositionIncrement);
...
keywordAttr.setKeyword(false); // from fix A; redundant once the state is restoredAll options and trade-offs5 options · 1,069 words
A+C, A and B were run through gradle in a scratch copy at a9779ef3c193 (A+C and A: both modules' check; B: the new tests); D and E are design alternatives that were not run. C on its own is the fix for the already reported #10130.
A keyword token ends the number, and the number keeps its own tokens' attributes (A+C) (validated by the checks) (recommended)
fixAC.diff, +12/-1 in each filter: fix A (below) plus the #10130 fix of the sibling candidate. It remembers the state of the last token appended to the number and the first token's position increment, and restores them before the term and offsets are set, so the split-off number carries its own part of speech, reading and position increment instead of the protected token's.
State lastNumeralState = null;
final int firstPositionIncrement = posIncrAttr.getPositionIncrement();
...
lastNumeralState = captureState();
moreTokens = input.incrementToken();
...
numeralTerm = !keywordAttr.isKeyword() && (isNumeral(term) || isNumeralPunctuation(term));
...
restoreState(lastNumeralState);
posIncrAttr.setPositionIncrement(firstPositionIncrement);
...
keywordAttr.setKeyword(false); // from fix A; redundant once the state is restoredTrade-offs
- Intended change: as for A: a protected word right after a numeral stays a word, and the numeral before it is normalized alone; the 천만 cost below applies too.
- C's own change: every composed number that is followed by another token now carries the part of speech, reading and other attributes of its own last token, and its first token's position increment, instead of the next token's. With no marker, stock gives the number in
이 구조는 튼튼하다the part of speech of 는 (JX), A+C that of 구조 (NNG). That is the separate, already reported #10130; maintainers may prefer it in its own PR. - Index-time output changes, so existing indexes keep the old terms until reindexed.
What the checks validated
- Gradle, scratch copy at
a9779ef3c193with the doc's tests added, JDK 25::lucene:analysis:kuromoji:checkand:lucene:analysis:nori:checkpass (google-java-format, ecjLint, forbiddenApis and the tests: kuromoji 31 suites, 197 tests; nori 14 suites, 88 tests; 0 failed, 11 skipped by their own annotations; the doc's 3 tests among them). - Standalone harness with the fix: the doc's tests 3/3 with
-eaand-da. - Token dumps:
이 구조는 튼튼하다with 구조 protected gives2(+1) and구조(+2, keyword), the input's increments;社員の三京一gives3with 名詞-数 / サン and京一(keyword). - Not run for A+C: the 10-iteration and assertions-off gradle runs and the finder's and reproducer's suites that A had.
A keyword token ends the number, and the number does not take its flag (A, smaller) (validated by the checks)
fixA.diff, +4/-1 in each filter (two code lines and two comments). The first line stops the composition at a keyword token, which is then emitted unchanged on the next call (it is already the captured look-ahead token). The second line is safe because a number starts only at a non-keyword token (:159), and with the first line every continuation token is non-keyword too, so a composed number is never a keyword.
if (moreTokens) {
term = termAttr.toString();
// a keyword token is not part of the number: it ends the number and is emitted unchanged
numeralTerm = !keywordAttr.isKeyword() && (isNumeral(term) || isNumeralPunctuation(term));
}
...
offsetAttr.setOffset(startOffset, endOffset);
// the number is made of non-keyword tokens only; do not keep the look-ahead token's flag
keywordAttr.setKeyword(false);Trade-offs
- Not fixed by A: the split-off number keeps the protected token's other attributes (the #10130 mechanism; A overwrites only term, offsets and the flag), among them its position increment, part of speech and reading:
이 구조는 튼튼하다gives2with position increment 2 instead of 1 (increments 2, 2 instead of 1, 2, so the positions after it shift by one), and社員の三京一gives3with 京一's part of speech and reading キョウイチ. The doc's tests pass with A because they do not assert these attributes. - Intended change: a protected word right after a numeral stays a word, and the numeral before it is normalized alone (
오경일gives5,경일;이 구조는gives2,구조, ...). - Cost: a protected word that is also a real part of a number splits that number: with 천만 protected,
삼천만 명gives3,천만instead of30000000, and일천만 원gives1,천만. The user asked for 천만 not to be normalized, and stock already keeps it in천만 원. - Index-time output changes, so existing indexes keep the old terms until reindexed.
- One extra boolean check per continuation token.
What the checks validated
- Gradle, scratch worktree at
a9779ef3c193, JDK 25: the 3 new tests fail on stock and pass with the fix. :lucene:analysis:kuromoji:checkand:lucene:analysis:nori:checkpass (google-java-format, ecjLint, forbiddenApis and the tests: kuromoji 31 suites, 197 tests; nori 14 suites, 88 tests; 0 failed, 11 skipped by their own annotations).- The number-filter suites also pass with
-Ptests.asserts=false(27 + 24 tests) and with-Ptests.iters=10(270 + 240 tests). - Standalone harness with the fix: the doc's tests 3/3 (
-eaand-da), the finder's 2/2, the independent reproducer's 8/8.
The first line of fix A, without clearing the flag.
numeralTerm = !keywordAttr.isKeyword() && (isNumeral(term) || isNumeralPunctuation(term));Trade-offs
Terms and offsets are right, but the number before the protected word inherits keyword=true from it, and its part of speech and position increment (the look-ahead state, the mechanism of the known #10130), so a later keyword-aware filter would skip the number. Fine only together with a #10130 fix, which is A+C.
What the checks validated
The finder's 2/2 and the reproducer's 8/8 pass, and the upstream TestJapaneseNumberFilter and TestKoreanNumberFilter pass 800 of 800 (20 iterations, in the hunt); the doc's tests fail 3 of 3 on the flag: keywordAtt 2 term=3 expected:<false> but was:<true> (gradle). The finder's model check with this change finds 0 violations of its keyword invariant in 531,440 streams per filter; that invariant checks terms and offsets, not the flag.
Say in the javadoc that KeywordAttribute is honoured only on a token that starts a number.
Trade-offs
No output change, but the javadoc's own use case, protecting a name, stays broken after a numeral (오경일). Not run (no code).
三京一 would stay 三, 京一.
Trade-offs
Needs buffering of the run and changes more output; mentioned by the reproducer as the other possible semantics. Not run.
Downstream, a rule that term-matches a protected word on such a field (an exclusion, an alert, a delete-by-term) silently skips documents where the word follows a numeral, and a term match on the made-up number hits them.
Bounded-exhaustive model checking on the real classes: all 531,440 abstract token streams of length 1 to 6 over 9 symbols (none starting with a stacked synonym) went through both stock filters against six invariants. "A keyword-marked token is emitted with its own term and offsets" failed on 103,089 per filter; the shortest is a numeral, then a keyword-marked numeral.
The method in full265 words
Method: bounded-exhaustive model checking of the real classes (a hunt unit, CJK number filters, 2026-10-26, main ea57360912ed; the hunt's "semi-formal" tier: exhaustive checking of the real jar against an exact oracle, no model checker or prover).
- The finder, an AI tool, wrote a checker (
TokenStreamModelCheck.java) that builds every token stream of length 1 to 6 over 9 abstract token kinds (a digit, an exponent, a decimal point, a word, a stacked synonym, a keyword-marked numeral, a numeral after a removed token, and two decompounded forms) that does not start with the stacked synonym (531,440 streams), and runs it as a canned stream through the stockJapaneseNumberFilterandKoreanNumberFilter. - Six invariants, each tied in the unit's model notes to a javadoc statement: no input token vanishes, a number holds no text from outside its span, its attributes come from inside its span, positions, position length 1, and KW: a keyword-marked token is emitted with its own term and offsets (from the class javadoc's keyword sentence). KW failed on 103,089 of 531,440 streams per filter, shortest
dk(a digit, then a keyword-marked numeral); with the one-line predicate change, KW fails on 0. - The other invariant failures belong to two already reported defects found in the same unit (#10130; #11398 / #11397 / #11395); each of the three fixes clears exactly its own invariants, and all three together satisfy all six.
- The finder reduced the KW counterexample to the upstream
testNameanalyzer and wrote the JUnit pair; the reproducer later found the natural Korean name 오경일, and the devil's advocate이 구조는 튼튼하다.
No prior report found: 267 recorded queries over GitHub, JIRA, the Lucene and Solr lists, downstream projects and the web (English, Japanese, Korean), plus the changed files of all 411 open PRs (2026-10-26). Closest: #10130, a different defect in the same loop.
Searches7 searches
None of the searched issues, PRs, commits, JIRA entries, list threads or downstream reports describes a keyword-marked token being folded into a number by either filter, or the keyword check covering only the first token of a number. Word-based searches show what was found, not that no report exists anywhere.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, apache/lucenenet, downstream servers, all of GitHub) | 2026-10-26 | a scripted prior-report search (3 query specs: class and method names, KeywordAttribute, symptom phrases, configuration names), 61 GraphQL searches, 6 REST look-ups (every issue and PR naming either class, LUCENE-3922) | no report of this defect |
| Related items (read in full) | 2026-10-26 | 34 hits judged | #10130 (LUCENE-9088, open) and its unmerged fix apache/lucene-solr#1073: the number takes the next token's attributes, a different defect in the same loop, and the PR adds no keyword check; #11398 / #11397 / #11395: offsets with stacked tokens; #10002 (LUCENE-8959): whitespace concatenation, concluded correct; #4995 and #9856: the introducing issues, no keyword discussion |
| JIRA archive (LUCENE, SOLR) | 2026-10-26 | 7 REST searches: both class names, both factories, kanji numerals, keyword combinations | only issues mirrored on GitHub, all read |
| Mailing lists | 2026-10-26 | 30 lists.apache.org searches (dev@ and java-user@ Lucene, solr-user@, users@solr, dev@solr) plus the tool's queries of dev@, java-user@, issues@ and commits@ | no report |
| Web (English, Japanese, Korean) | 2026-10-26 | 12 web searches plus the tool's web queries; Japanese and Korean forums only sampled | no relevant page |
| Open PRs | 2026-10-26 | the changed files of all 411 open apache/lucene PRs | none touches either filter or its test |
| GitHub issue search (spot check) | 2026-10-27 | "JapaneseNumberFilter" in apache/lucene (further queries were rate-limited) | the same items, nothing new |
Present since each filter was added: Japanese in 6.0.0 (LUCENE-3922, 2015), Korean in 8.2.0 (LUCENE-8812, 2019, added as similar to the Japanese one). It fails on every release run from 6.0.0 and 8.2.0 to 10.5.2 and on main; not fixed upstream.
Versions tested7 rows
Introduced by 4b93c66c252b "Added JapaneseNumberFilter (LUCENE-3922)" (@cmoen, 2015-02-03, trunk; never backported to 5.x, so first released in 6.0.0) and 5a75b8a0808e "LUCENE-8812: Add new KoreanNumberFilter ..." (@danmuzi, 2019-06-09; branch_8x 36b90c605693 the same day; first released in 8.2.0). Both were committed from JIRA patches (now #4995 and #9856), so there is no GitHub PR. git blame on main gives these commits for the keyword check and the continuation predicate. Not a regression.
Every tagged release with the filter in the history clone (83 Japanese, 48 Korean) has the single isKeyword() call and the unchanged predicate; 8.8.2 to 8.11.3 are not tagged there and were not run (8.8.1 and 8.11.4 on either side are affected). Before 9.1.0 the Korean factory is not in the nori SPI file (added by LUCENE-10352), so the filter is reached there by construction or class name. Not fixed upstream (read 2026-10-27): main (head a95c754ee003) has both files byte-identical to a9779ef3c193, last touched by ad24abfb0df0 (#15109, 2025-08-22, cosmetic); branch_10x has the same keyword check and predicate at the same lines and differs only in the catch clause of #15109, which is not on branch_10x (its last change to either file is 1d55d2383ce4, #14228, 2025-02-24).
| Version or commit | Verdict | Result |
|---|---|---|
| 5.5.5 (Japanese), 8.1.1 (Korean) | no filter | the class does not exist (the probe does not compile) |
| 6.0.0 | first Japanese release | both triggers fail, all 4 controls pass, with -ea and -da |
| 6.6.6, 7.0.0, 7.7.3, 8.0.0 | affected | Japanese probe: the same output |
| 8.2.0 | first Korean release | both triggers fail, all 4 controls pass, with -ea and -da |
| 8.11.4, 9.0.0, 10.0.0 | affected | both probes: the same output |
| 9.12.3, 10.5.2 | affected | both probes, the finder's JUnit and the doc's 3 tests fail |
| main ea57360912ed, a9779ef3c193 | affected | every repro run fails, with -ea and -da; with the recommended fix (a9779ef3c193) the doc's tests pass, and with fix A the doc's, the finder's and the reproducer's tests pass |
An independent reproducer, a devil's advocate and an adjudication said real; reviews by OpenAI GPT-5.6 (reading only) agreed on the root cause, with "not real" and "uncertain" only because they could not run code. This doc's tests and the recommended fix were run through gradle on main.
Checks and verdicts410 words
The hunt checks ran on main ea57360912ed; this doc's runs on a9779ef3c193, where both filters are unchanged. The GPT-5.6 reviews read code only.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Model check: KW fails on 103,089 of 531,440 streams per filter, 0 with the fix. The JUnit pair fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the fix. |
| Cross-model reviews (OpenAI GPT-5.6, reading only) | refute: not real, low (procedural); intended: real, medium | Root cause agreed, inputs in contract, intended argument moderate; it correctly flagged an overstatement about the other invariants under the fix, which was corrected. A re-run of the repro failed (2 of 2). |
| Independent reproducer | real | Its own 8-test suite (canned and real-tokenizer cases, 4 controls) fails its 4 bug tests on main (-ea, -da), 10.5.2 and 9.12.3; found the name 오경일; its own fix passes 8 of 8. |
| Root-cause review (OpenAI GPT-5.6, reading only) | uncertain, low (process only); root cause agrees | Separate runs fail 2 of 2 on main and 10.5.2 and pass with the fix; upstream number-filter tests 40 of 40 with the fix. |
| Devil's advocate | real | Built the strongest intended readings and showed why each fails; found 이 구조는 튼튼하다 and the 천만 trade-off of the fix. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's suite (8 run, 4 failed, -ea and -da), the finder's repro (2 of 2) and the independent reproduction with the fix (8 of 8). |
| Audit and main refresh | keep | Re-ran the finder's repro (2 of 2, -ea and -da); on a9779ef3c193 the main refresh found it still reproduces, messages identical. |
| This doc (2026-10-27) | reproduced; fix validated | 3 new tests with a keyword-flag assertion fail on main (gradle and harness, -ea and -da), 10.5.2 and 9.12.3; the recommended fix A+C and the smaller fix A pass them and both modules' check tasks. |
| Fact checks and resolution (2026-10-27) | text errors fixed; one fix gap found and closed | All runs reproduced. Fixed: the branch_10x line, the model-check stream count, the keyword-flag mechanism, the scope of the trigger and of "same as no marker", and the missing false positive on the made-up number. The cross-model check found that fix A leaves the split-off number with the protected token's position increment and part of speech; a re-run confirmed it, and the recommendation is now A+C, validated through both modules' check. |
Finding record, Lucene and upstream sources and repro files1,496 words
- Registry title
- JapaneseNumberFilter and KoreanNumberFilter honour KeywordAttribute only on the token that starts a number, so a keyword-protected numeral word directly after a numeral token is merged into the number and normalized (三 + protected 京一 gives 30000000000000001, the name 오경일 with 경일 protected gives 50000000000000001) (released since 6.0.0 / 8.2.0)
- Area
- analysis / kuromoji and nori number filters (JapaneseNumberFilter, KoreanNumberFilter: KeywordAttribute inside a number composition)
- Priority
- Low, rank 306: Needs the opt-in Japanese or Korean number filter with a keyword marker that protects a numeral word.
- Impact class
- silent wrong results
- Impact note
- Applications that add JapaneseNumberFilter or KoreanNumberFilter (opt-in: neither JapaneseAnalyzer nor KoreanAnalyzer uses them) and, as the class javadoc suggests, a keyword marker in front of it to protect numeral-looking words (names such as 京一 or 경일, words such as 구조): whenever a protected word directly follows a numeral token (三京一, the name 오경일, or 이 구조 in the documented nori chain that removes the space), it is silently folded into the number, so the index and query terms are what the filter gives with no marker at all, and term queries or delete-by-term on the protected word miss that document (executed); protected words in other positions are unaffected; deterministic, ordinary scale, every release of each filter (ja 6.0.0, ko 8.2.0, through 10.5.2) and main.
- Summary
- You put SetKeywordMarkerFilter in front of JapaneseNumberFilter (or KoreanNumberFilter) to protect a word made of numeral characters, as the class javadoc suggests for the name 京一, and expect that word to come out unchanged wherever it occurs. That works when the word starts a number (田中京一 gives 田中, 京一, the upstream testName), but the filter checks the keyword flag only on the first token of a number and accepts every following numeral token without looking at it, so a protected word right after a numeral is appended to the number: 社員の三京一 gives the single token 30000000000000001 (offsets 3-6) instead of 3 and 京一, and the Korean name 오경일 (tokens 오 / 경일, 경일 protected) gives 50000000000000001. No error is raised; a TermQuery for the protected word then misses the document (executed).
- Introduced
- 4b93c66c252b (JapaneseNumberFilter: 'Added JapaneseNumberFilter (LUCENE-3922)', by GitHub cmoen, trunk svn r1656670, never backported to branch_5x) and 5a75b8a0808e (KoreanNumberFilter: 'LUCENE-8812: Add new KoreanNumberFilter that can change Hangul character to number ...', by GitHub danmuzi, master; branch_8x backport 36b90c605693 the same day, a port of the Japanese filter); both were committed from JIRA patches, no GitHub PR (the issues are now #4995 and #9856). Each commit adds the filter with the defect already in it: the isKeyword() check runs once, before the composition loop, and the continuation predicate 'numeralTerm = isNumeral(term) || isNumeralPunctuation(term)' never looks at the keyword flag [read: git blame on main attributes both lines to these commits, git log -S isKeyword on both files returns only them; exec: 5.5.5 and 8.1.1 do not have the classes (compile error), 6.0.0 and 8.2.0 fail] (2015-02-03 (JapaneseNumberFilter); 2019-06-09 (KoreanNumberFilter))
- First affected version
- 6.0.0 [exec] for JapaneseNumberFilter (the trunk commit's 5.1.0 CHANGES entry was removed, no 5.x tag has the class; 5.5.5 executed compile_error); 8.2.0 [exec] for KoreanNumberFilter (8.1.1 executed compile_error)
- Affected versions
- every release of each filter: JapaneseNumberFilter 6.0.0 through 10.5.2, KoreanNumberFilter 8.2.0 through 10.5.2, plus main ea57360912ed (not fixed). Executed with the follow-up's public-API probes (2 triggers fail, 4 controls pass on every version): ja 6.0.0 (-ea and -da), 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); ko 8.2.0 (-ea and -da), 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) [exec]; the finder JUnit fails on main, 10.5.2 and 9.12.3 (each -ea and -da) and 10.0.0, the independent reproduction's suite on main (-ea, -da), 10.5.2 and 9.12.3 [exec]; the single isKeyword() call and the unchanged continuation predicate are in all 83 tagged releases with JapaneseNumberFilter and all 48 with KoreanNumberFilter in the history clone (which lacks the 8.8.2-8.11.x tags; 8.11.4 was executed) [read]. Upstream main e1c4184cb983 (2026-10-26) and branch_10x have byte-identical sources [read]. Before 9.1.0 KoreanNumberFilterFactory is not in the nori SPI file (added by LUCENE-10352), so the Korean filter is reached there by construction or class name; the affected range is unchanged
- 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, the prior-report record verdict new, 267 recorded queries: scripted prior-report search spec A (67: class and method names, KeywordAttribute and symptom phrases over GitHub, commits, downstream, four lists, JIRA and web), spec B (31: kuromoji_number, nori_number, keyword_marker, SetKeywordMarkerFilter over lists and JIRA; its GitHub pass was stopped after 7 queries for throttling) and the finder's spec-c3 run (51 queries, 1183 hits); 61 read-only GitHub GraphQL searches (apache/lucene, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, apache/lucenenet and all of GitHub); 6 GitHub REST look-ups (every issue and PR naming either class, LUCENE-3922, commits and contents of both files on main and branch_10x); 7 JIRA REST searches (LUCENE and SOLR); 30 lists.apache.org searches (dev@ and java-user@ lucene, solr-user@, users@solr, dev@solr); 12 web searches (English, Japanese, Korean); the changed-file lists of all 411 open apache/lucene PRs; the local full history; 34 hits read, all same_bug false). Closest items, none this bug: #10130 (LUCENE-9088, open) and its unmerged fix apache/lucene-solr#1073: the composed number takes the next token's attributes, a different defect in the same loop, and the PR adds no keyword check; #11398 / #11397 / #11395 (LUCENE-10362 / 10361 / 10359, open): offsets with stacked tokens, 'keyword=false' only in attribute dumps; #10002 (LUCENE-8959, open): concatenation across removed whitespace, which its reporter concluded is correct with the right chain; #4995 (LUCENE-3922) and #9856 (LUCENE-8812): the introducing issues, no keyword discussion. Not fixed upstream: upstream main e1c4184cb983 (2026-10-26) and branch_10x have byte-identical sources, and no open PR touches either file
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27): the doc's 3 tests through gradle (seed DEADBEEF; also with assertions off and another seed) and through a standalone harness with assertions on and off, and the 10.5.2 and 9.12.3 release jars; the recommended fix (A+C) and the smaller fix A in a scratch copy at the same commit (both modules'check; for A also the number-filter suites with assertions off and with 10 iterations); token dumps of position increments and parts of speech for stock, A, A+C and the one-line variant. The hunt, its reviews and the per-version probes (11 Japanese targets from 6.0.0, 7 Korean from 8.2.0) ran on mainea57360912ed, where both filter files are identical. OpenJDK 25.0.3, Linux aarch64.
- Finding record
lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseNumberFilter.java:69-72 (javadoc: may normalize tokens that are not numbers in their context; 'This filter respects the KeywordAttribute, which can be used to prevent specific normalizations from happening', example 京一), :159-161 (isKeyword() checked only on the token that starts a composition), :179 (first numeralTerm), :189 (next token read inside the loop), :203 (numeral.append), :207 (continuation predicate without a keyword check), :252 (normalizeNumber error handling)lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanNumberFilter.java:69-72 (same javadoc, example 경일), :74 (@lucene.experimental), :148-150, :168, :196, :241 (same code)lucene/core/src/java/org/apache/lucene/analysis/package-info.java:487-490 ('Keyword-aware TokenStreams/-Filters skip modification of tokens that return true from this attribute's isKeyword() method'); lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/KeywordAttribute.java:22-27 ('Keyword aware TokenStreams can decide to modify a token based on the return value of isKeyword()')lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseNumberFilter.java:161-195 and lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanNumberFilter.java:178 (testName: keyword token after a non-numeral token only); JapaneseNumberFilterFactory.java:44 and KoreanNumberFilterFactory.java:44 (SPI names japaneseNumber, koreanNumber); JapaneseAnalyzer.java and KoreanAnalyzer.java do not use the number filters (grep)
- The finding in 30 seconds
a Lucene main checkout (a9779ef3c193) lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseNumberFilter.java:154-225 (incrementToken: keyword check :159-161, loop :181-209, predicate :207, emit :211-225), :458-475 (isNumeral), :126-133 (exponents)a Lucene main checkout (a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanNumberFilter.java:143-216 (same code: keyword check :148-150, predicate :196, emit :200-215), :447-463 (isNumeral), :114-122 (exponents)a Lucene main checkout (a9779ef3c193) lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseNumberFilter.java:53-60 (javadoc examples, 三千2百2十三 becomes 3223)
- Who is affected
a Lucene main checkout (a9779ef3c193) lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseNumberFilterFactory.java:44, lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanNumberFilterFactory.java:44 (SPI names); JapaneseAnalyzer.java and KoreanAnalyzer.java do not reference the number filters (grep)
- Why it looks unintended
a Lucene main checkout (a9779ef3c193) lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseNumberFilter.java:40-47, :60, :63-64, :66-67, :69-72, :74-82 (class javadoc)a Lucene main checkout (a9779ef3c193) lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/KoreanNumberFilter.java:69-72, :74 (javadoc, @lucene.experimental)a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/analysis/package-info.java:487-490; lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/KeywordAttribute.java:22-27a Lucene main checkout (a9779ef3c193) lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseNumberFilter.java:161-195; lucene/analysis/nori/src/test/org/apache/lucene/analysis/ko/TestKoreanNumberFilter.java:178-219
- Prior reports
gh search issues --repo apache/lucene JapaneseNumberFilter (2026-10-27, read-only: #11398, #10130, #10002, #7278, #9856 and unrelated items)
- History
gh api repos/apache/lucene/contents/<each filter>?ref=main|branch_10x and commits?path=<each filter>&sha=main|branch_10x (2026-10-27, read-only: main blobs a1188e90d95 / 4348dad50ec, last commit ad24abfb0df0; branch_10x blobs e5f19b84552 / 4f031012da5, last commit 1d55d2383ce4; the only difference is the catch clause at :252 / :241)
- TestJapaneseNumberFilterKeywordAfterNumeral.java (JUnit test):
repro/LFV-360/src/org/apache/lucene/analysis/ja/TestJapaneseNumberFilterKeywordAfterNumeral.java(37 lines)