All findings · Lucene finding · LFV-1064 · sutegana-filter-factories-missing-normalize

The kuromoji small-kana filter factories have no normalize(), so CustomAnalyzer prefix, wildcard, fuzzy, regexp and range queries with small kana can miss documents

In a CustomAnalyzer, the kuromoji filters japaneseKatakanaUppercase and japaneseHiraganaUppercase turn small kana into full-size kana at index time, but Analyzer.normalize skips them. So query-parser prefix, wildcard, fuzzy, regexp and range terms keep their small kana: キャ* finds 0 documents for an indexed キャット, with no error, since 9.11.0.

Summary

Priority
Medium, rank 124In 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
silent wrong results
Since
9.11.0 (#12915, 2024), with the filters themselvesintroduced 2024-03-18 · about 2 years
Affected
9.11.0 to 10.6.0 and main (5 releases run); opt-in filters
Status
documented
Found by
AI tools: a scan for factories without normalize(), then an executed probe

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: how query parsers normalize terms

  1. At index time the analyzer runs its whole chain on each token. japaneseKatakanaUppercase maps the 28 small katakana (ャ, ッ, ォ, ...) to full-size ones; japaneseHiraganaUppercase maps the 12 small hiragana (ょ, っ, ...).
  2. A query like キャ* or [キャット TO キャット] holds a partial word or a bound that must not be tokenized. So the parser calls Analyzer.normalize, which should apply only the character-level normalizing filters.
  3. In a CustomAnalyzer, a filter takes part in normalize only if its factory overrides TokenFilterFactory.normalize(), usually as return create(input);, as cjkWidth and lowercase do. The default returns the input unchanged.
Summary of the bug: a CustomAnalyzer with a whitespace tokenizer and the japaneseKatakanaUppercase filter indexes the document キャット as キヤツト, but Analyzer.normalize leaves the query prefix キャ unchanged because the factory inherits the identity normalize(), so the classic QueryParser query キャ* finds 0 documents while the term query キャット finds 1.
One document キャット: 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.

The finding, step by step

  1. Build a CustomAnalyzer: whitespace tokenizer, then japaneseKatakanaUppercase. Index the word キャット ("cat"): the filter turns the small ャ and ッ into full-size ヤ and ツ, so the index holds キヤツト.
  2. Search with the classic QueryParser for キャ* or [キャット TO キャット]. For these the parser does not run the full analyzer; it calls Analyzer.normalize, which should return the indexed form (キヤ, キヤツト).
  3. You expect 1 hit for each, as with the cjkWidth filter, whose prefix and range queries do find their document.
  4. Instead normalize returns 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).
  5. Why: both factories implement only create() and inherit the do-nothing default normalize(), and CustomAnalyzer.normalize runs only each factory's normalize().

Who is affected

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.

The bug

  1. At index time CustomAnalyzer.createComponents chains each factory's create() (CustomAnalyzer.java:161-167 on main 645d4fe14a20). JapaneseKatakanaUppercaseFilter rewrites each small katakana through a 28-entry map and the pair ㇷ゚ (U+31F7 U+309A) to プ (JapaneseKatakanaUppercaseFilter.java:43-70, :86-97); JapaneseHiraganaUppercaseFilter maps 12 small hiragana one to one (JapaneseHiraganaUppercaseFilter.java:43-54, :69-74). Both read only the term text.
  2. The classic QueryParserBase sends range bounds (:550, :556), wildcard chunks (:646, :656), the whole regexp (:683), prefixes (:712) and fuzzy terms (:729) through Analyzer.normalize; the flexible parser's processors (TermRangeQueryNodeProcessor.java:138-139, FuzzyQueryNodeProcessor.java:59, WildcardQueryNodeProcessor.java:66, :76, RegexpQueryNodeProcessor.java:43) and SimpleQueryParser (fuzzy :573, prefix :605) do the same.
  3. CustomAnalyzer.normalize calls normalize() on each of its token filter factories (CustomAnalyzer.java:170-177).
  4. Both sutegana factories override only create() (JapaneseKatakanaUppercaseFilterFactory.java:45-48, JapaneseHiraganaUppercaseFilterFactory.java:45-48), so TokenFilterFactory.normalize runs: "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). CJKWidthFilterFactory delegates (:62-65).
  5. So normalize keeps 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 that normalize differs from the indexed term for exactly the 28 small katakana and 12 small hiragana the filters map, and for none with the fix.

The numbers

Main 645d4fe14a20, -ea and -da, one document per row, CustomAnalyzer.builder().withTokenizer("whitespace").addTokenFilter(<filter>), classic QueryParser, hit counts:

filter (document)indexed termnormalize(document text)term queryprefix[text TO text]
cjkWidth (control; キャット)キャットキャット1キャ*: 11
japaneseKatakanaUppercase (キャット)キヤツトキャット1キャ*: 00
japaneseHiraganaUppercase (ちょっと)ちよつとちょっと1ちょ*: 00

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) with japaneseKatakanaUppercase or japaneseHiraganaUppercase, 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; SimpleQueryParser has 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 for cjkWidth).
  • Opt-in. No stock analyzer uses these filters; JapaneseAnalyzer's normalization applies CJKWidthCharFilter (JapaneseAnalyzer.java:130-133) and LowerCaseFilter (: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 (キャット~1 0 hits, キャット~2 1). 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() returning create(input), and pass the subclass to CustomAnalyzer.builder().addTokenFilter(Class) (CustomAnalyzer.java:347); checked on stock main. CustomAnalyzer is final (:99), so its normalize cannot be overridden; an AnalyzerWrapper whose wrapTokenStreamForNormalization adds the two filters works by code reading (not run).
  • Downstream (not run). By code reading, Solr's TokenizerChain.normalize also calls each factory's normalize(), 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).

