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

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

commit 7f20e7663d7fb61d8ad4632efa09feef6ad47a71
Author: Kristian Rickert <[email protected]>
AuthorDate: Thu Jul 16 06:27:30 2026 -0400

    OPENNLP-1885: Make the tokenizer graph serializable with computed UIDs, 
name the format constants, document every helper
---
 .../opennlp/tools/tokenize/WordpieceEncoder.java   |  77 ++++++++++--
 .../test/java/opennlp/dl/CreateTokenizerTest.java  |   3 +-
 .../tools/tokenize/ReferenceBertPipeline.java      |   2 +
 .../WordpieceEncoderReferenceSequencesTest.java    | 130 ++++++++-------------
 .../tools/tokenize/WordpieceEncoderTest.java       |  47 +++++---
 opennlp-docs/src/docbkx/tokenizer.xml              |   4 +-
 .../opennlp/subword/sentencepiece/BpeEncoder.java  |  40 ++++---
 .../opennlp/subword/sentencepiece/ByteBuilder.java |   8 +-
 .../subword/sentencepiece/DoubleArrayTrie.java     |  32 ++++-
 .../opennlp/subword/sentencepiece/IntBuilder.java  |   8 +-
 .../subword/sentencepiece/ModelProtoReader.java    | 115 +++++++++++++++---
 .../opennlp/subword/sentencepiece/PieceTrie.java   |  68 +++++++++--
 .../sentencepiece/SentencePieceNormalizer.java     |  41 +++----
 .../sentencepiece/SentencePieceTokenizer.java      |  44 +++++--
 .../subword/sentencepiece/UnigramEncoder.java      |  15 ++-
 .../opennlp/subword/sentencepiece/Utf8Text.java    |   8 ++
 .../sentencepiece/SentencePieceFixtures.java       | 125 ++++++++++++++++++++
 .../SentencePieceModelValidationTest.java          |   6 +-
 .../sentencepiece/SentencePieceParityTest.java     |  65 +----------
 .../SentencePieceRealModelEvalTest.java            |  50 +-------
 .../SentencePieceTokenizerSerializationTest.java   |  71 +++++++++++
 21 files changed, 653 insertions(+), 306 deletions(-)

diff --git 
a/opennlp-api/src/main/java/opennlp/tools/tokenize/WordpieceEncoder.java 
b/opennlp-api/src/main/java/opennlp/tools/tokenize/WordpieceEncoder.java
index ab016bbaa..b130f45dc 100644
--- a/opennlp-api/src/main/java/opennlp/tools/tokenize/WordpieceEncoder.java
+++ b/opennlp-api/src/main/java/opennlp/tools/tokenize/WordpieceEncoder.java
@@ -40,7 +40,8 @@ import java.util.Set;
  * <p>Ids follow the line-number convention of BERT {@code vocab.txt} files: 
with the list
  * constructors a piece's id is its index, and with the map constructor the 
ids are given
  * explicitly. The classification, separator, and unknown tokens must all be 
