All findings · Lucene finding · LFV-877 · utr30-cyrillic-letters-added-after-unicode-4-not-folded

ICUFoldingFilter folds ҧ to п but not ԥ, its modern Abkhaz counterpart: none of the 24 Cyrillic hook, stroke and descender letters added in Unicode 4.1-7.0 is folded

ICUFoldingFilter strips the hook from the older Abkhaz letter ҧ (to п) but not from ԥ, its modern counterpart, so "аԥсуа" finds 0 of 1 documents spelled "аҧсуа". None of the 24 Cyrillic hook, stroke and descender letters added in Unicode 4.1-7.0 is folded, in any release since 3.1.0.

Summary

Priority
Low, rank 512The opt-in ICUFoldingFilter leaves the mark on 24 newer Cyrillic hook, stroke and descender letters used in minority-language spelling, which only hurts relevance.
Impact
minor
Since
3.1.0 (LUCENE-1343, 2010)introduced 2010-04-22 · over 16 years
Affected
3.1.0 to 10.5.2, 10.6.0 (branch_10_6, by reading) and main
Status
documented
Found by
exhaustive check of the folding data against Unicode names and ages

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

ICUFoldingFilter folds with a compiled table, utr30.nrm. Letters like ҧ have no Unicode decomposition, so only a hand-written Cyrillic block of DiacriticFolding.txt can fold them: 26 lines copied from the 2004 UTR#30 draft (Unicode 4.0), none above U+04CF. The 24 letters of this kind added in Unicode 4.1-7.0, such as ԥ, have no line, so they keep their mark.

Diagram of the bug: the older Abkhaz spelling аҧсуа is indexed as апсуа, while the modern spelling аԥсуа stays аԥсуа, so a search with one spelling finds 0 documents written in the other. The Cyrillic block of DiacriticFolding.txt has lines for the older hook, stroke and descender letters such as ҧ (04A7>043F) but none for the 24 added in Unicode 4.1-7.0 such as ԥ; adding 12 lines and regenerating utr30.nrm folds them.
The two Abkhaz spellings through ICUFoldingFilter: ҧ loses its hook and becomes п, ԥ keeps its descender, so the terms differ and the query finds nothing; the folding table has lines only for the older letters. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You add ICUFoldingFilter with its default settings at index and query time, and index the Abkhaz word "аҧсуа" in its older spelling, with ҧ (PE WITH MIDDLE HOOK).
  2. You expect the modern spelling "аԥсуа", with ԥ (PE WITH DESCENDER), to find it: the filter removes hooks and descenders, it folds ҧ to п, and Unicode's character list cross-references ԥ to ҧ.
  3. Instead the document's term is апсуа and the query's term is аԥсуа: 0 of 1 documents found. The filter leaves ԥ as it is (it only lower-cases the capital Ԥ to ԥ).
  4. The cause: the Cyrillic part of the folding table is a 2010 copy of a 2004 draft made for Unicode 4.0, never extended. Of 48 older Cyrillic hook, stroke and descender letters, 46 fold to their base letter; of the 24 added in Unicode 4.1-7.0, none does.
  5. No error, and each exact spelling still finds itself. Adding 12 lines to the table and regenerating it folds all 24 and changes no other code point.

Who is affected

Users of the optional analysis-icu ICUFoldingFilter (default data, no setting needed) who index Cyrillic text in an orthography using one of these letters, such as modern Abkhaz, Yupik, Itelmen or Nenets. A word spelled with the newer letter and the same word with the older or plain letter give two terms. Silent, deterministic, every release since 3.1.0.

Mechanism, terms and numbers871 words

ICUFoldingFilter (module analysis-icu) folds text for search; its javadoc lists "Diacritic removal (including stroke, hook, descender)". Cyrillic letters such as ҧ PE WITH MIDDLE HOOK have no Unicode decomposition, so the only thing that folds them is a hand-written block of DiacriticFolding.txt, compiled into utr30.nrm. That block was copied in 2010 from the 2004 draft data of UTR#30, which was based on Unicode 4.0, and has never been extended: 26 lines, none above U+04CF. So 46 of the 48 Cyrillic letters of this kind from Unicode 1.1-3.2 fold to their base letter (ҷ/Ҷ fold to a wrong letter, a separate bug), while none of the 24 added in Unicode 4.1-7.0 loses its mark: ӷ ӻ ӽ ӿ ԓ ԡ ԣ ꚋ ԥ ԧ ԩ ԯ and their capitals are only lower-cased. In Abkhaz, Unicode's character list marks ҧ as the letter of "older orthographies" and ԥ as "used in modern Abkhaz orthography": the older spelling "аҧсуа" is indexed as апсуа, the modern spelling "аԥсуа" stays аԥсуа, and it finds 0 of 1 documents; a document in the modern spelling is missed by the older and the plain spelling as well. No error is raised, and each exact spelling still matches itself. The file header says "Created from Unicode 5.2 UCD", yet 18 of the 24 code points were already in Unicode 5.2. Every release from 3.1.0 to 10.5.2, 10.6.0 (by reading) and main is affected: the 138 releases up to 10.5.2 ship 13 distinct compiled tables, and a standalone check fails on at least one release of each. The fix is 12 added data lines and a regenerated utr30.nrm; it changes indexed terms for these letters, so affected fields need reindexing.

