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.
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.- You add
ICUFoldingFilterwith its default settings at index and query time, and index the Abkhaz word "аҧсуа" in its older spelling, with ҧ (PE WITH MIDDLE HOOK). - 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 ҧ.
- 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 ԥ). - 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.
- 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.
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.
ICUFoldingFilter(moduleanalysis-icu): a token filter for "search term folding". Its normalizerNORMALIZERloads the compiled data fileutr30.nrm(ICUFoldingFilter.java:69-77); the one-argument constructor uses it, and so doesICUFoldingFilterFactory(SPI nameicuFolding), directly or, with afilterargument, wrapped in aFilteredNormalizer2, which can only exclude characters.utr30.nrm: compiled by the gradle taskregenerateUtr30DataFilesfromsrc/data/utr30/*.txtwith ICU'sgennorm2andicupkg. The task first regenerates the rule-based parts of the tables from Unicode properties and keeps the data lines after a# Rule: verbatimmarker 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
Diacriticproperty, 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.
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".
| small / capital | name (CYRILLIC ... LETTER), Unicode version | base |
|---|---|---|
| ӷ U+04F7 / Ӷ U+04F6 | GHE WITH DESCENDER, 4.1 | г |
| ӻ U+04FB / Ӻ U+04FA | GHE WITH STROKE AND HOOK, 5.0 | г |
| ӽ U+04FD / Ӽ U+04FC | HA WITH HOOK, 5.0 | х |
| ӿ U+04FF / Ӿ U+04FE | HA WITH STROKE, 5.0 | х |
| ԓ U+0513 / Ԓ U+0512 | EL WITH HOOK, 5.0 | л |
| ԡ U+0521 / Ԡ U+0520 | EL WITH MIDDLE HOOK, 5.1 | л |
| ԣ U+0523 / Ԣ U+0522 | EN WITH MIDDLE HOOK, 5.1 | н |
| ꚋ U+A68B / Ꚋ U+A68A | TE WITH MIDDLE HOOK, 5.1 | т |
| ԥ U+0525 / Ԥ U+0524 | PE WITH DESCENDER, 5.2 | п |
| ԧ U+0527 / Ԧ U+0526 | SHHA WITH DESCENDER, 6.0 | һ |
| ԩ U+0529 / Ԩ U+0528 | EN WITH LEFT HOOK, 7.0 | н |
| ԯ U+052F / Ԯ U+052E | EL WITH DESCENDER, 7.0 | л |
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.
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, withCustomAnalyzeror theicuFoldingSPI 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.
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 п.
/*
* 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[] {"п"});
}
}
# 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-eaand-da.
More files, environment, full output38 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> org.junit.ComparisonFailure: term 0 expected:<[п]> 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)- 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". UnlikeASCIIFoldingFilter("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.
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) thatICUFoldingFilteroutput changes for these 24 code points and affected fields need reindexing. The table is already regenerated at every ICU upgrade, andlucene/MIGRATE.md:873is 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;TestICUFoldingFilterandTestICUFoldingFilterFactorypass (6 tests); the generated-file checksum check passes. - With the 12 lines, the upstream
regenerateUtr30DataFilestask (ICU4C 78.3 built from source, ICU's NFKC data downloaded) changes exactlyDiacriticFolding.txt(+12 lines),utr30.nrm(61,776 to 61,888 bytes, SHA-1 83092802...) and two checksum values; thatutr30.nrmis byte-identical to the one the hunt built withgennorm2andicupkg -tb. - An exhaustive comparison of the stock and the fixed
utr30.nrmover 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,
ecjLintTestandcheckGoogleJavaFormatpass;fix-with-test.diffapplies cleanly withgit apply, and all of:lucene:analysis:icu:testpasses 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.
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.
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.
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 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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), Elasticsearch, OpenSearch and Solr trackers; dev@, java-user@, issues@, commits@; the JIRA archive; the web | 2026-10-28 | the 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 GitHub | 2026-10-28 | 67 more issue and PR searches (Abkhaz, Yupik, Nenets, Itelmen, the code points, "Cyrillic Supplement", utr30, "Rule: verbatim") and 9 code searches | nothing 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-24 | 2026-10-28 | changed-file lists; titles | only #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 Russian | 2026-10-28 | 9 JIRA, 18 list and 10 web queries; related threads read in full | no report; #4820 explains why the block was never extended ("neither of which has changed") but names no missing letter; #11274 removed duplicate verbatim lines |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's JUnit fails 2 of 2 through gradle (assertions on and off); passes with the fix |
branch_10_6 (heads 2763db11b0a, 4ae946e5a9b) | read | identical Cyrillic block |
| 9.12.3, 10.0.0, 10.5.2 | affected | the 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 | affected | the 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.0 | the 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 table | ships 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 absent | no ICUFoldingFilter |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Exhaustive 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 | real | Own 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 only | Neither 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, moderate | Built 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, medium | Re-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 | keep | Re-ran the JUnit on main with a new seed: 2 of 2 fail; code and history re-read. |
| this doc's runs | reproduced; fix validated | Gradle 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 472 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,514 words
- 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 upstreamregenerateUtr30DataFilestask (and the variant combined with LFV-855's one-line fix), assertions on and off, plusTestICUFoldingFilter,TestICUFoldingFilterFactory, all oflucene/analysis/icu, the generated-file checksum check,ecjLintTestandcheckGoogleJavaFormat(2026-10-28); an exhaustive comparison of the stock and the fixedutr30.nrmover 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 Javamain()(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 samemain()on 7.3.1, 7.4.0 and 9.10.0, so that each of the 13 distinctutr30.nrmtables 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.
- 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-299lucene/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.jsonlucene/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)
- TestICUFoldingPeWithDescender.java (JUnit test):
repro/LFV-877/src/org/apache/lucene/analysis/icu/TestICUFoldingPeWithDescender.java(28 lines)
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.
assets/LFV-877/bug.png(472 KB)