present in the
- * vocabulary, because every emitted piece must have an id.</p>
+ * vocabulary, because every emitted piece must have an id. Vocabulary entries 
starting with
+ * {@code ##} are continuation pieces, matching a word's interior rather than 
its start.</p>
  *
  * <p>Instances are immutable and safe for concurrent use by multiple 
threads.</p>
  *
@@ -48,6 +49,10 @@ import java.util.Set;
  */
 public final class WordpieceEncoder implements SubwordTokenizer {
 
+  // The wordpiece vocabulary convention: a piece with this prefix continues 
the current word,
+  // so it can only match after the word's first piece.
+  private static final String CONTINUATION_PREFIX = "##";
+
   // The reference implementation's limit: longer words become the unknown 
piece.
   private static final int MAX_WORD_CHARACTERS = 100;
 
@@ -144,6 +149,15 @@ public final class WordpieceEncoder implements 
SubwordTokenizer {
     this.unknownId = requiredId(byPiece, unknownToken);
   }
 
+  /**
+   * Converts an ordered vocabulary list into the piece-to-id mapping, 
assigning each piece its
+   * index as the id.
+   *
+   * @param vocabulary The ordered vocabulary.
+   * @return The piece-to-id mapping.
+   * @throws IllegalArgumentException Thrown if the list is null or contains a 
null or duplicate
+   *     entry.
+   */
   private static Map<String, Integer> byPiece(List<String> vocabulary) {
     if (vocabulary == null) {
       throw new IllegalArgumentException("The vocabulary must not be null.");
@@ -162,6 +176,14 @@ public final class WordpieceEncoder implements 
SubwordTokenizer {
     return byPiece;
   }
 
+  /**
+   * Looks up the id of a special token that must be present in the vocabulary.
+   *
+   * @param ids          The piece-to-id mapping.
+   * @param specialToken The token to look up.
+   * @return The token's id.
+   * @throws IllegalArgumentException Thrown if the token is not in the 
vocabulary.
+   */
   private static int requiredId(Map<String, Integer> ids, String specialToken) 
{
     final Integer id = ids.get(specialToken);
     if (id == null) {
@@ -171,11 +193,7 @@ public final class WordpieceEncoder implements 
SubwordTokenizer {
     return id;
   }
 
-  /**
-   * {@inheritDoc}
-   *
-   * @throws IllegalArgumentException Thrown if {@code text} is null.
-   */
+  /** {@inheritDoc} */
   @Override
   public List<SubwordPiece> encode(CharSequence text) {
     if (text == null) {
@@ -236,7 +254,7 @@ public final class WordpieceEncoder implements 
SubwordTokenizer {
       while (start < end) {
         String substring = new String(mapped.chars, start, end - start);
         if (start > from) {
-          substring = "##" + substring;
+          substring = CONTINUATION_PREFIX + substring;
         }
         if (vocabulary.contains(substring)) {
           wordPieces.add(new SubwordPiece(substring, ids.get(substring),
@@ -268,12 +286,24 @@ public final class WordpieceEncoder implements 
SubwordTokenizer {
     private int[] ends;
     private int length;
 
+    /**
+     * Instantiates an empty mapped text.
+     *
+     * @param capacity The initial capacity hint in chars.
+     */
     private MappedText(int capacity) {
       chars = new char[capacity];
       starts = new int[capacity];
       ends = new int[capacity];
     }
 
+    /**
+     * Appends one char with the original-text range it came from.
+     *
+     * @param c             The char to append.
+     * @param originalStart The inclusive original-text start of the char.
+     * @param originalEnd   The exclusive original-text end of the char.
+     */
     private void add(char c, int originalStart, int originalEnd) {
       if (length == chars.length) {
         final int capacity = Math.max(16, length * 2);
@@ -287,6 +317,13 @@ public final class WordpieceEncoder implements 
SubwordTokenizer {
       length++;
     }
 
+    /**
+     * Appends every char of a string, all sharing one original-text range.
+     *
+     * @param s             The string to append.
+     * @param originalStart The inclusive original-text start shared by all 
chars.
+     * @param originalEnd   The exclusive original-text end shared by all 
chars.
+     */
     private void add(String s, int originalStart, int originalEnd) {
       for (int i = 0; i < s.length(); i++) {
         add(s.charAt(i), originalStart, originalEnd);
@@ -385,8 +422,20 @@ public final class WordpieceEncoder implements 
SubwordTokenizer {
     return out;
   }
 
+  /**
+   * Lower cases and accent-strips one non-space run, emitting per-character 
ranges when the
+   * transformation is reproducible per code point and the run's full range 
otherwise.
+   *
+   * @param in   The input text with per-character ranges.
+   * @param from The inclusive start of the run in {@code in}.
+   * @param to   The exclusive end of the run in {@code in}.
+   * @param out  The output text to append to.
+   */
   private static void transformRun(MappedText in, int from, int to, MappedText 
out) {
     final String run = new String(in.chars, from, to - from);
+    // Locale.ROOT lower casing is the reference behavior of BERT's 
do_lower_case: the reference
+    // pipeline applies the full locale-independent Unicode case mappings 
(including one-to-many
+    // ones like the dotted capital I), which a per-code-point mapping cannot 
reproduce.
     final String content = stripAccents(run.toLowerCase(Locale.ROOT));
 
     // Rerun per code point to learn how many output chars each input code 
point produces.
@@ -419,6 +468,13 @@ public final class WordpieceEncoder implements 
SubwordTokenizer {
     }
   }
 
+  /**
+   * Removes combining marks after NFD decomposition, the accent stripping of 
BERT's
+   * {@code do_lower_case} mode.
+   *
+   * @param text The text to strip.
+   * @return The text without non-spacing marks.
+   */
   private static String stripAccents(String text) {
     final String decomposed = Normalizer.normalize(text, Normalizer.Form.NFD);
     final StringBuilder stripped = new StringBuilder(decomposed.length());
@@ -430,6 +486,13 @@ public final class WordpieceEncoder implements 
SubwordTokenizer {
     return stripped.toString();
   }
 
+  /**
+   * Reads the code point at an index, joining a surrogate pair when one 
starts there.
+   *
+   * @param text  The text to read from.
+   * @param index The char index to read at.
+   * @return The code point at {@code index}.
+   */
   private static int codePointAt(MappedText text, int index) {
     final char c = text.chars[index];
     if (Character.isHighSurrogate(c) && index + 1 < text.length
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/CreateTokenizerTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/CreateTokenizerTest.java
index 5131ae84e..5ae7e3ebe 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/CreateTokenizerTest.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/CreateTokenizerTest.java
@@ -101,7 +101,8 @@ public class CreateTokenizerTest {
     final Map<String, Integer> vocab = robertaVocab();
     vocab.remove(WordpieceTokenizer.ROBERTA_UNK_TOKEN);
 
-    assertThrows(IllegalArgumentException.class, () -> 
AbstractDL.createPipelineTokenizer(vocab, false));
+    assertThrows(IllegalArgumentException.class,
+        () -> AbstractDL.createPipelineTokenizer(vocab, false));
     assertThrows(IllegalArgumentException.class, () -> 
AbstractDL.createWordpieceTokenizer(vocab));
   }
 
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/ReferenceBertPipeline.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/ReferenceBertPipeline.java
index 867c28dbf..a07d6faaf 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/ReferenceBertPipeline.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/ReferenceBertPipeline.java
@@ -47,6 +47,8 @@ final class ReferenceBertPipeline {
     String normalized = cleanText(text);
     normalized = isolateCjkCharacters(normalized);
     if (lowerCase) {
+      // Locale.ROOT lower casing is the reference behavior of BERT's 
do_lower_case: the full
+      // locale-independent Unicode case mappings, including one-to-many ones.
       normalized = stripAccents(normalized.toLowerCase(Locale.ROOT));
     }
     return BertNormalization.isolatePunctuation(normalized);
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderReferenceSequencesTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderReferenceSequencesTest.java
index 68e8f219f..064d644a5 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderReferenceSequencesTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderReferenceSequencesTest.java
@@ -17,13 +17,17 @@
 package opennlp.tools.tokenize;
 
 import java.util.List;
+import java.util.stream.Stream;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 /**
- * The reference token sequences of the removed full-pipeline {@code 
Tokenizer}, re-asserted
- * against {@link WordpieceEncoder}.
+ * Reference token-sequence expectations for {@link WordpieceEncoder}, 
covering lower casing,
+ * accent stripping, punctuation and CJK isolation, and text cleaning.
  * <p>
  * All expected token sequences in this test were generated with the 
HuggingFace
  * {@code tokenizers} reference implementation ({@code BertWordPieceTokenizer})
@@ -43,92 +47,56 @@ public class WordpieceEncoderReferenceSequencesTest {
       "\u6211", "\u7231",  // CJK
       "natural", "language", "processing");
 
-  @Test
-  void testLowerCasesCapitalizedWords() {
-    final WordpieceEncoder encoder = new WordpieceEncoder(VOCABULARY);
-    final String[] tokens =
-        encoder.encodeToPieces("The quick brown fox jumps over the lazy dog.");
-
-    final String[] expected = {"[CLS]", "the", "quick", "brown", "fox", 
"jumps", "over",
-        "the", "lazy", "dog", ".", "[SEP]"};
-    Assertions.assertArrayEquals(expected, tokens);
-  }
-
-  @Test
-  void testLowerCasesBeforeWordpieceSplitting() {
-    final WordpieceEncoder encoder = new WordpieceEncoder(VOCABULARY);
-    final String[] tokens = encoder.encodeToPieces("Embeddings");
-
-    final String[] expected = {"[CLS]", "em", "##bed", "##ding", "##s", 
"[SEP]"};
-    Assertions.assertArrayEquals(expected, tokens);
-  }
-
-  @Test
-  void testStripsAccentsButKeepsNonCombiningCharacters() {
-    final WordpieceEncoder encoder = new WordpieceEncoder(VOCABULARY);
-    // The u-umlaut decomposes to u plus a combining diaeresis and the mark is 
stripped;
-    // the sharp s is not a combining mark and must survive, leaving an OOV 
token.
-    final String[] tokens = encoder.encodeToPieces("W\u00fcrttemberg 
Stra\u00dfe");
-
-    final String[] expected = {"[CLS]", "wurttemberg", "[UNK]", "[SEP]"};
-    Assertions.assertArrayEquals(expected, tokens);
+  /**
+   * The reference input and expected-sequence pairs, one argument set per 
pipeline behavior.
+   *
+   * @return The (input, expected pieces) pairs.
+   */
+  static Stream<Arguments> referenceSequences() {
+    return Stream.of(
+        // Lower cases capitalized words.
+        Arguments.of("The quick brown fox jumps over the lazy dog.",
+            new String[] {"[CLS]", "the", "quick", "brown", "fox", "jumps", 
"over",
+                "the", "lazy", "dog", ".", "[SEP]"}),
+        // Lower cases before wordpiece splitting.
+        Arguments.of("Embeddings",
+            new String[] {"[CLS]", "em", "##bed", "##ding", "##s", "[SEP]"}),
+        // The u-umlaut decomposes to u plus a combining diaeresis and the 
mark is stripped;
+        // the sharp s is not a combining mark and must survive, leaving an 
OOV token.
+        Arguments.of("W\u00fcrttemberg Stra\u00dfe",
+            new String[] {"[CLS]", "wurttemberg", "[UNK]", "[SEP]"}),
+        // Splits punctuation runs into single characters.
+        Arguments.of("Wait... what?!",
+            new String[] {"[CLS]", "wait", ".", ".", ".", "what", "?", "!", 
"[SEP]"}),
+        // Splits apostrophes as punctuation.
+        Arguments.of("don't",
+            new String[] {"[CLS]", "don", "'", "t", "[SEP]"}),
+        // Isolates CJK ideographs into single-character pieces.
+        Arguments.of("\u6211\u7231natural language processing",
+            new String[] {"[CLS]", "\u6211", "\u7231", "natural", "language",
+                "processing", "[SEP]"}),
+        // Tab and no-break space are whitespace; the NUL character is removed,
+        // joining "brown" and "fox" into one out-of-vocabulary token.
+        Arguments.of("the\tquick\u00a0brown\u0000fox",
+            new String[] {"[CLS]", "the", "quick", "[UNK]", "[SEP]"}),
+        // The reference implementation treats all C* categories as control
+        // characters: private use (U+E000, Co) and noncharacters (U+FDD0, Cn)
+        // are removed, joining the surrounding text into one OOV token.
+        Arguments.of("fox\ue000jumps and fox\ufdd0jumps",
+            new String[] {"[CLS]", "[UNK]", "[UNK]", "[UNK]", "[SEP]"}));
   }
 
-  @Test
-  void testSplitsPunctuationRunsIntoSingleCharacters() {
+  @ParameterizedTest
+  @MethodSource("referenceSequences")
+  void testEncodesTheReferenceSequence(String input, String[] expected) {
     final WordpieceEncoder encoder = new WordpieceEncoder(VOCABULARY);
-    final String[] tokens = encoder.encodeToPieces("Wait... what?!");
-
-    final String[] expected = {"[CLS]", "wait", ".", ".", ".", "what", "?", 
"!", "[SEP]"};
-    Assertions.assertArrayEquals(expected, tokens);
-  }
-
-  @Test
-  void testSplitsApostrophesAsPunctuation() {
-    final WordpieceEncoder encoder = new WordpieceEncoder(VOCABULARY);
-    final String[] tokens = encoder.encodeToPieces("don't");
-
-    final String[] expected = {"[CLS]", "don", "'", "t", "[SEP]"};
-    Assertions.assertArrayEquals(expected, tokens);
-  }
-
-  @Test
-  void testIsolatesCjkIdeographs() {
-    final WordpieceEncoder encoder = new WordpieceEncoder(VOCABULARY);
-    final String[] tokens = encoder.encodeToPieces("\u6211\u7231natural 
language processing");
-
-    final String[] expected = {"[CLS]", "\u6211", "\u7231", "natural", 
"language",
-        "processing", "[SEP]"};
-    Assertions.assertArrayEquals(expected, tokens);
-  }
-
-  @Test
-  void testCleansControlCharactersAndNormalizesWhitespace() {
-    final WordpieceEncoder encoder = new WordpieceEncoder(VOCABULARY);
-    // Tab and no-break space are whitespace; the NUL character is removed,
-    // joining "brown" and "fox" into one out-of-vocabulary token.
-    final String[] tokens = 
encoder.encodeToPieces("the\tquick\u00a0brown\u0000fox");
-
-    final String[] expected = {"[CLS]", "the", "quick", "[UNK]", "[SEP]"};
-    Assertions.assertArrayEquals(expected, tokens);
-  }
-
-  @Test
-  void testRemovesPrivateUseAndUnassignedCharacters() {
-    final WordpieceEncoder encoder = new WordpieceEncoder(VOCABULARY);
-    // The reference implementation treats all C* categories as control
-    // characters: private use (U+E000, Co) and noncharacters (U+FDD0, Cn)
-    // are removed, joining the surrounding text into one OOV token.
-    final String[] tokens = encoder.encodeToPieces("fox\ue000jumps and 
fox\ufdd0jumps");
-
-    final String[] expected = {"[CLS]", "[UNK]", "[UNK]", "[UNK]", "[SEP]"};
-    Assertions.assertArrayEquals(expected, tokens);
+    Assertions.assertArrayEquals(expected, encoder.encodeToPieces(input),
+        "sequence broke on: " + input);
   }
 
   @Test
   void testRejectsNullSpecialTokens() {
-    // The encoder's contract throws IllegalArgumentException where the 
removed class threw
-    // NullPointerException.
+    // The encoder's contract throws IllegalArgumentException for null special 
tokens.
     Assertions.assertThrows(IllegalArgumentException.class,
         () -> new WordpieceEncoder(VOCABULARY, true, null, "[SEP]", "[UNK]"));
     Assertions.assertThrows(IllegalArgumentException.class,
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderTest.java
index 610bba5ed..cf545b183 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderTest.java
@@ -20,8 +20,12 @@ import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Random;
+import java.util.stream.Stream;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -55,11 +59,13 @@ class WordpieceEncoderTest {
     assertEquals(expectedEnd, piece.end(), "end of " + piece);
   }
 
-  @Test
-  void testPieceSequenceMatchesTheReferencePipelineOnCuratedInputs() {
-    final ReferenceBertPipeline reference = new ReferenceBertPipeline(new 
HashSet<>(VOCAB), true);
-    final WordpieceEncoder encoder = uncased();
-    final String[] inputs = {
+  /**
+   * The curated parity inputs, each exercising a normalization step of the 
pipeline.
+   *
+   * @return The inputs.
+   */
+  static Stream<String> curatedInputs() {
+    return Stream.of(
         "",
         "   ",
         "Hello, WORLD!",
@@ -81,12 +87,16 @@ class WordpieceEncoderTest {
         "\uD83D\uDE00",
         "!!!",
         "a".repeat(101),
-        "he said: \u00ABhello\u00BB.",
-    };
-    for (final String input : inputs) {
-      assertArrayEquals(reference.tokenize(input), 
encoder.encodeToPieces(input),
-          "parity broke on: " + input);
-    }
+        "he said: \u00ABhello\u00BB.");
+  }
+
+  @ParameterizedTest
+  @MethodSource("curatedInputs")
+  void testPieceSequenceMatchesTheReferencePipelineOnCuratedInputs(String 
input) {
+    final ReferenceBertPipeline reference = new ReferenceBertPipeline(new 
HashSet<>(VOCAB), true);
+    final WordpieceEncoder encoder = uncased();
+    assertArrayEquals(reference.tokenize(input), encoder.encodeToPieces(input),
+        "parity broke on: " + input);
   }
 
   @Test
@@ -193,14 +203,13 @@ class WordpieceEncoderTest {
     assertPiece(pieces.get(2), "hello", 4, 6, 11);
   }
 
-  @Test
-  void testEmptyAndBlankTextEncodeToTheFramePiecesOnly() {
-    for (final String input : new String[] {"", "   "}) {
-      final List<SubwordPiece> pieces = uncased().encode(input);
-      assertEquals(2, pieces.size());
-      assertPiece(pieces.get(0), "[CLS]", 2, 0, 0);
-      assertPiece(pieces.get(1), "[SEP]", 3, input.length(), input.length());
-    }
+  @ParameterizedTest
+  @ValueSource(strings = {"", "   "})
+  void testEmptyAndBlankTextEncodeToTheFramePiecesOnly(String input) {
+    final List<SubwordPiece> pieces = uncased().encode(input);
+    assertEquals(2, pieces.size(), "frame pieces broke on <" + input + ">");
+    assertPiece(pieces.get(0), "[CLS]", 2, 0, 0);
+    assertPiece(pieces.get(1), "[SEP]", 3, input.length(), input.length());
   }
 
   @Test
diff --git a/opennlp-docs/src/docbkx/tokenizer.xml 
b/opennlp-docs/src/docbkx/tokenizer.xml
index 2c87a280e..3449cf0d1 100644
--- a/opennlp-docs/src/docbkx/tokenizer.xml
+++ b/opennlp-docs/src/docbkx/tokenizer.xml
@@ -546,8 +546,8 @@ wordTokenizer.tokenize("The quick brown fox.", (start, end, 
type) -> {
                        generally not a substring of the input; the spans 
always refer to the caller's original
                        text, so annotations computed over the pieces can be 
mapped back without guesswork. The
                        <code>encodeToIds</code> and 
<code>encodeToPieces</code> methods return just the ids or
-                       the piece strings when the spans are not needed. 
Implementations are safe for concurrent
-                       use by multiple threads.
+                       the piece strings when the spans are not needed. 
Implementations are expected to be safe
+                       for concurrent use by multiple threads.
                </para>
                <section xml:id="tools.tokenizer.subword.sentencepiece">
                        <title>SentencePiece</title>
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/BpeEncoder.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/BpeEncoder.java
index c6ad75542..0a08c980e 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/BpeEncoder.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/BpeEncoder.java
@@ -16,6 +16,7 @@
  */
 package opennlp.subword.sentencepiece;
 
+import java.io.Serializable;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -31,7 +32,9 @@ import java.util.PriorityQueue;
  * <p>Only pieces of the normal, user-defined, and unused types participate in 
merges; a merge that
  * lands on an unused piece is re-segmented back into its constituents.</p>
  */
-final class BpeEncoder {
+final class BpeEncoder implements Serializable {
+
+  private static final long serialVersionUID = -57799941356582785L;
 
   private static final int MAX_RESEGMENT_DEPTH = 100;
 
@@ -77,8 +80,12 @@ final class BpeEncoder {
    * @param normalized The buffer holding the normalized UTF-8 bytes; must not 
be null.
    * @param size       The number of valid bytes in {@code normalized}.
    * @return The segments covering all bytes, in text order.
+   * @throws IllegalArgumentException Thrown if {@code normalized} is null.
    */
   List<Segment> encode(byte[] normalized, int size) {
+    if (normalized == null) {
+      throw new IllegalArgumentException("The normalized buffer must not be 
null.");
+    }
     if (size == 0) {
       return List.of();
     }
@@ -92,7 +99,7 @@ final class BpeEncoder {
     while (position < size) {
       int matched = 0;
       if (userDefinedMatcher != null) {
-        matched = longestUserDefinedMatch(normalized, size, position);
+        matched = userDefinedMatcher.longestMatch(normalized, size, position);
       }
       final boolean frozen = matched > 0;
       final int length = frozen ? matched
@@ -158,6 +165,20 @@ final class BpeEncoder {
     return output;
   }
 
+  /**
+   * Offers the adjacent symbol pair {@code (left, right)} as a merge 
candidate: the pair joins
+   * the agenda only when the concatenation is a mergeable vocabulary piece, 
and a merge landing
+   * on an unused piece is remembered in {@code revMerge} for later 
re-segmentation.
+   *
+   * @param normalized The buffer holding the normalized UTF-8 bytes.
+   * @param from       Per symbol, the inclusive start offset in {@code 
normalized}.
+   * @param to         Per symbol, the exclusive end offset in {@code 
normalized}.
+   * @param freeze     Per symbol, whether it is a user-defined symbol 
excluded from merging.
+   * @param left       The index of the left symbol, or {@code -1} for none.
+   * @param right      The index of the right symbol, or {@code -1} for none.
+   * @param agenda     The merge agenda to add to.
+   * @param revMerge   The map from a merged piece to its two constituents.
+   */
   private void maybeAddPair(byte[] normalized, int[] from, int[] to, boolean[] 
freeze,
                             int left, int right, PriorityQueue<Pair> agenda,
                             Map<String, String[]> revMerge) {
@@ -207,19 +228,4 @@ final class BpeEncoder {
     consumed = resegment(parts[0], consumed, depth + 1, revMerge, output);
     return resegment(parts[1], consumed, depth + 1, revMerge, output);
   }
-
-  private int longestUserDefinedMatch(byte[] input, int inputLength, int from) 
{
-    int node = userDefinedMatcher.root();
-    int longest = 0;
-    for (int i = from; i < inputLength; i++) {
-      node = userDefinedMatcher.step(node, input[i]);
-      if (node == PieceTrie.DEAD) {
-        break;
-      }
-      if (userDefinedMatcher.value(node) >= 0) {
-        longest = i - from + 1;
-      }
-    }
-    return longest;
-  }
 }
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/ByteBuilder.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/ByteBuilder.java
index 95a02fddd..ef08bb624 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/ByteBuilder.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/ByteBuilder.java
@@ -68,9 +68,15 @@ final class ByteBuilder {
   /**
    * Shrinks the valid length.
    *
-   * @param newLength The new length, not greater than the current length.
+   * @param newLength The new length, not negative and not greater than the 
current length.
+   * @throws IllegalArgumentException Thrown if {@code newLength} is negative 
or greater than the
+   *     current length.
    */
   void truncate(int newLength) {
+    if (newLength < 0 || newLength > length) {
+      throw new IllegalArgumentException(
+          "The new length " + newLength + " is outside [0, " + length + "].");
+    }
     length = newLength;
   }
 
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/DoubleArrayTrie.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/DoubleArrayTrie.java
index 0ffb5e8be..28eefd3f1 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/DoubleArrayTrie.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/DoubleArrayTrie.java
@@ -16,6 +16,8 @@
  */
 package opennlp.subword.sentencepiece;
 
+import java.io.Serializable;
+
 /**
  * Read-only lookup over a serialized Darts-clone double-array trie, the 
dictionary format
  * embedded in a SentencePiece model's precompiled character map.
@@ -25,8 +27,23 @@ package opennlp.subword.sentencepiece;
  * prefix match is needed here, so this walks the byte key once and remembers 
the last accepting
  * state. Out-of-range unit references, which a well-formed trie never 
produces, fail loudly
  * rather than reading arbitrary memory.</p>
+ *
+ * @see <a href="https://github.com/s-yata/darts-clone";>Darts-clone</a>
  */
-final class DoubleArrayTrie {
+final class DoubleArrayTrie implements Serializable {
+
+  private static final long serialVersionUID = -1572336116472261588L;
+
+  // A non-leaf unit stores its transition label in the low 8 bits and the 
leaf flag in the sign
+  // bit. Key bytes are in [0, 255] with the sign bit clear, so comparing 
(unit & this mask)
+  // against a key byte both matches the label and rejects leaf units in one 
test.
+  private static final int LEAF_FLAG_AND_LABEL_MASK = 0x800000FF;
+
+  // A leaf unit stores the key's value in its low 31 bits; the sign bit is 
the leaf flag.
+  private static final int LEAF_VALUE_MASK = 0x7FFFFFFF;
+
+  // Bit 8 of a non-leaf unit marks that one of its children is a leaf holding 
this key's value.
+  private static final int HAS_LEAF_BIT = 8;
 
   private final int[] units;
 
@@ -72,12 +89,12 @@ final class DoubleArrayTrie {
         final int b = key[i] & 0xFF;
         nodePos ^= b;
         unit = u[nodePos];
-        if ((unit & 0x800000FF) != b) {
+        if ((unit & LEAF_FLAG_AND_LABEL_MASK) != b) {
           return result;
         }
         nodePos ^= offset(unit);
-        if (((unit >>> 8) & 1) == 1) {
-          final int value = u[nodePos] & 0x7FFFFFFF;
+        if (((unit >>> HAS_LEAF_BIT) & 1) == 1) {
+          final int value = u[nodePos] & LEAF_VALUE_MASK;
           result = ((long) value << 32) | (i - from + 1);
         }
       }
@@ -101,11 +118,14 @@ final class DoubleArrayTrie {
     if (nodePos < 0 || nodePos >= units.length) {
       return false;
     }
-    return (units[nodePos] & 0x800000FF) == b;
+    return (units[nodePos] & LEAF_FLAG_AND_LABEL_MASK) == b;
   }
 
   /**
-   * Returns the offset from a unit to its children, as encoded by Darts-clone.
+   * Returns the offset from a unit to its children, as encoded by 
Darts-clone: bits 10 to 30 hold
+   * the raw offset, and bit 9 is an extension flag that scales it by 256 for 
far-away children.
+   * The expression {@code (unit & (1 << 9)) >>> 6} evaluates to 8 exactly 
when bit 9 is set, so
+   * the raw offset is shifted left by either 0 or 8 bits.
    *
    * @param unit The unit word.
    * @return The child offset.
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/IntBuilder.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/IntBuilder.java
index 25dad2ec9..2da34c29b 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/IntBuilder.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/IntBuilder.java
@@ -67,9 +67,15 @@ final class IntBuilder {
   /**
    * Shrinks the valid length.
    *
-   * @param newLength The new length, not greater than the current length.
+   * @param newLength The new length, not negative and not greater than the 
current length.
+   * @throws IllegalArgumentException Thrown if {@code newLength} is negative 
or greater than the
+   *     current length.
    */
   void truncate(int newLength) {
+    if (newLength < 0 || newLength > length) {
+      throw new IllegalArgumentException(
+          "The new length " + newLength + " is outside [0, " + length + "].");
+    }
     length = newLength;
   }
 
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/ModelProtoReader.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/ModelProtoReader.java
index 4122746cb..c4c2a1472 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/ModelProtoReader.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/ModelProtoReader.java
@@ -28,6 +28,10 @@ import java.util.List;
  * directly and keeps only the fields inference needs: the pieces with scores 
and types, the
  * normalizer spec, the trainer-spec fields that change runtime behavior, and 
the embedded
  * self-test samples. Unknown fields are skipped, and malformed input fails 
loudly.</p>
+ *
+ * @see <a href=
+ *     
"https://github.com/google/sentencepiece/blob/master/src/sentencepiece_model.proto";>
+ *     sentencepiece_model.proto</a>
  */
 final class ModelProtoReader {
 
@@ -37,9 +41,43 @@ final class ModelProtoReader {
   private static final int WIRE_LEN = 2;
   private static final int WIRE_FIXED32 = 5;
 
+  // Field numbers of the ModelProto message in sentencepiece_model.proto.
+  private static final int FIELD_MODEL_PIECES = 1;
+  private static final int FIELD_MODEL_TRAINER_SPEC = 2;
+  private static final int FIELD_MODEL_NORMALIZER_SPEC = 3;
+  private static final int FIELD_MODEL_SELF_TEST_DATA = 4;
+
+  // Field numbers of the ModelProto.SentencePiece sub-message.
+  private static final int FIELD_PIECE_PIECE = 1;
+  private static final int FIELD_PIECE_SCORE = 2;
+  private static final int FIELD_PIECE_TYPE = 3;
+
+  // Field numbers of the TrainerSpec sub-message.
+  private static final int FIELD_TRAINER_MODEL_TYPE = 3;
+  private static final int FIELD_TRAINER_TREAT_WHITESPACE_AS_SUFFIX = 24;
+  private static final int FIELD_TRAINER_BYTE_FALLBACK = 35;
+  private static final int FIELD_TRAINER_UNK_ID = 40;
+
+  // Field numbers of the NormalizerSpec sub-message.
+  private static final int FIELD_NORMALIZER_PRECOMPILED_CHARSMAP = 2;
+  private static final int FIELD_NORMALIZER_ADD_DUMMY_PREFIX = 3;
+  private static final int FIELD_NORMALIZER_REMOVE_EXTRA_WHITESPACES = 4;
+  private static final int FIELD_NORMALIZER_ESCAPE_WHITESPACES = 5;
+
+  // Field numbers of the SelfTestData sub-message and its Sample entries.
+  private static final int FIELD_SELF_TEST_SAMPLES = 1;
+  private static final int FIELD_SAMPLE_INPUT = 1;
+  private static final int FIELD_SAMPLE_EXPECTED = 2;
+
   private final byte[] data;
   private int pos;
 
+  /**
+   * Prepares a reader positioned at the start of the given bytes; {@link 
#read(byte[])} drives
+   * the actual parse.
+   *
+   * @param data The raw bytes of a {@code .model} file.
+   */
   private ModelProtoReader(byte[] data) {
     this.data = data;
   }
@@ -61,10 +99,10 @@ final class ModelProtoReader {
       final long tag = reader.varint();
       final int field = (int) (tag >>> 3);
       switch (field) {
-        case 1 -> reader.piece(model, reader.lenPayload(tag));
-        case 2 -> reader.trainerSpec(model, reader.lenPayload(tag));
-        case 3 -> reader.normalizerSpec(model, reader.lenPayload(tag));
-        case 4 -> reader.selfTestData(model, reader.lenPayload(tag));
+        case FIELD_MODEL_PIECES -> reader.piece(model, reader.lenPayload(tag));
+        case FIELD_MODEL_TRAINER_SPEC -> reader.trainerSpec(model, 
reader.lenPayload(tag));
+        case FIELD_MODEL_NORMALIZER_SPEC -> reader.normalizerSpec(model, 
reader.lenPayload(tag));
+        case FIELD_MODEL_SELF_TEST_DATA -> reader.selfTestData(model, 
reader.lenPayload(tag));
         default -> reader.skip(tag);
       }
     }
@@ -87,9 +125,9 @@ final class ModelProtoReader {
     while (pos < end) {
       final long tag = varint();
       switch ((int) (tag >>> 3)) {
-        case 1 -> piece = utf8(lenPayload(tag));
-        case 2 -> score = fixed32Float(tag);
-        case 3 -> type = (int) varintOf(tag);
+        case FIELD_PIECE_PIECE -> piece = utf8(lenPayload(tag));
+        case FIELD_PIECE_SCORE -> score = fixed32Float(tag);
+        case FIELD_PIECE_TYPE -> type = (int) varintOf(tag);
         default -> skip(tag);
       }
     }
@@ -115,10 +153,11 @@ final class ModelProtoReader {
     while (pos < end) {
       final long tag = varint();
       switch ((int) (tag >>> 3)) {
-        case 3 -> model.modelType = (int) varintOf(tag);
-        case 24 -> model.treatWhitespaceAsSuffix = varintOf(tag) != 0;
-        case 35 -> model.byteFallback = varintOf(tag) != 0;
-        case 40 -> model.unkId = (int) varintOf(tag);
+        case FIELD_TRAINER_MODEL_TYPE -> model.modelType = (int) varintOf(tag);
+        case FIELD_TRAINER_TREAT_WHITESPACE_AS_SUFFIX ->
+            model.treatWhitespaceAsSuffix = varintOf(tag) != 0;
+        case FIELD_TRAINER_BYTE_FALLBACK -> model.byteFallback = varintOf(tag) 
!= 0;
+        case FIELD_TRAINER_UNK_ID -> model.unkId = (int) varintOf(tag);
         default -> skip(tag);
       }
     }
@@ -135,10 +174,12 @@ final class ModelProtoReader {
     while (pos < end) {
       final long tag = varint();
       switch ((int) (tag >>> 3)) {
-        case 2 -> model.precompiledCharsMap = bytes(lenPayload(tag));
-        case 3 -> model.addDummyPrefix = varintOf(tag) != 0;
-        case 4 -> model.removeExtraWhitespaces = varintOf(tag) != 0;
-        case 5 -> model.escapeWhitespaces = varintOf(tag) != 0;
+        case FIELD_NORMALIZER_PRECOMPILED_CHARSMAP ->
+            model.precompiledCharsMap = bytes(lenPayload(tag));
+        case FIELD_NORMALIZER_ADD_DUMMY_PREFIX -> model.addDummyPrefix = 
varintOf(tag) != 0;
+        case FIELD_NORMALIZER_REMOVE_EXTRA_WHITESPACES ->
+            model.removeExtraWhitespaces = varintOf(tag) != 0;
+        case FIELD_NORMALIZER_ESCAPE_WHITESPACES -> model.escapeWhitespaces = 
varintOf(tag) != 0;
         default -> skip(tag);
       }
     }
@@ -154,15 +195,15 @@ final class ModelProtoReader {
   private void selfTestData(RawModel model, int end) {
     while (pos < end) {
       final long tag = varint();
-      if ((int) (tag >>> 3) == 1) {
+      if ((int) (tag >>> 3) == FIELD_SELF_TEST_SAMPLES) {
         final int sampleEnd = lenPayload(tag);
         String input = null;
         String expected = null;
         while (pos < sampleEnd) {
           final long sampleTag = varint();
           switch ((int) (sampleTag >>> 3)) {
-            case 1 -> input = utf8(lenPayload(sampleTag));
-            case 2 -> expected = utf8(lenPayload(sampleTag));
+            case FIELD_SAMPLE_INPUT -> input = utf8(lenPayload(sampleTag));
+            case FIELD_SAMPLE_EXPECTED -> expected = 
utf8(lenPayload(sampleTag));
             default -> skip(sampleTag);
           }
         }
@@ -196,6 +237,13 @@ final class ModelProtoReader {
     return pos + (int) length;
   }
 
+  /**
+   * Reads the varint value of a field after checking its wire type.
+   *
+   * @param tag The field tag, whose wire type must be varint.
+   * @return The decoded value.
+   * @throws IllegalArgumentException Thrown if the wire type is wrong or the 
varint is malformed.
+   */
   private long varintOf(long tag) {
     if ((tag & 7) != WIRE_VARINT) {
       throw malformed("field " + (tag >>> 3) + " is not a varint");
@@ -203,6 +251,13 @@ final class ModelProtoReader {
     return varint();
   }
 
+  /**
+   * Reads the little-endian 32-bit float value of a field after checking its 
wire type.
+   *
+   * @param tag The field tag, whose wire type must be 32-bit.
+   * @return The decoded float.
+   * @throws IllegalArgumentException Thrown if the wire type is wrong or the 
input is truncated.
+   */
   private float fixed32Float(long tag) {
     if ((tag & 7) != WIRE_FIXED32) {
       throw malformed("field " + (tag >>> 3) + " is not a 32-bit value");
@@ -216,12 +271,24 @@ final class ModelProtoReader {
     return Float.intBitsToFloat(bits);
   }
 
+  /**
+   * Decodes the bytes from the current position up to {@code end} as UTF-8, 
advancing past them.
+   *
+   * @param end The exclusive end offset of the payload.
+   * @return The decoded string.
+   */
   private String utf8(int end) {
     final String s = new String(data, pos, end - pos, StandardCharsets.UTF_8);
     pos = end;
     return s;
   }
 
+  /**
+   * Copies the bytes from the current position up to {@code end}, advancing 
past them.
+   *
+   * @param end The exclusive end offset of the payload.
+   * @return The copied bytes.
+   */
   private byte[] bytes(int end) {
     final byte[] b = new byte[end - pos];
     System.arraycopy(data, pos, b, 0, b.length);
@@ -268,6 +335,12 @@ final class ModelProtoReader {
     }
   }
 
+  /**
+   * Advances the position by a fixed number of bytes.
+   *
+   * @param count The number of bytes to skip.
+   * @throws IllegalArgumentException Thrown if fewer than {@code count} bytes 
remain.
+   */
   private void advance(int count) {
     if (pos + count > data.length) {
       throw malformed("truncated field");
@@ -275,6 +348,12 @@ final class ModelProtoReader {
     pos += count;
   }
 
+  /**
+   * Creates the exception for malformed input, carrying the current byte 
position.
+   *
+   * @param detail A short description of what is malformed.
+   * @return The exception to throw.
+   */
   private IllegalArgumentException malformed(String detail) {
     return new IllegalArgumentException(
         "The model data is malformed at byte " + pos + ": " + detail + ".");
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/PieceTrie.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/PieceTrie.java
index 795a251bc..5253a9467 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/PieceTrie.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/PieceTrie.java
@@ -16,6 +16,8 @@
  */
 package opennlp.subword.sentencepiece;
 
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
 import java.util.Comparator;
 
@@ -27,7 +29,9 @@ import java.util.Comparator;
  * 256-entry direct table and narrow nodes scan a short sorted label slice; 
both layouts enumerate
  * identical transitions.</p>
  */
-final class PieceTrie {
+final class PieceTrie implements Serializable {
+
+  private static final long serialVersionUID = 30340094783102906L;
 
   /** The node id returned when no transition exists. */
   static final int DEAD = -1;
@@ -45,6 +49,15 @@ final class PieceTrie {
   private final int[] directStart;
   private final int[] directPool;
 
+  /**
+   * Wraps the packed arrays produced by {@link Builder} and derives the 
direct-dispatch tables
+   * for wide nodes.
+   *
+   * @param childStart Per node, the start of its edge slice; one trailing 
entry marks the end.
+   * @param labels     The transition label of every edge.
+   * @param childNodes The target node of every edge, parallel to {@code 
labels}.
+   * @param values     Per node, the accepted piece id, or {@code -1}.
+   */
   private PieceTrie(int[] childStart, byte[] labels, int[] childNodes, int[] 
values) {
     this.childStart = childStart;
     this.labels = labels;
@@ -61,7 +74,7 @@ final class PieceTrie {
       }
     }
     this.directPool = new int[wide * 256];
-    java.util.Arrays.fill(directPool, DEAD);
+    Arrays.fill(directPool, DEAD);
     for (int node = 0; node < values.length; node++) {
       final int direct = directStart[node];
       if (direct >= 0) {
@@ -131,6 +144,41 @@ final class PieceTrie {
     return values[node];
   }
 
+  /**
+   * Returns the byte length of the longest piece in this trie that is a 
prefix of
+   * {@code input[from, inputLength)}.
+   *
+   * @param input       The UTF-8 input buffer; must not be null.
+   * @param inputLength The number of valid bytes in {@code input}.
+   * @param from        The offset to match from.
+   * @return The matched length in bytes, or zero when no piece matches.
+   */
+  int longestMatch(byte[] input, int inputLength, int from) {
+    int node = root();
+    int longest = 0;
+    for (int i = from; i < inputLength; i++) {
+      node = step(node, input[i]);
+      if (node == DEAD) {
+        break;
+      }
+      if (value(node) >= 0) {
+        longest = i - from + 1;
+      }
+    }
+    return longest;
+  }
+
+  /**
+   * Creates the exception reported wherever a vocabulary piece turns out to 
be defined twice,
+   * keeping the message identical across all detection sites.
+   *
+   * @param piece The duplicated piece content.
+   * @return The exception to throw.
+   */
+  static IllegalArgumentException duplicatePiece(String piece) {
+    return new IllegalArgumentException("The piece '" + piece + "' is defined 
more than once.");
+  }
+
   // Builds the packed form from keys sorted by unsigned byte order. Key 
ranges sharing a prefix
   // are contiguous after the sort, so each recursion partitions its range by 
the byte at the
   // current depth.
@@ -150,6 +198,14 @@ final class PieceTrie {
     private int nextNode;
     private int nextEdge;
 
+    /**
+     * Prepares a builder over the keys and their sort order; {@link #count} 
and {@link #fill}
+     * perform the actual construction.
+     *
+     * @param pieces The UTF-8 bytes of each piece.
+     * @param ids    The id stored for each piece, parallel to {@code pieces}.
+     * @param order  The indices of {@code pieces} sorted by unsigned byte 
order.
+     */
     Builder(byte[][] pieces, int[] ids, Integer[] order) {
       this.pieces = pieces;
       this.ids = ids;
@@ -172,9 +228,7 @@ final class PieceTrie {
         i++;
         // A second key ending at the same depth is a duplicate; the sort made 
them adjacent.
         if (i < to && pieces[order[i]].length == depth) {
-          throw new IllegalArgumentException("The piece '"
-              + new String(pieces[order[i]], 
java.nio.charset.StandardCharsets.UTF_8)
-              + "' is defined more than once.");
+          throw duplicatePiece(new String(pieces[order[i]], 
StandardCharsets.UTF_8));
         }
       }
       while (i < to) {
@@ -213,9 +267,7 @@ final class PieceTrie {
       int i = from;
       if (i < to && pieces[order[i]].length == depth) {
         if (values[node] != -1 || (i + 1 < to && pieces[order[i + 1]].length 
== depth)) {
-          throw new IllegalArgumentException(
-              "The piece '" + new String(pieces[order[i]], 
java.nio.charset.StandardCharsets.UTF_8)
-                  + "' is defined more than once.");
+          throw duplicatePiece(new String(pieces[order[i]], 
StandardCharsets.UTF_8));
         }
         values[node] = ids[order[i]];
         i++;
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/SentencePieceNormalizer.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/SentencePieceNormalizer.java
index 4d5589514..0dd83c278 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/SentencePieceNormalizer.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/SentencePieceNormalizer.java
@@ -16,6 +16,8 @@
  */
 package opennlp.subword.sentencepiece;
 
+import java.io.Serializable;
+
 /**
  * The model-embedded text normalizer of a SentencePiece model, operating in 
UTF-8 byte space.
  *
@@ -26,7 +28,9 @@ package opennlp.subword.sentencepiece;
  * was derived from, with one trailing entry for the end position; that map is 
what lets every
  * downstream piece report an exact span of the caller's text.</p>
  */
-final class SentencePieceNormalizer {
+final class SentencePieceNormalizer implements Serializable {
+
+  private static final long serialVersionUID = -3059745470932191300L;
 
   // U+2581 LOWER ONE EIGHTH BLOCK in UTF-8, the escaped form of a space.
   static final byte[] SPACE_SYMBOL = {(byte) 0xE2, (byte) 0x96, (byte) 0x81};
@@ -238,6 +242,15 @@ final class SentencePieceNormalizer {
 
   private static final byte[] SINGLE_SPACE = {' '};
 
+  /**
+   * Appends the space symbol to the normalized output, mapping each of its 
bytes to the same
+   * original-byte offset.
+   *
+   * @param normalized  The normalized-byte builder to append to.
+   * @param normToOrig  The offset-map builder to append to.
+   * @param spaceSymbol The bytes of the (possibly escaped) space symbol.
+   * @param consumed    The original-byte offset the symbol maps back to.
+   */
   private static void appendSpace(ByteBuilder normalized, IntBuilder 
normToOrig,
                                   byte[] spaceSymbol, int consumed) {
     normalized.append(spaceSymbol, 0, spaceSymbol.length);
@@ -259,7 +272,7 @@ final class SentencePieceNormalizer {
    */
   private void normalizePrefix(byte[] input, int inputLength, int from, Chunk 
chunk) {
     if (userDefinedMatcher != null) {
-      final int matched = longestUserDefinedMatch(input, inputLength, from);
+      final int matched = userDefinedMatcher.longestMatch(input, inputLength, 
from);
       if (matched > 0) {
         chunk.data = input;
         chunk.from = from;
@@ -303,30 +316,6 @@ final class SentencePieceNormalizer {
     chunk.consumed = charLength;
   }
 
-  /**
-   * Returns the byte length of the longest user-defined symbol that is a 
prefix of
-   * {@code input[from, inputLength)}.
-   *
-   * @param input       The UTF-8 input buffer.
-   * @param inputLength The number of valid bytes in {@code input}.
-   * @param from        The offset to match from.
-   * @return The matched length in bytes, or zero when no user-defined symbol 
matches.
-   */
-  private int longestUserDefinedMatch(byte[] input, int inputLength, int from) 
{
-    int node = userDefinedMatcher.root();
-    int longest = 0;
-    for (int i = from; i < inputLength; i++) {
-      node = userDefinedMatcher.step(node, input[i]);
-      if (node == PieceTrie.DEAD) {
-        break;
-      }
-      if (userDefinedMatcher.value(node) >= 0) {
-        longest = i - from + 1;
-      }
-    }
-    return longest;
-  }
-
   /**
    * Returns the byte length of a UTF-8 sequence from its lead byte; trail and 
malformed lead bytes
    * report one byte.
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/SentencePieceTokenizer.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/SentencePieceTokenizer.java
index 961bb58b3..e8c0e04ee 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/SentencePieceTokenizer.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/SentencePieceTokenizer.java
@@ -22,9 +22,11 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.IntUnaryOperator;
 
 import opennlp.tools.tokenize.SubwordPiece;
 import opennlp.tools.tokenize.SubwordTokenizer;
@@ -43,6 +45,11 @@ import opennlp.tools.util.normalizer.OffsetAwareNormalizer;
  * for reuse outside tokenization.</p>
  *
  * <p>Instances are immutable after loading and safe for concurrent use by 
multiple threads.</p>
+ *
+ * @see <a href="https://github.com/google/sentencepiece";>SentencePiece</a>
+ * @see <a href="https://aclanthology.org/D18-2012/";>Kudo &amp; Richardson 
(EMNLP 2018),
+ *     "SentencePiece: A simple and language independent subword tokenizer and 
detokenizer for
+ *     Neural Text Processing"</a>
  */
 public final class SentencePieceTokenizer implements SubwordTokenizer, 
OffsetAwareNormalizer {
 
@@ -82,6 +89,13 @@ public final class SentencePieceTokenizer implements 
SubwordTokenizer, OffsetAwa
   private final List<String> selfTestInputs;
   private final List<String> selfTestExpected;
 
+  /**
+   * Validates a parsed model and derives the runtime structures: the piece 
maps, the byte-piece
+   * table, the normalizer, and the encoder matching the model's algorithm.
+   *
+   * @param model The parsed model description.
+   * @throws IllegalArgumentException Thrown if the model is structurally 
invalid.
+   */
   private SentencePieceTokenizer(ModelProtoReader.RawModel model) {
     final int count = model.pieces.size();
     pieces = model.pieces.toArray(new String[0]);
@@ -106,7 +120,7 @@ public final class SentencePieceTokenizer implements 
SubwordTokenizer, OffsetAwa
     reservedPieces = new HashMap<>();
     final List<String> userDefined = new ArrayList<>();
     byteToId = new int[256];
-    java.util.Arrays.fill(byteToId, -1);
+    Arrays.fill(byteToId, -1);
     int foundUnkId = -1;
     float minScore = Float.MAX_VALUE;
     for (int i = 0; i < count; i++) {
@@ -124,7 +138,7 @@ public final class SentencePieceTokenizer implements 
SubwordTokenizer, OffsetAwa
       final Map<String, Integer> target =
           isMain || algorithm == Algorithm.BPE ? mainPieces : reservedPieces;
       if (mainPieces.containsKey(piece) || reservedPieces.containsKey(piece)) {
-        throw new IllegalArgumentException("The piece '" + piece + "' is 
defined more than once.");
+        throw PieceTrie.duplicatePiece(piece);
       }
       target.put(piece, i);
       switch (types[i]) {
@@ -165,7 +179,8 @@ public final class SentencePieceTokenizer implements 
SubwordTokenizer, OffsetAwa
       }
     }
 
-    final PieceTrie userDefinedMatcher = userDefined.isEmpty() ? null : 
trieOf(userDefined, id -> 0);
+    final PieceTrie userDefinedMatcher =
+        userDefined.isEmpty() ? null : trieOf(userDefined, id -> 0);
 
     normalizer = new SentencePieceNormalizer(model.precompiledCharsMap, 
model.addDummyPrefix,
         model.removeExtraWhitespaces, model.escapeWhitespaces, 
model.treatWhitespaceAsSuffix,
@@ -204,8 +219,14 @@ public final class SentencePieceTokenizer implements 
SubwordTokenizer, OffsetAwa
     selfTestExpected = List.copyOf(model.selfTestExpected);
   }
 
-  private static PieceTrie trieOf(List<String> pieceList,
-                                  java.util.function.IntUnaryOperator idOf) {
+  /**
+   * Builds a {@link PieceTrie} over the given pieces.
+   *
+   * @param pieceList The pieces to index.
+   * @param idOf      Maps a piece's index in {@code pieceList} to the id the 
trie stores for it.
+   * @return The packed trie.
+   */
+  private static PieceTrie trieOf(List<String> pieceList, IntUnaryOperator 
idOf) {
     final byte[][] keys = new byte[pieceList.size()][];
     final int[] ids = new int[pieceList.size()];
     for (int i = 0; i < keys.length; i++) {
@@ -246,11 +267,7 @@ public final class SentencePieceTokenizer implements 
SubwordTokenizer, OffsetAwa
     return new 
SentencePieceTokenizer(ModelProtoReader.read(in.readAllBytes()));
   }
 
-  /**
-   * {@inheritDoc}
-   *
-   * @throws IllegalArgumentException Thrown if {@code text} is null.
-   */
+  /** {@inheritDoc} */
   @Override
   public List<SubwordPiece> encode(CharSequence text) {
     if (text == null) {
@@ -526,13 +543,16 @@ public final class SentencePieceTokenizer implements 
SubwordTokenizer, OffsetAwa
     return selfTestExpected;
   }
 
+  // The prefix of a byte-fallback piece string; a full piece has the form 
"<0xAB>".
+  private static final String BYTE_PIECE_PREFIX = "<0x";
+
   // "<0xAB>" piece strings for all byte values, as byte fallback emits them.
   private static final String[] BYTE_PIECES = new String[256];
 
   static {
     final char[] hex = "0123456789ABCDEF".toCharArray();
     for (int b = 0; b < 256; b++) {
-      BYTE_PIECES[b] = "<0x" + hex[b >>> 4] + hex[b & 0xF] + ">";
+      BYTE_PIECES[b] = BYTE_PIECE_PREFIX + hex[b >>> 4] + hex[b & 0xF] + ">";
     }
   }
 
@@ -543,7 +563,7 @@ public final class SentencePieceTokenizer implements 
SubwordTokenizer, OffsetAwa
    * @return The byte value in {@code [0, 255]}, or {@code -1} when the string 
is not a byte piece.
    */
   private static int parseBytePiece(String piece) {
-    if (piece.length() != 6 || !piece.startsWith("<0x") || piece.charAt(5) != 
'>') {
+    if (piece.length() != 6 || !piece.startsWith(BYTE_PIECE_PREFIX) || 
piece.charAt(5) != '>') {
       return -1;
     }
     final int high = Character.digit(piece.charAt(3), 16);
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/UnigramEncoder.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/UnigramEncoder.java
index c87662bef..1efa00310 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/UnigramEncoder.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/UnigramEncoder.java
@@ -16,6 +16,7 @@
  */
 package opennlp.subword.sentencepiece;
 
+import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -27,9 +28,14 @@ import java.util.List;
  * <p>Characters no piece covers fall back to the unknown id with a fixed 
penalty below the lowest
  * piece score, and user-defined symbols receive a length-based bonus score so 
they always win.</p>
  */
-final class UnigramEncoder {
+final class UnigramEncoder implements Serializable {
+
+  private static final long serialVersionUID = 5648005733414803707L;
 
   private static final float UNK_PENALTY = 10.0f;
+  // The score of a user-defined symbol is this bonus per matched byte beyond 
the first instead
+  // of a trained log-probability, so longer user-defined matches always win 
the best path.
+  private static final float USER_DEFINED_LENGTH_BONUS = 0.1f;
   private static final float SCORE_RESET_THRESHOLD = 100000.0f;
 
   private final PieceTrie trie;
@@ -65,8 +71,12 @@ final class UnigramEncoder {
    * @param normalized The buffer holding the normalized UTF-8 bytes; must not 
be null.
    * @param size       The number of valid bytes in {@code normalized}.
    * @return The best-path segments covering all bytes, in text order.
+   * @throws IllegalArgumentException Thrown if {@code normalized} is null.
    */
   List<Segment> encode(byte[] normalized, int size) {
+    if (normalized == null) {
+      throw new IllegalArgumentException("The normalized buffer must not be 
null.");
+    }
     if (size == 0) {
       return List.of();
     }
@@ -118,7 +128,8 @@ final class UnigramEncoder {
         maxFrontier = Math.max(maxFrontier, keyPos);
         final int length = keyPos - startsAt;
         // User-defined symbols receive a length bonus instead of a trained 
score.
-        final float score = userDefined[id] ? 0.1f * (length - 1) : scores[id];
+        final float score = userDefined[id]
+            ? USER_DEFINED_LENGTH_BONUS * (length - 1) : scores[id];
         final float candidate = score + bestScoreTillHere;
         final int slot = 3 * keyPos;
         if (best[slot] == -1 || candidate > Float.intBitsToFloat(best[slot + 
1])) {
diff --git 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/Utf8Text.java
 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/Utf8Text.java
index e830816e6..4f2bd95f0 100644
--- 
a/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/Utf8Text.java
+++ 
b/opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/Utf8Text.java
@@ -32,6 +32,14 @@ final class Utf8Text {
   private final int[] byteToChar;
   private final int charLength;
 
+  /**
+   * Wraps an encoded buffer and its offset map.
+   *
+   * @param bytes      The UTF-8 buffer.
+   * @param byteLength The number of valid bytes in {@code bytes}.
+   * @param byteToChar The byte-to-UTF-16 offset map, or null for pure-ASCII 
text.
+   * @param charLength The length of the original text in UTF-16 units.
+   */
   private Utf8Text(byte[] bytes, int byteLength, int[] byteToChar, int 
charLength) {
     this.bytes = bytes;
     this.byteLength = byteLength;
diff --git 
a/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceFixtures.java
 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceFixtures.java
new file mode 100644
index 000000000..11e54d2be
--- /dev/null
+++ 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceFixtures.java
@@ -0,0 +1,125 @@
+/*
+ * 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.subword.sentencepiece;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import opennlp.tools.tokenize.SubwordPiece;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Shared support for the tab-separated parity fixture files produced by the
+ * {@code gen_fixtures.py} and {@code gen_real_fixtures.py} scripts in the 
test resources: one
+ * line per input, holding the input, the expected piece count, four columns 
per expected piece
+ * (content, id, start, end), and the expected normalized form.
+ */
+final class SentencePieceFixtures {
+
+  private SentencePieceFixtures() {
+  }
+
+  /**
+   * One parsed fixture line: an input with the piece sequence and normalized 
form the reference
+   * implementation produced for it.
+   *
+   * @param input      The text to encode.
+   * @param pieces     The expected pieces with ids and original-text spans, 
in text order.
+   * @param normalized The expected normalized form of {@code input}.
+   */
+  record Fixture(String input, List<SubwordPiece> pieces, String normalized) {
+  }
+
+  /**
+   * Reads all fixture lines from a reader.
+   *
+   * @param reader The reader positioned at the start of a fixture file; must 
not be null.
+   * @return The parsed fixtures in file order.
+   * @throws IOException Thrown if the reader fails.
+   */
+  static List<Fixture> read(BufferedReader reader) throws IOException {
+    final List<Fixture> fixtures = new ArrayList<>();
+    String line;
+    while ((line = reader.readLine()) != null) {
+      final String[] cols = line.split("\t", -1);
+      final String input = unescape(cols[0]);
+      final int count = Integer.parseInt(cols[1]);
+      final List<SubwordPiece> pieces = new ArrayList<>(count);
+      for (int i = 0; i < count; i++) {
+        pieces.add(new SubwordPiece(unescape(cols[2 + i * 4]),
+            Integer.parseInt(cols[3 + i * 4]), Integer.parseInt(cols[4 + i * 
4]),
+            Integer.parseInt(cols[5 + i * 4])));
+      }
+      fixtures.add(new Fixture(input, pieces, unescape(cols[2 + count * 4])));
+    }
+    return fixtures;
+  }
+
+  /**
+   * Asserts that a tokenizer reproduces one fixture exactly: the piece 
sequence with ids and
+   * spans, and the normalized form.
+   *
+   * @param tokenizer The tokenizer under test.
+   * @param fixture   The expected encoding.
+   * @param context   A prefix for failure messages that identifies the model 
and input.
+   */
+  static void assertFixture(SentencePieceTokenizer tokenizer, Fixture fixture, 
String context) {
+    final List<SubwordPiece> actual = tokenizer.encode(fixture.input());
+    assertEquals(fixture.pieces().size(), actual.size(),
+        context + " piece count; got " + actual);
+    for (int i = 0; i < actual.size(); i++) {
+      final SubwordPiece expected = fixture.pieces().get(i);
+      final SubwordPiece got = actual.get(i);
+      assertEquals(expected.piece(), got.piece(), context + " piece " + i);
+      assertEquals(expected.id(), got.id(), context + " id of piece " + i);
+      assertEquals(expected.start(), got.start(), context + " start of piece " 
+ i);
+      assertEquals(expected.end(), got.end(), context + " end of piece " + i);
+    }
+    assertEquals(fixture.normalized(), 
tokenizer.normalize(fixture.input()).toString(),
+        context + " normalized form");
+  }
+
+  /**
+   * Reverses the fixture files' escaping of tab, newline, carriage return, 
and backslash.
+   *
+   * @param s The escaped column content.
+   * @return The unescaped text.
+   * @throws IllegalArgumentException Thrown if an unknown escape sequence 
occurs.
+   */
+  static String unescape(String s) {
+    final StringBuilder out = new StringBuilder(s.length());
+    for (int i = 0; i < s.length(); i++) {
+      final char c = s.charAt(i);
+      if (c == '\\' && i + 1 < s.length()) {
+        i++;
+        switch (s.charAt(i)) {
+          case 't' -> out.append('\t');
+          case 'n' -> out.append('\n');
+          case 'r' -> out.append('\r');
+          case '\\' -> out.append('\\');
+          default -> throw new IllegalArgumentException("bad escape in 
fixture: " + s);
+        }
+      } else {
+        out.append(c);
+      }
+    }
+    return out.toString();
+  }
+}
diff --git 
a/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceModelValidationTest.java
 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceModelValidationTest.java
index 66595eff8..a65df0899 100644
--- 
a/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceModelValidationTest.java
+++ 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceModelValidationTest.java
@@ -21,7 +21,9 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
@@ -45,7 +47,7 @@ class SentencePieceModelValidationTest {
   @Test
   void testNullAndEmptyInputFailLoudly() {
     assertThrows(IllegalArgumentException.class,
-        () -> SentencePieceTokenizer.load((java.nio.file.Path) null));
+        () -> SentencePieceTokenizer.load((Path) null));
     assertThrows(IllegalArgumentException.class,
         () -> SentencePieceTokenizer.load((InputStream) null));
     assertThrows(IllegalArgumentException.class,
@@ -62,7 +64,7 @@ class SentencePieceModelValidationTest {
   @Test
   void testTruncatedModelFailsLoudly() throws IOException {
     final byte[] whole = readModel();
-    final byte[] truncated = java.util.Arrays.copyOf(whole, whole.length / 3);
+    final byte[] truncated = Arrays.copyOf(whole, whole.length / 3);
     assertThrows(IllegalArgumentException.class,
         () -> SentencePieceTokenizer.load(new 
ByteArrayInputStream(truncated)));
   }
diff --git 
a/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceParityTest.java
 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceParityTest.java
index 7d970e3c5..95730ef91 100644
--- 
a/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceParityTest.java
+++ 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceParityTest.java
@@ -21,7 +21,6 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.StringJoiner;
@@ -30,8 +29,6 @@ import java.util.concurrent.ConcurrentHashMap;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
-import opennlp.tools.tokenize.SubwordPiece;
-
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -63,22 +60,10 @@ class SentencePieceParityTest {
   void testFixtureParity(String model) throws IOException {
     final SentencePieceTokenizer tokenizer = tokenizer(model);
     int lines = 0;
-    for (final Fixture fixture : fixtures(model)) {
+    for (final SentencePieceFixtures.Fixture fixture : fixtures(model)) {
       lines++;
-      final List<SubwordPiece> actual = tokenizer.encode(fixture.input);
-      final String context = model + " input <" + fixture.input + ">";
-      assertEquals(fixture.pieces.size(), actual.size(),
-          context + " piece count; got " + actual);
-      for (int i = 0; i < actual.size(); i++) {
-        final SubwordPiece expected = fixture.pieces.get(i);
-        final SubwordPiece got = actual.get(i);
-        assertEquals(expected.piece(), got.piece(), context + " piece " + i);
-        assertEquals(expected.id(), got.id(), context + " id of piece " + i);
-        assertEquals(expected.start(), got.start(), context + " start of piece 
" + i);
-        assertEquals(expected.end(), got.end(), context + " end of piece " + 
i);
-      }
-      assertEquals(fixture.normalized, 
tokenizer.normalize(fixture.input).toString(),
-          context + " normalized form");
+      SentencePieceFixtures.assertFixture(tokenizer, fixture,
+          model + " input <" + fixture.input() + ">");
     }
     assertTrue(lines >= 30, "the fixture file must not be empty or truncated");
   }
@@ -101,50 +86,12 @@ class SentencePieceParityTest {
     }
   }
 
-  private record Fixture(String input, List<SubwordPiece> pieces, String 
normalized) {
-  }
-
-  private static List<Fixture> fixtures(String model) throws IOException {
-    final List<Fixture> fixtures = new ArrayList<>();
+  private static List<SentencePieceFixtures.Fixture> fixtures(String model) 
throws IOException {
     try (InputStream in =
              SentencePieceParityTest.class.getResourceAsStream(model + 
".fixtures.tsv")) {
       assertNotNull(in, "missing test resource " + model + ".fixtures.tsv");
-      final BufferedReader reader =
-          new BufferedReader(new InputStreamReader(in, 
StandardCharsets.UTF_8));
-      String line;
-      while ((line = reader.readLine()) != null) {
-        final String[] cols = line.split("\t", -1);
-        final String input = unescape(cols[0]);
-        final int count = Integer.parseInt(cols[1]);
-        final List<SubwordPiece> pieces = new ArrayList<>(count);
-        for (int i = 0; i < count; i++) {
-          pieces.add(new SubwordPiece(unescape(cols[2 + i * 4]),
-              Integer.parseInt(cols[3 + i * 4]), Integer.parseInt(cols[4 + i * 
4]),
-              Integer.parseInt(cols[5 + i * 4])));
-        }
-        fixtures.add(new Fixture(input, pieces, unescape(cols[2 + count * 
4])));
-      }
-    }
-    return fixtures;
-  }
-
-  private static String unescape(String s) {
-    final StringBuilder out = new StringBuilder(s.length());
-    for (int i = 0; i < s.length(); i++) {
-      final char c = s.charAt(i);
-      if (c == '\\' && i + 1 < s.length()) {
-        i++;
-        switch (s.charAt(i)) {
-          case 't' -> out.append('\t');
-          case 'n' -> out.append('\n');
-          case 'r' -> out.append('\r');
-          case '\\' -> out.append('\\');
-          default -> throw new IllegalArgumentException("bad escape in 
fixture: " + s);
-        }
-      } else {
-        out.append(c);
-      }
+      return SentencePieceFixtures.read(
+          new BufferedReader(new InputStreamReader(in, 
StandardCharsets.UTF_8)));
     }
-    return out.toString();
   }
 }
diff --git 
a/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceRealModelEvalTest.java
 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceRealModelEvalTest.java
index 1255827ba..fd1a9ca9e 100644
--- 
a/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceRealModelEvalTest.java
+++ 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceRealModelEvalTest.java
@@ -26,9 +26,6 @@ import java.util.stream.Stream;
 
 import org.junit.jupiter.api.Test;
 
-import opennlp.tools.tokenize.SubwordPiece;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assumptions.assumeTrue;
 
@@ -64,49 +61,14 @@ class SentencePieceRealModelEvalTest {
 
   private static void assertModel(Path modelPath, Path fixturesPath) throws 
IOException {
     final SentencePieceTokenizer tokenizer = 
SentencePieceTokenizer.load(modelPath);
-    int lines = 0;
+    final List<SentencePieceFixtures.Fixture> fixtures;
     try (BufferedReader reader = Files.newBufferedReader(fixturesPath, 
StandardCharsets.UTF_8)) {
-      String line;
-      while ((line = reader.readLine()) != null) {
-        lines++;
-        final String[] cols = line.split("\t", -1);
-        final String input = unescape(cols[0]);
-        final int count = Integer.parseInt(cols[1]);
-        final String context = modelPath.getFileName() + " input <" + input + 
">";
-
-        final List<SubwordPiece> actual = tokenizer.encode(input);
-        assertEquals(count, actual.size(), context + " piece count; got " + 
actual);
-        for (int i = 0; i < count; i++) {
-          final SubwordPiece got = actual.get(i);
-          assertEquals(unescape(cols[2 + i * 4]), got.piece(), context + " 
piece " + i);
-          assertEquals(Integer.parseInt(cols[3 + i * 4]), got.id(), context + 
" id " + i);
-          assertEquals(Integer.parseInt(cols[4 + i * 4]), got.start(), context 
+ " start " + i);
-          assertEquals(Integer.parseInt(cols[5 + i * 4]), got.end(), context + 
" end " + i);
-        }
-        assertEquals(unescape(cols[2 + count * 4]), 
tokenizer.normalize(input).toString(),
-            context + " normalized form");
-      }
+      fixtures = SentencePieceFixtures.read(reader);
     }
-    assertTrue(lines >= 30, modelPath.getFileName() + " fixtures must not be 
truncated");
-  }
-
-  private static String unescape(String s) {
-    final StringBuilder out = new StringBuilder(s.length());
-    for (int i = 0; i < s.length(); i++) {
-      final char c = s.charAt(i);
-      if (c == '\\' && i + 1 < s.length()) {
-        i++;
-        switch (s.charAt(i)) {
-          case 't' -> out.append('\t');
-          case 'n' -> out.append('\n');
-          case 'r' -> out.append('\r');
-          case '\\' -> out.append('\\');
-          default -> throw new IllegalArgumentException("bad escape in 
fixture: " + s);
-        }
-      } else {
-        out.append(c);
-      }
+    for (final SentencePieceFixtures.Fixture fixture : fixtures) {
+      SentencePieceFixtures.assertFixture(tokenizer, fixture,
+          modelPath.getFileName() + " input <" + fixture.input() + ">");
     }
-    return out.toString();
+    assertTrue(fixtures.size() >= 30, modelPath.getFileName() + " fixtures 
must not be truncated");
   }
 }
diff --git 
a/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceTokenizerSerializationTest.java
 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceTokenizerSerializationTest.java
new file mode 100644
index 000000000..f1b56b769
--- /dev/null
+++ 
b/opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceTokenizerSerializationTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.subword.sentencepiece;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+
+/**
+ * Asserts the {@code Serializable} contract inherited through
+ * {@code opennlp.tools.util.normalizer.CharSequenceNormalizer}: a tokenizer 
round-tripped
+ * through Java object serialization must encode and normalize exactly like 
the original.
+ */
+class SentencePieceTokenizerSerializationTest {
+
+  private static final String[] INPUTS = {
+      "",
+      "The quick brown fox jumps over the lazy dog.",
+      " Hello   world  ",
+      "tokenization and segmentation",
+      "caf\u00e9 na\u00efve \u4e2d\u6587"
+  };
+
+  @ParameterizedTest
+  @ValueSource(strings = {"tiny-unigram", "tiny-unigram-bytefb", "tiny-bpe",
+      "tiny-unigram-identity", "tiny-unigram-suffix"})
+  void testRoundTripPreservesEncoding(String model) throws IOException, 
ClassNotFoundException {
+    final SentencePieceTokenizer original = 
SentencePieceParityTest.tokenizer(model);
+
+    final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
+      out.writeObject(original);
+    }
+    final SentencePieceTokenizer copy;
+    try (ObjectInputStream in =
+             new ObjectInputStream(new 
ByteArrayInputStream(bytes.toByteArray()))) {
+      copy = (SentencePieceTokenizer) in.readObject();
+    }
+
+    assertEquals(original.algorithm(), copy.algorithm(), model + " algorithm");
+    assertEquals(original.vocabularySize(), copy.vocabularySize(), model + " 
vocabulary size");
+    for (final String input : INPUTS) {
+      final String context = model + " input <" + input + ">";
+      assertIterableEquals(original.encode(input), copy.encode(input), context 
+ " pieces");
+      assertEquals(original.normalize(input).toString(), 
copy.normalize(input).toString(),
+          context + " normalized form");
+    }
+  }
+}

Reply via email to