Lucene's query parsers turn prefix, wildcard, fuzzy, regexp and range terms into index terms with Analyzer.normalize. A CustomAnalyzer builds that step from each filter factory's normalize(), and the two small-kana factories never implemented it.
- At index time the analyzer runs its whole chain on each token.
japaneseKatakanaUppercasemaps the 28 small katakana (ャ,ッ,ォ, ...) to full-size ones;japaneseHiraganaUppercasemaps the 12 small hiragana (ょ,っ, ...). - A query like
キャ*or[キャット TO キャット]holds a partial word or a bound that must not be tokenized. So the parser callsAnalyzer.normalize, which should apply only the character-level normalizing filters. - In a
CustomAnalyzer, a filter takes part innormalizeonly if its factory overridesTokenFilterFactory.normalize(), usually asreturn create(input);, ascjkWidthandlowercasedo. The default returns the input unchanged.
キャット: indexing runs japaneseKatakanaUppercase through create() and stores キヤツト, but query-time normalize() skips the filter, so キャ* stays キャ* and finds 0 documents. Select the still to open it at full size. Generated with AI, checked against the code.- Build a
CustomAnalyzer:whitespacetokenizer, thenjapaneseKatakanaUppercase. Index the wordキャット("cat"): the filter turns the smallャandッinto full-sizeヤandツ, so the index holdsキヤツト. - Search with the classic
QueryParserforキャ*or[キャット TO キャット]. For these the parser does not run the full analyzer; it callsAnalyzer.normalize, which should return the indexed form (キヤ,キヤツト). - You expect 1 hit for each, as with the
cjkWidthfilter, whose prefix and range queries do find their document. - Instead
normalizereturns the text unchanged, so both queries find 0 documents, with no error; the plain term queryキャットstill finds it (1 hit). The hiragana filter missesちょっとthe same way (ちょ*: 0 hits). - Why: both factories implement only
create()and inherit the do-nothing defaultnormalize(), andCustomAnalyzer.normalizeruns only each factory'snormalize().
Applications whose CustomAnalyzer or factory chain opts in to japaneseKatakanaUppercase or japaneseHiraganaUppercase and accepts prefix, wildcard, fuzzy, regexp or range syntax through Lucene's query parsers. Small kana are everyday Japanese, so once opted in, prefix and wildcard queries typed with one miss silently, and fuzzy, regexp and range queries can return wrong results. JapaneseAnalyzer and plain term queries are unaffected.
Mechanism, terms and numbers622 words
The kuromoji token filters japaneseKatakanaUppercase and japaneseHiraganaUppercase (added in 9.11.0) map the 28 small katakana and 12 small hiragana (sutegana, 捨て仮名) to full-size kana, so a document キャット is indexed as キヤツト. Lucene's query parsers build prefix, wildcard, fuzzy, regexp and range queries from Analyzer.normalize, which should return "the representation that it would have in the index". A CustomAnalyzer builds normalize from each factory's normalize(), and these two factories override only create(), so they inherit the identity default and normalize keeps the small kana. The term query キャット finds the document, but キャ* and [キャット TO キャット] find nothing, with no error. It has been there since the filters were added in 9.11.0 (executed on five releases through 10.6.0 and on main; the factory files are byte-identical in all 19 release tags); a small normalize() override (+5 lines) in each factory, as CJKWidthFilterFactory has, fixes it.
- At index time
CustomAnalyzer.createComponentschains each factory'screate()(CustomAnalyzer.java:161-167on main645d4fe14a20).JapaneseKatakanaUppercaseFilterrewrites each small katakana through a 28-entry map and the pairㇷ゚(U+31F7 U+309A) toプ(JapaneseKatakanaUppercaseFilter.java:43-70,:86-97);JapaneseHiraganaUppercaseFiltermaps 12 small hiragana one to one (JapaneseHiraganaUppercaseFilter.java:43-54,:69-74). Both read only the term text. - The classic
QueryParserBasesends range bounds (:550,:556), wildcard chunks (:646,:656), the whole regexp (:683), prefixes (:712) and fuzzy terms (:729) throughAnalyzer.normalize; the flexible parser's processors (TermRangeQueryNodeProcessor.java:138-139,FuzzyQueryNodeProcessor.java:59,WildcardQueryNodeProcessor.java:66,:76,RegexpQueryNodeProcessor.java:43) andSimpleQueryParser(fuzzy:573, prefix:605) do the same. CustomAnalyzer.normalizecallsnormalize()on each of its token filter factories (CustomAnalyzer.java:170-177).- Both sutegana factories override only
create()(JapaneseKatakanaUppercaseFilterFactory.java:45-48,JapaneseHiraganaUppercaseFilterFactory.java:45-48), soTokenFilterFactory.normalizeruns: "Normalize the specified input TokenStream While the default implementation returns input unchanged, filters that should be applied at normalization time can delegate to {@code create} method." (TokenFilterFactory.java:101-108).CJKWidthFilterFactorydelegates (:62-65). - So
normalizekeeps every small kana, while indexed terms never contain one. A bounded-exhaustive check on the real jars, over all 1,112,064 Unicode scalar values, finds thatnormalizediffers from the indexed term for exactly the 28 small katakana and 12 small hiragana the filters map, and for none with the fix.
Main 645d4fe14a20, -ea and -da, one document per row, CustomAnalyzer.builder().withTokenizer("whitespace").addTokenFilter(<filter>), classic QueryParser, hit counts:
| filter (document) | indexed term | normalize(document text) | term query | prefix | [text TO text] |
|---|---|---|---|---|---|
cjkWidth (control; キャット) | キャット | キャット | 1 | キャ*: 1 | 1 |
japaneseKatakanaUppercase (キャット) | キヤツト | キャット | 1 | キャ*: 0 | 0 |
japaneseHiraganaUppercase (ちょっと) | ちよつと | ちょっと | 1 | ちょ*: 0 | 0 |
With the chain of the upstream factory test (japanese tokenizer + japaneseKatakanaUppercase), the document ストップウォッチ (indexed as ストツプウオツチ) gets 1 hit for the term query and 0 for ストップ*, スト?プウォッチ and ストップウォッチ~1, in both the classic and the flexible StandardQueryParser. The fuzzy query misses although its text is the document text: unnormalized, it is 3 edits away from the indexed term.
Not every multi-term query with a small kana returns nothing. With the documents キャット and コーヒー (same chain, classic parser, -ea and -da): キャット~1 gets 0 hits but キャット~2 gets 1 (2 substitutions from キヤツト); /キャ.*/ gets 0 but /キ(ャ|ヤ)ツト/ gets 1 through its other branch; and [ア TO ヶ] gets 2, one more than with the fix ([ア TO ケ]), because ヶ (U+30F6) sorts after コ (U+30B3) while its mapped form ケ (U+30B1) sorts before it. Literal prefix and wildcard chunks with a mapped small kana always miss; fuzzy, regexp and range results depend on edit distance, pattern structure and bound order.
Exposure and likelihood407 words
- Who is hit. A
CustomAnalyzer(or another chain built from the factories by SPI name) withjapaneseKatakanaUppercaseorjapaneseHiraganaUppercase, used with the classic, complex-phrase, flexible or simple query parser for range, fuzzy, prefix, wildcard or regexp terms (each parser for the syntax it has;SimpleQueryParserhas only prefix and fuzzy). Executed: classic prefix, range and regexp; classic and flexible prefix, wildcard and fuzzy; and, in the independent fact check, complex-phrase prefix and simple prefix and fuzzy (0 hits each for both filters, against 1 forcjkWidth). - Opt-in. No stock analyzer uses these filters;
JapaneseAnalyzer's normalization appliesCJKWidthCharFilter(JapaneseAnalyzer.java:130-133) andLowerCaseFilter(:119-123). - Ordinary input once opted in. Small kana are everyday Japanese (
キャ,ちょっ, theッof most loanwords), and the filters' documented purpose is to "search against old style Japanese text such as patents, legal, contract policies, etc." with modern spellings (JapaneseKatakanaUppercaseFilter.java:32-33). A literal prefix or wildcard pattern typed with a mapped small kana then matches nothing in that field. - How each query type goes wrong. The query text keeps its small kana, while no indexed term contains a mapped one. Prefix and wildcard: a literal chunk with a mapped small kana never matches. Fuzzy: the kept small kana add to the edit distance, one substitution each here (
キャット~10 hits,キャット~21). Regexp: a literal mapped small kana misses, but another branch can still match (/キ(ャ|ヤ)ツト/1). Range: an unmapped bound sorts elsewhere, so a range can miss documents ([キャット TO キャット]0) or return extra ones ([ア TO ヶ]2 against 1 with the fix). - Silent. No exception and no warning; the query just matches fewer documents (or, for some range bounds, more). A query typed in the indexed (full-size) form still works, which is easy to miss in tests.
- No scale or unusual parameter. One token, deterministic, with assertions on and off.
- Workaround. Subclass each factory, add
normalize()returningcreate(input), and pass the subclass toCustomAnalyzer.builder().addTokenFilter(Class)(CustomAnalyzer.java:347); checked on stock main.CustomAnalyzerisfinal(:99), so itsnormalizecannot be overridden; anAnalyzerWrapperwhosewrapTokenStreamForNormalizationadds the two filters works by code reading (not run). - Downstream (not run). By code reading, Solr's
TokenizerChain.normalizealso calls each factory'snormalize(), so a Solr field type with these factories most likely misses the same way. Two downstream projects wrap or copy the filters (elastic/elasticsearch#106553, azagniotov/solr-lucene-analyzer-sudachi#110).
One JUnit test, public API only, no index: a keyword tokenizer with both filters, one indexing check and one normalize call. Copy it to lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/ and run it with gradle; it fails with assertions on and off.
/*
* 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 org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.custom.CustomAnalyzer;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
public class TestSuteganaFactoriesNormalize extends BaseTokenStreamTestCase {
public void testNormalizeMapsSmallKana() throws Exception {
try (Analyzer analyzer =
CustomAnalyzer.builder()
.withTokenizer("keyword")
.addTokenFilter("japaneseKatakanaUppercase")
.addTokenFilter("japaneseHiraganaUppercase")
.build()) {
assertAnalyzesTo(analyzer, "キャちょ", new String[] {"キヤちよ"});
// expected "キヤちよ" (the indexed term), actual "キャちょ": both factories lack normalize()
assertEquals("キヤちよ", analyzer.normalize("f", "キャちょ").utf8ToString());
}
}
}
./gradlew :lucene:analysis:kuromoji:test --tests "org.apache.lucene.analysis.ja.TestSuteganaFactoriesNormalize" -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
- Expected
analyzer.normalize("f", "キャちょ")returnsキヤちよ, the term the index holds forキャちょ.- Observed
- It returns
キャちょunchanged:ComparisonFailure: expected:<キ[ヤちよ]> but was:<キ[ャちょ]>, with assertions on and off.
More files, environment, full output130 words
The first assertion (indexing maps キャちょ to キヤちよ) passes; the second (normalize returns the indexed form) fails. The query consequence (0 hits for キャ*, [キャット TO キャット] and the others) is not part of the test.
The file passes the kuromoji module's checkGoogleJavaFormat, ecjLint and forbiddenApis as is. Name tests.asserts explicitly: the build randomizes it per seed otherwise. The standalone runner on the 10.6.0 release jars (-ea and -da) and on 9.11.0 (-da) prints the same failure.
| lucene | main 645d4fe14a20 (2026-10-28) and the 10.6.0 and 9.11.0 releases; every release run since 9.11.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
TestSuteganaFactoriesNormalize > testNormalizeMapsSmallKana FAILED
org.junit.ComparisonFailure: expected:<キ[ヤちよ]> but was:<キ[ャちょ]>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:AECD5D7A0C4BB052]:0)
at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:117)
at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
at app//org.apache.lucene.analysis.ja.TestSuteganaFactoriesNormalize.testNormalizeMapsSmallKana(TestSuteganaFactoriesNormalize.java:34)
1 test completed, 1 failedAnalyzer.normalizemust return "the representation that it would have in the index"; here it returnsキャちょwhile the index holdsキヤちよ.- Both filters are documented normalizers;
CJKWidthFilterFactoryand all 8 other character-level normalizer factories added since 2018 delegatenormalize()tocreate(). - #12915, which added them, never discusses the
normalize()method or multi-term queries; no test or doc excludes these filters.
The full argument381 words
- The contract.
Analyzer.normalize: "Normalize a string down to the representation that it would have in the index. This is typically used by query parsers in order to generate a query on a given term, without tokenizing or stemming, which are undesirable if the string to analyze is a partial word (eg. in case of a wildcard or fuzzy query)." (Analyzer.java:202-207). TheAnalyzerclass javadoc gives the rule: "Assuming FooFilter is about normalization and BarFilter is about stemming, only FooFilter should be applied" (Analyzer.java:55-56). - These filters are normalizers. "A {@link TokenFilter} that normalizes small letters (捨て仮名) in katakana into normal letters." (
JapaneseKatakanaUppercaseFilter.java:29; the hiragana filter says the same). They are character-level maps, not stemmers, and read no tokenizer attributes: the hiragana filter maps one character to one, and the katakana filter also looks one character ahead to joinㇷ+ U+309A intoプ(JapaneseKatakanaUppercaseFilter.java:86-90). #12915's own description says they "normalize sutegana to normal (uppercase) characters". - The opt-in exists for exactly this. "filters that should be applied at normalization time can delegate to {@code create} method" (
TokenFilterFactory.java:101-104).CJKWidthFilterFactory(:62-65) and kuromoji's ownJapaneseIterationMarkCharFilterFactory(:72-75) delegate. Of the factories added since LUCENE-8497 (2018), the character-level normalizers are CJKWidthChar, NorwegianNormalization, TeluguNormalization, RomanianNormalization (merged 10 days after #12915), HangulCompositionChar, CaseFolding, ThaiChar and ThaiNormalization: all 8 overridenormalize(); the sutegana pair is the only exception. - Nothing documents an exclusion. No javadoc,
CHANGES.txtorMIGRATE.mdentry, test or code comment says these filters are skipped bynormalize; the upstream factory tests call onlycreate(). #12915's body, conversation and inline review comments use "normalize" only as a verb for what the filters do, and never mention thenormalize()method,Analyzer.normalize, multi-term queries or query parsers. - Identity contradicts the contract here. Indexed terms never contain the 40 mapped characters, so a literal prefix or wildcard chunk that keeps one can never match a document in that field, and fuzzy, regexp and range terms are compared against text that is not in the indexed form.
- Labeling precedent is mixed. Similar fixes were listed under "Bug Fixes" (LUCENE-7692 in 6.5.0; GITHUB#13059, a missing step in
TeluguAnalyzer.normalize, in 9.10.0) and under "Improvements" (LUCENE-7353 in 6.2.0), so this fix could be listed either way.
The devil's-advocate check223 words
The strongest case for "intended" (the intended-behaviour checker and the GPT-5.6 intended-lens reviewer): normalization is opt-in and the identity default is documented, and TestCustomAnalyzer asserts that factories that do not opt in are skipped; the katakana filter is not purely per character, because ㇷ゚ becomes プ and a wildcard can split the pair; LUCENE-8022 was resolved Invalid because a user's own Analyzer must override normalize; and upstream has filed similar factory fixes as improvements.
Why it fails: the opt-in is for filters "that should be applied at normalization time", and by the Analyzer javadoc's own rule a normalizing (not stemming) filter is one; that test checks the plumbing with dummy factories, not which real factories should opt in. CJKWidthFilter composes half-width voiced marks the same way and its factory opts in; the hiragana filter is a pure one-to-one map. With the fix the exhaustive check over all 71,289 two-character kana strings leaves exactly one prefix exception (ㇷ + ゚) for katakana and none for hiragana, against 59 for cjkWidth; on stock main it finds 7,476 and 3,204. CustomAnalyzer is final and builds normalize only from its factories, so the responsibility LUCENE-8022 puts on a hand-written Analyzer sits in the factory here. How upstream labels the fix does not change the behaviour. Verdict: not intended, a defect (every checker agrees).
Recommended normalize() returns create(input) in both factories
Give both factories normalize() returning create(input), as CJKWidthFilterFactory does (+10 lines). The test passes, and so do the whole kuromoji test task (196 tests) and its lint checks. One limit stays, as with cjkWidth: a wildcard right after ㇷ cannot reach a document whose ㇷ゚ was indexed as プ.
@Override
public TokenStream normalize(TokenStream input) {
return create(input);
}All options and trade-offs2 options · 457 words
One fix was built and validated; one alternative is described. Workaround until a fix ships: subclass each factory with normalize() returning create(input) and pass the subclasses to CustomAnalyzer.builder().addTokenFilter(Class) (checked on stock main), or wrap the analyzer in an AnalyzerWrapper that adds the two filters in wrapTokenStreamForNormalization (not run).
Add the same normalize() override (+5 lines) to JapaneseKatakanaUppercaseFilterFactory and JapaneseHiraganaUppercaseFilterFactory (+10 lines in total), the pattern of CJKWidthFilterFactory and of the other character-level normalizer factories. The factory files are byte-identical in every release since 9.11.0, so the diff applies unchanged to any backport line.
@Override
public TokenStream normalize(TokenStream input) {
return create(input);
}Trade-offs
- One digraph at a wildcard boundary. With the fix
normalizeequals the indexed term for every whole term, but prefix and wildcard chunks are normalized separately: a chunk ending inㇷbefore*givesフ, while a document withㇷ゚is indexed asプ. The exhaustive prefix check finds exactly this one case (and 59 such cases for the acceptedcjkWidth). - Behaviour change. Multi-term queries typed with small kana start to match; queries typed in full-size kana match as before. No index change and no reindex: the index already holds full-size kana.
- Regexps are normalized as whole strings. The classic parser passes the whole regexp to
normalize(QueryParserBase.java:683; its own comment notes this "may not work with some analyzers"), so with the fix small kana inside regexp syntax are mapped too. Literal kana then match, but a character class that names a small kana changes meaning:/[ァ-ヶ].*/becomes/[ア-ケ].*/and finds 1 of the probe's two documents instead of 2. Every filter whose factory opts in gets the same text-level treatment. - Cost: none at index time;
normalizebuilds one more filter per call.
What the checks validated
- Scratch worktree at
645d4fe14a20, gradle, JDK 25: the test fails on stock with assertions on and off; with the fix it passes together with the four upstream sutegana filter and factory suites and the nightlyTestFactories(6 suites, 17 tests), and again with assertions off (16 tests). - The whole
:lucene:analysis:kuromojitest task passes with assertions on and off: 31 suites, 196 tests, 0 failures, 7 skipped.:lucene:analysis:kuromoji:check -x testpasses with the fix and the test. - Earlier (hunt): 0
normalizemismatches over all Unicode scalar values with the fix; the independent reproducer's and the GPT reviewers' own tests pass with the same change.
State in both factories' javadocs that they are not applied by Analyzer.normalize.
Trade-offs
No code change, but the current behaviour stays and every user who opts in needs the workaround; it contradicts the Analyzer.normalize contract and the filters' own description as normalizers.
Search servers that build field analyzers from these factories most likely miss the same way: Solr's TokenizerChain.normalize also calls each factory's normalize() (by code reading; not run).
A scan looked for token filter factories without a normalize() override, and an executed probe on stock main confirmed this pair: the index holds キヤツト, normalize returns キャット, and キャ* gets 0 hits against 1 for the cjkWidth control. An AI finder reduced it to one test and checked every Unicode character exhaustively.
The method in full156 words
Method: non-formal (code reading plus executed probes; the finder's later bounded-exhaustive check is semi-formal).
- The hunt looked for
Analyzer.normalizegaps across sibling factories, inventoried factories without anormalize()override, and witnessed this pair with a public-API differential probe on stock main645d4fe14a20(2026-10-30): the index holdsキヤツトandちよつと,normalizereturnsキャットandちょっと, the term query gets 1 hit and the prefix and range queries 0, while thecjkWidthcontrol gets 1 each. A separate re-run confirmed it. - A hunt unit reduced it to one JUnit (later minimized to the one test above) and added a bounded-exhaustive check on the real jars:
normalizeagainst indexing for all 1,112,064 Unicode scalar values and all 71,289 two-character kana strings, plus a prefix property, stock and with the fix, withcjkWidthas the control. - The finder also screened two siblings and did not claim them:
PatternReplaceFilterFactory(already open upstream as #12613) andCapitalizationFilterFactory(position-dependent, by design).
No prior report found in GitHub issues and PRs (incl. the migrated JIRA archive), JIRA, four Lucene mailing lists, downstream projects or the web (English and Japanese), searched 2026-11-01. Nearest are the introducing PR #12915 and the open #12613, the same omission in another factory.
Searches6 searches
No prior public report was found in the documented searches: none says that these two factories lack normalize(), or describes the symptom (multi-term query terms with small kana not mapped like the indexed term). No maintainer statement of prior knowledge was found. Gaps: keyword search only; GitHub search tokenizes CJK text unevenly (2-3 phrasings were used); private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, incl. the migrated JIRA archive) | 2026-11-01 | two automated runs (16 GitHub queries each) and 31 manual searches: class and SPI names, sutegana, 捨て仮名, small kana, kuromoji normalize / wildcard / prefix, TokenFilterFactory normalize, CustomAnalyzer normalize | no report; only #12915 (the introducing PR), #13420 (a refactor), #16736 (an unrelated test failure) and same-class precedents (#12613, #13059) |
| Open apache/lucene PRs | 2026-11-01 | the changed files of all 409 open PRs; PRs created since 2026-10-30 | none touches either factory, either filter, TokenFilterFactory.java or CustomAnalyzer.java |
| Downstream projects | 2026-11-01 | 2 automated and 32 manual GitHub searches (search servers, Japanese analysis plugins) | only wrappers and copies of the filters (elastic/elasticsearch#106553, #112356; azagniotov/solr-lucene-analyzer-sudachi#110), no report |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-11-01 | 20 automated and 15 direct searches | only notification mirrors of #12915 and release mails |
| JIRA (LUCENE, SOLR) | 2026-11-01 | 4 automated and 8 direct JQL searches | LUCENE-8497 (origin of the hook) and unrelated issues |
| Web | 2026-11-01 | general searches in English and Japanese, 3 pages read in full | the filters' author's usage post and user docs; none mentions normalize or multi-term queries |
Introduced with the filters by #12915 (merged 2024-03-18, first release 9.11.0); the factories never changed since. The hook they lack dates from LUCENE-8497 (2018). Every release run from 9.11.0 to 10.6.0 and main is affected; 9.10.0 has no such factories.
Versions tested6 rows
Introduced by #12915 "Add new token filters for Japanese sutegana (捨て仮名)" (author daixque, merged by benwtrent): squash commit d393b9d0395 on main (2024-03-18) and the branch_9x backport 04dd8d32afc, first released in 9.11.0 (CHANGES, "New Features").
- The factories were created without the override and never changed: their files are byte-identical in all 19 release tags from 9.11.0 to 10.6.0 and on main.
- The hook they should override,
TokenFilterFactory.normalize, came with LUCENE-8497 (65486442c4a, 2018-11-19, 8.0.0), so this is an omission at birth, not a regression. - Upstream main
f815e679bf6chas the same factory files (history clone), and the GitHub commit list ofbranch_10_6for both factory paths shows onlyd393b9d0395(read 2026-11-01). Not new in 10.6.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one-test repro fails with assertions on and off (gradle and the standalone runner) and passes with the fix |
| 10.6.0 | affected | the one-test repro and the finder's JUnit fail, -ea and -da (release jars) |
| 9.12.3, 10.0.0, 10.5.2 | affected | the finder's JUnit fails (-da) |
| 9.11.0 | first release | the finder's JUnit fails (-ea, -da) and passes with the fix; the one-test repro fails (-da) |
| 9.10.0 | no API | the SPI name japaneseKatakanaUppercase does not exist; the kuromoji jar has neither factory |
fix (normalize() returns create(input)) | not affected | the test passes; kuromoji test task 196 tests, 0 failures |
An independent reproducer, cross-model reviews (OpenAI GPT-5.6, refute and intended lenses), an intended-behaviour devil's advocate and an adjudication all say real; the adjudication confirmed it with high confidence after re-running the tests with new seeds. A credibility audit confirmed it.
Checks and verdicts306 words
The GPT reviews ran on main ea57360912ed, whose two factory files are identical to those at 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | JUnit fails on main (-ea, -da), 10.6.0, 10.5.2, 9.12.3 and 9.11.0 and passes with the fix; the 15 upstream sutegana tests and the nightly TestFactories pass with it; the exhaustive check finds exactly the 40 mapped characters on stock and none with the fix. |
| Independent reproducer | real | Own 2-test JUnit from the claim (normalize equals the indexed term; classic-parser キャ* finds an indexed キャット) fails on main, 10.6.0, 10.5.2, 9.12.3 and 9.11.0; its own shadow fix makes it pass. |
| GPT-5.6, refute lens (executed) | real (high) | Own tests fail with -ea and -da and two seeds (0-hit prefix query against 1 for the term query); with the fix they and the 15 upstream sutegana tests pass. |
| GPT-5.6, intended lens (executed) | real (high) | Own 4-test JUnit: prefix キャ* and the exact range get 0 hits against 1 for the term query; the cjkWidth control passes; rates the intended-behaviour case moderate. |
| Intended-behaviour devil's advocate | real | With the japanese tokenizer chain, ストップ*, スト?プウォッチ and ストップウォッチ~1 get 0 hits in the classic and the flexible parser; no doc, test or review excludes these filters. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's test with a new seed (-da), the finder's test (-ea) and the fix; suggested priority Low. |
| credibility audit | confirmed | Fresh-seed re-run fails as stated; impact re-checked; the one-test repro was re-checked too. |
| this page's runs | validated | Gradle on stock and with the fix (module tests and lint), release jars 10.6.0 and 9.11.0, the workaround, and a probe of fuzzy, regexp and range terms on main, stock and with the fix. |
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 383 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,249 words
- Registry title
- JapaneseKatakanaUppercaseFilterFactory and JapaneseHiraganaUppercaseFilterFactory do not override normalize(), so CustomAnalyzer.normalize leaves small kana unmapped and query-parser prefix, wildcard, regexp, fuzzy and range terms that contain them silently miss the indexed documents
- Area
- analysis-kuromoji / sutegana (small kana) token filter factories (JapaneseKatakanaUppercaseFilterFactory, JapaneseHiraganaUppercaseFilterFactory) and Analyzer.normalize via CustomAnalyzer, which the query parsers use for multi-term queries
- Priority
- Medium, rank 124: In every release since 9.11.0, CustomAnalyzer chains with the opt-in small-kana kuromoji filters skip those filters in normalize, so query-parser prefix, wildcard, fuzzy, regexp and range terms that contain everyday small kana silently miss documents. It is deterministic on common Japanese text but limited to an opt-in factory-built chain, so it is Medium.
- Impact class
- silent wrong results
- Impact note
- Applications that opt in to the non-default japaneseKatakanaUppercase or japaneseHiraganaUppercase filter in a factory-built chain (CustomAnalyzer; by code reading also downstream chains built from TokenFilterFactory.normalize) and accept prefix, wildcard, regexp, fuzzy or range syntax through the classic or flexible query parser silently get too few hits whenever the query term contains one of the 28 small katakana or 12 small hiragana the filters map; plain term and phrase queries, JapaneseAnalyzer and hand-written Analyzer subclasses are unaffected, no scale or unusual parameter is needed, it is deterministic with -ea and -da, and every release from 9.11.0 to 10.6.0 and main has it.
- Summary
- You build a CustomAnalyzer with the kuromoji small-kana filters (withTokenizer("keyword").addTokenFilter("japaneseKatakanaUppercase").addTokenFilter("japaneseHiraganaUppercase")), and indexing キャットちょっと stores the term キヤツトちよつと. You expect Analyzer.normalize("f", "キャットちょっと") to return キヤツトちよつと, 'the representation that it would have in the index', as it does for the similar cjkWidth filter. Instead it returns the input unchanged, because both factories override create() but not normalize() and CustomAnalyzer.normalize only chains each factory's normalize(), so query-parser multi-term terms keep their small kana and silently miss: a document キャット is found by the term query キャット but not by the prefix query キャ* or the range [キャット TO キャット].
- Introduced
- d393b9d03952c69d9641cd4a38bbe83939a25942 on main (#12915 'Add new token filters for Japanese sutegana (捨て仮名)', author daixque, squash-merged by benwtrent, author and committer date 2024-03-18T12:50:36Z) and its branch_9x backport 04dd8d32afcdc4f0798647f173db2924a3d38b42 (committed 2024-03-18T12:51:27Z, first released in 9.11.0) [read: the only commit on either factory file; both were created without a normalize() override and never changed; exec: 9.10.0 lacks the factories, 9.11.0 fails]. The hook they should override, TokenFilterFactory.normalize, is older (LUCENE-8497, 65486442c4a, 2018-11-19), so this is an omission at birth, not a regression (2024-03-18)
- First affected version
- 9.11.0 [exec: the JUnit repro fails with -ea and -da on the SHA-1-verified 9.11.0 release jars and passes with the fix]; 9.10.0 and earlier are not affected [exec: the SPI name japaneseKatakanaUppercase does not exist there; the 9.10.0 kuromoji jar has neither factory]
- Affected versions
- every release from 9.11.0 through 10.6.0 (9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2, 10.6.0, released 2026-10-29, 4ae946e5a9b) and main 645d4fe14a20; never fixed (upstream main f815e679bf6c and branch_10_6 unchanged at the fix site, checked 2026-11-01). [exec] the JUnit repro (seed DEADBEEF) fails 1 of 1 on main (-ea and -da), 10.6.0 (-ea and -da, SHA-1-verified release jars), 10.5.2, 10.0.0, 9.12.3 (-da) and 9.11.0 (-ea and -da); the independent reproduction's own JUnit fails 2 of 2 on main (-ea and -da), 10.6.0, 10.5.2, 9.12.3 and 9.11.0. [read] both factory files are byte-identical in all 19 release tags from 9.11.0 to 10.6.0 and on main. Not new in 10.6.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by fuzzing/PBT, exhaustive check
- Upstream and prior reports
- none found (2026-11-01, spot-checked again the same day): a scripted prior-report search pass (54 queries over apache/lucene issues and PRs incl. the migrated JIRA archive, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; 1,941 distinct hits, 0 high) and an independent follow-up pass of 145 logged queries (a scripted prior-report search over the same sources; 31 manual apache/lucene and 32 downstream GitHub searches; 15 direct mailing-list and 8 JIRA JQL searches; web searches in English and Japanese); the changed files of all 409 open apache/lucene PRs (none touches the two factories, the two filters, TokenFilterFactory.java or CustomAnalyzer.java); upstream commits on the factory files (only d393b9d0395, #12915, author and committer date 2024-03-18T12:50:36Z; main f815e679bf6c and branch_10_6 unchanged); PRs and issues created since 2026-10-30 (#16952-#16959) unrelated. Related, not reports: #12915 (the introducing PR; its review never mentions normalize, multi-term queries or query parsers), #12613 (open since 2023-10-02, no maintainer reply: the same omission in PatternReplaceFilterFactory, another factory), #9543 (LUCENE-8497, origin of the opt-in normalize() hook), #9234 (LUCENE-8186) and LUCENE-7606 (earlier, different CustomAnalyzer.normalize bugs, fixed), #13059 (a missing normalization step in TeluguAnalyzer.normalize fixed as a bug), #16736 (an unrelated TestRandomChains failure), elastic/elasticsearch#106553 and #112356 and azagniotov/solr-lucene-analyzer-sudachi#110 (downstream wrappers and copies of these filters; reach, not reports)
- Lucene commit tested
- main
645d4fe14a20(2026-10-28; upstream mainf815e679bf6cof 2026-11-01 has the same two factory files, and the GitHub commit list ofbranch_10_6shows no change to them): the one-test repro through gradle in a scratch worktree, stock and with the fix, plus the wholelucene/analysis/kuromojitest task and its format and lint checks, assertions on and off (the worktree was removed after the runs); the standalone JUnit runner on main and on the 10.6.0 (4ae946e5a9b) and 9.11.0 release jars; the finder's JUnit on 9.11.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (9.10.0 has no such factories); a bounded-exhaustive check ofnormalizeagainst indexing over all Unicode scalar values on the real jars, stock and with the fix; a probe of fuzzy, regexp and range terms on main, stock and with the fix. JDK OpenJDK 25.0.3, Linux aarch64.
- Finding record
lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseKatakanaUppercaseFilterFactory.java:45-48 and JapaneseHiraganaUppercaseFilterFactory.java:45-48 (only create() is overridden)lucene/core/src/java/org/apache/lucene/analysis/TokenFilterFactory.java:101-108 (normalize() default returns the input; 'filters that should be applied at normalization time can delegate to create')lucene/analysis/common/src/java/org/apache/lucene/analysis/custom/CustomAnalyzer.java:170-177 (normalize() chains each factory's normalize())lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:202-213 (normalize contract)lucene/analysis/common/src/java/org/apache/lucene/analysis/cjk/CJKWidthFilterFactory.java:62-65 (precedent: normalize() returns create(input))lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseKatakanaUppercaseFilter.java:28-34 and JapaneseHiraganaUppercaseFilter.java:28-34 (class javadocs: 'normalizes small letters', for searching old-style text)lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:550,556,646,656,683,712,729 (range, wildcard, regexp, prefix and fuzzy terms go through Analyzer.normalize)
- The finding in 30 seconds
lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseKatakanaUppercaseFilterFactory.java:45-48 (only create() is overridden)lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseHiraganaUppercaseFilterFactory.java:45-48 (same)lucene/core/src/java/org/apache/lucene/analysis/TokenFilterFactory.java:101-108 (normalize() default returns the input)lucene/analysis/common/src/java/org/apache/lucene/analysis/custom/CustomAnalyzer.java:161-167 (createComponents chains create()), :170-177 (normalize chains each factory's normalize())lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseKatakanaUppercaseFilter.java:29-33 (class javadoc), :43-70 (28-entry map), :86-97 (incrementToken, the small fu + U+309A rule)lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseHiraganaUppercaseFilter.java:29-33 (class javadoc), :43-54 (12-entry map), :69-74 (incrementToken)lucene/analysis/common/src/java/org/apache/lucene/analysis/cjk/CJKWidthFilterFactory.java:62-65 (normalize delegates to create)lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:550, :556, :646, :656, :683, :712, :729 (normalize call sites)lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/TermRangeQueryNodeProcessor.java:138-139, FuzzyQueryNodeProcessor.java:59, WildcardQueryNodeProcessor.java:66, :76, RegexpQueryNodeProcessor.java:43lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:573, :605
- Who is affected
lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseAnalyzer.java:109-117 (createComponents), :119-123 (normalize), :130-133 (initReaderForNormalization)lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseKatakanaUppercaseFilter.java:32-33 (documented purpose)lucene/analysis/common/src/java/org/apache/lucene/analysis/custom/CustomAnalyzer.java:99 (final class), :347 (addTokenFilter(Class))
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:55-56 (class javadoc rule), :202-207 (normalize contract)lucene/core/src/java/org/apache/lucene/analysis/TokenFilterFactory.java:101-108lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseKatakanaUppercaseFilter.java:29 and lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseHiraganaUppercaseFilter.java:29 (class javadocs)lucene/analysis/common/src/java/org/apache/lucene/analysis/cjk/CJKWidthFilterFactory.java:62-65; lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseIterationMarkCharFilterFactory.java:72-75lucene/CHANGES.txt:2778 (GITHUB#12915, 9.11.0 New Features), :3065 (GITHUB#13059, 9.10.0 Bug Fixes), :8462 (LUCENE-7692, 6.5.0 Bug Fixes), :9049 (LUCENE-7353, 6.2.0 Improvements)
- Suggested fix
lucene/analysis/common/src/java/org/apache/lucene/analysis/cjk/CJKWidthFilterFactory.java:62-65 (the pattern)
- History
lucene/CHANGES.txt:2778 (GITHUB#12915 under 9.11.0 New Features)
- TestSuteganaFactoriesNormalize.java (one JUnit test, public API):
repro/LFV-1064/TestSuteganaFactoriesNormalize.java(37 lines)
Made separately from this page, from the repro and the executed probes on main 645d4fe14a20.
assets/LFV-1064/bug.png(383 KB)
