All findings · Lucene finding · LFV-685 · irish-lowercase-filter-charwise-supplementary

IrishLowerCaseFilter leaves capital letters outside the BMP unchanged, so IrishAnalyzer misses their lower-case form

IrishAnalyzer's lower-case filter maps one 16-bit Java char at a time, so the 282 capitals stored as two chars (Adlam, Osage, Deseret and five more scripts) stay upper-case. A document with Adlam U+1E900 is not found by its lower-case form U+1E922: 0 hits instead of 1, since 3.6.0.

Summary

Priority
Low, rank 525IrishAnalyzer leaves supplementary-plane capitals unlowered, which only rare mixed-script text in an Irish field hits.
Impact
silent wrong results
Since
3.6.0 (2012), when the class was addedintroduced 2012-03-24 · over 14 years
Affected
3.6.0 to 10.5.2 and main; all 133 releases in that range reproduced (3.5.0 has no such class)
Status
documented
Found by
a bounded-exhaustive comparison of the lower-case filters with a code-point reference (semi-formal)

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

IrishLowerCaseFilter lower-cases with Character.toLowerCase(chArray[i]), one 16-bit char at a time. A letter outside the BMP is two chars, a surrogate pair; each half alone has no case and comes back unchanged, so the capital letter survives. The loop already advances by code point, like GreekLowerCaseFilter; only the read is per char.

Diagram of the bug: the Adlam capital U+1E900 is stored as two UTF-16 chars, U+D83A and U+DD00; IrishLowerCaseFilter passes each char alone to Character.toLowerCase, which returns each half unchanged, so the term stays U+1E900 U+1E923 instead of becoming U+1E922 U+1E923; with IrishAnalyzer, a search for the lower-case form finds 0 documents instead of 1; reading the code point instead of the char fixes it.
How the Adlam capital U+1E900 is stored as two chars, why lower-casing each half alone leaves it upper-case, and the missed search; made in parallel with this doc. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index the Irish sentence "Tá X anseo" ("X is here") with the default IrishAnalyzer, where X is the Adlam text U+1E900 U+1E923, a capital and a small letter. Each letter is stored as two Java chars, a surrogate pair.
  2. You search for X in lower case, U+1E922 U+1E923, through the same analyzer. You expect 1 hit: the analyzer lower-cases, and LowerCaseFilter maps U+1E900 to U+1E922.
  3. You get 0 hits. The filter passes each char alone to Character.toLowerCase; half of a pair has no case, so the capital is written back unchanged and the indexed term is still U+1E900 U+1E923.
  4. Only the 282 capitals outside the BMP are hit: AB + Osage U+104B0 + C becomes ab U+104B0 c. Exact-case searches still match, nothing throws, and such an index passes CheckIndex.

Who is affected

Fields analysed with the stock IrishAnalyzer, or with IrishLowerCaseFilter or the irishLowercase factory in a chain, at index and query time. It needs a capital from one of eight scripts outside the BMP, which Irish text never has, so only mixed-script content such as a quoted Adlam or Osage name is hit; there it happens every time.

Mechanism, terms and numbers558 words

The javadoc of IrishLowerCaseFilter says it "Normalises token text to lower case"; it is the only lower-casing step of IrishAnalyzer, for indexing and in normalize. Its loop passes one UTF-16 char at a time to Character.toLowerCase (IrishLowerCaseFilter.java:55). Letters outside the Basic Multilingual Plane (BMP) are stored as two chars, a surrogate pair, and each half alone has no case, so the 282 such capitals (Adlam, Deseret, Garay, Medefaidrin, Old Hungarian, Osage, Vithkuqi, Warang Citi) stay upper-case while BMP letters in the same token are lower-cased. A document indexed with IrishAnalyzer that contains the Adlam text U+1E900 U+1E923 is found by that exact spelling but not by its lower-case form U+1E922 U+1E923 (0 hits instead of 1). Nothing throws, and an index built from such text passes CheckIndex. Irish text itself has no such letters, so only mixed-script content in an Irish field is affected. The code has been there since the class was added in 3.6.0 (2012); reading the code point instead of the char fixes it.

