All findings · Lucene finding · LFV-668 · sorani-final-heh-trailing-bidi-mark

Sorani normalizer: an invisible direction mark after a word-final HEH (ه) keeps it from becoming AE (ە)

SoraniAnalyzer folds a word-final HEH ه into AE ە and deletes invisible direction marks, but checks for the word's end before deleting a mark after the HEH. So خانه ("house") plus a right-to-left mark keeps its HEH: a search for خانە finds 2 of 3 spellings, silently, since 4.7.0.

Summary

Priority
Low, rank 310Needs a word-final HEH followed by an invisible directional mark in Sorani text, a rare input.
Impact
silent wrong results
Since
4.7.0 (LUCENE-5379, 2014)introduced 2014-01-04 · over 12 years
Affected
4.7.0 to 10.5.2 and main
Status
documented
Found by
bounded-exhaustive check of the normalizer against its javadoc rules

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

Sorani's vowel e is AE ە, and the normalizer also folds a word-final HEH ه to it. It deletes invisible formatting characters such as direction marks too, but only after it has already looked at the HEH.

Summary of the bug: SoraniNormalizer.normalize walks the token خانه + RLM (U+062E U+0627 U+0646 U+0647 U+200F) once, left to right. At i = 3 the HEH test i == len - 1 is 3 == 4, false, so the HEH stays; at i = 4 the RLM, a Character.FORMAT character, is deleted (len = 4); the result ends in HEH (U+0647) and nothing checks it again. The class javadoc says word-final 0647 is converted to 06D5 (AE) and directional controls are removed. With the stock SoraniAnalyzer a document with خانه + RLM is indexed as [..U+0647] and one without the mark as [..U+06D5]; a query for خانه finds only the unmarked document, and a query with the mark only the marked one. The same with LRM and ALM; a leading RLM before a word-initial REH is harmless. A narrow fix (the HEH is final if only format characters other than ZWNJ and ZWJ follow) makes both documents index as AE; affected fields need a reindex.
How خانه + RLM is walked once, left to right, so its HEH is tested before the mark is deleted, and why two documents that look the same then never match. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index three documents with new SoraniAnalyzer() that spell "house" three ways: خانە (AE), خانه (HEH) and خانه followed by an invisible right-to-left mark (RLM, U+200F), each followed by گەورەیە ("is big").
  2. The normalizer's own rules say a word-final HEH becomes AE and direction marks are removed, so all three should be indexed as خانە, and a search for خانە should find 3 of 3 documents.
  3. It finds 2 of 3. The normalizer walks the word once, left to right. At the HEH it asks "is this the last character?", and the answer is no, because the RLM is still behind it. One step later it deletes the RLM, but it never looks at the HEH again.
  4. So the marked word is indexed as خانه (U+0647 at the end), and a query copied with the mark finds only that document, 1 of 3. The same happens with LRM and ALM, at query time (Analyzer.normalize) and after stemming. No error is raised.
  5. It has been like this since the Sorani analyzer was added in 4.7.0 (2014). Treating the HEH as final when only invisible formatting characters (other than ZWNJ and ZWJ) follow it fixes it, and all 30 upstream Sorani tests still pass.

Who is affected

Anyone indexing Sorani Kurdish with SoraniAnalyzer (default constructor included), SoraniNormalizationFilter or the soraniNormalization factory. It needs a word whose final e is typed as HEH with an invisible RLM, LRM or ALM right after it: rare, but silent, at index and query time, and the two spellings then never match.

Mechanism, terms and numbers739 words

Lucene's Sorani normalizer (SoraniNormalizer, used by SoraniNormalizationFilter, SoraniAnalyzer and the soraniNormalization factory) documents two rules that meet here: a word-final HEH (U+0647) is converted to AE (U+06D5), and "formatting characters such as directional controls are removed". It applies them in one left-to-right pass and tests i == len - 1 when it reaches the HEH, while a trailing RLM, LRM or ALM is still in the buffer; the mark is deleted one step later and the HEH, now last, is never converted. StandardTokenizer keeps such a mark inside the token, so the stock analyzer indexes خانه + RLM as خانه and خانه as خانە: the two look the same and do not match each other, at index and query time, with no error. Every release from 4.7.0 (2014) to 10.5.2 and main is affected. A 15-line fix that looks past trailing formatting characters the class deletes (any Character.FORMAT character except ZWNJ and ZWJ) passes the new test and all 30 upstream Sorani tests.

Terms

  • Sorani normalization: SoraniNormalizationFilter, and SoraniAnalyzer which uses it, runs SoraniNormalizer.normalize on every token. The class javadoc lists its rules, among them "Alternate forms of vowel 'e' (0647+200C, word-final 0647, 0629) are converted to 06D5 (AE)" and "Harakat, tatweel, and formatting characters such as directional controls are removed." (SoraniNormalizer.java:29, :33).
  • HEH and AE: AE ە (U+06D5) is Sorani's letter for the vowel e. At the end of a word the normalizer treats HEH ه (U+0647) as the same vowel; the upstream test gives the reason: "always (and in final form by def), so frequently omitted" (TestSoraniNormalizationFilter.java:70-73).
  • Direction marks: invisible characters such as RLM (U+200F), LRM (U+200E) and ALM (U+061C) that steer the display of mixed left-to-right and right-to-left text. Java classifies them as Character.FORMAT. They do not change how the HEH joins (Unicode joining type Transparent), so خانه + RLM looks exactly like خانه.

