All findings · Lucene finding · LFV-341 · katakanaromanizer-ignores-charsref-offset

KatakanaRomanizer.romanize(CharsRef) ignores CharsRef.offset and romanizes the start of the backing array

KatakanaRomanizer.romanize uses a CharsRef's length but not its offset: the slice トウキョウ at offset 1 of アトウキョウ comes back as [atoukyo], the romaji of the array's first 5 characters, instead of [toukyou]. Lucene's own JapaneseCompletionFilter always passes offset 0, so only code that calls the romanizer directly is hit.

Summary

Priority
Low, rank 875KatakanaRomanizer ignores CharsRef.offset, but Lucene's own filter always passes offset 0, so this latent defect reaches only direct third-party callers with sliced input.
Impact
minor
Since
9.0.0 (LUCENE-10102, 2021), when the class was addedintroduced 2021-09-17 · about 5 years
Affected
9.0.0 to 10.6.0 and main
Status
documented
Found by
bounded-exhaustive translation validation (2,682,144 inputs)

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

KatakanaRomanizer turns katakana into the romaji a user types (トウキョウ to toukyou), for Japanese auto-completion. It takes a CharsRef, a window (offset + length) into a char[], but reads the window as if it started at index 0.

Background in 3 steps

  1. A CharsRef is a window into a char[]: chars, offset and length. new CharsRef(buf, 1, 5) means the 5 characters buf[1] to buf[5]; CharsRef.subSequence(start, end) also returns a window with a non-zero offset (declared as CharSequence, so a caller casts it to CharsRef).
  2. KatakanaRomanizer.getInstance().romanize(input) walks the katakana, matches the longest known keystroke at each position (キョ is kyo) and returns every romaji spelling: ハシ gives [hasi, hashi].
  3. JapaneseCompletionFilter (used by JapaneseCompletionAnalyzer) calls it once per token reading, with a buffer whose offset is always 0.
Diagram of the one-test repro: a char array holding アトウキョウ. The caller's CharsRef covers indexes 1 to 5, トウキョウ, whose romaji is toukyou. KatakanaRomanizer.romanize reads indexes 0 to 4 instead, アトウキョ, and returns atoukyo, because it uses the slice's length but not its offset.
The array アトウキョウ, the window the caller passes (offset 1, length 5: トウキョウ) and the window romanize actually reads (index 0 to 4: アトウキョ). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You hold katakana in a larger char[], アトウキョウ, and want the romaji of the 5 characters from index 1, トウキョウ (Tokyo). You pass that window as new CharsRef(chars, 1, 5) (offset 1, length 5) to KatakanaRomanizer.getInstance().romanize(...).
  2. You expect [toukyou]: a CharsRef is documented as a slice (offset + length) of its array, and [toukyou] is what romanize returns for the same 5 characters at offset 0.
  3. You get [atoukyo], with no error, assertions on or off: the romaji of アトウキョ, characters 0 to 4 of the array. romanize counts positions 0 to 4 inside the slice but reads chars[pos] instead of chars[offset + pos], in two places.
  4. Lucene's own CharsRef.subSequence makes such slices too: subSequence(2, 6) of ハシヤキュウ (that is ヤキュウ; subSequence is declared to return CharSequence, so the caller casts it to CharsRef) gives [hasiyaki, hashiyaki] instead of [yakyuu]. JapaneseCompletionFilter, the only production caller in Lucene, always passes offset 0, so analysis output and indexes are unaffected.

Who is affected

Only code that calls the public KatakanaRomanizer.getInstance().romanize(CharsRef) itself with a window whose offset is not 0, such as a reused buffer or CharsRef.subSequence; every such call reads the wrong characters. JapaneseCompletionFilter, JapaneseCompletionAnalyzer and the filter factory always pass offset 0 and are unaffected. Every release since 9.0.0; no outside direct caller found.

Mechanism, terms and numbers673 words

KatakanaRomanizer.getInstance().romanize(CharsRef input) turns katakana into the romaji a user would type, for Japanese auto-completion. A CharsRef is "a slice (offset + length) into an existing char[]". romanize walks a position from 0 to input.length but uses it as an absolute index into input.chars, without adding input.offset, in the keystroke lookup and in the copy of the unmatched rest. So a slice that does not start at index 0 is romanized from chars[0, length) instead of chars[offset, offset + length): the slice トウキョウ at offset 1 of アトウキョウ gives [atoukyo] instead of [toukyou], with no exception, assertions on or off.

