All findings · Lucene finding · LFV-023 · regexp-case-insensitive-dotted-i

RegExp CASE_INSENSITIVE matches dotted and dotless i one way only: /İ/ matches "i", but /i/ does not match "İ"

With RegExp.CASE_INSENSITIVE, /İstanbul/ matches the term "istanbul", but /istanbul/ does not match "İstanbul"; the dotless ı and I behave the same way. Since 10.2.0 the answer depends on which side, query or document, holds the Turkish letter.

Summary

Priority
Low, rank 761Kept in Low: CASE_INSENSITIVE (and the deprecated ASCII flag, which behaves the same since 10.2) matches dotted and dotless i one-way only, which Turkish or Azerbaijani text hits on everyday words, but it is two code points, the documented simple case folding would remove the extra matches rather than add the missing ones, and maintainers deliberately dropped Turkish mappings.
Impact
silent wrong results
Since
10.2.0 (#14384, 2025-03-21)introduced 2025-02-18 · about 1 year
Affected
every release 10.2.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: exhaustive sweep of every code point against ICU4J and java.util.regex

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

Case-insensitive regexps and the four Turkish i letters

  1. A case-insensitive RegExp turns each pattern letter into the set of letters it accepts: a accepts {a, A}; k accepts {k, K, K (Kelvin sign)}.
  2. Lucene documents that set as Unicode's default simple case folding (CaseFolding.txt, statuses C and S); ICU computes it as the 'simple case-insensitive closure' of the letter.
  3. Turkish and Azerbaijani have four i letters, paired i/İ and ı/I. Default folding pairs only i with I: İ and ı fold to themselves, because their links to i and I are Turkic-only 'T' mappings, excluded by default.
Diagram of the bug: the four letters i, I, İ (U+0130) and ı (U+0131) with the matches RegExp CASE_INSENSITIVE makes. i and I match each other; the pattern İ matches the text i and the pattern ı matches the text I, but not the other way round, so /İstanbul/ finds 'istanbul' while /istanbul/ does not find 'İstanbul'.
With CASE_INSENSITIVE, the pattern İ accepts i but the pattern i does not accept İ; ı and I behave the same way. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index the keyword terms istanbul, Istanbul, İstanbul (İ is U+0130, capital I with dot) and ısparta, Isparta, isparta (ı is U+0131, dotless i). Search with new RegexpQuery(new Term("f", pattern), RegExp.NONE, RegExp.CASE_INSENSITIVE).
  2. You expect case-insensitive matching to be symmetric: if /İstanbul/ finds "istanbul", /istanbul/ should find "İstanbul". The documented model, Unicode simple case folding, says neither should.
  3. Instead /İstanbul/ hits {İstanbul, istanbul}, while /istanbul/ and /Istanbul/ hit {Istanbul, istanbul}. Likewise /ısparta/ hits {Isparta, ısparta}, while /isparta/ and /Isparta/ hit {Isparta, isparta}.
  4. Why: each pattern letter c accepts c, Java's Character.toUpperCase(c) and toLowerCase(c), plus ICU-generated extras. toLowerCase('İ') is 'i' and toUpperCase('ı') is 'I', but nothing maps i or I back.
  5. A sweep of every code point finds exactly these 2 one-way edges. No error is raised; default, case-sensitive queries are unaffected.

Who is affected

Applications that opt in to case-insensitive regexps: RegexpQuery or RegExp with CASE_INSENSITIVE (or the deprecated ASCII_CASE_INSENSITIVE), CASE_INSENSITIVE_RANGE, or Automata.makeCaseInsensitiveChar/String, over Turkish or Azerbaijani text. Words with İ or ı match or miss depending on which side holds the letter. Default case-sensitive queries are unaffected.

Mechanism, terms and numbers640 words

RegExp.CASE_INSENSITIVE (and the deprecated ASCII_CASE_INSENSITIVE, which takes the same path since 10.2.0) is not symmetric for two letters of the Turkish and Azerbaijani alphabets: the pattern İ (U+0130) matches the text i, but i does not match İ, and the pattern ı (U+0131) matches I, but I does not match ı. It is not transitive either: İ matches i and i matches I, but İ does not match I. CaseExpansion.expand adds Java's simple upper and lower case mapping of every pattern letter, and for exactly these two code points those mappings lie outside the simple case-folding closure that the ICU-generated data is meant to equal; nothing removes them. Neither model the javadoc names behaves this way: C+S simple case folding makes İ and ı match only themselves, and java.util.regex makes all four letters match each other. The cleanup in #14384, which removed the Turkish mappings on purpose, left these two one-way edges behind. Every release from 10.2.0 to 10.6.0 and main is affected; a validated fix lets the generator skip the out-of-closure mappings.

Terms

  • i U+0069, I U+0049, İ U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE, ı U+0131 LATIN SMALL LETTER DOTLESS I.
  • C+S simple case folding: the CaseFolding.txt mappings with status C (common) and S (simple). For the i letters: 0049; C; 0069 (line 71), 0049; T; 0131 (line 72), 0130; F; 0069 0307 and 0130; T; 0069 (lines 146-147); U+0131 has no row. "The default option is to exclude" T mappings (lines 52-53). So under C+S, İ and ı match only themselves (Unicode 16.0.0 files).
  • Closure: ICU's UnicodeSet(c, c).closeOver(SIMPLE_CASE_INSENSITIVE): {I, i} for i and I, {İ} for İ, {ı} for ı (executed with ICU4J 78.3, whose Unicode 17 data CaseExpansion.java was generated from, :25-26; the CaseFolding.txt rows cited above are Unicode 16.0.0).

What Lucene does

CaseExpansion.expand(c) (CaseExpansion.java:32-44, a generated file) accepts c, then Character.toUpperCase(c) if it differs (:36-39), then Character.toLowerCase(c) if it differs (:41-44), then a generated switch of extras (:46 onward), which has no case for any of the four letters. Its javadoc says "c itself and its scf mappings" (:31).

The generator (GenerateCaseExpansion.groovy:17-24) emits, per code point, only the closure members that are not c, its upper case or its lower case; the template (:71-85) writes the upper/lower adds, guarded only by != c, with no check against the closure. That equals the closure only if the upper and lower case are inside it, which fails for exactly two code points. From UnicodeData.txt, Java gives toLowerCase(U+0130) = i (line 305) and toUpperCase(U+0131) = I (line 306), while i and I map only to each other:

pattern \ text   i  I  İ  ı
i                1  1  0  0
I                1  1  0  0
İ                1  0  1  0     <- matches i, but i does not match İ
ı                0  1  0  1     <- matches I, but I does not match ı
java.util.regex (CASE_INSENSITIVE|UNICODE_CASE): all 16 pairs match

(main 645d4fe14a20, both flags; the 10.6.0 jar gives the same matrix.)

Where it reaches users

Every case-insensitive entry point calls expand: RegExp single characters (RegExp.java:682-687, via Automata.makeCaseInsensitiveChar), strings (:701-705, Automata.makeCaseInsensitiveString), character classes (:1338), CASE_INSENSITIVE_RANGE (:773), and the public Automata.makeCaseInsensitiveChar/String (Automata.java:121-127, :569-592, :700-709; public since 10.5.0, #15753). RegexpQuery on an index gives the asymmetric hit sets of the steps above on main and 10.6.0. With CASE_INSENSITIVE_RANGE, [İ-ı] matches i and I, but [h-i] does not match İ and [H-I] does not match ı.

An exhaustive sweep of 0 to 0x10FFFF through Automata.makeCaseInsensitiveChar finds 2,940 code points that accept more than one code point and exactly 2 one-way edges, U+0130 -> U+0069 and U+0131 -> U+0049.

Exposure and likelihood211 words
  • Trigger. An opt-in match flag plus text or a pattern containing İ or ı. No Lucene query parser sets the flags (in non-test code only util/automaton uses them); applications pass them to RegexpQuery(term, syntaxFlags, matchFlags) (main; in 10.x the 4-argument constructor with a determinize work limit), RegExp, or call Automata.makeCaseInsensitiveChar/String directly.
  • The deprecated flag too. ASCII_CASE_INSENSITIVE has taken the same path since 10.2.0 (check(ASCII_CASE_INSENSITIVE | CASE_INSENSITIVE)), so applications that set it before 10.2.0 got Unicode case-insensitivity, and this asymmetry, on upgrade.
  • Scale. Exactly 2 code points, but every word containing them; in Turkish and Azerbaijani these are everyday letters (for example İstanbul). For other languages it practically never occurs.
  • Effect. Silent wrong match sets: a query with İ finds documents with i, but a query with i does not find documents with İ; the same for ı and I. No exception, no index damage.
  • Downstream (read, not tested): Elasticsearch #153513 (merged 2026-07-11) routes its case_insensitive term, prefix and wildcard queries through Automata.makeCaseInsensitiveChar/String and its regexp query through RegExp.CASE_INSENSITIVE; both reach expand.
  • API status. RegExp, Automata and RegexpQuery are marked @lucene.experimental (RegExp.java:352, Automata.java:44, RegexpQuery.java:40), so a behaviour change in a minor release is allowed; this weakens a compatibility argument, not the inconsistency.

Minimal repro

One JUnit class, one test method, one code point on each side, core only, public API (RegExp and CharacterRunAutomaton). It asserts only symmetry, not which answer is right, so it holds for either fix. It covers İ; the ı edge has the same cause.

TestRegExpCaseInsensitiveDottedI.java (one test class)35 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.util.automaton;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestRegExpCaseInsensitiveDottedI extends LuceneTestCase {

  public void testDottedCapitalIMatchesSymmetrically() {
    // expected: /U+0130/ on "i" and /i/ on "U+0130" give the same answer; actual: true, then false
    assertEquals(
        "CASE_INSENSITIVE /U+0130/ on \"i\" vs /i/ on \"U+0130\"",
        matches("\u0130", "i"),
        matches("i", "\u0130"));
  }

  private static boolean matches(String regex, String text) {
    Automaton automaton = new RegExp(regex, RegExp.NONE, RegExp.CASE_INSENSITIVE).toAutomaton();
    return new CharacterRunAutomaton(automaton).run(text);
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestRegExpCaseInsensitiveDottedI -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
/İ/ on "i" and /i/ on "İ" give the same answer.
Observed
expected:<true> but was:<false>: /İ/ matches "i", /i/ does not match "İ"; assertions on and off.

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

More files, environment, full output192 words

/\u0130/ is run on the text "i" and /i/ on "\u0130", both with RegExp.CASE_INSENSITIVE, and the test asserts the two answers are equal. Deterministic, single thread; LuceneTestCase is only the base class, and the result does not depend on the random locale (Java's code-point case mapping is locale-independent). The source is ASCII-only, with \u escapes.

Regression test: this same class (or its one method added to TestRegExp); it passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 10.2.0 to 10.6.0: fail; 10.1.0: no CASE_INSENSITIVE flag; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain Java character mapping)
Command (environment)
cp $REPRO/TestRegExpCaseInsensitiveDottedI.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/automaton/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestRegExpCaseInsensitiveDottedI -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed; -Ptests.asserts=false identical)
   >     java.lang.AssertionError: CASE_INSENSITIVE /U+0130/ on "i" vs /i/ on "U+0130" expected:<true> but was:<false>
   >         at __randomizedtesting.SeedInfo.seed([DEADBEEF:B536C22A3EB52FDB]:0)
   >         at org.junit.Assert.assertEquals(Assert.java:120)
   >         at org.apache.lucene.util.automaton.TestRegExpCaseInsensitiveDottedI.testDottedCapitalIMatchesSymmetrically(TestRegExpCaseInsensitiveDottedI.java:25)

:lucene:core:test (FAILURE): 1 test, 1  failure

# standalone runner, seed DEADBEEF: main 645d4fe14a20, 10.2.0, 10.5.2 and 10.6.0 fail with -ea and -da, same message

Why it looks unintended

  • The javadoc names two models, C+S simple case folding (İ and ı match only themselves) and java.util.regex (all four letters match); both are symmetric.
  • #14384, which built this code, removed the Turkish mappings; its author describes the result as ICU's simple case-insensitive closure, which is {İ} for İ.
  • Lucene's own UnicodeUtil.foldCase maps İ to İ.
The full argument372 words
  • Documented model. RegExp.java:431-433: "We support common case folding in addition to simple case folding as defined by the common (C) and simple (S) mappings in .../CaseFolding.txt"; :430-431: "the attempt is to reach parity with java.util.regex.Pattern Pattern.CASE_INSENSITIVE and Pattern.UNICODE_CASE". Under C+S neither edge exists; under Pattern all 16 pairs match (executed). Either way one of today's two answers is wrong.
  • API wording. Automata.java:121-124: "case-insensitive equivalents"; CaseExpansion.java:31: "c itself and its scf mappings", and i is not a simple case folding of İ.
  • The author's design. #14384 (rmuir): "Remove turkish mappings, these were wrongly mixed in and only make things worse ... it should not be mixed in with normal mappings" (commit message) and "the CaseFolding class now just gives you UnicodeSet(ch).closeOver(UnicodeSet.SIMPLE_CASE_INSENSITIVE)" (PR comment, 2025-03-21). Earlier, as rmuir wrote on #14350, "dotless and dotted I are DIFFERENT, but it mixes all these characters up and conflates them", and in the #14192 review: "we want the simple \"L1\" 1-1 case folding, no expansion/contraction/normalization/turkish-azeri alt". The surviving edges are the Turkic pairing, next to the default {I, i}.
  • Lucene's folding. UnicodeUtil.foldCase ("Returns the Unicode simple case folding", UnicodeUtil.java:690-693) maps U+0130 to U+0130 (SimpleCaseFolding.java:43-44) and U+0131 to itself; as neoremind noted in their #16371 review, U+0130 has "no Common/Simple mapping (only Full and Turkic ones)".
  • No test pins it, and the parity test cannot see it. No U+0130/U+0131 in the automaton tests. TestRegExp case 14 (TestRegExp.java:263-282, asserted at :294-318) switches each letter's case with Character.toUpperCase/toLowerCase and requires both Pattern and RegExp to match the original text, but its palette (:171-173) has neither letter. Replayed for single code points over the 2,933 with a case-switched form, RegExp's half fails for exactly U+0130 and U+0131; Pattern's half fails only for U+1E9E (pattern ß on text ẞ, a JDK quirk). Under the recommended fix RegExp's half still fails for both letters: case 14 encodes java.util.regex parity, so it explains why upstream tests never caught this but is not a regression test for that fix.
  • What this settles. These facts make the asymmetry a poor fit for every documented model and point to an incomplete #14384 cleanup; they do not settle which symmetric behaviour maintainers want.
The devil's-advocate check192 words

The strongest case for "intended": the class-3 paragraph says "lowercase matching on these values is supported: 0x00DF, 0x0130, ..." (RegExp.java:464-466), which reads as "İ matches its lowercase"; the javadoc only promises "most Unicode characters" and "a best effort ... there may be differences" (:428, :440-441); and in OpenSearch PR #20928 msfroh drew exactly these one-way edges as the "Unicode ROOT locale" case conversions, naming Automata.makeCaseInsensitiveChar() as the rules for that mode.

Why it fails: the class-3 sentence was written in #14192, when İ matched all of i, I and ı, and #14384 left it unchanged; it names no direction and never says i must fail to match İ. One-way is a property of toLowerCase, not of a match relation, and "best effort" allows gaps against Pattern, not an answer that depends on which side holds the letter. The C+S paragraph, the "equivalents" wording and the author's statements all describe a symmetric relation. A cross-model refute review rated the argument strong but returned only a procedural "not real" (it could not run Java); the tie-break rejected it. Expect the fix-direction question, not doubt about the bug.

Suggested fix

Recommended Drop the two one-way edges via the generator

Let the ICU generator emit a guard for every code point whose ICU simple upper or lower case lies outside its simple case-folding closure, so it gets only its closure: with ICU 78.3 exactly U+0130 and U+0131. Drop 0x0130 from the javadoc's class-3 list. The repro and 280 automaton and regexp tests pass; 0 one-way edges remain.

    // add codepoint
    fn.accept(c);
    // code points whose upper/lowercase is outside their simple case folding closure
    switch (c) {
      case 0x0130: // LATIN CAPITAL LETTER I WITH DOT ABOVE
        return;
      case 0x0131: // LATIN SMALL LETTER DOTLESS I
        return;
      default:
        break;
    }
    // add uppercase from tables
All options and trade-offs4 options · 630 words

One fix was written and validated in a scratch worktree of 645d4fe14a20; the alternatives are described but not run. Which symmetric behaviour Lucene wants is the maintainers' choice; the recommended one follows the documented model and the #14384 intent.

Drop the two one-way edges via the generator (validated by the checks) (recommended)

+41/-6 in 4 files. GenerateCaseExpansion.groovy gets generateClosureOnly() (+24) and a call in the template (+2): for each code point whose ICU UCharacter.toUpperCase or toLowerCase is not in closeOver(SIMPLE_CASE_INSENSITIVE), it emits a case that accepts the other closure members and returns before the Java mappings are added. Regenerating with ./gradlew :lucene:core:regenerateCaseExpansion :lucene:core:tidy adds exactly the two cases below to CaseExpansion.java (+9) and updates its checksum file; RegExp.java:466 drops 0x0130 from the class-3 list.

    // add codepoint
    fn.accept(c);
    // code points whose upper/lowercase is outside their simple case folding closure
    switch (c) {
      case 0x0130: // LATIN CAPITAL LETTER I WITH DOT ABOVE
        return;
      case 0x0131: // LATIN SMALL LETTER DOTLESS I
        return;
      default:
        break;
    }
    // add uppercase from tables

Trade-offs

  • Behaviour change for Turkish text: /İstanbul/ no longer matches "istanbul" and /ı/ no longer matches "I". That is what C+S, ICU's closure, UnicodeUtil.foldCase and #14384 say, but it moves further from java.util.regex for these letters (it chooses the C+S closure over Pattern parity; TestRegExp case 14 would fail for İ/ı under it); worth a CHANGES entry.
  • Data-driven for ICU: a later ICU upgrade that adds such a code point is handled by regeneration. The generator never reads the JDK's mappings and the task's only declared input is the ICU version (lucene/analysis/icu/build.gradle:287-312), so a JDK-only Unicode change is not seen; a test that sweeps all code points for one-way edges would catch it.
  • Build nit: the generator script is not a declared input of regenerateCaseExpansion, so after editing it the task printed SKIPPED until the checksum file was deleted.

What the checks validated

Gradle --no-daemon in a scratch worktree of 645d4fe14a20, OpenJDK 25.0.3 (2026-10-31). Stock: the repro fails with assertions on and off. With the fix:

  • the repro passes with assertions on and off;
  • org.apache.lucene.util.automaton.* plus TestRegexpQuery, TestRegexpRandom, TestRegexpRandom2, TestAutomatonQuery, TestAutomatonQueryUnicode, TestWildcardQuery, TestWildcardRandom, TestPrefixQuery, TestPrefixRandom: 280 tests, 0 failures, 2 skipped, seeds DEADBEEF and CAFEBABE;
  • checkGoogleJavaFormat, forbiddenApisMain, ecjLintMain and regenerateCaseExpansionChecksumCheck for core pass;
  • the tie-break probe on the fixed jar: 0 asymmetric pairs among the four letters with both flags, accepted sets equal to ICU's closure, 0 one-way edges over every code point (2,938 code points accept more than one, down from 2,940: exactly İ and ı), and RegexpQuery /İstanbul/ hits only "İstanbul".

The whole :lucene:core:test was not run.

Make all four letters match each other (java.util.regex parity, not run)

Restore what #14192 shipped: i, I, İ and ı mutually equal.

Trade-offs

Symmetric and transitive, matches Pattern and the class-3 sentence; but it is the Turkish conflation #14384 removed ("wrongly mixed in and only make things worse"), and it makes /i/ match "ı" and /I/ match "İ" in every language.

Add only the two reverse edges (not recommended, not run)

Add i -> İ and I -> ı.

Trade-offs

Symmetric, but still not transitive (İ ~ i ~ I, yet İ does not match I), and it mixes Turkic pairs into the default too.

Generate the whole closure and drop the runtime Java mappings (larger, not run)

The generator emits every closure member; expand no longer calls Character.toUpperCase/toLowerCase.

Trade-offs

Also removes the dependence on the running JDK's Unicode version (the study saw 56 Unicode 17 code points that JDK 25 does not map, a separate symmetric info item); costs a switch of about 3,000 cases (2,994 code points with a non-trivial closure under ICU 78.3) instead of today's 87 case labels.

Downstream note

Engines that expose case-insensitive queries through this expansion (Elasticsearch's case_insensitive option, read, not tested) return the same one-way matches to their users; only which documents match changes, and only for words with İ or ı.

How it was found

An earlier fuzzing and property-based study ran RegExp CASE_INSENSITIVE over every Unicode code point and compared the accepted sets with ICU4J 78.3's simple case-folding closure and java.util.regex. Exactly two one-way edges came out; an independent verifier reproduced them with its own test and through RegexpQuery on an index.

The method in full128 words

Method: semi-formal (bounded-exhaustive check on the real jar).

  • Finder. The study's fuzzing and property-based unit (run 1, main 7cecf79523, 2026-10-22) used Jazzer, jqwik and JQF, plus an exhaustive enumeration of RegExp CASE_INSENSITIVE over all 1,112,064 code points, each compared with an independent oracle (ICU4J case folding, java.util.regex). The enumeration took 1.4 s and found exactly 2 asymmetric pairs.
  • Verifier. A separate verifier wrote its own 5-method JUnit (2 characterisation tests that pass, 3 expected-property tests that fail), with ICU4J 78.3 as oracle and RegexpQuery on an index, and ran the 4x4 matrix on 10.1.0 (0 pairs) through main (4 pairs).
  • Hunt. A one-test minimal repro, a current-main re-check, a cross-model refute review and a second adjudication with an independent public-API probe followed (see the checks below).

Prior reports, history and checks

Prior reports

No prior report: 177 queries and checks on 2026-10-31 over GitHub (Lucene and downstream), the mailing lists, JIRA and the web, plus 25 related threads read. Closest: #14350, where rmuir objected to conflating dotted and dotless I before #14384 removed the Turkish table.

Searches3 searches

Verdict new, meaning the searches below found no public report (keyword search cannot prove absence): no issue, PR, review comment, commit, CHANGES entry, list thread, JIRA entry or downstream report says that Lucene's case-insensitive matching treats İ/ı one way only. Not fixed upstream; of 409 open PRs only #14350 (draft) and #14389 (stale) touch the files, and neither changes the two edges.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr)2026-10-31a scripted prior-report search (77 issue/PR, 6 commit and 5 downstream queries) plus 34 manual searches ("LATIN CAPITAL LETTER I WITH DOT ABOVE", turkic, İstanbul, "scf mappings", UNICODE_CASE, class names); full threads of 25 related PRs and issues; the file lists of all 409 open PRsbackground only: #14384, #14350, #14192, #14381, #16371, #14389, #15753; downstream OpenSearch #20928/#20843, Elasticsearch #153513
Mailing lists, JIRA and the web2026-10-3132 list queries (dev@, java-user@, issues@, commits@), 8 JIRA queries, 6 web queriesnothing about this asymmetry; the feature postdates the JIRA migration
Earlier searches2026-10-22 to 2026-10-31the study verifier's GitHub phrasings (until the search rate limit) and a JIRA search; the candidate check's 18 queriesno report

History

Introduced in 10.2.0 by #14384 (rmuir, 2025-03-21; 4f52782a1e2 on main, 8914678afc1 on branch_10x), which replaced #14192's symmetric table with Java mappings plus ICU extras. Every release 10.2.0 to 10.6.0 and main fail; 10.1.0 has no Unicode flag. Commit-level runs isolate #14384.

Versions tested6 rows

Introduced by #14384 "build: generate CaseFolding.java from \"gradle regenerate\"" (rmuir, merged 2025-03-21; main 4f52782a1e2, branch_10x backport 8914678afc1, same patch-id). It deleted the hand-written rows that mapped each of I, i, İ and ı to the other three and switched to c + Character.toUpperCase + Character.toLowerCase + ICU extras. Its prerequisite #14192 (john-wagster, merged 2025-02-19 UTC; 7c050f9c6e4 / a288a6e987a) added CASE_INSENSITIVE with a symmetric table in which all 16 pairs match. Both first shipped in 10.2.0, so no release had the symmetric behaviour. #16371 (10.6.0) added CaseFoldingFilter and renamed the class to CaseExpansion without changing expand(). The deprecated ASCII_CASE_INSENSITIVE was ASCII-only up to 10.1.0 and shares the bug since 10.2.0.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal repro fails, assertions on and off; 4x4 matrix and RegexpQuery hit sets asymmetric
10.2.0 to 10.5.2 affectedminimal repro fails (10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 run; 10.2.0 and 10.5.2 also with assertions off)
10.1.0 and earlier (9.12.3, 10.0.0, 10.1.0 run) not affectedno CASE_INSENSITIVE; the same assertion with ASCII_CASE_INSENSITIVE passes (ASCII-only)
#14384 (8914678afc1, 4f52782a1e2) vs its parents bisectthe parents pass (all 16 pairs match); #14384 fails (4 one-way pairs) on both branches
#14192 (a288a6e987a, 7c050f9c6e4) bisectpasses: symmetric, all 16 pairs match
main 645d4fe14a20 with the fix not affectedrepro passes; 0 one-way edges over every code point

Independent checks

The study's verifier, a current-main re-check and a tie-break (re-runs, an exhaustive sweep, RegexpQuery end to end) found it real; the only dissent was a procedural "not real" from a read-only OpenAI GPT-5.6 review that could not run Java and agreed on the root cause. This doc's gradle run reproduced it and validated the fix.

Checks and verdicts267 words

Every executed check ran on stock builds or SHA-1-checked release jars; the cross-model reviewer could only read code.

Each independent check and its verdict
CheckVerdictWhat it found
Study finder (fuzzing and property-based unit) candidateExhaustive enumeration over every code point against ICU4J and java.util.regex: exactly 2 asymmetric pairs.
Study verifier new bug, lowOwn 5-method JUnit: 3 expected-property tests fail on main, seeds DEADBEEF, CAFE and 1; RegexpQuery on an index; 10.1.0 has 0 pairs, 10.2.0 to main 4.
Public-list re-run (main b83101cdd2) reproducesThe study's test: 3 of 5 fail as before.
Minimal repro and current-main re-check realThe one-test class fails on main with -ea and -da, on 10.5.2 (-ea, -da), 10.6.0 and 10.2.0; the 4x4 probe shows both edges with both flags.
Refute lens (OpenAI GPT-5.6, read-only) not real (procedural, low)Could not run Java; agreed on the root cause, the legal trigger and reachability; rated the javadoc best-effort/class-3 argument strong.
Second adjudication real5 re-runs fail (main -ea/-da; 10.2.0, 10.5.2, 10.6.0 -da); an independent public-API probe: exhaustive sweep (2 edges), RegexpQuery on main and 10.6.0, CASE_INSENSITIVE_RANGE, the case-14 replay; the intended-behaviour argument checked against the author's statements and rejected.
History follow-up 10.2.0, #1438414 releases and 8 commits run; #14192 symmetric, #14384 introduces the edges.
This doc real; fix validatedGradle in a scratch worktree: stock fails with assertions on and off; the fix passes the repro and 280 automaton and regexp tests; the probe on the fixed jar finds 0 one-way edges.

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 notes562 words

Finding record

LFV-023, snapshot of 2026-11-06

Registry title
RegExp CASE_INSENSITIVE matching is asymmetric for U+0130 and U+0131 (dotted and dotless i)
Area
util / automaton
Priority
Low, rank 761: Kept in Low: CASE_INSENSITIVE (and the deprecated ASCII flag, which behaves the same since 10.2) matches dotted and dotless i one-way only, which Turkish or Azerbaijani text hits on everyday words, but it is two code points, the documented simple case folding would remove the extra matches rather than add the missing ones, and maintainers deliberately dropped Turkish mappings.
Impact class
silent wrong results
Impact note
Users of the opt-in CASE_INSENSITIVE flag on text with U+0130 or U+0131; released since 10.2.0.
Summary
You match text that contains U+0130 or U+0131 with a CASE_INSENSITIVE RegExp. You expect case-insensitive matching to be symmetric (the study compared against ICU4J and java.util.regex). Instead the result depends on which member of the pair is in the pattern and which is in the text.
Introduced
#14192 (a288a6e987a) and #14384 (8914678afc1) (2025-02-18 (#14192); 2025-03-21 (#14384))
First affected version
10.2.0
Affected versions
10.2.0, 10.3.1, 10.4.0, 10.5.2 and main [exec]; 10.1.0 not affected (ASCII-only case-insensitivity) [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that could only read the code (its low-confidence “not real” is procedural); not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off through gradle in a scratch worktree and through a standalone runner; with the recommended fix it passes, as do 280 automaton, regexp, wildcard and prefix tests (seeds DEADBEEF and CAFEBABE), and a sweep of every code point finds 0 one-way edges. Releases 10.2.0 to 10.6.0 (11 releases, SHA-1-checked jars) fail; 10.1.0 has no CASE_INSENSITIVE flag and its ASCII flag passes the same assertion. Commit-level runs on #14192, #14384 and their parents. Upstream main 2f424fd428da (2026-10-30) has the same code [read].

Lucene and upstream sources

The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/CaseExpansion.java:31-46
  • a Lucene main checkout (645d4fe14a20) gradle/regenerate/icu/GenerateCaseExpansion.groovy:14-35, :71-85
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:682-687, :701-705, :743-752, :773, :1338
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:121-127, :569-592, :700-709
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:683, :702
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/RegexpQuery.java:77-79
  • a Lucene main checkout (645d4fe14a20) RegExp.java:352, Automata.java:44, RegexpQuery.java:40 (@lucene.experimental)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:428-441, :464-466
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:121-124
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/CaseExpansion.java:31
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java:690-693; lucene/core/src/java/org/apache/lucene/util/SimpleCaseFolding.java:43-44
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/automaton/TestRegExp.java:171-173, :263-282
  • GitHub (read-only, 2026-10-31): apache/lucene#14384 issuecomment-2743662885; #14350 issuecomment-2726984173; #14192 discussion_r1942009773
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/icu/build.gradle:287-312 (regenerateCaseExpansion inputs: the ICU version only)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed probe numbers (the 4x4 match matrix and the RegexpQuery hit sets).

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.