The bug

  1. normalize walks the buffer once, left to right, with one switch per character (SoraniNormalizer.java:72-128).
  2. case HEH converts the HEH only if (i == len - 1): if it is the last character of the buffer at that moment (:89-93).
  3. A trailing mark is still in the buffer then. The default branch deletes it later, like every Character.FORMAT character (:120-124).
  4. For خانه + RLM (len = 5): at i = 3 the test is 3 == 4, false, so the HEH stays; at i = 4 the RLM is deleted (len = 4); the result خانه ends in an unconverted HEH.
  5. The class's other position rule is safe: a leading mark is deleted before the REH is visited, so a word-initial REH still becomes RREH (:100-104). The ZWNJ rule rewrites the HEH before it itself (:82-88).
  6. SoraniAnalyzer tokenizes with StandardTokenizer, which keeps a trailing format character inside the token (UAX#29 rule WB4, StandardTokenizerImpl.jflex:83-85), and its normalize (the query side) applies the same filter (SoraniAnalyzer.java:113-130).

The numbers

  • Filter output for خانه + one tail, stock and with the fix: no tail ..06D5 both; RLM, LRM, ALM, PDF (U+202C), RLO (U+202E), PDI (U+2069): ..0647, with the fix ..06D5; ZWNJ ..06D5 both (its own rule); ZWJ, TATWEEL and FATHA ..0647 both (not claimed: they force a joining form or mark a consonant).
  • SoraniAnalyzer, three documents خانە گەورەیە (AE spelling), خانه گەورەیە (HEH spelling) and the HEH spelling with an RLM after خانه: a search for خانە or for خانه finds 2 of 3; the query copied with the mark finds 1 of 3; with the fix every query finds 3 of 3.
  • Stemming: کوردییه ("Kurdish") is stemmed to [..06CC 06CC], with a trailing RLM to [..06CC 06CC 0647].
  • Exhaustive, all 1,118,481 strings of length 0 to 5 over a 16-character alphabet: within that alphabet the fix changes exactly the 31,420 inputs whose last HEH is followed only by RLM, LRM, ALM, PDF or soft hyphen, and in each only that HEH.
  • Every BMP character after خانه: the fix changes the output (HEH to AE) for exactly 41 characters, the Character.FORMAT characters other than ZWNJ and ZWJ on JDK 25. Besides the direction marks they include U+FEFF, U+200B, U+2060-U+2064, U+206A-U+206F, U+180E, U+FFF9-U+FFFB and the prepended concatenation marks (U+0600-U+0605, U+06DD, U+070F, U+0890, U+0891, U+08E2). None of them is join-causing (ICU: 26 Transparent, 15 Non_Joining).
Exposure and likelihood592 words
  • Who is exposed. Applications that index Sorani Kurdish text with new SoraniAnalyzer() or any other constructor (no option avoids it), with SoraniNormalizationFilter in a custom chain, or with the SPI factory soraniNormalization (the factory javadoc shows the Solr form solr.SoraniNormalizationFilterFactory). Downstream servers that ship a built-in Sorani analyzer on this filter, such as Elasticsearch's sorani analyzer with its sorani_normalization filter, inherit it (read in their documentation; not executed).
  • Default or opt-in. You opt in by choosing the Sorani analyzer or filter for a field; inside it, this is the default behaviour.
  • What triggers it. A word whose final vowel is typed as HEH and whose final HEH is followed only by one or more invisible formatting characters that the class deletes, such as RLM, LRM, ALM, PDF, RLO, PDI or the soft hyphen, with no ZWNJ or ZWJ among them (HEH + RLM + ZWNJ already becomes AE through the ZWNJ rule). Deterministic: every such input, every time; no size or scale is needed.
  • Effect. The marked word is indexed and queried as ..0647, the unmarked one as ..06D5, so they do not match each other in either direction (a search finds 2 of 3 spellings, a query copied with the mark 1 of 3). The stemmer then sees a different word, so the stemmed terms differ too (کوردییه: [..06CC 06CC] without the mark, [..06CC 06CC 0647] with it). The marked term is one that HEH + TATWEEL or HEH + FATHA already produce, so a query typed with the mark also matches a document whose word ends in HEH + TATWEEL (executed in the follow-up's probe).
  • Not affected. Words spelled with AE, words without a trailing mark, marks anywhere but directly after a final HEH. No exception, no hang, no extra cost; offsets and positions are consistent and CheckIndex is clean (the follow-up's probe: 3,000 trigger-dense strings, checkRandomData(200), CheckIndex after forceMerge(1)).
  • How likely. Rare. The upstream Sorani vocabulary test file (8,272 rows of frequent corpus words) has 11 words with a trailing direction control (U+202E), none of them after a HEH, and the prior-report searches found no report in the 12 years since 4.7.0.
  • Existing indexes and rollout. A fix changes indexed terms of affected words, so those fields need a reindex, and index-time and query-time analysis must change together: with the fixed analyzer at query time only, a query typed with the mark no longer finds the old marked documents, so neither spelling finds them (reasoning).
  • Workaround. Until a fix ships, a patternReplace char filter that deletes the direction marks in front of the tokenizer gives ..06D5 and 3 of 3 hits (executed with SPI names through CustomAnalyzer); it must be in both the index-time and the query-time analyzer. Its pattern leaves out the soft hyphen and the other format characters the fix covers, and the executed CustomAnalyzer has no stop filter or keyword marker, so it is not the same as SoraniAnalyzer; to keep SoraniAnalyzer (a final class), wrap it in an AnalyzerWrapper that adds the char filter in wrapReader and wrapReaderForNormalization (not executed).
  • Unicode versions and older JDKs. JDK 25's Character data is Unicode 16.0, StandardTokenizer's tables are Unicode 12.1, and the joining types here come from ICU (Unicode 17). ALM (U+061C) and the isolates LRI, RLI, FSI and PDI (U+2066-U+2069) were added in Unicode 6.3, after Java 8's Unicode 6.2, so on Java 8 they are not format characters and would stay in the term (not executed); RLM and LRM behave the same on every supported JDK.

Minimal repro

The test shows that SoraniNormalizationFilter leaves a word-final HEH (U+0647) unconverted when an invisible RLM (U+200F) follows it: the RLM is removed, but only after the word-final test has already been made.

TestSoraniFinalHehBeforeRlm.java (JUnit test)29 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.ckb;

import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;

public class TestSoraniFinalHehBeforeRlm extends BaseTokenStreamTestCase {

  public void testFinalHehFollowedByRlm() throws Exception {
    // expected: word-final HEH U+0647 becomes AE U+06D5 and the trailing RLM is removed; actual: HEH
    assertTokenStreamContents(
        new SoraniNormalizationFilter(keywordMockTokenizer("\u062E\u0627\u0646\u0647\u200F")),
        new String[] {"\u062E\u0627\u0646\u06D5"});
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-668/, 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.ckb.TestSoraniFinalHehBeforeRlm
Expected vs actual
Expected the term U+062E U+0627 U+0646 U+06D5 (AE, as without the mark); actual U+062E U+0627 U+0646 U+0647 (-ea and -da).

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

More files, environment, full output38 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: term 0 expected:<خان[ە]> but was:<خان[ه]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:262)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:513)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:598)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:622)
       org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents(BaseTokenStreamTestCase.java:689)
       org.apache.lucene.analysis.ckb.TestSoraniFinalHehBeforeRlm.testFinalHehFollowedByRlm(TestSoraniFinalHehBeforeRlm.java:25)

Why it looks unintended

  • The class javadoc: "word-final 0647" is converted to AE, and "directional controls are removed" (SoraniNormalizer.java:29, :33).
  • Its word-initial rule ignores a removed mark: a leading RLM before REH still gives RREH. Only the trailing case breaks.
  • No test keeps HEH before a mark; the upstream vocabulary treats its 11 words with a trailing mark as if it were absent.
The full argument356 words
  • The contract. The class javadoc (SoraniNormalizer.java:21-35): "Alternate forms of vowel 'e' (0647+200C, word-final 0647, 0629) are converted to 06D5 (AE)" (:29) and "Harakat, tatweel, and formatting characters such as directional controls are removed." (:33). The public SoraniNormalizationFilter is "A TokenFilter that applies SoraniNormalizer to normalize the orthography" (SoraniNormalizationFilter.java:24). SoraniNormalizer was a public class, with this rule list in its published javadoc, from 4.7.0 to 9.12.x (javap of the 4.7.0 and 9.12.3 jars; Lucene renders javadoc with -protected); it is package-private only since 10.0.0 (LUCENE-10561). The javadoc states no order of operations and no general "removed characters never change the result" rule, so the claim is that this order leaves two documented rules in tension, not that a stated invariant is broken.
  • The rule's reason. testFinalH: "always (and in final form by def), so frequently omitted" (TestSoraniNormalizationFilter.java:70-73). The rule is about the final letter form; RLM, LRM, ALM, PDF and RLO have Unicode joining type Transparent (PDI: Non_Joining), so a HEH before them is still drawn in final form.
  • The class's own symmetry. A leading RLM, LRM, ALM, RLE or TATWEEL before REH still gives word-initial RREH, and the ZWNJ rule looks past a mark (HEH + RLM + ZWNJ gives AE). Only the trailing check runs before the deletion.
  • Upstream data. The vocabulary of TestSoraniStemFilter.testVocabulary (8,272 rows) has 11 words ending in U+202E whose expected output drops the mark and normalizes and stems as if it were absent (none of them ends in a HEH). Of its words ending in a HEH before characters the class removes, 320 end in HEH + ZWNJ (made AE by the ZWNJ rule) and one is HEH + TATWEEL -> HEH, which is not claimed (TATWEEL forces a joining form) and which the fix keeps; none has a HEH before a direction mark.
  • History. Both rules came in the first commit, in today's order (LUCENE-5379, 2014); the four later commits on the file change only the license header, formatting, array syntax and visibility (package-private from 10.0.0). No test, comment, commit message, CHANGES or MIGRATE entry mentions trailing marks.
The devil's-advocate check296 words

The intended-behaviour checker and the cross-model intended lens (which rated it moderate) built the strongest case: the javadoc is an unordered list of independent rules, and "word-final" can be read literally as "the last character of the token as given"; the upstream vocabulary pins HEH + TATWEEL -> HEH, so a trailing removable character can block the rule on purpose; the class is package-private, so its javadoc is no published contract; the behaviour has been stable for 12 years; and a fix forces a reindex.

The answer: the same class treats removed characters as absent for its word-initial rule and its ZWNJ rule; the stated reason is the final letter form, which the marks do not change, while TATWEEL and ZWJ do, so the pinned row agrees with the narrow claim and the narrow fix keeps it; the class promises to remove direction controls itself and the stock tokenizer hands them over inside the token; the project's own vocabulary expects trailing marks to be ignored; no record supports the literal reading. The package-private point does not hold for most of the bug's life: the class was public, with this list in its published javadoc, from 4.7.0 to 9.12.x; and a reindex is the usual cost of an analyzer fix.

Limits of the answer: the javadoc states no order of operations; the leading-mark case shows what the loop order allows, not what the authors meant for trailing marks; the 11 vocabulary words with a trailing mark do not end in a HEH; no Sorani reference is cited. Verdict: a real, narrow order-of-operations defect, best presented as an inconsistency between two documented rules that appears unintended, with the compatibility cost acknowledged. Do not argue idempotence: it is not documented, and HEH + TATWEEL stays non-idempotent with the fix.

Suggested fix

Recommended Treat the HEH as word-final when only formatting characters other than ZWNJ and ZWJ follow it

In SoraniNormalizer, count the HEH as word-final when only formatting characters that the class deletes follow it (any Character.FORMAT character except ZWNJ and ZWJ; +15/-1). Validated with gradle: the new test and all 30 upstream Sorani tests pass, assertions on and off, plus format and lint checks. Affected fields need a reindex.

static final char ZWJ = '\u200D';                      // new constant
case HEH:
  if (isWordFinal(s, i, len)) {                       // was: if (i == len - 1)
    s[i] = AE;
  }
  break;
private static boolean isWordFinal(char[] s, int pos, int len) {
  for (int i = pos + 1; i < len; i++) {
    if (s[i] == ZWNJ || s[i] == ZWJ || Character.getType(s[i]) != Character.FORMAT) {
      return false;
    }
  }
  return true;
}
All options and trade-offs4 options · 1,249 words

Two equivalent fixes were built and validated with gradle in a scratch worktree at a9779ef3c193; A has the same condition as the finder's fix, which every verifier also ran. A wider fix fails the upstream vocabulary. Until a fix ships, a patternReplace char filter that deletes the direction marks in front of the tokenizer works around it (executed: 3 of 3 hits).

Treat the HEH as word-final when only formatting characters other than ZWNJ and ZWJ follow it (validated by the checks) (recommended)

case HEH calls a helper instead of i == len - 1: the HEH is word-final if every character after it is a Character.FORMAT character other than ZWNJ and ZWJ (+15/-1 in SoraniNormalizer.java, with a new ZWJ constant). It tests no Unicode joining property: on JDK 25 it admits 41 BMP characters, every format character the class deletes except ZWNJ and ZWJ (see the validation). The helper's javadoc in the diff ("does not change joining") is a draft; more exact: "a formatting character that normalize deletes, other than ZWNJ (handled by its own case) and ZWJ (which joins the HEH to what follows)". The code below is an excerpt; the full diff:

--- a/lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizer.java
+++ b/lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizer.java
@@ -45,6 +45,7 @@
   static final char HEH = '\u0647';
   static final char AE = '\u06D5';
   static final char ZWNJ = '\u200C';
+  static final char ZWJ = '\u200D';
   static final char HEH_DOACHASHMEE = '\u06BE';
   static final char TEH_MARBUTA = '\u0629';
 
@@ -87,7 +88,7 @@
           i--;
           break;
         case HEH:
-          if (i == len - 1) {
+          if (isWordFinal(s, i, len)) {
             s[i] = AE;
           }
           break;
@@ -126,4 +127,17 @@
     }
     return len;
   }
