All findings · Lucene finding · LFV-622 · opennlp-sentence-breakiterator-next-n-last-boundary

OpenNLPSentenceBreakIterator.next(n) returns DONE instead of the end of the text when it lands on the last boundary

OpenNLPSentenceBreakIterator.next(n) returns DONE (-1) instead of the end of the text when the n-th boundary is the end, so next(1) disagrees with next(): on a one-sentence text next() returns 27 and next(1) returns -1. It is in every release since 7.3.0; Lucene's own tokenizer never calls next(n).

Summary

Priority
Low, rank 558OpenNLPSentenceBreakIterator.next(n) returns DONE instead of the final boundary, and nothing checked in Lucene or downstream projects calls it.
Impact
minor
Since
7.3.0 (LUCENE-2899, 2017)introduced 2017-12-15 · about 8 years
Affected
7.3.0 to 10.6.0 and main (11 releases executed, 10.6.0 by reading)
Status
documented
Found by
semi-formal: bounded-exhaustive consistency check of the real class

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

OpenNLPSentenceBreakIterator is the OpenNLP module's java.text.BreakIterator over sentences. As for any BreakIterator, next() moves to the next boundary and next(n) jumps n boundaries; both should return DONE (-1) only when no such boundary exists. The last boundary is the end of the text.

Diagram of OpenNLPSentenceBreakIterator on a three-sentence text with boundaries 0, 31, 59 and 102: next(1) and next(2) from the first boundary return 31 and 59, next(3) lands on the end of the text, 102, but returns DONE (-1), while three calls of next() return 102.
The three-sentence text with its boundaries 0, 31, 59 and 102: next(3) from first() lands on the end of the text, 102, but returns DONE (-1). Select the still to open it at full size. Generated with AI, checked against the code.
  1. You split the 102-character text Sentence number 1 has 6 words. Sentence number 2, 5 words. And finally, sentence number 3 has 8 words. with OpenNLPSentenceBreakIterator and the module's test sentence model. Its boundaries are 0, 31, 59 and 102, the end of the text.
  2. From first() you jump with next(n): next(1) returns 31, next(2) returns 59, and next(4) returns DONE because it steps past the end.
  3. You expect next(3) to return 102, the third boundary, as three calls of next() do. The JDK and ICU4J sentence iterators return 102 on the same text.
  4. Instead next(3) returns -1 (DONE), although the iterator did move to 102. Even with no model at all, on Whole text is one sentence., next() returns 27 but next(1) returns -1.
  5. Why: the class stores only the sentence starts {0, 31, 59}, so the end is boundary number 3. next(n) adds n to its counter and returns DONE when the counter is >= 3: landing exactly on the end is treated like going past it.

Who is affected

Only application code that uses this iterator directly and calls next(n) with n > 0, in the opt-in OpenNLP module. A loop that steps with next(n) until DONE loses its last group of sentences; with next(1), the last sentence of every text. Lucene's tokenizer only calls next(): tokens, indexing and search are unchanged.

Mechanism, terms and numbers592 words

OpenNLPSentenceBreakIterator is the OpenNLP module's java.text.BreakIterator over sentences. next(n) should return the n-th boundary from the current one, the same value as n calls of next(). When that boundary is the end of the text it returns DONE (-1) instead, although it does move the iterator there: next(3) from first() on the module's three-sentence test text returns -1 instead of 102, and with no model at all next(1) returns -1 where next() returns 27. A loop that steps with next(n) until DONE loses its last group of sentences (with next(1), the last sentence of every text). No Lucene code calls next(int) on this class, so tokenization, indexing and search are unchanged. Every release from 7.3.0 to 10.6.0 (10.6.0 by reading) and main is affected; a small fix in next(int) (+4/-1 lines) was validated with the module's tests.

