This is an automated email from the ASF dual-hosted git repository.

mawiesne pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp.git


The following commit(s) were added to refs/heads/main by this push:
     new d5d37dca2 OPENNLP-205: Refactor the end-of-sentence position to span 
mapping in SentenceDetectorME (#1141)
d5d37dca2 is described below

commit d5d37dca2088aa41ec167c863ee368481b4a2bcc
Author: Kristian Rickert <[email protected]>
AuthorDate: Mon Jul 6 13:17:48 2026 -0400

    OPENNLP-205: Refactor the end-of-sentence position to span mapping in 
SentenceDetectorME (#1141)
---
 .../tools/sentdetect/SentenceDetectorME.java       | 161 ++++++-----
 .../SentenceDetectorMESpanMappingTest.java         | 308 +++++++++++++++++++++
 2 files changed, 404 insertions(+), 65 deletions(-)

diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/sentdetect/SentenceDetectorME.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/sentdetect/SentenceDetectorME.java
index 074db9625..7be0d10b1 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/sentdetect/SentenceDetectorME.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/sentdetect/SentenceDetectorME.java
@@ -42,6 +42,7 @@ import opennlp.tools.util.Span;
 import opennlp.tools.util.StringList;
 import opennlp.tools.util.StringUtil;
 import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.normalizer.CharClass;
 
 /**
  * A sentence detector for splitting up raw text into sentences.
@@ -185,15 +186,36 @@ public class SentenceDetectorME implements 
SentenceDetector, Probabilistic {
     return sentences;
   }
 
+  // The whitespace definition of the sentence detector: the Unicode 
White_Space set
+  // (OPENNLP-205). It drives both the detection loop of sentPosDetect (the 
delimiter-run skip
+  // heuristic and the placement of sentence-start positions) and the 
position-to-span mapping,
+  // so both stages agree on what separates sentences. Unlike the previously 
used
+  // StringUtil.isWhitespace, this covers the next line control (U+0085) and 
does not treat the
+  // U+001C..U+001F information separators as whitespace; around those 
characters the candidate
+  // positions themselves can differ from pre-OPENNLP-205 releases, not only 
the span edges.
+  // Both deltas are pinned in SentenceDetectorMESpanMappingTest. Model 
feature generation
+  // (SDContextGenerator) is not affected.
+  private static final CharClass WHITESPACE = CharClass.whitespace();
+
   private int getFirstWS(CharSequence s, int pos) {
-    while (pos < s.length() && !StringUtil.isWhitespace(s.charAt(pos)))
-      pos++;
+    while (pos < s.length()) {
+      final int cp = Character.codePointAt(s, pos);
+      if (WHITESPACE.contains(cp)) {
+        break;
+      }
+      pos += Character.charCount(cp);
+    }
     return pos;
   }
 
   private int getFirstNonWS(CharSequence s, int pos) {
-    while (pos < s.length() && StringUtil.isWhitespace(s.charAt(pos)))
-      pos++;
+    while (pos < s.length()) {
+      final int cp = Character.codePointAt(s, pos);
+      if (!WHITESPACE.contains(cp)) {
+        break;
+      }
+      pos += Character.charCount(cp);
+    }
     return pos;
   }
 
@@ -244,77 +266,86 @@ public class SentenceDetectorME implements 
SentenceDetector, Probabilistic {
     }
 
     int[] starts = ArrayMath.toIntArray(positions);
+    Span[] spans = mapPositionsToSpans(s, starts, localProbs);
 
-    // string does not contain sentence end positions
-    if (starts.length == 0) {
-
-      // remove leading and trailing whitespace
-      int start = 0;
-      int end = s.length();
-
-      while (start < s.length() && StringUtil.isWhitespace(s.charAt(start)))
-        start++;
-
-      while (end > 0 && StringUtil.isWhitespace(s.charAt(end - 1)))
-        end--;
-
-      if (end - start > 0) {
-        localProbs.add(1d);
-        state.sentProbs = localProbs;
-        return new Span[] {new Span(start, end)};
-      }
-      else {
-        state.sentProbs = localProbs;
-        return new Span[0];
-      }
-    }
-
-    // Convert the sentence end indexes to spans
+    // Publish for backward-compatible probs() access (last-writer-wins under 
concurrency)
+    state.sentProbs = localProbs;
 
-    boolean leftover = starts[starts.length - 1] != s.length();
-    Span[] spans = new Span[leftover ? starts.length + 1 : starts.length];
+    return spans;
+  }
 
+  /**
+   * Maps accepted sentence-start positions to trimmed sentence {@link Span}s, 
the core of the
+   * end-of-sentence position to span mapping (OPENNLP-205). Not part of the 
public API; the
+   * package visibility is deliberate, kept so the mapping can be exercised 
directly in tests.
+   * Several branches (the whitespace-only candidate that keeps {@code probs} 
aligned, and the
+   * stale-{@code probs} reset in the zero-positions branch) are not reachable 
through the public
+   * {@code sentPosDetect} entry point.
+   *
+   * <p>Each span runs from the previous position (or the text start) to the 
next position, with
+   * Unicode {@code White_Space} trimmed from both edges. A candidate that is 
whitespace-only is
+   * dropped together with its probability, so {@code probs} and the returned 
spans always stay
+   * aligned; text after the last position becomes a final span with 
probability {@code 1.0}.
+   * With no positions at all, the whole text is one trimmed span with 
probability {@code 1.0},
+   * or no span when it is blank.</p>
+   *
+   * @param s      The text the positions refer to.
+   * @param starts The accepted sentence-start positions, ascending.
+   * @param probs  The probability per position; mutated (in every branch) so 
it exactly mirrors
+   *               the probabilities of the returned spans.
+   * @return The trimmed spans, in order, each carrying its probability via
+   *         {@link Span#getProb()}.
+   */
+  Span[] mapPositionsToSpans(CharSequence s, int[] starts, List<Double> probs) 
{
+    final List<Span> spans = new ArrayList<>(starts.length + 1);
+    int sentStart = 0;
     for (int si = 0; si < starts.length; si++) {
-      int start;
-
-      if (si == 0) {
-        start = 0;
-      }
-      else {
-        start = starts[si - 1];
-      }
+      // A candidate might contain only whitespace; it is dropped together 
with its probability,
+      // which keeps the spans and the probabilities aligned by construction.
+      addTrimmedSpan(spans, s, sentStart, starts[si], probs.get(si));
+      sentStart = starts[si];
+    }
+    // The text after the last position; with no positions at all this is the 
whole text, which
+    // covers input without any accepted sentence end.
+    if (starts.length == 0 || starts[starts.length - 1] != s.length()) {
+      addTrimmedSpan(spans, s, sentStart, s.length(), 1d);
+    }
+    probs.clear();
+    for (Span span : spans) {
+      probs.add(span.getProb());
+    }
+    return spans.toArray(new Span[0]);
+  }
 
-      // A span might contain only white spaces, in this case the length of
-      // the span will be zero after trimming and should be ignored.
-      Span span = new Span(start, starts[si]).trim(s);
-      if (span.length() > 0) {
-        spans[si] = span;
-      }
-      else {
-        localProbs.remove(si);
-      }
+  // Appends [start, end) as a span with the given probability attached, 
unless it is
+  // whitespace-only and trims to nothing.
+  private void addTrimmedSpan(List<Span> spans, CharSequence s, int start, int 
end,
+      double prob) {
+    Span span = trimmedSpan(s, start, end);
+    if (span != null) {
+      spans.add(new Span(span, prob));
     }
+  }
 
-    if (leftover) {
-      Span span = new Span(starts[starts.length - 1], s.length()).trim(s);
-      if (span.length() > 0) {
-        spans[spans.length - 1] = span;
-        localProbs.add(1d);
+  // 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 Span trimmedSpan(CharSequence s, int start, int end) {
+    while (start < end) {
+      final int cp = Character.codePointAt(s, start);
+      if (!WHITESPACE.contains(cp)) {
+        break;
       }
+      start += Character.charCount(cp);
     }
-    /*
-     * set the prob for each span
-     */
-    for (int i = 0; i < spans.length; i++) {
-      double prob = localProbs.get(i);
-      spans[i] = new Span(spans[i], prob);
-
+    while (end > start) {
+      final int cp = Character.codePointBefore(s, end);
+      if (!WHITESPACE.contains(cp)) {
+        break;
+      }
+      end -= Character.charCount(cp);
     }
-
-    // Publish for backward-compatible probs() access (last-writer-wins under 
concurrency)
-    state.sentProbs = localProbs;
-
-    return spans;
+    return end > start ? new Span(start, end) : null;
   }
 
   /**
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
new file mode 100644
index 000000000..d081c1839
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
@@ -0,0 +1,308 @@
+/*
+ * 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 opennlp.tools.sentdetect;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.dictionary.Dictionary;
+import opennlp.tools.util.Span;
+
+/**
+ * Characterization tests for the end-of-sentence position to {@link Span} 
mapping of
+ * {@link SentenceDetectorME#sentPosDetect(CharSequence)} (OPENNLP-205). These 
pin the mapping
+ * behavior, including edge cases, so the mapping can be refactored without 
silently changing
+ * results; every expectation was captured from the behavior of the 
pre-refactoring implementation.
+ */
+public class SentenceDetectorMESpanMappingTest extends 
AbstractSentenceDetectorTest {
+
+  private static final String SECOND = "There are many tests, this is the 
second.";
+
+  private static SentenceDetectorME tokenEnd;
+  private static SentenceDetectorME noTokenEnd;
+
+  @BeforeAll
+  static void prepareResources() throws IOException {
+    Dictionary abb = loadAbbDictionary(Locale.ENGLISH);
+    tokenEnd = new SentenceDetectorME(
+        train(new SentenceDetectorFactory("eng", true, abb, null), 
Locale.ENGLISH));
+    noTokenEnd = new SentenceDetectorME(
+        train(new SentenceDetectorFactory("eng", false, abb, null), 
Locale.ENGLISH));
+  }
+
+  private static void assertSpans(SentenceDetectorME sd, String input, Span... 
expected) {
+    Span[] actual = sd.sentPosDetect(input);
+    Assertions.assertEquals(expected.length, actual.length,
+        () -> "span count for: " + input);
+    for (int i = 0; i < expected.length; i++) {
+      Assertions.assertEquals(expected[i].getStart(), actual[i].getStart(), 
"start of span " + i);
+      Assertions.assertEquals(expected[i].getEnd(), actual[i].getEnd(), "end 
of span " + i);
+    }
+    // The probability list must stay aligned with the returned spans, 
whatever the mapping drops.
+    Assertions.assertEquals(actual.length, sd.probs().length,
+        () -> "probs alignment for: " + input);
+  }
+
+  @Test
+  void twoSentencesMapToExactSpans() {
+    String input = "This is a test. " + SECOND;
+    assertSpans(tokenEnd, input, new Span(0, 15), new Span(16, 57));
+    assertSpans(noTokenEnd, input, new Span(0, 15), new Span(16, 57));
+  }
+
+  @Test
+  void leadingAndTrailingWhitespaceIsExcludedFromSpans() {
+    String input = "   This is a test.   " + SECOND + "   ";
+    assertSpans(tokenEnd, input, new Span(3, 18), new Span(21, 62));
+    assertSpans(noTokenEnd, input, new Span(3, 18), new Span(21, 62));
+  }
+
+  @Test
+  void multiCharacterDelimiterRunStaysWithItsSentence() {
+    String input = "This is great!!! " + SECOND;
+    assertSpans(tokenEnd, input, new Span(0, 16), new Span(17, 58));
+    assertSpans(noTokenEnd, input, new Span(0, 16), new Span(17, 58));
+  }
+
+  @Test
+  void noBreakSpaceBetweenSentencesSplitsAndTrims() {
+    // NBSP is SPACE_SEPARATOR, recognized as whitespace by the mapping both 
before and after the
+    // OPENNLP-205 refactoring; the second span must start at the word, not 
the NBSP.
+    String input = "This is a test.\u00A0" + SECOND;
+    assertSpans(tokenEnd, input, new Span(0, 15), new Span(16, 57));
+  }
+
+  @Test
+  void carriageReturnLineFeedBetweenSentences() {
+    String input = "This is a test.\r\n" + SECOND;
+    assertSpans(tokenEnd, input, new Span(0, 15), new Span(17, 58));
+  }
+
+  @Test
+  void whitespaceOnlyAndEmptyInputYieldNoSpans() {
+    assertSpans(tokenEnd, "   ");
+    assertSpans(tokenEnd, "");
+  }
+
+  @Test
+  void inputWithoutEndOfSentenceCharactersIsOneTrimmedSpan() {
+    assertSpans(tokenEnd, "  no end of sentence marker here  ", new Span(2, 
32));
+  }
+
+  @Test
+  void delimiterIslandsRemainSeparateSpans() {
+    // Pinned, not endorsed: a free-standing delimiter between sentences is 
reported as its own
+    // one-character span with its own probability. Changing this would change 
public output, so
+    // the OPENNLP-205 mapping refactoring must preserve it.
+    String input = "This is a test. . " + SECOND;
+    assertSpans(tokenEnd, input, new Span(0, 15), new Span(16, 17), new 
Span(18, 59));
+    assertSpans(noTokenEnd, input, new Span(0, 15), new Span(16, 17), new 
Span(18, 59));
+    String runs = "This is a test.  .  .  " + SECOND;
+    assertSpans(tokenEnd, runs,
+        new Span(0, 15), new Span(17, 18), new Span(20, 21), new Span(23, 64));
+  }
+
+  @Test
+  void nextLineControlBetweenSpacedSentencesIsTrimmed() {
+    // U+0085 NEL is Unicode White_Space. The pre-refactoring mapping missed it
+    // (StringUtil.isWhitespace does not cover it), so the second span used to 
start at the NEL
+    // and carry it as leading content; with the mapping on the CharClass 
Unicode White_Space set
+    // (OPENNLP-205), the span starts at the word.
+    String input = "This is a test. \u0085 " + SECOND;
+    assertSpans(tokenEnd, input, new Span(0, 15), new Span(18, 59));
+  }
+
+  @Test
+  void bareNextLineControlDoesNotSplit() {
+    // With no ordinary space around the delimiter, the model's context 
(unchanged by
+    // OPENNLP-205; feature generation must stay stable for existing models) 
does not produce a
+    // split here, so the text stays one span covering both parts.
+    String input = "This is a test.\u0085" + SECOND;
+    assertSpans(tokenEnd, input, new Span(0, 57));
+  }
+
+  @Test
+  void mapPositionsToSpansHandlesNoPositions() {
+    java.util.List<Double> probs = new java.util.ArrayList<>();
+    Assertions.assertEquals(0,
+        tokenEnd.mapPositionsToSpans("  \t ", new int[0], probs).length);
+    Assertions.assertTrue(probs.isEmpty());
+    Span[] whole = tokenEnd.mapPositionsToSpans("  some text  ", new int[0], 
probs);
+    Assertions.assertEquals(1, whole.length);
+    Assertions.assertEquals(new Span(2, 11).getStart(), whole[0].getStart());
+    Assertions.assertEquals(new Span(2, 11).getEnd(), whole[0].getEnd());
+    Assertions.assertEquals(java.util.List.of(1d), probs);
+  }
+
+  @Test
+  void mapPositionsToSpansDropsAWhitespaceOnlyCandidateWithItsProbability() {
+    // The whitespace-only-candidate branch is not reachable through 
sentPosDetect's position
+    // invariants, but the mapping guarantees alignment structurally: the span 
and its
+    // probability are dropped together. The old implementation removed the 
probability by index
+    // from a pre-sized array, which could misalign the pairing.
+    java.util.List<Double> probs = new 
java.util.ArrayList<>(java.util.List.of(0.9d, 0.8d));
+    Span[] spans = tokenEnd.mapPositionsToSpans("ab  cd", new int[] {4, 4}, 
probs);
+    Assertions.assertEquals(2, spans.length);
+    Assertions.assertEquals(0, spans[0].getStart());
+    Assertions.assertEquals(2, spans[0].getEnd());
+    Assertions.assertEquals(0.9d, spans[0].getProb());
+    Assertions.assertEquals(4, spans[1].getStart());
+    Assertions.assertEquals(6, spans[1].getEnd());
+    Assertions.assertEquals(1d, spans[1].getProb());
+    Assertions.assertEquals(java.util.List.of(0.9d, 1d), probs);
+  }
+
+  @Test
+  void mapPositionsToSpansTrimsTheFullUnicodeWhitespaceSet() {
+    java.util.List<Double> probs = new 
java.util.ArrayList<>(java.util.List.of(0.7d));
+    Span[] spans = tokenEnd.mapPositionsToSpans(
+        "One.\u0085\u00A0\u2028Two", new int[] {7}, probs);
+    Assertions.assertEquals(2, spans.length);
+    Assertions.assertEquals(0, spans[0].getStart());
+    Assertions.assertEquals(4, spans[0].getEnd());
+    Assertions.assertEquals(7, spans[1].getStart());
+    Assertions.assertEquals(10, spans[1].getEnd());
+  }
+
+  @Test
+  void informationSeparatorsAreContentNotWhitespace() {
+    // Deliberate delta from the old StringUtil-based mapping: the 
U+001C..U+001F information
+    // separators are not Unicode White_Space, so they are no longer trimmed 
from span edges.
+    java.util.List<Double> probs = new 
java.util.ArrayList<>(java.util.List.of(0.7d));
+    Span[] spans = tokenEnd.mapPositionsToSpans("A.\u001C B", new int[] {4}, 
probs);
+    Assertions.assertEquals(2, spans.length);
+    Assertions.assertEquals(0, spans[0].getStart());
+    Assertions.assertEquals(3, spans[0].getEnd()); // includes the separator 
control
+  }
+
+  @Test
+  void mapPositionsToSpansClearsStaleProbsInTheZeroPositionsBranch() {
+    // The probs contract holds in every branch: with no positions, entries a 
caller left in the
+    // list are cleared so the list mirrors the returned spans instead of 
keeping stale values.
+    List<Double> stale = new ArrayList<>(List.of(0.4d, 0.3d));
+    Span[] spans = tokenEnd.mapPositionsToSpans("  some text  ", new int[0], 
stale);
+    Assertions.assertEquals(1, spans.length);
+    Assertions.assertEquals(List.of(1d), stale);
+
+    // Blank input: no spans, and the stale entries are gone as well.
+    List<Double> blankStale = new ArrayList<>(List.of(0.4d));
+    Assertions.assertEquals(0,
+        tokenEnd.mapPositionsToSpans(" \t ", new int[0], blankStale).length);
+    Assertions.assertTrue(blankStale.isEmpty());
+  }
+
+  @Test
+  void mapPositionsToSpansAttachesProbabilityOneInTheZeroPositionsBranch() {
+    // Every returned span carries its probability; the whole-text span of the 
zero-positions
+    // branch is no exception (it used to report the Span default of 0.0 via 
getProb()).
+    List<Double> probs = new ArrayList<>();
+    Span[] spans = tokenEnd.mapPositionsToSpans("no end of sentence", new 
int[0], probs);
+    Assertions.assertEquals(1, spans.length);
+    Assertions.assertEquals(1d, spans[0].getProb());
+    Assertions.assertEquals(List.of(1d), probs);
+  }
+
+  @Test
+  void controlOnlyInputIsOneSpanOfContent() {
+    // Deliberate delta, the whole-text counterpart of
+    // informationSeparatorsAreContentNotWhitespace: information separators 
are content, so
+    // control-only input no longer trims to nothing. The old mapping returned 
no spans here.
+    List<Double> probs = new ArrayList<>();
+    Span[] spans = tokenEnd.mapPositionsToSpans("\u001C\u001C", new int[0], 
probs);
+    Assertions.assertEquals(1, spans.length);
+    Assertions.assertEquals(0, spans[0].getStart());
+    Assertions.assertEquals(2, spans[0].getEnd());
+    Assertions.assertEquals(1d, spans[0].getProb());
+    Assertions.assertEquals(List.of(1d), probs);
+
+    // The same input through the public API: no end-of-sentence characters, 
so the detector
+    // takes the zero-positions branch and reports the control characters as 
one sentence span.
+    assertSpans(tokenEnd, "\u001C\u001C", new Span(0, 2));
+    Assertions.assertArrayEquals(new double[] {1d}, tokenEnd.probs());
+  }
+
+  @Test
+  void mapPositionsToSpansPreservesSupplementaryCharactersAtSpanEdges() {
+    // The trimming cursors scan by code point (CharClass discipline). 
Surrogate pairs at span
+    // edges must survive intact; U+1D518 and U+1D51E are supplementary-plane 
letters.
+    String frakturU = "\uD835\uDD18"; // MATHEMATICAL FRAKTUR CAPITAL U
+    String frakturA = "\uD835\uDD1E"; // MATHEMATICAL FRAKTUR SMALL A
+
+    List<Double> probs = new ArrayList<>();
+    String noPositions = "  " + frakturU + "no end" + frakturA + "  ";
+    Span[] spans = tokenEnd.mapPositionsToSpans(noPositions, new int[0], 
probs);
+    Assertions.assertEquals(1, spans.length);
+    Assertions.assertEquals(frakturU + "no end" + frakturA,
+        spans[0].getCoveredText(noPositions).toString());
+
+    // A position between two sentences that start and end with supplementary 
characters.
+    probs = new ArrayList<>(List.of(0.9d));
+    String twoParts = frakturU + "one. " + frakturA + "two";
+    Span[] parts = tokenEnd.mapPositionsToSpans(twoParts, new int[] {7}, 
probs);
+    Assertions.assertEquals(2, parts.length);
+    Assertions.assertEquals(frakturU + "one.", 
parts[0].getCoveredText(twoParts).toString());
+    Assertions.assertEquals(frakturA + "two", 
parts[1].getCoveredText(twoParts).toString());
+    Assertions.assertEquals(List.of(0.9d, 1d), probs);
+  }
+
+  @Test
+  void supplementaryCharactersFlowThroughTheDetectorUnharmed() {
+    // End-to-end through the trained model: the whitespace cursors of the 
detection loop also
+    // scan by code point, so a surrogate pair adjacent to the sentence 
boundary stays intact.
+    String fraktur = "\uD835\uDD18\uD835\uDD1E"; // two supplementary letters 
as a word
+    String input = "This is a " + fraktur + " test. " + SECOND;
+    Span[] spans = tokenEnd.sentPosDetect(input);
+    Assertions.assertEquals(2, spans.length);
+    Assertions.assertEquals("This is a " + fraktur + " test.",
+        spans[0].getCoveredText(input).toString());
+    Assertions.assertEquals(SECOND, spans[1].getCoveredText(input).toString());
+  }
+
+  @Test
+  void informationSeparatorGluedToTheDelimiterExtendsTheTokenEndBoundary() {
+    // Detection-loop delta of the Unicode White_Space set (OPENNLP-205), 
pinned deliberately:
+    // U+001C directly after the delimiter is content now. With useTokenEnd 
the token containing
+    // the end-of-sentence character runs through "\u001CThere" up to the next 
real whitespace,
+    // and the second sentence starts after that token; without useTokenEnd 
the second sentence
+    // starts at the separator and carries it as leading content. The old 
StringUtil-based
+    // cursors treated U+001C as whitespace and started the second sentence 
directly behind it
+    // in both configurations. Feature generation is untouched; only the 
placement of the
+    // accepted position moved.
+    String input = "This is a test.\u001C" + SECOND;
+    assertSpans(tokenEnd, input, new Span(0, 21), new Span(22, 57));
+    assertSpans(noTokenEnd, input, new Span(0, 15), new Span(15, 57));
+  }
+
+  @Test
+  void 
informationSeparatorInsideAbbreviationLikeTokenChangesTheCandidateSkip() {
+    // Detection-loop delta, pinned deliberately: in "z.\u001Cb. x" the first 
delimiter sits in
+    // front of non-whitespace content (U+001C is content now), so the 
delimiter-run skip
+    // heuristic merges it into the multi-period token and only the second 
delimiter is scored.
+    // The old cursors saw U+001C as whitespace and scored the first delimiter 
as well. The
+    // model splits at the second delimiter, so the multi-period token stays 
one sentence and
+    // "x" becomes the next; the separator is covered as content inside the 
first span.
+    String input = "z.\u001Cb. x";
+    assertSpans(tokenEnd, input, new Span(0, 5), new Span(6, 7));
+    assertSpans(noTokenEnd, input, new Span(0, 5), new Span(6, 7));
+  }
+}

Reply via email to