+
+  /**
+   * Returns true if every char after {@code pos} is a formatting character that {@link #normalize}
+   * removes and that does not change joining (not ZWNJ or ZWJ), such as a directional mark.
+   */
+  private static boolean isWordFinal(char[] s, int pos, int len) {
+    for (int i = pos + 1; i < len; i++) {
+      if (s[i] == ZWNJ || s[i] == ZWJ || Character.getType(s[i]) != Character.FORMAT) {
+        return false;
+      }
+    }
+    return true;
+  }
 }
static final char ZWJ = '\u200D';                      // new constant
case HEH:
  if (isWordFinal(s, i, len)) {                       // was: if (i == len - 1)
    s[i] = AE;
  }
  break;
private static boolean isWordFinal(char[] s, int pos, int len) {
  for (int i = pos + 1; i < len; i++) {
    if (s[i] == ZWNJ || s[i] == ZWJ || Character.getType(s[i]) != Character.FORMAT) {
      return false;
    }
  }
  return true;
}

Trade-offs

  • Existing indexes keep the old term for affected words until reindexed; worth a CHANGES line.
  • Cost: a forward scan from a HEH that stops at the first other character. The extra scan reads each character at most once per token, so the fix adds only linear work. (normalize() itself is not linear today: every deletion shifts the rest of the buffer, StemmerUtil.delete.)
  • Breadth: it admits every format character the class deletes, not only the direction marks. If maintainers want only the direction marks and the soft hyphen, the helper can test an explicit list instead (not built or tested).
  • Index-time and query-time analysis must change together during the rollout.
  • Unchanged by design: HEH + TATWEEL, ZWJ or a vowel mark stays HEH; HEH + ZWNJ was already AE.
  • Only main was built and tested with gradle; normalize() is the same, ignoring whitespace, from 4.7.0 on, so a backport should need at most context changes.

