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.
- A
CharsRefis a window into achar[]:chars,offsetandlength.new CharsRef(buf, 1, 5)means the 5 charactersbuf[1]tobuf[5];CharsRef.subSequence(start, end)also returns a window with a non-zero offset (declared asCharSequence, so a caller casts it toCharsRef). KatakanaRomanizer.getInstance().romanize(input)walks the katakana, matches the longest known keystroke at each position (キョiskyo) and returns every romaji spelling:ハシgives[hasi, hashi].JapaneseCompletionFilter(used byJapaneseCompletionAnalyzer) calls it once per token reading, with a buffer whose offset is always 0.
アトウキョウ, 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.- You hold katakana in a larger
char[],アトウキョウ, and want the romaji of the 5 characters from index 1,トウキョウ(Tokyo). You pass that window asnew CharsRef(chars, 1, 5)(offset 1, length 5) toKatakanaRomanizer.getInstance().romanize(...). - You expect
[toukyou]: aCharsRefis documented as a slice (offset + length) of its array, and[toukyou]is whatromanizereturns for the same 5 characters at offset 0. - You get
[atoukyo], with no error, assertions on or off: the romaji ofアトウキョ, characters 0 to 4 of the array.romanizecounts positions 0 to 4 inside the slice but readschars[pos]instead ofchars[offset + pos], in two places. - Lucene's own
CharsRef.subSequencemakes such slices too:subSequence(2, 6)ofハシヤキュウ(that isヤキュウ;subSequenceis declared to returnCharSequence, so the caller casts it toCharsRef) gives[hasiyaki, hashiyaki]instead of[yakyuu].JapaneseCompletionFilter, the only production caller in Lucene, always passes offset 0, so analysis output and indexes are unaffected.
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 input is a slice.
CharsRef"Represents char[], as a slice (offset + length) into an existing char[]" (CharsRef.java:23). The public constructorCharsRef(char[] chars, int offset, int length)(:51-56) andsubSequence(:146-150,new CharsRef(chars, offset + start, end - start)) make slices with a non-zero offset. - The entry check reads the slice correctly.
romanizestarts withassert CharSequenceUtils.isKatakanaOrHWAlphabets(input)(KatakanaRomanizer.java:104), which reads throughCharSequence.charAt(CharSequenceUtils.java:45-53), andCharsRef.charAtreturnschars[offset + index](CharsRef.java:139-143). - The body reads from index 0.
posstarts at 0 (:107) and runs toinput.length(:108). Each step callslongestKeystrokeMatch(input, pos)(:112), which builds the lookup key asnew CharsRef(input.chars, inputOffset, len)(:173) instead ofinput.offset + inputOffset. When no keystroke matches, the loop stops (:113-115) and the rest is copied asoutput.append(input.chars, pos, input.length - pos)(:165) instead ofinput.offset + pos. - Result. For offset 0 both readings agree; for any other offset the method processes
chars[0, length). Every index stays inside the array (a validCharsRefhasoffset + length <= chars.length), so there is no exception and no out-of-bounds read. The method is inconsistent with itself: theassertchecks one range and the body reads another, and:150does honour the offset of the map's own refs. - Why Lucene never sees it. The only in-tree production caller is
JapaneseCompletionFilter.java:228,romanize(pdgReading.get()), wherepdgReadingis aCharsRefBuilder(:152) andCharsRefBuilder.get()assertsref.offset == 0(CharsRefBuilder.java:132-135). The other in-tree calls, inTestKatakanaRomanizer, all passnew CharsRef(String).
| slice | slice content | expected | actual |
|---|---|---|---|
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
CharsRefwhose offset is not 0:new CharsRef(buf, off, len)over a reused buffer, orCharsRef.subSequence(start, end)withstart > 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,JapaneseCompletionFilterFactoryand 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 passnew CharsRef(String), and nothing has been reported since 9.0.0 (2021).
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.
/*
* 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));
}
}
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トウキョウ(whatromanizereturns 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).
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.
| lucene | main 645d4fe14a20: affected; also the 10.6.0, 10.5.2 and 9.12.3 release jars with this test |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)CharsRefis documented as "a slice (offset + length) into an existing char[]" (and tagged@lucene.internal);romanizeis public, exported since 9.1.0, and documents no offset-0 rule.- The method contradicts itself: its entry
assertreads the slice with the offset, the body without it. - Lucene has fixed ignored
BytesRef/IntsRefoffsets before, latent ones included (1b671cfba3a, LUCENE-4423, LUCENE-5339, LUCENE-10052).
The full argument216 words
- The contract is a slice.
CharsRef.java:23documents (offset + length). Theromanizejavadoc ("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) andromanize(:103) are public, with no@lucene.internalor@lucene.experimentaltag, in a package thatmodule-info.java:24exports. The export dates from 9.1.0: 9.0.0 has nomodule-info.java, though the class was already public there. The input typeCharsRefitself is tagged@lucene.internal(CharsRef.java:26; see the strongest counter-case below). - The method contradicts itself. Its
assert(:104) reads the slice throughcharAt, which adds the offset;:150honourscref.offset; only:165and:173dropinput.offset. - Precedent. Lucene treats an ignored
*Ref.offsetas 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.
TestKatakanaRomanizeruses onlynew CharsRef(String); the review of #297 discusses token start/end offsets andCharSequencesignatures, neverCharsRef.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.
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); // :173All 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.
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); // :173Trade-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, includingTestKatakanaRomanizer,TestJapaneseCompletionFilter,TestJapaneseCompletionFilterFactory,TestJapaneseCompletionAnalyzerand 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.
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.
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 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.
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 thatromanizenever addsinput.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
CharsRefand 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.
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".
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits | 2026-10-26 | the 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 #11399 | only the feature, cleanup and token-offset items |
| Open PRs | 2026-10-26 | the changed files of all 411 open apache/lucene PRs | none touches lucene/analysis/kuromoji/.../ja/completion/ |
| Lists, JIRA, web, downstream code | 2026-10-26 | dev@, java-user@, issues@ and commits@ (20 queries); JIRA (4 queries); 6 web queries; code search for KatakanaRomanizer in downstream engines | no report; no downstream direct caller |
| Recheck (GitHub) | 2026-10-31 | KatakanaRomanizer; romanize offset; JapaneseCompletionFilter; CharsRef offset ignored; open PRs matching KatakanaRomanizer; the upstream file history | only #12044, #11140, #297, #11399, #16416 and two unrelated items; the file's last commit is still #13328 (2024) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 8.10.1, 8.11.4 | no such class | the probe does not compile: package org.apache.lucene.analysis.ja.completion does not exist |
| 9.0.0 | first release | history 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 | affected | history 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 | affected | history probe on all 12 releases: 3 of 3 slices wrong; this doc's test fails on 10.5.2 |
| 10.6.0 | affected | this doc's test fails: expected [toukyou], got [atoukyo] |
| main 645d4fe14a20 | affected | this doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Exhaustive 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), procedural | Could not run Java; root cause agrees; a separate re-run fails 2 of 2. |
| Independent reproducer | real | Own 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 impact | Built 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 reproduces | The 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. |
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.
- PNG 474 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,288 words
- 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 mainea57360912edand again ona9779ef3c193;KatakanaRomanizer.javais the same blob up to645d4fe14a20. 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 of645d4fe14a20on 2026-10-31. Upstream main2f424fd428da(2026-10-30) still has the same code.
- 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-181lucene/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-135lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java:152, (at 645d4fe14a20) :228lucene/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) :26lucene/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:18a 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
- TestKatakanaRomanizerCharsRefOffset.java (1 test):
repro/LFV-341/src/org/apache/lucene/analysis/ja/completion/TestKatakanaRomanizerCharsRefOffset.java(30 lines)
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.
assets/LFV-341/bug.png(474 KB)