Terms

  • Boundary: a position between sentences. For a text of three sentences the boundaries are the three sentence starts and the end of the text: 0, 31, 59 and 102 for the module's test text.
  • next() / next(n): next() moves to the boundary after the current one and returns it, or returns DONE (-1) when the current boundary is already the last one. next(n) jumps n boundaries (BreakIterator#next() is documented as "Equivalent to next(1)"); it returns DONE when there are fewer than n boundaries left.

The bug, step by step (code at 645d4fe14a20)

  1. setText() stores only the sentence starts (OpenNLPSentenceBreakIterator.java:195-205): {0, 31, 59}. The end of the text has no entry; counted as a boundary it is number sentenceStarts.length, here 3.
  2. next() (:62-72) returns DONE only when the position is already the end (:64). From the last sentence start it calls last() (:69-70), which moves to the end and returns it (:51-60).
  3. next(int n) (:163-187) adds n to the counter first: currentSentence += n (:165).
  4. Backwards (n < 0, :166-176) it adds one when the position is the end (:167-169), so it counts the end as boundary number 3, and it returns DONE only for a counter below 0: landing on the first boundary returns 0.
  5. Forwards (n > 0, :177-185) it returns DONE when currentSentence >= sentenceStarts.length (:178), after moving to the end (:179-181). >= also matches the end boundary itself, so next(3) from 0 (counter 3, 3 >= 3) moves to 102 and returns -1. By the next() javadoc ("Equivalent to next(1)") and the next(int) javadoc example, only a counter above 3, or a call made at the end, should give DONE; the next(int) javadoc's "has been reached" sentence is the strongest contrary reading (see "Why it is not intended").

What the calls return (main, the module's test sentence model)

callexpected (contract; JDK and ICU4J)main
next(1), next(2) from first()31, 5931, 59
next(3) from first() (lands on the end)102-1 (current() is 102)
next(1) from 59102-1
next(4) from first() (past the end)DONEDONE
next(-2) from 5900
no model, Whole text is one sentence.: next() / next(1) from first()27 / 2727 / -1

Only the returned value is wrong: in a probe with a fixed-span detector and 1 to 6 sentences, the position and the later calls it made (current(), previous(), next(), next(1), last(), following(0), preceding(end), next(-1)) gave the same results as with the fix.

Exposure and likelihood277 words
  • Who is exposed. Application code that uses OpenNLPSentenceBreakIterator itself as a java.text.BreakIterator and calls next(int) with n > 0. The class is public final in an exported package of the opt-in lucene-analysis-opennlp module, with no @lucene.internal tag; the public no-argument NLPSentenceDetectorOp is enough (no model file).
  • Deterministic. With n = 1 it fires from the last sentence start of every non-empty text; for larger n whenever the n-th boundary is the end. Assertions on or off. Where there is no distinct end boundary to land on (an empty text, or no sentences), DONE is already right.
  • Effect. One wrong return value, -1 instead of the end offset. A loop that stops at DONE loses its final group whenever the sentence count is a multiple of n; with n = 1 it loses the last sentence of every text. In a probe over 48 layouts, such a loop was wrong in 48 on main and in 23 with the fix or the JDK iterator (the overshoot cases the contract allows). A caller that uses the value as an offset unchecked gets a StringIndexOutOfBoundsException. A caller that reads current() after DONE is unaffected.
  • Not in Lucene's own paths. OpenNLPTokenizer is the only in-tree user, through SegmentingTokenizerBase, which calls only setText, current() and next(); it gives the same 24 tokens and offsets on main, the fixed build and 10.5.2. The only other next(int) call outside tests, in LengthGoalBreakIterator, is marked "Not supported".
  • How likely. Rare: no caller of next(int) on this class was found in Lucene, Solr, Elasticsearch, OpenSearch or the public code-search hits read, and nobody reported it in the eight years since 7.3.0.

Minimal repro

The test shows that OpenNLPSentenceBreakIterator.next(1) returns DONE instead of the end of the text when that is the next boundary (no model file: new NLPSentenceDetectorOp()).

TestSentenceBreakIteratorNextOne.java (JUnit test)31 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.opennlp;

import org.apache.lucene.analysis.opennlp.tools.NLPSentenceDetectorOp;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSentenceBreakIteratorNextOne extends LuceneTestCase {

  public void testNextOneToEndOfText() {
    OpenNLPSentenceBreakIterator bi = new OpenNLPSentenceBreakIterator(new NLPSentenceDetectorOp());
    bi.setText("One sentence.");
    bi.first();
    // next(1) must return the end of the text, 13, as next() does; actual: DONE (-1)
    assertEquals("next(1) from first()", 13, bi.next(1));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-622/, 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:$LUCENE/lucene/analysis/opennlp/build/libs/lucene-analysis-opennlp-$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.opennlp.TestSentenceBreakIteratorNextOne
# the module(s) analysis-opennlp also need their third-party runtime jars
Expected vs actual
Expected next(1) from first() on "One sentence." to return 13, as next() does; actual -1 (DONE) on main, with -ea and -da.

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

More files, environment, full output36 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: next(1) from first() expected:<13> but was:<-1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.analysis.opennlp.TestSentenceBreakIteratorNextOne.testNextOneToEndOfText(TestSentenceBreakIteratorNextOne.java:29)

Why it looks unintended

  • BreakIterator#next() is documented as "Equivalent to next(1)", yet here next() returns the end and next(1) returns DONE from the same position.
  • Backwards the class is right: next(-2) from 59 returns the first boundary, 0, as its own test asserts.
  • The next(int) javadoc example gives DONE only for a jump past the last boundary; nothing documents the forward case.
The full argument255 words
  • The contract. BreakIterator#next(): returns DONE "If the current boundary is the last text boundary", and "Equivalent to next(1)". BreakIterator#next(int): "Returns the nth boundary from the current boundary"; its example, with three more boundaries up to the last one: "the next(2) call will return m + 2" and "A next(4) call would return BreakIterator.DONE", so next(3) returns the last boundary. @return: "The character index of the nth boundary from the current position".
  • The class contradicts itself. Its next() returns the end from the last sentence start (OpenNLPSentenceBreakIterator.java:69-70; the upstream test asserts it at TestOpenNLPSentenceBreakIterator.java:90 and :130), while next(1) from there returns DONE. Its backward branch counts the end as a boundary and returns the edge boundary it lands on (OpenNLPSentenceBreakIterator.java:166-176; the test asserts next(-2) == 0 at :115).
  • Nothing documents the forward exact-landing case. The upstream tests use next(2) and the overshoots next(13) and next(-8) (TestOpenNLPSentenceBreakIterator.java:114, :147-148, :226-227); no code comment, CHANGES or MIGRATE entry, issue or PR states it. The class's deliberate JDK deviations in following() and preceding() carry a JDK-8015110 comment (OpenNLPSentenceBreakIterator.java:82-84, :144-146); next(int) carries none.
  • The other implementations checked return the end. The JDK and ICU4J sentence iterators return 102 for next(3) on the same text. In Lucene, SplittingBreakIterator, WholeBreakIterator and CustomSeparatorBreakIterator implement next(n) as a loop over next() and so return the end.
  • History. The class was added as a "shim class" for OpenNLPTokenizer (LUCENE-2899); none of the issue's 136 comments mentions next(int).
The devil's-advocate check186 words

The strongest case for "works as designed": (1) the next(int) javadoc's first sentence, "If either the first or last text boundary has been reached, it returns BreakIterator.DONE", read as "landing on the last boundary returns DONE"; (2) upstream deliberately keeps non-JDK edge behaviour in this class: following(pos) returns DONE anywhere in the last sentence (asserted at TestOpenNLPSentenceBreakIterator.java:142), and preceding() differs from the JDK (:109-110; #13170 added preceding(25) == 0 "verifying correct behavior" in 2024, where the JDK gives 22). So "the JDK does it" alone does not settle what this class should do.

Why it fails: next() is "Equivalent to next(1)" and this class's own next() returns the end, so reading (1) contradicts the class itself; the javadoc example shows DONE only on overshoot; and the backward branch returns the edge boundary it lands on, which reading (1) would forbid. The following()/preceding() deviations are commented and tested, the next(int) one is neither. A works-as-designed review by a reviewer from another model family rated the case moderate and rejected it; a separate devil's-advocate checker rejected it with "fairly high confidence" (its words).

Suggested fix

Recommended A. Return the end when next(n) lands on it

In the n > 0 branch of next(int), return DONE only when the call starts at the end or the counter passes the end; when it lands exactly on the end, move there and return it (+4/-1). Validated: the new test, the upstream test with the additions, all 36 module tests and check pass, assertions on and off.

     } else if (n > 0) {
-      if (currentSentence >= sentenceStarts.length) {
+      if (text.getIndex() == text.getEndIndex() || currentSentence > sentenceStarts.length) {
         currentSentence = sentenceStarts.length - 1;
         text.setIndex(text.getEndIndex());
         return DONE;
+      } else if (currentSentence == sentenceStarts.length) { // the n-th boundary is the end
+        currentSentence = sentenceStarts.length - 1;
+        text.setIndex(text.getEndIndex());
       } else {
All options and trade-offs3 options · 737 words

Options A and B were each applied in a scratch worktree of main 645d4fe14a20 and run through gradle with assertions on and off (B not through check), and through the repro harness with the hunt's walk oracle. The overflow of next(Integer.MAX_VALUE) after one next() (an ArrayIndexOutOfBoundsException) is a separate observation that was not checked as a bug of its own; fix A leaves it, fix B removes it.

A. Return the end when next(n) lands on it (validated by the checks) (recommended)

In next(int) (OpenNLPSentenceBreakIterator.java:177-185), return DONE only when the call starts at the end of the text or the counter passes sentenceStarts.length; when the counter equals sentenceStarts.length, set the counter to the last sentence, move to the end and fall through to return current(). This is the finder's fix.

Why the position check. At the end the counter is sentenceStarts.length - 1, as last() and next() leave it, so the counter alone cannot tell "at the end" from "at the last sentence start".

     } else if (n > 0) {
-      if (currentSentence >= sentenceStarts.length) {
+      if (text.getIndex() == text.getEndIndex() || currentSentence > sentenceStarts.length) {
         currentSentence = sentenceStarts.length - 1;
         text.setIndex(text.getEndIndex());
         return DONE;
+      } else if (currentSentence == sentenceStarts.length) { // the n-th boundary is the end
+        currentSentence = sentenceStarts.length - 1;
+        text.setIndex(text.getEndIndex());
       } else {

Trade-offs

The smallest change at the root cause, still O(1). The returned value changes only where it was -1 for an existing boundary. It leaves the separate leading-whitespace bug of this class and the huge-n overflow as they are. Compatibility (A and B alike): a public method now returns the end offset where it returned DONE; no in-tree or public caller found depends on that, but private callers that treat the terminal DONE as a stop signal cannot be ruled out.

What the checks validated

  • The new test: 3 of 3 pass with assertions on and off (stock: 2 of 3 fail); the upstream test with the additions: 9 of 9 (stock: 6 of 9 fail).
  • All of :lucene:analysis:opennlp:test: 6 classes, 36 tests, 0 failures, assertions on and off.
  • :lucene:analysis:opennlp:check -x test passes (google-java-format, ecjLint, forbiddenApis, licenses) with the fix and both tests in the tree.
  • Repro harness, fix shadowed: the new test with -ea and -da; earlier the finder's, the independent reproducer's and the devil's advocate's tests; the walk oracle's next(k) rule 0 of 84 layouts (stock: 84 of 84); the history probe passes on 7.3.0 and main.

B. Implement next(n) as n steps of next() or previous() (validated by the checks)

Replace the body with two loops that call next() (n > 0) or previous() (n < 0) until n steps are done or one returns DONE, the shape of SplittingBreakIterator.next(int) in the highlighter module (+8/-21).

  @Override
  public int next(int n) {
    // n steps of next() or previous(): BreakIterator#next() is "Equivalent to next(1)"
    int result = current();
    for (; n > 0 && result != DONE; --n) {
      result = next();
    }
    for (; n < 0 && result != DONE; ++n) {
      result = previous();
    }
    return result;
  }

Trade-offs

It follows the definition of next(n) as repeated next()/previous() calls (checked on the test texts and the walk oracle's 84 layouts) and adds nothing to a counter, so the tested huge-n case returns DONE instead of overflowing; but it costs O(min(|n|, boundaries left)) per call instead of O(1), and next(-n) then depends on previous(), which throws on text that starts with whitespace (a separate bug in this class): in the walk oracle the crash count goes from 42 to 126 until that bug is fixed. With its fix (setText sets the first sentence start to the begin index), A and B both give 0 everywhere. Prefer B only if both bugs are fixed together.

What the checks validated

  • The new test 3 of 3 and the upstream test with the additions 9 of 9, assertions on and off; all 36 module tests with assertions on. Not run through check.
  • The walk oracle's next(k) rule 0 of 84; next(Integer.MAX_VALUE) after one next() returns DONE instead of throwing.

Not recommended: document the current behaviour

A javadoc note that next(n) returns DONE when it reaches the end of the text.

Trade-offs

Keeps next(1) != next() and the asymmetry with next(-n), so it contradicts the inherited BreakIterator#next() contract.

Downstream note

The Lucene.NET port of this class carries the identical next(int) logic (read, not run).

How it was found

A bounded-exhaustive consistency check of the real class: for 84 stub sentence layouts it compared next(k) from first() with k calls of next(). All 84 disagreed, each exactly when the jump landed on the end of the text, and none did with the fix. A public-API test and independent checks followed.

The method in full171 words
  • Method (semi-formal): bounded-exhaustive internal-consistency checking of the stock jar, in the hunt's unit on the OpenNLP filters against the OpenNLP library. A stub sentence detector produced 1 to 14 sentences with start gaps of 1 to 3 characters and a leading offset of 0 or 2 (84 layouts), and three walk rules compared the navigation methods with each other: W1 backward walk = forward walk, W2 next(k) from first() = k calls of next() (and DONE past the end), W3 next(-k) from last() = k calls of previous().
  • Result. W2 failed in all 84 layouts, each exactly at k = the number of sentences (the end boundary), and in none with the fix. W1 and W3 failed only in the 42 layouts with a leading offset: that is the separate leading-whitespace bug of this class, with its own fix.
  • The finder cross-checked the JDK sentence iterator (it returns the end for the same jump), then wrote a public-API JUnit with the module's test model; independent checkers followed (see "Independent checks").

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26, refreshed 2026-10-29): about 200 tool queries over GitHub issues, PRs and commits, the Lucene lists, JIRA and the web, plus manual GitHub, downstream and history searches and all 407 open PRs. Closest: #12210 and its fix #13170, a different preceding() crash in the same class.

Searches5 searches

16 close hits were triaged; none describes this bug. Limits: keyword search; only the first page of broad GitHub queries (the 94 results for "next(int)" triaged by title); JIRA text ~ only; 4 of 18 planned web queries throttled; private reports cannot be seen.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search2026-10-26four runs of 50-51 queries (identifiers, symptom, the whole module): GitHub issues, PRs and commits, Solr, Elasticsearch, OpenSearch, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, the webonly term matches (LUCENE-7620, LUCENE-2899, #12210, #13170)
GitHub (manual)2026-10-26; refreshed 2026-10-29about 20 queries: the class name, "next(int)", BreakIterator "next(n)", OpenNLP sentence titles, apache/lucene-solr; the file lists of all 407 open apache/lucene PRs; the 15 PRs opened since 2026-10-26#12210, #13170, #3973; open PRs #12295 (cosmetic) and #16911 (deletes every file on branch_10x) touch the file; neither fixes next(int); of the 15 PRs opened since 2026-10-26 only #16911 touches the module
Downstream and ports2026-10-26Solr, Elasticsearch, OpenSearch, Lucene.NET, OpenNLP; public code searchno report; the Lucene.NET port has the identical code
Mailing lists and JIRA2026-10-26lists.apache.org (Lucene, Solr, OpenNLP); ASF JIRA text search, all projectsonly #12210 notifications; LUCENE-2899 and SOLR-17023 (unrelated)
Git history2026-10-26log --follow, -L on next(int), -S on the condition; CHANGES.txtnext(int) unchanged since 2017; newest commit on the file #14216 (2025-02); no CHANGES entry

History

Born with the class: LUCENE-2899 (2017-12-15; 3e2f9e62d77 on master, b720e1ee3a5 on branch_7x), first released in 7.3.0. A version-neutral probe fails identically on 11 releases from 7.3.0 to 10.5.2 and on main; next(int) has never changed. Not fixed upstream.

Versions tested8 rows

Introduced by LUCENE-2899, "Add OpenNLP Analysis capabilities as a module" (JIRA patch era, migrated as #3973, no GitHub PR), committed by sarowe on 2017-12-15: 3e2f9e62d77 on master (first released in 8.0.0) and b720e1ee3a5 on branch_7x (first released in 7.3.0). git blame puts every line of next(int) and next() on 3e2f9e62d77; the three later commits on the file (LUCENE-9570 reformat, #13170 preceding() fix, #14216 errorprone) do not change them. 7.2.1 and earlier have no OpenNLP module.

Executed. The probe uses only API that is unchanged from 7.3.0 to main, runs on each release's own jars with the test sentence model blob its release tag carries (for 8.11.4, whose tag is not in the local clone, the 7.5.0-8.8.1 blob; the model-free check needs none), and checks the snippet case, the three-sentence case and the upstream controls. Read, not executed: every release tag from 7.3.0 to 10.5.2 in the history clone has the same next(int) body; 8.8.2 to 8.11.3 are not tagged there and lie between 8.8.1 (read) and 8.11.4 (executed). Not fixed upstream: in the history clone, whose origin/main is 645d4fe14a20 (2026-10-28), the newest commit on the file is #14216 (2025-02).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectednew test 2 of 3 fail through gradle and the harness, assertions on and off
main a9779ef3c193 affectedprobe fails (-ea, -da); the finder's, the reproducer's and the devil's advocate's tests fail
10.0.0, 10.5.2 affectedprobe fails; the JUnit repros fail on 10.5.2
9.0.0, 9.12.3 affectedprobe fails; the finder's JUnit also fails on 9.4.2
8.0.0, 8.11.4 affectedprobe fails
7.3.0, 7.3.1, 7.4.0, 7.5.0, 7.7.3 affectedprobe fails; 7.3.0 is the first release with the module
7.2.1 API absentno OpenNLP module
7.3.0 + fix, main + fix not affectedprobe passes: next(1) = 27, next(3) = 102

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and an adjudication each re-ran it on main and judged it real; a works-as-designed review by a reviewer from another model family agreed, reading only. A read-only refute review said "not real" only because it could not run code.

Checks and verdicts306 words

Every verdict below that re-ran a repro did so on main a9779ef3c193 (module identical to 645d4fe14a20); the cross-model reviews could only read code (they could not write or run anything, and the re-run supplied to them lacked the model file). This doc's own validation ran on 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
Cross-model refute review not real (low), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6), reading only: it may not vote real without its own run. It agreed with the root cause and found the inputs within the contract; the re-run supplied to it left out the model's classpath entry and stopped on "Resource not found", which is not a reproduction.
Cross-model works-as-designed review real (medium)Same reviewer, reading only: the "intended" case is moderate and fails (the class's own next() and next(-n), the uncommented next(int)). A separate run re-executed the finder's test on main and compared ICU4J and the JDK (102 for next(3)).
Independent reproducer realOwn test written from the claim (no-model detector, a fixed-span detector, the test model, a JDK control): 4 of 5 fail on main (-ea, -da) and 10.5.2, the 3 model-free tests also on 9.12.3; 5 of 5 pass with the fix.
Intended-behaviour devil's advocate real (fairly high)Built the strongest "works as designed" case and rejected it; own test 3 of 7 fail on main and 10.5.2 with 4 controls passing; 7 of 7 and the 9 upstream tests pass with the fix.
Adjudication confirmed (high)Re-ran the reproducer's test (4 of 5 fail, -ea and -da) and the finder's (2 of 3 fail) on main.
Credibility audit keepRe-ran the repro with -da (2 of 3 fail), re-read the code, confirmed the minor impact.

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

Finding record

LFV-622, snapshot of 2026-11-06

Registry title
OpenNLPSentenceBreakIterator.next(n) returns DONE instead of the end of the text when the n-th boundary is the last one, so next(1) differs from next() and a caller that steps with next(n) until DONE loses the final sentence group
Area
analysis / opennlp OpenNLPSentenceBreakIterator (java.text.BreakIterator implementation, next(int))
Priority
Low, rank 558: OpenNLPSentenceBreakIterator.next(n) returns DONE instead of the final boundary, and nothing checked in Lucene or downstream projects calls it.
Impact class
minor
Impact note
Hits only applications that use the opt-in analysis-opennlp module's OpenNLPSentenceBreakIterator directly as a java.text.BreakIterator and call next(int) with a positive n: whenever the n-th boundary is the end of the text (with n = 1 from the last sentence start of every text) the call returns DONE (-1) instead of the end offset, deterministically, with -ea and -da, with no model file and no scale needed, in every release from 7.3.0 to 10.6.0 (10.6.0 by reading) and main; the iterator is still moved to the end, so current() and every later call are correct, a caller that stops at DONE loses the final sentence group (with n = 1 the last sentence) and a caller that uses the value unchecked as an offset gets a StringIndexOutOfBoundsException; no Lucene code calls next(int) on this class (OpenNLPTokenizer, through SegmentingTokenizerBase, uses only setText, current() and next(), and its tokens and offsets are unchanged, executed), and no downstream caller was found.
Summary
You set a text on an OpenNLPSentenceBreakIterator and call next(n) to jump n sentence boundaries, expecting the n-th boundary, as java.text.BreakIterator#next(int) promises ('Returns the nth boundary from the current boundary') and as n calls of next() give. When that boundary is the end of the text, next(n) returns DONE (-1) instead: with the model-free NLPSentenceDetectorOp on 'Whole text is one sentence.', next() from first() returns 27 but next(1) returns -1, and on the module's three-sentence test text (boundaries 0, 31, 59, 102) next(3) from first() returns -1 instead of 102 while next(2) correctly returns 59. The iterator is still moved to the end, so only the returned value is wrong; code that steps with next(n) until DONE loses the final sentence group.
Introduced
b720e1ee3a524034fb8a8a6188b0b23bf17ff1cb (branch_7x, the commit in the first affected release 7.3.0) and 3e2f9e62d772218bf1fcae6d58542fad3ec43742 (master, first released in 8.0.0), both 'LUCENE-2899: Add OpenNLP Analysis capabilities as a module', committed by sarowe on 2017-12-15 (JIRA patch era, migrated as apache/lucene#3973, no GitHub PR) [read: git blame puts every line of next(int) (163-187 on main) and of next() on 3e2f9e62d77, git log -L on next(int) and git log -S on 'currentSentence >= sentenceStarts.length' show no later change; exec: the history probe fails on 7.3.0 and passes there with the same 3-line fix shadowed]. Not a regression: the defect was born with the class; the later commits on the file (7f8b7ffbcad LUCENE-9570 reformat, 44fa35b65ff #13170 preceding() fix, b0ebaaed565 #14216 errorprone) do not touch next() or next(int) (2017-12-15 (both commits))
First affected version
7.3.0 [exec: the version-neutral history probe fails on 7.3.0 with the same output as on main, and passes there with the fix shadowed]; 7.2.1 and earlier have no analysis-opennlp module (API absent [exec: the 7.2.1 module artifact does not exist, and the class is absent at the tag]); the master commit first shipped in 8.0.0
Affected versions
every release from 7.3.0 through 10.6.0 (7.3.0-7.7.3, 8.0.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.6.0; 10.6.0 by reading) and main a9779ef3c193; never fixed. [exec] the follow-up's version-neutral main() probe NextNHistoryProbe gives identical failing output (model-free one sentence: next() = 27, next(1) = -1; the release's own test sentence model on three sentences: next(3) = -1 instead of 102; controls next(4) == DONE, next(2) == 59, next(-2) == 0 hold) on 7.3.0, 7.3.1, 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da), under OpenNLP tools 1.8.3 to 2.5.12; the finder's JUnit fails 2 of 3 on main (-ea and -da), 10.5.2, 9.12.3 and 9.4.2; the independent reproduction's JUnit fails 4 of 5 on main (-ea and -da) and 10.5.2, and its 3 model-free tests fail on 9.12.3; the intended-behaviour check's JUnit fails 3 of 7 on main (-ea and -da) and 10.5.2. [read] every release tag 7.3.0..10.5.2 in the history clone has a next(int) body identical (whitespace-normalised) to main's; 8.8.2-8.11.3 are untagged in the clone and bracketed by 8.8.1 [read] and 8.11.4 [exec]; the heads branch_10_6 2763db11b0a (before 10.6.0 RC1; file unchanged at 4ae946e5a9b) and the unreleased branch_10x 1309534296e are identical [read, not executed]. Not affected: 7.2.1 and earlier (API absent). Executed only on aarch64 Linux with JDK 25.0.3 (pure Java logic)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new): four scripted prior-report searches of 50-51 queries each (the finder's run plus identifier, symptom and module-wide specs) over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 15 manual GitHub searches (apache/lucene, apache/lucene-solr and all of GitHub); downstream searches in Solr, Elasticsearch, OpenSearch, Lucene.NET and OpenNLP; lists.apache.org (Lucene, Solr and OpenNLP lists); ASF JIRA text search over all projects; git log --follow, -L and -S in the history clone and upstream commits on main, branch_10x and branch_10_6; 14 of 18 planned web queries (4 throttled); the changed-file lists of all 407 open apache/lucene PRs. 16 closest hits, none the same bug: #12210 and its fix #13170 (2024; preceding() ArrayIndexOutOfBoundsException, a different method and root cause), #3973 (LUCENE-2899, the module addition; no discussion of next(int)), #8671 (LUCENE-7620, next(n) design for LengthGoalBreakIterator), #11735 (sentence boundaries in the OpenNLP filters, different code), the open PRs #12295 (cosmetic instanceof change in characterIteratorToString) and #16911 (branch_10x decommission), the only two that touch the file, and JDK-8015110 (following()/preceding(), cited in the class). The Lucene.NET port carries the identical Next(int) code, also unreported. Not fixed upstream: next(int) is unchanged since 2017 and the newest commit on the file is #14216 (2025-02) on main, branch_10x and branch_10_6. Limits: GitHub search reads only the first page of broad queries (the 94 results of 'next(int)' were triaged by title), JIRA was searched with text~ only, private reports are not visible
Lucene commit tested
main 645d4fe14a20 (2026-10-28) through gradle in a scratch worktree: the new 3-test class and the upstream TestOpenNLPSentenceBreakIterator with the proposed additions (assertions on and off), all 36 tests of :lucene:analysis:opennlp:test and :lucene:analysis:opennlp:check -x test, on stock and with fix A or fix B; the worktree was removed afterwards. The new test through the repro harness on the stock 645d4fe14a20 jars (-ea, -da) and with fix A or B shadowed; there also the upstream test with the additions, the hunt's walk oracle and the security probe, with -ea. The hunt's runs on main a9779ef3c193, whose lucene/analysis/opennlp module is identical; the history probe on 7.3.0, 7.3.1, 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2. JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPSentenceBreakIterator.java:26-27 (at a9779ef3c193) (public final class, 'A BreakIterator that splits sentences using an OpenNLP sentence chunking model'), :51-60 (last()), :62-72 (next(): from the last sentence start it returns last() = the end of the text), :74-94 and :135-161 (following() and preceding(): the only deviations with a JDK-8015110 comment), :163-187 (next(int); :165 'currentSentence += n'; :166-176 the n < 0 branch counts the end as a boundary and returns the first boundary it lands on; :177-182 the n > 0 branch returns DONE when currentSentence >= sentenceStarts.length, which includes the end-of-text index sentenceStarts.length); identical at a Lucene main checkout ea57360912ed
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPTokenizer.java:52 (at a9779ef3c193) (the only in-tree construction of the class)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/util/SegmentingTokenizerBase.java:114, (at a9779ef3c193) :167, :194-199 (setText, current() and next() only; never next(int))
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/LengthGoalBreakIterator.java:143-145 (at a9779ef3c193) (next(int): 'assert false : "Not supported"', delegates; the only other next(int) in Lucene main outside tests)
  • lucene/analysis/opennlp/src/test/org/apache/lucene/analysis/opennlp/TestOpenNLPSentenceBreakIterator.java:87-93 (at a9779ef3c193) and :130 (next() reaches the end), :114-115 (next(2) == 59, next(-2) == 0), :147-148 and :226-227 (overshoots next(13) and next(-8) == DONE); no forward next(n) lands exactly on the end
  • JDK 25.0.3 src.zip java/text/BreakIterator.java:283-302 (next(int): 'Returns the nth boundary from the current boundary. If either the first or last text boundary has been reached, it returns DONE ...'; the example: with three more boundaries next(2) returns m + 2 and next(4) returns DONE; '@return The character index of the nth boundary from the current position'), :304-315 (next(): DONE 'if the current boundary is the last text boundary'; 'Equivalent to next(1)')
The finding in 30 seconds
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPSentenceBreakIterator.java:51-60, :62-72, :163-187 (:165, :166-176, :177-185, :178), :195-205 @ 645d4fe14a20
Who is affected
  • lucene/analysis/opennlp/src/java/module-info.java:25; lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPSentenceBreakIterator.java:26-27 @ 645d4fe14a20
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPTokenizer.java:52; lucene/analysis/common/src/java/org/apache/lucene/analysis/util/SegmentingTokenizerBase.java:114, :167, :194, :199; lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/LengthGoalBreakIterator.java:143-146 @ 645d4fe14a20
Why it looks unintended
  • JDK 25.0.3 lib/src.zip java.base/java/text/BreakIterator.java:283-302 (next(int) javadoc and example), :304-315 (next(): 'Equivalent to next(1)')
  • lucene/analysis/opennlp/src/java/org/apache/lucene/analysis/opennlp/OpenNLPSentenceBreakIterator.java:69-70, :81-87, :82-84, :144-146, :166-176 @ 645d4fe14a20
  • lucene/analysis/opennlp/src/test/org/apache/lucene/analysis/opennlp/TestOpenNLPSentenceBreakIterator.java:90, :109-110, :114-115, :130, :142, :147-148, :226-227 @ 645d4fe14a20
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/SplittingBreakIterator.java:227-241, WholeBreakIterator.java:74-85, CustomSeparatorBreakIterator.java:130-141 @ 645d4fe14a20
Suggested fix
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/SplittingBreakIterator.java:227-241 @ 645d4fe14a20

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

Repro files

About the diagrams

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.