What the checks validated

  • Gradle, JDK 25, scratch worktree at a9779ef3c193: the new test fails 5 of 7 on stock (assertions on and off) and passes 7 of 7 with the fix; every test in org.apache.lucene.analysis.ckb passes: 6 suites, 37 tests (30 upstream, including checkRandomData over SoraniAnalyzer and the 8,272-row vocabulary, plus the 7 new), 0 skipped, assertions on and off.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of lucene/analysis/common pass.
  • Exhaustive, all 1,118,481 strings of length 0 to 5 over a 16-character alphabet: within that alphabet it changes exactly the 31,420 inputs whose last HEH is followed only by RLM, LRM, ALM, PDF or soft hyphen, and only that HEH, from U+0647 to U+06D5.
  • Every BMP character after خانه (surrogates skipped): the output changes for exactly 41 characters, each from ..0647 to ..06D5: the direction marks and controls (U+200E, U+200F, U+061C, U+202A-U+202E, U+2066-U+2069), U+00AD, U+200B, U+2060-U+2064, U+FEFF, U+206A-U+206F (deprecated format controls, including INHIBIT and ACTIVATE ARABIC FORM SHAPING), U+180E, U+FFF9-U+FFFB and the prepended concatenation marks (U+0600-U+0605, U+06DD, U+070F, U+0890, U+0891, U+08E2). ICU 78.3: 26 Transparent, 15 Non_Joining, none join-causing. Through StandardTokenizer 38 of them stay in the token; U+200B, U+0890 and U+0891 do not. Fix B gives the same output.
  • The finder's fix has the same condition; with it every verifier's claim tests passed (finder 5 of 5, the independent reproduction 12 of 12, adjudication 10 of 10), as did the 28 (finder) and 30 (refute reviewer) upstream tests they ran.

