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.
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.- 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.withOpenNLPSentenceBreakIteratorand the module's test sentence model. Its boundaries are 0, 31, 59 and 102, the end of the text. - From
first()you jump withnext(n):next(1)returns 31,next(2)returns 59, andnext(4)returnsDONEbecause it steps past the end. - You expect
next(3)to return 102, the third boundary, as three calls ofnext()do. The JDK and ICU4J sentence iterators return 102 on the same text. - Instead
next(3)returns -1 (DONE), although the iterator did move to 102. Even with no model at all, onWhole text is one sentence.,next()returns 27 butnext(1)returns -1. - 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 returnsDONEwhen the counter is>= 3: landing exactly on the end is treated like going past it.
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.
- 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 returnsDONE(-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 returnsDONEwhen there are fewer than n boundaries left.
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 numbersentenceStarts.length, here 3.next()(:62-72) returnsDONEonly when the position is already the end (:64). From the last sentence start it callslast()(:69-70), which moves to the end and returns it (:51-60).next(int n)(:163-187) adds n to the counter first:currentSentence += n(:165).- 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 returnsDONEonly for a counter below 0: landing on the first boundary returns 0. - Forwards (
n > 0,:177-185) it returnsDONEwhencurrentSentence >= sentenceStarts.length(:178), after moving to the end (:179-181).>=also matches the end boundary itself, sonext(3)from 0 (counter 3,3 >= 3) moves to 102 and returns -1. By thenext()javadoc ("Equivalent to next(1)") and thenext(int)javadoc example, only a counter above 3, or a call made at the end, should giveDONE; thenext(int)javadoc's "has been reached" sentence is the strongest contrary reading (see "Why it is not intended").
| call | expected (contract; JDK and ICU4J) | main |
|---|---|---|
next(1), next(2) from first() | 31, 59 | 31, 59 |
next(3) from first() (lands on the end) | 102 | -1 (current() is 102) |
next(1) from 59 | 102 | -1 |
next(4) from first() (past the end) | DONE | DONE |
next(-2) from 59 | 0 | 0 |
no model, Whole text is one sentence.: next() / next(1) from first() | 27 / 27 | 27 / -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
OpenNLPSentenceBreakIteratoritself as ajava.text.BreakIteratorand callsnext(int)with n > 0. The class ispublic finalin an exported package of the opt-inlucene-analysis-opennlpmodule, with no@lucene.internaltag; the public no-argumentNLPSentenceDetectorOpis 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),
DONEis already right. - Effect. One wrong return value, -1 instead of the end offset. A loop that stops at
DONEloses 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 aStringIndexOutOfBoundsException. A caller that readscurrent()afterDONEis unaffected. - Not in Lucene's own paths.
OpenNLPTokenizeris the only in-tree user, throughSegmentingTokenizerBase, which calls onlysetText,current()andnext(); it gives the same 24 tokens and offsets on main, the fixed build and 10.5.2. The only othernext(int)call outside tests, inLengthGoalBreakIterator, 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.
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()).
/*
* 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));
}
}
# 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.
More files, environment, full output36 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)BreakIterator#next()is documented as "Equivalent to next(1)", yet herenext()returns the end andnext(1)returnsDONEfrom 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 givesDONEonly for a jump past the last boundary; nothing documents the forward case.
The full argument255 words
- The contract.
BreakIterator#next(): returnsDONE"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", sonext(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 atTestOpenNLPSentenceBreakIterator.java:90and:130), whilenext(1)from there returnsDONE. Its backward branch counts the end as a boundary and returns the edge boundary it lands on (OpenNLPSentenceBreakIterator.java:166-176; the test assertsnext(-2) == 0at:115). - Nothing documents the forward exact-landing case. The upstream tests use
next(2)and the overshootsnext(13)andnext(-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 infollowing()andpreceding()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,WholeBreakIteratorandCustomSeparatorBreakIteratorimplementnext(n)as a loop overnext()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 mentionsnext(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).
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.
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 testpasses (google-java-format, ecjLint, forbiddenApis, licenses) with the fix and both tests in the tree.- Repro harness, fix shadowed: the new test with
-eaand-da; earlier the finder's, the independent reproducer's and the devil's advocate's tests; the walk oracle'snext(k)rule 0 of 84 layouts (stock: 84 of 84); the history probe passes on 7.3.0 and main.
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 onenext()returnsDONEinstead of throwing.
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.
The Lucene.NET port of this class carries the identical next(int) logic (read, not run).
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)fromfirst()= k calls ofnext()(andDONEpast the end), W3next(-k)fromlast()= k calls ofprevious(). - 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").
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.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search | 2026-10-26 | four 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 web | only term matches (LUCENE-7620, LUCENE-2899, #12210, #13170) |
| GitHub (manual) | 2026-10-26; refreshed 2026-10-29 | about 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 ports | 2026-10-26 | Solr, Elasticsearch, OpenSearch, Lucene.NET, OpenNLP; public code search | no report; the Lucene.NET port has the identical code |
| Mailing lists and JIRA | 2026-10-26 | lists.apache.org (Lucene, Solr, OpenNLP); ASF JIRA text search, all projects | only #12210 notifications; LUCENE-2899 and SOLR-17023 (unrelated) |
| Git history | 2026-10-26 | log --follow, -L on next(int), -S on the condition; CHANGES.txt | next(int) unchanged since 2017; newest commit on the file #14216 (2025-02); no CHANGES entry |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | new test 2 of 3 fail through gradle and the harness, assertions on and off |
main a9779ef3c193 | affected | probe fails (-ea, -da); the finder's, the reproducer's and the devil's advocate's tests fail |
| 10.0.0, 10.5.2 | affected | probe fails; the JUnit repros fail on 10.5.2 |
| 9.0.0, 9.12.3 | affected | probe fails; the finder's JUnit also fails on 9.4.2 |
| 8.0.0, 8.11.4 | affected | probe fails |
| 7.3.0, 7.3.1, 7.4.0, 7.5.0, 7.7.3 | affected | probe fails; 7.3.0 is the first release with the module |
| 7.2.1 | API absent | no OpenNLP module |
| 7.3.0 + fix, main + fix | not affected | probe passes: next(1) = 27, next(3) = 102 |
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.
| Check | Verdict | What it found |
|---|---|---|
| Cross-model refute review | not real (low), procedural | An 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 | real | Own 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 | keep | Re-ran the repro with -da (2 of 3 fail), re-read the code, confirmed the minor impact. |
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 427 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,576 words
- 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 upstreamTestOpenNLPSentenceBreakIteratorwith the proposed additions (assertions on and off), all 36 tests of:lucene:analysis:opennlp:testand: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 stock645d4fe14a20jars (-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 maina9779ef3c193, whoselucene/analysis/opennlpmodule 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.
- 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 ea57360912edlucene/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 endJDK 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 @ 645d4fe14a20lucene/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 @ 645d4fe14a20lucene/analysis/opennlp/src/test/org/apache/lucene/analysis/opennlp/TestOpenNLPSentenceBreakIterator.java:90, :109-110, :114-115, :130, :142, :147-148, :226-227 @ 645d4fe14a20lucene/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
- TestSentenceBreakIteratorNextOne.java (JUnit test):
repro/LFV-622/src/org/apache/lucene/analysis/opennlp/TestSentenceBreakIteratorNextOne.java(31 lines)
assets/LFV-622/bug.png(427 KB)