Terms

  • ICUFoldingFilter (module analysis-icu): a token filter for "search term folding". Its normalizer NORMALIZER loads the compiled data file utr30.nrm (ICUFoldingFilter.java:69-77); the one-argument constructor uses it, and so does ICUFoldingFilterFactory (SPI name icuFolding), directly or, with a filter argument, wrapped in a FilteredNormalizer2, which can only exclude characters.
  • utr30.nrm: compiled by the gradle task regenerateUtr30DataFiles from src/data/utr30/*.txt with ICU's gennorm2 and icupkg. The task first regenerates the rule-based parts of the tables from Unicode properties and keeps the data lines after a # Rule: verbatim marker as written.
  • Why only a table line helps: none of the 72 Cyrillic letters of this kind (48 older, 24 newer) has an NFKD decomposition or the Unicode Diacritic property, so neither NFKC nor the [:Diacritic:] rule touches them. A Cyrillic letter with a hook, stroke or descender is folded only if the block has a line for it.

The block

DiacriticFolding.txt:597-626, headed "Cyrillic script \"composed\" that do not further decompose, so decompose here", "These are from UTR#30 DiacriticFolding.txt" and "# Rule: verbatim": 26 lines from 047D>0461 to 04CE>043C, for example 0493>0433 (ғ GHE WITH STROKE to г), 04A7>043F (ҧ PE WITH MIDDLE HOOK to п) and 04C6>043B (ӆ EL WITH TAIL to л). The UTR#30 draft data it names is "DRAFT 1 - 2004-07-14", "Base version of Unicode: 4.0.0", and has no line for any of the 24 newer letters. Capitals need no line: nfkc_cf.txt case-folds them to the small letter first (for example 0524>0525). The file's header (:24) says "Created from Unicode 5.2 UCD".

The 24 letters (none folded today)

small / capitalname (CYRILLIC ... LETTER), Unicode versionbase
ӷ U+04F7 / Ӷ U+04F6GHE WITH DESCENDER, 4.1г
ӻ U+04FB / Ӻ U+04FAGHE WITH STROKE AND HOOK, 5.0г
ӽ U+04FD / Ӽ U+04FCHA WITH HOOK, 5.0х
ӿ U+04FF / Ӿ U+04FEHA WITH STROKE, 5.0х
ԓ U+0513 / Ԓ U+0512EL WITH HOOK, 5.0л
ԡ U+0521 / Ԡ U+0520EL WITH MIDDLE HOOK, 5.1л
ԣ U+0523 / Ԣ U+0522EN WITH MIDDLE HOOK, 5.1н
ꚋ U+A68B / Ꚋ U+A68ATE WITH MIDDLE HOOK, 5.1т
ԥ U+0525 / Ԥ U+0524PE WITH DESCENDER, 5.2п
ԧ U+0527 / Ԧ U+0526SHHA WITH DESCENDER, 6.0һ
ԩ U+0529 / Ԩ U+0528EN WITH LEFT HOOK, 7.0н
ԯ U+052F / Ԯ U+052EEL WITH DESCENDER, 7.0л

The split

Over every Cyrillic letter named "<base> WITH <hook, stroke, descender, tail, tick, upturn or bar>" whose base letter exists: Unicode 1.1, 36 fold to the base letter and 2 (ҷ/Ҷ, CHE WITH DESCENDER) to a wrong letter, a separate bug; 3.0, 2 of 2; 3.2, 8 of 8; then 4.1, 0 of 2; 5.0, 0 of 8; 5.1, 0 of 6; 5.2, 0 of 2; 6.0, 0 of 2; 7.0, 0 of 4. The same check also lists three Unicode 15.0 subscript and modifier letters of this kind (U+1E067, U+1E06B, U+1E06D); they do fold, through their compatibility decomposition to the older ґ, ҫ and ұ, which have block lines.

Not claimed here

89 Latin letters with a stroke, bar, hook, descender, tail, curl or similar mark are unfolded too: 76 added in Unicode 6.0-16.0, and 13 older ones (Unicode 1.1-5.1) that the ASCIIFoldingFilter-derived Latin block lists only as commented-out placeholders (such as #019B>, DiacriticFolding.txt:283). That block deliberately leaves many letters out, as ASCIIFoldingFilter does, so they are not part of this report.

Exposure and likelihood203 words
  • Who is exposed. Analysis chains with ICUFoldingFilter: built by hand, with CustomAnalyzer or the icuFolding SPI name, or through a downstream product that wraps the filter. No Lucene analyzer adds it by default; once chosen, its default data is enough.
  • Which text. Cyrillic text in an orthography that uses one of the 24 letters. Unicode's NamesList names modern Abkhaz for ԥ (cross-referenced to ҧ, "older orthographies for Abkhasian"), Yupik for ӷ, Itelmen for ԓ and Nenets for ԯ.
  • Effect. Deterministic, for every token with one of these letters: it keeps its hook, stroke or descender, so the same word written with the older letter form or with the plain base letter (for example on a keyboard without the letter) gives a different term, and the query misses. Either direction misses: a modern-spelling document is missed by the older and the plain spelling too. No exception, no index damage, nothing in the logs; each exact spelling still matches itself.
  • How likely. Rare for most indexes: the text must use one of these minority-language letters and the spellings must differ. Nobody has reported it since 3.1.0 (2011).
  • Fixing it changes indexed terms: fields that contain these letters need a reindex after the fix.

Minimal repro

The test shows that ICUFoldingFilter leaves the descender on Cyrillic ԥ (U+0525 PE WITH DESCENDER, Unicode 5.2, modern Abkhaz), while it folds the older form ҧ (U+04A7 PE WITH MIDDLE HOOK) to п.

TestICUFoldingPeWithDescender.java (JUnit test)28 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.icu;

import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;

public class TestICUFoldingPeWithDescender extends BaseTokenStreamTestCase {

  public void testPeWithDescenderFoldsToPe() throws Exception {
    // expected: PE WITH DESCENDER U+0525 folds to PE U+043F, as PE WITH MIDDLE HOOK U+04A7 does; actual: unchanged
    assertTokenStreamContents(
        new ICUFoldingFilter(whitespaceMockTokenizer("ԥ")), new String[] {"п"});
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-877/, 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/icu/build/libs/lucene-analysis-icu-$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.icu.TestICUFoldingPeWithDescender
# the module(s) analysis-icu also need their third-party runtime jars
Expected vs actual
Expected ԥ to fold to п (U+043F); actual: ԥ unchanged (term 0 expected:<[п]> but was:<[ԥ]>), with -ea and -da.

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

More files, environment, full output38 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: term 0 expected:<[п]> but was:<[ԥ]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:262)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:598)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:622)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:689)
       org.apache.lucene.analysis.icu.TestICUFoldingPeWithDescender.testPeWithDescenderFoldsToPe(TestICUFoldingPeWithDescender.java:25)

Why it looks unintended

  • The javadoc lists "Diacritic removal (including stroke, hook, descender)" with no coverage hedge; the header says "Created from Unicode 5.2 UCD", yet 18 of the 24 letters are in 5.2.
  • Every older letter of the kind folds, including ҧ, the counterpart of ԥ.
  • UTR#30 says its foldings must grow with Unicode; nothing records a decision to exclude these letters.
The full argument308 words
  • Contract. ICUFoldingFilter.java:33: "Diacritic removal (including stroke, hook, descender)". The module overview: search term folding "removes distinctions (such as accent marks) between similar characters" and is "a more thorough replacement for ASCIIFoldingFilter and LowerCaseFilter that applies the same ideas to many more languages". Unlike ASCIIFoldingFilter ("only those characters with reasonable ASCII alternatives are converted"), it has no coverage hedge.
  • The block's stated job. "Cyrillic script \"composed\" that do not further decompose, so decompose here" (DiacriticFolding.txt:597). The 24 letters are exactly such characters.
  • The file claims Unicode 5.2. "Created from Unicode 5.2 UCD" (:24), yet 18 of the 24 code points (Unicode 4.1-5.2) existed when the block was copied in 2010. In that 2010 file the three large sections already had post-4.0 entries (157 in the [:Diacritic:] list, 117 in the Latin block, 113 in "Additional signs and diacritic"); the Cyrillic block had none (as did the small "Additional Arabic/Hebrew decompositions" block).
  • Old and new siblings disagree. ҧ, ғ, ҕ, ҳ, ӆ, ң, ӈ, ҭ and 38 more older letters fold to their base letter; ԥ, the modern counterpart of ҧ, and the 23 other newer letters do not. Only the Unicode version decides.
  • The source expects growth. UTR#30 section 5.3: "As characters are added to [Unicode] many of them need to be added to the definition of foldings described here." Its data file: "Additional instances of such characters are expected to be added over time, so there will be future additions to this table."
  • No decision recorded. Where the maintainers chose not to fold a Latin letter, the file keeps a commented-out placeholder (67 lines such as #019B>); the Cyrillic block has none. No test, CHANGES entry or issue mentions these letters, no upstream test asserts any Cyrillic folding of the filter, and no commit ever had a line for any of them.
The devil's-advocate check315 words

The strongest case for "intended": the javadoc says the filter applies "foldings from UTR#30", and the module overview says it "implements many of the foldings specified in" that report. UTR#30 says its foldings "cover Unicode, Version 4.0" and lists diacritic removal under "Provisional foldings". In its table the source set of "Diacritic removal (includes stroke, hook, descender)" is "Latin/Greek/Cyrillic characters with diacritics <>", where <> means the characters listed in its data file, which covers Unicode 4.0 and lists none of these letters. The report is a withdrawn draft; the javadoc names kinds of folding of a finite table. Maintainers knowingly left the block static: in #4820 (LUCENE-3747) "Other sections were based either on AsciiFoldingFilter or UTR#30, neither of which has changed" and "It doesn't need to be perfect"; in #11274 the verbatim sections are "(basically hacks)"; in #2420 removing diacritics is called "language-specific". ICU's root collation keeps these letters, old and new, primary-distinct from their base letters (70 of the 72; only ґ/Ґ GHE WITH UPTURN is primary-equal). So this is a data-coverage enhancement.

Why it fails: the report did limit its foldings to Unicode 4.0, but it also says its table must grow as characters are added, so its 4.0 listing reads as a provisional snapshot rather than a decision to leave later letters unfolded; this is an argument, not proof. Lucene maintains its own copy: its Latin section comes from ASCIIFoldingFilter, and the file introduced in 2010 already had post-4.0 source code points in its other large sections, while the Cyrillic block had none. "Neither of which has changed" explains why the 2012 automation skipped the block; it is not a decision that newer letters stay unfolded. The language-specific and collation points apply equally to the older letters, which the filter already folds. Verdict: not intended, a coverage defect; the label (bug or improvement) is the committers' call.

Suggested fix

Recommended Add the 12 missing small-letter lines to the Cyrillic block and regenerate utr30.nrm

Add 12 small-letter lines (04F7>0433 ... A68B>0442) after the last line of the Cyrillic block and run regenerateUtr30DataFiles. Exactly the 24 code points change, each to its base letter; the repro passes and analysis-icu runs 160 tests with 0 failures (10 skipped). Indexed terms change, so affected fields need reindexing.

All options and trade-offs5 options · 829 words

Two options were applied in a scratch worktree at a9779ef3c193 and run through gradle with the upstream regeneration task; the others were not run.

Add the 12 missing small-letter lines to the Cyrillic block and regenerate utr30.nrm (validated by the checks) (recommended)

Twelve lines go after 04CE>043C (DiacriticFolding.txt:626); each maps a small letter to the base letter named in its Unicode name, as 24 of the block's 26 existing lines do (for example 0493>0433 GHE WITH STROKE, 04A7>043F PE WITH MIDDLE HOOK); the other two are 04B7>04BC (CHE WITH DESCENDER to ABKHASIAN CHE, the separate bug) and 04CC>04BC (KHAKASSIAN CHE, whose name has no "WITH"). Capitals need no line: they are case-folded to the small letter first. Then ./gradlew :lucene:analysis:icu:regenerateUtr30DataFiles rebuilds utr30.nrm and updates the two SHA-1 values in src/generated/checksums/regenerateUtr30DataFiles.json (it compiles ICU4C 78.3 and downloads ICU's NFKC data). Add a test like the repro's first one to TestICUFoldingFilter.

--- a/lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt
+++ b/lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt
@@ -624,6 +624,18 @@
 04CA>043D
 04CC>04BC
 04CE>043C
+04F7>0433
+04FB>0433
+04FD>0445
+04FF>0445
+0513>043B
+0521>043B
+0523>043D
+0525>043F
+0527>04BB
+0529>043D
+052F>043B
+A68B>0442
 
 # Additional signs and diacritic, from examination of [:Mark:]&[:Lm:]
 # Rule: verbatim
(+ utr30.nrm and the two checksum values, produced by regenerateUtr30DataFiles)

Trade-offs

  • Reindexing. Indexed terms change for text with these letters: documents indexed before the fix keep аԥсуа, new queries produce апсуа. Add a CHANGES entry (and, if the committers want, a MIGRATE note) that ICUFoldingFilter output changes for these 24 code points and affected fields need reindexing. The table is already regenerated at every ICU upgrade, and lucene/MIGRATE.md:873 is a precedent for an analysis change that asks users to reindex (Romanian analysis).
  • No API change; no performance effect is expected (12 more mappings in a 61 KB table); not benchmarked.
  • Copies of the data file outside Lucene need the same change separately.

What the checks validated

  • Stock main a9779ef3c193 (gradle, seed DEADBEEF): the repro fails 2 of 2 with assertions on and off; TestICUFoldingFilter and TestICUFoldingFilterFactory pass (6 tests); the generated-file checksum check passes.
  • With the 12 lines, the upstream regenerateUtr30DataFiles task (ICU4C 78.3 built from source, ICU's NFKC data downloaded) changes exactly DiacriticFolding.txt (+12 lines), utr30.nrm (61,776 to 61,888 bytes, SHA-1 83092802...) and two checksum values; that utr30.nrm is byte-identical to the one the hunt built with gennorm2 and icupkg -tb.
  • An exhaustive comparison of the stock and the fixed utr30.nrm over all 1,112,064 Unicode scalar values, each normalized on its own, differs at exactly the 24 code points, each now folding to its base letter.
  • With the fix: the repro passes 2 of 2 with assertions on and off; the two upstream suites pass (6 tests); the checksum check, ecjLintTest and checkGoogleJavaFormat pass; fix-with-test.diff applies cleanly with git apply, and all of :lucene:analysis:icu:test passes with it (17 classes, 160 tests, 0 failures, 10 skipped), with assertions on and off.

The same, together with the sibling one-line fix for ҷ (04B7>04BC to 04B7>0447) (validated by the checks)

For one issue or PR on the Cyrillic block: the 12 lines above plus the separate fix that makes CHE WITH DESCENDER fold to ч instead of the different letter ҽ.

Trade-offs

Two independent changes in one PR; each can be reviewed and reverted on its own. Reindexing covers 26 code points instead of 24.

What the checks validated

  • The combined diff applies cleanly to a9779ef3c193. Regenerated by the same task, the table differs from the stock one at exactly 26 code points (the 24 above, plus U+04B6/U+04B7, now folding to ч); this bug's repro, the sibling's repro and the two upstream suites pass together (4 classes, 11 tests, 0 failures); the checksum check passes.

Derive the Cyrillic block from Unicode names at regeneration time

In GenerateUTR30DataFiles, map each Cyrillic letter named "<base> WITH <hook, stroke, descender, tail, ...>" whose base letter exists to that base, so future Unicode versions are covered automatically. Not run.

Trade-offs

A larger change to the generator; the name patterns need care (YERU WITH BACK YER is a ligature-like letter, not a base plus a mark); and the maintainers chose hand-curated lists for this data on purpose.

Document the limit instead

Say in the javadoc that the Cyrillic diacritic table covers the letters of the UTR#30 draft data (Unicode 4.0). Not run.

Trade-offs

Keeps indexes stable but leaves the ҧ/ԥ inconsistency in place.

Workaround for users today

Map the 24 letters to their base letters before the filter (for example with a MappingCharFilter), or pass a custom Normalizer2 to the two-argument constructor. Not run as a workaround. One pitfall was executed: if the custom normalizer is a fixed utr30.nrm loaded with Normalizer2.getInstance(InputStream, name, mode), give it a name other than "utr30". ICU4J caches these instances by name, so once ICUFoldingFilter.NORMALIZER is loaded, a second load under "utr30" returns the stock normalizer, which leaves ԥ unchanged.

Trade-offs

Both change indexed terms, so they also need a reindex. The factory's filter argument cannot help: it can only exclude characters from folding.

Downstream note

Downstream products that wrap this filter inherit the gap, and so do copies of the data file elsewhere: GitHub code search finds 92 files with the block's line 04A7>043F and none with any of the proposed new lines.

How it was found

An exhaustive check over all Unicode scalar values compared each Latin, Greek and Cyrillic letter named "<base> WITH <mark>" with the fold of its base letter, by the Unicode version that added it. For Cyrillic the cut was exact: up to 3.2 every such letter loses its mark, from 4.1 to 7.0 none does.

The method in full218 words

Method: bounded-exhaustive translation validation of the folding data (semi-formal tier), in a hunt unit that checked the scope of ICUFoldingFilter's diacritic removal against the UTR#30 domain and against collation primaries.

  • The harness ran the stock main jar (ICU4J 78.3, Unicode 17) over every Unicode scalar value and, for each Latin, Greek and Cyrillic letter whose name is "<base> WITH <diacritic>" with an existing base letter, compared fold(letter) with fold(base), bucketed by Unicode age. An earlier unit's name oracle had listed the unfolded letters without filing them; the age split was found here (2026-10-28).
  • Cyrillic: 48 of 48 letters of this kind from Unicode 1.1-3.2 lose the mark (46 to the base letter, 2 to a wrong letter, a separate bug), 0 of 24 from 4.1-7.0. The 3 Unicode 15.0 subscript and modifier letters of this kind fold through their compatibility decomposition. Latin has 89 unfolded letters of this kind (76 from Unicode 6.0-16.0, 13 older ones that the Latin block keeps as commented-out placeholders), not claimed because the Latin block deliberately leaves letters out.
  • The finding was reduced to a public-API JUnit and checked by an independent reproducer with its own oracle, reading-only reviews by a model from another family (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication, a history, prior-report and security follow-up, and an audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-28): 163 queries over GitHub issues, PRs, commits and code, JIRA, the Lucene and Solr lists, downstream trackers and the web, plus the file lists of all 410 open PRs. GitHub code search finds none of the proposed new lines in any copy of the file.

Searches4 searches

Verdict new: no report by anyone. Keyword search cannot prove absence; private reports cannot be searched. The nearest items are below; none of them is this bug.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), Elasticsearch, OpenSearch and Solr trackers; dev@, java-user@, issues@, commits@; the JIRA archive; the web2026-10-28the hunt's prior-report tool (51 queries: class and file names, Abkhaz, Cyrillic descender/hook/stroke, code points)no report; nearest #9177 (LUCENE-8129), #2420 (LUCENE-1343), #4820 (LUCENE-3747)
apache/lucene, apache/lucene-solr, Lucene.NET, ICU4N, Elasticsearch, OpenSearch, Solr, unicode-org/icu, and all of GitHub2026-10-2867 more issue and PR searches (Abkhaz, Yupik, Nenets, Itelmen, the code points, "Cyrillic Supplement", utr30, "Rule: verbatim") and 9 code searchesnothing on point; 92 files carry the existing line 04A7>043F, none any of the 8 proposed lines queried
the 410 open apache/lucene PRs (file lists); PRs and issues created since 2026-10-242026-10-28changed-file lists; titlesonly #16911 touches utr30, deleting the branch_10x tree; nothing on ICU folding
JIRA (LUCENE, SOLR, LUCENENET); the Lucene and Solr user lists; the web in English and Russian2026-10-289 JIRA, 18 list and 10 web queries; related threads read in fullno report; #4820 explains why the block was never extended ("neither of which has changed") but names no missing letter; #11274 removed duplicate verbatim lines

History

Born with the filter: 3599d0c60b17 (LUCENE-1343, 2010-04-22), first released in 3.1.0, copied from the 2004 UTR#30 draft data. The block was never extended; 17 versions from 3.1.0 to main were run, all affected, covering each of the 13 compiled tables shipped in the 138 releases. 20 letters are visible from 3.1.0, the 4 of Unicode 7.0 from 5.0.0.

Versions tested7 rows

Introduced with the filter by 3599d0c60b17 "LUCENE-1343: Add ICUFoldingFilter, normalizes unicode text for search" (2010-04-22, svn trunk r936657; issue #2420, no PR), backported to branch_3x as 534809c2eae (2010-05-06), which shipped in 3.1.0. git blame -w gives 29 of the 30 block lines to 3599d0c60b17 and the marker # Rule: verbatim to 5abc76ea4221 (LUCENE-3747, 2012). The Unicode 6.0 and 7.0 upgrades (LUCENE-2797 d0c3026c316, LUCENE-5995 7a50635bc4c) added mark entries elsewhere in the file but no line for these letters, and git log --all -G finds no commit that ever had one. Wrong from the start, so not a regression.

A letter becomes a gap in the first release whose ICU4J assigns it (unassigned code points pass through unchanged): 20 letters (Unicode 4.1-6.0) from 3.1.0 with ICU4J 4.6, the 4 of Unicode 7.0 from 5.0.0 with ICU4J 54.1. Every other release ships a table that was executed: the 138 releases from 3.1.0 to 10.5.2 carry 13 distinct compiled tables (utr30.nrm in each release's ICU analysis jar, compared by SHA-1), and the main() ran on at least one release of each. The 26 mapping lines of the block are also identical at all 129 release tags in the history clone and at every commit that changed the file without deleting it. Upstream main c5ab3e57b9ba has the file byte-identical; its last change is fe5a12300c6 (ICU 78.1 bump, 2025-11-12 UTC).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's JUnit fails 2 of 2 through gradle (assertions on and off); passes with the fix
branch_10_6 (heads 2763db11b0a, 4ae946e5a9b) readidentical Cyrillic block
9.12.3, 10.0.0, 10.5.2 affectedthe JUnit fails 2 of 2 (10.5.2 with -ea and -da); the main() fails
5.0.0, 5.5.5, 6.6.6, 7.3.1, 7.4.0, 7.7.3, 8.11.4, 9.0.0, 9.10.0 affectedthe standalone main(): 24 of 24 letters keep their mark; 8 of 8 controls fold
3.1.0 (first), 3.6.2, 4.0.0, 4.10.4 first release 3.1.0the main(): all 20 assigned letters keep their mark (the 4 of Unicode 7.0 are unassigned there)
every other release, 3.1.0 to 10.5.2 same tableships a utr30.nrm byte-identical to that of a release above (13 distinct tables in 138 releases, each executed)
3.0.3 and earlier API absentno ICUFoldingFilter

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own runs, controls passing; reading-only reviews by a model from another family (OpenAI GPT-5.6) disagreed only on the label; an adjudication and an audit re-ran it, and this doc's gradle runs reproduce it and validate the fix.

Checks and verdicts334 words

Every executing check wrote or re-ran a repro on stock main, and every control passed. The cross-model reviews could not execute code on this host, so all execution evidence comes from the hunt's own runs.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive name and age oracle, then the JUnit: fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; passes with a gennorm2-rebuilt table, as do the upstream folding tests (6 of 6).
independent reproducer realOwn 4-test JUnit written from the claim and its own age oracle: fails 2 of 4 on main (-ea, -da), 10.5.2 and 9.12.3, controls pass; passes 4 of 4 with the rebuilt table. Calls it a Low defect; an enhancement framing would be defensible.
cross-model reviews (OpenAI GPT-5.6, reading only) intended lens: real, low; refute lens: not real, low, procedural onlyNeither review could run code. The intended lens found the inputs in contract and the intended argument moderate; the refute lens argued "coverage enhancement" and says its verdict is not evidence against the result. Separate re-runs failed 2 of 2 each time.
intended-behaviour devil's advocate real, moderateBuilt the UTR#30-scope case for intent and rejected it; measured the 2010 file's post-4.0 entries per section. Re-ran the JUnit on main (-ea, -da) and 10.5.2: 2 of 2 fail.
adjudication confirmed, mediumRe-ran the independent reproduction's JUnit with -da (2 of 4 fail, controls pass) and the finder's with -ea (2 of 2 fail).
audit keepRe-ran the JUnit on main with a new seed: 2 of 2 fail; code and history re-read.
this doc's runs reproduced; fix validatedGradle in a clean worktree: the repro fails 2 of 2 on stock (assertions on and off) and passes 2 of 2 with the 12-line fix applied through the upstream regeneration task; lucene/analysis/icu runs 160 tests with 0 failures with it, and the fixed table differs from the stock one at exactly 24 code points.

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

Finding record

LFV-877, snapshot of 2026-11-06

Registry title
ICUFoldingFilter removes the hook, stroke or descender from older Cyrillic letters such as ҧ (U+04A7) but from none of the 24 letters of that kind added in Unicode 4.1-7.0, such as its modern Abkhaz counterpart ԥ (U+0525), so the two spellings of a word give different terms
Area
analysis / icu (ICUFoldingFilter UTR#30 folding data: the hand-written 'Rule: verbatim' Cyrillic block of DiacriticFolding.txt, compiled into utr30.nrm)
Priority
Low, rank 512: The opt-in ICUFoldingFilter leaves the mark on 24 newer Cyrillic hook, stroke and descender letters used in minority-language spelling, which only hurts relevance.
Impact class
minor
Impact note
Relevance only, no error: applications that use the optional analysis-icu ICUFoldingFilter or ICUFoldingFilterFactory (its default normalizer, no setting needed) and index Cyrillic text of a minority-language orthography that uses one of these letters (modern Abkhaz ԥ, Yupik, Itelmen, Nenets, Khanty and others) get a different term for a word spelled with the newer letter than for the same word spelled with the older letter form or the plain base letter, so such queries silently miss the document while the exact spelling still matches itself; no scale is needed, -ea and -da behave the same, every release from 3.1.0 to 10.6.0 (10.6.0 by reading) and main are affected, and because a fix changes indexed terms, affected fields need reindexing.
Summary
You index Abkhaz text in the older spelling 'аҧсуа' with ICUFoldingFilter, whose javadoc lists 'Diacritic removal (including stroke, hook, descender)' and which folds ҧ (U+04A7) to п, and you expect the modern spelling 'аԥсуа', written with ԥ (U+0525, the modern-orthography counterpart of U+04A7), to find it. Instead the hand-copied Cyrillic table in DiacriticFolding.txt, taken from the 2004 UTR#30 draft data for Unicode 4.0 and never extended, has no line for ԥ or for any of the other 23 hook, stroke or descender letters added in Unicode 4.1-7.0 (ӷ ӻ ӽ ӿ ԓ ԡ ԣ ꚋ ԧ ԩ ԯ and the capitals), so they are only case-folded and keep the mark, and the query finds 0 documents. All 48 older letters of the same kind lose the mark (46 fold to their base letter; ҷ/Ҷ fold to a wrong letter, the separate bug LFV-855).
Introduced
3599d0c60b17e395b9cd3a3f745187a5d32bb8be (svn trunk r936657, 'LUCENE-1343: Add ICUFoldingFilter, normalizes unicode text for search', issue LUCENE-1343 = #2420, by GitHub rmuir; no PR), backported to branch_3x as 534809c2eae7addcd93ede794e1e42d72a87e105 (r941694, 'LUCENE-1343: backport'), the commit that shipped in 3.1.0 [read: git blame puts all 26 mapping lines of the Cyrillic block on 3599d0c60b17, which copied them unchanged from the UTR#30 draft data ('DRAFT 1 - 2004-07-14', 'Base version of Unicode: 4.0.0') while its own header says 'Created from Unicode 5.2 UCD'; the ICU upgrades LUCENE-2797 (Unicode 6.0, d0c3026c316) and LUCENE-5995 (Unicode 7.0, 7a50635bc4c) added mark entries elsewhere in the file but no line for these letters; LUCENE-3747 (5abc76ea422, 2012) marked the block '# Rule: verbatim', which GenerateUTR30DataFiles copies unchanged; git log -G over all utr30 data finds no commit that ever had a mapping line for any of the 12 small letters] [exec: 3.1.0, the first release with the API, already leaves all 20 of the letters its ICU4J 4.6 assigns unfolded]. Wrong from the start, so not a regression (2010-04-22 (trunk author date 2010-04-22T08:47:49Z; branch_3x backport 2010-05-06))
First affected version
3.1.0 [exec] (the first release with ICUFoldingFilter; its ICU4J 4.6 = Unicode 6.0 assigns 20 of the 24 letters; the 4 Unicode 7.0 letters U+0528/0529/052E/052F are assigned, and unfolded, from 5.0.0 with ICU4J 54.1, before which they were unassigned code points that pass through unchanged anyway; 3.0.3 [exec]: API absent, no lucene-icu artifact on Maven Central)
Affected versions
every release from 3.1.0 to 10.5.2, 10.6.0 (by reading) and main a9779ef3c193 [exec: the follow-up's portable history harness reports every assigned letter of the 24 still marked and the Abkhaz spellings different on 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main, with all 8 control letters folding on each, and 0 of 24 on main with the patched utr30.nrm; the finder's JUnit fails 2 of 2 on 9.12.3 and 10.0.0 (-ea) and on 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails 2 of 4 on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check reproduced on main (-ea, -da) and 10.5.2; re-runs on main 2026-10-28] [read: the 26 mapping lines are byte-identical at all 18 commits that touched DiacriticFolding.txt and at the release tags 3.1, 3.6.2, 4.0.0, 5.0.0, 9.0.0 and 10.0.0; branch_10_6 (10.6.0, heads 2763db11b0a and 4ae946e5a9b) has the identical Cyrillic block; upstream main c5ab3e57b9ba has the file byte-identical, last changed by fe5a12300c6d (ICU 78.1 bump, author date 2025-11-12)]
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-28, read-only; the prior-report record, verdict new, confidence high): 163 logged queries, among them a scripted prior-report search (51 queries over GitHub apache/lucene issues and PRs, commits, the Elasticsearch, OpenSearch and Solr trackers, the dev, java-user, issues and commits lists, LUCENE JIRA and the web), 67 more GitHub issue/PR queries (apache/lucene; Elasticsearch, OpenSearch, Solr, lucene-solr, Lucene.NET, ICU4N, unicode-org/icu and all of GitHub), 9 GitHub code searches (the existing block line '04A7>043F' matches 92 copies of the file; none of the 8 proposed fix lines queried matches any file), 9 JIRA queries (LUCENE, SOLR, LUCENENET), 18 extra list queries and 10 web searches (English and Russian), plus the file lists of all 410 open PRs (only #15, #681, #15825 and #16911 touch lucene/analysis/icu; only #16911, which deletes the branch_10x tree, touches utr30). Nearest items, none about these letters: #4820 (LUCENE-3747, made the block verbatim), #11274 (LUCENE-10238), #2420 (LUCENE-1343, introduced the block), #9177 (LUCENE-8129). Not fixed upstream: DiacriticFolding.txt on upstream main c5ab3e57b9ba is byte-identical to a9779ef3c193, last changed by fe5a12300c6d (ICU 78.1 bump, author date 2025-11-12). Re-check 2026-10-28: none of the 22 apache/lucene issues and PRs created since 2026-10-26 (to #16928) concerns ICU folding, no open PR mentions ICUFoldingFilter, and the newest item matching 'utr30' was last updated in 2022 (one call was rate-limited and retried)
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): this doc's JUnit through gradle in a clean worktree, stock and with the 12-line fix applied through the upstream regenerateUtr30DataFiles task (and the variant combined with LFV-855's one-line fix), assertions on and off, plus TestICUFoldingFilter, TestICUFoldingFilterFactory, all of lucene/analysis/icu, the generated-file checksum check, ecjLintTest and checkGoogleJavaFormat (2026-10-28); an exhaustive comparison of the stock and the fixed utr30.nrm over all 1,112,064 Unicode scalar values. Hunt runs: the JUnit on 9.12.3, 10.0.0, 10.5.2 and main; a standalone Java main() (each release's Lucene and ICU4J jars) on 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main. Fact-check runs (2026-10-28): the same main() on 7.3.1, 7.4.0 and 9.10.0, so that each of the 13 distinct utr30.nrm tables shipped in the 138 releases from 3.1.0 to 10.5.2 was executed; an index probe of all 9 index/query pairs of the three Abkhaz spellings on main.

Lucene and upstream sources

Finding record
  • lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt:24 (at a9779ef3c193) ('### Created from Unicode 5.2 UCD'), :597-599 (block comment 'Cyrillic script "composed" that do not further decompose, so decompose here', 'These are from UTR#30 DiacriticFolding.txt', '# Rule: verbatim'), :601-626 (26 mapping lines 047D>0461 .. 04CE>043C, among them 04A7>043F; none above U+04CF)
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUFoldingFilter.java:23-24 (at a9779ef3c193) ('applies search term folding to Unicode text, applying foldings from UTR#30 Character Foldings'), :33 ('Diacritic removal (including stroke, hook, descender)'), :66-76 (NORMALIZER loaded from the utr30.nrm class resource)
  • lucene/analysis/icu/src/resources/org/apache/lucene/analysis/icu/utr30.nrm (at a9779ef3c193) (sha1 91e180cb4918f02b94718c1fa5f8584cec1a0082; byte-identical to a gennorm2 rebuild of the stock sources)
  • GitHub apache/lucene #4820 (LUCENE-3747), #11274 (LUCENE-10238), #2420 (LUCENE-1343), #9177 (LUCENE-8129), read-only
The finding in 30 seconds
  • lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt:24 (header), :35-36 ([:Diacritic:] rule), :597-626 (the block; :605, :613, :622, :626)
  • lucene/analysis/icu/src/data/utr30/nfkc_cf.txt (0524>0525, 04F6>04F7, A68A>A68B: capitals case-folded to small letters)
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUFoldingFilter.java:22-33, :64-87 (javadoc; NORMALIZER from utr30.nrm; constructors)
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUFoldingFilterFactory.java:40-64 (SPI name icuFolding; filter = FilteredNormalizer2)
  • lucene/analysis/icu/build.gradle:77-135 (regenerateUtr30DataFiles); lucene/analysis/icu/src/tools/java/org/apache/lucene/analysis/icu/GenerateUTR30DataFiles.java:75-76, :122-154 (verbatim lines kept)
  • lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt:283, :294, :297, :347, :367, :378, :391, :405, :477, :489, :502, :504, :532 (the commented-out placeholders #019B> ... #2C79> of the 13 older Latin letters)
Why it looks unintended
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUFoldingFilter.java:23-33; lucene/analysis/icu/src/java/overview.html:286-287, :290, :298-299
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ASCIIFoldingFilter.java:31-32 (coverage hedge)
  • lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt:24, :259-260, :283 (first of 67 '#XXXX>' placeholders), :597-626
Suggested fix
  • lucene/analysis/icu/build.gradle:77-135; lucene/analysis/icu/src/generated/checksums/regenerateUtr30DataFiles.json
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUFoldingFilterFactory.java:51-58 (filter = exclusion only)
  • lucene/MIGRATE.md:873 (Romanian analysis: 'you should reindex Romanian documents')
  • lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt:618 (04B7>04BC), :625 (04CC>04BC)

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

Repro files

About the diagrams

Made in parallel with this doc; every term, fold and hit count on it comes from an executed probe on main a9779ef3c193, stock and with the fixed table.

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.