The bug is latent inside Lucene: the only in-tree production caller, JapaneseCompletionFilter, passes a CharsRefBuilder.get(), whose offset is always 0, so the filter, JapaneseCompletionAnalyzer and indexes are unaffected. Only code that calls the public romanizer directly with a window into a larger array (a reused buffer, or CharsRef.subSequence) gets wrong romaji. It has been there since the class was created (LUCENE-10102, first released in 9.0.0) and affects every release to 10.6.0 and main; adding input.offset at the two sites fixes it.

The mechanism (main 645d4fe14a20)

  1. The input is a slice. CharsRef "Represents char[], as a slice (offset + length) into an existing char[]" (CharsRef.java:23). The public constructor CharsRef(char[] chars, int offset, int length) (:51-56) and subSequence (:146-150, new CharsRef(chars, offset + start, end - start)) make slices with a non-zero offset.
  2. The entry check reads the slice correctly. romanize starts with assert CharSequenceUtils.isKatakanaOrHWAlphabets(input) (KatakanaRomanizer.java:104), which reads through CharSequence.charAt (CharSequenceUtils.java:45-53), and CharsRef.charAt returns chars[offset + index] (CharsRef.java:139-143).
  3. The body reads from index 0. pos starts at 0 (:107) and runs to input.length (:108). Each step calls longestKeystrokeMatch(input, pos) (:112), which builds the lookup key as new CharsRef(input.chars, inputOffset, len) (:173) instead of input.offset + inputOffset. When no keystroke matches, the loop stops (:113-115) and the rest is copied as output.append(input.chars, pos, input.length - pos) (:165) instead of input.offset + pos.
  4. Result. For offset 0 both readings agree; for any other offset the method processes chars[0, length). Every index stays inside the array (a valid CharsRef has offset + length <= chars.length), so there is no exception and no out-of-bounds read. The method is inconsistent with itself: the assert checks one range and the body reads another, and :150 does honour the offset of the map's own refs.
  5. Why Lucene never sees it. The only in-tree production caller is JapaneseCompletionFilter.java:228, romanize(pdgReading.get()), where pdgReading is a CharsRefBuilder (:152) and CharsRefBuilder.get() asserts ref.offset == 0 (CharsRefBuilder.java:132-135). The other in-tree calls, in TestKatakanaRomanizer, all pass new CharsRef(String).

The numbers

sliceslice contentexpectedactual
new CharsRef("アトウキョウ".toCharArray(), 1, 5)トウキョウ[toukyou][atoukyo] (romaji of アトウキョ)
(CharsRef) new CharsRef("ハシヤキュウ").subSequence(2, 6)ヤキュウ[yakyuu][hasiyaki, hashiyaki] (romaji of ハシヤキ)
new CharsRef("xトウキョウt".toCharArray(), 1, 6)トウキョウt[toukyout][] (x matches no keystroke)
(CharsRef) new CharsRef("トウキョウエキ").subSequence(5, 7)エキ[eki][tou]
new CharsRef("トAスシ".toCharArray(), 2, 2)スシ[susi, sushi][toA] (ト romanized; A, from before the slice, matches no keystroke and is copied verbatim)

In the first four rows the output is exactly what romanize gives for the first length characters of the array; in the last, :165 copies the unmatched A from outside the slice, even with assertions on, because the entry assert checks only the slice. The same slice content at offset 0 gives the expected value. subSequence is declared to return CharSequence (CharsRef.java:146), so the executed tests cast it to CharsRef. In a bounded-exhaustive check over every katakana string of length 1 to 3 (U+30A0 to U+30FF) with the suffixes "", "k" and "sy", 2,682,141 of 2,682,144 inputs romanize differently when sliced at offset 1 of an array padded with ア (the 3 others are ア, アア and アアア, which the padding matches); over random buffers and slices, 79% to 90% differ.