Terms

  • UTF-16 char: Java stores text as 16-bit char values. Most letters, including every Irish one, fit in one char: they are in the Basic Multilingual Plane (BMP).
  • Supplementary character: a code point above U+FFFF, stored as two chars, a surrogate pair (a high and a low surrogate). The Adlam capital alif U+1E900 is the pair U+D83A U+DD00. A surrogate alone is not a letter and has no case.
  • Code point lower-casing: Character.toLowerCase(int) maps a whole code point (U+1E900 to U+1E922). The char overload cannot: the JDK documents that it "cannot handle supplementary characters".

The bug

  1. IrishLowerCaseFilter.incrementToken first handles the Irish prefixes (n-eclipsis and t-prothesis: nAthair becomes n-athair), then lower-cases the rest of the term: i += Character.toChars(Character.toLowerCase(chArray[i]), chArray, i); (IrishLowerCaseFilter.java:54-56 on main).
  2. chArray[i] is one char. For U+D83A, Character.toLowerCase returns U+D83A; toChars writes it back and returns 1; the low half U+DD00 goes the same way. The capital letter is unchanged.
  3. The loop is otherwise written for code points: it advances by the number of chars toChars wrote, as GreekLowerCaseFilter does (GreekLowerCaseFilter.java:46, which reads Character.codePointAt(chArray, i, chLen)). LowerCaseFilter lower-cases each code point through CharacterUtils.toLowerCase (CharacterUtils.java:47-63).
  4. IrishAnalyzer runs StandardTokenizer, which keeps words of every script, and this filter is its only lower-casing step (IrishAnalyzer.java:126-135); IrishAnalyzer.normalize, which the classic query parser calls for range, wildcard, regexp, prefix and fuzzy terms (QueryParserBase.java:550-729, read; not run end to end), runs the same filter (:138-142).

The numbers

input (after KeywordTokenizer)expected, as LowerCaseFilterIrishLowerCaseFilter
U+10400 (Deseret capital long I)U+10428U+10400
U+104B0 (Osage capital A)U+104D8U+104B0
U+1E900 U+1E923 (Adlam capital alif, small daali)U+1E922 U+1E923U+1E900 U+1E923
AB U+104B0 Cab U+104D8 cab U+104B0 c
nA U+10400 (n-eclipsis, then Deseret)n-a U+10428n-a U+10400
nAthair, ÉIRE (controls)n-athair, éiren-athair, éire

Every code point whose lower case differs under Character.toLowerCase(int): 1,178 in the BMP, all handled correctly, and 282 outside it, all left unchanged: Adlam 34, Deseret 40, Garay 22, Medefaidrin 32, Old Hungarian 51, Osage 36, Vithkuqi 35, Warang Citi 32 (JDK 25 tables). Emoji and CJK ideographs have no case and are not affected. No lower-case mapping crosses between the BMP and the supplementary planes, so the term length never changes.

Exposure and likelihood295 words
  • Who is exposed. The stock IrishAnalyzer with any constructor (indexing and normalize), any chain with IrishLowerCaseFilter, and the irishLowercase factory (IrishLowerCaseFilterFactory.java:40; its normalize is the same filter, :60-64).
  • What it takes. An upper-case letter of Adlam (a living script for Fulani), Osage, Deseret, Old Hungarian, Warang Citi, Medefaidrin, Vithkuqi or Garay in an Irish-analysed field. Irish orthography has none, so it takes mixed-script content: a quoted name or word, or a historic-script passage.
  • What changes for the user. The capitalised and the lower-case spellings become two different terms, so a case-insensitive search misses in both directions (0 hits instead of 1 in the repro). In the intended-behaviour check's probe, "Tá X agus Y anseo" analysed to [tá, U+1E900 U+1E923, U+104B0, anseo] with capital Adlam and Osage words and [tá, U+1E922 U+1E923, U+104D8, anseo] with lower-case ones. Exact-case matches still work.
  • What does not happen. No exception, no change in term length, valid UTF-16 terms and no index damage: an index built by the stock IrishAnalyzer from such text passes CheckIndex in two segments and after forceMerge(1), and every term decodes as strict UTF-8 (assertions on and off). BMP letters in the same token are lower-cased.
  • Scale. Deterministic, with assertions on or off, at any index size: every occurrence of such a capital is indexed (and queried) unchanged.
  • JDK dependence. The affected set follows the JDK's Unicode tables: 282 code points with JDK 25. Deseret letters exist in every JDK Lucene supports; scripts added in later Unicode versions (Osage and Adlam in 9.0, Garay in 16.0) need a newer JDK. Only JDK 25 was run.
  • How likely. Rare: no public report of it was found (searches of 2026-10-27 and 2026-10-28), and the trigger needs mixed-script text in an Irish field.

