[ 
https://issues.apache.org/jira/browse/OPENNLP-1991?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18126310#comment-18126310
 ] 

ASF GitHub Bot commented on OPENNLP-1991:
-----------------------------------------

krickert commented on code in PR #1323:
URL: https://github.com/apache/opennlp/pull/1323#discussion_r4247899606


##########
opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java:
##########
@@ -0,0 +1,760 @@
+/*
+ * 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.depparse;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.StringUtil;
+
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TAGS;
+import static 
opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TAGS;
+import static 
opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.corpus;
+import static opennlp.tools.depparse.DependencyTestSamples.nonProjectiveSample;
+import static opennlp.tools.depparse.DependencyTestSamples.sample;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests feedforward training, parsing, refinement, and model serialization.
+ */
+public class FeedforwardDependencyParserTest {
+
+  /** The random seed shared by every training run in this class. */
+  private static final long SEED = 
FeedforwardDependencyTrainer.Settings.DEFAULT_SEED;
+
+  /** The header every serialized model starts with. */
+  private static final String MAGIC = FeedforwardDependencyModel.MAGIC;
+
+  /** The embedding width of the test networks. */
+  private static final int EMBEDDING_SIZE = 16;
+
+  /** The hidden width of the test networks. */
+  private static final int HIDDEN_SIZE = 32;
+
+  /** The minibatch size of the test networks. */
+  private static final int BATCH_SIZE = 32;
+
+  /** The word cutoff that gives every corpus word its own embedding row. */
+  private static final int WORD_CUTOFF = 1;
+
+  /** The number of vocabularies a serialized model starts with. */
+  private static final int VOCABULARY_COUNT = 3;
+
+  /** The first tag row in the hand-written minimal model, after three word 
rows. */
+  private static final int FIRST_TAG_ID = 3;
+
+  /** The first label row in the hand-written minimal model, after three tag 
rows. */
+  private static final int FIRST_LABEL_ID = 6;
+
+  /** The total embedding rows of the hand-written minimal model. */
+  private static final int MINIMAL_ROWS = 8;
+
+  private static FeedforwardDependencyModel model;
+  private static FeedforwardDependencyParser parser;
+
+  /**
+   * Builds the shared test hyperparameters: no dropout, no L2, and a fixed 
seed.
+   *
+   * @param epochs The number of passes over the corpus.
+   * @param learningRate The AdaGrad step size.
+   * @return The settings. Never {@code null}.
+   */
+  private static FeedforwardDependencyTrainer.Settings settings(int epochs,
+      double learningRate) {
+    return new FeedforwardDependencyTrainer.Settings(EMBEDDING_SIZE, 
HIDDEN_SIZE, epochs,
+        BATCH_SIZE, learningRate, 0.0, 0.0, WORD_CUTOFF, SEED);
+  }
+
+  /**
+   * @return The distinct training sentences, one per parameterized 
invocation. Never
+   *         {@code null}.
+   */
+  private static List<DependencySample> trainingSamples() {
+    return DependencyTestSamples.sentences();
+  }
+
+  /**
+   * Trains the shared model once for all tests, with dropout off and a fixed 
seed so
+   * the test network memorizes the corpus deterministically.
+   *
+   * @throws IOException Thrown if reading the in-memory samples fails.
+   */
+  @BeforeAll
+  static void trainParser() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(120, 0.05);
+    model = FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    parser = new FeedforwardDependencyParser(model);
+  }
+
+  /** Checks that the greedy parser reproduces a training sentence. */
+  @Test
+  void testMemorizesTrainingSentences() {
+    assertEquals(THE_DOG_BARKS_TREE, parser.parse(THE_DOG_BARKS_TOKENS, 
THE_DOG_BARKS_TAGS));
+  }
+
+  /** Checks UAS and LAS of 1.0 on the training corpus. */
+  @Test
+  void testEvaluatorScoresPerfectlyOnTrainingData() throws IOException {
+    final DependencyEvaluator evaluator = new DependencyEvaluator(parser);
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+  }
+
+  /** Checks that unseen words still parse to a single-rooted tree. */
+  @Test
+  void testUnknownWordsStillYieldASingleRootedTree() {
+    final DependencyTree parsed = parser.parse(new String[] {"unseen", 
"words"},
+        new String[] {"JJ", "NNS"});
+    assertEquals(2, parsed.size());
+    parsed.root();
+  }
+
+  /** Checks that the transition inventory holds only actions the oracle 
produced. */
+  @Test
+  void testTransitionInventoryContainsOnlyObservedActions() {
+    assertFalse(List.of(model.transitions()).contains("LEFT_ARC:root"));
+  }
+
+  /**
+   * Checks that a beam of one yields the greedy parse for every training 
sample.
+   *
+   * @param sample The training sample to parse both ways.
+   */
+  @ParameterizedTest(name = "beam of one matches greedy for {0}")
+  @MethodSource("trainingSamples")
+  void testBeamOfOneMatchesGreedy(DependencySample sample) {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 1);
+    assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+        beamed.parse(sample.getTokens(), sample.getTags()));
+  }
+
+  /** Checks that beam search reproduces a training sentence. */
+  @Test
+  void testBeamedParserReproducesTrainingSentences() {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 4);
+    assertEquals(THE_DOG_BARKS_TREE, beamed.parse(THE_DOG_BARKS_TOKENS, 
THE_DOG_BARKS_TAGS));
+  }
+
+  /** Checks that beam search is deterministic and single-rooted on unseen 
words. */
+  @Test
+  void testBeamedParseIsDeterministicAndSingleRooted() {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 8);
+    final String[] tokens = {"unseen", "words", "everywhere"};
+    final String[] tags = {"JJ", "NNS", "RB"};
+    final DependencyTree first = beamed.parse(tokens, tags);
+    assertEquals(first, beamed.parse(tokens, tags));
+    assertEquals(3, first.size());
+    first.root();
+  }
+
+  /** Checks that refinement keeps perfect scores on the training corpus. */
+  @Test
+  void testRefinementKeepsToyPerformance() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(60, 0.05);
+    final FeedforwardDependencyModel local = 
FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    final FeedforwardDependencyTrainer.Settings refineSettings = settings(2, 
0.01);
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        local, ObjectStreamUtils.createObjectStream(corpus()), refineSettings, 
2);
+
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator(new FeedforwardDependencyParser(refined, 2));
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+  }
+
+  /** Checks that refinement fails loudly on a relation label the model never 
saw. */
+  @Test
+  void testRefineRejectsAnUnknownRelation() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(1, 0.01);
+    final List<DependencySample> unseenRelation = List.of(
+        sample(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", 
"VBZ"},
+            new int[] {1, 2, -1}, new String[] {"det", "dislocated", "root"}));
+    final IllegalArgumentException e = 
assertThrows(IllegalArgumentException.class,
+        () -> FeedforwardDependencyTrainer.refine(model,
+            ObjectStreamUtils.createObjectStream(unseenRelation), settings, 
2));
+    assertEquals("unknown transition in the refinement samples: 
LEFT_ARC:dislocated",
+        e.getMessage());
+  }
+
+  /** Checks that refinement returns a distinct model and leaves the input 
scores unchanged. */
+  @Test
+  void testRefineReturnsANewModelAndLeavesTheOriginalUntouched() throws 
IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(60, 0.05);
+    final FeedforwardDependencyModel local = 
FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    final String[] tokens = THE_DOG_BARKS_TOKENS;
+    final String[] tags = THE_DOG_BARKS_TAGS;
+    final int[] features = local.featureIds(
+        FeedforwardContext.extract(new ArcStandardState(tokens.length), 
tokens, tags));
+    final double[] before = local.score(features);
+
+    final FeedforwardDependencyTrainer.Settings refineSettings = settings(2, 
0.01);
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        local, ObjectStreamUtils.createObjectStream(corpus()), refineSettings, 
2);
+
+    assertNotSame(local, refined);
+    assertArrayEquals(before, local.score(features));
+    assertFalse(Arrays.equals(before, refined.score(features)));
+  }
+
+  /**
+   * Checks the final-sigma rule applied after code-point case mapping.
+   *
+   * @param input The capitalized word.
+   * @param expected The normalized word.
+   */
+  @ParameterizedTest(name = "normalize({0}) is {1}")
+  @CsvSource({
+      // ODOS, road, all caps: the trailing sigma position lowers to U+03C2
+      "\u039F\u0394\u039F\u03A3, \u03BF\u03B4\u03BF\u03C2",
+      // SOFIA: the word-initial sigma is not final and lowers to the medial 
U+03C3
+      "\u03A3\u039F\u03A6\u0399\u0391, \u03C3\u03BF\u03C6\u03B9\u03B1",
+      // a lone capital sigma has no preceding letter, so the rule does not 
fire
+      "\u03A3, \u03C3",
+      // case-ignorable combining marks and apostrophes do not change the 
cased-letter test
+      "\u039F\u0301\u03A3, \u03BF\u0301\u03C2",
+      "\u039F\u03A3\u0301\u0391, \u03BF\u03C3\u0301\u03B1",
+      "\u039F\u2019\u03A3, \u03BF\u2019\u03C2",
+      "\u039F\u03A3\u2019\u0391, \u03BF\u03C3\u2019\u03B1"
+  })
+  void testNormalizeAppliesTheFinalSigmaRule(String input, String expected) {
+    assertEquals(expected, FeedforwardDependencyModel.normalize(input));
+  }
+
+  /** Checks that an uncased predecessor does not trigger the final-sigma 
rule. */
+  @Test
+  void testFinalSigmaRequiresCasedLetterContext() {
+    assertEquals("\u4E2D\u03C3", 
FeedforwardDependencyModel.normalize("\u4E2D\u03A3"));
+  }
+
+  /**
+   * Checks that normalization reuses a string when no case mapping is needed.
+   */
+  @Test
+  void testNormalizeReturnsTheSameInstanceForLowercaseWords() {
+    final String plain = "barks";
+    assertSame(plain, FeedforwardDependencyModel.normalize(plain));
+    // lowercase Greek with its native final sigma is already normalized
+    final String greek = "\u03BF\u03B4\u03BF\u03C2";
+    assertSame(greek, FeedforwardDependencyModel.normalize(greek));
+  }
+
+  /**
+   * Checks that non-projective samples are skipped before transition 
validation.
+   */
+  @Test
+  void testNonProjectiveSampleWithUnknownRelationIsSkippedNotFatal() throws 
IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(1, 0.01);
+    // heads {2, 3, -1, 2}: the arcs from 2 to 0 and from 3 to 1 cross, so the 
tree
+    // is non-projective, and "dislocated" is a relation the model was never 
trained on
+    final List<DependencySample> mixed = List.of(
+        nonProjectiveSample(new String[] {"a", "b", "c", "d"},
+            new String[] {"DT", "NN", "VBZ", "NN"},
+            new String[] {"det", "dislocated", "root", "obj"}),
+        sample(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", 
"VBZ"},

Review Comment:
   Uses the `THE_DOG_BARKS_*` constants, one 
`nonProjectiveSampleWithUnseenRelation()`, and one `minimalNetwork(...)` 
builder in `DependencyTestSamples`.



##########
opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java:
##########
@@ -0,0 +1,760 @@
+/*
+ * 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.depparse;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.StringUtil;
+
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TAGS;
+import static 
opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TAGS;
+import static 
opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.corpus;
+import static opennlp.tools.depparse.DependencyTestSamples.nonProjectiveSample;
+import static opennlp.tools.depparse.DependencyTestSamples.sample;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests feedforward training, parsing, refinement, and model serialization.
+ */
+public class FeedforwardDependencyParserTest {
+
+  /** The random seed shared by every training run in this class. */
+  private static final long SEED = 
FeedforwardDependencyTrainer.Settings.DEFAULT_SEED;
+
+  /** The header every serialized model starts with. */
+  private static final String MAGIC = FeedforwardDependencyModel.MAGIC;
+
+  /** The embedding width of the test networks. */
+  private static final int EMBEDDING_SIZE = 16;
+
+  /** The hidden width of the test networks. */
+  private static final int HIDDEN_SIZE = 32;
+
+  /** The minibatch size of the test networks. */
+  private static final int BATCH_SIZE = 32;
+
+  /** The word cutoff that gives every corpus word its own embedding row. */
+  private static final int WORD_CUTOFF = 1;
+
+  /** The number of vocabularies a serialized model starts with. */
+  private static final int VOCABULARY_COUNT = 3;
+
+  /** The first tag row in the hand-written minimal model, after three word 
rows. */
+  private static final int FIRST_TAG_ID = 3;
+
+  /** The first label row in the hand-written minimal model, after three tag 
rows. */
+  private static final int FIRST_LABEL_ID = 6;
+
+  /** The total embedding rows of the hand-written minimal model. */
+  private static final int MINIMAL_ROWS = 8;
+
+  private static FeedforwardDependencyModel model;
+  private static FeedforwardDependencyParser parser;
+
+  /**
+   * Builds the shared test hyperparameters: no dropout, no L2, and a fixed 
seed.
+   *
+   * @param epochs The number of passes over the corpus.
+   * @param learningRate The AdaGrad step size.
+   * @return The settings. Never {@code null}.
+   */
+  private static FeedforwardDependencyTrainer.Settings settings(int epochs,
+      double learningRate) {
+    return new FeedforwardDependencyTrainer.Settings(EMBEDDING_SIZE, 
HIDDEN_SIZE, epochs,
+        BATCH_SIZE, learningRate, 0.0, 0.0, WORD_CUTOFF, SEED);
+  }
+
+  /**
+   * @return The distinct training sentences, one per parameterized 
invocation. Never
+   *         {@code null}.
+   */
+  private static List<DependencySample> trainingSamples() {
+    return DependencyTestSamples.sentences();
+  }
+
+  /**
+   * Trains the shared model once for all tests, with dropout off and a fixed 
seed so
+   * the test network memorizes the corpus deterministically.
+   *
+   * @throws IOException Thrown if reading the in-memory samples fails.
+   */
+  @BeforeAll
+  static void trainParser() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(120, 0.05);
+    model = FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    parser = new FeedforwardDependencyParser(model);
+  }
+
+  /** Checks that the greedy parser reproduces a training sentence. */
+  @Test
+  void testMemorizesTrainingSentences() {
+    assertEquals(THE_DOG_BARKS_TREE, parser.parse(THE_DOG_BARKS_TOKENS, 
THE_DOG_BARKS_TAGS));
+  }
+
+  /** Checks UAS and LAS of 1.0 on the training corpus. */
+  @Test
+  void testEvaluatorScoresPerfectlyOnTrainingData() throws IOException {
+    final DependencyEvaluator evaluator = new DependencyEvaluator(parser);
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+  }
+
+  /** Checks that unseen words still parse to a single-rooted tree. */
+  @Test
+  void testUnknownWordsStillYieldASingleRootedTree() {
+    final DependencyTree parsed = parser.parse(new String[] {"unseen", 
"words"},
+        new String[] {"JJ", "NNS"});
+    assertEquals(2, parsed.size());
+    parsed.root();
+  }
+
+  /** Checks that the transition inventory holds only actions the oracle 
produced. */
+  @Test
+  void testTransitionInventoryContainsOnlyObservedActions() {
+    assertFalse(List.of(model.transitions()).contains("LEFT_ARC:root"));
+  }
+
+  /**
+   * Checks that a beam of one yields the greedy parse for every training 
sample.
+   *
+   * @param sample The training sample to parse both ways.
+   */
+  @ParameterizedTest(name = "beam of one matches greedy for {0}")
+  @MethodSource("trainingSamples")
+  void testBeamOfOneMatchesGreedy(DependencySample sample) {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 1);
+    assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+        beamed.parse(sample.getTokens(), sample.getTags()));
+  }
+
+  /** Checks that beam search reproduces a training sentence. */
+  @Test
+  void testBeamedParserReproducesTrainingSentences() {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 4);
+    assertEquals(THE_DOG_BARKS_TREE, beamed.parse(THE_DOG_BARKS_TOKENS, 
THE_DOG_BARKS_TAGS));
+  }
+
+  /** Checks that beam search is deterministic and single-rooted on unseen 
words. */
+  @Test
+  void testBeamedParseIsDeterministicAndSingleRooted() {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 8);
+    final String[] tokens = {"unseen", "words", "everywhere"};
+    final String[] tags = {"JJ", "NNS", "RB"};
+    final DependencyTree first = beamed.parse(tokens, tags);
+    assertEquals(first, beamed.parse(tokens, tags));
+    assertEquals(3, first.size());
+    first.root();
+  }
+
+  /** Checks that refinement keeps perfect scores on the training corpus. */
+  @Test
+  void testRefinementKeepsToyPerformance() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(60, 0.05);
+    final FeedforwardDependencyModel local = 
FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    final FeedforwardDependencyTrainer.Settings refineSettings = settings(2, 
0.01);
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        local, ObjectStreamUtils.createObjectStream(corpus()), refineSettings, 
2);
+
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator(new FeedforwardDependencyParser(refined, 2));
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+  }
+
+  /** Checks that refinement fails loudly on a relation label the model never 
saw. */
+  @Test
+  void testRefineRejectsAnUnknownRelation() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(1, 0.01);
+    final List<DependencySample> unseenRelation = List.of(
+        sample(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", 
"VBZ"},
+            new int[] {1, 2, -1}, new String[] {"det", "dislocated", "root"}));
+    final IllegalArgumentException e = 
assertThrows(IllegalArgumentException.class,
+        () -> FeedforwardDependencyTrainer.refine(model,
+            ObjectStreamUtils.createObjectStream(unseenRelation), settings, 
2));
+    assertEquals("unknown transition in the refinement samples: 
LEFT_ARC:dislocated",
+        e.getMessage());
+  }
+
+  /** Checks that refinement returns a distinct model and leaves the input 
scores unchanged. */
+  @Test
+  void testRefineReturnsANewModelAndLeavesTheOriginalUntouched() throws 
IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(60, 0.05);
+    final FeedforwardDependencyModel local = 
FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    final String[] tokens = THE_DOG_BARKS_TOKENS;
+    final String[] tags = THE_DOG_BARKS_TAGS;
+    final int[] features = local.featureIds(
+        FeedforwardContext.extract(new ArcStandardState(tokens.length), 
tokens, tags));
+    final double[] before = local.score(features);
+
+    final FeedforwardDependencyTrainer.Settings refineSettings = settings(2, 
0.01);
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        local, ObjectStreamUtils.createObjectStream(corpus()), refineSettings, 
2);
+
+    assertNotSame(local, refined);
+    assertArrayEquals(before, local.score(features));
+    assertFalse(Arrays.equals(before, refined.score(features)));
+  }
+
+  /**
+   * Checks the final-sigma rule applied after code-point case mapping.
+   *
+   * @param input The capitalized word.
+   * @param expected The normalized word.
+   */
+  @ParameterizedTest(name = "normalize({0}) is {1}")
+  @CsvSource({
+      // ODOS, road, all caps: the trailing sigma position lowers to U+03C2
+      "\u039F\u0394\u039F\u03A3, \u03BF\u03B4\u03BF\u03C2",
+      // SOFIA: the word-initial sigma is not final and lowers to the medial 
U+03C3
+      "\u03A3\u039F\u03A6\u0399\u0391, \u03C3\u03BF\u03C6\u03B9\u03B1",
+      // a lone capital sigma has no preceding letter, so the rule does not 
fire
+      "\u03A3, \u03C3",
+      // case-ignorable combining marks and apostrophes do not change the 
cased-letter test
+      "\u039F\u0301\u03A3, \u03BF\u0301\u03C2",
+      "\u039F\u03A3\u0301\u0391, \u03BF\u03C3\u0301\u03B1",
+      "\u039F\u2019\u03A3, \u03BF\u2019\u03C2",
+      "\u039F\u03A3\u2019\u0391, \u03BF\u03C3\u2019\u03B1"
+  })
+  void testNormalizeAppliesTheFinalSigmaRule(String input, String expected) {
+    assertEquals(expected, FeedforwardDependencyModel.normalize(input));
+  }
+
+  /** Checks that an uncased predecessor does not trigger the final-sigma 
rule. */
+  @Test
+  void testFinalSigmaRequiresCasedLetterContext() {
+    assertEquals("\u4E2D\u03C3", 
FeedforwardDependencyModel.normalize("\u4E2D\u03A3"));
+  }
+
+  /**
+   * Checks that normalization reuses a string when no case mapping is needed.
+   */
+  @Test
+  void testNormalizeReturnsTheSameInstanceForLowercaseWords() {
+    final String plain = "barks";
+    assertSame(plain, FeedforwardDependencyModel.normalize(plain));
+    // lowercase Greek with its native final sigma is already normalized
+    final String greek = "\u03BF\u03B4\u03BF\u03C2";
+    assertSame(greek, FeedforwardDependencyModel.normalize(greek));
+  }
+
+  /**
+   * Checks that non-projective samples are skipped before transition 
validation.
+   */
+  @Test
+  void testNonProjectiveSampleWithUnknownRelationIsSkippedNotFatal() throws 
IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(1, 0.01);
+    // heads {2, 3, -1, 2}: the arcs from 2 to 0 and from 3 to 1 cross, so the 
tree
+    // is non-projective, and "dislocated" is a relation the model was never 
trained on
+    final List<DependencySample> mixed = List.of(
+        nonProjectiveSample(new String[] {"a", "b", "c", "d"},
+            new String[] {"DT", "NN", "VBZ", "NN"},
+            new String[] {"det", "dislocated", "root", "obj"}),
+        sample(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", 
"VBZ"},
+            new int[] {1, 2, -1}, new String[] {"det", "nsubj", "root"}));
+
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        model, ObjectStreamUtils.createObjectStream(mixed), settings, 2);
+    assertNotSame(model, refined);
+  }
+
+  /**
+   * Checks that serialized vocabulary entries follow their numeric ids.
+   */
+  @Test
+  void testSerializedVocabulariesAreWrittenInAscendingIdOrder() throws 
IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    try (DataInputStream data = new DataInputStream(
+        new ByteArrayInputStream(out.toByteArray()))) {
+      data.readUTF();
+      for (int vocab = 0; vocab < VOCABULARY_COUNT; vocab++) {
+        final int size = data.readInt();
+        int previous = Integer.MIN_VALUE;
+        for (int entry = 0; entry < size; entry++) {
+          data.readUTF();
+          final int id = data.readInt();
+          assertTrue(id > previous,
+              "vocabulary " + vocab + " must be written in ascending id 
order");
+          previous = id;
+        }
+      }
+    }
+  }
+
+  /**
+   * Compares cached and direct scoring, including repeated cache reads.
+   */
+  @Test
+  void testScoringCacheMatchesTheDirectPath() {
+    final FeedforwardDependencyModel uncached = model.copy();
+    final String[] tokens = THE_DOG_BARKS_TOKENS;
+    final String[] tags = THE_DOG_BARKS_TAGS;
+    final int[] features = model.featureIds(
+        FeedforwardContext.extract(new ArcStandardState(tokens.length), 
tokens, tags));
+
+    for (int round = 0; round < 3; round++) {
+      final double[] cached = model.score(features);
+      final double[] direct = uncached.score(features);
+      assertArrayEquals(direct, cached);
+    }
+  }
+
+  /** Checks per-code-point lowering and reserved-symbol pass-through. */
+  @Test
+  void testNormalizeUsesTheUnicodeDataCaseMapping() {
+    // StringUtil maps per code point via UnicodeData, so no character 
expands; the JDK's
+    // String.toLowerCase would render this word as "i" + COMBINING DOT ABOVE 
instead.
+    assertEquals(StringUtil.toLowerCase("\u0130STANBUL"),
+        FeedforwardDependencyModel.normalize("\u0130STANBUL"));
+    assertEquals("istanbul", 
FeedforwardDependencyModel.normalize("\u0130STANBUL"));
+    assertEquals("*hello", FeedforwardDependencyModel.normalize("*HELLO"));
+    // reserved symbols still pass through untouched
+    assertEquals(FeedforwardDependencyModel.UNKNOWN,
+        
FeedforwardDependencyModel.normalize(FeedforwardDependencyModel.UNKNOWN));
+    assertNull(FeedforwardDependencyModel.normalize(null));
+  }
+
+  /** Checks the null and beam-size guards of refinement. */
+  @Test
+  void testRefineValidation() {
+    final FeedforwardDependencyTrainer.Settings settings =
+        FeedforwardDependencyTrainer.Settings.defaults();
+    assertEquals("model must not be null", 
assertThrows(IllegalArgumentException.class,
+        () -> FeedforwardDependencyTrainer.refine(null,
+            ObjectStreamUtils.createObjectStream(corpus()), settings, 
4)).getMessage());
+    assertThrows(IllegalArgumentException.class, () -> 
FeedforwardDependencyTrainer
+        .refine(model, ObjectStreamUtils.createObjectStream(corpus()), 
settings, 1));
+  }
+
+  /** Checks the parser constructor guards. */
+  @Test
+  void testBeamSizeValidation() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new FeedforwardDependencyParser(model, 0));
+    assertThrows(IllegalArgumentException.class,
+        () -> new FeedforwardDependencyParser(null, 4));
+  }
+
+  /** Checks that a serialized and reloaded model parses like the original. */
+  @Test
+  void testModelRoundTripThroughSerialization() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    final FeedforwardDependencyModel reloaded =
+        FeedforwardDependencyModel.load(new 
ByteArrayInputStream(out.toByteArray()));
+    final DependencyTree parsed = new FeedforwardDependencyParser(reloaded)
+        .parse(SHE_EATS_FISH_TOKENS, SHE_EATS_FISH_TAGS);
+    assertEquals(SHE_EATS_FISH_TREE, parsed);
+  }
+
+  /** Checks that arbitrary bytes are rejected with an IOException. */
+  @Test
+  void testCorruptModelIsRejected() {

Review Comment:
   One `@ParameterizedTest` over named payloads asserting 
`InvalidFormatException`: wrong magic, unknown version, bounds, truncation, 
duplicate and missing symbols, block layout, width, cell limit, non-finite 
weight, trailing data, and the transition cases. An accept test loads a minimal 
network with a symbol above the old `writeUTF` limit and round-trips the bytes.



##########
opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java:
##########
@@ -0,0 +1,760 @@
+/*
+ * 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.depparse;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.StringUtil;
+
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TAGS;
+import static 
opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TAGS;
+import static 
opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.corpus;
+import static opennlp.tools.depparse.DependencyTestSamples.nonProjectiveSample;
+import static opennlp.tools.depparse.DependencyTestSamples.sample;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests feedforward training, parsing, refinement, and model serialization.
+ */
+public class FeedforwardDependencyParserTest {
+
+  /** The random seed shared by every training run in this class. */
+  private static final long SEED = 
FeedforwardDependencyTrainer.Settings.DEFAULT_SEED;
+
+  /** The header every serialized model starts with. */
+  private static final String MAGIC = FeedforwardDependencyModel.MAGIC;
+
+  /** The embedding width of the test networks. */
+  private static final int EMBEDDING_SIZE = 16;
+
+  /** The hidden width of the test networks. */
+  private static final int HIDDEN_SIZE = 32;
+
+  /** The minibatch size of the test networks. */
+  private static final int BATCH_SIZE = 32;
+
+  /** The word cutoff that gives every corpus word its own embedding row. */
+  private static final int WORD_CUTOFF = 1;
+
+  /** The number of vocabularies a serialized model starts with. */
+  private static final int VOCABULARY_COUNT = 3;
+
+  /** The first tag row in the hand-written minimal model, after three word 
rows. */
+  private static final int FIRST_TAG_ID = 3;
+
+  /** The first label row in the hand-written minimal model, after three tag 
rows. */
+  private static final int FIRST_LABEL_ID = 6;
+
+  /** The total embedding rows of the hand-written minimal model. */
+  private static final int MINIMAL_ROWS = 8;
+
+  private static FeedforwardDependencyModel model;
+  private static FeedforwardDependencyParser parser;
+
+  /**
+   * Builds the shared test hyperparameters: no dropout, no L2, and a fixed 
seed.
+   *
+   * @param epochs The number of passes over the corpus.
+   * @param learningRate The AdaGrad step size.
+   * @return The settings. Never {@code null}.
+   */
+  private static FeedforwardDependencyTrainer.Settings settings(int epochs,
+      double learningRate) {
+    return new FeedforwardDependencyTrainer.Settings(EMBEDDING_SIZE, 
HIDDEN_SIZE, epochs,
+        BATCH_SIZE, learningRate, 0.0, 0.0, WORD_CUTOFF, SEED);
+  }
+
+  /**
+   * @return The distinct training sentences, one per parameterized 
invocation. Never
+   *         {@code null}.
+   */
+  private static List<DependencySample> trainingSamples() {
+    return DependencyTestSamples.sentences();
+  }
+
+  /**
+   * Trains the shared model once for all tests, with dropout off and a fixed 
seed so
+   * the test network memorizes the corpus deterministically.
+   *
+   * @throws IOException Thrown if reading the in-memory samples fails.
+   */
+  @BeforeAll
+  static void trainParser() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(120, 0.05);
+    model = FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    parser = new FeedforwardDependencyParser(model);
+  }
+
+  /** Checks that the greedy parser reproduces a training sentence. */
+  @Test
+  void testMemorizesTrainingSentences() {
+    assertEquals(THE_DOG_BARKS_TREE, parser.parse(THE_DOG_BARKS_TOKENS, 
THE_DOG_BARKS_TAGS));
+  }
+
+  /** Checks UAS and LAS of 1.0 on the training corpus. */
+  @Test
+  void testEvaluatorScoresPerfectlyOnTrainingData() throws IOException {
+    final DependencyEvaluator evaluator = new DependencyEvaluator(parser);
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+  }
+
+  /** Checks that unseen words still parse to a single-rooted tree. */
+  @Test
+  void testUnknownWordsStillYieldASingleRootedTree() {
+    final DependencyTree parsed = parser.parse(new String[] {"unseen", 
"words"},
+        new String[] {"JJ", "NNS"});
+    assertEquals(2, parsed.size());
+    parsed.root();
+  }
+
+  /** Checks that the transition inventory holds only actions the oracle 
produced. */
+  @Test
+  void testTransitionInventoryContainsOnlyObservedActions() {
+    assertFalse(List.of(model.transitions()).contains("LEFT_ARC:root"));
+  }
+
+  /**
+   * Checks that a beam of one yields the greedy parse for every training 
sample.
+   *
+   * @param sample The training sample to parse both ways.
+   */
+  @ParameterizedTest(name = "beam of one matches greedy for {0}")
+  @MethodSource("trainingSamples")
+  void testBeamOfOneMatchesGreedy(DependencySample sample) {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 1);
+    assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+        beamed.parse(sample.getTokens(), sample.getTags()));
+  }
+
+  /** Checks that beam search reproduces a training sentence. */
+  @Test
+  void testBeamedParserReproducesTrainingSentences() {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 4);
+    assertEquals(THE_DOG_BARKS_TREE, beamed.parse(THE_DOG_BARKS_TOKENS, 
THE_DOG_BARKS_TAGS));
+  }
+
+  /** Checks that beam search is deterministic and single-rooted on unseen 
words. */
+  @Test
+  void testBeamedParseIsDeterministicAndSingleRooted() {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 8);
+    final String[] tokens = {"unseen", "words", "everywhere"};
+    final String[] tags = {"JJ", "NNS", "RB"};
+    final DependencyTree first = beamed.parse(tokens, tags);
+    assertEquals(first, beamed.parse(tokens, tags));
+    assertEquals(3, first.size());
+    first.root();
+  }
+
+  /** Checks that refinement keeps perfect scores on the training corpus. */
+  @Test
+  void testRefinementKeepsToyPerformance() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(60, 0.05);
+    final FeedforwardDependencyModel local = 
FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    final FeedforwardDependencyTrainer.Settings refineSettings = settings(2, 
0.01);
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        local, ObjectStreamUtils.createObjectStream(corpus()), refineSettings, 
2);
+
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator(new FeedforwardDependencyParser(refined, 2));
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+  }
+
+  /** Checks that refinement fails loudly on a relation label the model never 
saw. */
+  @Test
+  void testRefineRejectsAnUnknownRelation() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(1, 0.01);
+    final List<DependencySample> unseenRelation = List.of(
+        sample(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", 
"VBZ"},
+            new int[] {1, 2, -1}, new String[] {"det", "dislocated", "root"}));
+    final IllegalArgumentException e = 
assertThrows(IllegalArgumentException.class,
+        () -> FeedforwardDependencyTrainer.refine(model,
+            ObjectStreamUtils.createObjectStream(unseenRelation), settings, 
2));
+    assertEquals("unknown transition in the refinement samples: 
LEFT_ARC:dislocated",
+        e.getMessage());
+  }
+
+  /** Checks that refinement returns a distinct model and leaves the input 
scores unchanged. */
+  @Test
+  void testRefineReturnsANewModelAndLeavesTheOriginalUntouched() throws 
IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(60, 0.05);
+    final FeedforwardDependencyModel local = 
FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    final String[] tokens = THE_DOG_BARKS_TOKENS;
+    final String[] tags = THE_DOG_BARKS_TAGS;
+    final int[] features = local.featureIds(
+        FeedforwardContext.extract(new ArcStandardState(tokens.length), 
tokens, tags));
+    final double[] before = local.score(features);
+
+    final FeedforwardDependencyTrainer.Settings refineSettings = settings(2, 
0.01);
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        local, ObjectStreamUtils.createObjectStream(corpus()), refineSettings, 
2);
+
+    assertNotSame(local, refined);
+    assertArrayEquals(before, local.score(features));
+    assertFalse(Arrays.equals(before, refined.score(features)));
+  }
+
+  /**
+   * Checks the final-sigma rule applied after code-point case mapping.
+   *
+   * @param input The capitalized word.
+   * @param expected The normalized word.
+   */
+  @ParameterizedTest(name = "normalize({0}) is {1}")
+  @CsvSource({
+      // ODOS, road, all caps: the trailing sigma position lowers to U+03C2
+      "\u039F\u0394\u039F\u03A3, \u03BF\u03B4\u03BF\u03C2",
+      // SOFIA: the word-initial sigma is not final and lowers to the medial 
U+03C3
+      "\u03A3\u039F\u03A6\u0399\u0391, \u03C3\u03BF\u03C6\u03B9\u03B1",
+      // a lone capital sigma has no preceding letter, so the rule does not 
fire
+      "\u03A3, \u03C3",
+      // case-ignorable combining marks and apostrophes do not change the 
cased-letter test
+      "\u039F\u0301\u03A3, \u03BF\u0301\u03C2",
+      "\u039F\u03A3\u0301\u0391, \u03BF\u03C3\u0301\u03B1",
+      "\u039F\u2019\u03A3, \u03BF\u2019\u03C2",
+      "\u039F\u03A3\u2019\u0391, \u03BF\u03C3\u2019\u03B1"
+  })
+  void testNormalizeAppliesTheFinalSigmaRule(String input, String expected) {
+    assertEquals(expected, FeedforwardDependencyModel.normalize(input));
+  }
+
+  /** Checks that an uncased predecessor does not trigger the final-sigma 
rule. */
+  @Test
+  void testFinalSigmaRequiresCasedLetterContext() {
+    assertEquals("\u4E2D\u03C3", 
FeedforwardDependencyModel.normalize("\u4E2D\u03A3"));
+  }
+
+  /**
+   * Checks that normalization reuses a string when no case mapping is needed.
+   */
+  @Test
+  void testNormalizeReturnsTheSameInstanceForLowercaseWords() {
+    final String plain = "barks";
+    assertSame(plain, FeedforwardDependencyModel.normalize(plain));
+    // lowercase Greek with its native final sigma is already normalized
+    final String greek = "\u03BF\u03B4\u03BF\u03C2";
+    assertSame(greek, FeedforwardDependencyModel.normalize(greek));
+  }
+
+  /**
+   * Checks that non-projective samples are skipped before transition 
validation.
+   */
+  @Test
+  void testNonProjectiveSampleWithUnknownRelationIsSkippedNotFatal() throws 
IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(1, 0.01);
+    // heads {2, 3, -1, 2}: the arcs from 2 to 0 and from 3 to 1 cross, so the 
tree
+    // is non-projective, and "dislocated" is a relation the model was never 
trained on
+    final List<DependencySample> mixed = List.of(
+        nonProjectiveSample(new String[] {"a", "b", "c", "d"},
+            new String[] {"DT", "NN", "VBZ", "NN"},
+            new String[] {"det", "dislocated", "root", "obj"}),
+        sample(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", 
"VBZ"},
+            new int[] {1, 2, -1}, new String[] {"det", "nsubj", "root"}));
+
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        model, ObjectStreamUtils.createObjectStream(mixed), settings, 2);
+    assertNotSame(model, refined);
+  }
+
+  /**
+   * Checks that serialized vocabulary entries follow their numeric ids.
+   */
+  @Test
+  void testSerializedVocabulariesAreWrittenInAscendingIdOrder() throws 
IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    try (DataInputStream data = new DataInputStream(
+        new ByteArrayInputStream(out.toByteArray()))) {
+      data.readUTF();
+      for (int vocab = 0; vocab < VOCABULARY_COUNT; vocab++) {
+        final int size = data.readInt();
+        int previous = Integer.MIN_VALUE;
+        for (int entry = 0; entry < size; entry++) {
+          data.readUTF();
+          final int id = data.readInt();
+          assertTrue(id > previous,
+              "vocabulary " + vocab + " must be written in ascending id 
order");
+          previous = id;
+        }
+      }
+    }
+  }
+
+  /**
+   * Compares cached and direct scoring, including repeated cache reads.
+   */
+  @Test
+  void testScoringCacheMatchesTheDirectPath() {
+    final FeedforwardDependencyModel uncached = model.copy();
+    final String[] tokens = THE_DOG_BARKS_TOKENS;
+    final String[] tags = THE_DOG_BARKS_TAGS;
+    final int[] features = model.featureIds(
+        FeedforwardContext.extract(new ArcStandardState(tokens.length), 
tokens, tags));
+
+    for (int round = 0; round < 3; round++) {
+      final double[] cached = model.score(features);
+      final double[] direct = uncached.score(features);
+      assertArrayEquals(direct, cached);
+    }
+  }
+
+  /** Checks per-code-point lowering and reserved-symbol pass-through. */
+  @Test
+  void testNormalizeUsesTheUnicodeDataCaseMapping() {
+    // StringUtil maps per code point via UnicodeData, so no character 
expands; the JDK's
+    // String.toLowerCase would render this word as "i" + COMBINING DOT ABOVE 
instead.
+    assertEquals(StringUtil.toLowerCase("\u0130STANBUL"),
+        FeedforwardDependencyModel.normalize("\u0130STANBUL"));
+    assertEquals("istanbul", 
FeedforwardDependencyModel.normalize("\u0130STANBUL"));
+    assertEquals("*hello", FeedforwardDependencyModel.normalize("*HELLO"));
+    // reserved symbols still pass through untouched
+    assertEquals(FeedforwardDependencyModel.UNKNOWN,
+        
FeedforwardDependencyModel.normalize(FeedforwardDependencyModel.UNKNOWN));
+    assertNull(FeedforwardDependencyModel.normalize(null));
+  }
+
+  /** Checks the null and beam-size guards of refinement. */
+  @Test
+  void testRefineValidation() {
+    final FeedforwardDependencyTrainer.Settings settings =
+        FeedforwardDependencyTrainer.Settings.defaults();
+    assertEquals("model must not be null", 
assertThrows(IllegalArgumentException.class,
+        () -> FeedforwardDependencyTrainer.refine(null,
+            ObjectStreamUtils.createObjectStream(corpus()), settings, 
4)).getMessage());
+    assertThrows(IllegalArgumentException.class, () -> 
FeedforwardDependencyTrainer
+        .refine(model, ObjectStreamUtils.createObjectStream(corpus()), 
settings, 1));
+  }
+
+  /** Checks the parser constructor guards. */
+  @Test
+  void testBeamSizeValidation() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new FeedforwardDependencyParser(model, 0));
+    assertThrows(IllegalArgumentException.class,
+        () -> new FeedforwardDependencyParser(null, 4));
+  }
+
+  /** Checks that a serialized and reloaded model parses like the original. */
+  @Test
+  void testModelRoundTripThroughSerialization() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    final FeedforwardDependencyModel reloaded =
+        FeedforwardDependencyModel.load(new 
ByteArrayInputStream(out.toByteArray()));
+    final DependencyTree parsed = new FeedforwardDependencyParser(reloaded)
+        .parse(SHE_EATS_FISH_TOKENS, SHE_EATS_FISH_TAGS);
+    assertEquals(SHE_EATS_FISH_TREE, parsed);
+  }
+
+  /** Checks that arbitrary bytes are rejected with an IOException. */
+  @Test
+  void testCorruptModelIsRejected() {
+    assertThrows(IOException.class, () -> FeedforwardDependencyModel.load(
+        new ByteArrayInputStream("not a 
model".getBytes(StandardCharsets.UTF_8))));
+  }
+
+  /** Checks that a negative vocabulary size is rejected with an 
InvalidFormatException. */
+  @Test
+  void testNegativeVocabularyCountFailsWithInvalidFormat() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      data.writeUTF(MAGIC);
+      data.writeInt(-1);
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that a hidden matrix of the wrong width is rejected. */
+  @Test
+  void testInconsistentModelDimensionsFailDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeMinimalModel(data, FIRST_TAG_ID, 1, 0.0f);
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that overlapping vocabulary ids are rejected. */
+  @Test
+  void testDuplicateVocabularyIdsFailDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeMinimalModel(data, 0, FeedforwardContext.FEATURE_COUNT, 0.0f);
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that a NaN weight is rejected. */
+  @Test
+  void testNonFiniteWeightFailsDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeMinimalModel(data, FIRST_TAG_ID, FeedforwardContext.FEATURE_COUNT, 
Float.NaN);
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that bytes after the model are rejected. */
+  @Test
+  void testTrailingModelDataFailsDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    out.write(1);
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that an inventory without SHIFT is rejected. */
+  @Test
+  void testModelWithoutShiftFailsDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeModelWithTransitions(data, Transition.rightArc("root").encode());
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that an inventory without RIGHT_ARC is rejected. */
+  @Test
+  void testModelWithoutRootArcFailsDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeModelWithTransitions(data, Transition.SHIFT.encode());
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /**
+   * Writes a test vocabulary with consecutive embedding indices.
+   *
+   * @param data The output to write to.
+   * @param first The embedding row of the first symbol.
+   * @param symbols The symbols, in row order.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeVocabulary(DataOutputStream data, int first, 
String... symbols)
+      throws IOException {
+    data.writeInt(symbols.length);
+    for (int i = 0; i < symbols.length; i++) {
+      data.writeUTF(symbols[i]);
+      data.writeInt(first + i);
+    }
+  }
+
+  /**
+   * Writes the smallest complete model, with selected fields exposed for 
corruption.
+   *
+   * @param data The output to write to.
+   * @param firstTagId The embedding row of the first tag symbol.
+   * @param hiddenColumns The hidden matrix width.
+   * @param outputBias The single output bias value.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeMinimalModel(DataOutputStream data, int firstTagId,
+      int hiddenColumns, float outputBias) throws IOException {
+    data.writeUTF(MAGIC);
+    writeVocabulary(data, 0,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE,
+        DependencyContextGenerator.ROOT_VALUE);
+    writeVocabulary(data, firstTagId,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE,
+        DependencyContextGenerator.ROOT_VALUE);
+    writeVocabulary(data, FIRST_LABEL_ID,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE);
+    data.writeInt(1);
+    data.writeUTF(Transition.SHIFT.encode());
+    data.writeInt(1);
+    writeMatrix(data, MINIMAL_ROWS, 1);
+    writeMatrix(data, 1, hiddenColumns);
+    writeVector(data, 1);
+    writeMatrix(data, 1, 1);
+    data.writeInt(1);
+    data.writeFloat(outputBias);
+  }
+
+  /**
+   * Writes a structurally complete model with the selected transition 
inventory.
+   *
+   * @param data The output to write to.
+   * @param transitions The encoded transitions, in output order.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeModelWithTransitions(DataOutputStream data,
+      String... transitions) throws IOException {
+    data.writeUTF(MAGIC);
+    writeVocabulary(data, 0,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE,
+        DependencyContextGenerator.ROOT_VALUE);
+    writeVocabulary(data, FIRST_TAG_ID,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE,
+        DependencyContextGenerator.ROOT_VALUE);
+    writeVocabulary(data, FIRST_LABEL_ID,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE);
+    data.writeInt(transitions.length);
+    for (final String transition : transitions) {
+      data.writeUTF(transition);
+    }
+    data.writeInt(1);
+    writeMatrix(data, MINIMAL_ROWS, 1);
+    writeMatrix(data, 1, FeedforwardContext.FEATURE_COUNT);
+    writeVector(data, 1);
+    writeMatrix(data, transitions.length, 1);
+    writeVector(data, transitions.length);
+  }
+
+  /**
+   * Writes a zero-filled matrix in the model format.
+   *
+   * @param data The output to write to.
+   * @param rows The row count.
+   * @param columns The column count.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeMatrix(DataOutputStream data, int rows, int columns)
+      throws IOException {
+    data.writeInt(rows);
+    data.writeInt(columns);
+    for (int i = 0; i < rows * columns; i++) {
+      data.writeFloat(0.0f);
+    }
+  }
+
+  /**
+   * Writes a zero-filled vector in the model format.
+   *
+   * @param data The output to write to.
+   * @param length The vector length.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeVector(DataOutputStream data, int length) throws 
IOException {
+    data.writeInt(length);
+    for (int i = 0; i < length; i++) {
+      data.writeFloat(0.0f);
+    }
+  }
+
+  /**
+   * Checks that each out-of-range hyperparameter is rejected.
+   *
+   * @param embeddingSize The embedding width.
+   * @param hiddenSize The hidden width.
+   * @param learningRate The step size.
+   * @param l2 The L2 penalty.
+   * @param dropout The dropout probability.
+   */
+  @ParameterizedTest(name = "embedding {0}, hidden {1}, rate {2}, l2 {3}, 
dropout {4}")
+  @CsvSource({
+      "0, 32, 0.05, 0.0, 0.0",
+      "16, 32, -1.0, 0.0, 0.0",
+      "16, 32, 0.05, 0.0, 1.0",
+      "16, 32, NaN, 0.0, 0.0",
+      "16, 32, 0.05, Infinity, 0.0",
+      "4097, 32, 0.05, 0.0, 0.0",
+      "16, 65537, 0.05, 0.0, 0.0",
+      "1000, 3000, 0.05, 0.0, 0.0"
+  })
+  void testSettingsValidation(int embeddingSize, int hiddenSize, double 
learningRate,

Review Comment:
   Added reject cases for epochs 0, batch size 0, cutoff -1 and dropout -0.1, 
and accept cases at 4096, 65536 and the largest dropout below 1.



##########
opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/FeedforwardDependencyParserTest.java:
##########
@@ -0,0 +1,760 @@
+/*
+ * 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.depparse;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.StringUtil;
+
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TAGS;
+import static 
opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.SHE_EATS_FISH_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TAGS;
+import static 
opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TOKENS;
+import static opennlp.tools.depparse.DependencyTestSamples.THE_DOG_BARKS_TREE;
+import static opennlp.tools.depparse.DependencyTestSamples.corpus;
+import static opennlp.tools.depparse.DependencyTestSamples.nonProjectiveSample;
+import static opennlp.tools.depparse.DependencyTestSamples.sample;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests feedforward training, parsing, refinement, and model serialization.
+ */
+public class FeedforwardDependencyParserTest {
+
+  /** The random seed shared by every training run in this class. */
+  private static final long SEED = 
FeedforwardDependencyTrainer.Settings.DEFAULT_SEED;
+
+  /** The header every serialized model starts with. */
+  private static final String MAGIC = FeedforwardDependencyModel.MAGIC;
+
+  /** The embedding width of the test networks. */
+  private static final int EMBEDDING_SIZE = 16;
+
+  /** The hidden width of the test networks. */
+  private static final int HIDDEN_SIZE = 32;
+
+  /** The minibatch size of the test networks. */
+  private static final int BATCH_SIZE = 32;
+
+  /** The word cutoff that gives every corpus word its own embedding row. */
+  private static final int WORD_CUTOFF = 1;
+
+  /** The number of vocabularies a serialized model starts with. */
+  private static final int VOCABULARY_COUNT = 3;
+
+  /** The first tag row in the hand-written minimal model, after three word 
rows. */
+  private static final int FIRST_TAG_ID = 3;
+
+  /** The first label row in the hand-written minimal model, after three tag 
rows. */
+  private static final int FIRST_LABEL_ID = 6;
+
+  /** The total embedding rows of the hand-written minimal model. */
+  private static final int MINIMAL_ROWS = 8;
+
+  private static FeedforwardDependencyModel model;
+  private static FeedforwardDependencyParser parser;
+
+  /**
+   * Builds the shared test hyperparameters: no dropout, no L2, and a fixed 
seed.
+   *
+   * @param epochs The number of passes over the corpus.
+   * @param learningRate The AdaGrad step size.
+   * @return The settings. Never {@code null}.
+   */
+  private static FeedforwardDependencyTrainer.Settings settings(int epochs,
+      double learningRate) {
+    return new FeedforwardDependencyTrainer.Settings(EMBEDDING_SIZE, 
HIDDEN_SIZE, epochs,
+        BATCH_SIZE, learningRate, 0.0, 0.0, WORD_CUTOFF, SEED);
+  }
+
+  /**
+   * @return The distinct training sentences, one per parameterized 
invocation. Never
+   *         {@code null}.
+   */
+  private static List<DependencySample> trainingSamples() {
+    return DependencyTestSamples.sentences();
+  }
+
+  /**
+   * Trains the shared model once for all tests, with dropout off and a fixed 
seed so
+   * the test network memorizes the corpus deterministically.
+   *
+   * @throws IOException Thrown if reading the in-memory samples fails.
+   */
+  @BeforeAll
+  static void trainParser() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(120, 0.05);
+    model = FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    parser = new FeedforwardDependencyParser(model);
+  }
+
+  /** Checks that the greedy parser reproduces a training sentence. */
+  @Test
+  void testMemorizesTrainingSentences() {
+    assertEquals(THE_DOG_BARKS_TREE, parser.parse(THE_DOG_BARKS_TOKENS, 
THE_DOG_BARKS_TAGS));
+  }
+
+  /** Checks UAS and LAS of 1.0 on the training corpus. */
+  @Test
+  void testEvaluatorScoresPerfectlyOnTrainingData() throws IOException {
+    final DependencyEvaluator evaluator = new DependencyEvaluator(parser);
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+  }
+
+  /** Checks that unseen words still parse to a single-rooted tree. */
+  @Test
+  void testUnknownWordsStillYieldASingleRootedTree() {
+    final DependencyTree parsed = parser.parse(new String[] {"unseen", 
"words"},
+        new String[] {"JJ", "NNS"});
+    assertEquals(2, parsed.size());
+    parsed.root();
+  }
+
+  /** Checks that the transition inventory holds only actions the oracle 
produced. */
+  @Test
+  void testTransitionInventoryContainsOnlyObservedActions() {
+    assertFalse(List.of(model.transitions()).contains("LEFT_ARC:root"));
+  }
+
+  /**
+   * Checks that a beam of one yields the greedy parse for every training 
sample.
+   *
+   * @param sample The training sample to parse both ways.
+   */
+  @ParameterizedTest(name = "beam of one matches greedy for {0}")
+  @MethodSource("trainingSamples")
+  void testBeamOfOneMatchesGreedy(DependencySample sample) {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 1);
+    assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+        beamed.parse(sample.getTokens(), sample.getTags()));
+  }
+
+  /** Checks that beam search reproduces a training sentence. */
+  @Test
+  void testBeamedParserReproducesTrainingSentences() {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 4);
+    assertEquals(THE_DOG_BARKS_TREE, beamed.parse(THE_DOG_BARKS_TOKENS, 
THE_DOG_BARKS_TAGS));
+  }
+
+  /** Checks that beam search is deterministic and single-rooted on unseen 
words. */
+  @Test
+  void testBeamedParseIsDeterministicAndSingleRooted() {
+    final FeedforwardDependencyParser beamed = new 
FeedforwardDependencyParser(model, 8);
+    final String[] tokens = {"unseen", "words", "everywhere"};
+    final String[] tags = {"JJ", "NNS", "RB"};
+    final DependencyTree first = beamed.parse(tokens, tags);
+    assertEquals(first, beamed.parse(tokens, tags));
+    assertEquals(3, first.size());
+    first.root();
+  }
+
+  /** Checks that refinement keeps perfect scores on the training corpus. */
+  @Test
+  void testRefinementKeepsToyPerformance() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(60, 0.05);
+    final FeedforwardDependencyModel local = 
FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    final FeedforwardDependencyTrainer.Settings refineSettings = settings(2, 
0.01);
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        local, ObjectStreamUtils.createObjectStream(corpus()), refineSettings, 
2);
+
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator(new FeedforwardDependencyParser(refined, 2));
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+  }
+
+  /** Checks that refinement fails loudly on a relation label the model never 
saw. */
+  @Test
+  void testRefineRejectsAnUnknownRelation() throws IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(1, 0.01);
+    final List<DependencySample> unseenRelation = List.of(
+        sample(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", 
"VBZ"},
+            new int[] {1, 2, -1}, new String[] {"det", "dislocated", "root"}));
+    final IllegalArgumentException e = 
assertThrows(IllegalArgumentException.class,
+        () -> FeedforwardDependencyTrainer.refine(model,
+            ObjectStreamUtils.createObjectStream(unseenRelation), settings, 
2));
+    assertEquals("unknown transition in the refinement samples: 
LEFT_ARC:dislocated",
+        e.getMessage());
+  }
+
+  /** Checks that refinement returns a distinct model and leaves the input 
scores unchanged. */
+  @Test
+  void testRefineReturnsANewModelAndLeavesTheOriginalUntouched() throws 
IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(60, 0.05);
+    final FeedforwardDependencyModel local = 
FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings);
+    final String[] tokens = THE_DOG_BARKS_TOKENS;
+    final String[] tags = THE_DOG_BARKS_TAGS;
+    final int[] features = local.featureIds(
+        FeedforwardContext.extract(new ArcStandardState(tokens.length), 
tokens, tags));
+    final double[] before = local.score(features);
+
+    final FeedforwardDependencyTrainer.Settings refineSettings = settings(2, 
0.01);
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        local, ObjectStreamUtils.createObjectStream(corpus()), refineSettings, 
2);
+
+    assertNotSame(local, refined);
+    assertArrayEquals(before, local.score(features));
+    assertFalse(Arrays.equals(before, refined.score(features)));
+  }
+
+  /**
+   * Checks the final-sigma rule applied after code-point case mapping.
+   *
+   * @param input The capitalized word.
+   * @param expected The normalized word.
+   */
+  @ParameterizedTest(name = "normalize({0}) is {1}")
+  @CsvSource({
+      // ODOS, road, all caps: the trailing sigma position lowers to U+03C2
+      "\u039F\u0394\u039F\u03A3, \u03BF\u03B4\u03BF\u03C2",
+      // SOFIA: the word-initial sigma is not final and lowers to the medial 
U+03C3
+      "\u03A3\u039F\u03A6\u0399\u0391, \u03C3\u03BF\u03C6\u03B9\u03B1",
+      // a lone capital sigma has no preceding letter, so the rule does not 
fire
+      "\u03A3, \u03C3",
+      // case-ignorable combining marks and apostrophes do not change the 
cased-letter test
+      "\u039F\u0301\u03A3, \u03BF\u0301\u03C2",
+      "\u039F\u03A3\u0301\u0391, \u03BF\u03C3\u0301\u03B1",
+      "\u039F\u2019\u03A3, \u03BF\u2019\u03C2",
+      "\u039F\u03A3\u2019\u0391, \u03BF\u03C3\u2019\u03B1"
+  })
+  void testNormalizeAppliesTheFinalSigmaRule(String input, String expected) {
+    assertEquals(expected, FeedforwardDependencyModel.normalize(input));
+  }
+
+  /** Checks that an uncased predecessor does not trigger the final-sigma 
rule. */
+  @Test
+  void testFinalSigmaRequiresCasedLetterContext() {
+    assertEquals("\u4E2D\u03C3", 
FeedforwardDependencyModel.normalize("\u4E2D\u03A3"));
+  }
+
+  /**
+   * Checks that normalization reuses a string when no case mapping is needed.
+   */
+  @Test
+  void testNormalizeReturnsTheSameInstanceForLowercaseWords() {
+    final String plain = "barks";
+    assertSame(plain, FeedforwardDependencyModel.normalize(plain));
+    // lowercase Greek with its native final sigma is already normalized
+    final String greek = "\u03BF\u03B4\u03BF\u03C2";
+    assertSame(greek, FeedforwardDependencyModel.normalize(greek));
+  }
+
+  /**
+   * Checks that non-projective samples are skipped before transition 
validation.
+   */
+  @Test
+  void testNonProjectiveSampleWithUnknownRelationIsSkippedNotFatal() throws 
IOException {
+    final FeedforwardDependencyTrainer.Settings settings = settings(1, 0.01);
+    // heads {2, 3, -1, 2}: the arcs from 2 to 0 and from 3 to 1 cross, so the 
tree
+    // is non-projective, and "dislocated" is a relation the model was never 
trained on
+    final List<DependencySample> mixed = List.of(
+        nonProjectiveSample(new String[] {"a", "b", "c", "d"},
+            new String[] {"DT", "NN", "VBZ", "NN"},
+            new String[] {"det", "dislocated", "root", "obj"}),
+        sample(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", 
"VBZ"},
+            new int[] {1, 2, -1}, new String[] {"det", "nsubj", "root"}));
+
+    final FeedforwardDependencyModel refined = 
FeedforwardDependencyTrainer.refine(
+        model, ObjectStreamUtils.createObjectStream(mixed), settings, 2);
+    assertNotSame(model, refined);
+  }
+
+  /**
+   * Checks that serialized vocabulary entries follow their numeric ids.
+   */
+  @Test
+  void testSerializedVocabulariesAreWrittenInAscendingIdOrder() throws 
IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    try (DataInputStream data = new DataInputStream(
+        new ByteArrayInputStream(out.toByteArray()))) {
+      data.readUTF();
+      for (int vocab = 0; vocab < VOCABULARY_COUNT; vocab++) {
+        final int size = data.readInt();
+        int previous = Integer.MIN_VALUE;
+        for (int entry = 0; entry < size; entry++) {
+          data.readUTF();
+          final int id = data.readInt();
+          assertTrue(id > previous,
+              "vocabulary " + vocab + " must be written in ascending id 
order");
+          previous = id;
+        }
+      }
+    }
+  }
+
+  /**
+   * Compares cached and direct scoring, including repeated cache reads.
+   */
+  @Test
+  void testScoringCacheMatchesTheDirectPath() {
+    final FeedforwardDependencyModel uncached = model.copy();
+    final String[] tokens = THE_DOG_BARKS_TOKENS;
+    final String[] tags = THE_DOG_BARKS_TAGS;
+    final int[] features = model.featureIds(
+        FeedforwardContext.extract(new ArcStandardState(tokens.length), 
tokens, tags));
+
+    for (int round = 0; round < 3; round++) {
+      final double[] cached = model.score(features);
+      final double[] direct = uncached.score(features);
+      assertArrayEquals(direct, cached);
+    }
+  }
+
+  /** Checks per-code-point lowering and reserved-symbol pass-through. */
+  @Test
+  void testNormalizeUsesTheUnicodeDataCaseMapping() {
+    // StringUtil maps per code point via UnicodeData, so no character 
expands; the JDK's
+    // String.toLowerCase would render this word as "i" + COMBINING DOT ABOVE 
instead.
+    assertEquals(StringUtil.toLowerCase("\u0130STANBUL"),
+        FeedforwardDependencyModel.normalize("\u0130STANBUL"));
+    assertEquals("istanbul", 
FeedforwardDependencyModel.normalize("\u0130STANBUL"));
+    assertEquals("*hello", FeedforwardDependencyModel.normalize("*HELLO"));
+    // reserved symbols still pass through untouched
+    assertEquals(FeedforwardDependencyModel.UNKNOWN,
+        
FeedforwardDependencyModel.normalize(FeedforwardDependencyModel.UNKNOWN));
+    assertNull(FeedforwardDependencyModel.normalize(null));
+  }
+
+  /** Checks the null and beam-size guards of refinement. */
+  @Test
+  void testRefineValidation() {
+    final FeedforwardDependencyTrainer.Settings settings =
+        FeedforwardDependencyTrainer.Settings.defaults();
+    assertEquals("model must not be null", 
assertThrows(IllegalArgumentException.class,
+        () -> FeedforwardDependencyTrainer.refine(null,
+            ObjectStreamUtils.createObjectStream(corpus()), settings, 
4)).getMessage());
+    assertThrows(IllegalArgumentException.class, () -> 
FeedforwardDependencyTrainer
+        .refine(model, ObjectStreamUtils.createObjectStream(corpus()), 
settings, 1));
+  }
+
+  /** Checks the parser constructor guards. */
+  @Test
+  void testBeamSizeValidation() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new FeedforwardDependencyParser(model, 0));
+    assertThrows(IllegalArgumentException.class,
+        () -> new FeedforwardDependencyParser(null, 4));
+  }
+
+  /** Checks that a serialized and reloaded model parses like the original. */
+  @Test
+  void testModelRoundTripThroughSerialization() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    final FeedforwardDependencyModel reloaded =
+        FeedforwardDependencyModel.load(new 
ByteArrayInputStream(out.toByteArray()));
+    final DependencyTree parsed = new FeedforwardDependencyParser(reloaded)
+        .parse(SHE_EATS_FISH_TOKENS, SHE_EATS_FISH_TAGS);
+    assertEquals(SHE_EATS_FISH_TREE, parsed);
+  }
+
+  /** Checks that arbitrary bytes are rejected with an IOException. */
+  @Test
+  void testCorruptModelIsRejected() {
+    assertThrows(IOException.class, () -> FeedforwardDependencyModel.load(
+        new ByteArrayInputStream("not a 
model".getBytes(StandardCharsets.UTF_8))));
+  }
+
+  /** Checks that a negative vocabulary size is rejected with an 
InvalidFormatException. */
+  @Test
+  void testNegativeVocabularyCountFailsWithInvalidFormat() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      data.writeUTF(MAGIC);
+      data.writeInt(-1);
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that a hidden matrix of the wrong width is rejected. */
+  @Test
+  void testInconsistentModelDimensionsFailDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeMinimalModel(data, FIRST_TAG_ID, 1, 0.0f);
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that overlapping vocabulary ids are rejected. */
+  @Test
+  void testDuplicateVocabularyIdsFailDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeMinimalModel(data, 0, FeedforwardContext.FEATURE_COUNT, 0.0f);
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that a NaN weight is rejected. */
+  @Test
+  void testNonFiniteWeightFailsDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeMinimalModel(data, FIRST_TAG_ID, FeedforwardContext.FEATURE_COUNT, 
Float.NaN);
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that bytes after the model are rejected. */
+  @Test
+  void testTrailingModelDataFailsDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    out.write(1);
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that an inventory without SHIFT is rejected. */
+  @Test
+  void testModelWithoutShiftFailsDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeModelWithTransitions(data, Transition.rightArc("root").encode());
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /** Checks that an inventory without RIGHT_ARC is rejected. */
+  @Test
+  void testModelWithoutRootArcFailsDuringLoading() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (DataOutputStream data = new DataOutputStream(out)) {
+      writeModelWithTransitions(data, Transition.SHIFT.encode());
+    }
+    assertThrows(InvalidFormatException.class, () -> 
FeedforwardDependencyModel.load(
+        new ByteArrayInputStream(out.toByteArray())));
+  }
+
+  /**
+   * Writes a test vocabulary with consecutive embedding indices.
+   *
+   * @param data The output to write to.
+   * @param first The embedding row of the first symbol.
+   * @param symbols The symbols, in row order.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeVocabulary(DataOutputStream data, int first, 
String... symbols)
+      throws IOException {
+    data.writeInt(symbols.length);
+    for (int i = 0; i < symbols.length; i++) {
+      data.writeUTF(symbols[i]);
+      data.writeInt(first + i);
+    }
+  }
+
+  /**
+   * Writes the smallest complete model, with selected fields exposed for 
corruption.
+   *
+   * @param data The output to write to.
+   * @param firstTagId The embedding row of the first tag symbol.
+   * @param hiddenColumns The hidden matrix width.
+   * @param outputBias The single output bias value.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeMinimalModel(DataOutputStream data, int firstTagId,
+      int hiddenColumns, float outputBias) throws IOException {
+    data.writeUTF(MAGIC);
+    writeVocabulary(data, 0,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE,
+        DependencyContextGenerator.ROOT_VALUE);
+    writeVocabulary(data, firstTagId,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE,
+        DependencyContextGenerator.ROOT_VALUE);
+    writeVocabulary(data, FIRST_LABEL_ID,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE);
+    data.writeInt(1);
+    data.writeUTF(Transition.SHIFT.encode());
+    data.writeInt(1);
+    writeMatrix(data, MINIMAL_ROWS, 1);
+    writeMatrix(data, 1, hiddenColumns);
+    writeVector(data, 1);
+    writeMatrix(data, 1, 1);
+    data.writeInt(1);
+    data.writeFloat(outputBias);
+  }
+
+  /**
+   * Writes a structurally complete model with the selected transition 
inventory.
+   *
+   * @param data The output to write to.
+   * @param transitions The encoded transitions, in output order.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeModelWithTransitions(DataOutputStream data,
+      String... transitions) throws IOException {
+    data.writeUTF(MAGIC);
+    writeVocabulary(data, 0,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE,
+        DependencyContextGenerator.ROOT_VALUE);
+    writeVocabulary(data, FIRST_TAG_ID,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE,
+        DependencyContextGenerator.ROOT_VALUE);
+    writeVocabulary(data, FIRST_LABEL_ID,
+        FeedforwardDependencyModel.UNKNOWN,
+        DependencyContextGenerator.NONE_VALUE);
+    data.writeInt(transitions.length);
+    for (final String transition : transitions) {
+      data.writeUTF(transition);
+    }
+    data.writeInt(1);
+    writeMatrix(data, MINIMAL_ROWS, 1);
+    writeMatrix(data, 1, FeedforwardContext.FEATURE_COUNT);
+    writeVector(data, 1);
+    writeMatrix(data, transitions.length, 1);
+    writeVector(data, transitions.length);
+  }
+
+  /**
+   * Writes a zero-filled matrix in the model format.
+   *
+   * @param data The output to write to.
+   * @param rows The row count.
+   * @param columns The column count.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeMatrix(DataOutputStream data, int rows, int columns)
+      throws IOException {
+    data.writeInt(rows);
+    data.writeInt(columns);
+    for (int i = 0; i < rows * columns; i++) {
+      data.writeFloat(0.0f);
+    }
+  }
+
+  /**
+   * Writes a zero-filled vector in the model format.
+   *
+   * @param data The output to write to.
+   * @param length The vector length.
+   * @throws IOException Thrown if writing fails.
+   */
+  private static void writeVector(DataOutputStream data, int length) throws 
IOException {
+    data.writeInt(length);
+    for (int i = 0; i < length; i++) {
+      data.writeFloat(0.0f);
+    }
+  }
+
+  /**
+   * Checks that each out-of-range hyperparameter is rejected.
+   *
+   * @param embeddingSize The embedding width.
+   * @param hiddenSize The hidden width.
+   * @param learningRate The step size.
+   * @param l2 The L2 penalty.
+   * @param dropout The dropout probability.
+   */
+  @ParameterizedTest(name = "embedding {0}, hidden {1}, rate {2}, l2 {3}, 
dropout {4}")
+  @CsvSource({
+      "0, 32, 0.05, 0.0, 0.0",
+      "16, 32, -1.0, 0.0, 0.0",
+      "16, 32, 0.05, 0.0, 1.0",
+      "16, 32, NaN, 0.0, 0.0",
+      "16, 32, 0.05, Infinity, 0.0",
+      "4097, 32, 0.05, 0.0, 0.0",
+      "16, 65537, 0.05, 0.0, 0.0",
+      "1000, 3000, 0.05, 0.0, 0.0"
+  })
+  void testSettingsValidation(int embeddingSize, int hiddenSize, double 
learningRate,
+      double l2, double dropout) {
+    assertThrows(IllegalArgumentException.class, () -> new 
FeedforwardDependencyTrainer
+        .Settings(embeddingSize, hiddenSize, 10, BATCH_SIZE, learningRate, l2, 
dropout,
+            WORD_CUTOFF, SEED));
+  }
+
+  /** Checks that a diverging run fails with an IllegalStateException. */
+  @Test
+  void testTrainingRejectsNonFiniteWeights() {
+    final FeedforwardDependencyTrainer.Settings settings =
+        new FeedforwardDependencyTrainer.Settings(
+            4, 4, 2, 1, Double.MAX_VALUE, 0.0, 0.0, WORD_CUTOFF, SEED);
+    assertThrows(IllegalStateException.class,
+        () -> FeedforwardDependencyTrainer.train(
+            ObjectStreamUtils.createObjectStream(corpus()), settings));
+  }
+
+  /** Checks the feature length guards of the model. */
+  @Test
+  void testModelRejectsInvalidFeatureArrays() {
+    assertThrows(IllegalArgumentException.class, () -> model.featureIds(new 
String[0]));
+    assertThrows(IllegalArgumentException.class, () -> model.score(new 
int[0]));
+  }
+
+  /** Checks that pretrained vectors seed rows and malformed vectors are 
rejected. */
+  @Test
+  void testPretrainedSeedingAppliesAndValidates() throws IOException {
+    // near-zero learning keeps the seeded row observable after one epoch
+    final FeedforwardDependencyTrainer.Settings settings =
+        new FeedforwardDependencyTrainer.Settings(4, 8, 1, BATCH_SIZE, 1e-9, 
0.0, 0.0,
+            WORD_CUTOFF, SEED);
+    final float[] vector = {0.25f, -0.5f, 0.75f, -1.0f};
+    final FeedforwardDependencyModel seeded = 
FeedforwardDependencyTrainer.train(
+        ObjectStreamUtils.createObjectStream(corpus()), settings,
+        word -> "dog".equals(word) ? vector.clone() : null);
+    final int row = seeded.wordIds().get("dog");
+    for (int d = 0; d < vector.length; d++) {
+      assertEquals(vector[d], seeded.embeddings()[row][d], 1e-4);
+    }
+    assertThrows(IllegalArgumentException.class,
+        () -> FeedforwardDependencyTrainer.train(
+            ObjectStreamUtils.createObjectStream(corpus()), settings,
+            word -> new float[] {1.0f}));
+    assertThrows(IllegalArgumentException.class,
+        () -> FeedforwardDependencyTrainer.train(
+            ObjectStreamUtils.createObjectStream(corpus()), settings,
+            word -> new float[] {Float.NaN, 0.0f, 0.0f, 0.0f}));
+  }
+
+  /** Checks the null and length guards of the parser and trainer. */
+  @Test
+  void testArgumentValidation() {

Review Comment:
   One `@ParameterizedTest` with name, expected message and `Executable`, now 
covering `parse(tokens, null)` and null samples and parameters for `refine`.





> Add a feedforward neural dependency parser
> ------------------------------------------
>
>                 Key: OPENNLP-1991
>                 URL: https://issues.apache.org/jira/browse/OPENNLP-1991
>             Project: OpenNLP
>          Issue Type: Improvement
>            Reporter: Kristian Rickert
>            Assignee: Kristian Rickert
>            Priority: Major
>          Time Spent: 10m
>  Remaining Estimate: 0h
>
> Add a feedforward neural implementation of the dependency parser API 
> introduced by OPENNLP-547. Use OpenNLP’s standard model container, artifact 
> serializers, TrainingParameters, factory, and CLI conventions. Include 
> deterministic training, greedy and beam decoding, model round-trip tests, and 
> held-out Universal Dependencies evaluation. Address the deferred review 
> feedback on shared scoring/training logic and parser-owned caches.  Implement 
> this without new dependencies.
> The list above may still get this ticket to further split.  For now this is a 
> placeholder to capture the code that was moved out of OPENNLP-547.  I'll edit 
> after 547 is merged.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to