Trim trailing formatting characters other than ZWNJ and ZWJ before the loop (equivalent) (validated by the checks)

Before the loop, drop trailing Character.FORMAT characters other than ZWNJ and ZWJ (+10):

--- a/lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizer.java
+++ b/lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizer.java
@@ -45,6 +45,7 @@
   static final char HEH = '\u0647';
   static final char AE = '\u06D5';
   static final char ZWNJ = '\u200C';
+  static final char ZWJ = '\u200D';
   static final char HEH_DOACHASHMEE = '\u06BE';
   static final char TEH_MARBUTA = '\u0629';
 
@@ -70,6 +71,11 @@
    * @return length of input buffer after normalization
    */
   int normalize(char[] s, int len) {
+    // remove trailing formatting characters that do not change joining (such as directional marks)
+    // first, so that they do not hide a word-final HEH
+    while (len > 0 && isTrailingRemovable(s[len - 1])) {
+      len--;
+    }
     for (int i = 0; i < len; i++) {
       switch (s[i]) {
         case YEH:
@@ -126,4 +132,8 @@
     }
     return len;
   }
+
+  private static boolean isTrailingRemovable(char ch) {
+    return ch != ZWNJ && ch != ZWJ && Character.getType(ch) == Character.FORMAT;
+  }
 }

Trade-offs

Reads as "strip trailing invisible marks first" and touches no case of the switch; A keeps the change inside the HEH rule. Choose by taste.

What the checks validated

  • The same output as A on all 1,118,481 inputs of the exhaustive run (identical output digests).
  • Gradle: the new test and the 30 upstream ckb tests pass (assertions on), and checkGoogleJavaFormat passes; standalone: the new test 7 of 7.
  • The same output as A for every BMP character after خانه (the 41-character scan).

Also look past harakat and TATWEEL (rejected)

The finder's first fix also skipped harakat and TATWEEL after the HEH.

Trade-offs

Fails the upstream vocabulary test (هـ expected ه, got ە; 1 of 28 fails): TATWEEL forces a joining form, and a vowel mark can mark the HEH as the consonant h. A post-pass "if the output ends in HEH, make it AE" would change the same HEH + tail inputs and fail the same row, and more: a word ending in HEH DOACHASHMEE (U+06BE), which the class rewrites to HEH, would also become AE (reasoning).

Document that word-final means the last character as given (rejected)

Change the javadoc instead of the code.

Trade-offs

Keeps the two documented rules in tension (a removed mark would still change the result), unlike the word-initial rule, and leaves two spellings of the same word unmatched.

Downstream note

Downstream, a deny or moderation rule written as an analyzed Sorani term query could miss a document whose word carries the invisible mark; servers with a built-in Sorani analyzer on this filter inherit the mismatch.

How it was found

Semi-formal. A bounded-exhaustive check ran the stock filter on all 732,541 strings of up to 4 characters over a 29-character alphabet against rules taken from the javadoc. "Deleting a character the class removes must not change the result" failed on 23,190 inputs, all a HEH followed only by removable characters; the claim was then narrowed to direction marks.

The method in full153 words

