Skip to content

OPENNLP-205: Refactor the end-of-sentence position to span mapping in SentenceDetectorME#1141

Merged
mawiesne merged 4 commits into
mainfrom
OPENNLP-205
Jul 6, 2026
Merged

OPENNLP-205: Refactor the end-of-sentence position to span mapping in SentenceDetectorME#1141
mawiesne merged 4 commits into
mainfrom
OPENNLP-205

Conversation

@krickert

@krickert krickert commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Closes a ticket open since 2011: the mapping from accepted end-of-sentence positions to sentence spans in SentenceDetectorME.sentPosDetect was interwoven with the detection loop, dropped a whitespace-only candidate's probability by index from a pre-sized array (a latent span/probability misalignment), and tested whitespace with StringUtil.isWhitespace.

Two commits, meant to be read in order:

  1. Characterization. Pins the current mapping behavior before touching it, with expectations captured from the existing implementation: exact spans and probability alignment for plain, whitespace-padded, multi-delimiter, NBSP, CRLF, whitespace-only, empty, and no-delimiter input, free-standing delimiter islands, and the current U+0085 handling, with useTokenEnd both enabled and disabled.
  2. Refactoring. The mapping moves into a package-visible mapPositionsToSpans seam that is directly testable without a trained model; spans and probabilities are built in lockstep, so they stay aligned by construction; trimming and the whitespace cursors run on the Unicode White_Space set of CharClass.whitespace() from the normalization engine merged with OPENNLP-1860: Unicode normalization engine — CharClass, rungs, Dimension, confusables (1a/7) #1108.

Behavior deltas, both deliberate and pinned by tests: the next line control (U+0085, in Unicode White_Space but missed by the old check) is now trimmed from span edges instead of riding along as sentence content, and the U+001C..U+001F information separators (not Unicode White_Space) are no longer treated as whitespace. SDContextGenerator and the break-acceptance heuristics are deliberately untouched, so existing models see exactly the features they were trained on.

All sentence-detector suites pass (73 tests incl. the seven language-specific model tests), full opennlp-runtime module green.

JIRA: https://issues.apache.org/jira/browse/OPENNLP-205

@mawiesne mawiesne added java Pull requests that update Java code tests Pull requests that add or update test code labels Jul 3, 2026
@rzo1

rzo1 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Hi @krickert,

I didn't have time to deeply review this yet, so here you get some Fable 5 output — take it with a grain of salt. I'll read it myself next week (human in the loop).


TL;DR from the model: The refactoring itself looks sound and there are no public API breaks (nothing changed signature or visibility; mapPositionsToSpans is package-private). The behavioral deltas are confined to inputs containing U+0085 or U+001C–U+001F — plain ASCII/ordinary Unicode is unaffected. But two of those deltas reach further than the PR description says, plus a few smaller things:

1. The whitespace change leaks into the detection loop, not just the mapping (SentenceDetectorME.java:196)
getFirstWS/getFirstNonWS also drive the abbreviation-skip heuristic (line 224, enders.get(i + 1) < fws) and position placement (lines 241/244). Verified by simulation: for "z.\u001Cb. x" the old code scores the first delimiter with the model, the new code skips it — the candidate set changes for existing models, even though SDContextGenerator is untouched. For "Hi.\u0085Next." the accepted position shifts from 3 to 4. So "existing models see exactly the features they were trained on" is true per candidate, but which candidates get evaluated changes. Suggestion: either confine WHITESPACE to mapPositionsToSpans/trimmedSpan, or widen the stated scope and pin the skip-heuristic deltas with tests.

2. Two live whitespace definitions in the toolkit after this PR (SentenceDetectorME.java:315 vs Span.java:265)
Span.trim still uses StringUtil.isWhitespace (includes U+001C–1F, excludes U+0085) — the exact opposite of the new set on those chars. Offset consumers notice: e.g. BratDocumentParser.java:87 does coveredIndexes.get(sentence.getStart()), and a NEL-adjacent boundary now yields a different key. Worth either migrating Span.trim/StringUtil too (with deprecation) or explicitly noting the follow-up.

3. Control-only input now yields a "sentence" (SentenceDetectorME.java:282)
"\u001C\u001C" (no EOS chars → starts.length == 0 branch): old code returned new Span[0], new code returns [Span(0,2)] with prob 1.0. Not pinned by any new test — informationSeparatorsAreContentNotWhitespace only covers a mid-text separator with hand-fed positions.

