All findings · Lucene finding · LFV-533 · capitalization-filter-charwise-case-mapping-supplementary

CapitalizationFilter never changes the case of letters outside the BMP, so Adlam, Osage and Deseret words are neither capitalized nor lower-cased

CapitalizationFilter changes case one UTF-16 char at a time, so letters stored as two chars (Adlam, Osage and six more scripts) keep their case: PARIS and paris become one term, but an Adlam word in capitals and in small letters stays two, so a query finds 1 of 2 documents.

Summary

Priority
Low, rank 528CapitalizationFilter ignores case for supplementary-plane cased scripts such as Adlam and Osage, a tiny share of text in a niche filter.
Impact
silent wrong results
Since
4.0.0-ALPHA (2012), the first release with the class; the code is from Solr 1.3 (2007-2008)introduced 2007-07-14 · over 19 years
Affected
4.0.0-ALPHA to 10.5.2 and main; executed on 4.0.0-ALPHA, 4.0.0, 8.11.4, 9.12.3, 10.5.2 and main; the 129 cached release jars 4.0.0 to 10.5.1 make the same four char calls
Status
documented
Found by
a bounded-exhaustive comparison with a code-point reference written from the javadoc (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

processWord changes case with Character.toUpperCase(char) and toLowerCase(char), one 16-bit char at a time (CapitalizationFilter.java:152, :159, :190, :193). A letter outside the BMP is two chars; each half alone has no case and comes back unchanged. LowerCaseFilter and UpperCaseFilter map whole code points.

Background: letters stored as two chars

  1. Java stores text as 16-bit char values. Most letters of everyday text, Latin, Greek and Cyrillic included, fit in one char: they are in the Basic Multilingual Plane (BMP).
  2. Letters above U+FFFF, such as those of Adlam, Osage and Deseret, take two chars, a surrogate pair: the Adlam small alif U+1E922 is U+D83A U+DD22.
  3. A surrogate alone is not a letter. Character.toUpperCase(char) returns U+D83A unchanged; Character.toUpperCase(int) on the whole code point U+1E922 returns the capital U+1E900.
Diagram of the bug: CapitalizationFilter with the default settings turns "aB" + U+104B0 (Osage capital letter A) into "Ab" + U+104B0 instead of "Ab" + U+104D8. In memory the token is four chars, 0061 0042 D801 DCB0; line 190 upper-cases 'a' to 'A', line 193 lower-cases 'B' to 'b', and the surrogate halves D801 and DCB0 map to themselves, so the result is 0041 0062 D801 DCB0, while the expected small letter is D801 DCD8. The same happens at lines 152, 159, 190 and 193. In a CustomAnalyzer field with the keyword tokenizer and the capitalization filter, kitten, KITTEN and Kitten give one term, Kitten, found 3 of 3 times, but three Osage case forms give three terms and a query for the capitalized form finds 1 of 3. Mapping whole code points fixes it: one term, 3 of 3.
How the token "aB" + Osage capital A (U+104B0) is stored as four chars, 0061 0042 D801 DCB0, why changing the case of each char alone leaves the Osage letter capital (actual D801 DCB0, expected D801 DCD8), the same calls in every branch, and an index where three Osage case forms give three terms instead of one. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You analyse a field with KeywordTokenizer + CapitalizationFilter (default settings), documented to "make the first letter capital and the rest lower case", and index four values: PARIS, paris, and the Adlam letters alif + daali in capitals (U+1E900 U+1E901) and in small letters (U+1E922 U+1E923).
  2. You expect 2 terms, Paris and U+1E900 U+1E923 (capital alif, small daali), and a query for either Adlam spelling to find both Adlam documents.
  3. You get 3 terms: Paris, U+1E900 U+1E901 and U+1E922 U+1E923. The query paris finds 2 documents; the query U+1E922 U+1E923 finds 1.
  4. Why: each Adlam letter is two Java chars, a surrogate pair. The filter passes each char alone to Character.toUpperCase or toLowerCase; half of a pair has no case and comes back unchanged. BMP letters in the same token are mapped: aB + Osage U+104B0 gives Ab + U+104B0, not Ab + U+104D8.
  5. No exception in any run, and the terms stay valid UTF-16. Every path of the filter does it: the first letter, the rest of the word, later words (onlyFirstWord, on by default) and keep words with forceFirstLetter.

Who is affected

Any chain with CapitalizationFilter or the capitalization factory (schemas, CustomAnalyzer). It needs text in one of eight cased scripts outside the BMP, such as Adlam (a living script for Fulani) or Osage; there it happens every time: case variants stay separate terms and facet values (with a case-sensitive keep list, some merge into a kept word instead).

Mechanism, terms and numbers624 words

The javadoc of CapitalizationFilter says "It will make the first letter capital and the rest lower case" (CapitalizationFilter.java:26-28). Its processWord changes case with Character.toUpperCase(char) and Character.toLowerCase(char), one UTF-16 char at a time (:152, :159, :190, :193). Letters outside the Basic Multilingual Plane (BMP) are stored as two chars, a surrogate pair, and each half alone has no case, so the 564 cased code points of Adlam, Deseret, Garay, Medefaidrin, Old Hungarian, Osage, Vithkuqi and Warang Citi are never capitalized and never lower-cased, while BMP letters in the same token are. In a field analysed with KeywordTokenizer + CapitalizationFilter, PARIS and paris become one term, Paris, but the Adlam letters alif + daali in capitals and in small letters stay two terms, neither capitalized, and a query for one spelling finds 1 of the 2 documents. No run threw, and the output is valid UTF-16 of the same length. The char-wise lines come from the Solr original (2007-2008), were moved into Lucene unchanged in 2010 and are in every release since 4.0.0-ALPHA; mapping code points, as LowerCaseFilter and UpperCaseFilter do, fixes it.

Terms

  • UTF-16 char: Java stores text as 16-bit char values. Most letters 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. The Adlam small alif U+1E922 is U+D83A U+DD22. A surrogate alone is not a letter and has no case.
  • Code-point case mapping: Character.toUpperCase(int) and toLowerCase(int) map a whole code point (U+1E922 to U+1E900). The char overloads cannot: the JDK documents that they "cannot handle supplementary characters".

The bug

  1. incrementToken splits the term into words at chars <= ' ' and . and calls processWord on each word, editing the term buffer in place (CapitalizationFilter.java:102-143).
  2. processWord (:145-196) maps case with the char overloads at four places: :152, every char of a later word when onlyFirstWord is true (the default); :159, the first char of a keep word with forceFirstLetter; :190, buffer[offset] = Character.toUpperCase(buffer[offset]);, the first letter; :193, every other char of the word, lower-cased.
  3. For a surrogate both overloads return the char unchanged (all 2,048 surrogate units checked), so a supplementary letter keeps its case. BMP letters in the same word are mapped.
  4. The code-point helpers exist next door: CharacterUtils.toLowerCase and toUpperCase (CharacterUtils.java:54-70, :105-113), used by LowerCaseFilter (:38) and UpperCaseFilter (:48). This filter's own case-insensitive keep set compares per code point too (CharArrayMap.java:231-290).

The numbers

input (one token, KeywordTokenizer)expectedstock filterpath
U+1E922 U+1E923 (Adlam, small)U+1E900 U+1E923U+1E922 U+1E923first letter: high half :190, low half :193
U+1E900 U+1E901 (Adlam, capitals)U+1E900 U+1E923U+1E900 U+1E901rest of the word, :193
aB U+104B0 (Osage capital A)Ab U+104D8Ab U+104B0rest of the word, :193
hEy space U+10400 U+10401 (Deseret capitals)Hey space U+10428 U+10429Hey space U+10400 U+10401later word, :152
keep word U+10428 x, forceFirstLetterU+10400 xU+10428 xkeep word, :159
kiTTEN, hEy yOU (controls)Kitten, Hey youKitten, Hey youBMP

In an index (KeywordTokenizer + CapitalizationFilter, four documents PARIS, paris, U+1E900 U+1E901, U+1E922 U+1E923): expected terms [Paris, U+1E900 U+1E923], actual [Paris, U+1E900 U+1E901, U+1E922 U+1E923]; the query paris finds 2 documents, the query U+1E922 U+1E923 finds 1 instead of 2.

The supplementary code points with a case mapping (JDK 25 tables): 564 in eight scripts, Adlam 68, Deseret 80, Garay 44, Medefaidrin 64, Old Hungarian 102, Osage 72, Vithkuqi 70, Warang Citi 64. Emoji and CJK ideographs have no case and are not affected.

Exposure and likelihood356 words
  • Who is exposed. Chains built with the constructors, the capitalization factory in a CustomAnalyzer (reached with the stock standard, whitespace and keyword tokenizers: 9 of 9 cases unmapped, while lowercase in the same pipeline lower-cases the capitals) or a schema field type like the factory's javadoc example. Every path that changes case is affected (the default settings, onlyFirstWord=false and a keep word with forceFirstLetter were run). The filter is opt-in and in no stock analyzer.
  • What it takes. Text in Adlam (a living script for Fulani), Osage, or historic or rarely used Deseret, Old Hungarian, Warang Citi, Medefaidrin, Vithkuqi or Garay.
  • What changes for the user. Usually, case variants of such words stay separate terms (3 instead of 2 in the repro), separate facet values, and a query in one case misses the other (1 hit instead of 2). The capitalized display form the filter exists for is never produced for them. In one configuration the opposite also happens: with a case-sensitive keep list and forceFirstLetter, the stock filter turns [a, U+10400] into the kept word [A, U+10400] instead of [A, U+10428], merging case variants that the fixed filter keeps apart (35 stock terms in that configuration of the security probe, none in the other four).
  • What does not happen. In a bounded probe over 7,380 tokens and 5 configurations, assertions on and off: no exception, no change in term length, valid UTF-16; the filter keeps no state across tokens or documents.
  • Scale. Deterministic, with assertions on or off. The result depends only on the token text and the settings, so index size should not matter; only small indexes were run.
  • JDK dependence. Deseret (Unicode 3.1) shows it on every JDK Lucene supported; Adlam and Osage need Unicode 9.0 tables, which first came with JDK 11 (JDK 9 and 10 have Unicode 8.0; JDK 11 has Unicode 10.0, JEP 327). Only JDK 25 was run for the repro; a JDK 11 sanity run maps Adlam and Osage too.
  • How likely. Rare: the documented searches found no public report since Solr 1.3.0 (2008), and it needs those scripts in a field with this filter.

Minimal repro

The test shows that CapitalizationFilter does not capitalize a first letter outside the BMP: the Adlam word U+1E922 U+1E923 goes through KeywordTokenizer + CapitalizationFilter unchanged.

TestCapitalizationFilterSupplementary.java (JUnit test)42 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.miscellaneous;

import java.io.StringReader;
import java.util.List;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.core.KeywordTokenizer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestCapitalizationFilterSupplementary extends LuceneTestCase {

  public void testCapitalizesFirstLetterOutsideBmp() throws Exception {
    Tokenizer tokenizer = new KeywordTokenizer();
    tokenizer.setReader(new StringReader(new String(new int[] {0x1E922, 0x1E923}, 0, 2)));
    try (TokenStream ts = new CapitalizationFilter(tokenizer)) {
      CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
      ts.reset();
      ts.incrementToken();
      // expected U+1E900 U+1E923 (Adlam capital first letter), actual U+1E922 U+1E923 (unchanged)
      assertEquals(
          List.of("1e900", "1e923"),
          term.toString().codePoints().mapToObj(Integer::toHexString).toList());
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-533/, 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.miscellaneous.TestCapitalizationFilterSupplementary
Expected vs actual
Expected U+1E900 U+1E923 (capital first letter); actual U+1E922 U+1E923 (expected:<[1e900, 1e923]> but was:<[1e922, 1e923]>), with -ea and -da.

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

More files, environment, full output36 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
    => java.lang.AssertionError: expected:<[1e900, 1e923]> but was:<[1e922, 1e923]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.analysis.miscellaneous.TestCapitalizationFilterSupplementary.testCapitalizesFirstLetterOutsideBmp(TestCapitalizationFilterSupplementary.java:37)

Why it looks unintended

  • The javadoc promises "the first letter capital and the rest lower case", with no BMP limit; the JDK says the char overloads "cannot handle supplementary characters".
  • LowerCaseFilter, UpperCaseFilter and this filter's own keep set work per code point; tests require it for Deseret.
  • The same defect was fixed in LowerCaseFilter (LUCENE-2069) and, as bugs, in TruncateTokenFilter (#15900) and DecimalDigitFilter (LUCENE-6914).
The full argument263 words
  • Contract. "A filter to apply normal capitalization rules to Tokens. It will make the first letter capital and the rest lower case." (CapitalizationFilter.java:26-28). No script, BMP or UTF-16-unit limit in the class or the factory javadoc. Where Lucene means UTF-16 units it says so: LengthFilter.java:26, "Length is calculated as the number of UTF-16 code units".
  • The API used cannot do it. The JDK javadoc of Character.toUpperCase(char) and toLowerCase(char): "This method cannot handle supplementary characters. To support all Unicode characters, including supplementary characters, use the toUpperCase(int) method."
  • Siblings and their tests. TestAnalyzers.testLowerCaseFilter and testUpperCaseFilter ("handles entire unicode range correctly") require Deseret to be mapped (TestAnalyzers.java:135-169). The filter's own keepIgnoreCase set folds case per code point.
  • Policy before the class moved in. 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." That commit (2009) is an ancestor of the 2010 move of this class into Lucene, which copied the char loop verbatim.
  • Same defect class treated as a bug. CHANGES.txt:1198-1200 (10.5.0 Bug Fixes): "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". CHANGES.txt:8852: "LUCENE-6914: Fixed DecimalDigitFilter in case of supplementary code points."
  • Nothing presents it as a choice. No test, javadoc, CHANGES or MIGRATE entry, commit message or JIRA discussion (SOLR-248, SOLR-468 and LUCENE-2413, read in full) marks the char loop as BMP-only. TestCapitalizationFilter.testRandomString checks token-stream consistency, not case.
The devil's-advocate check187 words

The strongest case for "intended" (the intended-behaviour check and the cross-model reviewer): a legacy, display-oriented char-buffer filter from Solr 1.3 ("useful to build nice looking facet parameters"), never updated, whose fix changes indexed output; on #15900 uschindler argued at one point that half surrogates from char-based truncation are "expected", "exactly the same like if you use String#substring(n,m)" (earlier in the same thread, uschindler had called that filter "somehow broken in general"), and both LUCENE-2069 and #15900 kept a legacy path.

Why it fails: nothing documents a BMP limit; the JDK documents the overloads used as unable to do the job; the sibling case filters and this filter's own keep set work per code point; on #15900 rmuir wrote "Nobody expects that behavior and doing this based on codepoints is a better api" (before the "expected" remark), and that fix shipped under Bug Fixes. Every verifier who assessed the argument rated it weak (the intended-behaviour check: no intended argument survives). What survives is the narrow reach and the reindex cost: reasons for low priority and a CHANGES note, perhaps a legacy option, not for keeping the behaviour.

Suggested fix

Recommended A: map code points, as LowerCaseFilter and UpperCaseFilter do

Map code points, as LowerCaseFilter does: CharacterUtils.toLowerCase for the rest and later words, Character.toUpperCase(int) on the first code point (+11/-9 lines). The repro passes with assertions on and off; the whole analysis-common test task (1,913 tests, assertions on) and lint pass; lone surrogates stay unchanged. Indexed terms change for these scripts: mention reindexing.

All options and trade-offs3 options · 636 words

One fix, the finder's patch, was rebuilt and validated in a scratch worktree at a9779ef3c193; it applies unchanged.

A: map code points, as LowerCaseFilter and UpperCaseFilter do (validated by the checks) (recommended)

Replace the two char loops with CharacterUtils.toLowerCase(buffer, from, to) and the two first-letter statements with a small helper that reads the first code point, upper-cases it with Character.toUpperCase(int) and writes it back with Character.toChars. All three paths of processWord are covered.

--- a/lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/CapitalizationFilter.java
+++ b/lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/CapitalizationFilter.java
@@ -19,6 +19,7 @@
 import java.io.IOException;
 import java.util.Collection;
 import org.apache.lucene.analysis.CharArraySet;
+import org.apache.lucene.analysis.CharacterUtils;
 import org.apache.lucene.analysis.TokenFilter;
 import org.apache.lucene.analysis.TokenStream;
 import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
@@ -148,15 +149,13 @@
     }
 
     if (onlyFirstWord && wordCount > 0) {
-      for (int i = 0; i < length; i++) {
-        buffer[offset + i] = Character.toLowerCase(buffer[offset + i]);
-      }
+      CharacterUtils.toLowerCase(buffer, offset, offset + length);
       return;
     }
 
     if (keep != null && keep.contains(buffer, offset, length)) {
       if (wordCount == 0 && forceFirstLetter) {
-        buffer[offset] = Character.toUpperCase(buffer[offset]);
+        upperCaseFirstCodePoint(buffer, offset, length);
       }
       return;
     }
@@ -187,11 +186,14 @@
     /*char[] chars = w.toCharArray();
     StringBuilder word = new StringBuilder( w.length() );
     word.append( Character.toUpperCase( chars[0] ) );*/
-    buffer[offset] = Character.toUpperCase(buffer[offset]);
-
-    for (int i = 1; i < length; i++) {
-      buffer[offset + i] = Character.toLowerCase(buffer[offset + i]);
-    }
+    int firstLen = upperCaseFirstCodePoint(buffer, offset, length);
+    CharacterUtils.toLowerCase(buffer, offset + firstLen, offset + length);
     // return word.toString();
   }
+
+  /** Upper-cases the first code point of the word; returns its length in chars. */
+  private static int upperCaseFirstCodePoint(char[] buffer, int offset, int length) {
+    int cp = Character.codePointAt(buffer, offset, offset + length);
+    return Character.toChars(Character.toUpperCase(cp), buffer, offset);
+  }
 }

Trade-offs

  • Output changes only for supplementary letters; BMP text gives the same chars as before. Indexes built with this filter need reindexing for exact consistency, so a CHANGES note should say so.
  • The in-place edit relies on case mapping keeping each code point's UTF-16 length; that holds for all 1,112,064 non-surrogate code points with JDK 25, and CharacterUtils.toLowerCase (used by LowerCaseFilter) relies on the same.
  • With a case-sensitive keep list, the stock filter maps [a, U+10400] onto a kept word [A, U+10400]; the fixed filter keeps them apart ([A, U+10428]). Worth one sentence in the PR.

What the checks validated

  • Stock main: the paste-ready test fails 3 of 4, assertions on and off; the 29 upstream TestCapitalizationFilter* tests pass.
  • With fix A, TestCapitalizationFilter*: 3 classes, 33 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, checkGoogleJavaFormat, forbiddenApisMain and forbiddenApisTest of the module pass.
  • The standalone runner with the fixed class shadowed: 4 of 4 on main, -ea and -da; earlier, the finder's test (4 of 4) and the 29 upstream tests on ea57360912ed.
  • Malformed UTF-16: all 137,257 strings of 0-6 chars over {a, B, space, ., U+D801, U+DCB0, U+DCD8} (128,166 with an unpaired surrogate) through the stock and the fixed filter, three configurations, -ea and -da: no exception, the same length, lone surrogates unchanged, and without a valid pair the fixed output equals the stock output.

B: A plus a legacy option (not built)

Keep the char-wise path behind an option, as #15900 did for TruncateTokenFilter ("new factory parameters available to configure legacy prefix chars and new codepoint behaviour") or LUCENE-2069 did with a Version argument.

Trade-offs

More API surface for 564 code points; only worth it if maintainers want old terms to stay exactly as they are without reindexing.

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

A javadoc note that only BMP letters change case.

Trade-offs

Cheap, but contradicts LowerCaseFilter, UpperCaseFilter, the filter's own keep set and the project's past fixes.

Downstream note

Search servers built on Lucene that expose the factory in a schema, as its javadoc example text_cptlztn does, inherit the behaviour.

How it was found

Semi-formal. A bounded-exhaustive comparison ran the stock filter against a per-code-point reference written from its javadoc, on every token of up to 4 code points over a, B, space and two Deseret letters, with both onlyFirstWord settings: 836 of 1,414 checks differed, each at a supplementary letter. A public-API JUnit then confirmed it on the stock jar.

The method in full179 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier). The finder wrote two references for CapitalizationFilter from its javadoc, one that maps case per char and one per code point, and compared the stock filter with them:

  • with the per-char reference, on every token of up to 5 chars over {a, B, space, '.', U+0131} under 1,440 parameter combinations (7,874,496 checks): the mismatches found the sibling word-boundary bug (LFV-535);
  • with the per-code-point reference, on every token of up to 4 code points over {a, B, space, U+10428, U+10400}, with the default parameters and with onlyFirstWord=false, restricted to checks (a token with one onlyFirstWord setting) where the per-char reference agrees, so the sibling bug is excluded: 836 of 1,414 checks differ, each at a supplementary letter; 880 of all 1,562 checks (781 tokens x 2 settings) still differ after the sibling's fix.

A public-API JUnit on the stock main jar then confirmed it (found 2026-10-26). The same reference was used to find and to check, so the exhaustive count is not independent evidence; the verifiers wrote their own tests.

Prior reports, history and checks

Prior reports

No prior report found: four scripted prior-report search runs (GitHub, JIRA, the Lucene and old Solr lists, the web), 64 GitHub and 16 JIRA searches and the files of all 408 open PRs (2026-10-26), and a GitHub re-check on 2026-10-28. Nearest: #2763, the 2009 policy for supplementary characters in char-based code, which never names this class. Not fixed upstream.

Searches5 searches

The documented searches found no issue, PR, review, commit message, JIRA entry, mailing-list thread or web page that reports that CapitalizationFilter maps case per char or leaves supplementary letters unchanged. Every CapitalizationFilter issue on record is about other behaviour: SOLR-248 (origin), SOLR-468 (keep semantics), SOLR-1317 (NPE), SOLR-1320 and SOLR-1342 (term-buffer length), SOLR-1660 (maxWordCount crash) and LUCENE-5559 = #6621 (argument validation). Context and precedent: #2763 (LUCENE-1689, the 2009 umbrella; it names only Lucene classes, and this class was in Solr until 2010), #3145 (LUCENE-2069, LowerCaseFilter), #6433 (LUCENE-5369, UpperCaseFilter), #7972 (LUCENE-6914) and #15899 / #15900 (TruncateTokenFilter). If you read the 2009 umbrella as covering every char-based class, the label becomes "known". LFV-685 (IrishLowerCaseFilter) 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-26the scripted prior-report search, the finder's run (45 queries) and the follow-up's (58: GitHub 20, commits 3, downstream 2, lists 20, JIRA 4, web 9), plus 64 GraphQL issue and PR searches over apache/lucene, apache/solr, Elasticsearch, OpenSearch, Lucene.NET and all of GitHubno report; only #6621 and #6108 mention the class (in the body or comments), neither about this
Open PRs2026-10-26the changed files of all 408 open apache/lucene PRsonly #16911 (decommission branch_10x) lists the file; not a fix
JIRA archive and mailing lists2026-10-2616 JQL searches over SOLR and LUCENE, 19 issues read in full; dev@, java-user@, issues@, commits@, solr-user@, solr-dev@, general@ and java-dev@ (two more tool runs, 24 queries on the old Solr lists)no report; a 2010 solr-user thread about '.' splitting words belongs to the sibling LFV-535
Web2026-10-269 general web searches (titles and snippets)no report; one 2011 Q&A page, 'Solr CapitalizationFilterFactory not working', could not be opened
GitHub re-check2026-10-28read-only searches for CapitalizationFilter and capitalization issues and PRs, the file lists of the 15 open PRs created since 2026-10-26, and the commits on the filenothing new (of the 15 new open PRs only #16911, the branch_10x decommission, touches the file: it deletes it there; not a fix); the file's last upstream commit is from 2020-12-28 (LUCENE-9570, formatting)

History

As old as the code: char-wise since the Solr original 777e0c81608 (SOLR-248, 2007); the current lines are from ac58a1a4dd4 (SOLR-468, 2008), moved into Lucene unchanged by 8f71031ac88 (LUCENE-2413, 2010). First Lucene release 4.0.0-ALPHA; never changed since, and every release tested reproduces it.

Versions tested9 rows

Introduced by 777e0c81608 (Solr, SOLR-248, svn r556196, 2007-07-14, "SOLR-248 -- Added CapitalizationFilterFactory"), whose first letter, rest-of-word and keep-word paths were already char-wise; only the later-words path used String.toLowerCase(), which maps code points. ac58a1a4dd4 (SOLR-468, svn r630572, 2008-02-24) rewrote processWord to edit the char[] in place, which produced the four statements on main and made the later-words path char-wise too. 8f71031ac88 (LUCENE-2413 = #3488, svn r957162, 2010-06-23, "consolidate remaining solr tokenstreams into modules/analysis") copied it into Lucene verbatim. svn era, no pull request. Later commits only changed the path, argument validation (LUCENE-5559), an import (LUCENE-7318), licence headers, two javadoc doclint fixes (bab0b463371, 376256316b0) and formatting (LUCENE-9570). All 133 release-tag versions of the file (Solr 1.3.0-3.6.2, Lucene 4.0.0-ALPHA-10.5.2) have the four char-wise statements. The history clone has no tags for 8.8.2-8.11.4, so the release jars were checked too: in the 129 release jars 4.0.0 to 10.5.1 of the hunt's jar cache (SHA-1 checked) the bytecode of processWord calls Character.toUpperCase(char) twice and Character.toLowerCase(char) twice, and no code-point overload. Not a regression: LowerCaseFilter already mapped code points (LUCENE-2069, 2009) when the class moved in. Upstream main 645d4fe14a20, branch_10x and branch_10_6 have the same file blob as the tested commit (2026-10-28).

Versions and branches executed, with results
Version or commitVerdictResult
Lucene 3.6.2 no classClassNotFoundException: the Lucene class does not exist before 4.0.0-ALPHA
Solr 1.3.0 Solr's classnone of 4 supplementary cases (Adlam x2, Osage, Deseret) mapped; BMP control OK
4.0.0-ALPHA, 4.0.0 affecteda release-portable main(): 3 of 3 supplementary cases wrong, control OK
8.11.4 affectedthe same main(): the same 3 cases wrong
4.0.0 to 10.5.1 (129 release jars) bytecodejavap -c of processWord: 2 x toUpperCase(char), 2 x toLowerCase(char), no code-point call, in every release jar, 8.8.2-8.11.4 included
9.12.3, 10.5.2 affectedthis doc's test: 4 run, 3 failed (-da), the same messages as main; the hunt's and the independent reproduction's tests fail too
main ea57360912ed affectedthe hunt's test 3 of 4 and the independent reproduction's 5 of 7 fail, -ea and -da
main a9779ef3c193 affectedthis doc's test: 3 of 4 fail through gradle and the JUnit runner, -ea and -da
main + fix A fix33 of 33 (gradle, -ea and -da); whole analysis-common module 1,913 tests, 0 failures (-ea)

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) could only read, not run: real once, and once not real for that procedural reason alone. This doc wrote a new test and validated the fix through gradle.

Checks and verdicts327 words

The hunt's checks ran on main ea57360912ed, this doc's on a9779ef3c193; the filter and its factory are byte-identical in both. Every run used JDK 25.0.3.

Each independent check and its verdict
CheckVerdictWhat it found
finder: exhaustive check and JUnit 836 of 1,414 checks wrongAll at a supplementary letter. The JUnit fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3; with the fix 4 of 4 pass, and the 29 upstream tests pass.
cross-model reviewer, intended lens (OpenAI GPT-5.6) real, mediumReading only (it could not start Java); intended argument weak; a separate re-run of the repro failed 3 of 4.
Independent reproducer realOwn 7-test JUnit with Deseret letters fails 5 of 7 on main (-ea, -da), 10.5.2 and 9.12.3; oracle and BMP controls pass; it also covers the onlyFirstWord and keep-word paths.
Cross-model reviewer, refute lens (OpenAI GPT-5.6) not real, low (procedural)Could not execute; agreed on the root cause and the contract, rated the intended argument weak and noted "Static evidence strongly favors a real defect"; a separate re-run failed 3 of 4.
Intended-behaviour devil's advocate real; no intended argument survivesReached it through CustomAnalyzer with the stock standard, whitespace and keyword tokenizers: 9 of 9 cases unmapped, while 'lowercase' maps them (-ea, -da).
adjudication confirmed, highNew seed: the independent reproduction's test 5 of 7 fail (-ea and -da), the finder's 3 of 4.
registry re-run and credibility audit keep3 of 4 fail with -ea and -da, 4 of 4 pass with the fix; the audit re-ran with a fresh seed and advises a low-priority correctness fix.
this doc confirmed; fix validatedNew format-clean test fails 3 of 4 through gradle (-ea, -da) and the runner (main, 10.5.2, 9.12.3); with the fix it passes, as do the whole analysis-common test task (assertions on) and the module's lint, format and forbidden-API checks; the fix also holds on malformed UTF-16.

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

Finding record

LFV-533, snapshot of 2026-11-06

Registry title
CapitalizationFilter changes case one UTF-16 char at a time, so cased letters outside the BMP (Adlam, Osage, Deseret and five other scripts) are neither capitalized nor lower-cased, while BMP letters in the same token are
Area
analysis-common / miscellaneous (CapitalizationFilter case mapping)
Priority
Low, rank 528: CapitalizationFilter ignores case for supplementary-plane cased scripts such as Adlam and Osage, a tiny share of text in a niche filter.
Impact class
silent wrong results
Impact note
Hits applications that put the opt-in CapitalizationFilter (directly, or by its 'capitalization' factory name in a schema or CustomAnalyzer) on a field that holds text in one of the eight cased scripts encoded outside the BMP (Adlam, Osage, Deseret, Old Hungarian, Warang Citi, Medefaidrin, Vithkuqi, Garay): every such letter that should change case keeps its case, on every release since 4.0.0-ALPHA, deterministically with -ea and -da and at any scale, so case variants of one word stay separate terms and facet values and a query in the other case misses; the output stays valid UTF-16, nothing is thrown or corrupted, and BMP text, emoji and CJK ideographs are not affected.
Summary
You add CapitalizationFilter, documented to 'make the first letter capital and the rest lower case', and give it a word in a cased script outside the BMP, for example the Adlam word U+1E922 U+1E923 (two small letters), and expect U+1E900 U+1E923 (capital first letter). Instead every supplementary letter keeps its case: U+1E922 U+1E923 comes out unchanged, U+1E900 U+1E901 is not lower-cased to U+1E900 U+1E923, and 'aB' followed by the Osage capital U+104B0 gives 'Ab' + U+104B0, because processWord applies Character.toUpperCase(char) and toLowerCase(char) to single UTF-16 units and a surrogate unit maps to itself. BMP letters in the same token are mapped, nothing is thrown, and the onlyFirstWord and keep-word forceFirstLetter paths behave the same way.
Introduced
777e0c81608b331e766c9d283778186e8fb4c7ab (Solr, JIRA SOLR-248, svn r556196; the first version of the filter, with char-wise first-letter, rest-of-word and keep-word case mapping); the four statements now on main come from ac58a1a4dd4b (SOLR-468, svn r630572, the in-place char[] rewrite, which also made the onlyFirstWord later-words path char-wise) and were copied verbatim into Lucene by 8f71031ac88e (LUCENE-2413 = #3488, svn r957162) [read: git blame -w -C -C -M of main lines 152, 159, 190 and 193 and per-commit diffs in the full-history clone]; svn era, no pull request; never changed since (later commits touched only the path, argument validation, an import, license headers and formatting) (2007-07-14 (Solr origin; current line form 2008-02-24; moved into Lucene 2010-06-23))
First affected version
4.0.0-ALPHA [exec] (the first Lucene release with the class, 2012-06-29; first GA release 4.0.0 [exec]); the same code shipped earlier as Solr's own class from Solr 1.3.0 [exec]; Lucene 3.6.2 has no such class [exec: ClassNotFoundException]
Affected versions
every Lucene release before 10.6.0 with the class, 4.0.0-ALPHA through 10.5.2, and main ea57360912ed: JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3 [exec, finder and the independent reproduction], and a release-portable main() fails on 8.11.4, 4.0.0 [exec, finder] and 4.0.0-ALPHA and Solr 1.3.0 [exec, follow-up history check]; the four char-wise statements are unchanged in all 133 release-tag file versions scanned (Solr 1.3.0-3.6.2, Lucene 4.0.0-ALPHA-10.5.2) [read]; not fixed upstream: upstream main ffa9d816eeb4 has a byte-identical file and branch_10x, branch_10_6 and branch_10_5 have the same lines [read, 2026-10-26]; unaffected: Lucene before 4.0.0-ALPHA (no class) [exec on 3.6.2]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new, all read-only: the finder's scripted prior-report search (45 queries) and the follow-up's (58 queries over GitHub issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, every source finished), two more scripted prior-report searches over the ASF and old Solr lists (24 queries on solr-user@, solr-dev@, general@ and java-dev@), 64 GitHub GraphQL issue and PR searches (apache/lucene, apache/solr, Elasticsearch, OpenSearch, Lucene.NET and all of GitHub), 16 JQL searches with 19 SOLR and LUCENE issues read in full, the file lists of all 408 open apache/lucene PRs, the solr.apache.org lists, full-history git searches and 9 web searches; gap: Q&A sites only through web search, and one 2011 Q&A page titled 'Solr CapitalizationFilterFactory not working' could not be opened); every CapitalizationFilter issue on record (SOLR-248, SOLR-468, SOLR-1317, SOLR-1320, SOLR-1342, SOLR-1660, LUCENE-5559 = #6621) is about other behaviour; related prior items, context and precedent only: #2763 (LUCENE-1689, the 2009 umbrella 'supplementary character handling', which set the policy that char-based analysis code must handle supplementary characters; it names only Lucene classes, and CapitalizationFilter was a Solr class until 2010), #3145 (LUCENE-2069, the same defect fixed in LowerCaseFilter), #6433 (LUCENE-5369, UpperCaseFilter maps per code point), #3144, #3259 and #3170 (LUCENE-2068, -2183, -2094), SOLR-1593, #7972 and #8417 (LUCENE-6914, LUCENE-7363, DecimalDigitFilter), #6106 (LUCENE-5042), #15899 and #15900 (TruncateTokenFilter on code points, 10.5.0 Bug Fixes; in #15899 uschindler wrote that all other similar filters work correctly), #3488 (LUCENE-2413, moved the class into Lucene); not fixed upstream: upstream main ffa9d816eeb4 has a byte-identical file and no open PR changes it (#16911, the branch_10x decommission, deletes it on that branch only and is not a fix). Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
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); main ea57360912ed, 8.11.4, 4.0.0, 4.0.0-ALPHA and Solr 1.3.0 (the hunt's tests and the history probe); the fix on main (gradle: the TestCapitalizationFilter* classes and the whole :lucene:analysis:common:test task, plus the module's lint, format and forbidden-API 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/miscellaneous/CapitalizationFilter.java:26-31 (class javadoc: 'It will make the first letter capital and the rest lower case'; 'nice looking facet parameters'), :145-196 (processWord), :152, :159, :190, :193 (Character.toLowerCase(char) / toUpperCase(char) on single UTF-16 units), :107 and :116 (termAtt.length(), in UTF-16 units, compared with maxTokenLength)
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/CapitalizationFilterFactory.java (factory javadoc, no BMP or code-unit limit; no normalize() override)
  • a Lucene main checkout lucene/analysis/common/src/test/org/apache/lucene/analysis/core/TestAnalyzers.java:136-166 (LowerCaseFilter and UpperCaseFilter must map Deseret letters)
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/LengthFilter.java:26 ('Length is calculated as the number of UTF-16 code units'); lucene/analysis/common/src/java/org/apache/lucene/analysis/ga/IrishLowerCaseFilter.java:55 (side observation, char overload, not executed)
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/CapitalizationFilter.java:26-31 (javadoc), :102-143 (incrementToken), :145-196 (processWord; char overloads at :152, :159, :190, :193)
  • lucene/core/src/java/org/apache/lucene/analysis/CharacterUtils.java:54-70, :105-113; lucene/core/src/java/org/apache/lucene/analysis/LowerCaseFilter.java:38; lucene/analysis/common/src/java/org/apache/lucene/analysis/core/UpperCaseFilter.java:48
  • lucene/core/src/java/org/apache/lucene/analysis/CharArrayMap.java:231-290 (code-point case folding of the keep set)
  • JDK 25 src.zip java.base/java/lang/Character.java:11210-11213 (toLowerCase(char)), :11270-11273 (toUpperCase(char)): 'cannot handle supplementary characters'
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/CapitalizationFilterFactory.java:49-58 (schema example), :65 (SPI name)
Why it looks unintended
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/CapitalizationFilter.java:26-31
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/CapitalizationFilterFactory.java:28-61 (factory javadoc)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/LengthFilter.java:26
  • JDK 25 src.zip java.base/java/lang/Character.java:11210-11213, :11270-11273
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/core/TestAnalyzers.java:135-169
  • lucene/core/src/java/org/apache/lucene/analysis/CharArrayMap.java:231-290
  • lucene/CHANGES.txt:17927-17928 (3.1.0, LUCENE-2069), :1198-1200 (10.5.0 Bug Fixes, GITHUB#15899/#15900), :8852 (LUCENE-6914)
Suggested fix
  • lucene/CHANGES.txt:1198-1200 (#15900 legacy parameters), :17927-17931 (LUCENE-2069 Version argument)
Prior reports
  • gh api repos/apache/lucene/commits?path=.../CapitalizationFilter.java; gh search issues/prs --repo apache/lucene CapitalizationFilter; gh pr list --search created:>=2026-10-26 (read-only, 2026-10-28)
History
  • gh api repos/apache/lucene/contents/.../CapitalizationFilter.java?ref=645d4fe14a20, branch_10x, branch_10_6: blob 08b46c3191e5 (= a9779ef3c193) (read-only, 2026-10-28)
Diagrams
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/CapitalizationFilter.java:27-28, :30, :120-134, :145-196 (:152, :159, :190, :193)

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 from an executed probe on main a9779ef3c193 (stock jars and the fixed class, assertions on and off); every value drawn comes from that probe and the source at that commit. It uses the Osage example (left to right, BMP and supplementary letters in one token) rather than the Adlam one of the repro; the defect is the same for all eight scripts. 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.