Method: bounded-exhaustive translation validation of the script normalizers against their documented rules (the hunt's "semi-formal" tier), 2026-10-27.

  • The harness (NormalizerTV.java) ran the stock SoraniNormalizationFilter on every string of length up to 4 over a 29-character alphabet (letters including ZWNJ and TATWEEL, the harakat and 6 format characters), 732,541 inputs, and compared it with oracles derived from the class javadoc.
  • The deletion-invariance oracle (deleting a character that the javadoc says is removed must not change the result) failed on 23,190 inputs, all a HEH followed only by removable characters at the end. A residual run that excluded that shape found 0 other violations.
  • The upstream vocabulary pins HEH + TATWEEL -> HEH, and a wider fix failed it, so the finder narrowed the claim to trailing format characters other than ZWNJ and ZWJ and wrote the public-API JUnit on the stock jars.
  • Independent verifiers then reproduced and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report: two scripted prior-report search runs (68 and 33 queries), 57 manual GitHub, downstream and JIRA searches, the OSS-Fuzz feed, the files of all 409 open PRs and the introducing issue, searched 2026-10-27. Related only: a withdrawn fuzzer crash and a scanner advisory, both about exceptions in the same method.

Searches4 searches

No issue, PR, JIRA ticket, list thread, downstream issue or web page reports a word-final HEH left unconverted before a direction mark, or the order of the HEH test and the format-character deletion. Searches are lexical and PR review threads are not indexed, so this is "no report found".

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-27a scripted prior-report search (22 issue and PR queries, 3 commit queries), 27 manual searches for the class names, code points, mark names, Character.FORMAT, ZWNJ and the sibling normalizers, the introducing issue's body and comments, the file's full history; a later read-only re-checkonly the introducing issue #6442 (LUCENE-5379); unrelated hits such as #7426 (package naming) and PR #540 (a remark on the ZWNJ constant)
Open PRs2026-10-27the changed files of all 409 open apache/lucene PRsonly #16911 touches ckb (it decommissions branch_10x)
Mailing lists and JIRA2026-10-2756 list queries over dev@, java-user@, issues@ and commits@ (two tool runs, including RLM, 200F, 200E, 061C, "directional marks" and "bidi"); 11 tool and 7 manual JIRA queries (LUCENE, SOLR, LUCENENET)a java-user@ thread on scanner advisory sonatype-2025-002284, about an exception from invalid buffer or length input: not this bug
Downstream, the web and fuzzing feeds2026-10-273 tool and 23 manual searches in Elasticsearch, OpenSearch, Solr, lucene-solr and Lucene.NET; 6 web queries; the OSV feed of OSS-Fuzz findings for LuceneOSV-2025-253, a withdrawn fuzzer crash inside the same method (an exception, not this bug); #7048 (LUCENE-5986), a declined request about the Arabic normalizer; no report

History

Present since the Sorani analyzer was added: LUCENE-5379 (2140f4368ad, backport a670b8d5b22, 2014-01-04), first released in 4.7.0. It fails on all 12 releases run from 4.7.0 to 10.5.2 and on main; 4.6.1 has no Sorani API. Not a regression, and not fixed upstream.

Versions tested7 rows

Introduced by LUCENE-5379 ("Kurdish Analyzer", issue #6442; author rmuir): 2140f4368ad on trunk and the same-day branch_4x backport a670b8d5b22, which shipped in 4.7.0 (tagged 2014-02-25). A Subversion commit, no PR. Both rules arrived together, in today's order. The four later commits on the file (813ca77250d LUCENE-7013 license header, 8ef6a0da568 LUCENE-9570 reformat, 8ac26737913 LUCENE-10003 array syntax, 694d797526a LUCENE-10561 package-private, first released in 10.0.0) do not change the logic; ignoring whitespace and the signature, normalize() is identical from 4.7.0 to main. Not fixed upstream: main a95c754ee003 (2026-10-26) still tests i == len - 1. Every run used JDK 25.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test 5 of 7 fail (gradle and standalone, assertions on and off); the finder's 4 of 5; the independent reproduction's 6 of 10; the main() 5 of 5 checks
10.5.2, 9.12.3 affectedthe brief's test 5 of 7 fail; the main() 5 of 5 checks (10.5.2 also with plain javac and java)
6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 10.0.0 main()5 of 5 checks fail; both controls pass
4.7.0, 4.10.4, 5.0.0, 5.5.5 main()4 of 4 checks fail (Analyzer.normalize exists only from 6.2.0); both controls pass
4.6.1 no APIno Sorani analysis classes: the repro does not compile
main + the fix not affectedevery check passes; 37 ckb tests pass with gradle
upstream main a95c754ee003, branch_10x readstill if (i == len - 1) in case HEH

Independent checks

An independent reproducer, a devil's advocate, an adjudication and a credibility audit, plus two executed reviews by another model family (OpenAI GPT-5.6, refute and intended-behaviour lenses), all found it real with their own runs on main, assertions on and off. This doc's own test, exhaustive comparison and gradle runs agree.

Checks and verdicts385 words

Every verifier wrote or ran its own tests against the stock jars; each verdict below names what it ran.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit fails 4 of 5 on main (assertions on and off), 10.5.2 and 9.12.3; 5 of 5 with the narrow fix; the 28 upstream tests it ran pass with the narrow fix, 1 fails with the wider one.
Independent reproducer realOwn JUnit from the claim: 6 of 10 fail on main (on and off), 10.5.2 and 9.12.3; 4 controls pass; only the trailing position of a mark changes the output; impact JUnit 2 of 2 fail (sentence, stem); 12 of 12 pass with the fix.
Cross-model review, refute lens real (high)An independent reviewer from another model family (OpenAI GPT-5.6, executed mode, on ea57360912ed with identical analysis sources): own JUnits fail through the filter, normalize() and the full indexing chain; the 30-test ckb suite passes with the fix.
Cross-model review, intended lens real (high); intended-behaviour argument moderateIts expected-semantics test fails on main, 10.5.2 and 9.12.3 and passes with the fix; the review step re-ran it on a9779ef3c193 (1 of 6 fail, assertions on and off).
Devil's advocate real (high), low impactBuilt the strongest intended case and showed why it fails; probe: HEH kept before RLM, LRM, ALM, PDF, RLO and PDI; a leading mark before REH is ignored; the fix keeps HEH before TATWEEL, ZWJ and DAMMA.
adjudication confirmed, highRe-ran the independent reproduction's JUnit (6 of 10 fail, assertions on and off) and the independent reproduction's JUnit with the fix (10 of 10 pass); class silent wrong results.
Follow-up history, prior art, security12 releases plus main executed; prior-report verdict new; security none after an executed probe.
credibility audit keepRe-ran the finder's JUnit (4 of 5 fail, on and off) and an own end-to-end TermQuery test: the document with HEH + RLM in running text is missed (2 of 3 fail, on and off).
this doc's runs reproduced; fix validatedThe new test 5 of 7 fail on main (gradle and standalone, on and off), 10.5.2 and 9.12.3; the fix passes it and the 30 upstream ckb tests; the exhaustive run shows the fix changes only the bug's inputs.

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

Finding record

LFV-668, snapshot of 2026-11-06

Registry title
SoraniNormalizer checks for a word-final HEH before it removes trailing directional marks, so a word ending in HEH followed by RLM, LRM or ALM keeps HEH (U+0647) instead of becoming AE (U+06D5) as the class javadoc says
Area
analysis-common / Sorani normalizer (SoraniNormalizer.normalize; SoraniNormalizationFilter, SoraniAnalyzer and the 'soraniNormalization' factory)
Priority
Low, rank 310: Needs a word-final HEH followed by an invisible directional mark in Sorani text, a rare input.
Impact class
silent wrong results
Impact note
Search quality only, with no error: applications that analyze Sorani Kurdish text with SoraniAnalyzer (default constructor included), SoraniNormalizationFilter or the 'soraniNormalization' factory, on every release from 4.7.0 through 10.5.2 and on main, index and query a word whose final vowel is typed as HEH and that carries an invisible trailing RLM, LRM or ALM (StandardTokenizer keeps it inside the token) as a different term from the same word without the mark ([..U+0647] vs [..U+06D5]; the stemmed term can differ too), so the two visually identical spellings do not match each other; no scale is needed, -ea and -da behave the same, the mark must sit directly after the final HEH, and because a fix changes indexed terms, affected fields need reindexing after it.
Summary
You analyze Sorani Kurdish text with the stock SoraniAnalyzer or SoraniNormalizationFilter. The normalizer's javadoc says a word-final HEH (U+0647) is converted to AE (U+06D5) and that formatting characters such as directional controls are removed, so خانه ('house') followed by an invisible right-to-left mark should normalize like خانه itself, to خانە. Instead the HEH rule asks 'is this the last character?' while the trailing mark is still in the buffer, and the mark is deleted only later in the same pass, so خانه+RLM (and +LRM, +ALM) becomes خانه with a plain HEH and no longer matches خانە, with no error.
Introduced
2140f4368ade00f1d3b6f2bb25ecaf257c639c3d (trunk, Subversion r1555359, 'LUCENE-5379: Kurdish Analyzer' by rmuir; JIRA LUCENE-5379, migrated as apache/lucene#6442; no PR, pre-GitHub) with its same-day branch_4x backport a670b8d5b22 (r1555360), the copy that shipped in 4.7.0 [read: git log --follow and a whitespace-insensitive compare of normalize() across release tags in the history clone]: the 'case HEH: if (i == len - 1)' test and the later FORMAT deletion in the default branch arrived together, in today's order. Not a regression: the four later commits on the file (813ca77250d LUCENE-7013 license header, 8ef6a0da568 LUCENE-9570 reformat, 8ac26737913 LUCENE-10003 array syntax, 694d797526a LUCENE-10561 package-private) do not change the logic. (2014-01-04 (trunk and branch_4x; first released in 4.7.0, tagged 2014-02-25))
First affected version
4.7.0 [exec] (the first release with the org.apache.lucene.analysis.ckb package; the follow-up's cross-version main() repro fails all 4 filter and analyzer checks there, Analyzer.normalize being absent before 6.2.0, and both controls pass). 4.6.1, the last release before it, has no Sorani analysis API [exec: the repro does not compile], so no release ever applied the rule across a trailing mark.
Affected versions
Every release that ships the Sorani analysis API, 4.7.0 through 10.5.2, and main a9779ef3c193; not fixed. [exec] the follow-up's version-independent public-API repro (SoraniNormalizationFilter with a trailing RLM, LRM and ALM, the SoraniAnalyzer token stream and, from 6.2.0, SoraniAnalyzer.normalize; 2 controls) fails on 4.7.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), with both controls passing, and passes on main with the fix shadowed; the finder's JUnit fails 4 of 5 on 9.12.3, 10.5.2 and main (-ea and -da) (it needs org.apache.lucene.tests.analysis, so it does not compile on 9.0.0); the independent reproduction's JUnit fails 6 of 10 on 9.12.3, 10.5.2 and main. [read] the normalize() body is identical, ignoring whitespace and the signature, from 4.7.0 to main, and upstream main a95c754ee003 (2026-10-26) and branch_10x still contain 'case HEH: if (i == len - 1)' (read 2026-10-27). All runs used JDK 25.0.3; ALM (U+061C, Unicode 6.3) is not a FORMAT character on JDKs with older Unicode tables (Java 8), where the ALM would stay in the term and the HEH would still stay HEH (not executed).
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 (searched 2026-10-27, read-only; the prior-report record, verdict new): scripted prior-report search merged run (22 GitHub issue/PR queries on apache/lucene, 3 commit, 3 downstream-repository, 28 mailing-list queries over the dev, java-user, issues and commits lists, 6 JIRA, 6 web; 0 errors) plus a second run (28 list and 5 JIRA queries for RLM, 200F, 200E, 061C, 'directional marks' and 'bidi'), 27 manual apache/lucene searches (code points, mark names, Character.FORMAT, ZWNJ, sibling normalizers, OSS-Fuzz, Sonatype), 23 manual downstream searches (Elasticsearch, OpenSearch, Solr, lucene-solr, Lucene.NET), 7 manual JIRA queries (SOLR, LUCENE, LUCENENET), the OSV OSS-Fuzz feed for Lucene, the changed files of all 409 open apache/lucene PRs, the introducing issue gh#6442 (LUCENE-5379; body and all 3 comments) and the file's full git history; the finder's run (23 queries) and the intended-behaviour check's list and JIRA run (23 queries) found only LUCENE-5379. No issue, PR, JIRA ticket, list thread, downstream issue or web page reports a word-final HEH left unconverted before a directional mark, or the order of the HEH test and the format-character deletion. Related, not the same bug: OSV-2025-253 (OSS-Fuzz 407477665, an exception inside SoraniNormalizer.normalize after WordDelimiterGraphFilter; published 2025-04-03, withdrawn 2026-10-01) and a java-user@ thread on scanner advisory sonatype-2025-002284 (2025-09-01, an exception from invalid buffer or length input): both report an exception, while this bug throws nothing and its fix touches only the HEH condition; gh#7048 (LUCENE-5986, a declined request to stop folding word-final TEH MARBUTA in ArabicNormalizer); gh#11597 (LUCENE-10561, visibility only). Not fixed upstream: main a95c754ee003 and branch_10x unchanged; the only open PR that touches ckb is #16911 (decommissions branch_10x). Gaps: the OSS-Fuzz tracker entry was not readable; PR review threads are not search-indexed (PR #540's were read by hand). 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 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the release jars 10.5.2 and 9.12.3 with the same test; the follow-up's version-independent repro on 12 releases from 4.7.0 to 10.5.2 (4.6.1 has no Sorani API); the cross-model reviews on ea57360912ed, whose lucene/analysis sources equal a9779ef3c193. The fix was validated in the scratch worktree: the new test, all 30 upstream ckb tests with assertions on and off, and the module's format, ecj-lint and forbidden-API checks; an exhaustive run over 1,118,481 strings shows it changes only the bug's inputs.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizer.java:21-35 (at a9779ef3c193) (class javadoc; package-private class at :36), :29 ('Alternate forms of vowel 'e' (0647+200C, word-final 0647, 0629) are converted to 06D5 (AE)'), :33 ('Harakat, tatweel, and formatting characters such as directional controls are removed.'), :72-73 (single left-to-right loop), :82-88 (case ZWNJ rewrites the previous HEH), :89-93 (case HEH: if (i == len - 1) s[i] = AE), :100-104 (case REH: word-initial RREH), :120-124 (default: Character.FORMAT deleted)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizationFilter.java:24-37 (at a9779ef3c193) (public filter applying the normalizer to the term buffer); SoraniNormalizationFilterFactory.java:40 (SPI name 'soraniNormalization'); SoraniAnalyzer.java:78 (default constructor), :114-115 (StandardTokenizer then SoraniNormalizationFilter), :125-126 (normalize applies the same filter)
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.jflex:83-85 (at a9779ef3c193) (UAX#29 WB4: Format characters stay attached to the preceding token); lucene/analysis/common/src/test/org/apache/lucene/analysis/ckb/TestSoraniNormalizationFilter.java:70-73 (testFinalH: 'always (and in final form by def), so frequently omitted')
  • GitHub, read-only (2026-10-27): apache/lucene #6442 (LUCENE-5379), #7048 (LUCENE-5986), #11597 (LUCENE-10561), PR #540, PR #16911; apache/lucene main a95c754ee003 and branch_10x SoraniNormalizer.java (condition unchanged); osv.dev OSV-2025-253; lists.apache.org java-user@ thread x0l9sdfftmsnbng82bd19no9vzjq5d67
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizer.java:21-35 (at a9779ef3c193) (class javadoc), :29, :33, :72-128 (normalize), :82-88 (ZWNJ), :89-93 (HEH), :100-104 (REH), :120-124 (FORMAT deletion)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniAnalyzer.java:113-130 (at a9779ef3c193)
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.jflex:83-85 (at a9779ef3c193) (UAX#29 WB4)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/ckb/TestSoraniNormalizationFilter.java:70-73 (at a9779ef3c193) (testFinalH)
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniAnalyzer.java:78-101 (at a9779ef3c193) (constructors), :113-130
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizationFilterFactory.java:26-32 (at a9779ef3c193) (Solr example), :40 (SPI name)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/ckb/ckbtestdata.zip (at a9779ef3c193) (8,272 rows; 11 trailing U+202E; read 2026-10-27)
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.jflex:42 (at a9779ef3c193) (%unicode 12.1)
Why it looks unintended
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizer.java:21-36, (at a9779ef3c193) :29, :33, :82-88, :100-104
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ckb/SoraniNormalizationFilter.java:24 (at a9779ef3c193)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/ckb/TestSoraniNormalizationFilter.java:70-73 (at a9779ef3c193)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/ckb/TestSoraniStemFilter.java:107 (at a9779ef3c193) (testVocabulary), ckbtestdata.zip
  • lucene/CHANGES.txt:2179 (at a9779ef3c193) (LUCENE-10561, under the 10.0.0 header at :2155), :12736 (LUCENE-5379)
  • Lucene main a9779ef3c193 build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:464 (-protected)
Suggested fix
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/util/StemmerUtil.java:85-90 (at a9779ef3c193) (delete shifts the buffer)

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 executed probes on main a9779ef3c193 (stock and with the fix) and on 10.5.2. The PNG is the summary frame. Its "28 of 28" upstream tests is the finder's run, which left out one factory suite; the gradle run of the whole ckb package counts 30 upstream tests, all passing.

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.