Exposure and likelihood196 words
  • Silent. Romaji of the wrong characters, or of fewer characters, with no exception, assertions on or off. Characters from before the slice can be copied verbatim: the slice スシ of トAスシ gives [toA].
  • Who is exposed. Code that calls the public romanizer directly with a CharsRef whose offset is not 0: new CharsRef(buf, off, len) over a reused buffer, or CharsRef.subSequence(start, end) with start > 0. Every such call reads the wrong window, so the result is usually wrong (2,682,141 of 2,682,144 sliced inputs in the bounded-exhaustive check, which pads with ア; 79% to 90% of random slices).
  • Not affected. JapaneseCompletionFilter, JapaneseCompletionAnalyzer, JapaneseCompletionFilterFactory and any analysis chain built on them: the filter's call always has offset 0, so analysis output and indexes are unchanged in every release. Stock uses of the filter, in Lucene or in downstream engines, go through that same offset-0 call; no public direct caller with another offset was found.
  • Likelihood: rare. The package javadoc reads "Utilities for JapaneseCompletionFilter" (package-info.java:18); a code search found no outside direct caller other than two test harnesses that pass new CharsRef(String), and nothing has been reported since 9.0.0 (2021).

Minimal repro

One JUnit test in lucene/analysis/kuromoji, public classes and methods only, one assertion: romanize the slice トウキョウ at offset 1 of アトウキョウ. Deterministic; it fails with assertions on and off; no build change is needed.

TestKatakanaRomanizerCharsRefOffset.java (1 test)30 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.ja.completion;

import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.CharsRef;

public class TestKatakanaRomanizerCharsRefOffset extends LuceneTestCase {

  public void testRomanizeSliceAtNonZeroOffset() {
    CharsRef slice = new CharsRef("アトウキョウ".toCharArray(), 1, 5);
    // the slice is "トウキョウ": expected [toukyou]; actual [atoukyo] (chars [0, 5) are read)
    assertEquals(List.of(new CharsRef("toukyou")), KatakanaRomanizer.getInstance().romanize(slice));
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or any later commit where KatakanaRomanizer.java is unchanged)
cp $REPRO/TestKatakanaRomanizerCharsRefOffset.java lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/completion/
./gradlew :lucene:analysis:kuromoji:test --tests org.apache.lucene.analysis.ja.completion.TestKatakanaRomanizerCharsRefOffset -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
Expected
[toukyou], the romaji of the slice トウキョウ (what romanize returns for the same characters at offset 0).
Observed
expected:<[toukyou]> but was:<[atoukyo]> (the romaji of アトウキョ) on main (assertions on and off), 10.6.0, 10.5.2 and 9.12.3 (assertions on).

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

More files, environment, full output156 words

The test sits in org.apache.lucene.analysis.ja.completion only to match the upstream test layout; it uses only public classes and methods (CharsRef is tagged @lucene.internal). It has no randomness and fails the same way with seed DEADBEEF, assertions on and off, through gradle and the standalone runner.

The same file fails unchanged on the 10.6.0, 10.5.2 and 9.12.3 jars (assertions on). It does not compile on 9.0.0, where LuceneTestCase was still in org.apache.lucene.util (it moved in 9.1.0); 9.0.0 is covered by the history probe, a main() program on the public API.

Regression test: this same test, as its own class or as one more method in the existing TestKatakanaRomanizer.

Environment

lucenemain 645d4fe14a20: affected; also the 10.6.0, 10.5.2 and 9.12.3 release jars with this test
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestKatakanaRomanizerCharsRefOffset > testRomanizeSliceAtNonZeroOffset FAILED
    java.lang.AssertionError: expected:<[toukyou]> but was:<[atoukyo]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E60AB4631A1BC6B9]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:120)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
        at org.apache.lucene.analysis.ja.completion.TestKatakanaRomanizerCharsRefOffset.testRomanizeSliceAtNonZeroOffset(TestKatakanaRomanizerCharsRefOffset.java:28)

Why it looks unintended

  • CharsRef is documented as "a slice (offset + length) into an existing char[]" (and tagged @lucene.internal); romanize is public, exported since 9.1.0, and documents no offset-0 rule.
  • The method contradicts itself: its entry assert reads the slice with the offset, the body without it.
  • Lucene has fixed ignored BytesRef/IntsRef offsets before, latent ones included (1b671cfba3a, LUCENE-4423, LUCENE-5339, LUCENE-10052).
