All findings · Lucene finding · LFV-607 · iterationmark-self-mapped-kana-treated-as-voiced

JapaneseIterationMarkCharFilter turns なゝめ into などめ: the unvoiced iteration mark after な..の, っ or ぱ..ぺ yields a different kana

With the opt-in JapaneseIterationMarkCharFilter, なゝめ (an older spelling of ななめ, "diagonal") comes out as などめ, so a search for ななめ finds nothing. It happens after each of the 10 kana without a voiced form (な..の, っ, ぱ..ぺ) and their katakana forms, in every release since 4.0.0-BETA (2012).

Summary

Priority
Low, rank 510The opt-in JapaneseIterationMarkCharFilter mis-expands the unvoiced mark after 10 kana, which in practice shows up only in older orthography, so it is rarer than its kuromoji sibling LFV-605.
Impact
silent wrong results
Since
4.0.0-BETA (2012), when the class was addedintroduced 2012-07-10 · over 14 years
Affected
4.0.0-BETA to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
bounded-exhaustive comparison with a reference expander that uses Unicode decomposition to tell voiced kana

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

The finding in 30 seconds

JapaneseIterationMarkCharFilter expands Japanese iteration marks before tokenizing: ゝ (ヽ in katakana) repeats the kana before it without a voicing mark, ゞ (ヾ) repeats it voiced.

Summary card titled: Lucene's JapaneseIterationMarkCharFilter turns なゝめ into などめ instead of ななめ. Panel 1, what you do: a CustomAnalyzer with the japaneseIterationMark char filter and the keyword tokenizer, a document with the text なゝめ, and a count of a query for ななめ; the class javadoc (line 26) promises the expanded form. Panel 2, what the filter does with なゝめ: ゝ repeats な (U+306A); h2d[31] = な, so isHiraganaDakuten(な) = true, and line 303 returns c - 1 = ど (U+3069): actual などめ, expected ななめ. Panel 3, the voicing table h2d for the entries づ to ぱ: kana that map to their voiced form, voiced kana that map to themselves, and な に ぬ ね の and ぱ, which have no voiced form but map to themselves too; line 408, inside(c, h2d, '\u304b') && c == lookupHiraganaDakuten(c), is true for every self-mapping. Panel 4, executed on main with -ea and -da: the query f:ななめ expected 1 hit, actual 0 hits; っゝ → っぢ, なゝ → など, にゝ → にな, ぬゝ → ぬに, ねゝ → ねぬ, のゝ → のね, ぱゝ → ぱば, ぴゝ → ぴび, ぷゝ → ぷぶ, ぺゝ → ぺべ, and the katakana forms; right on main: なゞ → なな, じゝ → じし; the two-line fix (voiced only if the code point before c maps to c) makes all 20 pairs repeat their kana, 14 of 14 upstream tests pass, and documents indexed before a fix need reindexing. Footer: opt-in, JapaneseAnalyzer does not install this filter.
Summary card: what you do (index なゝめ, query ななめ), what the filter does with な (h2d[31] = な, isHiraganaDakuten(な) = true, c - 1 = ど), the voicing table from づ to ぱ, and the executed result with the fix. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You put JapaneseIterationMarkCharFilter with its default flags in front of a tokenizer and index なゝめ, the older spelling of ななめ ("diagonal"). The mark ゝ means "repeat the kana before me, without a voicing mark".
  2. You expect ななめ, just as こゝろ gives こころ and じゝ gives じし, so a TermQuery for ななめ finds the document.
  3. Instead the filter writes などめ, with no error: the query for ななめ finds 0 documents, the one for などめ finds 1. The same happens for 19 more pairs, such as のゝ → のね, っゝ → っぢ, ぱゝ → ぱば and ナヽ → ナド.
  4. Why: the voicing table maps が to が because it is voiced, but also な to な because it has no voiced form. isHiraganaDakuten calls every such self-mapping voiced, and for a voiced kana ゝ gives the code point before it (が → か), so な (U+306A) becomes ど (U+3069).
  5. The voiced mark is right for the same kana (なゞ → なな), and so are the other 40 kana of the table and ぽゝ → ぽぽ. A two-line fix passes the repro and all 200 kuromoji tests.

Who is affected