Minimal repro

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.

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

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

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.

Environment

lucenemain 645d4fe14a20 (2026-10-28) and the 10.6.0 and 9.11.0 releases; every release run since 9.11.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

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 failed

Why it looks unintended

  • Analyzer.normalize must return "the representation that it would have in the index"; here it returns キャちょ while the index holds キヤちよ.
  • Both filters are documented normalizers; CJKWidthFilterFactory and all 8 other character-level normalizer factories added since 2018 delegate normalize() to create().
  • #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). The Analyzer class 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 own JapaneseIterationMarkCharFilterFactory (: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 override normalize(); the sutegana pair is the only exception.
  • Nothing documents an exclusion. No javadoc, CHANGES.txt or MIGRATE.md entry, test or code comment says these filters are skipped by normalize; the upstream factory tests call only create(). #12915's body, conversation and inline review comments use "normalize" only as a verb for what the filters do, and never mention the normalize() 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).

Suggested fix

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).

normalize() returns create(input) in both factories (validated by the checks) (recommended)

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 normalize equals 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 accepted cjkWidth).
  • 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; normalize builds 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 nightly TestFactories (6 suites, 17 tests), and again with assertions off (16 tests).
  • The whole :lucene:analysis:kuromoji test task passes with assertions on and off: 31 suites, 196 tests, 0 failures, 7 skipped. :lucene:analysis:kuromoji:check -x test passes with the fix and the test.
  • Earlier (hunt): 0 normalize mismatches over all Unicode scalar values with the fix; the independent reproducer's and the GPT reviewers' own tests pass with the same change.

Document the limitation (not run)

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.

Downstream note

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).

How it was found

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.normalize gaps across sibling factories, inventoried factories without a normalize() override, and witnessed this pair with a public-API differential probe on stock main 645d4fe14a20 (2026-10-30): the index holds キヤツト and ちよつと, normalize returns キャット and ちょっと, the term query gets 1 hit and the prefix and range queries 0, while the cjkWidth control 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: normalize against 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, with cjkWidth as the control.
  • The finder also screened two siblings and did not claim them: PatternReplaceFilterFactory (already open upstream as #12613) and CapitalizationFilterFactory (position-dependent, by design).

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. the migrated JIRA archive)2026-11-01two automated runs (16 GitHub queries each) and 31 manual searches: class and SPI names, sutegana, 捨て仮名, small kana, kuromoji normalize / wildcard / prefix, TokenFilterFactory normalize, CustomAnalyzer normalizeno report; only #12915 (the introducing PR), #13420 (a refactor), #16736 (an unrelated test failure) and same-class precedents (#12613, #13059)
Open apache/lucene PRs2026-11-01the changed files of all 409 open PRs; PRs created since 2026-10-30none touches either factory, either filter, TokenFilterFactory.java or CustomAnalyzer.java
Downstream projects2026-11-012 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-0120 automated and 15 direct searchesonly notification mirrors of #12915 and release mails
JIRA (LUCENE, SOLR)2026-11-014 automated and 8 direct JQL searchesLUCENE-8497 (origin of the hook) and unrelated issues
Web2026-11-01general searches in English and Japanese, 3 pages read in fullthe filters' author's usage post and user docs; none mentions normalize or multi-term queries

History

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 f815e679bf6c has the same factory files (history clone), and the GitHub commit list of branch_10_6 for both factory paths shows only d393b9d0395 (read 2026-11-01). Not new in 10.6.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-test repro fails with assertions on and off (gradle and the standalone runner) and passes with the fix
10.6.0 affectedthe one-test repro and the finder's JUnit fail, -ea and -da (release jars)
9.12.3, 10.0.0, 10.5.2 affectedthe finder's JUnit fails (-da)
9.11.0 first releasethe finder's JUnit fails (-ea, -da) and passes with the fix; the one-test repro fails (-da)
9.10.0 no APIthe SPI name japaneseKatakanaUppercase does not exist; the kuromoji jar has neither factory
fix (normalize() returns create(input)) not affectedthe test passes; kuromoji test task 196 tests, 0 failures

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedJUnit 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 realOwn 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 realWith 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 confirmedFresh-seed re-run fails as stated; impact re-checked; the one-test repro was re-checked too.
this page's runs validatedGradle 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,249 words

Finding record

LFV-1064, snapshot of 2026-11-06

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 main f815e679bf6c of 2026-11-01 has the same two factory files, and the GitHub commit list of branch_10_6 shows no change to them): the one-test repro through gradle in a scratch worktree, stock and with the fix, plus the whole lucene/analysis/kuromoji test 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 of normalize against 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.

Lucene and upstream sources

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:43
  • lucene/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-108
  • lucene/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-75
  • lucene/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)

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

Repro files

About the diagrams

Made separately from this page, from the repro and the executed probes on main 645d4fe14a20.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.