4. mapPositionsToSpans breaks its own javadoc contract in the zero-positions branch (SentenceDetectorME.java:281)
Javadoc says probs is "mutated so it mirrors the returned spans", but that branch never clears the list — a non-empty input list leaves stale entries. Latent for sentPosDetect (fresh list each call), real for the package-visible seam. Note: the clear() belongs inside that branch, not at method entry (the main path reads probs.get(si) first).

5. Minor cleanups

  • keptProbs is redundant — every kept Span already carries its prob, so the final rebuild can iterate spans with getProb(); that removes the hand-maintained parallel bookkeeping this refactor set out to eliminate (line 291).
  • The trim-skip-attach pattern exists in three copies (lines 281, 292, 301); also, the zero-positions branch returns a span whose getProb() is 0.0 while the javadoc claims every span has its probability attached.
  • WHITESPACE.contains(s.charAt(pos)) feeds UTF-16 code units to a code-point API. Benign today (all Unicode White_Space members are BMP), but a codePointAt loop or a comment would match CharClass's documented discipline (line 316).

@krickert

krickert commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Hi @krickert,

I didn't have time to deeply review this yet, so here you get some Fable 5 output — take it with a grain of salt. I'll read it myself next week (human in the loop).

TL;DR from the model: The refactoring itself looks sound and there are no public API breaks (nothing changed signature or visibility; mapPositionsToSpans is package-private). The behavioral deltas are confined to inputs containing U+0085 or U+001C–U+001F — plain ASCII/ordinary Unicode is unaffected. But two of those deltas reach further than the PR description says, plus a few smaller things:

1. The whitespace change leaks into the detection loop, not just the mapping (SentenceDetectorME.java:196) getFirstWS/getFirstNonWS also drive the abbreviation-skip heuristic (line 224, enders.get(i + 1) < fws) and position placement (lines 241/244). Verified by simulation: for "z.\u001Cb. x" the old code scores the first delimiter with the model, the new code skips it — the candidate set changes for existing models, even though SDContextGenerator is untouched. For "Hi.\u0085Next." the accepted position shifts from 3 to 4. So "existing models see exactly the features they were trained on" is true per candidate, but which candidates get evaluated changes. Suggestion: either confine WHITESPACE to mapPositionsToSpans/trimmedSpan, or widen the stated scope and pin the skip-heuristic deltas with tests.

2. Two live whitespace definitions in the toolkit after this PR (SentenceDetectorME.java:315 vs Span.java:265) Span.trim still uses StringUtil.isWhitespace (includes U+001C–1F, excludes U+0085) — the exact opposite of the new set on those chars. Offset consumers notice: e.g. BratDocumentParser.java:87 does coveredIndexes.get(sentence.getStart()), and a NEL-adjacent boundary now yields a different key. Worth either migrating Span.trim/StringUtil too (with deprecation) or explicitly noting the follow-up.

3. Control-only input now yields a "sentence" (SentenceDetectorME.java:282) "\u001C\u001C" (no EOS chars → starts.length == 0 branch): old code returned new Span[0], new code returns [Span(0,2)] with prob 1.0. Not pinned by any new test — informationSeparatorsAreContentNotWhitespace only covers a mid-text separator with hand-fed positions.

4. mapPositionsToSpans breaks its own javadoc contract in the zero-positions branch (SentenceDetectorME.java:281) Javadoc says probs is "mutated so it mirrors the returned spans", but that branch never clears the list — a non-empty input list leaves stale entries. Latent for sentPosDetect (fresh list each call), real for the package-visible seam. Note: the clear() belongs inside that branch, not at method entry (the main path reads probs.get(si) first).

5. Minor cleanups

  • keptProbs is redundant — every kept Span already carries its prob, so the final rebuild can iterate spans with getProb(); that removes the hand-maintained parallel bookkeeping this refactor set out to eliminate (line 291).
  • The trim-skip-attach pattern exists in three copies (lines 281, 292, 301); also, the zero-positions branch returns a span whose getProb() is 0.0 while the javadoc claims every span has its probability attached.
  • WHITESPACE.contains(s.charAt(pos)) feeds UTF-16 code units to a code-point API. Benign today (all Unicode White_Space members are BMP), but a codePointAt loop or a comment would match CharClass's documented discipline (line 316).

