All findings · Lucene finding · LFV-855 · utr30-cyrillic-che-with-descender-folds-to-abkhasian-che

ICUFoldingFilter folds the Tajik letter ҷ to the Abkhaz letter ҽ instead of ч, so "Точикистон" does not find "Тоҷикистон"

ICUFoldingFilter should strip the descender from ҷ and give ч, like қ, ҳ and the other WITH DESCENDER letters in its table, but one line maps ҷ to ҽ, a different letter. So "Тоҷикистон" is indexed as "тоҽикистон" and the query "Точикистон" finds nothing, in every release since 3.1.0.

Summary

Priority
Low, rank 309Only Tajik or Abkhaz text in the opt-in ICU folding filter is affected.
Impact
silent wrong results
Since
3.1.0 (LUCENE-1343, 2010)introduced 2010-04-22 · over 16 years
Affected
3.1.0 to 10.6.0 and main
Status
documented
Found by
exhaustive check of the folding data against Unicode character names

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, built from Lucene's DiacriticFolding.txt. A hand-written block of that file strips descenders and strokes from Cyrillic letters: җ to ж, қ to к, ҳ to х, ҹ to ч. One line, 04B7>04BC, sends ҷ to Ҽ (ABKHASIAN CHE) instead, and case folding then makes it ҽ.

Summary card titled: Lucene ICUFoldingFilter folds ҷ (CHE WITH DESCENDER) to a different letter, ҽ (ABKHASIAN CHE). A row of executed folds: ҷ U+04B7 and Ҷ U+04B6 to ҽ; ӌ U+04CC to ҽ (a companion question); ҹ to ч, ҳ to х, қ to к, җ to ж. Left panel: Ҷ U+04B6 is case-folded to ҷ U+04B7, line 618 of DiacriticFolding.txt (04B7>04BC) maps it to Ҽ U+04BC, and case folding gives ҽ U+04BD, where ч is expected; lines 617 to 619 of the block are shown, with ABKHASIAN CHE marked as not the base letter of ҷ, while every sibling WITH DESCENDER line keeps its base. Right panel: the document's index terms ҽумхурии тоҽикистон, the query terms чумхурии точикистон, count(q) = 0 where 1 hit is expected; the phrase query and the icuFolding factory chain give 0 hits too, the exact spelling 1 hit; in Abkhaz ҷ, ҽ and ҿ all fold to ҽ. Notes: affects ICUFoldingFilter and the icuFolding factory with the default normalizer, every release from 3.1.0 to 10.5.2, branch_10_6 and main; copied in 2010 from the 2004 UTR#30 draft data. Fix panel: change line 618 to 04B7>0447 and regenerate utr30.nrm, raise line 625 as a question; it changes indexed terms, so reindex.
One Tajik phrase through ICUFoldingFilter: the document's ҷ becomes ҽ while the query's plain ч stays ч, so the terms differ and the query finds nothing. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You add ICUFoldingFilter with its default settings to the analyzer, at index and query time, and index the Tajik phrase "Ҷумҳурии Тоҷикистон" (Republic of Tajikistan). ҷ is "che with descender", a letter of Tajik and Abkhaz.
  2. You expect the plain-letter spelling "Чумхурии Точикистон" to find it: the filter removes descenders, and in the same phrase ҳ already folds to х, as қ folds to к and ҹ to ч.
  3. Instead the document is indexed as ҽумхурии тоҽикистон and the query as чумхурии точикистон: 0 of 1 documents found. The one-word query "Точикистон" also finds 0.
  4. The cause is one line of the folding table, 04B7>04BC: it maps ҷ to Ҽ (ABKHASIAN CHE), which case folding turns into ҽ. The next line, 04B9>0447, maps ҹ to ч.
  5. No error, and the exact spelling still finds itself. In Abkhaz, which writes ҷ and ҽ as two letters, the two now match each other.

Who is affected

Users of the optional analysis-icu ICUFoldingFilter (its default normalizer; no setting needed) who index Tajik or Abkhaz Cyrillic text. A query typed with plain ч misses every document that writes the word with ҷ, and in Abkhaz ҷ and ҽ match each other. Deterministic at any scale, silent, in every release since 3.1.0.

Mechanism, terms and numbers605 words