Minimal repro

The test shows that IrishAnalyzer (through IrishLowerCaseFilter, which lower-cases one UTF-16 char at a time) does not lower-case an upper-case letter outside the BMP.

TestIrishLowerCaseSupplementary.java (JUnit test)32 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.ga;

import java.util.Locale;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIrishLowerCaseSupplementary extends LuceneTestCase {

  public void testSupplementaryUpperCaseIsLowerCased() throws Exception {
    try (Analyzer a = new IrishAnalyzer()) {
      // expected: Deseret capital U+10400 lower-cased to U+10428; actual: U+10400 unchanged
      int cp = a.normalize("f", "\uD801\uDC00").utf8ToString().codePointAt(0);
      assertEquals("U+10428", String.format(Locale.ROOT, "U+%X", cp));
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-685/, 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: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.ga.TestIrishLowerCaseSupplementary
Expected vs actual
Expected the Deseret capital U+10400 to become U+10428; actual: it stays U+10400 (expected:<U+104[28]> but was:<U+104[00]>), with -ea and -da.

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

More files, environment, full output32 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<U+104[28]> but was:<U+104[00]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.analysis.ga.TestIrishLowerCaseSupplementary.testSupplementaryUpperCaseIsLowerCased(TestIrishLowerCaseSupplementary.java:29)

Why it looks unintended

  • The javadoc says "Normalises token text to lower case", with no BMP limit; LowerCaseFilter lower-cases these letters.
  • Lucene made LowerCaseFilter handle supplementary characters in 3.1 (LUCENE-2069), before this class was added in 3.6.0.
  • The loop already advances by code point; only the read is per char. TruncateTokenFilter's same defect was fixed as a bug (#15899).
The full argument278 words
  • Contract. "Normalises token text to lower case, handling t-prothesis and n-eclipsis (i.e., that 'nAthair' should become 'n-athair')" (IrishLowerCaseFilter.java:24-27, word for word as in 3.6.0). No script or BMP restriction; LowerCaseFilter's javadoc is nearly the same ("Normalizes token text to lower case.", LowerCaseFilter.java:22) and it lower-cases these letters.
  • Policy before the class existed. lucene/CHANGES.txt:17927-17928 (3.1.0): "LUCENE-2069: Added Unicode 4 support to LowerCaseFilter. Due to the switch to Java 5, supplementary characters are now lowercased correctly." TurkishLowerCaseFilter (LUCENE-2102, 2009) and GreekLowerCaseFilter (LUCENE-2463, 2010) map code points. In 3.6.0, the release that added this filter, LowerCaseFilter already mapped by code point: the 3.6.0 jar lower-cases U+10400, U+104B0 and U+1E900 (executed on JDK 25). Of these, only Deseret U+10400 (Unicode 3.1) was in the JDKs of 2012; Osage and Adlam entered Unicode in 9.0 (2016).
  • Same defect class treated as a bug. lucene/CHANGES.txt:1198-1200 (10.5.0): "GITHUB#15899, GITHUB#15900: Improve TruncateTokenFilter to truncate on codepoints not chars and no longer produce half surrogates". On #15899 uschindler wrote "We modified all other similar filters in the past (like ngrams), they all work correctly". That is precedent for treating char-wise handling as a bug, not proof of a complete audit: CapitalizationFilter (LFV-533) is still char-wise too.
  • Nothing presents it as a choice. The class was new in 3.6.0 with no legacy switch; the upstream tests assert expected output only for BMP input (TestIrishAnalyzer.testRandomStrings feeds random text but checks only token-stream consistency, not case); the LUCENE-3883 thread (#4956, 25 comments) never mentions supplementary characters; the loop is written in the code-point idiom (i += Character.toChars(...)) but reads a char, and the JDK documents that the char overload "cannot handle supplementary characters".
The devil's-advocate check234 words

The strongest case for "intended" (the intended-behaviour check and the cross-model reviewer): the constructor says it "normalises Irish token text" (IrishLowerCaseFilter.java:31), Irish is written in Latin script with no supplementary letters, so Adlam or Osage text is out of scope; and the factory says "this will 'mostly work', except for special cases, just like most other filters" (IrishLowerCaseFilterFactory.java:60).

Why it fails: the class javadoc promises lower case without restriction; IrishAnalyzer tokenises every script and has no other lower-casing step; the filter does lower-case non-Irish BMP scripts (Greek, Cyrillic, Armenian, executed by the intended-behaviour check), so the only gap is the supplementary planes, which comes from the char API, not from a choice of domain; TurkishLowerCaseFilter uses the same wording ("Normalizes Turkish token text to lower case", TurkishLowerCaseFilter.java:25) and maps code points; and the factory comment came with 44695f0f3f5 ("LUCENE-3883: add multitermaware to the irish case filter") and is about multi-term normalisation, not supplementary characters. Every verifier rejected the intended-behaviour argument, and the cross-model reviewer rated it weak twice. A later cross-model fact check of this doc rated it moderate: domain scope, BMP-only tests, the reindex cost and the factory comment give it some weight, although no evidence shows that the BMP limit was considered and chosen. What survives is the narrow reach and the reindex cost: reasons for low priority and a CHANGES note, not for keeping the behaviour.

Suggested fix

Recommended A: read the code point, not the char (one statement)

Read the code point instead of the char: Character.toLowerCase(Character.codePointAt(chArray, i, chLen)) at IrishLowerCaseFilter.java:55. The repro then passes, assertions on and off, and so does the whole analysis-common test task (1,913 tests). Only the 282 affected capitals change, so a CHANGES note should mention reindexing.

      for (int i = idx; i < chLen; ) {
-        i += Character.toChars(Character.toLowerCase(chArray[i]), chArray, i);
+        i +=
+            Character.toChars(
+                Character.toLowerCase(Character.codePointAt(chArray, i, chLen)), chArray, i);
      }
All options and trade-offs3 options · 449 words

Two equivalent fixes were built and validated in a scratch worktree at a9779ef3c193; A is the smallest diff and the one every checker ran.

A: read the code point, not the char (one statement) (validated by the checks) (recommended)

The finder's one-line fix, wrapped by google-java-format. The loop already advances by the number of chars written, so nothing else changes.

      for (int i = idx; i < chLen; ) {
-        i += Character.toChars(Character.toLowerCase(chArray[i]), chArray, i);
+        i +=
+            Character.toChars(
+                Character.toLowerCase(Character.codePointAt(chArray, i, chLen)), chArray, i);
      }

Trade-offs

  • Changes output only for the 282 supplementary capitals: every BMP code point already matched, and no lower-case mapping crosses the BMP boundary, so the term length never changes.
  • Indexed terms change for text with these letters: indexes built with IrishAnalyzer need reindexing for exact consistency. For TruncateTokenFilter the project added factory parameters for the old behaviour (#15900); an option seems unnecessary here, but that is the maintainers' call.

What the checks validated

  • Stock main: the paste-ready test fails 3 of 4, assertions on and off; the 10 upstream Irish tests pass.
  • With fix A, org.apache.lucene.analysis.ga.*: 4 classes, 14 tests, 0 failures, assertions on and off.
  • With fix A, the whole :lucene:analysis:common:test task: 340 classes, 1,913 tests, 0 failures, 18 skipped (skips not examined).
  • ecjLintMain, ecjLintTest and checkGoogleJavaFormat of the module pass.
  • Earlier checks: the finder's test (4 of 4), the independent reproduction's (7 of 7, and 17 of 17 with the upstream Irish tests), the cross-model reviewer's (5 of 5), the intended-behaviour check's (8 of 8), and the history probe on 3.6.0 and main all pass with the same change.

B: call CharacterUtils.toLowerCase(chArray, idx, chLen), as LowerCaseFilter does (validated by the checks)

Replace the loop with the shared helper and import org.apache.lucene.analysis.CharacterUtils (+2/-3 lines). It maps each code point with Character.toLowerCase(int), with an ASCII fast path, so the output is the same as A.

-      for (int i = idx; i < chLen; ) {
-        i += Character.toChars(Character.toLowerCase(chArray[i]), chArray, i);
-      }
+      CharacterUtils.toLowerCase(chArray, idx, chLen);

Trade-offs

Reuses one documented code path instead of a second hand-written loop; a slightly larger diff than A, and only this doc's runs checked it.

What the checks validated

Validated the same way: ga.* 14 tests, 0 failures, assertions on and off; the whole module 340 classes, 1,913 tests, 0 failures, 18 skipped; lint and format pass; the standalone runner passes on main with -ea and -da.

C: keep the code and document the BMP-only limit (not recommended)

A javadoc note that only BMP letters are lower-cased.

Trade-offs

Cheap, but contradicts LowerCaseFilter, the Turkish and Greek filters and the project's own past fixes, for no benefit.

Downstream note

Search servers built on Lucene that configure an Irish field type with this filter, as the factory's javadoc example text_ga does, inherit the behaviour and the same missed case-insensitive matches.

How it was found

Semi-formal. A bounded-exhaustive comparison checked the stock Greek, Irish and Turkish lower-case filters against a reference written from their javadoc (lower case per code point, as LowerCaseFilter) on every code point alone, 1,112,064 per filter. Irish differed on exactly 282, all capitals outside the BMP; a public-API JUnit then confirmed it end to end.

The method in full155 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier). The finder wrote a reference for each language lower-case filter from its javadoc and listed mappings: lower case means Character.toLowerCase(int) per code point, as LowerCaseFilter does, plus each filter's documented special rules. It compared the stock Greek, Irish and Turkish filters with the reference:

  • on every code point 0..0x10FFFF alone (1,112,064 checks per filter): Greek 0 and Turkish 0 mismatches, Irish 282, all supplementary upper-case letters;
  • on 12 Latin, Greek and combining blocks in alone, initial, medial, final, after-n, after-t and after-I positions (15,984 checks per filter): Irish 0 mismatches, so the n-eclipsis and t-prothesis rules and every BMP letter behave as documented.

A public-API JUnit on the stock main jar then showed the effect through the filter and through an IrishAnalyzer index and search (found 2026-10-27). IrishLowerCaseFilter was one of the rule-table classes in the hunt unit's scope; the bug came from its every-code-point sweep.

Prior reports, history and checks

Prior reports

No prior report found: two scripted prior-report search runs (45 queries plus 6 web left pending, then 79 including 6 web; GitHub, JIRA, mailing lists), GitHub code and open-PR searches on 2026-10-27, and a GitHub re-check on 2026-10-28. Nearest: #2763, the 2009 policy that analysis code must handle supplementary characters, which never names this class. Not fixed upstream.

Searches5 searches

No issue, PR, review, commit message, JIRA entry, mailing-list thread or web page reports that IrishLowerCaseFilter or IrishAnalyzer lower-cases per char or leaves supplementary capitals unchanged. The nearest items are context or precedent: #4956 (LUCENE-3883, which added the class; its 25 comments do not mention supplementary characters), #2763 (LUCENE-1689, the 2009 umbrella policy; closed in 2013 with "Any remaining problems can be opened as separate issues"), #3145 (LUCENE-2069, the same defect fixed in LowerCaseFilter), #3178 (LUCENE-2102, Turkish), LUCENE-2463 (Greek) and #15899 / #15900 (TruncateTokenFilter). The registry sibling LFV-533 (CapitalizationFilter) has the same pattern with a different fix site.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories2026-10-27the scripted prior-report search, two runs (the finder's: 45 queries, its 6 web queries left pending; the follow-up's: 79 queries including 6 web, 531 distinct hits, all high and medium hits and every hit naming Irish reviewed), plus REST and GraphQL searches: class names, Irish, eclipsis, prothesis, Deseret, Adlam, lowercase with supplementary or surrogateno report; only #4956, #4993 and #11389 name the class, none about this
Open PRs2026-10-27the changed files of all 409 open apache/lucene PRsonly #16911 (decommission branch_10x) touches the file; not a fix
Mailing lists and JIRA archive2026-10-27dev@, java-user@, issues@ and commits@ queries; LUCENE and SOLR JIRA queries on IrishLowerCaseFilter, Irish, text_ga and supplementary-character analysis issuesno report
Web and ports2026-10-27web queries (titles and snippets); Lucene.NET history of its own copy of the filterno report; the port's changes were for culture and allocation reasons
GitHub re-check2026-10-2811 read-only queries (IrishLowerCaseFilter issues and PRs, IrishAnalyzer, Adlam, Deseret and Osage with lowercase, items created since 2026-10-26, open PRs mentioning Irish) and the commits on the filenothing new; the file's last upstream commit is from 2024-08-16 (formatting)

History

As old as the class: added in 2012 by f597b9a1cc4 (LUCENE-3883) and its branch_3x backport 69321091b9e, first released in 3.6.0. The line has not changed since; all 133 releases from 3.6.0 to 10.5.2 and main reproduce it.

Versions tested7 rows

Introduced by f597b9a1cc4 (trunk, svn r1304836, 2012-03-24, "LUCENE-3883: Irish Analyzer"; issue #4956; CHANGES: "LUCENE-3883: Add Analyzer for Irish. (Jim Regan via Robert Muir)") and the same-day branch_3x backport 69321091b9e (svn r1304845, identical file), which shipped in 3.6.0. A JIRA patch, no PR. They are the only commits that add the line: git log --all -S restricted to the file also lists aad44bc6e36 (2021), which deletes the file on the retired branch_8x. Later commits only moved the file, changed licence headers and reformatted it. The irishLowercase factory is in Lucene's analysis module from 4.0.0, and IrishAnalyzer.normalize runs the filter from 6.2.0 (LUCENE-7355). Not a regression: the line is as old as the class, and LowerCaseFilter in the same release already mapped by code point (the 3.6.0 jar, run on JDK 25, lower-cases these letters; of them, Deseret was already in the JDKs of 2012).

Versions and branches executed, with results
Version or commitVerdictResult
3.5.0 no classthe class does not exist (compile error)
3.6.0 affectedfirst release: Adlam, Deseret after n-eclipsis, Osage and the IrishAnalyzer match fail, with -ea and -da; controls pass
f597b9a1cc4 / 69321091b9e file over the 3.6.0 jars introducing commitsthe same four checks fail
all 133 releases 3.6.0 to 10.5.2 affectedthe history probe fails on every one; controls pass on every one
9.12.3, 10.5.2 affectedthis doc's test: 4 run, 3 failed (-da), the same messages as main
main a9779ef3c193 affectedthis doc's test: 3 of 4 fail through gradle and the JUnit runner, -ea and -da
3.6.0 and main with the one-line fix fixall checks pass

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate, the adjudication and a credibility audit all ran code and said real; a reviewer from another model family (OpenAI GPT-5.6) ran its own tests twice and agreed. This doc re-derived the 282 count and validated two fixes through gradle.

Checks and verdicts342 words

Every check ran on main a9779ef3c193 with JDK 25.0.3, except the cross-model review, which ran on main ea57360912ed, where the three Irish files are identical.

Each independent check and its verdict
CheckVerdictWhat it found
finder: exhaustive sweep and JUnit 282 of 1,112,064 code points wrongAll supplementary capitals; 0 of 15,984 positional checks wrong. The JUnit fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3; with the fix 4 of 4 pass.
cross-model reviewer, refute lens (OpenAI GPT-5.6) real, highReproduced with its own Osage test through the filter and IrishAnalyzer.normalize; the fix passed the 10 upstream Irish tests; intended argument weak.
Independent reproducer realOwn 7-test JUnit (Deseret, Osage, Adlam, mixed token, t-prothesis, normalize, search) fails 6 of 7 on main (-ea, -da), 10.5.2 and 9.12.3; with the fix 17 of 17 pass, including the upstream Irish tests.
Cross-model reviewer, intended lens (OpenAI GPT-5.6) real, highOwn Osage test fails 2 of 5 on main, 10.5.2 and 9.12.3 and passes 5 of 5 with the fix; intended argument weak.
Intended-behaviour devil's advocate real; no intended argument survivesOwn 8-test probe fails 3 of 8 with all 5 controls passing (the Turkish and Greek filters and three other analyzers lower-case the same letters; the Irish filter lower-cases Greek, Cyrillic and Armenian); 8 of 8 with the fix.
adjudication confirmed, highNew seed: the independent reproduction's test 6 of 7 fail (-ea), the finder's 3 of 4 (-da); with the fix 7 of 7 pass.
registry re-run and credibility audit keepClean checkout: 3 of 4 fail with -ea and -da, 4 of 4 pass with the fix; the audit re-ran with another seed.
this doc confirmed; two fixes validatedNew format-clean test fails 3 of 4 through gradle (-ea, -da) and the runner (main, 10.5.2, 9.12.3); both fixes pass it and the whole analysis-common test task; the 282 count and per-script split re-derived with a plain JDK loop; an index built from such text passes CheckIndex (-ea, -da).

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

Finding record

LFV-685, snapshot of 2026-11-06

Registry title
IrishLowerCaseFilter lower-cases one UTF-16 unit at a time, so IrishAnalyzer leaves upper-case letters outside the BMP (Adlam, Osage, Deseret and others) unchanged
Area
analysis-common / ga (IrishLowerCaseFilter and IrishAnalyzer case mapping)
Priority
Low, rank 525: IrishAnalyzer leaves supplementary-plane capitals unlowered, which only rare mixed-script text in an Irish field hits.
Impact class
silent wrong results
Impact note
Hits applications that analyse a field with the stock IrishAnalyzer (or put IrishLowerCaseFilter or its 'irishLowercase' factory in a chain) when the field holds upper-case letters of one of the eight cased scripts encoded outside the BMP (Adlam, Deseret, Garay, Medefaidrin, Old Hungarian, Osage, Vithkuqi, Warang Citi; 282 code points in the JDK 25 tables): those letters stay upper-case at index time and at query time (IrishAnalyzer.normalize too), deterministically with -ea and -da, at any scale and on every release since 3.6.0, so a capitalised word is not found by its lower-case form; BMP letters in the same token are lower-cased, nothing throws, the index is not damaged, and Irish text itself has no such letters, so only mixed-script content in an Irish field is affected.
Summary
You index text with IrishAnalyzer (or put IrishLowerCaseFilter in a chain), whose filter is documented to 'normalise token text to lower case', and expect a capitalised word to match its lower-case form, as it does with LowerCaseFilter. For letters outside the BMP it does not: the Adlam capital U+1E900 stays U+1E900 instead of becoming U+1E922 (the Deseret capital U+10400 likewise stays, also after n-eclipsis), so a document holding the Adlam word U+1E900 U+1E923 is not found by U+1E922 U+1E923 (0 hits instead of 1). The loop at IrishLowerCaseFilter.java:55 passes one UTF-16 char to Character.toLowerCase, which returns a surrogate unit unchanged; BMP letters in the same token are lower-cased and nothing is thrown.
Introduced
f597b9a1cc4d9ad326ade156c630a90063fb8c5f (trunk, 'LUCENE-3883: Irish Analyzer', svn r1304836; LUCENE-3883 = #4956, committed from a JIRA patch, no pull request): the class's first commit already has the char-wise loop 'i += Character.toChars(Character.toLowerCase(chArray[i]), chArray, i)'; the branch_3x backport 69321091b9e56d5a672f014c552e8d7d47178a79 (svn r1304845, same file content) put it into 3.6.0 [read: git blame of main :55, git log -S on the line; exec: shadowing either commit's file over the 3.6.0 jars fails the same way, and 3.6.0's file with the one-line fix passes]; the line is unchanged since (later commits only moved the file, changed licence headers and reformatted) (2012-03-24 (trunk f597b9a1cc4 and branch_3x backport 69321091b9e, same day))
First affected version
3.6.0 [exec] (the first release with IrishLowerCaseFilter and IrishAnalyzer; 3.5.0 has no such class [exec: compile error]); the 'irishLowercase' factory is in Lucene's analysis module from 4.0.0 (in 3.6 it was Solr's class), and IrishAnalyzer.normalize runs the filter from 6.2.0 (LUCENE-7355) [read]
Affected versions
every release from 3.6.0 to 10.5.2 [exec: all 133 Maven Central releases in that range with the follow-up's release-portable history probe, -ea, and also -da on 3.6.0, 9.12.3 and 10.5.2] and main a9779ef3c193 [exec: -ea and -da, by the finder, the independent reproduction, the cross-model review, the intended-behaviour check, the adjudication and the re-run]; the finder's JUnit fails 3 of 4 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; not fixed upstream: upstream main a95c754ee003 has byte-identical IrishLowerCaseFilter, IrishAnalyzer and IrishLowerCaseFilterFactory [read, 2026-10-27]; unaffected: 3.5.0 and earlier (no class) [exec on 3.5.0]; the prereleases 4.0.0-ALPHA and 4.0.0-BETA contain the commit but were not run
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-27, the prior-report record verdict new, confidence high, all read-only: the finder's scripted prior-report search (45 queries: 16 GitHub issue and PR, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA archive) and the follow-up's run (79 queries including 6 web; 531 distinct hits, none the same bug), GitHub REST and GraphQL issue, PR, commit and code searches on apache/lucene, all of GitHub and downstream repositories (Elasticsearch, OpenSearch, Solr, Lucene.NET), and the open-PR file lists; the independent reproduction's gh search and the intended-behaviour check's own scripted prior-report search (179 hits) found nothing either); upstream main a95c754ee003 has byte-identical Irish files, and the only open PR whose file list includes IrishLowerCaseFilter.java (#16911, decommissioning branch_10x) is not a fix; related items, context and precedent only: #4956 (LUCENE-3883, introduced the class; its 25 comments do not mention supplementary characters), #2763 (LUCENE-1689, the 2009 umbrella policy that char-based analysis code must handle supplementary characters; it never names this class and was closed with 'Any remaining problems can be opened as separate issues'), #3145 (LUCENE-2069, the same defect fixed in LowerCaseFilter), #3178 (LUCENE-2102, TurkishLowerCaseFilter by code point), LUCENE-2463 (GreekLowerCaseFilter by code point), #3259 and #3170 (LUCENE-2183, LUCENE-2094), #15899 and #15900 (TruncateTokenFilter surrogate pairs; a committer wrote that all other similar filters had been fixed); the Lucene.NET port's commits 3c4cfa4 and 56c8e08 touched its own copy of the filter for culture and allocation reasons without describing this defect; gaps: GitHub search does not index inline PR review comments, and web search covered titles and snippets only
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, and the standalone JUnit runner); the release jars 10.5.2 and 9.12.3 (this doc's test); all 133 releases from 3.6.0 to 10.5.2 (the history probe); an index-integrity probe on main (CheckIndex, assertions on and off); two fixes on main (gradle: the Irish test package and the whole :lucene:analysis:common:test task, plus the module's lint and format checks). JDK 25.0.3 on Linux aarch64 only.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishLowerCaseFilter.java:24-27 (class javadoc 'Normalises token text to lower case, handling t-prothesis and n-eclipsis'), :31 (constructor 'normalises Irish token text'), :54-56 (char-wise Character.toLowerCase(chArray[i]) loop)
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishAnalyzer.java:121-130 (StandardTokenizer then IrishLowerCaseFilter in createComponents), :138-140 (normalize uses IrishLowerCaseFilter); IrishLowerCaseFilterFactory.java:40 (SPI name 'irishLowercase'); the three files are byte-identical in Lucene main ea57360912ed
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishLowerCaseFilter.java:24-27, :31, :37-61 (javadoc, constructor, incrementToken loop at :54-56)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishAnalyzer.java:126-135 (createComponents), :138-142 (normalize)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/el/GreekLowerCaseFilter.java:46
  • lucene/core/src/java/org/apache/lucene/analysis/CharacterUtils.java:47-63; LowerCaseFilter.java:22, :38
  • JDK 25 src.zip java.base/java/lang/Character.java (toLowerCase(char) javadoc: 'cannot handle supplementary characters')
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishAnalyzer.java:126-142; lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishLowerCaseFilterFactory.java:40, :60-64
Why it looks unintended
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishLowerCaseFilter.java:24-27, :31; lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishLowerCaseFilterFactory.java:60
  • lucene/core/src/java/org/apache/lucene/analysis/LowerCaseFilter.java:22; lucene/analysis/common/src/java/org/apache/lucene/analysis/tr/TurkishLowerCaseFilter.java:25
  • lucene/CHANGES.txt:17927-17928 (3.1.0, LUCENE-2069), :1198-1200 (10.5.0, GITHUB#15899/#15900)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/ga/TestIrishAnalyzer.java:65-67 (testRandomStrings, checkRandomData)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/CapitalizationFilter.java:152, :159, :190, :193 (char-wise case mapping, LFV-533)
Suggested fix
  • lucene/CHANGES.txt:1198-1200 (TruncateTokenFilter legacy parameters)
History
  • a Lucene clone: git tag --contains 69321091b9e (releases/lucene-solr/3.6.0); releases/lucene-solr/3.6.0 IrishLowerCaseFilter.java:59
Diagrams
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishLowerCaseFilter.java:54-56

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

Repro files

About the diagrams

A step-by-step picture made in parallel with this doc from the executed repro on main a9779ef3c193; every value drawn should come from the repro output and the source at that commit. The PNG is the final summary frame.

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.