The full argument216 words
  • The contract is a slice. CharsRef.java:23 documents (offset + length). The romanize javadoc ("Translates a sequence of katakana to romaji. An input can produce multiple outputs ...", KatakanaRomanizer.java:99-102) states no offset-0 precondition, and nothing asserts one.
  • The API is public and exported. KatakanaRomanizer (:39), getInstance() (:90) and romanize (:103) are public, with no @lucene.internal or @lucene.experimental tag, in a package that module-info.java:24 exports. The export dates from 9.1.0: 9.0.0 has no module-info.java, though the class was already public there. The input type CharsRef itself is tagged @lucene.internal (CharsRef.java:26; see the strongest counter-case below).
  • The method contradicts itself. Its assert (:104) reads the slice through charAt, which adds the offset; :150 honours cref.offset; only :165 and :173 drop input.offset.
  • Precedent. Lucene treats an ignored *Ref.offset as a bug even when no in-tree caller is affected: 1b671cfba3a (2010, "fix nasty bug (doesnt affect any codecs today): respect bytesref offset"), LUCENE-4423 (7145779e4dc, "DocumentStoredFieldVisitor.binaryField ignores offset and length"), LUCENE-5339 (d815a3608b2, "fix 'ignores IntsRef.offset bug' in TaxoFacetCounts"), LUCENE-10052 (88588e3deaa, #258, "FSTTermsReader.IntersectEnum was illegally ignoring BytesRef.offset").
  • Nothing relies on offset 0. TestKatakanaRomanizer uses only new CharsRef(String); the review of #297 discusses token start/end offsets and CharSequence signatures, never CharsRef.offset.
The devil's-advocate check200 words

The strongest case for "intended" (the refute reviewer, the cross-model review and the intended-behaviour check): CharsRef is @lucene.internal (CharsRef.java:26); the package is documented as "Utilities for JapaneseCompletionFilter"; the class was package-private in the PR's first commit and became public on the PR branch, which the filter in another package needed anyway, so public visibility alone does not prove it was meant for end users; and the only production caller passes offset 0. So the class may be filter plumbing that never needs other offsets.

Why it fails: @lucene.internal warns about API stability, it does not let a method ignore the documented slice fields; being plumbing does not make a wrong result right when the method takes a CharsRef, documents no restriction and reads the slice correctly in its own assert; the trigger needs only Lucene's own CharsRef.subSequence (plus the cast its declared CharSequence return type requires); and Lucene has fixed latent offset bugs before. Verdict: a defect, minor; the residual doubt is priority, not whether it is a bug, and a maintainer may still prefer to document an offset-0 rule (option C). Not security: wrong romaji for the caller's own data, never outside the array.

Suggested fix

Recommended A: add input.offset at both sites

Add input.offset where romanize reads input.chars: in the keystroke lookup (KatakanaRomanizer.java:173) and in the copy of the unmatched rest (:165), 2 lines. The repro passes, and so do all 196 kuromoji tests and the module's lint checks; calls with offset 0 behave exactly as before.

      for (CharsRefBuilder output : pendingOutputs) {
        output.append(input.chars, input.offset + pos, input.length - pos);   // :165
      }
  ...
    for (int len = Math.min(input.length - inputOffset, keystrokes.length); len > 0; len--) {
      CharsRef ref = new CharsRef(input.chars, input.offset + inputOffset, len);   // :173
All options and trade-offs3 options · 393 words

One fix was built and validated through gradle on 645d4fe14a20; the other two options are listed for discussion and were not built.

A: add input.offset at both sites (validated by the checks) (recommended)

2 lines changed in KatakanaRomanizer.java. pos and inputOffset stay slice-relative, so the loop bounds (:108, :162, :172) are unchanged.

      for (CharsRefBuilder output : pendingOutputs) {
        output.append(input.chars, input.offset + pos, input.length - pos);   // :165
      }
  ...
    for (int len = Math.min(input.length - inputOffset, keystrokes.length); len > 0; len--) {
      CharsRef ref = new CharsRef(input.chars, input.offset + inputOffset, len);   // :173

Trade-offs

None known: for offset 0, which is every call Lucene makes, the behaviour is identical, with no extra work or allocation.

Note for review: :136 appends cref.chars, 0, cref.length without cref.offset. Those refs come from the romaji map, built with new CharsRef(String) (:63), so their offset is always 0 and this is harmless; using cref.offset there for consistency with :150 is optional and was not validated.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: the repro fails on stock and passes with the fix, assertions on and off.
  • The whole :lucene:analysis:kuromoji:test (31 suites, including TestKatakanaRomanizer, TestJapaneseCompletionFilter, TestJapaneseCompletionFilterFactory, TestJapaneseCompletionAnalyzer and the repro): 196 tests, 7 skipped, 0 failures.
  • :lucene:analysis:kuromoji:check -x test (checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest): passes, with the test file in the tree.
  • Earlier, on main ea57360912ed, the same two lines made the finder's tests, the independent reproduction's three tests and the history probe pass.

B: copy a sliced input at entry

if (input.offset != 0) input = CharsRef.deepCopyOf(input); at the top of romanize (CharsRef.deepCopyOf returns a copy with offset 0, CharsRef.java:157). Not built.

Trade-offs

Keeps the body as it is, but costs a copy for every sliced input and leaves the index arithmetic inconsistent with the assert.

C: document and assert an offset-0 precondition

Add "the input must have offset 0" to the javadoc and assert input.offset == 0. Not built; not recommended.

Trade-offs

A maintainer may reasonably prefer this for filter plumbing. But with assertions off, as in production, callers that miss the javadoc still get wrong romaji silently, and romanize would accept less than the general CharsRef slice that Lucene's own subSequence returns, so such callers must copy first.

Downstream note

Downstream code that romanizes a window of a reused buffer gets romaji of other characters of its own buffer, or those characters copied verbatim (never outside the array); products that only use JapaneseCompletionFilter or JapaneseCompletionAnalyzer are unaffected.

How it was found

An AI tool hunting in the Japanese completion filter noticed the missing offset while writing a reference romanizer. A bounded-exhaustive translation-validation harness then measured it: of 2,682,144 short katakana inputs, romanized whole and as a slice at offset 1, 2,682,141 differ. A public-API JUnit witnessed it on the stock jars.

The method in full173 words

Method: semi-formal (a hunt unit, the Japanese completion filter).

  • The finder, an AI tool, wrote a reference romanizer for a translation-validation harness (JcfTranslationValidation, section S3) and noticed from reading the code that romanize never adds input.offset.
  • The harness then checked it exhaustively within a bound: every katakana string of length 1 to 3 over U+30A0 to U+30FF, with the suffixes "", "k" and "sy" (2,682,144 inputs), romanized once as a whole CharsRef and once as the same content sliced at offset 1 of a padded array. 2,682,141 differ (class OFFSET_SLICE_DIFF), on main and 10.5.2; the 3 equal ones are ア, アア and アアア, which the padding character ア happens to match.
  • The finder then wrote a public-API JUnit on the stock jars, and independent verifiers wrote their own tests from the claim. The one-test repro was cut from them on 2026-10-29.
  • It is recorded as semi-formal: the signal came from a bounded-exhaustive comparison, not from a model checker, solver or proof, and the hypothesis came from code reading.

Prior reports, history and checks

Prior reports

No prior report: 75 recorded searches over GitHub, the mailing lists, JIRA, downstream repositories and the web, plus the files of all 411 open PRs (2026-10-26), rechecked on 2026-10-31. Closest: #11399 "JapaneseCompletionFilter messes up offsets", a different bug about token start/end offsets.

Searches4 searches

Verdict new: 11 hits classified, none the same bug. Related, not reports of this bug: #11399 / LUCENE-10363 (open, 0 comments; token start/end offsets found by TestRandomChains, different code and fix), #11140 / LUCENE-10102 and #297 (the feature that added the class; their "offset" comments concern token offsets), #12044 (closed, unmerged cleanup that left romanize untouched), #16416 (the filter's output queue). Gaps: GitHub search does not stem words, the list API matches whole words, and private reports are not visible, so the claim is "no prior report found".

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits2026-10-26the scripted prior-report search (16 issue/PR queries, commit and downstream queries) and manual searches, e.g. KatakanaRomanizer, romanize, longestKeystrokeMatch, "input.offset" CharsRef, "ignores offset" romanize; full threads of #11140, #297, #12044, #16416 and #11399only the feature, cleanup and token-offset items
Open PRs2026-10-26the changed files of all 411 open apache/lucene PRsnone touches lucene/analysis/kuromoji/.../ja/completion/
Lists, JIRA, web, downstream code2026-10-26dev@, java-user@, issues@ and commits@ (20 queries); JIRA (4 queries); 6 web queries; code search for KatakanaRomanizer in downstream enginesno report; no downstream direct caller
Recheck (GitHub)2026-10-31KatakanaRomanizer; romanize offset; JapaneseCompletionFilter; CharsRef offset ignored; open PRs matching KatakanaRomanizer; the upstream file historyonly #12044, #11140, #297, #11399, #16416 and two unrelated items; the file's last commit is still #13328 (2024)

History

Introduced with the class by LUCENE-10102 (#297, merged 2021-09-17, first released in 9.0.0). Executed on all 33 releases from 9.0.0 to 10.5.2, on 10.6.0 and on main: all affected; 8.x has no such class. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-10102 / #297 "Add JapaneseCompletionFilter for Input Method-aware auto-completion" (by mocobeta, co-authored by rmuir), squash-merged to main as 4e86df96c03 on 2021-09-17: the commit that created KatakanaRomanizer. git blame attributes both faulty lines to it. Within the PR, the lookup line dates from its first commit (2021-09-13); the remnant copy took its current form in a later commit that reduced char copying and kept the earlier loop's omission. The two later commits to the file (#12978, #13328) touch neither line. No commit-level A/B is possible: the parent commit has no ja/completion package. Both lines are byte-identical at all 33 release tags from 9.0.0 to 10.5.2; 10.5.2, 10.6.0, 645d4fe14a20 and upstream main 2f424fd428da (2026-10-30) have the same file blob.

Versions and branches executed, with results
Version or commitVerdictResult
8.10.1, 8.11.4 no such classthe probe does not compile: package org.apache.lucene.analysis.ja.completion does not exist
9.0.0 first releasehistory probe: 3 of 3 slices wrong, offset-0 control passes (assertions on and off); the independent reproduction's main() variant fails
9.1.0 to 9.12.3 affectedhistory probe on all 20 releases: 3 of 3 slices wrong; the finder's JUnit fails on 9.1.0, the independent reproduction's and this doc's test on 9.12.3
10.0.0 to 10.5.2 affectedhistory probe on all 12 releases: 3 of 3 slices wrong; this doc's test fails on 10.5.2
10.6.0 affectedthis doc's test fails: expected [toukyou], got [atoukyo]
main 645d4fe14a20 affectedthis doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and a reading-only review by another model family (OpenAI GPT-5.6) all rated it real, and an adjudication confirmed it with high confidence. The repros still fail on the later main commits a9779ef3c193 and 645d4fe14a20.

Checks and verdicts286 words

Before the finding was recorded, a reading-only refute review by OpenAI GPT-5.6 agreed on the root cause and judged its "filter plumbing" counter-argument moderate; its "not real" was procedural, because it could not run Java, and a separate re-run failed 2 of 2.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateExhaustive slice comparison (2,682,141 of 2,682,144 differ); own JUnit fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.1.0; with the fix, 9 tests pass, including upstream TestKatakanaRomanizer and TestJapaneseCompletionFilter.
Refute lens (OpenAI GPT-5.6, reading only) not real (medium), proceduralCould not run Java; root cause agrees; a separate re-run fails 2 of 2.
Independent reproducer realOwn JUnit outside the package: 2 of 3 fail on main (assertions on and off), 10.5.2 and 9.12.3, including the subSequence(2, 6) case; a main() variant fails on 9.0.0; the offset-0 baseline passes; its fix passes its tests and 7 upstream tests.
Intended lens (OpenAI GPT-5.6, reading only) real (medium)Intended argument moderate; root cause agrees; the fix fixes it; inputs in contract; a separate re-run fails 2 of 2.
Devil's advocate real (medium-high), low impactBuilt the strongest intended case and rejected it; own subSequence(5, 7) test gives [tou] instead of [eki] on main (assertions on and off) and 10.5.2.
Adjudication confirmed (high)Re-ran the independent reproduction's JUnit: 2 of 3 fail with assertions on and off; 3 of 3 pass with the fix.
Repro minimization and main refresh verified; still reproducesThe one-test repro fails on 645d4fe14a20 (assertions on and off) and passes with the fix; the finder's 2 tests still fail on a9779ef3c193 with byte-identical messages.

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

Finding record

LFV-341, snapshot of 2026-11-06

Registry title
KatakanaRomanizer.romanize(CharsRef) ignores CharsRef.offset, so a CharsRef that is a slice of a larger char[] is romanized from chars[0, length) instead of the slice (latent: Lucene's own JapaneseCompletionFilter always passes offset 0)
Area
analysis / kuromoji (KatakanaRomanizer in org.apache.lucene.analysis.ja.completion, the romaji helper of JapaneseCompletionFilter)
Priority
Low, rank 875: KatakanaRomanizer ignores CharsRef.offset, but Lucene's own filter always passes offset 0, so this latent defect reaches only direct third-party callers with sliced input.
Impact class
minor
Impact note
Only application code that calls the public KatakanaRomanizer.getInstance().romanize(CharsRef) directly with a CharsRef whose offset is not 0 (new CharsRef(buffer, off, len), or Lucene's own CharsRef.subSequence(start, end) with start > 0) is hit: it silently gets the romaji of the first length characters of the backing array instead of the slice, with no exception with -ea or -da, in every release from 9.0.0 to 10.6.0 and on main; Lucene's only caller, JapaneseCompletionFilter (and so JapaneseCompletionAnalyzer and any analysis chain that uses the filter), always passes offset 0, so analysis output and indexes are unaffected, and nothing is read outside the array.
Summary
You call KatakanaRomanizer.getInstance().romanize(...) with a CharsRef that is a slice of a larger array, for example CharsRef.subSequence(2, 6) of 'ハシヤキュウ' (the slice 'ヤキュウ' at offset 2), and expect the romaji of the slice, [yakyuu], because CharsRef is documented as 'a slice (offset + length) into an existing char[]' and the same text at offset 0 gives [yakyuu]. Instead romanize reads chars[0, length), here 'ハシヤキ', and returns [hasiyaki, hashiyaki]; a slice at offset 1 of 'アトウキョウ' gives [atoukyo] instead of [toukyou], and one at offset 1 of 'xトウキョウt' gives [] instead of [toukyout]. There is no exception, with or without assertions, and Lucene's own JapaneseCompletionFilter is not affected because it always passes offset 0.
Introduced
4e86df96c0385a7b552434d7e2a9bee69f18bd77 (LUCENE-10102 / apache/lucene#297 'Add JapaneseCompletionFilter for Input Method-aware auto-completion', by mocobeta, co-authored by rmuir, squash-merged to main 2021-09-17), the commit that created KatakanaRomanizer [read: git blame on main ea57360912ed attributes both faulty lines, KatakanaRomanizer.java:165 and :173, to it; git log --all -S on each fragment returns only this commit; the two later commits to the file, 78b4f75a2c6 (#12978) and 942065cc3e7 (#13328), touch neither line]. Within the PR, the keystroke lookup without the offset is in the first commit dff3942eed5 (2021-09-13), and the remnant copy took its current form in ea337a2fb89 (2021-09-17), which kept the omission of an earlier loop. No commit-level A/B is possible: the parent de45b68c909 has no ja/completion package. (2021-09-17 (PR #297 merged; the pattern first appears on the PR branch on 2021-09-13))
First affected version
9.0.0 [exec] (the first release that has KatakanaRomanizer at all; on 8.10.1 and 8.11.4, the last 8.x release, the probe does not compile because package org.apache.lucene.analysis.ja.completion does not exist [exec])
Affected versions
every release from 9.0.0 through 10.5.2 (all 33: 21 on 9.x, 12 on 10.x), 10.6.0 [exec: the one-test repro fails on the 10.6.0 jars with -ea, 2026-10-31] and main ea57360912ed; not fixed on upstream main as of 2026-10-26 [exec: the history probe (public API only; differential oracle romanize(slice) == romanize of the same characters at offset 0) gives the same 3 wrong results and a passing offset-0 control on all 33 releases and main, also with -da on 9.0.0, 9.12.3, 10.5.2 and main; the finder's JUnit fails 2 of 2 on 9.1.0, 9.12.3, 10.5.2 and main; the independent reproduction's JUnit fails 2 of 3 on main, 10.5.2 and 9.12.3, and the independent reproduction's main() variant fails on 9.0.0; read: both faulty lines are byte-identical at all 33 release tags, and upstream main has the same file blob 6e2e4f0268da]. Not affected: 8.x and earlier, which do not have the class.
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 and voted “not real” while agreeing with the root cause (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record: 75 recorded queries, namely 51 by the scripted prior-report search over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, plus 24 manual GitHub, code-search and git-history searches; the full threads of #11140, #297, #12044, #16416 and #11399 read; the changed files of all 411 open apache/lucene PRs checked; 11 hits classified, none the same bug; the finder, the cross-model review and the intended-behaviour check found nothing either); not fixed upstream (upstream main still has both faulty lines, file blob 6e2e4f0268da, last commit touching the file 942065cc3e7e of 2024-09-10, read 2026-10-26; no open PR touches lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/); closest items, not reports: #11140 / LUCENE-10102 and #297 (introduced the class; their 'offset' comments concern token start/end offsets), #11399 / LUCENE-10363 'JapaneseCompletionFilter messes up offsets' (open, 0 comments; a different bug: token start/end offsets found by TestRandomChains, different code and fix), #12044 (closed unmerged cleanup that left the romanize body untouched).
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): this doc's one-method test through gradle with assertions on and off (seed DEADBEEF) and on the standalone runner; the same test on the 10.6.0, 10.5.2 and 9.12.3 release jars (fails). The hunt's tests ran on main ea57360912ed and again on a9779ef3c193; KatakanaRomanizer.java is the same blob up to 645d4fe14a20. The history probe ran on all 33 releases from 9.0.0 to 10.5.2. Fix A was run through gradle in a scratch worktree of 645d4fe14a20 on 2026-10-31. Upstream main 2f424fd428da (2026-10-30) still has the same code.

Lucene and upstream sources

Finding record
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java:32-39 (at ea57360912ed) (class javadoc 'performs prefix match on the given char sequence', public class, no @lucene.internal or @lucene.experimental), :90 (public static getInstance), :99-104 (romanize javadoc 'Translates a sequence of katakana to romaji.', public signature, entry assert CharSequenceUtils.isKatakanaOrHWAlphabets(input)), :107 (pos starts at 0), :112 (longestKeystrokeMatch(input, pos)), :162-167 (remnant copy; :165 output.append(input.chars, pos, input.length - pos) without input.offset), :171-181 (longestKeystrokeMatch; :173 new CharsRef(input.chars, inputOffset, len) without input.offset)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/CharSequenceUtils.java:45-53 (at ea57360912ed) (isKatakanaOrHWAlphabets reads through CharSequence.charAt, which honours the offset)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java:228 (at ea57360912ed) (the only in-tree caller: romanize(pdgReading.get()))
  • lucene/core/src/java/org/apache/lucene/util/CharsRef.java:22-25 (at ea57360912ed) (class javadoc 'Represents char[], as a slice (offset + length) into an existing char[].', @lucene.internal), :145-150 (subSequence returns new CharsRef(chars, offset + start, end - start))
  • lucene/core/src/java/org/apache/lucene/util/CharsRefBuilder.java:132-135 (at ea57360912ed) (get() asserts ref.offset == 0, so the filter's call always has offset 0)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/package-info.java (at ea57360912ed) ('Utilities for JapaneseCompletionFilter'); lucene/analysis/kuromoji/src/java/module-info.java:24 (exports org.apache.lucene.analysis.ja.completion)
The finding in 30 seconds
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java:99-115, (at 645d4fe14a20) :136, :150, :159-181
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/CharSequenceUtils.java:45-53 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/CharsRef.java:22-27, (at 645d4fe14a20) :51-56, :139-150; lucene/core/src/java/org/apache/lucene/util/CharsRefBuilder.java:132-135
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java:152, (at 645d4fe14a20) :228
  • lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/completion/TestKatakanaRomanizer.java:27-35 (at 645d4fe14a20) (offset-0 values hasi/hashi, yakyuu, toukyou)
Who is affected
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java:228; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/CharsRefBuilder.java:133; lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/package-info.java:18
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/CharsRef.java:23, (at 645d4fe14a20) :26
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java:39, (at 645d4fe14a20) :90, :99-104, :150, :165, :173; lucene/analysis/kuromoji/src/java/module-info.java:24; .../ja/completion/package-info.java:18
  • a Lucene clone releases/lucene/9.0.0 (no module-info.java in the tree) and releases/lucene/9.1.0 lucene/analysis/kuromoji/src/java/module-info.java:24 (exports org.apache.lucene.analysis.ja.completion)
Suggested fix
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java:60-63, (at 645d4fe14a20) :136, :150, :162-173; lucene/core/src/java/org/apache/lucene/util/CharsRef.java:157-162
History
  • a Lucene clone releases/lucene/9.0.0, 10.5.2, 10.6.0 and 645d4fe14a20: KatakanaRomanizer.java (git rev-parse, grep of both lines)
  • gh api repos/apache/lucene/commits?path=lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java and contents?ref=main (read-only, 2026-10-31)
Diagrams
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java:165, (at 645d4fe14a20) :173

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

Repro files

About the diagrams

A picture of the one-test repro and the two lines that drop input.offset, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.

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.