ICUFoldingFilter (module analysis-icu) folds text for search; its javadoc lists "Diacritic removal (including stroke, hook, descender)". Its data, utr30.nrm, is compiled from Lucene's own folding tables and ICU's NFKC data. One hand-copied line of DiacriticFolding.txt, 04B7>04BC, maps U+04B7 CYRILLIC SMALL LETTER CHE WITH DESCENDER (ҷ, a letter of Tajik and Abkhaz) to U+04BC CYRILLIC CAPITAL LETTER ABKHASIAN CHE, which case folding turns into ҽ U+04BD. Removing the descender gives ч U+0447, which is what every sibling WITH DESCENDER or WITH STROKE line of the same block does (җ to ж, қ to к, ҳ to х, ҹ to ч). So the Tajik phrase "Ҷумҳурии Тоҷикистон" is indexed as "ҽумхурии тоҽикистон", the query "Чумхурии Точикистон" (the same words with plain letters) becomes "чумхурии точикистон", and it finds 0 documents; in Abkhaz, which writes ҷ and ҽ as separate letters, the two fold to one term. There is no error, and the exact spelling still matches itself. The line was copied in 2010 from the 2004 draft data of UTR#30 (since withdrawn) and is in every release from 3.1.0 to 10.6.0 and on main. The fix is a one-line data change plus a regenerated utr30.nrm; it changes indexed terms for these letters.

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.
  • utr30.nrm: compiled by the gradle task regenerateUtr30DataFiles from src/data/utr30/*.txt with ICU's gennorm2 and icupkg. The task regenerates the rule-based parts of the tables and keeps the data lines after a # Rule: verbatim marker as written, up to the next # Rule: line.
  • The Cyrillic verbatim block (DiacriticFolding.txt:597-626), headed "Cyrillic script \"composed\" that do not further decompose, so decompose here", "These are from UTR#30 DiacriticFolding.txt", "Rule: verbatim". Each line is source>target in hex.

The block (names added)

lineentryletterfolds to
:6070497>0436җ ZHE WITH DESCENDERж ZHE
:609049B>043Aқ KA WITH DESCENDERк KA
:61204A3>043Dң EN WITH DESCENDERн EN
:61404AB>0441ҫ ES WITH DESCENDERс ES
:61704B3>0425ҳ HA WITH DESCENDERХ HA (case-folded to х)
:61804B7>04BCҷ CHE WITH DESCENDERҼ ABKHASIAN CHE (case-folded to ҽ)
:61904B9>0447ҹ CHE WITH VERTICAL STROKEч CHE
:62004BF>04BCҿ ABKHASIAN CHE WITH DESCENDERҼ ABKHASIAN CHE (its own base letter)
:62504CC>04BCӌ KHAKASSIAN CHEҼ ABKHASIAN CHE (same origin; a companion question)

The block has 26 data lines (:601-626), with the same mappings as the small-letter lines of the 2004 UTR#30 draft data file. 24 map to the base letter of the character's Unicode name (for :620 that base is ABKHASIAN CHE itself); only :618 and :625 map to a different letter, ABKHASIAN CHE.

One letter, step by step (stock jar)

Ҷ U+04B6 is case-folded to ҷ U+04B7; line 618 maps ҷ to Ҽ U+04BC; case folding maps Ҽ to ҽ U+04BD. getRawDecomposition(U+04B7) is U+04BC, and normalize gives U+04BD for Ҷ ҷ Ҽ ҽ Ҿ ҿ Ӌ ӌ alike, while Ч and ч give ч. U+04B7 has no NFKC decomposition, so the order in which gennorm2 reads the files (a separate bug, LFV-857) does not matter here.

Not claimed here

Eight Cyrillic descender letters added to Unicode after the 2004 draft (GHE, PE, SHHA and EL WITH DESCENDER, small and capital) keep their descender (the capitals are only case-folded). That is a separate completeness gap with a different fix (missing lines, not a wrong one).

Exposure and likelihood197 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. The default normalizer is enough; no option is involved.
  • Which text. Tajik and Abkhaz Cyrillic text: Unicode's NamesList marks U+04B7 "Abkhasian, Tajik". Tajik writes, for example, "Тоҷикистон" (Tajikistan).
  • Effect. Deterministic for every token with ҷ/Ҷ, which folds to ҽ; what a search then gets wrong depends on the query: one typed with plain ч (for example on a keyboard layout without Tajik letters) does not match the indexed term, so the document is missed; in Abkhaz text a word with ҷ and the same word with ҽ become one term, so they match each other. No exception, no index damage, nothing in the logs; the exact spelling still matches itself.
  • How likely. Rare for most indexes, because the text must be Tajik or Abkhaz, but systematic for a Tajik-language search application. No public report was found (see Prior reports), although the line has shipped since 3.1.0 (2011).
  • Fixing it changes indexed terms: fields that contain ҷ/Ҷ need a reindex after the fix.

Minimal repro

The test shows that ICUFoldingFilter folds the Tajik/Abkhaz letter CHE WITH DESCENDER "ҷ" (U+04B7) to the different letter ABKHASIAN CHE "ҽ" (U+04BD), not to CHE "ч" (U+0447) as every sibling WITH DESCENDER letter keeps its base letter.

TestICUFoldingCheWithDescender.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 TestICUFoldingCheWithDescender extends BaseTokenStreamTestCase {

  public void testCheWithDescenderFoldsToChe() throws Exception {
    // expected: CHE WITH DESCENDER U+04B7 folds to CHE U+0447; actual: ABKHASIAN CHE U+04BD
    assertTokenStreamContents(
        new ICUFoldingFilter(whitespaceMockTokenizer("ҷ")), new String[] {"ч"});
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-855/, 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.TestICUFoldingCheWithDescender
# the module(s) analysis-icu also need their third-party runtime jars
Expected vs actual
Expected term 0 expected:<[ч]>; actual but was:<[ҽ]>, with -ea and -da.

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

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.TestICUFoldingCheWithDescender.testCheWithDescenderFoldsToChe(TestICUFoldingCheWithDescender.java:25)

Why it looks unintended

  • The javadoc lists "Diacritic removal (including stroke, hook, descender)"; the block's own comment says "decompose here".
  • The other WITH DESCENDER and WITH STROKE lines keep the base letter, such as 04B9>0447 (ҹ to ч); ABKHASIAN CHE is a separate letter.
  • The 2004 draft it copies contradicts itself (Ҹ to Ҽ, ҹ to ч); Lucene already departs from it for Ҹ.
The full argument283 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".
  • The block's stated purpose. "Cyrillic script \"composed\" that do not further decompose, so decompose here" (DiacriticFolding.txt:597). ABKHASIAN CHE is not a decomposition of CHE WITH DESCENDER: the Unicode name gives the base letter CHE, and the Unicode 1.0 names were "CHE WITH RIGHT DESCENDER" for U+04B7 and "IE HOOK" for U+04BD.
  • The siblings. Every other WITH DESCENDER or WITH STROKE line of the block keeps the base letter of the character's name, including CHE WITH VERTICAL STROKE (:619, to ч) and ABKHASIAN CHE WITH DESCENDER (:620, to ABKHASIAN CHE); the only other line that sends a different letter to ABKHASIAN CHE is :625 (KHAKASSIAN CHE, same origin).
  • The copied source is inconsistent and withdrawn. The UTR#30 draft data ("DRAFT 1 - 2004-07-14 ... Subject to change") maps capital Ҹ U+04B8 to ABKHASIAN CHE but small ҹ U+04B9 to CHE. Lucene copied only the small-letter lines, so it already folds Ҹ to ч, not to ҽ. The report defines diacritic removal as mapping to "related base characters", so that users can "search for words with accented characters in them by supplying the equivalent word spelled in base letters only"; the Unicode site lists the draft as withdrawn.
  • No test or decision records it. No upstream test asserts any Cyrillic folding, and no javadoc, CHANGES or MIGRATE entry or issue discusses ҷ. LUCENE-1343 (#2420), which added the table, lists five intended differences from ASCIIFoldingFilter, none Cyrillic. In LUCENE-10238 (#11274) the verbatim sections are called "basically hacks" and edited: maintained data, not a frozen copy.
The devil's-advocate check224 words

The strongest case for "intended": the block says "These are from UTR#30 DiacriticFolding.txt" and "Rule: verbatim", and line 618 has the same mapping as the draft (which maps both U+04B6 and U+04B7 to U+04BC). The javadoc names a category, not a promise per character; eight newer Cyrillic descender letters keep their descender. ҷ is a letter of its own in Tajik, so a maintainer may prefer not to fold it, and any change forces a reindex.

Why it fails: Lucene does not reproduce the draft anyway (it differs for Ҹ, and the draft maps a case pair to two different letters); the block's stated job is to decompose, and the target is a third letter that Abkhaz uses next to ҷ, which no reading of "descender removal" yields. "Do not fold ҷ" is a fair position. No current normative Unicode source mandates U+0447 (UTR#30 is a withdrawn draft), so do not claim one; ask the maintainers to choose between ч and not folding ҷ, since either removes the merge with ҽ. Reindexing is the normal cost of a change to this data; every ICU upgrade regenerates utr30.nrm. Framing note from the independent reproducer: a plain-ч query would also miss ҷ if ҷ were left unfolded, so the distinctive harm of today's line is the merge with ҽ and the break from its siblings.

Suggested fix

Recommended Change line 618 to 04B7>0447 and regenerate utr30.nrm

Change line 618 of DiacriticFolding.txt to 04B7>0447 and regenerate utr30.nrm and its checksum file. Then ҷ and Ҷ fold to ч and no other code point changes; the repro passes, and analysis-icu runs 161 tests with 0 failures (10 skipped). Indexed terms change, so affected fields need reindexing.

--- a/lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt
+++ b/lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt
@@ -615,7 +615,7 @@
 04AD>0442
 04B1>04AF
 04B3>0425
-04B7>04BC
+04B7>0447
 04B9>0447
 04BF>04BC
 04C4>043A
(+ utr30.nrm regenerated, + the two new SHA-1 values in regenerateUtr30DataFiles.json)
All options and trade-offs4 options · 829 words

One fix and one variant were applied in a scratch worktree at a9779ef3c193 and run through gradle; the other options were not run.

Change line 618 to 04B7>0447 and regenerate utr30.nrm (validated by the checks) (recommended)

One line of DiacriticFolding.txt changes. The binary utr30.nrm (61,776 bytes before and after) and the two SHA-1 values in src/generated/checksums/regenerateUtr30DataFiles.json follow from it: upstream, ./gradlew :lucene:analysis:icu:regenerateUtr30DataFiles produces both (the task runs GenerateUTR30DataFiles, downloads ICU's NFKC data files and compiles ICU4C 78.3). Here the file was rebuilt with only that task's last two steps (gennorm2 over the sorted *.txt, then icupkg -tb) and ICU4C 77.1, whose rebuild of the unchanged sources is byte-identical to the shipped file. An independent run of the full task, with only line 618 changed, produced a byte-identical utr30.nrm and the same two checksum lines. Add a test such as the repro's testCheWithDescenderFoldsToChe to TestICUFoldingFilter.

--- a/lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt
+++ b/lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt
@@ -615,7 +615,7 @@
 04AD>0442
 04B1>04AF
 04B3>0425
-04B7>04BC
+04B7>0447
 04B9>0447
 04BF>04BC
 04C4>043A
(+ utr30.nrm regenerated, + the two new SHA-1 values in regenerateUtr30DataFiles.json)

Trade-offs

  • Reindexing. Indexed terms change for text with ҷ/Ҷ: 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 U+04B6/U+04B7 and affected fields need reindexing. Earlier changes to this data (ICU upgrades, LUCENE-3747, LUCENE-10238) shipped with plain CHANGES entries and no reindex note; MIGRATE.md has a precedent for an analysis change that asks users to reindex (Romanian analysis, lucene/MIGRATE.md:873).
  • No API change; no performance effect is expected (one mapping changes, the file size does not); not benchmarked.
  • Copies outside Lucene need the same change separately: downstream copies of the text file, and Lucene.NET's compiled utr30.nrm, which would have to be regenerated.

What the checks validated

  • Stock main a9779ef3c193 (gradle, seed DEADBEEF): the repro fails 2 of 3 with assertions on and off; TestICUFoldingFilter and TestICUFoldingFilterFactory pass (6 tests); the generated-file checksum check passes.
  • With the fix: the repro passes 3 of 3 with assertions on and off; the two upstream suites pass (6 tests); all of :lucene:analysis:icu:test with the repro added passes (17 classes, 161 tests, 0 failures, 10 skipped); regenerateUtr30DataFilesChecksumCheck passes with the updated checksum file; ecjLintTest passes.
  • An exhaustive comparison of the stock and the fixed utr30.nrm over all 1,112,064 non-surrogate code points, each normalized on its own, differs at exactly U+04B6 and U+04B7, which now fold to U+0447; an independent fact check repeated it with its own code and got the same 2 code points.
  • The fixed utr30.nrm was rebuilt from the patched sources with gennorm2 and icupkg -tb; the same rebuild of the unchanged sources is byte-identical to the shipped file, and the full upstream task (ICU4C 78.3) with only line 618 changed gives a byte-identical fixed file (SHA-1 d14cf338...).
  • checkGoogleJavaFormat flagged only the new test's line wrapping; the formatted test is the final repro file and was re-run: 2 of 3 fail on stock, 3 of 3 pass with the fix.
  • After the fact checks, the repro's comments were corrected ("selected sibling letters" instead of "every letter"; code unchanged): google-java-format and ecjLintTest pass, it fails 2 of 3 on stock and passes 3 of 3 with the fix, assertions on and off, and fix-with-test.diff was rebuilt from fix.diff plus this file; it applies cleanly.

Also change line 625 to 04CC>0447 (KHAKASSIAN CHE ӌ/Ӌ) (validated by the checks)

The finder's original sketch: the same change plus the neighbouring line with the same origin and the same target.

Trade-offs

Its Unicode 1.0 name was "CHE WITH LEFT DESCENDER", which points to ч, but its current name, KHAKASSIAN CHE, carries no diacritic, the 2004 draft grouped it with ABKHASIAN CHE, and no test asserts it. Treat it as a suspect companion mapping for maintainers to adjudication, not as part of the minimal fix or as a second bug.

What the checks validated

  • With this variant: the repro passes 3 of 3 (assertions on), TestICUFoldingFilter and TestICUFoldingFilterFactory pass (6 tests), and the checksum check passes.
  • Its utr30.nrm is byte-identical to the hunt's rebuilt file, whose exhaustive comparison with the stock one differs at exactly U+04B6, U+04B7, U+04CB and U+04CC.

Delete line 618 (do not fold ҷ at all)

For maintainers who want ҷ to stay distinct. Not run.

Trade-offs

Removes the wrong merge with ҽ, but a query with plain ч still misses ҷ, and it departs from every sibling line of the block.

Workaround for users today

Map ҷ to ч and Ҷ to Ч before the filter (for example with a MappingCharFilter), or exclude them with the factory's filter argument (a UnicodeSet of the characters to fold). Not run.

Trade-offs

Both change indexed terms too, so they also need a reindex; with filter, the excluded letters are not case-folded either. The filter route only removes the merge with ҽ: a plain-ч query still misses, as with not folding ҷ; only the char-filter mapping fixes that.

Downstream note

Lucene.NET ships a compiled utr30.nrm that gives the same fold, and public copies of the file inside downstream plugins carry the same line, so their users get the same fold.

How it was found

An exhaustive check compared, for each of the 1,467 Unicode letters named "<base> WITH ..." whose base letter exists, the filter's output with the fold of the base letter, 5 fold to a different base letter, and for 2 of them, CHE WITH DESCENDER (U+04B6/U+04B7), the wrong mapping comes from one of Lucene's hand-written lines.

The method in full243 words

Method: bounded-exhaustive translation validation of the folding data (semi-formal tier), in a hunt unit that checked the UTR#30 verbatim tables against Unicode character names and the compiled .nrm.

  • An independent model of the source tables (gennorm2's precedence by sorted file name, recursive mapping, canonical reordering) agreed with the shipped utr30.nrm and with gennorm2's own combined output on all 1,114,112 code points (0 mismatches); a mutation of lines 618 and 625 in the model was flagged at exactly the affected code points (U+04B7 and U+04CC in the raw mapping; U+04B6, U+04B7, U+04CB and U+04CC after recursion), and a gennorm2 rebuild of the unchanged sources is byte-identical to the shipped file.
  • A name oracle (ICU4J 78.3, Unicode 17) took the 1,467 letters named "<base> WITH ..." whose base letter exists: 1,050 fold to the base letter's fold, 355 are left unfolded, 57 give the base plus something, and 5 fold to a wrong base letter. Two of the five are U+04B6/U+04B7, from line 618; the other three (U+06C0 and two presentation forms) follow Unicode's own canonical decomposition of U+06C0 into U+06D5 plus hamza above (Lucene's line 637 then removes the hamza as a diacritic), not a wrong Lucene line.
  • The finding was reduced to a public-API JUnit and checked by an independent reproducer, 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. Found 2026-10-28 on main a9779ef3c193.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-28): 221 queries over GitHub issues, PRs, commits and code, the Lucene and Solr lists, JIRA, downstream trackers, the UTR#30 review feedback and the web, plus the changed files of 44 open PRs and of recently closed ones. The issue that added the line never discusses che.

Searches4 searches

Verdict new: no report by anyone. Keyword search cannot prove absence; private trackers and private reports cannot be searched. The nearest items are listed 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, two runs (96 queries: class and file names, Tajik, Abkhasian, "che with descender", code points)no report; nearest #2420 (LUCENE-1343, added the line), #9177 (LUCENE-8129, the filter option), #10052
apache/lucene, apache/lucene-solr, Lucene.NET, ICU4N, Elasticsearch, OpenSearch, Solr, unicode-org/icu, and all of GitHub2026-10-2843 own issue and PR searches (Tajik, Abkhaz, ҷ, U+04B7, 04BC, Khakassian, UTR#30) and 4 code searchesnothing on point; code search finds 92 files with 04B7>04BC (apache/lucene's own included) and none with 04B7>0447 or 04CC>0447
the 411 open apache/lucene PRs (all listed, the file lists of 44 read); PRs closed or updated since 2026-10-20; PRs and issues created since 2026-10-262026-10-28changed-file lists; titlesnone fixes the line; #16911 deletes the file only as part of retiring branch_10x
related threads read in full: #2420, #2464, #4820, #11274, #10806, #10122, #9177; the Lucene, Solr and Lucene.NET lists; JIRA (LUCENE, SOLR, LUCENENET); the public review feedback on the UTR#30 draft2026-10-28comments grepped for che and the code points; 49 list and 11 JIRA queriesno report; two 2014 list threads ask how to change an unnamed mapping in DiacriticFolding.txt

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 line never changed; all 138 releases from 3.1.0 to 10.5.2, 10.6.0 (branch_10_6 4ae946e5a9b) and main fold ҷ to ҽ.

Versions tested5 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 -M -C gives every line of the Cyrillic block to 3599d0c60b17 except the marker # Rule: verbatim (5abc76ea4221, LUCENE-3747, 2012); git log -S '04B7>04BC' --all finds only the two adding commits and aad44bc6e36 (2021), which emptied the old branch_8x tree. Wrong from the start, so not a regression.

The 138 releases and main ship 14 distinct utr30.nrm builds (run here with 16 ICU4J versions, 4.6 to 78.3); on main the file has been regenerated about 20 times (git log --follow: creation plus 20 changes, the last for ICU 78.1), and every rebuild kept the verbatim line. The 3-test JUnit ran on the six refs below; every final release ran the one-token main() (3.1.0 and 3.2.0 with their republished ICU4J 4.6 jar); the 4.0.0 alpha and beta pre-releases were not run. Upstream main c5ab3e57b9b (fetched 2026-10-28) has no change under lucene/analysis/icu since a9779ef3c193, and the branch_10x head 657014b3c57 has the same line.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's JUnit fails 2 of 3 through gradle (assertions on and off); passes with the fix
branch_10_6 head 4ae946e5a9b (10.6.0) affectedthe main() folds ҷ and Ҷ to ҽ; the JUnit fails
9.1.0, 9.12.3, 10.0.0, 10.5.2 affectedthe JUnit fails 2 of 3 (10.5.2 with -ea and -da)
all 138 final releases 3.1.0 to 10.5.2 first release 3.1.0the standalone main() folds ҷ and Ҷ to ҽ; all 8 control letters fold to their base letter
3.0.3 and earlier API absentno ICUFoldingFilter (compile error on the ICU package and class only)

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) agreed on the root cause; an adjudication and an audit re-ran it, and this doc's gradle runs reproduce it and validate the fix.

Checks and verdicts429 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 translation validation and name oracle, then the 3-test JUnit: fails 2 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; passes with the rebuilt table, as do the upstream folding tests (6 of 6).
independent reproducer realOwn 5-test JUnit written from the claim: fails 3 of 5 on main (-ea, -da), 10.5.2 and 9.12.3; passes 5 of 5 with the rebuilt table.
cross-model reviews (OpenAI GPT-5.6, reading only) intended lens: real, medium; refute lens: not real, low, procedural onlyRoot cause agrees, inputs in contract, intended argument moderate. The refute runs could start no command and say this is not evidence against the result.
cross-model adversarial review step uncertain (procedural)The reviewer could not execute; separate runs fail on main and 10.5.2, pass with the fix, and an exhaustive comparison of the two tables differs at exactly 4 code points (the two-line variant).
intended-behaviour devil's advocate real, medium-highBuilt the case for intent and rejected each point; added the Unicode 1.0 names. Re-ran the JUnit on main (-ea, -da) and 10.5.2: 2 of 3 fail.
adjudication confirmed, mediumRe-ran the independent reproduction's JUnit with a new seed on main: 3 of 5 fail with -ea and -da, controls pass. Asks for the minimal patch and KHAKASSIAN CHE as a companion question.
audit keepRe-ran the JUnit (2 of 3 fail) and an independent probe of the normalizer on main.
this doc's runs reproduced; fix validatedGradle in a clean worktree: the repro fails 2 of 3 on stock (assertions on and off) and passes 3 of 3 with the one-line fix; lucene/analysis/icu runs 161 tests with 0 failures (10 skipped) with it, and the fixed table differs from the stock one at exactly 2 code points.
fact checks (an independent Claude check and an independent reviewer from another model family, OpenAI GPT-5.6) bug confirmed; wording errors correctedThe Claude check re-ran the doc's command (2 of 3 fail), the fix (3 of 3 pass), the whole module, the full upstream regeneration task (byte-identical utr30.nrm) and an exhaustive comparison of its own; the cross-model check read the evidence. Both found the bug, the fix and the verdict right and listed wording errors (counts, scopes, a mislabelled diagram section), all corrected.

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

Finding record

LFV-855, snapshot of 2026-11-06

Registry title
ICUFoldingFilter folds the Tajik/Abkhaz letter CHE WITH DESCENDER (ҷ U+04B7, Ҷ U+04B6) to the different letter ABKHASIAN CHE (ҽ U+04BD) instead of to CHE (ч U+0447), so Tajik text is not found by the same words typed with plain ч
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 309: Only Tajik or Abkhaz text in the opt-in ICU folding filter is affected.
Impact class
silent wrong results
Impact note
Applications that use the optional analysis-icu ICUFoldingFilter or ICUFoldingFilterFactory (its default normalizer, no setting needed) and index Tajik or Abkhaz Cyrillic text silently miss documents with ҷ/Ҷ when the query spells the word with plain ч ('Чумхурии Точикистон' does not find 'Ҷумҳурии Тоҷикистон'), and in Abkhaz text the three separate letters ҷ, ҽ and ҿ match each other; the exact spelling still matches itself, nothing throws, no scale is needed, every release from 3.1.0 to 10.5.2, branch_10_6 (10.6.0) and main are affected, and a fix changes indexed terms, so affected fields need a reindex.
Summary
You index Tajik text such as 'Ҷумҳурии Тоҷикистон' with ICUFoldingFilter, whose javadoc lists 'Diacritic removal (including stroke, hook, descender)', and expect the same words typed without the Tajik letters, 'Чумхурии Точикистон', to find it, as they do for ҳ, қ and the other WITH DESCENDER letters. Instead the hand-copied line '04B7>04BC' in DiacriticFolding.txt maps CHE WITH DESCENDER to ABKHASIAN CHE, so ҷ and Ҷ are indexed as ҽ while the query's ч stays ч, and the query gets 0 hits. In Abkhaz, which uses ҷ, ҽ and ҿ as three separate letters, all three fold to ҽ and match each other.
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: the commit created DiacriticFolding.txt and utr30.nrm; git blame -w -M -C on main's file puts the whole Cyrillic block, including line 618 '04B7>04BC' and line 625 '04CC>04BC', on 3599d0c60b17, and only the marker line 599 '# Rule: verbatim' on the later 5abc76ea4221 (LUCENE-3747); git log -S '04B7>04BC' --all finds only the two adding commits and a branch_8x commit that deleted that branch's tree] [exec: 3.1.0, the first release with the API, already folds U+04B7 to U+04BD]. The line was copied, with the rest of the block ('These are from UTR#30 DiacriticFolding.txt'), from the 2004 UTR#30 draft data file (since withdrawn), which itself maps U+04B6/U+04B7 to U+04BC; wrong from the start, so not a regression (2010-04-22 (trunk author date 2010-04-22T08:47:49Z; branch_3x backport author date 2010-05-06T12:27:18Z))
First affected version
3.1.0 [exec] (the first release with ICUFoldingFilter; 3.0.3 [exec]: API absent, the probe fails to compile on the ICU package and class only, and Maven Central has no lucene-icu 3.0.3 artifact)
Affected versions
every release from 3.1.0 to 10.5.2 (all 138 final releases on Maven Central: lucene-icu 3.1.0-3.6.2, lucene-analyzers-icu 4.0.0-8.11.4, lucene-analysis-icu 9.0.0-10.5.2), the branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29) and main a9779ef3c193 [exec: the follow-up's history harness folds U+04B7 and U+04B6 to U+04BD on every one, while all 8 control letters fold to their base letter (3.1.0 and 3.2.0 run with --allow-stale, because their lucene-icu depends on a republished ICU4J 4.6; branch_10_6 with its analysis-icu jar compiled from the branch sources); the candidate's JUnit fails 2 of 3 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da), branch_10_6 and main (-ea and -da); the independent reproduction's JUnit fails 3 of 5 on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check and the cross-model review step reproduced on main and 10.5.2] [read: every release tag's DiacriticFolding.txt contains 04B7>04BC, each release jar's utr30.nrm has its tag's git blob, and 14 distinct utr30.nrm builds with 16 ICU4J versions (4.6 to 78.3) all fold the same way; upstream main 20ced71c1e6 has no analysis/icu change since a9779ef3c193; branch_10x head 657014b3c57 has the same line]
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; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-28, read-only; the prior-report record, verdict new): 221 logged queries, among them two scripted prior-report searches (51 and 45 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), 43 extra GitHub issue/PR queries (apache/lucene, apache/lucene-solr, apache/lucenenet, ICU4N, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, unicode-org/icu, all of GitHub), 4 GitHub code searches (92 files carry '04B7>04BC', none carries '04B7>0447' or '04CC>0447'), 11 JIRA queries (SOLR, LUCENE, LUCENENET), 49 list queries and 9 web searches (English and Russian); the file lists of the open PRs (411 listed, 44 read) and of the 34 PRs closed or updated since 2026-10-20 (#16911 deletes the file only as part of retiring branch_10x). Nearest items, none about this mapping: #2420 (LUCENE-1343, the introducing issue; all 28 comments read), #4820 (LUCENE-3747) and #11274 (LUCENE-10238), maintenance of the verbatim sections, #9177 (LUCENE-8129, a UnicodeSet filter for letters not to fold), #10052, and 2014 java-user/solr-user threads about an unnamed DiacriticFolding mapping. Re-check 2026-10-28: none of the 15 apache/lucene PRs created since 2026-10-26 touches lucene/analysis/icu, and none of the issues created since then concerns ICU folding (listing only; the GitHub search API was rate-limited at that moment)
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): this doc's JUnit through gradle in a clean worktree, stock and with the one-line fix (and the variant that also changes KHAKASSIAN CHE), assertions on and off, plus TestICUFoldingFilter, TestICUFoldingFilterFactory, all of lucene/analysis/icu and the generated-file checksum check with the fix (2026-10-28); an exhaustive comparison of the stock and the fixed utr30.nrm over all 1,112,064 non-surrogate code points. Hunt runs: the JUnit on 9.1.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main; a standalone Java main() (each release's Lucene and ICU4J jars) on all 138 final releases from 3.1.0 to 10.5.2. After the fact checks: the repro, after a comment-only edit, again fails 2 of 3 on stock and passes 3 of 3 with the fix, assertions on and off; an independent run of the full upstream regenerateUtr30DataFiles task with only line 618 changed gives the same utr30.nrm as the fix.

Lucene and upstream sources

Finding record
  • lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt:597-599 (at a9779ef3c193) (block comment '# Cyrillic script "composed" that do not further decompose, so decompose here', '# These are from UTR#30 DiacriticFolding.txt', '# Rule: verbatim'), :618 '04B7>04BC', :625 '04CC>04BC', :600-626 (siblings 0497>0436, 0499>0437, 049B>043A, 04A3>043D, 04AB>0441, 04AD>0442, 04B3>0425, 04B9>0447, 04BF>04BC)
  • 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) (compiled data; byte-identical to a gennorm2 rebuild of the stock sources)
The finding in 30 seconds
  • lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt:597-626 (block comment :597-599; :607, :609, :612, :614, :617, :618, :619, :620, :625)
  • 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-58 (SPI name icuFolding; filter)
  • lucene/analysis/icu/build.gradle:77-135 (regenerateUtr30DataFiles: GenerateUTR30DataFiles, gennorm2, icupkg -tb)
  • lucene/analysis/icu/src/tools/java/org/apache/lucene/analysis/icu/GenerateUTR30DataFiles.java:75-78, :122-154 (verbatim lines kept until the next '# Rule:' line); DiacriticFolding.txt:27-33 (the file's own note)
Who is affected
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUFoldingFilterFactory.java:43 (SPI name icuFolding); lucene/analysis/icu/src/resources/META-INF/services/org.apache.lucene.analysis.TokenFilterFactory (registration); no analyzer under lucene/analysis/icu/src/java uses the filter (grep)
Why it looks unintended
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUFoldingFilter.java:33; lucene/analysis/icu/src/java/overview.html:284-293
  • lucene/analysis/icu/src/data/utr30/DiacriticFolding.txt:597-599, :601-626
Suggested fix
  • lucene/analysis/icu/build.gradle:77-135; lucene/analysis/icu/src/generated/checksums/regenerateUtr30DataFiles.json; help/regeneration.md:13-56 (checksum check)
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/ICUFoldingFilterFactory.java:51-58 (filter argument)
  • lucene/CHANGES.txt:4457, :15241 (LUCENE-10238, LUCENE-3747 entries); lucene/MIGRATE.md:873 (Romanian analysis: 'you should reindex Romanian documents')

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

Repro files

About the diagrams

It was made in parallel with this doc; every term, fold and hit count on screen comes from an executed probe on main a9779ef3c193, stock and with the fix.diff table (04B7>0447, rebuilt utr30.nrm) first on the classpath, and every code and data row is a line of the code at a9779ef3c193.

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.