Applications that add the opt-in char filter (the class, the SPI name japaneseIterationMark, or a search server's iteration-mark char filter) with default flags; JapaneseAnalyzer does not install it. It hits older orthography and names such as なゝめ, なゝつ and のゝしる: such words are indexed misspelled, and searches for the modern spelling miss them.

Mechanism, terms and numbers666 words

JapaneseIterationMarkCharFilter (kuromoji, opt-in, SPI name japaneseIterationMark) expands the Japanese iteration marks: ゝ (ヽ in katakana) repeats the kana before it without a voicing mark, ゞ (ヾ) repeats it voiced. For 10 hiragana that have no voiced form, っ, な に ぬ ね の and ぱ ぴ ぷ ぺ, and their 10 katakana forms, the unvoiced mark becomes the code point before the kana instead: なゝめ → などめ instead of ななめ, のゝしる → のねしる, ぱゝ → ぱば, ナヽメ → ナドメ. The voicing table stores a kana without a voiced form as a self-mapping, exactly like a voiced kana, and isHiraganaDakuten treats every self-mapping as voiced. Words in older orthography are indexed misspelled and searches for the modern spelling miss them, with no error, in every release from 4.0.0-BETA (2012) to 10.6.0 (10.6.0 by reading) and on main. A two-line fix checks the code point before the kana instead.

Terms

  • Iteration marks (odoriji): ゝ (U+309D, hiragana) and ヽ (U+30FD, katakana) repeat the kana before them; older texts write ななめ ("diagonal") as なゝめ and ののしる ("to revile") as のゝしる. The voiced marks ゞ/ヾ repeat the kana with a voicing mark (みすゞ → みすず).
  • Voicing mark (dakuten): か → が, し → じ, は → ば. The unvoiced mark repeats a voiced kana without it: じゝ → じし. Kana of the na-row (な に ぬ ね の), the small っ and the p-sounds ぱ ぴ ぷ ぺ (which carry a handakuten) have no voiced form.
  • JapaneseIterationMarkCharFilter: opt-in, in kuromoji since 4.0.0; SPI name japaneseIterationMark. Its javadoc: "Normalizes Japanese horizontal iteration marks (odoriji) to their expanded form" (JapaneseIterationMarkCharFilter.java:26). Kana normalization is on by default (:48).

The bug

  1. The voicing table h2d has 50 entries for か (U+304B) .. ぼ (U+307C) (:82-131; katakana k2d is derived from it, :133-138). A kana maps to its voiced form (か => が), a voiced kana to itself (が => が), and a kana without a voiced form also to itself, at index 24 (っ, :106), 31-35 (な..の, :113-117) and 38, 41, 44, 47 (ぱ ぴ ぷ ぺ, :120, :123, :126, :129). lookupHiraganaDakuten documents a self-mapping as "c itself if no dakuten variant exists" (:385).
  2. isHiraganaDakuten(c) is inside(c, h2d, '\u304b') && c == lookupHiraganaDakuten(c) (:408; katakana :418). That is true for が and equally for な.
  3. For ゝ, normalizedHiragana returns isHiraganaDakuten(c) ? (char) (c - 1) : c (:303; katakana :321). For が that removes the dakuten (U+304C - 1 = か); for な (U+306A) it gives ど (U+3069), for の ね, for っ ぢ, for ぱ ば.
  4. The voiced mark returns lookupHiraganaDakuten(c) directly (:305), so なゞ → なな is right. ぽゝ → ぽぽ is right only because ぽ (U+307D) lies one code point past the table.
inputexpectedactual on main
なゝめななめなどめ
のゝしるののしるのねしる
にゝ, ぬゝ, ねゝにに, ぬぬ, ねねにな, ぬに, ねぬ
ぱゝ (ぴゝ ぷゝ ぺゝ alike)not a voiced kana (ぱぱ, as ぽゝ → ぽぽ)ぱば
っゝっっっぢ
ナヽメ (all 10 katakana alike)ナナメナドメ
controls: こゝろ, じゝ, ぽゝ, おゝの, なゞ, ところゞゝゝこころ, じし, ぽぽ, おおの, なな, ところどころthe same (right)

The numbers

20 of 20 pairs wrong on main with assertions on and off. A document indexed as なゝめ (keyword tokenizer): TermQuery ななめ → 0 hits, などめ → 1 hit. With JapaneseTokenizer behind the filter, なゝめに切る becomes [など, め, に, 切る] while ななめに切る gives [ななめ, に, 切る]; のゝしる声 becomes [の, ね, しる, 声]. In the pair and sequence sections of the exhaustive check this is the only discrepancy: over all strings of length up to 5 on a 14-symbol alphabet (of the affected kana it contains only な) under all 4 flag combinations, 0 mismatches in the strings without な (1,608,932 checks), 24,985 in those with な (707,844 checks), 0 with the fix. Two separate gaps remain with or without the fix (see How it was found).

Exposure and likelihood274 words
  • Who. Applications that add JapaneseIterationMarkCharFilter to an analyzer: the class, the factory (SPI name japaneseIterationMark, for example in CustomAnalyzer), or a downstream search server's iteration-mark char filter, which constructs this class with Lucene's default flags (read from their source; not run there). Opt-in: JapaneseAnalyzer installs only CJKWidthCharFilter. It reproduces with the default flags.
  • What triggers it. Text that writes one of the 10 kana (or its katakana form) before the unvoiced mark: older orthography and names such as なゝめ (ななめ), なゝつ (ななつ), のゝしる (ののしる), ナヽ. っゝ is not attested in real text, and handakuten kana before ゝ are uncommon. No frequency was measured. Deterministic: no scale, seed or assertion dependence.
  • What breaks. The filter exists to make the marked and the expanded spelling match; for these words they do not. A document written なゝめ is not found by ななめ and is found by などめ. Query text analyzed by the same analyzer goes through the same filter (for multi-term queries the factory's normalize() builds it too), so a query typed with the mark matches the marked documents but not those written ななめ.
  • Silent. No exception, no log line. The output keeps the input length, so offsets stay right.
  • After a fix. Documents indexed with the filter keep the wrong terms until they are reindexed; query-side analysis is right as soon as the fix is deployed.
  • Workaround today. A MappingCharFilter in front of this filter that maps the 20 pairs (なゝ => なな, ナヽ => ナナ, ...) fixes the single mark (なゝめ → ななめ), but not a span of marks: なのゝゝ (expected なのなの) gives なのどね on stock main and なののね with the workaround.

Minimal repro

JapaneseIterationMarkCharFilter expands the unvoiced iteration mark ゝ after な (a kana with no voiced form) to a different kana, because it reads な as voiced.

TestIterationMarkUnvoicedKana.java (JUnit test)34 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 java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIterationMarkUnvoicedKana extends LuceneTestCase {

  public void testUnvoicedMarkRepeatsNa() throws Exception {
    StringWriter out = new StringWriter();
    try (Reader r = new JapaneseIterationMarkCharFilter(new StringReader("なゝめ"))) {
      r.transferTo(out);
    }
    // expected ゝ to repeat な unchanged ("ななめ"), actual "などめ" (な treated as a voiced kana)
    assertEquals("ななめ", out.toString());
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-607/, 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.TestIterationMarkUnvoicedKana
# the module(s) analysis-kuromoji also need their third-party runtime jars
Expected vs actual
Expected: なゝめ -> ななめ; actual: などめ.

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

More files, environment, full output32 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<な[な]め> but was:<な[ど]め>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.analysis.ja.TestIterationMarkUnvoicedKana.testUnvoicedMarkRepeatsNa(TestIterationMarkUnvoicedKana.java:32)

Why it looks unintended

  • The class's javadoc says a self-mapping means "no dakuten variant exists"; isHiraganaDakuten reads it as "is a dakuten".
  • The contract is "their expanded form"; even the documented fallback for illegal sequences gives なな, never など.
  • The voiced mark gets these kana right (なゞ → なな); the coverage tests skip exactly the 10 affected kana.
The full argument213 words
  • The javadoc contradicts the predicate. lookupHiraganaDakuten returns "c itself if no dakuten variant exists" (JapaneseIterationMarkCharFilter.java:385); isHiraganaDakuten (javadoc "true if c is a hiragana dakuten", :405) reads that same self-mapping as voiced (:408). な, の, っ and ぱ are not voiced kana.
  • The contract. "Normalizes Japanese horizontal iteration marks (odoriji) to their expanded form" (:26). The expanded form of なゝ repeats な; など is a different word.
  • The documented fallback. For an illegal sequence the filter "emits the illegal source character as-is" (:28-30), which would give なな again. No documented reading produces など, のね or ぱば.
  • One branch away it is right. The voiced mark uses the lookup directly: なゞ → なな.
  • Internally inconsistent. ぽゝ → ぽぽ is asserted upstream (TestJapaneseIterationMarkCharFilter.java:135), yet ぱゝ → ぱば, only because ぽ lies just outside the table and ぱ inside it.
  • Never tested, never discussed. The coverage tests (:189-215) list the か..ぼ kana that have a voiced form, each followed by its voiced form, and skip the 10 affected kana; no Lucene test has an affected kana before ゝ/ヽ. The introducing issue (LUCENE-4201, now #5273) discusses illegal sequences, the full stop, surrogates, offsets and random tests, never these kana. CHANGES has only the "Added" entry.
The devil's-advocate check165 words

The strongest case (the intended-behaviour checker and the cross-model review): the author wrote the self-mapping entries on purpose, kana iteration marks are rare in modern text, the class already lists limitations (illegal sequences, the full stop, no vertical marks) and carries @IgnoreRandomChains, so odd inputs could be called unsupported; and handakuten kana with ゝ have no settled rule.

Why it fails: the self-mapping is the class's own documented sentinel for "no variant", and the predicate misreads it; the listed limitations do not cover these kana, and the documented illegal-sequence fallback would give なな anyway; @IgnoreRandomChains and the open #11394 concern offsets and an index-out-of-bounds exception, a different symptom and code path; and an unvoiced mark that outputs a voiced kana (ぱば) or a kana of another row (のね) is wrong under any rule. Rarity lowers the priority; it does not make the output intended. Not claimed: that ぱぱ, rather than ぱは, is the required answer for handakuten kana; the docs only rule out ぱば.

Suggested fix

Recommended Voiced means "the voiced form of the code point before it" (recommended)

Count c as voiced only if it is the voiced form of the code point before it: base = c - 1; inside(base) && lookup(base) == c, in isHiraganaDakuten and isKatakanaDakuten. Same cost. The repro passes and all 200 kuromoji tests pass, assertions on and off. Indexes built with the filter need reindexing.

   private boolean isHiraganaDakuten(char c) {
-    return inside(c, h2d, '\u304b') && c == lookupHiraganaDakuten(c);
+    char base = (char) (c - 1);
+    return inside(base, h2d, '\u304b') && lookupHiraganaDakuten(base) == c;
   }
   private boolean isKatakanaDakuten(char c) {
-    return inside(c, k2d, '\u30ab') && c == lookupKatakanaDakuten(c);
+    char base = (char) (c - 1);
+    return inside(base, k2d, '\u30ab') && lookupKatakanaDakuten(base) == c;
   }
All options and trade-offs4 options · 628 words

The recommended option was validated in a scratch worktree at a9779ef3c193: gradle, the whole :lucene:analysis:kuromoji:test task plus the paste-ready test, assertions on and off, and the module's lint and format checks; and with the standalone runner (the fixed class shadowed) on the paste-ready test and the probe. The other options were not run.

Voiced means "the voiced form of the code point before it" (recommended) (validated by the checks)

Two lines per predicate, with a short comment, plus a test method testKanaWithoutVoicedForm in TestJapaneseIterationMarkCharFilter (なゝめ, のゝしる, the 10 hiragana and the 10 katakana pairs). Voiced kana stay voiced (が: か maps to が), unvoiced kana stay unvoiced (か: the code point before it is outside the table), and the 10 kana become unvoiced (な: ど maps to ど, not な), so ゝ repeats them: なな, っっ, ぱぱ.

   private boolean isHiraganaDakuten(char c) {
-    return inside(c, h2d, '\u304b') && c == lookupHiraganaDakuten(c);
+    char base = (char) (c - 1);
+    return inside(base, h2d, '\u304b') && lookupHiraganaDakuten(base) == c;
   }
   private boolean isKatakanaDakuten(char c) {
-    return inside(c, k2d, '\u30ab') && c == lookupKatakanaDakuten(c);
+    char base = (char) (c - 1);
+    return inside(base, k2d, '\u30ab') && lookupKatakanaDakuten(base) == c;
   }

Trade-offs

  • Reuses the table; one lookup per unvoiced mark, as today. Only the unvoiced-mark branch changes; the voiced mark and the span logic are untouched.
  • For handakuten kana it gives ぱぱ, consistent with the existing ぽゝ → ぽぽ test; a maintainer may prefer ぱは. Either way ぱば is gone.
  • Leaves the separate gap outside the table (ゔゝ, ヴヽ, ヷヽ..ヺヽ repeated unchanged; うゞ, ウヾ, ワヾ..ヲヾ not voiced; 12 pairs) as it is.
  • Existing indexes keep the wrong terms until reindexed.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: the paste-ready test fails 3 of 4 on stock main with assertions on and off; the new upstream test method alone fails on stock main (term 0 expected:<な[な]め> but was:<な[ど]め>; the other 14 pass).
  • With the fix (code and test method) plus the paste-ready test, the whole :lucene:analysis:kuromoji:test task passes: 31 classes, 200 tests, 0 failures (7 skipped, all pre-existing nightly or large-data tests), with assertions on and off. That includes TestJapaneseIterationMarkCharFilter (15, with its random-string tests) and TestJapaneseIterationMarkCharFilterFactory (5).
  • ecjLintMain, ecjLintTest and checkGoogleJavaFormat of the module pass.
  • With the fixed class shadowed, the paste-ready test passes 4 of 4 (assertions on and off) and the probe prints no wrong line. Earlier, the finder's sketch of the same predicate cleared the exhaustive check (20 → 0 pair mismatches, 24,985 → 0 sequence mismatches) and the 14 upstream tests.

Decide voicing by Unicode canonical decomposition (untested)

Treat c as voiced when its NFD form ends with U+3099, and use the first character as the unvoiced kana (java.text.Normalizer).

Trade-offs

Independent of the table, and it would also cover the voiced kana outside it (ゔ, ヴ, ヷ..ヺ). But it adds a Normalizer call per unvoiced mark and changes output for those out-of-table kana (ゔゝ would become ゔう), a separate gap that needs its own decision. Not run.

Mark "no voiced form" in the table itself (untested)

For example a separate flag array, or a sentinel value that lookup turns back into c.

Trade-offs

Makes the table self-describing, at the cost of a larger diff that also touches the voiced-mark path. Not run.

Workaround without a Lucene change: a MappingCharFilter in front (validated by the checks)

Map the 20 pairs (なゝ => なな, ナヽ => ナナ, ...) before this filter.

Trade-offs

Fixes the single mark (なゝめ → ななめ, ぱゝ → ぱぱ) but not a span of marks: なのゝゝ gives なののね instead of なのなの.

What the checks validated

Run in the probe on stock main: right for the 6 single-mark words, wrong for the span.

Downstream note

Search servers that expose this char filter as kuromoji_iteration_mark build it from the same Lucene class with its default flags, so their Japanese analysis should produce the same wrong kana (read from their source; not run there).

How it was found

A bounded-exhaustive check compared the filter with a reference expander that uses Unicode decomposition to tell voiced kana: 890 character/mark pairs and all strings of length up to 5 over a 14-symbol alphabet (2.3 million checks). 20 pairs differed, and among the strings only those containing な; the finder then reduced it to a public-API JUnit.

The method in full246 words
  • Method: bounded-exhaustive translation validation (the hunt's semi-formal tier). A harness ran the stock main jar against a reference expander written from the class javadoc and the upstream tests, with Unicode canonical decomposition (U+3099) as the independent voicing oracle.
  • Pair section. Every character from U+3041 to U+30FF except the four kana marks (and a kanji, a letter and a question mark) followed by each of the five iteration marks, except the 60 pairs that need a voiced form outside the table: 890 checks (178 sources x 5 marks), 20 mismatches (the 20 pairs), 0 with the fix. The 60 out-of-table pairs were counted separately (the gap noted below).
  • Sequence section. All strings of length up to 5 over a 14-symbol alphabet (か が な ぽ カ ガ 時, the five marks, 。 and a supplementary character) under all 4 flag combinations: 1,608,932 checks without な, 0 mismatches; 707,844 with な, 24,985 mismatches, 0 with the fix. So the per-kana rule is the only discrepancy in that alphabet. Non-vacuity: a reference without the full-stop barrier gives 98,884 mismatches.
  • Reduction. A public-API JUnit (keyword analyzer behind the filter) on the stock main jars, 2026-10-26; this doc's paste-ready test adds the search miss.
  • Also seen, not part of this bug: the table stops at か..ぼ, so 12 pairs outside it (ゔゝ, ヴヽ, ヷヽ..ヺヽ, うゞ, ウヾ, ワヾ..ヲヾ) are not voiced or unvoiced; read(cbuf, off, 0) returns -1 instead of 0. Both are the same on stock main and with the fix.

Prior reports, history and checks

Prior reports

No matching prior report found (searched 2026-10-26) in GitHub issues, PRs and commits, JIRA, the mailing lists, open PRs' changed files, downstream projects and the web. Closest: #5273, the issue that added the filter, and the open #11394 (offsets and an index-out-of-bounds error, a different bug).

Searches5 searches

No report of this defect was found, and it is not fixed upstream: upstream main a95c754ee003 still has inside(c) && c == lookup(c), the last commit touching the file is #14228 (2025-02-24, static final), and no open PR touches it. Another contributor's re-implementation of the filter expects バナヽ → バナナ in its own test, with no mention of Lucene's output.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists (dev@, java-user@, issues@, commits@), JIRA archive2026-10-26the scripted prior-report search, four runs (180 queries, 925 distinct hits)#5273 (LUCENE-4201, the introduction; its 18 comments never mention these kana) and #11394 (LUCENE-10358, open, offsets / IndexOutOfBounds); nothing about this defect
GitHub by hand (apache/lucene; Elasticsearch, OpenSearch, Solr, the .NET port, other ports, all of GitHub)2026-10-2618 apache/lucene issue and PR queries (the class name, "iteration mark", ゝ, ヽ, odoriji, dakuten, handakuten, normalizeKana, ...) and 17 downstream and port queriesonly the two items above, other kuromoji topics and items that merely name the class
Open PRs2026-10-26the changed files of all 408 open apache/lucene PRsnone touches the filter or its factory
Mailing lists and JIRA (LUCENE, SOLR)2026-10-26phrase searches on java-user@, dev@, issues@ and the Solr user lists; 7 JQL queriesonly threads that list the factory, and the issues above
Web (English and Japanese)2026-10-269 queries in two passesno report

History

In the code since the class was added (a15331043f98, LUCENE-4201, 2012-07-10; first released in 4.0.0-BETA); the logic never changed. The same 20 wrong pairs on 16 releases from 4.0.0-BETA to 10.5.2 and on main; 4.0.0-ALPHA has no such class.

Versions tested6 rows

Introduced with the class: a15331043f98 "Added JapaneseIterationMarkCharFilter (LUCENE-4201)", 2012-07-10, backported to branch_4x the same day as 2f1a3e4bad81; no PR (the change predates GitHub; JIRA LUCENE-4201, now #5273). git blame on main assigns the self-mapped entries, the predicates and the c - 1 branches to it, and later commits (LUCENE-4228, LUCENE-4393, javadoc and license fixes, LUCENE-9570 reformatting, LUCENE-10352 @IgnoreRandomChains, #14228 static final) do not touch the logic. The 10.5.2 class source is byte-identical to main.

Executed with one version-neutral main() probe: the same 20 wrong pairs, controls right, on 16 releases and main; 4.0.0-ALPHA does not compile (no class); main with the fix passes. Read only: 122 release tags contain the introducing commit or its branch_4x backport (83 + 39), and upstream main, branch_10x and branch_10_6 still have the code, so 10.6.0 (released 2026-10-29) and the unreleased 11.0 line are affected. Inferred: 8.8.2 and the 8.9.0-8.11.x releases were cut from branch_8x after the repository split, so the history clone has no tags for them; 8.8.1 contains the commit, the logic never changed, and 8.11.2 and 8.11.4 were run and reproduce.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedpaste-ready test fails 3 of 4 (gradle and runner, assertions on and off); history probe 20 of 20 wrong
10.5.2, 9.12.3 affectedpaste-ready test fails with the same messages (published jars; 10.5.2 assertions on and off, 9.12.3 on)
10.0.0, 9.0.0, 8.11.4, 8.0.0, 7.7.3, 7.0.0, 6.6.6, 6.0.0, 5.5.5, 5.0.0, 4.10.4, 4.1.0, 4.0.0 affectedhistory probe: the same 20 wrong pairs, controls right
4.0.0-BETA firsthistory probe, assertions on and off: なゝめ → などめ, 20 of 20 wrong
4.0.0-ALPHA no classthe probe does not compile: JapaneseIterationMarkCharFilter does not exist
main + the two-line fix not affectedpaste-ready test 4 of 4, history probe 0 wrong, whole kuromoji test task 200 tests 0 failures

Independent checks

The finder, an independent reproducer, an intended-behaviour checker, the adjudication and a credibility audit all ran code and said real. A reviewer from another model family (OpenAI GPT-5.6) could not run code: a lens said not real on procedure only, agreeing on the root cause, the other said real. This doc's gradle runs confirm the repro and the fix.

Checks and verdicts355 words

Every verdict except the cross-model review's rests on executed runs on main a9779ef3c193. The cross-model review read the checkout at ea57360912ed, where the kuromoji module is identical.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive harness (20 of 890 pairs wrong, 0 with the fix); public-API JUnit fails 2 of 3 on main (assertions on and off), 10.5.2 and 9.12.3; main() variant fails on 8.11.2, 7.7.3, 5.5.5 and 4.0.0.
independent reproducer realOwn JUnit with a Unicode-decomposition oracle fails 3 of 4 on main (on and off), 10.5.2 and 9.12.3; its scan of the whole kana blocks finds exactly the same 20 pairs without the finder's list; passes with the fix.
refute review (OpenAI GPT-5.6) not real, low (procedural; reading only)Could not run any command, so its own repro never ran; agreed on the root cause, that the inputs are legal and that the intended-behaviour argument is weak. A separate re-run of the finder's JUnit failed 2 of 3 on main.
intended review (OpenAI GPT-5.6) real, medium (reading only)Rated the intended-behaviour defence weak. A separate executed check (every kana before ゝ/ヽ against a Unicode-decomposition oracle) found the 20 pairs on main, assertions on and off, and none of them with the fix.
intended-behaviour checker real, high (na-row); medium on the handakuten valueNo not-a-bug argument survives (the lookup javadoc, なゞ → なな, the illegal-sequence fallback); its test through the factory fails 3 of 4 on main and 10.5.2, including 0 hits for ななめ.
adjudication confirmed, highRe-ran the reproducer's test on main with assertions on and off: 3 of 4 fail.
credibility audit keepRe-ran the finder's test on main with assertions on (seed DEADBEEF) and off (new seed 5EED0A04): 2 of 3 fail; re-read the root cause; hand-checked the fix.
this doc reproduces; fix validatedGradle in a scratch worktree: the paste-ready test fails 3 of 4 on stock main with assertions on and off; with the fix the whole kuromoji test task passes, 200 tests, on and off; the runner reproduces on 10.5.2 and 9.12.3.

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

Finding record

LFV-607, snapshot of 2026-11-06

Registry title
JapaneseIterationMarkCharFilter expands the unvoiced kana iteration mark ゝ/ヽ after the na-row, the small tsu and the handakuten kana (な に ぬ ね の, っ, ぱ ぴ ぷ ぺ and their katakana forms) to a different kana, so なゝめ becomes などめ instead of ななめ (のゝしる becomes のねしる, ぱゝ becomes ぱば), because kana that have no voiced form are stored as self-mappings in the voicing table and the 'is voiced' check reads every self-mapping as voiced
Area
analysis-kuromoji / Japanese char filters (JapaneseIterationMarkCharFilter kana iteration marks ゝ/ヽ: isHiraganaDakuten / isKatakanaDakuten over the h2d / k2d voicing tables, used by normalizedHiragana / normalizedKatakana; JapaneseIterationMarkCharFilterFactory 'japaneseIterationMark')
Priority
Low, rank 510: The opt-in JapaneseIterationMarkCharFilter mis-expands the unvoiced mark after 10 kana, which in practice shows up only in older orthography, so it is rarer than its kuromoji sibling LFV-605.
Impact class
silent wrong results
Impact note
Hits only applications that add the opt-in JapaneseIterationMarkCharFilter (the class, or the factory name 'japaneseIterationMark') with its default normalizeKana=true in front of an analyzer (JapaneseAnalyzer does not install it) and that index or query text in which one of 20 kana (っ, な, に, ぬ, ね, の, ぱ, ぴ, ぷ, ぺ, or the katakana form) is followed by the unvoiced mark ゝ/ヽ, which in practice means older orthography and names such as なゝめ, なゝつ or のゝしる: the filter silently emits a different kana (などめ), so the indexed term is not the expanded spelling and a query for ななめ misses the document (executed), deterministically at any size, with -ea and -da, in every release since 4.0.0-BETA; the voiced mark ゞ/ヾ, the kana that do have a voiced form, sequences of marks and offsets are correct.
Summary
You put JapaneseIterationMarkCharFilter in front of an analyzer and index なゝめ, the older spelling of ななめ ('diagonal'), and expect the filter to replace the iteration mark ゝ with the repeated kana, as its javadoc promises ('normalizes ... iteration marks to their expanded form'). Instead it emits などめ, so the index holds a different word and a search for ななめ finds nothing; the same happens for 19 more kana (のゝ becomes のね, ぱゝ becomes ぱば, っゝ becomes っぢ, and the katakana forms with ヽ). The filter decides whether the preceding kana is voiced by checking whether it maps to itself in its voicing table, but the kana that have no voiced form (っ, な..の, ぱ ぴ ぷ ぺ) are stored there as self-mappings too, so it strips a dakuten they never had and emits the preceding code point.
Introduced
a15331043f9823eaee675f45012f93071f1e21fb (trunk, svn r1359613, 'Added JapaneseIterationMarkCharFilter (LUCENE-4201)', author cmoen) and its branch_4x backport 2f1a3e4bad811fe027c3c25f4b3206ace37ddedf (svn r1359645, same day), which the 4.x and 5.x release tags contain; no PR (the change predates GitHub; JIRA LUCENE-4201, now GitHub issue #5273). The bug came with the class, it is not a regression [read: git blame -w on main attributes the self-mapping table entries h2d[24], h2d[31..35], h2d[38], h2d[41], h2d[44], h2d[47] (JapaneseIterationMarkCharFilter.java:106, 113-117, 120, 123, 126, 129), the predicates isHiraganaDakuten / isKatakanaDakuten (:407-409, :417-419) and the c - 1 branches (:303, :321) to a15331043f98, and git log --follow -S 'c == lookupHiraganaDakuten(c)', -S '(char) (c - 1)' and -G 'h2d\[31\] = ' return only that commit; exec: 4.0.0-ALPHA has no such class (compile error), 4.0.0-BETA reproduces]. Later commits to the file (LUCENE-4228, LUCENE-4393, javadoc and license fixes, LUCENE-9570 reformatting, LUCENE-10352 @IgnoreRandomChains, #14228 'static final') do not touch the logic (2012-07-10 (trunk commit and branch_4x backport, same day))
First affected version
4.0.0-BETA [exec] (first GA release 4.0.0 [exec]); last unaffected: 4.0.0-ALPHA [exec: the class does not exist, the probe does not compile]; 3.6.x and earlier never had the class [read]
Affected versions
Every release from 4.0.0-BETA through 10.6.0 (10.6.0 by reading; up to 10.5.2, 122 release tags contain the introducing commit [read: git tag --contains]; 8.8.2 to 8.11.4 were cut from branch_8x after the repository split and are not tagged in the clone, 8.11.2 and 8.11.4 executed) and main a9779ef3c193, never fixed. [exec] the follow-up's version-neutral main() probe (public Reader constructor, default flags) gives the same wrong output for all 20 pairs, with its upstream-asserted controls correct, on 4.0.0-BETA (-ea and -da), 4.0.0, 4.1.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 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), and passes on main with the fix shadowed; the finder's JUnit fails 2 of 3 on main (-ea and -da), 10.5.2 and 9.12.3 and its main() variant fails on 8.11.2, 7.7.3, 5.5.5 and 4.0.0; the independent reproduction's JUnit fails 3 of 4 on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's fails 3 of 4 on main (-ea and -da) and 10.5.2. [read] the 10.5.2 class source is byte-identical to main; on upstream main (a95c754ee003, 2026-10-26), branch_10x and branch_10_6 the last commit touching the file is the 2025-02-24 #14228 'static final' change (ce50d3a135d on main, 1d55d2383ce on the branches), so 10.6.0 (released 2026-10-29) and the unreleased 11.0 line are affected (not executed). Not executed: x86 (pure Java char logic). JDK 25.0.3, aarch64
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, read-only). The follow-up prior-art step, which had crashed, was backfilled 2026-10-26: an unpublished file and the prior-report record, verdict new. The verdict combines the backfill with what the registration check built from: three scripted prior-report searches on those specs (v1 45 queries / 576 hits, v2 45 queries / 122 hits, v3 45 queries / 545 hits; 135 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr issues, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive), the finder's run (45 queries, 24 hits), 925 distinct hits in all (180 queries), the finder's and the intended-behaviour check's manual GitHub searches (JapaneseIterationMarkCharFilter, 'iteration mark', ゝ, ヽ, odoriji, dakuten, open PRs for the class and for kuromoji), 4 web searches in English and Japanese in this step, and a read-only check of the file's upstream commit history. The only items about this class: #5273 (LUCENE-4201, the introducing issue; its 18 comments cover random tests, offsets, surrogates, illegal sequences and the full-stop barrier, never kana without a voiced form) and #11394 (LUCENE-10358, open: TestRandomChains offsets or IndexOutOfBounds, a different symptom and code path). Every other hit is unrelated (for example LUCENE-3358, StandardTokenizer and the combining dakuten, 2011; build, typo and reformatting issues that list the class). Not fixed upstream: on main (a95c754ee003, 2026-10-26), branch_10x and branch_10_6 the last commit touching the file is #14228 (2025-02-24, 'static final'), and no open PR touches it (finder's check 2026-10-26; re-checked in this step: 0 open PRs mention JapaneseIterationMarkCharFilter, and the 2 open PRs that mention kuromoji, #12517 (UniDic dictionary build) and #365, are unrelated). Backfill 2026-10-26 (fresh, read-only): the changed files of all 408 open apache/lucene PRs (GraphQL scan) include no PR that touches JapaneseIterationMarkCharFilter or its factory; upstream main a95c754ee003 still has the predicate (last commit on the file #14228); 18 fresh apache/lucene issue/PR searches, 17 downstream/port searches (Elasticsearch, OpenSearch, Solr, the .NET port, codelibs, all of GitHub), phrase searches on java-user@, dev@ and issues@ plus the Solr user lists, JQL over the LUCENE and SOLR projects, and 5 web searches found no report; the only near item is another contributor's re-implementation (codelibs IterationMarkCharFilter) whose own test expects バナヽ -> バナナ, with no mention of Lucene's output
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, and the standalone JUnit runner); the release jars 10.5.2 (assertions on and off) and 9.12.3 (this doc's paste-ready test); 16 releases from 4.0.0-BETA to 10.5.2 and main (the history step's version-neutral main() probe); 4.0.0-ALPHA (no such class); the fix on main (gradle, the whole kuromoji test task, assertions on and off).

Lucene and upstream sources

Finding record
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseIterationMarkCharFilter.java:26 (at a9779ef3c193) (class javadoc 'Normalizes Japanese horizontal iteration marks (odoriji) to their expanded form'), :28-30 (illegal sequences: 'emits the illegal source character as-is'), :40-41 (@IgnoreRandomChains, LUCENE-10358), :48 (NORMALIZE_KANA_DEFAULT = true), :82-131 (h2d table; self-mappings without a voiced form at :106 っ, :113-117 な..の, :120 ぱ, :123 ぴ, :126 ぷ, :129 ぺ), :133-138 (k2d derived from h2d), :146-161 (constructors), :300-327 (normalizedHiragana / normalizedKatakana: ゝ/ヽ return c - 1 when is*Dakuten, :303, :321), :381-399 (lookup* javadoc 'c itself if no dakuten variant exists'), :401-419 (isHiraganaDakuten / isKatakanaDakuten = inside(c) && c == lookup(c); javadoc 'true if c is a hiragana dakuten'), :450-453 (length-preserving correct())
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseIterationMarkCharFilterFactory.java:40 (at a9779ef3c193) (SPI name japaneseIterationMark), :67-75 (create and normalize both build the filter)
  • lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseIterationMarkCharFilter.java:91 (at a9779ef3c193) (おゝの->おおの), :103 (じゝ->じし), :134-135 (ぽゞ and ぽゝ both ->ぽぽ, 'a corner case test for inside()'), :186-214 (coverage tests list only か..ぼ kana that have voiced forms)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseAnalyzer.java:127, (at a9779ef3c193) :132 (only CJKWidthCharFilter; the iteration-mark filter is not installed)
  • upstream GitHub REST (read-only, 2026-10-26): commits on lucene/analysis/kuromoji/.../JapaneseIterationMarkCharFilter.java for main (head a95c754ee003), branch_10x and branch_10_6: last change 2025-02-24 (#14228)
The finding in 30 seconds
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseIterationMarkCharFilter.java:26, (at a9779ef3c193) :28-30, :48, :62-63, :82-131 (h2d; :106, :113-117, :120, :123, :126, :129 self-mappings without a voiced form), :133-138 (k2d), :300-327 (:303, :305, :321), :381-399 (:385, :395), :401-419 (:405, :408, :418), :446-448 (inside)
  • lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseIterationMarkCharFilter.java:91, (at a9779ef3c193) :95, :103, :107, :133-135, :189-215
Who is affected
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseAnalyzer.java:126-127, (at a9779ef3c193) :131-132 (only CJKWidthCharFilter)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseIterationMarkCharFilterFactory.java:34 (at a9779ef3c193) (@since 4.0.0), :40 (SPI name), :54-56 (normalizeKana default), :67-75 (create, normalize)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseIterationMarkCharFilter.java:450-453 (at a9779ef3c193) (correct() is the identity)
Why it looks unintended
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseIterationMarkCharFilter.java:26, (at a9779ef3c193) :28-30, :32-38, :40-41, :303, :305, :385, :395, :405, :408, :415, :418
  • lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseIterationMarkCharFilter.java:91, (at a9779ef3c193) :103, :133-135, :189-215

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

Repro files

About the diagrams

The PNG is the summary card. Made in parallel with this doc.

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