This is all great feedback - I'll double check but this is insanely specific and really good advice. Fable it up as much as you'd like.

@krickert

krickert commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

All five points are addressed in 559ed65 (one commit, squashed). Point-by-point:

1. Whitespace change leaking into the detection loop - kept, deliberately, and now documented + pinned. After going back and forth on this I did not confine WHITESPACE to the mapping: the whole point of the OPENNLP-1850/1852 direction is a single standards-sourced whitespace definition (UCD White_Space), and reintroducing StringUtil.isWhitespace for the loop would put two definitions inside one method. Instead the scope is stated at the WHITESPACE constant (it drives the skip heuristic and position placement, not just span trimming), and both loop deltas are pinned with model-driven tests:

  • "This is a test.\u001CThere…" - separator glued to the delimiter; with useTokenEnd the second sentence now starts after the token (There), without it the separator rides along as leading content
  • "z.\u001Cb. x" - the skip heuristic now merges the first delimiter into the multi-period token and only the second one is scored

Per-candidate feature generation (SDContextGenerator) is untouched, so any candidate the model does evaluate sees exactly the features it was trained on. The PR description's scope claim was too narrow - the deltas are candidate-level for these control characters, and that's intended.

2. Two live whitespace definitions (Span.trim / StringUtil) - real, and intentionally out of scope here. The StringUtil whitespace migration is already tracked as a scoreboard item under OPENNLP-1852; migrating Span.trim belongs there (it's public API used well beyond sentdetect, so it deserves its own deprecation path rather than riding along on this PR).

3. Control-only input now yields a "sentence" - kept and pinned. It's the whole-text counterpart of the already-pinned "information separators are content" delta: if U+001C is content mid-text, "\u001C\u001C" can't trim to nothing. Tested through both the mapping seam and sentPosDetect (span (0,2), prob 1.0, probs() aligned).

4. probs contract in the zero-positions branch - fixed by removing the branch. The zero-positions case is folded into the main flow (one loop over positions + a shared tail for the remainder), and the probs list is rebuilt from the probabilities the spans themselves carry. Stale caller entries are cleared in every path, and the whole-text span now actually carries its 1.0 via getProb() instead of the Span default 0.0. Both tested, including stale-list inputs.

5. Minor cleanups - all taken: keptProbs is gone (spans are the single source of truth), the trim-skip-attach triplication collapsed into one addTrimmedSpan helper, and the cursors (getFirstWS/getFirstNonWS/trimmedSpan) now scan with codePointAt/codePointBefore + charCount per the CharClass code-point discipline - with supplementary-plane tests (U+1D518/U+1D51E) at span edges and end-to-end through a trained model.

Mapping suite went from 14 to 21 tests; full sentdetect suite (78) and the opennlp-runtime verify (incl. checkstyle) are green.

@mawiesne mawiesne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thx @krickert and team. Please address the three comments below. Next, we can proceed, once that is in.

* @return The trimmed spans, in order, each carrying its probability via
* {@link Span#getProb()}.
*/
static Span[] mapPositionsToSpans(CharSequence s, int[] starts, List<Double> probs) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method should be declared private and there is no need to have it static. Please adjust it accordingly.

}
// Appends [start, end) as a span with the given probability attached, unless it is
// whitespace-only and trims to nothing.
private static void addTrimmedSpan(List<Span> spans, CharSequence s, int start, int end,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this helper method there is no need to have it static. Please adjust it accordingly.

// Returns [start, end) with Unicode White_Space trimmed from both edges, or null when nothing
// remains. Scans by code point, matching the CharClass discipline; all current White_Space
// members are BMP, but this keeps surrogate pairs at the edges intact by construction.
private static Span trimmedSpan(CharSequence s, int start, int end) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this helper method there is no need to have it static. Please adjust it accordingly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mawiesne On static: agreed, mapPositionsToSpans, addTrimmedSpan, and trimmedSpan hold no instance state, so they are now instance methods (f59954a).

On private: I kept mapPositionsToSpans package-private and documented why in its javadoc. It is exercised by unit tests that reach mapping branches sentPosDetect cannot produce, and OpenNLP ships no mocking framework and uses no reflection in its tests, so package visibility is the only way to keep that coverage. Making it private would remove these tests:

Lose coverage (branches unreachable through sentPosDetect):

  • mapPositionsToSpansDropsAWhitespaceOnlyCandidateWithItsProbability: the span and its probability are dropped together; the detection loop's position invariants never yield a whitespace-only candidate.
  • mapPositionsToSpansClearsStaleProbsInTheZeroPositionsBranch: stale caller entries are cleared in the zero-positions branch; production always passes a fresh probs list.
  • mapPositionsToSpansTrimsTheFullUnicodeWhitespaceSet: a NEL + NBSP + LS run trimmed at a supplied mid-text position.
  • informationSeparatorsAreContentNotWhitespace: U+001C..U+001F stay as span-edge content; the public separator test covers candidate placement, not edge trimming.

Redundant with public-API tests (no coverage lost):

  • mapPositionsToSpansHandlesNoPositions: covered by inputWithoutEndOfSentenceCharactersIsOneTrimmedSpan.
  • mapPositionsToSpansAttachesProbabilityOneInTheZeroPositionsBranch: assertable via probs() on a no-end-of-sentence input.
  • controlOnlyInputIsOneSpanOfContent: already carries a public-API assertion in the same test.
  • mapPositionsToSpansPreservesSupplementaryCharactersAtSpanEdges: covered by supplementaryCharactersFlowThroughTheDetector.

I'd lean on keeping it package-private, with the javadoc note explaining the visibility (which is done). Otherwise we'd make it private; I drop the four coverage tests above (and the four redundant ones) and rely on the public-API characterization tests.

Thoughts? Is it OK as is?

krickert added 4 commits July 6, 2026 06:25
Pins the current behavior of SentenceDetectorME.sentPosDetect's mapping
from accepted end-of-sentence positions to sentence spans, before
refactoring it: exact spans and probability alignment for plain,
whitespace-padded, multi-delimiter, NBSP, CRLF, whitespace-only, empty,
and no-delimiter input, the free-standing-delimiter islands that map to
one-character spans, and the current treatment of the next line control
(U+0085), which the mapping does not recognize as whitespace. Every
expectation was captured from the behavior of the current
implementation, with useTokenEnd both enabled and disabled.
…lass whitespace engine

Refactors the position-to-span mapping of SentenceDetectorME, open since
2011: the mapping now lives in a package-visible mapPositionsToSpans
seam that is directly testable without a trained model, builds spans
and probabilities in lockstep (the old pre-sized array dropped a
whitespace-only candidate's probability by index, which could misalign
the pairing and leave null slots), and trims with the Unicode
White_Space set of CharClass.whitespace() from the normalization engine
instead of StringUtil.isWhitespace.

Behavior deltas, both pinned by tests: the next line control (U+0085)
now counts as whitespace, so it is trimmed from span edges instead of
riding along as sentence content; the U+001C..U+001F information
separators, which are not Unicode White_Space, are no longer trimmed.
Model feature generation (SDContextGenerator) and the break-acceptance
heuristics are deliberately untouched, so existing models see exactly
the features they were trained on.

The characterization suite from the previous commit passes unchanged
except the U+0085 expectation, updated to the corrected behavior; four
new tests cover the mapping seam directly, including the
whitespace-only-candidate alignment guarantee that is unreachable
through the public API.
…tection-loop scope

Addresses the review feedback on the span mapping refactoring in one
pass, keeping the single Unicode White_Space definition (no return to
StringUtil.isWhitespace anywhere).

Probs contract of mapPositionsToSpans. The zero-positions branch
violated the method's own javadoc twice: it never cleared entries a
caller left in the probs list, and the whole-text span it returned
carried the Span default probability of 0.0 instead of the 1.0 the
list reported. The branch is folded into the main flow: one loop
appends the trimmed span per accepted position, a shared tail appends
the text after the last position (the whole text when there are no
positions), and the probs list is rebuilt from the probabilities the
spans themselves carry. That also removes the keptProbs bookkeeping
and the three copies of the trim-skip-attach pattern, so spans and
probabilities cannot drift apart by construction, in any branch.

Code-point cursors. CharClass.contains takes a code point, but the
whitespace cursors (getFirstWS, getFirstNonWS, trimmedSpan) fed it
UTF-16 code units from charAt. Benign today (every White_Space member
is in the BMP, unpaired surrogates are never members), but it relied
on that property silently and broke the documented code-point
discipline of the CharClass engine. All three cursors now scan with
codePointAt/codePointBefore and charCount stepping, so surrogate pairs
are one unit by construction. No behavior change for any current
input.

Detection-loop scope, stated and pinned. The White_Space set does not
only trim span edges: through getFirstWS/getFirstNonWS it drives the
delimiter-run skip heuristic and the placement of accepted
sentence-start positions, so around U+0085 and U+001C..U+001F the
candidate positions themselves can differ from pre-OPENNLP-205
releases, which is wider than a mapping-only change. This is intended:
the detector keeps one whitespace definition across both stages, per
the standards-sourced character class direction of OPENNLP-1850/1852.
The scope is documented at the WHITESPACE constant and both loop
deltas are pinned with model-driven tests ("test.\u001CThere" token
placement with and without useTokenEnd; "z.\u001Cb. x" candidate
skip). Per-candidate feature generation (SDContextGenerator) is
untouched, so any candidate the model evaluates sees exactly the
features it was trained on.

Behavior delta, pinned deliberately: control-only input such as
"\u001C\u001C" has no end-of-sentence character and used to trim to
nothing; information separators are content under White_Space, so it
is now one sentence span with probability 1.0. This is the whole-text
counterpart of the already pinned
informationSeparatorsAreContentNotWhitespace delta.

New tests cover the stale-probs clearing for content and blank input,
the attached probability of the zero-positions span, control-only
input through both the mapping seam and sentPosDetect, supplementary
characters (U+1D518, U+1D51E) at span edges and end-to-end through a
trained model, and the two detection-loop deltas.
Addresses mawiesne's review on #1141:
- mapPositionsToSpans, addTrimmedSpan, and trimmedSpan hold no instance state,
  so they are now instance methods rather than static.
- mapPositionsToSpans stays package-private (not private): several mapping
  branches, the whitespace-only-candidate drop and the stale-probs reset in the
  zero-positions branch, are not reachable through the public sentPosDetect API,
  and the project ships no mocking framework and uses no reflection in tests, so
  package visibility is the only way to keep that branch coverage. The deliberate
  visibility is documented in the method javadoc.
- The mapping tests call the helper on an instance instead of statically.
@mawiesne mawiesne requested review from rzo1 and removed request for rzo1 July 6, 2026 12:33
@mawiesne mawiesne merged commit d5d37dc into main Jul 6, 2026
10 checks passed
@mawiesne mawiesne deleted the OPENNLP-205 branch July 6, 2026 17:17
krickert added a commit that referenced this pull request Jul 7, 2026
…alSentenceModel

The Jenkins eval-tests-configurable job failed evalSentenceModel after
OPENNLP-205 (d5d37dc, #1141) landed: it pins a SHA digest of every
sentence SentenceDetectorME produces over the Leipzig
eng_news_2010_300K corpus using the SourceForge en-sent.bin model, and
OPENNLP-205 deliberately changed which characters SentenceDetectorME
trims as whitespace (Unicode White_Space, not StringUtil.isWhitespace).
The corpus contains at least one boundary affected by that documented
delta, so the digest changed. No other test in the class touches
SentenceDetectorME, and every other digest in the 138-test run was
unaffected, which is consistent with an isolated, expected fallout of
the OPENNLP-205 fix rather than a regression.
mawiesne pushed a commit that referenced this pull request Jul 8, 2026
…nents (#1105)

* OPENNLP-1865 Offset-safe, Unicode-aware input normalization in the DL components
* For broader context, see epic: https://issues.apache.org/jira/browse/OPENNLP-1850

Follow-up:
* OPENNLP-205: Update the stale SourceForgeModelEval golden hash for evalSentenceModel
The Jenkins eval-tests-configurable job failed evalSentenceModel after
OPENNLP-205 (d5d37dc, #1141) landed: it pins a SHA digest of every
sentence SentenceDetectorME produces over the Leipzig
eng_news_2010_300K corpus using the SourceForge en-sent.bin model, and
OPENNLP-205 deliberately changed which characters SentenceDetectorME
trims as whitespace (Unicode White_Space, not StringUtil.isWhitespace).
The corpus contains at least one boundary affected by that documented
delta, so the digest changed. No other test in the class touches
SentenceDetectorME, and every other digest in the 138-test run was
unaffected, which is consistent with an isolated, expected fallout of
the OPENNLP-205 fix rather than a regression.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

java Pull requests that update Java code tests Pull requests that add or update test code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants