You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opennlp.apache.org by ma...@apache.org on 2026/10/08 19:52:50 UTC

(opennlp) branch main updated: OPENNLP-547: Add a transition-based dependency parser component (#1236)

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

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


The following commit(s) were added to refs/heads/main by this push:
     new deb80bae8 OPENNLP-547: Add a transition-based dependency parser component (#1236)
deb80bae8 is described below

commit deb80bae8a07262cf79b5ae540fdeca6494a8533
Author: Kristian Rickert <kr...@apache.org>
AuthorDate: Thu Oct 8 14:52:42 2026 -0500

    OPENNLP-547: Add a transition-based dependency parser component (#1236)
    
    * OPENNLP-547: Add a transition-based dependency parser
    
    Add an arc-standard dependency parser with maxent and perceptron training,
    model persistence, and an immutable dependency tree. Skip and count
    non-projective training sentences.
    
    Read UTF-8 CoNLL-U samples with universal or language-specific POS tags.
    Add CLI tools for training, parsing, evaluation and cross-validation,
    including attachment scores, evaluation listeners and -misclassified
    output.
    
    Add treebank evaluations for English, German, Spanish and French, and a
    manual chapter with tested examples.
    
    * OPENNLP-547: Share token and tag validation through ParamChecks
    
    Add one internal check for non-empty parallel arrays and use it from the
    private validators in DependencySample and DependencyParserME. Preserve
    the validation order and name null elements by their parameter and index.
    
    Test the shared check directly and verify that samples and parsers report
    the same errors for invalid input.
---
 .../java/opennlp/tools/depparse/DependencyArc.java |  65 +++
 .../depparse/DependencyEvaluationMonitor.java      |  14 +-
 .../opennlp/tools/depparse/DependencyParser.java   |  50 +++
 .../opennlp/tools/depparse/DependencySample.java   | 145 +++++++
 .../opennlp/tools/depparse/DependencyTree.java     | 270 ++++++++++++
 .../main/java/opennlp/tools/util/ParamChecks.java  |  32 ++
 .../tools/depparse/DependencySampleTest.java       | 262 ++++++++++++
 .../opennlp/tools/depparse/DependencyTreeTest.java | 185 +++++++++
 .../java/opennlp/tools/util/ParamChecksTest.java   |  55 +++
 .../tools/cmdline/AbstractEvaluatorTool.java       |  12 +
 .../opennlp/tools/cmdline/AbstractTrainerTool.java |   7 +
 .../src/main/java/opennlp/tools/cmdline/CLI.java   |  10 +
 .../DependencyEvaluationErrorListener.java         |  65 +++
 .../cmdline/depparse/DependencyModelLoader.java}   |  30 +-
 .../DependencyParserCrossValidatorTool.java        |  79 ++++
 .../depparse/DependencyParserEvaluatorTool.java    |  71 ++++
 .../cmdline/depparse/DependencyParserMETool.java   |  89 ++++
 .../depparse/DependencyParserTrainerTool.java      |  80 ++++
 .../depparse/DependencyParserToolsTest.java        | 447 ++++++++++++++++++++
 .../tools/cmdline/StreamFactoryRegistry.java       |   2 +
 .../conllu/ConlluDependencySampleStream.java       | 293 +++++++++++++
 .../ConlluDependencySampleStreamFactory.java       | 109 +++++
 .../conllu/ConlluLemmaSampleStreamFactory.java     |   6 +-
 .../conllu/ConlluPOSSampleStreamFactory.java       |   6 +-
 .../opennlp/tools/formats/conllu/ConlluTagset.java |  55 ++-
 .../tools/formats/conllu/ConlluWordLine.java       |   2 +-
 .../conllu/ConlluDependencyParserUsageTest.java    | 214 ++++++++++
 .../ConlluDependencySampleStreamFactoryTest.java   | 113 +++++
 .../conllu/ConlluDependencySampleStreamTest.java   | 453 +++++++++++++++++++++
 .../tools/formats/conllu/ConlluTagsetTest.java     |  63 +++
 .../tools/formats/conllu/ConlluTestLines.java}     |  21 +-
 .../opennlp/tools/depparse/ArcStandardOracle.java  | 148 +++++++
 .../opennlp/tools/depparse/ArcStandardState.java   | 303 ++++++++++++++
 .../opennlp/tools/depparse/AttachmentScores.java   | 114 ++++++
 .../tools/depparse/DependencyContextGenerator.java | 348 ++++++++++++++++
 .../tools/depparse/DependencyCrossValidator.java   | 170 ++++++++
 .../tools/depparse/DependencyEvaluator.java        | 157 +++++++
 .../tools/depparse/DependencyEventStream.java      | 126 ++++++
 .../opennlp/tools/depparse/DependencyModel.java    | 125 ++++++
 .../opennlp/tools/depparse/DependencyParserME.java | 208 ++++++++++
 .../java/opennlp/tools/depparse/Transition.java    | 129 ++++++
 .../tools/depparse/ArcStandardOracleTest.java      | 195 +++++++++
 .../tools/depparse/ArcStandardStateTest.java       | 135 ++++++
 .../tools/depparse/AttachmentScoresTest.java       |  77 ++++
 .../depparse/DependencyContextGeneratorTest.java   | 235 +++++++++++
 .../depparse/DependencyCrossValidatorTest.java     | 268 ++++++++++++
 .../tools/depparse/DependencyEvaluatorTest.java    | 250 ++++++++++++
 .../depparse/DependencyParserEdgeCaseTest.java     | 178 ++++++++
 .../tools/depparse/DependencyParserMETest.java     | 314 ++++++++++++++
 .../tools/depparse/DependencyTestSamples.java      | 141 +++++++
 .../opennlp/tools/depparse/RecordingMonitor.java   |  48 +++
 .../opennlp/tools/depparse/TransitionTest.java     |  70 ++++
 opennlp-docs/src/docbkx/dependency.xml             | 135 ++++++
 opennlp-docs/src/docbkx/opennlp.xml                |   1 +
 .../tools/eval/UniversalDependencyParserEval.java  | 301 ++++++++++++++
 55 files changed, 7457 insertions(+), 24 deletions(-)

diff --git a/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyArc.java b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyArc.java
new file mode 100644
index 000000000..d82d009c3
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyArc.java
@@ -0,0 +1,65 @@
+/*
+ * 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 opennlp.tools.util.StringUtil;
+
+/**
+ * One labeled edge of a {@link DependencyTree}: the token at {@code dependent} is governed
+ * by the token at {@code head} under the given {@code relation}.
+ *
+ * <p>Indices are zero-based positions in the token array the tree was built over. A
+ * {@code head} of {@link #ROOT_HEAD} marks the dependent as the sentence root, which is
+ * attached to the artificial root node rather than to another token.</p>
+ *
+ * @param head The zero-based index of the governing token, or {@link #ROOT_HEAD} when the
+ *             dependent is the sentence root.
+ * @param dependent The zero-based index of the governed token.
+ * @param relation The dependency relation label, for example {@code nsubj}.
+ *
+ * @since 3.0.0
+ */
+public record DependencyArc(int head, int dependent, String relation) {
+
+  /**
+   * The {@code head} value marking an arc from the artificial root node.
+   */
+  public static final int ROOT_HEAD = -1;
+
+  /**
+   * Validates the arc invariants.
+   *
+   * @throws IllegalArgumentException Thrown if {@code dependent} is negative, {@code head}
+   *         is less than {@link #ROOT_HEAD}, the arc is a self-loop, or {@code relation}
+   *         is {@code null} or blank.
+   */
+  public DependencyArc {
+    if (dependent < 0) {
+      throw new IllegalArgumentException("dependent must not be negative: " + dependent);
+    }
+    if (head < ROOT_HEAD) {
+      throw new IllegalArgumentException("head must be a token index or ROOT_HEAD: " + head);
+    }
+    if (head == dependent) {
+      throw new IllegalArgumentException("arc must not be a self-loop: " + head);
+    }
+    if (relation == null || StringUtil.isBlank(relation)) {
+      throw new IllegalArgumentException("relation must not be null or blank");
+    }
+  }
+}
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyEvaluationMonitor.java
similarity index 73%
copy from opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java
copy to opennlp-api/src/main/java/opennlp/tools/depparse/DependencyEvaluationMonitor.java
index 3f6ee760a..dd181a466 100644
--- a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java
+++ b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyEvaluationMonitor.java
@@ -15,9 +15,15 @@
  * limitations under the License.
  */
 
-package opennlp.tools.formats.conllu;
+package opennlp.tools.depparse;
+
+import opennlp.tools.util.eval.EvaluationMonitor;
+
+/**
+ * A marker interface for evaluating {@link DependencyParser dependency parsers}.
+ *
+ * @since 3.0.0
+ */
+public interface DependencyEvaluationMonitor extends EvaluationMonitor<DependencySample> {
 
-public enum ConlluTagset {
-  U,
-  X
 }
diff --git a/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyParser.java b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyParser.java
new file mode 100644
index 000000000..34c625c45
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyParser.java
@@ -0,0 +1,50 @@
+/*
+ * 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;
+
+/**
+ * The interface for dependency parsers, which assign every token of a sentence a syntactic
+ * head and a relation label, forming a single-rooted tree over the sentence.
+ *
+ * <p>Dependency parsing complements the constituency {@link opennlp.tools.parser.Parser}:
+ * where a constituency parse groups tokens into nested phrases, a dependency parse links
+ * each token directly to the token it modifies. The result is a {@link DependencyTree}
+ * whose indices refer back to the input token array, so spans computed for those tokens
+ * remain valid for the parse.</p>
+ *
+ * <p>Thread safety is implementation specific.</p>
+ *
+ * @see DependencyTree
+ * @since 3.0.0
+ */
+public interface DependencyParser {
+
+  /**
+   * Parses a sentence into a {@link DependencyTree}.
+   *
+   * @param tokens The input tokens. Must not be {@code null}, must contain
+   *               at least one token, and must not contain {@code null} entries.
+   * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+   *             {@code null}, must have the same length as {@code tokens}, and must not
+   *             contain {@code null} entries.
+   * @return A {@link DependencyTree} over the given tokens. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code tokens} or {@code tags} is
+   *         {@code null}, empty, of mismatched length, or contains a {@code null} entry.
+   */
+  DependencyTree parse(String[] tokens, String[] tags);
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/depparse/DependencySample.java b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencySample.java
new file mode 100644
index 000000000..bef4bdca3
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencySample.java
@@ -0,0 +1,145 @@
+/*
+ * 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.Serial;
+import java.util.Arrays;
+import java.util.Objects;
+
+import opennlp.tools.commons.Sample;
+import opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.util.ParamChecks;
+
+/**
+ * One dependency-annotated sentence: tokens, their part-of-speech tags, and the gold
+ * {@link DependencyTree} over them. Used for training and evaluating a
+ * {@link DependencyParser}.
+ *
+ * <p>Instances are immutable and safe to share between threads.</p>
+ *
+ * @since 3.0.0
+ */
+@ThreadSafe
+public final class DependencySample implements Sample {
+
+  @Serial
+  private static final long serialVersionUID = 3889986411217462795L;
+
+  private final String[] tokens;
+  private final String[] tags;
+  private final DependencyTree tree;
+
+  /**
+   * Initializes a {@link DependencySample}.
+   *
+   * @param tokens The input tokens. Must not be {@code null} or empty and must
+   *               not contain {@code null} entries.
+   * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+   *             {@code null}, must have the same length as {@code tokens}, and must not
+   *             contain {@code null} entries.
+   * @param tree The dependency tree over the tokens. Must not be {@code null} and its
+   *              {@link DependencyTree#size()} must equal the number of tokens.
+   * @throws IllegalArgumentException Thrown if any parameter is {@code null},
+   *         {@code tokens} is empty, {@code tokens} or {@code tags} contains a
+   *         {@code null} entry, or the lengths of tokens, tags and tree disagree.
+   */
+  public DependencySample(String[] tokens, String[] tags, DependencyTree tree) {
+    ParamChecks.requireNonNullArg(tree, "tree");
+    checkTokensAndTags(tokens, tags);
+    if (tokens.length != tree.size()) {
+      throw new IllegalArgumentException("tokens, tags and tree must agree in length: "
+          + tokens.length + ", " + tags.length + ", " + tree.size());
+    }
+    this.tokens = tokens.clone();
+    this.tags = tags.clone();
+    this.tree = tree;
+  }
+
+  /**
+   * Validates the token and tag arrays of this sample.
+   *
+   * @param tokens The token array. Must not be {@code null} or empty and must not
+   *               contain {@code null} entries.
+   * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+   *             {@code null}, must have the same length as {@code tokens}, and must not
+   *             contain {@code null} entries.
+   * @throws IllegalArgumentException Thrown if an array is {@code null}, {@code tokens}
+   *         is empty, the lengths do not match, or an entry is {@code null}.
+   */
+  private void checkTokensAndTags(String[] tokens, String[] tags) {
+    ParamChecks.requireNonEmptyParallelArrays(tokens, "tokens", tags, "tags");
+  }
+
+  /**
+   * @return The tokens of the sentence. Never {@code null}.
+   */
+  public String[] getTokens() {
+    return tokens.clone();
+  }
+
+  /**
+   * @return The part-of-speech tags aligned with the tokens. Never {@code null}.
+   */
+  public String[] getTags() {
+    return tags.clone();
+  }
+
+  /**
+   * @return The dependency tree over the tokens. Never {@code null}.
+   */
+  public DependencyTree getTree() {
+    return tree;
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    if (!(obj instanceof DependencySample other)) {
+      return false;
+    }
+    return Arrays.equals(tokens, other.tokens) && Arrays.equals(tags, other.tags)
+        && tree.equals(other.tree);
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public int hashCode() {
+    return Objects.hash(Arrays.hashCode(tokens), Arrays.hashCode(tags), tree);
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public String toString() {
+    final StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < tokens.length; i++) {
+      sb.append(i + 1).append('\t').append(tokens[i]).append('\t').append(tags[i])
+          .append('\t').append(tree.headOf(i) + 1).append('\t').append(tree.relationOf(i))
+          .append(System.lineSeparator());
+    }
+    return sb.toString();
+  }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyTree.java b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyTree.java
new file mode 100644
index 000000000..935de5089
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyTree.java
@@ -0,0 +1,270 @@
+/*
+ * 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.InvalidObjectException;
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.StringUtil;
+
+/**
+ * An immutable dependency tree over one sentence: for every token, the index of its head
+ * and the label of the relation to that head.
+ *
+ * <p>Token indices are zero-based positions in the sentence the tree was built for.
+ * Exactly one token carries the head value {@link DependencyArc#ROOT_HEAD}, marking it as
+ * the sentence root. Instances are immutable and safe to share between threads.</p>
+ *
+ * @since 3.0.0
+ */
+@ThreadSafe
+public final class DependencyTree implements Serializable {
+
+  @Serial
+  private static final long serialVersionUID = -3305194579961339787L;
+
+  /** Traversal state of a token whose head chain has not been followed yet. */
+  private static final byte UNVISITED = 0;
+
+  /** Traversal state of a token on the head chain currently being followed. */
+  private static final byte VISITING = 1;
+
+  /** Traversal state of a token whose head chain is known to reach the root. */
+  private static final byte VISITED = 2;
+
+  private final int[] heads;
+  private final String[] relations;
+  private final int root;
+  private final int hash;
+
+  /**
+   * Wraps already validated arrays; instances are created through {@link #of}.
+   *
+   * @param heads The validated head array, owned by the new instance.
+   * @param relations The validated relation array, owned by the new instance.
+   * @param root The index of the single token whose head is {@link DependencyArc#ROOT_HEAD}.
+   */
+  private DependencyTree(int[] heads, String[] relations, int root) {
+    this.heads = heads;
+    this.relations = relations;
+    this.root = root;
+    this.hash = 31 * Arrays.hashCode(heads) + Arrays.hashCode(relations);
+  }
+
+  /**
+   * Creates a {@link DependencyTree} from parallel head and relation arrays.
+   *
+   * @param heads For each token, the zero-based index of its head token, or
+   *              {@link DependencyArc#ROOT_HEAD} for the sentence root. Must not be
+   *              {@code null} or empty, every value must be a valid token index or
+   *              {@link DependencyArc#ROOT_HEAD}, no token may head itself, and exactly
+   *              one token must be the root.
+   * @param relations For each token, the label of the relation to its head. Must not be
+   *                  {@code null}, must have the same length as {@code heads}, and no
+   *                  entry may be {@code null} or blank.
+   * @return A validated {@link DependencyTree}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if any of the above constraints is violated.
+   */
+  public static DependencyTree of(int[] heads, String[] relations) {
+    ParamChecks.requireNonNullArg(heads, "heads");
+    ParamChecks.requireNonNullArg(relations, "relations");
+    if (heads.length == 0) {
+      throw new IllegalArgumentException("a dependency tree needs at least one token");
+    }
+    if (heads.length != relations.length) {
+      throw new IllegalArgumentException("heads and relations must have the same length: "
+          + heads.length + " != " + relations.length);
+    }
+    int roots = 0;
+    int root = DependencyArc.ROOT_HEAD;
+    for (int i = 0; i < heads.length; i++) {
+      if (heads[i] == DependencyArc.ROOT_HEAD) {
+        roots++;
+        root = i;
+      } else if (heads[i] < 0 || heads[i] >= heads.length) {
+        throw new IllegalArgumentException("head of token " + i
+            + " is out of range: " + heads[i]);
+      } else if (heads[i] == i) {
+        throw new IllegalArgumentException("token " + i + " must not head itself");
+      }
+      if (relations[i] == null || StringUtil.isBlank(relations[i])) {
+        throw new IllegalArgumentException("relation of token " + i + " must not be blank");
+      }
+    }
+    if (roots != 1) {
+      throw new IllegalArgumentException("expected exactly one root, found " + roots);
+    }
+    checkAcyclic(heads);
+    return new DependencyTree(heads.clone(), relations.clone(), root);
+  }
+
+  /**
+   * Rejects a cycle that is disconnected from the single root by following every token's
+   * head chain until it reaches the root or a token already known to reach it.
+   *
+   * @param heads The head array, already checked for range, self-heads, and root count.
+   * @throws IllegalArgumentException Thrown if a head chain returns to a token on that
+   *         same chain.
+   */
+  private static void checkAcyclic(int[] heads) {
+    final byte[] states = new byte[heads.length];
+    for (int start = 0; start < heads.length; start++) {
+      int current = start;
+      while (current != DependencyArc.ROOT_HEAD && states[current] == UNVISITED) {
+        states[current] = VISITING;
+        current = heads[current];
+      }
+      if (current != DependencyArc.ROOT_HEAD && states[current] == VISITING) {
+        throw new IllegalArgumentException(
+            "dependency tree contains a cycle at token " + current);
+      }
+      current = start;
+      while (current != DependencyArc.ROOT_HEAD && states[current] == VISITING) {
+        states[current] = VISITED;
+        current = heads[current];
+      }
+    }
+  }
+
+  /**
+   * @return The number of tokens the tree spans.
+   */
+  public int size() {
+    return heads.length;
+  }
+
+  /**
+   * Retrieves the head of a token.
+   *
+   * @param index The zero-based token index. Must be within {@code [0, size())}.
+   * @return The zero-based index of the head token, or {@link DependencyArc#ROOT_HEAD}
+   *         when the token is the sentence root.
+   * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+   */
+  public int headOf(int index) {
+    checkIndex(index);
+    return heads[index];
+  }
+
+  /**
+   * Retrieves the relation label of a token.
+   *
+   * @param index The zero-based token index. Must be within {@code [0, size())}.
+   * @return The label of the relation between the token and its head. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+   */
+  public String relationOf(int index) {
+    checkIndex(index);
+    return relations[index];
+  }
+
+  /**
+   * @return The zero-based index of the sentence root token.
+   */
+  public int root() {
+    return root;
+  }
+
+  /**
+   * @return All arcs of the tree in token order, one per token, as a new unmodifiable
+   *     list allocated on every call. Never {@code null}.
+   */
+  public List<DependencyArc> arcs() {
+    final List<DependencyArc> arcs = new ArrayList<>(heads.length);
+    for (int i = 0; i < heads.length; i++) {
+      arcs.add(new DependencyArc(heads[i], i, relations[i]));
+    }
+    return Collections.unmodifiableList(arcs);
+  }
+
+  /**
+   * Rejects a token index outside {@code [0, size())}.
+   *
+   * @param index The zero-based token index to check.
+   * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+   */
+  private void checkIndex(int index) {
+    if (index < 0 || index >= heads.length) {
+      throw new IllegalArgumentException("token index out of range: " + index
+          + ", size: " + heads.length);
+    }
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    if (!(obj instanceof DependencyTree other)) {
+      return false;
+    }
+    return hash == other.hash && Arrays.equals(heads, other.heads)
+        && Arrays.equals(relations, other.relations);
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public int hashCode() {
+    return hash;
+  }
+
+  /**
+   * Replaces a deserialized instance by a validated one, so the arrays and the cached hash
+   * read from a stream pass the same checks as {@link #of(int[], String[])}.
+   *
+   * @return A validated tree with the same heads and relations.
+   * @throws InvalidObjectException Thrown if the stream does not hold a valid tree.
+   */
+  @Serial
+  private Object readResolve() throws InvalidObjectException {
+    try {
+      return of(heads, relations);
+    } catch (IllegalArgumentException e) {
+      final InvalidObjectException invalid = new InvalidObjectException(e.getMessage());
+      invalid.initCause(e);
+      throw invalid;
+    }
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public String toString() {
+    final StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < heads.length; i++) {
+      if (i > 0) {
+        sb.append(' ');
+      }
+      sb.append(i).append("<-").append(heads[i]).append(':').append(relations[i]);
+    }
+    return sb.toString();
+  }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/ParamChecks.java b/opennlp-api/src/main/java/opennlp/tools/util/ParamChecks.java
index 3e26dc733..39f6966f5 100644
--- a/opennlp-api/src/main/java/opennlp/tools/util/ParamChecks.java
+++ b/opennlp-api/src/main/java/opennlp/tools/util/ParamChecks.java
@@ -188,4 +188,36 @@ public final class ParamChecks {
     }
     return value;
   }
+
+  /**
+   * Checks two arrays that hold corresponding values. Both arrays must be non-empty,
+   * have the same length, and contain no {@code null} entries.
+   *
+   * @param first The first array to check.
+   * @param firstName The first parameter name used in exception messages.
+   * @param second The second array to check.
+   * @param secondName The second parameter name used in exception messages.
+   * @throws IllegalArgumentException Thrown if either array is {@code null} or empty,
+   *     their lengths differ, or an entry is {@code null}.
+   */
+  public static void requireNonEmptyParallelArrays(Object[] first, String firstName,
+      Object[] second, String secondName) {
+    requireNonNullArg(first, firstName);
+    requireNonNullArg(second, secondName);
+    if (first.length == 0) {
+      throw new IllegalArgumentException(firstName + " must not be empty");
+    }
+    if (first.length != second.length) {
+      throw new IllegalArgumentException(firstName + " and " + secondName
+          + " must have the same length: " + first.length + " != " + second.length);
+    }
+    for (int i = 0; i < first.length; i++) {
+      if (first[i] == null) {
+        throw new IllegalArgumentException(firstName + "[" + i + "]" + NOT_NULL_SUFFIX);
+      }
+      if (second[i] == null) {
+        throw new IllegalArgumentException(secondName + "[" + i + "]" + NOT_NULL_SUFFIX);
+      }
+    }
+  }
 }
diff --git a/opennlp-api/src/test/java/opennlp/tools/depparse/DependencySampleTest.java b/opennlp-api/src/test/java/opennlp/tools/depparse/DependencySampleTest.java
new file mode 100644
index 000000000..968a4508b
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/depparse/DependencySampleTest.java
@@ -0,0 +1,262 @@
+/*
+ * 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.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.ObjectStreamClass;
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+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;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.commons.Sample;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests the invariants of {@link DependencySample}.
+ */
+public class DependencySampleTest {
+
+  private static final String[] TOKENS = {"the", "dog", "barks"};
+  private static final String[] TAGS = {"DT", "NN", "VBZ"};
+
+  /** The tree matching {@link #TOKENS} and {@link #TAGS}. */
+  private static DependencyTree tree() {
+    return DependencyTree.of(new int[] {1, 2, -1},
+        new String[] {"det", "nsubj", "root"});
+  }
+
+  @Test
+  void testSampleContractAndSerialization() throws Exception {
+    final DependencySample sample = new DependencySample(TOKENS, TAGS, tree());
+    assertInstanceOf(Sample.class, sample);
+    final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
+      out.writeObject(sample);
+    }
+    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
+      assertEquals(sample, in.readObject());
+    }
+  }
+
+  @Test
+  void testAccessors() {
+    final DependencySample sample = new DependencySample(TOKENS, TAGS, tree());
+    assertArrayEquals(TOKENS, sample.getTokens());
+    assertArrayEquals(TAGS, sample.getTags());
+    assertEquals(tree(), sample.getTree());
+  }
+
+  @Test
+  void testEquals() {
+    assertEquals(new DependencySample(TOKENS, TAGS, tree()),
+        new DependencySample(TOKENS, TAGS, tree()));
+  }
+
+  @Test
+  void testNullArgumentsThrow() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(null, TAGS, tree()));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(TOKENS, null, tree()));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(TOKENS, TAGS, null));
+  }
+
+  @Test
+  void testNullTokenOrTagThrows() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(new String[] {"the", null, "barks"}, TAGS, tree()));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(TOKENS, new String[] {"DT", null, "VBZ"}, tree()));
+  }
+
+  @Test
+  void testLengthMismatchThrows() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(new String[] {"one"}, TAGS, tree()));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(TOKENS, new String[] {"DT"}, tree()));
+  }
+
+  @Test
+  void testEmptySampleThrows() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(new String[0], new String[0], tree()));
+  }
+
+  @Test
+  void testNullEntriesThrow() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(null, TAGS, tree()));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(TOKENS, null, tree()));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(new String[] {"the", null, "barks"}, TAGS, tree()));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(TOKENS, new String[] {"DT", null, "VBZ"}, tree()));
+  }
+
+  /**
+   * Pins the message of every token and tag rejection of the constructor.
+   *
+   * @return Token and tag arrays with the expected message.
+   */
+  static Stream<Arguments> rejectedTokensAndTags() {
+    return Stream.of(
+        Arguments.of(null, null, "tokens must not be null"),
+        Arguments.of(null, TAGS, "tokens must not be null"),
+        Arguments.of(TOKENS, null, "tags must not be null"),
+        Arguments.of(new String[0], new String[0], "tokens must not be empty"),
+        Arguments.of(new String[0], TAGS, "tokens must not be empty"),
+        Arguments.of(new String[] {"one"}, TAGS, "tokens and tags must have the same length: 1 != 3"),
+        Arguments.of(TOKENS, new String[] {"DT"}, "tokens and tags must have the same length: 3 != 1"),
+        Arguments.of(TOKENS, new String[0], "tokens and tags must have the same length: 3 != 0"),
+        Arguments.of(new String[] {null, "dog", "barks"}, TAGS, "tokens[0] must not be null"),
+        Arguments.of(new String[] {"the", "dog", null}, TAGS, "tokens[2] must not be null"),
+        Arguments.of(TOKENS, new String[] {"DT", "NN", null}, "tags[2] must not be null"),
+        Arguments.of(new String[] {"the", null, "barks"}, new String[] {null, "NN", "VBZ"},
+            "tags[0] must not be null"));
+  }
+
+  /** Rejected token and tag arrays report the first violation in array order. */
+  @ParameterizedTest(name = "{2}")
+  @MethodSource("rejectedTokensAndTags")
+  void testCheckTokensAndTagsMessages(String[] tokens, String[] tags, String message) {
+    final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(tokens, tags, tree()));
+    assertEquals(message, exception.getMessage());
+  }
+
+  /** The validation checks presence only: empty, blank and supplementary-plane strings pass. */
+  @ParameterizedTest(name = "\"{0}\"")
+  @ValueSource(strings = {"", " ", "\t", "\u00a0", "\uD835\uDFCF", "_"})
+  void testCheckTokensAndTagsAcceptsAnyNonNullString(String value) {
+    final String[] tokens = {value, "dog", value};
+    final String[] tags = {value, value, "VBZ"};
+    final DependencySample sample = new DependencySample(tokens, tags, tree());
+    assertArrayEquals(tokens, sample.getTokens());
+    assertArrayEquals(tags, sample.getTags());
+  }
+
+  /** A single-token sentence is the smallest accepted sample. */
+  @Test
+  void testSingleTokenSample() {
+    final DependencySample sample = new DependencySample(new String[] {"Run"},
+        new String[] {"VB"}, DependencyTree.of(new int[] {-1}, new String[] {"root"}));
+    assertEquals(1, sample.getTokens().length);
+    assertEquals("1\tRun\tVB\t0\troot" + System.lineSeparator(), sample.toString());
+  }
+
+  /** Long arrays are validated in one pass and pass when every entry is present. */
+  @Test
+  void testCheckTokensAndTagsAcceptsLongArrays() {
+    final int length = 100_000;
+    final String[] tokens = new String[length];
+    final String[] tags = new String[length];
+    Arrays.fill(tokens, "w");
+    Arrays.fill(tags, "T");
+    final int[] heads = new int[length];
+    final String[] relations = new String[length];
+    Arrays.fill(relations, "dep");
+    for (int i = 1; i < length; i++) {
+      heads[i] = i - 1;
+    }
+    heads[0] = DependencyArc.ROOT_HEAD;
+    relations[0] = "root";
+    final DependencyTree tree = DependencyTree.of(heads, relations);
+    assertEquals(length, new DependencySample(tokens, tags, tree).getTokens().length);
+    tags[length - 1] = null;
+    final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(tokens, tags, tree));
+    assertEquals("tags[" + (length - 1) + "] must not be null", exception.getMessage());
+  }
+
+  /** The stream class carries the declared serial version UID, so the field is picked up. */
+  @Test
+  void testSerialVersionUid() {
+    assertEquals(3889986411217462795L,
+        ObjectStreamClass.lookup(DependencySample.class).getSerialVersionUID());
+  }
+
+  /** A deserialized sample keeps the hash code and the array contents of the original. */
+  @Test
+  void testDeserializedSampleKeepsHashCodeAndContents() throws Exception {
+    final DependencySample sample = new DependencySample(TOKENS, TAGS, tree());
+    final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
+      out.writeObject(sample);
+    }
+    final DependencySample copy;
+    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
+      copy = (DependencySample) in.readObject();
+    }
+    assertEquals(sample.hashCode(), copy.hashCode());
+    assertArrayEquals(TOKENS, copy.getTokens());
+    assertArrayEquals(TAGS, copy.getTags());
+    assertEquals(tree(), copy.getTree());
+    assertEquals(sample.toString(), copy.toString());
+  }
+
+  /**
+   * Samples that differ in one part.
+   *
+   * @return Samples that are not equal to the fixture sample.
+   */
+  static Stream<Arguments> differentSamples() {
+    return Stream.of(
+        Arguments.of("tokens", new DependencySample(new String[] {"a", "dog", "barks"}, TAGS, tree())),
+        Arguments.of("tags", new DependencySample(TOKENS, new String[] {"DT", "NNS", "VBZ"}, tree())),
+        Arguments.of("relation", new DependencySample(TOKENS, TAGS,
+            DependencyTree.of(new int[] {1, 2, -1}, new String[] {"det", "obj", "root"}))),
+        Arguments.of("head", new DependencySample(TOKENS, TAGS,
+            DependencyTree.of(new int[] {2, 2, -1}, new String[] {"det", "nsubj", "root"}))));
+  }
+
+  /** The equals and hashCode contract: equal samples share a hash code, changed parts differ. */
+  @ParameterizedTest(name = "different {0}")
+  @MethodSource("differentSamples")
+  void testEqualsAndHashCodeContract(String part, DependencySample other) {
+    final DependencySample sample = new DependencySample(TOKENS, TAGS, tree());
+    assertEquals(sample, sample);
+    assertEquals(sample.hashCode(), new DependencySample(TOKENS, TAGS, tree()).hashCode());
+    assertNotEquals(sample, other);
+    assertNotEquals(sample, null);
+    assertNotEquals(sample, TOKENS);
+  }
+
+  @Test
+  void testInputArraysAreCopied() {
+    final String[] tokens = TOKENS.clone();
+    final DependencySample sample = new DependencySample(tokens, TAGS, tree());
+    tokens[0] = "a";
+    assertEquals("the", sample.getTokens()[0]);
+  }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/depparse/DependencyTreeTest.java b/opennlp-api/src/test/java/opennlp/tools/depparse/DependencyTreeTest.java
new file mode 100644
index 000000000..fd9414eba
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/depparse/DependencyTreeTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.ObjectStreamClass;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests the invariants and accessors of {@link DependencyTree} and {@link DependencyArc}.
+ */
+public class DependencyTreeTest {
+
+  /** The three-token tree shared by the accessor tests. */
+  private static DependencyTree sample() {
+    return DependencyTree.of(new int[] {1, 2, -1},
+        new String[] {"det", "nsubj", "root"});
+  }
+
+  @Test
+  void testAccessors() {
+    final DependencyTree tree = sample();
+    assertEquals(3, tree.size());
+    assertEquals(1, tree.headOf(0));
+    assertEquals(2, tree.headOf(1));
+    assertEquals(DependencyArc.ROOT_HEAD, tree.headOf(2));
+    assertEquals("nsubj", tree.relationOf(1));
+    assertEquals(2, tree.root());
+  }
+
+  @Test
+  void testSerializationPreservesRoot() throws Exception {
+    final DependencyTree tree = sample();
+    final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
+      out.writeObject(tree);
+    }
+    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
+      final DependencyTree restored = (DependencyTree) in.readObject();
+      assertEquals(tree, restored);
+      assertEquals(tree.hashCode(), restored.hashCode());
+      assertEquals(tree.root(), restored.root());
+      assertEquals(DependencyArc.ROOT_HEAD, restored.headOf(restored.root()));
+    }
+  }
+
+  /** The stream class carries the declared serial version UID, so the field is picked up. */
+  @Test
+  void testSerialVersionUid() {
+    assertEquals(-3305194579961339787L,
+        ObjectStreamClass.lookup(DependencyTree.class).getSerialVersionUID());
+  }
+
+  @Test
+  void testArcsAreInTokenOrder() {
+    final List<DependencyArc> arcs = sample().arcs();
+    assertEquals(3, arcs.size());
+    assertEquals(new DependencyArc(1, 0, "det"), arcs.get(0));
+    assertEquals(new DependencyArc(2, 1, "nsubj"), arcs.get(1));
+    assertEquals(new DependencyArc(DependencyArc.ROOT_HEAD, 2, "root"), arcs.get(2));
+  }
+
+  @Test
+  void testEqualsAndHashCode() {
+    assertEquals(sample(), sample());
+    assertEquals(sample().hashCode(), sample().hashCode());
+    assertNotEquals(sample(), DependencyTree.of(new int[] {1, 2, -1},
+        new String[] {"amod", "nsubj", "root"}));
+  }
+
+  @Test
+  void testInputArraysAreCopied() {
+    final int[] heads = {1, -1};
+    final String[] relations = {"nsubj", "root"};
+    final DependencyTree tree = DependencyTree.of(heads, relations);
+    heads[0] = 0;
+    relations[0] = "det";
+    assertEquals(1, tree.headOf(0));
+    assertEquals("nsubj", tree.relationOf(0));
+  }
+
+  @Test
+  void testNullArraysThrow() {
+    assertEquals("heads must not be null", assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(null, new String[] {"root"})).getMessage());
+    assertEquals("relations must not be null", assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {-1}, null)).getMessage());
+  }
+
+  @Test
+  void testEmptyTreeThrows() {
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[0], new String[0]));
+  }
+
+  @Test
+  void testLengthMismatchThrows() {
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {-1}, new String[] {"root", "nsubj"}));
+  }
+
+  @Test
+  void testRootCountIsEnforced() {
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {1, 0}, new String[] {"a", "b"}));
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {-1, -1}, new String[] {"root", "root"}));
+  }
+
+  @Test
+  void testDisconnectedCycleThrows() {
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {-1, 2, 1},
+            new String[] {"root", "dep", "dep"}));
+  }
+
+  @Test
+  void testSelfHeadThrows() {
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {0, -1}, new String[] {"a", "root"}));
+  }
+
+  @Test
+  void testOutOfRangeHeadThrows() {
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {2, -1}, new String[] {"a", "root"}));
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {-3, -1}, new String[] {"a", "root"}));
+  }
+
+  @Test
+  void testBlankRelationThrows() {
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {1, -1}, new String[] {" ", "root"}));
+    // blankness follows the toolkit whitespace definition, which covers the no-break
+    // space U+00A0 that the JDK predicate leaves out
+    assertThrows(IllegalArgumentException.class,
+        () -> DependencyTree.of(new int[] {1, -1}, new String[] {"\u00A0", "root"}));
+    // and a label that only looks unusual is still content
+    assertEquals("nmod:poss", DependencyTree.of(new int[] {1, -1},
+        new String[] {"nmod:poss", "root"}).relationOf(0));
+  }
+
+  @Test
+  void testIndexBoundsThrow() {
+    final DependencyTree tree = sample();
+    assertThrows(IllegalArgumentException.class, () -> tree.headOf(-1));
+    assertThrows(IllegalArgumentException.class, () -> tree.relationOf(3));
+  }
+
+  @Test
+  void testArcValidation() {
+    assertThrows(IllegalArgumentException.class, () -> new DependencyArc(0, 0, "root"));
+    assertThrows(IllegalArgumentException.class, () -> new DependencyArc(1, -1, "det"));
+    assertThrows(IllegalArgumentException.class, () -> new DependencyArc(-2, 0, "det"));
+    assertThrows(IllegalArgumentException.class, () -> new DependencyArc(1, 0, " "));
+    assertThrows(IllegalArgumentException.class, () -> new DependencyArc(1, 0, "\u00A0"));
+    assertThrows(IllegalArgumentException.class, () -> new DependencyArc(1, 0, null));
+    assertEquals("det", new DependencyArc(1, 0, "det").relation());
+  }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/util/ParamChecksTest.java b/opennlp-api/src/test/java/opennlp/tools/util/ParamChecksTest.java
index 38c8ed3ba..d93f6a930 100644
--- a/opennlp-api/src/test/java/opennlp/tools/util/ParamChecksTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/util/ParamChecksTest.java
@@ -20,11 +20,15 @@ package opennlp.tools.util;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.Stream;
 
 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;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -36,6 +40,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
  */
 public class ParamChecksTest {
 
+  private static final String FIRST_NAME = "words";
+  private static final String SECOND_NAME = "labels";
+
   @Test
   void testNonNullArgumentIsReturnedUnchanged() {
     final Object value = new Object();
@@ -182,4 +189,52 @@ public class ParamChecksTest {
         () -> ParamChecks.requireFinite(value, "weight"));
     assertEquals("weight must be finite", e.getMessage());
   }
+
+  /**
+   * Supplies invalid parallel arrays, including inputs that violate several checks.
+   *
+   * @return The arrays and the expected message for the first violation.
+   */
+  private static Stream<Arguments> rejectedParallelArrays() {
+    final Object[] values = {"a", "b"};
+    return Stream.of(
+        Arguments.of(null, values, "words must not be null"),
+        Arguments.of(values, null, "labels must not be null"),
+        Arguments.of(null, null, "words must not be null"),
+        Arguments.of(new Object[0], null, "labels must not be null"),
+        Arguments.of(new Object[0], new Object[0], "words must not be empty"),
+        Arguments.of(new Object[0], values, "words must not be empty"),
+        Arguments.of(values, new Object[0], "words and labels must have the same length: 2 != 0"),
+        Arguments.of(new Object[] {null}, values,
+            "words and labels must have the same length: 1 != 2"),
+        Arguments.of(new Object[] {null, "b"}, values, "words[0] must not be null"),
+        Arguments.of(new Object[] {"a", null}, values, "words[1] must not be null"),
+        Arguments.of(values, new Object[] {null, "b"}, "labels[0] must not be null"),
+        Arguments.of(values, new Object[] {"a", null}, "labels[1] must not be null"),
+        Arguments.of(new Object[] {null, "b"}, new Object[] {null, "b"},
+            "words[0] must not be null"),
+        Arguments.of(new Object[] {"a", null}, new Object[] {null, "b"},
+            "labels[0] must not be null"));
+  }
+
+  @ParameterizedTest(name = "{2}")
+  @MethodSource("rejectedParallelArrays")
+  void testParallelArraysRejectInvalidInput(Object[] first, Object[] second, String message) {
+    final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+        () -> ParamChecks.requireNonEmptyParallelArrays(first, FIRST_NAME, second, SECOND_NAME));
+    assertEquals(message, exception.getMessage());
+  }
+
+  @Test
+  void testParallelArraysAcceptDifferentElementTypesWithoutChangingTheArrays() {
+    final String[] first = {"", "a", "b"};
+    final Integer[] second = {0, 1, 2};
+    final String[] firstCopy = first.clone();
+    final Integer[] secondCopy = second.clone();
+
+    ParamChecks.requireNonEmptyParallelArrays(first, FIRST_NAME, second, SECOND_NAME);
+
+    assertArrayEquals(firstCopy, first);
+    assertArrayEquals(secondCopy, second);
+  }
 }
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/AbstractEvaluatorTool.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/AbstractEvaluatorTool.java
index 8f7d45afc..9afa24ef8 100644
--- a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/AbstractEvaluatorTool.java
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/AbstractEvaluatorTool.java
@@ -17,6 +17,8 @@
 
 package opennlp.tools.cmdline;
 
+import java.io.IOException;
+
 import opennlp.tools.util.ObjectStream;
 
 /**
@@ -38,6 +40,16 @@ public abstract class AbstractEvaluatorTool<T, P> extends AbstractTypedParamTool
     super(sampleType, params);
   }
 
+  /**
+   * Creates a {@link TerminateToolException} for an I/O error while reading evaluation data.
+   *
+   * @param e The exception raised while reading the samples.
+   * @return The exception to throw. Never {@code null}.
+   */
+  protected TerminateToolException createTerminationIOException(IOException e) {
+    return new TerminateToolException(-1, "IO error while reading test data: " + e.getMessage(), e);
+  }
+
   @Override
   public void run(String format, String[] args) {
     validateAllArgs(args, this.paramsClass, format);
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/AbstractTrainerTool.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/AbstractTrainerTool.java
index 2f4719af7..0b766555b 100644
--- a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/AbstractTrainerTool.java
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/AbstractTrainerTool.java
@@ -39,6 +39,13 @@ public abstract class AbstractTrainerTool<T, P> extends AbstractEvaluatorTool<T,
     super(sampleType, params);
   }
 
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Reports training-data errors, with a specific message for
+   * {@link InsufficientTrainingDataException}.</p>
+   */
+  @Override
   protected TerminateToolException createTerminationIOException(IOException e) {
 
     if (e instanceof InsufficientTrainingDataException) {
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java
index e09be4a8f..ff682d053 100644
--- a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java
@@ -33,6 +33,10 @@ import opennlp.tools.cmdline.chunker.ChunkerCrossValidatorTool;
 import opennlp.tools.cmdline.chunker.ChunkerEvaluatorTool;
 import opennlp.tools.cmdline.chunker.ChunkerMETool;
 import opennlp.tools.cmdline.chunker.ChunkerTrainerTool;
+import opennlp.tools.cmdline.depparse.DependencyParserCrossValidatorTool;
+import opennlp.tools.cmdline.depparse.DependencyParserEvaluatorTool;
+import opennlp.tools.cmdline.depparse.DependencyParserMETool;
+import opennlp.tools.cmdline.depparse.DependencyParserTrainerTool;
 import opennlp.tools.cmdline.dictionary.DictionaryBuilderTool;
 import opennlp.tools.cmdline.doccat.DoccatConverterTool;
 import opennlp.tools.cmdline.doccat.DoccatCrossValidatorTool;
@@ -162,6 +166,12 @@ public final class CLI {
     tools.add(new LemmatizerTrainerTool());
     tools.add(new LemmatizerEvaluatorTool());
 
+    // Dependency parser
+    tools.add(new DependencyParserTrainerTool());
+    tools.add(new DependencyParserEvaluatorTool());
+    tools.add(new DependencyParserCrossValidatorTool());
+    tools.add(new DependencyParserMETool());
+
     // Chunker
     tools.add(new ChunkerMETool());
     tools.add(new ChunkerTrainerTool());
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyEvaluationErrorListener.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyEvaluationErrorListener.java
new file mode 100644
index 000000000..0a5103625
--- /dev/null
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyEvaluationErrorListener.java
@@ -0,0 +1,65 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.OutputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.cmdline.EvaluationErrorPrinter;
+import opennlp.tools.depparse.DependencyEvaluationMonitor;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.log.LogPrintStream;
+
+/**
+ * A {@link DependencyEvaluationMonitor} that prints each misparsed sentence next to its
+ * gold tree, in the five-column form of {@link DependencySample#toString()}.
+ */
+public class DependencyEvaluationErrorListener extends EvaluationErrorPrinter<DependencySample>
+    implements DependencyEvaluationMonitor {
+
+  private static final Logger logger =
+      LoggerFactory.getLogger(DependencyEvaluationErrorListener.class);
+
+  /**
+   * Creates a listener that prints to the logger of this class.
+   */
+  public DependencyEvaluationErrorListener() {
+    super(new LogPrintStream(logger));
+  }
+
+  /**
+   * Creates a listener that prints to the given {@link OutputStream}.
+   *
+   * @param outputStream The stream to print to. Must not be {@code null}.
+   */
+  public DependencyEvaluationErrorListener(OutputStream outputStream) {
+    super(outputStream);
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Prints the reference sample and the prediction.</p>
+   */
+  @Override
+  public void misclassified(DependencySample reference, DependencySample prediction) {
+    printError(reference, prediction);
+  }
+}
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyModelLoader.java
similarity index 51%
copy from opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java
copy to opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyModelLoader.java
index 3f6ee760a..78e464b31 100644
--- a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyModelLoader.java
@@ -15,9 +15,31 @@
  * limitations under the License.
  */
 
-package opennlp.tools.formats.conllu;
+package opennlp.tools.cmdline.depparse;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import opennlp.tools.cmdline.ModelLoader;
+import opennlp.tools.commons.Internal;
+import opennlp.tools.depparse.DependencyModel;
+
+/**
+ * Loads a {@link DependencyModel} for the command line tools.
+ * <p>
+ * <b>Note:</b> Do not use this class, internal use only!
+ */
+@Internal
+public class DependencyModelLoader extends ModelLoader<DependencyModel> {
+
+  /** Initializes a loader that names the model "Dependency parser" in its messages. */
+  public DependencyModelLoader() {
+    super("Dependency parser");
+  }
+
+  @Override
+  protected DependencyModel loadModel(InputStream modelIn) throws IOException {
+    return new DependencyModel(modelIn);
+  }
 
-public enum ConlluTagset {
-  U,
-  X
 }
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserCrossValidatorTool.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserCrossValidatorTool.java
new file mode 100644
index 000000000..0cf1fb6dc
--- /dev/null
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserCrossValidatorTool.java
@@ -0,0 +1,79 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.cmdline.AbstractCrossValidatorTool;
+import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.cmdline.depparse.DependencyParserCrossValidatorTool.CrossValidationParams;
+import opennlp.tools.cmdline.params.BasicTrainingParams;
+import opennlp.tools.cmdline.params.CVParams;
+import opennlp.tools.depparse.DependencyCrossValidator;
+import opennlp.tools.depparse.DependencyEvaluationMonitor;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.util.ObjectStream;
+
+/** Cross validates an arc-standard dependency parser on CoNLL-U samples. */
+public class DependencyParserCrossValidatorTool
+    extends AbstractCrossValidatorTool<DependencySample, CrossValidationParams> {
+
+  interface CrossValidationParams extends CVParams, BasicTrainingParams {
+  }
+
+  private static final Logger logger = LoggerFactory.getLogger(DependencyParserCrossValidatorTool.class);
+
+  /** Creates the cross validator tool. */
+  public DependencyParserCrossValidatorTool() {
+    super(DependencySample.class, CrossValidationParams.class);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public String getShortDescription() {
+    return "Cross validates a dependency parser";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void run(String format, String[] args) {
+    super.run(format, args);
+    try (ObjectStream<DependencySample> samples = sampleStream) {
+      mlParams = DependencyParserTrainerTool.loadTrainingParameters(params.getParams());
+      DependencyEvaluationMonitor errorListener = null;
+      if (params.getMisclassified()) {
+        errorListener = new DependencyEvaluationErrorListener();
+      }
+      DependencyCrossValidator validator =
+          new DependencyCrossValidator(params.getLang(), mlParams, errorListener);
+      validator.evaluate(samples, params.getFolds());
+      logger.info("Tokens: {}; UAS: {}; LAS: {}", validator.getWordCount(),
+          validator.getUas(), validator.getLas());
+      logger.info("Tokens excluding punctuation: {}; UAS: {}; LAS: {}",
+          validator.getWordCountExcludingPunctuation(),
+          validator.getUasExcludingPunctuation(), validator.getLasExcludingPunctuation());
+    } catch (IOException e) {
+      throw createTerminationIOException(e);
+    } catch (IllegalArgumentException e) {
+      throw new TerminateToolException(-1, e.getMessage(), e);
+    }
+  }
+}
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserEvaluatorTool.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserEvaluatorTool.java
new file mode 100644
index 000000000..8d25e52a2
--- /dev/null
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserEvaluatorTool.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.tools.cmdline.depparse;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.cmdline.AbstractEvaluatorTool;
+import opennlp.tools.cmdline.params.EvaluatorParams;
+import opennlp.tools.depparse.DependencyEvaluationMonitor;
+import opennlp.tools.depparse.DependencyEvaluator;
+import opennlp.tools.depparse.DependencyParserME;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.util.ObjectStream;
+
+/** Reports labeled and unlabeled attachment scores against gold CoNLL-U trees. */
+public class DependencyParserEvaluatorTool
+    extends AbstractEvaluatorTool<DependencySample, EvaluatorParams> {
+
+  private static final Logger logger = LoggerFactory.getLogger(DependencyParserEvaluatorTool.class);
+
+  /** Creates the evaluator tool. */
+  public DependencyParserEvaluatorTool() {
+    super(DependencySample.class, EvaluatorParams.class);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public String getShortDescription() {
+    return "Measures dependency parser attachment scores";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void run(String format, String[] args) {
+    super.run(format, args);
+    try (ObjectStream<DependencySample> samples = sampleStream) {
+      DependencyEvaluationMonitor errorListener = null;
+      if (params.getMisclassified()) {
+        errorListener = new DependencyEvaluationErrorListener();
+      }
+      DependencyEvaluator evaluator = new DependencyEvaluator(new DependencyParserME(
+          new DependencyModelLoader().load(params.getModel())), errorListener);
+      evaluator.evaluate(samples);
+      logger.info("Tokens: {}; UAS: {}; LAS: {}", evaluator.getWordCount(),
+          evaluator.getUas(), evaluator.getLas());
+      logger.info("Tokens excluding punctuation: {}; UAS: {}; LAS: {}",
+          evaluator.getWordCountExcludingPunctuation(),
+          evaluator.getUasExcludingPunctuation(), evaluator.getLasExcludingPunctuation());
+    } catch (IOException e) {
+      throw createTerminationIOException(e);
+    }
+  }
+}
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserMETool.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserMETool.java
new file mode 100644
index 000000000..66cc5342a
--- /dev/null
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserMETool.java
@@ -0,0 +1,89 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+
+import opennlp.tools.cmdline.BasicCmdLineTool;
+import opennlp.tools.cmdline.CLI;
+import opennlp.tools.cmdline.CmdLineUtil;
+import opennlp.tools.cmdline.SystemInputStreamFactory;
+import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.depparse.DependencyParserME;
+import opennlp.tools.depparse.DependencyTree;
+import opennlp.tools.postag.POSSample;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.PlainTextByLineStream;
+import opennlp.tools.util.StringUtil;
+
+/** Parses POS-tagged sentences from standard input and prints basic CoNLL-U trees. */
+public class DependencyParserMETool extends BasicCmdLineTool {
+
+  /** Separates the fields of a CoNLL-U token row. */
+  private static final String FIELD_SEPARATOR = "\t";
+
+  /** Fills a CoNLL-U field that the parser does not predict. */
+  private static final String EMPTY_FIELD = "_";
+
+  /** {@inheritDoc} */
+  @Override
+  public String getShortDescription() {
+    return "Parses dependency trees from POS-tagged sentences";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public String getHelp() {
+    return "Usage: " + CLI.CMD + " " + getName() + " model < tagged-sentences";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void run(String[] args) {
+    if (args.length != 1) {
+      throw new TerminateToolException(1, getHelp());
+    }
+    DependencyParserME parser = new DependencyParserME(new DependencyModelLoader().load(new File(args[0])));
+    PrintWriter out = new PrintWriter(System.out, true);
+    try (ObjectStream<String> lines = new PlainTextByLineStream(
+        new SystemInputStreamFactory(), SystemInputStreamFactory.encoding())) {
+      String line;
+      while ((line = lines.read()) != null) {
+        if (StringUtil.isBlank(line)) {
+          continue;
+        }
+        POSSample sample = POSSample.parse(line);
+        String[] tokens = sample.getSentence();
+        String[] tags = sample.getTags();
+        DependencyTree tree = parser.parse(tokens, tags);
+        for (int i = 0; i < tokens.length; i++) {
+          // Input tags may be UPOS or XPOS; retain them in XPOS without guessing a mapping.
+          out.println(String.join(FIELD_SEPARATOR, Integer.toString(i + 1), tokens[i],
+              EMPTY_FIELD, EMPTY_FIELD, tags[i], EMPTY_FIELD,
+              Integer.toString(tree.headOf(i) + 1), tree.relationOf(i),
+              EMPTY_FIELD, EMPTY_FIELD));
+        }
+        out.println();
+      }
+    } catch (IOException e) {
+      CmdLineUtil.handleStdinIoError(e);
+    }
+  }
+}
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserTrainerTool.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserTrainerTool.java
new file mode 100644
index 000000000..09bf7d590
--- /dev/null
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/depparse/DependencyParserTrainerTool.java
@@ -0,0 +1,80 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.IOException;
+
+import opennlp.tools.cmdline.AbstractTrainerTool;
+import opennlp.tools.cmdline.CmdLineUtil;
+import opennlp.tools.cmdline.depparse.DependencyParserTrainerTool.TrainerParams;
+import opennlp.tools.cmdline.params.TrainingToolParams;
+import opennlp.tools.depparse.DependencyModel;
+import opennlp.tools.depparse.DependencyParserME;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.TrainingParameters;
+
+/** Trains and saves an arc-standard dependency parser model. */
+public class DependencyParserTrainerTool extends AbstractTrainerTool<DependencySample, TrainerParams> {
+
+  interface TrainerParams extends TrainingToolParams {
+  }
+
+  /** Creates the trainer tool. */
+  public DependencyParserTrainerTool() {
+    super(DependencySample.class, TrainerParams.class);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public String getName() {
+    return "DependencyParserTrainerME";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public String getShortDescription() {
+    return "Trains a dependency parser from CoNLL-U samples";
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void run(String format, String[] args) {
+    super.run(format, args);
+    try (ObjectStream<DependencySample> samples = sampleStream) {
+      CmdLineUtil.checkOutputFile("dependency parser model", params.getModel());
+      mlParams = loadTrainingParameters(params.getParams());
+      DependencyModel model = DependencyParserME.train(params.getLang(), samples, mlParams);
+      CmdLineUtil.writeModel("dependency parser", params.getModel(), model);
+    } catch (IOException e) {
+      throw createTerminationIOException(e);
+    }
+  }
+
+  /**
+   * Loads the training parameters file named on the command line, falling back to the
+   * default parameters when none is given.
+   *
+   * @param paramsFile The parameters file name, or {@code null} if none was given.
+   * @return The training parameters. Never {@code null}.
+   */
+  static TrainingParameters loadTrainingParameters(String paramsFile) {
+    TrainingParameters loaded = CmdLineUtil.loadTrainingParameters(paramsFile, false);
+    return loaded == null ? TrainingParameters.defaultParams() : loaded;
+  }
+}
diff --git a/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyParserToolsTest.java b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyParserToolsTest.java
new file mode 100644
index 000000000..9ff205367
--- /dev/null
+++ b/opennlp-core/opennlp-cli/src/test/java/opennlp/tools/cmdline/depparse/DependencyParserToolsTest.java
@@ -0,0 +1,447 @@
+/*
+ * 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.cmdline.depparse;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.cmdline.CLI;
+import opennlp.tools.cmdline.StreamFactoryRegistry;
+import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.depparse.DependencyModel;
+import opennlp.tools.depparse.DependencyParserME;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.depparse.DependencyTree;
+import opennlp.tools.util.ObjectStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Exercises CLI registration, CoNLL-U input, training, persistence and inference. */
+public class DependencyParserToolsTest {
+
+  /** A two-token sentence in CoNLL-U; the tests repeat it to build a corpus. */
+  private static final String SENTENCE = """
+      1\tdogs\tdog\tNOUN\tNNS\t_\t2\tnsubj\t_\t_
+      2\trun\trun\tVERB\tVBP\t_\t0\troot\t_\t_
+
+      """;
+
+  /** How often {@link #SENTENCE} is repeated in the training data. */
+  private static final int REPETITIONS = 20;
+
+  /** The gold tree of {@link #SENTENCE}. */
+  private static final DependencyTree GOLD =
+      DependencyTree.of(new int[] {1, -1}, new String[] {"nsubj", "root"});
+
+  /** The sentence with the heads swapped, which a parser trained on {@link #SENTENCE} misparses. */
+  private static final String REVERSED_SENTENCE = """
+      1\tdogs\tdog\tNOUN\tNNS\t_\t0\troot\t_\t_
+      2\trun\trun\tVERB\tVBP\t_\t1\tobj\t_\t_
+
+      """;
+
+  /** The gold tree of {@link #REVERSED_SENTENCE}. */
+  private static final DependencyTree REVERSED =
+      DependencyTree.of(new int[] {-1, 0}, new String[] {"root", "obj"});
+
+  /** The expected block the error listener prints for {@link #REVERSED_SENTENCE}. */
+  private static final String EXPECTED_REVERSED = "Expected: {\n"
+      + "1\tdogs\tNOUN\t0\troot" + System.lineSeparator()
+      + "2\trun\tVERB\t1\tobj" + System.lineSeparator() + "}";
+
+  /** The predicted block the error listener prints for the parse of {@link #SENTENCE}. */
+  private static final String PREDICTED_SENTENCE = "Predicted: {\n"
+      + "1\tdogs\tNOUN\t2\tnsubj" + System.lineSeparator()
+      + "2\trun\tVERB\t0\troot" + System.lineSeparator() + "}";
+
+  /** The sentence with the subject relabeled, so only a label differs from {@link #SENTENCE}. */
+  private static final String RELABELED_SENTENCE = """
+      1\tdogs\tdog\tNOUN\tNNS\t_\t2\tnmod\t_\t_
+      2\trun\trun\tVERB\tVBP\t_\t0\troot\t_\t_
+
+      """;
+
+  /** The expected block the error listener prints for {@link #RELABELED_SENTENCE}. */
+  private static final String EXPECTED_RELABELED = "Expected: {\n"
+      + "1\tdogs\tNOUN\t2\tnmod" + System.lineSeparator()
+      + "2\trun\tVERB\t0\troot" + System.lineSeparator() + "}";
+
+  /** The message prefix of a tool ended by an unusable argument value. */
+  private static final String INVALID_ARGUMENT = "Invalid argument: ";
+
+  /** The message of the CoNLL-U format for an encoding other than UTF-8. */
+  private static final String UTF_8_REQUIRED = "CoNLL-U data must use UTF-8";
+
+  /** The scores logged for a parser that reproduces all 40 tokens of the data. */
+  private static final String PERFECT_SCORES = "Tokens: 40; UAS: 1.0; LAS: 1.0";
+
+  /** The punctuation-free scores logged for the same parser; the data has no punctuation. */
+  private static final String PERFECT_SCORES_EXCLUDING_PUNCTUATION =
+      "Tokens excluding punctuation: 40; UAS: 1.0; LAS: 1.0";
+
+  @TempDir
+  private Path dir;
+
+  private Path data;
+  private Path model;
+
+  @BeforeEach
+  void writeData() throws IOException {
+    data = dir.resolve("train.conllu");
+    model = dir.resolve("parser.bin");
+    Files.writeString(data, SENTENCE.repeat(REPETITIONS));
+  }
+
+  /** Trains a model into {@link #model} through the trainer tool. */
+  private void train() {
+    new DependencyParserTrainerTool().run("conllu", new String[] {
+        "-lang", "eng", "-data", data.toString(), "-model", model.toString()});
+  }
+
+  /**
+   * Runs a tool while capturing the INFO messages its logger emits.
+   *
+   * @param tool The tool class whose logger is captured.
+   * @param run Runs the tool.
+   * @return The formatted log messages, in order. Never {@code null}.
+   */
+  private static List<String> logOf(Class<?> tool, Runnable run) {
+    final Logger logger = (Logger) LoggerFactory.getLogger(tool);
+    final Level previous = logger.getLevel();
+    final ListAppender<ILoggingEvent> appender = new ListAppender<>();
+    appender.start();
+    logger.addAppender(appender);
+    logger.setLevel(Level.INFO);
+    try {
+      run.run();
+    } finally {
+      logger.setLevel(previous);
+      logger.detachAppender(appender);
+    }
+    return appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList();
+  }
+
+  @Test
+  void testToolsAreRegistered() {
+    assertTrue(CLI.getToolNames().contains("DependencyParserME"));
+    assertTrue(CLI.getToolNames().contains("DependencyParserTrainerME"));
+    assertTrue(CLI.getToolNames().contains("DependencyParserEvaluator"));
+    assertTrue(CLI.getToolNames().contains("DependencyParserCrossValidator"));
+  }
+
+  @Test
+  void testTrainerWritesALoadableModel() throws IOException {
+    train();
+    assertEquals(GOLD, new DependencyParserME(new DependencyModel(model))
+        .parse(new String[] {"dogs", "run"}, new String[] {"NOUN", "VERB"}));
+  }
+
+  @Test
+  void testEvaluatorLogsAttachmentScores() {
+    train();
+    final List<String> log = logOf(DependencyParserEvaluatorTool.class,
+        () -> new DependencyParserEvaluatorTool().run("conllu", new String[] {
+            "-model", model.toString(), "-data", data.toString()}));
+    assertEquals(List.of(PERFECT_SCORES, PERFECT_SCORES_EXCLUDING_PUNCTUATION), log);
+  }
+
+  @Test
+  void testCrossValidatorLogsAttachmentScores() {
+    final List<String> log = logOf(DependencyParserCrossValidatorTool.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", new String[] {
+            "-lang", "eng", "-data", data.toString(), "-folds", "2"}));
+    assertEquals(List.of(PERFECT_SCORES, PERFECT_SCORES_EXCLUDING_PUNCTUATION), log);
+  }
+
+  @Test
+  void testParserToolPrintsConllu() {
+    train();
+    final InputStream previousIn = System.in;
+    final PrintStream previousOut = System.out;
+    final ByteArrayOutputStream output = new ByteArrayOutputStream();
+    try {
+      System.setIn(new ByteArrayInputStream("dogs_NOUN run_VERB\n".getBytes(StandardCharsets.UTF_8)));
+      System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8));
+      new DependencyParserMETool().run(new String[] {model.toString()});
+    } finally {
+      System.setIn(previousIn);
+      System.setOut(previousOut);
+    }
+    assertEquals("1\tdogs\t_\t_\tNOUN\t_\t2\tnsubj\t_\t_\n"
+        + "2\trun\t_\t_\tVERB\t_\t0\troot\t_\t_\n\n",
+        output.toString(StandardCharsets.UTF_8).replace("\r\n", "\n"));
+  }
+
+  @Test
+  void testConlluFormatReadsTheSelectedTagset() throws IOException {
+    final var factory = StreamFactoryRegistry.getFactory(DependencySample.class, "conllu");
+    try (ObjectStream<DependencySample> samples = factory.create(new String[] {
+        "-data", data.toString(), "-tagset", "x"})) {
+      assertEquals("NNS", samples.read().getTags()[0]);
+    }
+    try (ObjectStream<DependencySample> samples = factory.create(new String[] {
+        "-data", data.toString()})) {
+      assertEquals("NOUN", samples.read().getTags()[0]);
+    }
+  }
+
+  @Test
+  void testConlluFormatRejectsUnknownTagset() {
+    final var factory = StreamFactoryRegistry.getFactory(DependencySample.class, "conllu");
+    assertThrows(TerminateToolException.class, () -> factory.create(new String[] {
+        "-data", data.toString(), "-tagset", "invalid"}));
+  }
+
+  @ParameterizedTest
+  @ValueSource(ints = {Integer.MIN_VALUE, -1, 0, 1})
+  void testCrossValidatorRejectsFoldCountBelowTwo(int folds) {
+    final TerminateToolException exception = assertThrows(TerminateToolException.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", new String[] {
+            "-lang", "eng", "-data", data.toString(), "-folds", Integer.toString(folds)}));
+    assertEquals(-1, exception.getCode());
+    assertEquals("folds must be at least 2: " + folds, exception.getMessage());
+  }
+
+  @Test
+  void testCrossValidatorRunsWithDefaultFoldCount() {
+    final List<String> log = logOf(DependencyParserCrossValidatorTool.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", new String[] {
+            "-lang", "eng", "-data", data.toString(), "-misclassified", "true"}));
+    assertEquals(List.of(PERFECT_SCORES, PERFECT_SCORES_EXCLUDING_PUNCTUATION), log);
+  }
+
+  @Test
+  void testEvaluatorPrintsMisparsedSamples() throws IOException {
+    train();
+    final Path test = dir.resolve("test.conllu");
+    Files.writeString(test, REVERSED_SENTENCE);
+    final List<String> log = logOf(DependencyEvaluationErrorListener.class,
+        () -> new DependencyParserEvaluatorTool().run("conllu", new String[] {
+            "-model", model.toString(), "-data", test.toString(), "-misclassified", "true"}));
+    assertEquals(List.of(EXPECTED_REVERSED + "\n" + PREDICTED_SENTENCE), log);
+  }
+
+  @Test
+  void testEvaluatorWithoutMisclassifiedFlagPrintsNoSamples() throws IOException {
+    train();
+    final Path test = dir.resolve("test.conllu");
+    Files.writeString(test, REVERSED_SENTENCE);
+    final List<String> log = logOf(DependencyEvaluationErrorListener.class,
+        () -> new DependencyParserEvaluatorTool().run("conllu", new String[] {
+            "-model", model.toString(), "-data", test.toString()}));
+    assertEquals(List.of(), log);
+  }
+
+  /** With {@code -misclassified false} the evaluator prints no samples either. */
+  @Test
+  void testEvaluatorWithMisclassifiedFalsePrintsNoSamples() throws IOException {
+    train();
+    final Path test = dir.resolve("test.conllu");
+    Files.writeString(test, REVERSED_SENTENCE);
+    final List<String> log = logOf(DependencyEvaluationErrorListener.class,
+        () -> new DependencyParserEvaluatorTool().run("conllu", new String[] {
+            "-model", model.toString(), "-data", test.toString(), "-misclassified", "false"}));
+    assertEquals(List.of(), log);
+  }
+
+  /** A sentence that differs only in a label counts for UAS, not LAS, and is printed as misparsed. */
+  @Test
+  void testEvaluatorPrintsLabelOnlyDifference() throws IOException {
+    train();
+    final Path test = dir.resolve("test.conllu");
+    Files.writeString(test, RELABELED_SENTENCE);
+    final List<List<String>> listenerLog = new ArrayList<>();
+    final List<String> scores = logOf(DependencyParserEvaluatorTool.class,
+        () -> listenerLog.add(logOf(DependencyEvaluationErrorListener.class,
+            () -> new DependencyParserEvaluatorTool().run("conllu", new String[] {
+                "-model", model.toString(), "-data", test.toString(), "-misclassified", "true"}))));
+    assertEquals(List.of("Tokens: 2; UAS: 1.0; LAS: 0.5",
+        "Tokens excluding punctuation: 2; UAS: 1.0; LAS: 0.5"), scores);
+    assertEquals(List.of(List.of(EXPECTED_RELABELED + "\n" + PREDICTED_SENTENCE)), listenerLog);
+  }
+
+  /** The cross validator prints a held-out sentence the parser of its fold misparses. */
+  @Test
+  void testCrossValidatorPrintsMisparsedSamples() throws IOException {
+    Files.writeString(data, SENTENCE.repeat(REPETITIONS) + REVERSED_SENTENCE);
+    final List<String> log = logOf(DependencyEvaluationErrorListener.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", new String[] {
+            "-lang", "eng", "-data", data.toString(), "-folds", "2", "-misclassified", "true"}));
+    assertEquals(List.of(EXPECTED_REVERSED + "\n" + PREDICTED_SENTENCE), log);
+  }
+
+  /** With {@code -misclassified false}, or without the flag, the cross validator prints no samples. */
+  @ParameterizedTest(name = "-misclassified \"{0}\"")
+  @ValueSource(strings = {"false", ""})
+  void testCrossValidatorWithoutMisclassifiedPrintsNoSamples(String flag) throws IOException {
+    Files.writeString(data, SENTENCE.repeat(REPETITIONS) + REVERSED_SENTENCE);
+    final List<String> args = new ArrayList<>(List.of(
+        "-lang", "eng", "-data", data.toString(), "-folds", "2"));
+    if (!flag.isEmpty()) {
+      args.addAll(List.of("-misclassified", flag));
+    }
+    final List<String> log = logOf(DependencyEvaluationErrorListener.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", args.toArray(new String[0])));
+    assertEquals(List.of(), log);
+  }
+
+  /** A fold count that is not an integer is rejected by the argument parser with code 1. */
+  @ParameterizedTest(name = "folds = \"{0}\"")
+  @ValueSource(strings = {"abc", "2.5", "", "0x2", "two", " 2"})
+  void testCrossValidatorRejectsNonIntegerFoldCount(String folds) {
+    final TerminateToolException exception = assertThrows(TerminateToolException.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", new String[] {
+            "-lang", "eng", "-data", data.toString(), "-folds", folds}));
+    assertEquals(1, exception.getCode());
+    assertTrue(exception.getMessage().startsWith(INVALID_ARGUMENT + "-folds " + folds),
+        exception.getMessage());
+    assertTrue(exception.getMessage().endsWith("Value must be an integer!"), exception.getMessage());
+  }
+
+  /** Both tools pass an {@code -encoding} other than UTF-8 on to the format, which rejects it. */
+  @Test
+  void testToolsRejectOtherEncodings() {
+    train();
+    final TerminateToolException evaluator = assertThrows(TerminateToolException.class,
+        () -> new DependencyParserEvaluatorTool().run("conllu", new String[] {
+            "-model", model.toString(), "-data", data.toString(), "-encoding", "ISO-8859-1"}));
+    assertEquals(-1, evaluator.getCode());
+    assertEquals(UTF_8_REQUIRED, evaluator.getMessage());
+    final TerminateToolException validator = assertThrows(TerminateToolException.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", new String[] {
+            "-lang", "eng", "-data", data.toString(), "-encoding", "UTF-16"}));
+    assertEquals(-1, validator.getCode());
+    assertEquals(UTF_8_REQUIRED, validator.getMessage());
+  }
+
+  /** A missing data file ends both tools while the format opens the data. */
+  @Test
+  void testToolsRejectMissingDataFile() {
+    train();
+    final Path missing = dir.resolve("missing.conllu");
+    final String message = "The Data file does not exist! Path: " + missing.toAbsolutePath();
+    final TerminateToolException evaluator = assertThrows(TerminateToolException.class,
+        () -> new DependencyParserEvaluatorTool().run("conllu", new String[] {
+            "-model", model.toString(), "-data", missing.toString()}));
+    assertEquals(-1, evaluator.getCode());
+    assertEquals(message, evaluator.getMessage());
+    final TerminateToolException validator = assertThrows(TerminateToolException.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", new String[] {
+            "-lang", "eng", "-data", missing.toString()}));
+    assertEquals(-1, validator.getCode());
+    assertEquals(message, validator.getMessage());
+  }
+
+  /** Unreadable test data ends the evaluator with the evaluation I/O message. */
+  @Test
+  void testEvaluatorReportsUnreadableTestData() throws IOException {
+    train();
+    final Path bad = dir.resolve("bad.conllu");
+    Files.write(bad, malformedUtf8());
+    final TerminateToolException exception = assertThrows(TerminateToolException.class,
+        () -> new DependencyParserEvaluatorTool().run("conllu", new String[] {
+            "-model", model.toString(), "-data", bad.toString()}));
+    assertEquals(-1, exception.getCode());
+    assertTrue(exception.getMessage().startsWith("IO error while reading test data: "),
+        exception.getMessage());
+    assertTrue(exception.getCause() instanceof IOException);
+  }
+
+  /** Unreadable training data ends the cross validator with the training I/O message. */
+  @Test
+  void testCrossValidatorReportsUnreadableTrainingData() throws IOException {
+    Files.write(data, malformedUtf8());
+    final TerminateToolException exception = assertThrows(TerminateToolException.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", new String[] {
+            "-lang", "eng", "-data", data.toString()}));
+    assertEquals(-1, exception.getCode());
+    assertTrue(exception.getMessage().startsWith(
+        "IO error while reading training data or indexing data: "), exception.getMessage());
+  }
+
+  /** Empty test data scores zero tokens without failing. */
+  @Test
+  void testEvaluatorOnEmptyDataLogsZeroScores() throws IOException {
+    train();
+    final Path empty = dir.resolve("empty.conllu");
+    Files.writeString(empty, "");
+    final List<String> log = logOf(DependencyParserEvaluatorTool.class,
+        () -> new DependencyParserEvaluatorTool().run("conllu", new String[] {
+            "-model", model.toString(), "-data", empty.toString(), "-misclassified", "true"}));
+    assertEquals(List.of("Tokens: 0; UAS: 0.0; LAS: 0.0",
+        "Tokens excluding punctuation: 0; UAS: 0.0; LAS: 0.0"), log);
+  }
+
+  /** Empty training data ends the cross validator with the training data message. */
+  @Test
+  void testCrossValidatorOnEmptyDataTerminates() throws IOException {
+    Files.writeString(data, "");
+    final TerminateToolException exception = assertThrows(TerminateToolException.class,
+        () -> new DependencyParserCrossValidatorTool().run("conllu", new String[] {
+            "-lang", "eng", "-data", data.toString()}));
+    assertEquals(-1, exception.getCode());
+    assertTrue(exception.getMessage().contains("Not enough training data"), exception.getMessage());
+  }
+
+  /**
+   * Builds a word line whose form holds a truncated UTF-8 sequence.
+   *
+   * @return The bytes of one word line and a newline.
+   */
+  private static byte[] malformedUtf8() {
+    final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    bytes.writeBytes("1\t".getBytes(StandardCharsets.UTF_8));
+    bytes.write(0xc3);
+    bytes.writeBytes("\t_\tNOUN\tNNS\t_\t0\troot\t_\t_\n".getBytes(StandardCharsets.UTF_8));
+    return bytes.toByteArray();
+  }
+
+  @Test
+  void testErrorListenerPrintsBothSamples() {
+    final ByteArrayOutputStream output = new ByteArrayOutputStream();
+    final DependencySample reference = new DependencySample(
+        new String[] {"dogs", "run"}, new String[] {"NOUN", "VERB"}, REVERSED);
+    final DependencySample prediction = new DependencySample(
+        new String[] {"dogs", "run"}, new String[] {"NOUN", "VERB"}, GOLD);
+    new DependencyEvaluationErrorListener(output).misclassified(reference, prediction);
+    final String expected = EXPECTED_REVERSED + "\n" + PREDICTED_SENTENCE + "\n\n";
+    assertEquals(expected.replace("\r\n", "\n"),
+        output.toString(StandardCharsets.UTF_8).replace("\r\n", "\n"));
+  }
+}
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/cmdline/StreamFactoryRegistry.java b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/cmdline/StreamFactoryRegistry.java
index 14972980b..d3d5d833c 100644
--- a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/cmdline/StreamFactoryRegistry.java
+++ b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/cmdline/StreamFactoryRegistry.java
@@ -45,6 +45,7 @@ import opennlp.tools.formats.ad.ADPOSSampleStreamFactory;
 import opennlp.tools.formats.ad.ADSentenceSampleStreamFactory;
 import opennlp.tools.formats.ad.ADTokenSampleStreamFactory;
 import opennlp.tools.formats.brat.BratNameSampleStreamFactory;
+import opennlp.tools.formats.conllu.ConlluDependencySampleStreamFactory;
 import opennlp.tools.formats.conllu.ConlluLemmaSampleStreamFactory;
 import opennlp.tools.formats.conllu.ConlluPOSSampleStreamFactory;
 import opennlp.tools.formats.conllu.ConlluSentenceSampleStreamFactory;
@@ -130,6 +131,7 @@ public final class StreamFactoryRegistry {
     LetsmtSentenceStreamFactory.registerFactory();
     MosesSentenceSampleStreamFactory.registerFactory();
 
+    ConlluDependencySampleStreamFactory.registerFactory();
     ConlluTokenSampleStreamFactory.registerFactory();
     ConlluSentenceSampleStreamFactory.registerFactory();
     ConlluPOSSampleStreamFactory.registerFactory();
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluDependencySampleStream.java b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluDependencySampleStream.java
new file mode 100644
index 000000000..4e4d8c375
--- /dev/null
+++ b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluDependencySampleStream.java
@@ -0,0 +1,293 @@
+/*
+ * 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.formats.conllu;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.depparse.DependencyTree;
+import opennlp.tools.util.InputStreamFactory;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.StringUtil;
+
+/**
+ * Reads {@link DependencySample samples} directly from
+ * <a href="https://universaldependencies.org/format.html">CoNLL-U</a> content, mapping
+ * the {@code HEAD} and {@code DEPREL} columns of the basic dependency annotation.
+ *
+ * <p>This reader does not use {@link ConlluStream}, which merges multiword token ranges
+ * with their syntactic words for token and lemma samples. Dependency samples instead
+ * omit range lines and empty nodes while retaining the syntactic word rows. Sentences
+ * with incomplete or invalid basic dependencies are skipped and counted.</p>
+ *
+ * @since 3.0.0
+ */
+public class ConlluDependencySampleStream implements ObjectStream<DependencySample> {
+
+  private static final Logger logger =
+      LoggerFactory.getLogger(ConlluDependencySampleStream.class);
+
+  /** The number of tab-separated columns of a CoNLL-U word line. */
+  private static final int COLUMNS = 10;
+
+  /** The column holding the word index, a multiword token range, or an empty node id. */
+  private static final int ID = 0;
+
+  /** The column holding the word form. */
+  private static final int FORM = 1;
+
+  /** The column holding the universal part-of-speech tag. */
+  private static final int UPOS = 3;
+
+  /** The column holding the language-specific part-of-speech tag. */
+  private static final int XPOS = 4;
+
+  /** The column holding the one-based index of the head, {@code 0} for the root. */
+  private static final int HEAD = 6;
+
+  /** The HEAD column value of the root word, which has no governing word. */
+  private static final String ROOT_HEAD_ID = "0";
+
+  /** The column holding the relation label to the head. */
+  private static final int DEPREL = 7;
+
+  /** Separates the columns of a word line. */
+  private static final char FIELD_SEPARATOR = '\t';
+
+  /** Marks a multiword token range id such as {@code 1-2}. */
+  private static final char MULTIWORD_RANGE = '-';
+
+  /** Marks an empty node id such as {@code 1.1}. */
+  private static final char EMPTY_NODE = '.';
+
+  /** The CoNLL-U placeholder of a missing value. */
+  private static final String PLACEHOLDER = "_";
+
+  /** The byte order mark some editors prepend to UTF-8 content. */
+  private static final char BOM = '\ufeff';
+
+  /** The first character of a comment line. */
+  private static final char COMMENT = '#';
+
+  /** The digit zero: the base of the digit values, and the one digit an ID must not start with. */
+  private static final char ZERO_DIGIT = '0';
+
+  private final InputStreamFactory in;
+  private final int tagColumn;
+
+  private BufferedReader reader;
+  private boolean firstLine = true;
+  private int skipped;
+
+  /**
+   * Initializes the stream.
+   *
+   * @param in The CoNLL-U content. Must not be {@code null}.
+   * @param tagset The tagset whose part-of-speech column feeds the sample tags. Must
+   *               not be {@code null}.
+   * @throws IOException Thrown if opening the content fails.
+   * @throws IllegalArgumentException Thrown if any parameter is {@code null}.
+   */
+  public ConlluDependencySampleStream(InputStreamFactory in, ConlluTagset tagset)
+      throws IOException {
+    ParamChecks.requireNonNullArg(in, "in");
+    ParamChecks.requireNonNullArg(tagset, "tagset");
+    this.in = in;
+    this.tagColumn = tagset == ConlluTagset.U ? UPOS : XPOS;
+    this.reader = open();
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Sentences without a usable basic dependency annotation are skipped, and their
+   * count is logged once the content is exhausted.</p>
+   */
+  @Override
+  public DependencySample read() throws IOException {
+    List<String[]> words;
+    while (!(words = nextSentence()).isEmpty()) {
+      final DependencySample sample = convert(words);
+      if (sample != null) {
+        return sample;
+      }
+      skipped++;
+    }
+    if (skipped > 0) {
+      logger.warn("Skipped {} sentence(s) without a complete basic dependency annotation.",
+          skipped);
+      skipped = 0;
+    }
+    return null;
+  }
+
+  /**
+   * Reads the syntactic word lines of the next sentence: comments, multiword token
+   * ranges, and empty nodes are dropped; an empty list means the end of the content.
+   *
+   * <p>Sentences are separated by any line {@link StringUtil#isBlank(CharSequence)}
+   * accepts.</p>
+   *
+   * @return The word lines of the next sentence, or an empty list at the end of the
+   *         content. Never {@code null}.
+   * @throws IOException Thrown if reading fails.
+   * @throws InvalidFormatException Thrown if a word line does not have the expected column count.
+   */
+  private List<String[]> nextSentence() throws IOException {
+    final List<String[]> words = new ArrayList<>();
+    String line;
+    while ((line = reader.readLine()) != null) {
+      if (firstLine) {
+        firstLine = false;
+        if (!line.isEmpty() && line.charAt(0) == BOM) {
+          line = line.substring(1);
+        }
+      }
+      if (StringUtil.isBlank(line)) {
+        if (!words.isEmpty()) {
+          return words;
+        }
+        continue;
+      }
+      if (line.charAt(0) == COMMENT) {
+        continue;
+      }
+      final String[] fields = StringUtil.split(line, FIELD_SEPARATOR, -1);
+      if (fields.length != COLUMNS) {
+        throw new InvalidFormatException("CoNLL-U word line has " + fields.length
+            + " columns, expected " + COLUMNS + ": " + line);
+      }
+      final String id = fields[ID];
+      if (id.indexOf(MULTIWORD_RANGE) < 0 && id.indexOf(EMPTY_NODE) < 0) {
+        words.add(fields);
+      }
+    }
+    return words;
+  }
+
+  /**
+   * Reads a word ID, the one-based position of a syntactic word in its sentence, from the
+   * ID column or from the HEAD column of another word.
+   *
+   * @param id The column value. Must not be {@code null}.
+   * @return The position, or {@code -1} if the column is not a plain decimal number:
+   *         empty, signed, with a leading zero, with a non-ASCII digit, or too large for
+   *         an {@code int}.
+   */
+  private int wordIndex(String id) {
+    final int length = id.length();
+    if (length == 0 || id.charAt(0) == ZERO_DIGIT) {
+      return -1;
+    }
+    int value = 0;
+    for (int i = 0; i < length; i++) {
+      final char c = id.charAt(i);
+      if (!StringUtil.isAsciiDigit(c)) {
+        return -1;
+      }
+      final int digit = c - ZERO_DIGIT;
+      if (value > (Integer.MAX_VALUE - digit) / 10) {
+        return -1;
+      }
+      value = value * 10 + digit;
+    }
+    return value;
+  }
+
+  /**
+   * Converts one sentence into a sample.
+   *
+   * @param words The word lines of the sentence.
+   * @return The converted sample, or {@code null} when the sentence's annotation is
+   *         unusable: an ID or head that is not a plain decimal, an underscore head,
+   *         selected tag or relation, or a graph that is not a tree.
+   */
+  private DependencySample convert(List<String[]> words) {
+    final int n = words.size();
+    final String[] tokens = new String[n];
+    final String[] tags = new String[n];
+    final int[] heads = new int[n];
+    final String[] relations = new String[n];
+    for (int i = 0; i < n; i++) {
+      final String[] word = words.get(i);
+      if (wordIndex(word[ID]) != i + 1) {
+        return null;
+      }
+      tokens[i] = word[FORM];
+      tags[i] = word[tagColumn];
+      if (PLACEHOLDER.equals(tags[i]) || PLACEHOLDER.equals(word[DEPREL])) {
+        return null;
+      }
+      relations[i] = word[DEPREL];
+      final int head = ROOT_HEAD_ID.equals(word[HEAD]) ? 0 : wordIndex(word[HEAD]);
+      if (head < 0) {
+        return null;
+      }
+      heads[i] = head - 1;
+    }
+    try {
+      return new DependencySample(tokens, tags, DependencyTree.of(heads, relations));
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Reopens the content through the {@link InputStreamFactory}, which must therefore
+   * produce a fresh stream on every call.</p>
+   */
+  @Override
+  public void reset() throws IOException, UnsupportedOperationException {
+    reader.close();
+    reader = open();
+    firstLine = true;
+    skipped = 0;
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void close() throws IOException {
+    reader.close();
+  }
+
+  /**
+   * Opens a fresh UTF-8 reader over the content.
+   *
+   * @return A reader positioned at the start of the content. Never {@code null}.
+   * @throws IOException Thrown if opening the content fails.
+   */
+  private BufferedReader open() throws IOException {
+    return new BufferedReader(
+        new InputStreamReader(in.createInputStream(), StandardCharsets.UTF_8.newDecoder()
+            .onMalformedInput(CodingErrorAction.REPORT)
+            .onUnmappableCharacter(CodingErrorAction.REPORT)));
+  }
+}
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamFactory.java b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamFactory.java
new file mode 100644
index 000000000..e52037db7
--- /dev/null
+++ b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamFactory.java
@@ -0,0 +1,109 @@
+/*
+ * 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.formats.conllu;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+import opennlp.tools.cmdline.ArgumentParser;
+import opennlp.tools.cmdline.StreamFactoryRegistry;
+import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.cmdline.params.BasicFormatParams;
+import opennlp.tools.commons.Internal;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.formats.AbstractSampleStreamFactory;
+import opennlp.tools.formats.FormatUtil;
+import opennlp.tools.util.ObjectStream;
+
+/**
+ * Creates {@link ConlluDependencySampleStream} instances for the command line tools. The
+ * data is always read as UTF-8, as the CoNLL-U format requires.
+ * <p>
+ * <b>Note:</b> Do not use this class, internal use only!
+ *
+ * @see DependencySample
+ * @see ConlluDependencySampleStream
+ */
+@Internal
+public class ConlluDependencySampleStreamFactory extends
+        AbstractSampleStreamFactory<DependencySample, ConlluDependencySampleStreamFactory.Parameters> {
+
+  /** The message for an {@code -encoding} value other than UTF-8. */
+  private static final String UTF_8_REQUIRED = "CoNLL-U data must use UTF-8";
+
+  /** The command line parameters of the CoNLL-U dependency format. */
+  public interface Parameters extends BasicFormatParams {
+    /** {@inheritDoc} */
+    @Override
+    @ArgumentParser.ParameterDescription(valueName = "charsetName",
+        description = UTF_8_REQUIRED)
+    @ArgumentParser.OptionalParameter(defaultValue = "UTF-8")
+    Charset getEncoding();
+
+    @ArgumentParser.ParameterDescription(valueName = "tagset",
+        description = "u|x u for unified tags and x for language-specific part-of-speech tags")
+    @ArgumentParser.OptionalParameter(defaultValue = "u")
+    String getTagset();
+  }
+
+  /**
+   * Registers the factory as the default and as the {@code conllu} format for
+   * {@link DependencySample dependency samples}.
+   */
+  public static void registerFactory() {
+    StreamFactoryRegistry.registerFactory(DependencySample.class,
+        StreamFactoryRegistry.DEFAULT_FORMAT, new ConlluDependencySampleStreamFactory(Parameters.class));
+    StreamFactoryRegistry.registerFactory(DependencySample.class,
+        ConlluPOSSampleStreamFactory.CONLLU_FORMAT,
+        new ConlluDependencySampleStreamFactory(Parameters.class));
+  }
+
+  /**
+   * Initializes the factory.
+   *
+   * @param params The parameters interface the command line arguments are parsed into.
+   */
+  protected ConlluDependencySampleStreamFactory(Class<Parameters> params) {
+    super(params);
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * @throws TerminateToolException Thrown if the encoding is not UTF-8, the tagset
+   *         parameter is unknown, or the data cannot be opened.
+   */
+  @Override
+  public ObjectStream<DependencySample> create(String[] args) {
+    Parameters params = validateBasicFormatParameters(args, Parameters.class);
+
+    if (!StandardCharsets.UTF_8.equals(params.getEncoding())) {
+      throw new TerminateToolException(-1, UTF_8_REQUIRED);
+    }
+
+    final ConlluTagset tagset = ConlluTagset.fromFactoryParameter(params.getTagset());
+
+    try {
+      return new ConlluDependencySampleStream(FormatUtil.createInputStreamFactory(params.getData()), tagset);
+    } catch (IOException e) {
+      throw new TerminateToolException(-1,
+              "IO Error while creating an Input Stream: " + e.getMessage(), e);
+    }
+  }
+}
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluLemmaSampleStreamFactory.java b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluLemmaSampleStreamFactory.java
index 371a8c9af..6f9372d18 100644
--- a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluLemmaSampleStreamFactory.java
+++ b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluLemmaSampleStreamFactory.java
@@ -59,11 +59,7 @@ public class ConlluLemmaSampleStreamFactory extends
   public ObjectStream<LemmaSample> create(String[] args) {
     Parameters params = validateBasicFormatParameters(args, Parameters.class);
 
-    ConlluTagset tagset = switch (params.getTagset()) {
-      case "u" -> ConlluTagset.U;
-      case "x" -> ConlluTagset.X;
-      default -> throw new TerminateToolException(-1, "Unknown tagset parameter: " + params.getTagset());
-    };
+    final ConlluTagset tagset = ConlluTagset.fromFactoryParameter(params.getTagset());
 
     try {
       return new ConlluLemmaSampleStream(new ConlluStream(
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluPOSSampleStreamFactory.java b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluPOSSampleStreamFactory.java
index 7e285948c..7cea20e33 100644
--- a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluPOSSampleStreamFactory.java
+++ b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluPOSSampleStreamFactory.java
@@ -61,11 +61,7 @@ public class ConlluPOSSampleStreamFactory extends
   public ObjectStream<POSSample> create(String[] args) {
     Parameters params = validateBasicFormatParameters(args, Parameters.class);
 
-    ConlluTagset tagset = switch (params.getTagset()) {
-      case "u" -> ConlluTagset.U;
-      case "x" -> ConlluTagset.X;
-      default -> throw new TerminateToolException(-1, "Unknown tagset parameter: " + params.getTagset());
-    };
+    final ConlluTagset tagset = ConlluTagset.fromFactoryParameter(params.getTagset());
 
     try {
       return new ConlluPOSSampleStream(new ConlluStream(
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java
index 3f6ee760a..cb3347e3c 100644
--- a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java
+++ b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java
@@ -17,7 +17,58 @@
 
 package opennlp.tools.formats.conllu;
 
+import opennlp.tools.cmdline.TerminateToolException;
+
+/**
+ * The part-of-speech tag column of a CoNLL-U file: the universal tags or the
+ * language-specific tags.
+ */
 public enum ConlluTagset {
-  U,
-  X
+  /** The universal part-of-speech tags, the {@code UPOS} column. */
+  U("u"),
+  /** The language-specific part-of-speech tags, the {@code XPOS} column. */
+  X("x");
+
+  /** The value of the command line {@code tagset} parameter that selects this tagset. */
+  private final String parameter;
+
+  /**
+   * @param parameter The command line value that selects this tagset.
+   */
+  ConlluTagset(String parameter) {
+    this.parameter = parameter;
+  }
+
+  /**
+   * Resolves the value of a command line {@code tagset} parameter.
+   *
+   * @param parameter {@code u} for the universal tags or {@code x} for the
+   *                  language-specific tags.
+   * @return The matching {@link ConlluTagset}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code parameter} is neither {@code u}
+   *         nor {@code x}.
+   */
+  public static ConlluTagset fromParameter(String parameter) {
+    for (ConlluTagset tagset : values()) {
+      if (tagset.parameter.equals(parameter)) {
+        return tagset;
+      }
+    }
+    throw new IllegalArgumentException("Unknown tagset parameter: " + parameter);
+  }
+
+  /**
+   * Reads the {@code -tagset} parameter of a command line format factory.
+   *
+   * @param parameter The parameter value, {@code u} or {@code x}.
+   * @return The tagset.
+   * @throws TerminateToolException Thrown if the parameter is unknown.
+   */
+  static ConlluTagset fromFactoryParameter(String parameter) {
+    try {
+      return fromParameter(parameter);
+    } catch (IllegalArgumentException e) {
+      throw new TerminateToolException(-1, e.getMessage());
+    }
+  }
 }
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluWordLine.java b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluWordLine.java
index 39d7894ef..362dce045 100644
--- a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluWordLine.java
+++ b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluWordLine.java
@@ -129,7 +129,7 @@ public class ConlluWordLine {
   }
 
   /**
-   * @return Retrieves the enhanced dependency graph in the form of a list of
+   * @return Retrieves the enhanced dependency tree in the form of a list of
    * head-deprel pairs.
    */
   public String getDeps() {
diff --git a/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencyParserUsageTest.java b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencyParserUsageTest.java
new file mode 100644
index 000000000..e92a718dc
--- /dev/null
+++ b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencyParserUsageTest.java
@@ -0,0 +1,214 @@
+/*
+ * 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.formats.conllu;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import opennlp.tools.depparse.DependencyArc;
+import opennlp.tools.depparse.DependencyEvaluator;
+import opennlp.tools.depparse.DependencyModel;
+import opennlp.tools.depparse.DependencyParserME;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.depparse.DependencyTree;
+import opennlp.tools.util.InputStreamFactory;
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.TrainingParameters;
+
+import static opennlp.tools.formats.conllu.ConlluTestLines.line;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Demonstrates the full dependency parsing workflow on a self-contained fixture: read
+ * gold sentences from CoNLL-U content, train a {@link DependencyParserME}, parse a
+ * sentence, inspect the resulting {@link DependencyTree}, and persist the model.
+ *
+ * <p>The fixture contains four sentences and needs no external data. Repetition makes
+ * the expected training results deterministic.</p>
+ */
+public class ConlluDependencyParserUsageTest {
+
+  /**
+   * The training fixture: four gold sentences in CoNLL-U form. The {@code HEAD} column
+   * is one-based with {@code 0} marking the root; the reader converts it to the
+   * zero-based convention of {@link DependencyTree}.
+   */
+  private static final String CONLLU = String.join("\n",
+      "# text = the dog barks",
+      line("1", "the", "the", "DET", "DT", "_", "2", "det", "_", "_"),
+      line("2", "dog", "dog", "NOUN", "NN", "_", "3", "nsubj", "_", "_"),
+      line("3", "barks", "bark", "VERB", "VBZ", "_", "0", "root", "_", "_"),
+      "",
+      "# text = dogs bark",
+      line("1", "dogs", "dog", "NOUN", "NNS", "_", "2", "nsubj", "_", "_"),
+      line("2", "bark", "bark", "VERB", "VBP", "_", "0", "root", "_", "_"),
+      "",
+      "# text = she eats fish",
+      line("1", "she", "she", "PRON", "PRP", "_", "2", "nsubj", "_", "_"),
+      line("2", "eats", "eat", "VERB", "VBZ", "_", "0", "root", "_", "_"),
+      line("3", "fish", "fish", "NOUN", "NN", "_", "2", "obj", "_", "_"),
+      "",
+      "# text = Alice sent Bob a message",
+      line("1", "Alice", "Alice", "PROPN", "NNP", "_", "2", "nsubj", "_", "_"),
+      line("2", "sent", "send", "VERB", "VBD", "_", "0", "root", "_", "_"),
+      line("3", "Bob", "Bob", "PROPN", "NNP", "_", "2", "iobj", "_", "_"),
+      line("4", "a", "a", "DET", "DT", "_", "5", "det", "_", "_"),
+      line("5", "message", "message", "NOUN", "NN", "_", "2", "obj", "_", "_"),
+      line("6", ".", ".", "PUNCT", ".", "_", "2", "punct", "_", "_"),
+      "") + "\n";
+
+  /** How often the fixture is repeated so the trainer sees enough evidence per feature. */
+  private static final int REPETITIONS = 40;
+
+  /** The tokens of the four fixture sentences. */
+  private static final long FIXTURE_WORDS = 14;
+
+  private static final String[] ALICE_TOKENS = {"Alice", "sent", "Bob", "a", "message", "."};
+  private static final String[] ALICE_TAGS = {"PROPN", "VERB", "PROPN", "DET", "NOUN", "PUNCT"};
+  private static final DependencyTree ALICE_TREE = DependencyTree.of(
+      new int[] {1, -1, 1, 4, 1, 1},
+      new String[] {"nsubj", "root", "iobj", "det", "obj", "punct"});
+
+  private static DependencyModel model;
+  private static DependencyParserME parser;
+
+  /**
+   * Reads the fixture sentences through the CoNLL-U reader.
+   *
+   * @return One sample per fixture sentence, in file order. Never {@code null}.
+   * @throws IOException Thrown if reading the in-memory content fails.
+   */
+  private static List<DependencySample> readFixture() throws IOException {
+    final InputStreamFactory in =
+        () -> new ByteArrayInputStream(CONLLU.getBytes(StandardCharsets.UTF_8));
+    final List<DependencySample> samples = new ArrayList<>();
+    try (ConlluDependencySampleStream stream =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      DependencySample sample;
+      while ((sample = stream.read()) != null) {
+        samples.add(sample);
+      }
+    }
+    return samples;
+  }
+
+  /**
+   * Trains the parser once for all tests: read the fixture, repeat it for evidence,
+   * and hand the samples to the trainer.
+   *
+   * @throws IOException Thrown if reading the in-memory samples fails.
+   */
+  @BeforeAll
+  static void trainParser() throws IOException {
+    final List<DependencySample> fixture = readFixture();
+    final List<DependencySample> trainingSamples = new ArrayList<>();
+    for (int i = 0; i < REPETITIONS; i++) {
+      trainingSamples.addAll(fixture);
+    }
+    final TrainingParameters parameters = TrainingParameters.defaultParams();
+    parameters.put(Parameters.CUTOFF_PARAM, 0);
+    model = DependencyParserME.train("eng",
+        ObjectStreamUtils.createObjectStream(trainingSamples), parameters);
+    parser = new DependencyParserME(model);
+  }
+
+  @Test
+  void testReaderDeliversTheGoldAnnotation() throws IOException {
+    final List<DependencySample> fixture = readFixture();
+    assertEquals(4, fixture.size());
+    final DependencySample first = fixture.get(0);
+    assertEquals(DependencyTree.of(new int[] {1, 2, -1},
+        new String[] {"det", "nsubj", "root"}), first.getTree());
+    assertEquals("NOUN", first.getTags()[1]);
+  }
+
+  @Test
+  void testParseAssignsHeadsAndRelations() {
+    // Parsing takes the tokens and their part-of-speech tags; the result names, for
+    // every token, its head token and the relation between the two.
+    final DependencyTree parse = parser.parse(
+        new String[] {"the", "dog", "barks"}, new String[] {"DET", "NOUN", "VERB"});
+    assertEquals(1, parse.headOf(0));
+    assertEquals("det", parse.relationOf(0));
+    assertEquals(2, parse.headOf(1));
+    assertEquals("nsubj", parse.relationOf(1));
+    assertEquals(DependencyArc.ROOT_HEAD, parse.headOf(2));
+    assertEquals("root", parse.relationOf(2));
+    assertEquals(2, parse.root());
+  }
+
+  @Test
+  void testParseAliceSentence() {
+    assertEquals(ALICE_TREE, parser.parse(ALICE_TOKENS, ALICE_TAGS));
+  }
+
+  @Test
+  void testArcsNameDependentHeadAndRelation() {
+    // The manual's loop over the arcs of a parse, collecting instead of printing.
+    final String[] tokens = ALICE_TOKENS;
+    final String[] tags = ALICE_TAGS;
+    final DependencyTree tree = parser.parse(tokens, tags);
+    final List<String> lines = new ArrayList<>();
+    for (DependencyArc arc : tree.arcs()) {
+      final String dependent = tokens[arc.dependent()];
+      final String head = arc.head() == DependencyArc.ROOT_HEAD
+          ? "ROOT" : tokens[arc.head()];
+      lines.add(dependent + " -> " + head + " (" + arc.relation() + ")");
+    }
+    assertEquals(List.of(
+        "Alice -> sent (nsubj)",
+        "sent -> ROOT (root)",
+        "Bob -> sent (iobj)",
+        "a -> message (det)",
+        "message -> sent (obj)",
+        ". -> sent (punct)"), lines);
+  }
+
+  @Test
+  void testEvaluatorScoresTheParserAgainstGoldSamples() throws IOException {
+    // The evaluator parses each gold sentence and accumulates the two standard scores;
+    // the expected score is exact because the parser is evaluated on its training data.
+    final DependencyEvaluator evaluator = new DependencyEvaluator(parser);
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(readFixture()));
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+    assertEquals(FIXTURE_WORDS, evaluator.getWordCount());
+  }
+
+  @Test
+  void testPersistedModelParsesLikeTheOriginal(@TempDir Path dir) throws IOException {
+    // A trained model is saved to a file and loaded back like any other tool model; the
+    // reloaded parser must produce the exact same parse as the original.
+    final Path file = dir.resolve("en-depparse.bin");
+    model.serialize(file);
+    final DependencyParserME reloaded = new DependencyParserME(new DependencyModel(file));
+    assertEquals(DependencyTree.of(new int[] {1, -1, 1},
+            new String[] {"nsubj", "root", "obj"}),
+        reloaded.parse(new String[] {"she", "eats", "fish"},
+            new String[] {"PRON", "VERB", "NOUN"}));
+  }
+}
diff --git a/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamFactoryTest.java b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamFactoryTest.java
new file mode 100644
index 000000000..e29b2f7a5
--- /dev/null
+++ b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamFactoryTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.formats.conllu;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.util.ObjectStream;
+
+import static opennlp.tools.formats.conllu.ConlluTestLines.line;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Tests the UTF-8 requirement of the dependency sample stream factory. */
+public class ConlluDependencySampleStreamFactoryTest {
+
+  private static final String TOKEN = "café";
+
+  @TempDir
+  private Path directory;
+
+  private Path data;
+
+  private final ConlluDependencySampleStreamFactory factory =
+      new ConlluDependencySampleStreamFactory(ConlluDependencySampleStreamFactory.Parameters.class);
+
+  @BeforeEach
+  void writeData() throws IOException {
+    data = directory.resolve("sample.conllu");
+    Files.writeString(data, line("1", TOKEN, TOKEN, "NOUN", "NN", "_", "0", "root", "_", "_") + "\n");
+  }
+
+  @Test
+  void testDefaultEncodingReadsUtf8() throws IOException {
+    try (ObjectStream<DependencySample> samples = factory.create(new String[] {"-data", data.toString()})) {
+      assertArrayEquals(new String[] {TOKEN}, samples.read().getTokens());
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"UTF-8", "UTF8", "utf-8"})
+  void testUtf8AliasesAreAccepted(String encoding) throws IOException {
+    try (ObjectStream<DependencySample> samples = factory.create(new String[] {
+        "-data", data.toString(), "-encoding", encoding})) {
+      assertArrayEquals(new String[] {TOKEN}, samples.read().getTokens());
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"ISO-8859-1", "UTF-16", "UTF-16LE", "UTF-16BE", "US-ASCII", "windows-1252",
+      "UTF-32"})
+  void testOtherEncodingsAreRejected(String encoding) {
+    final TerminateToolException error = assertThrows(TerminateToolException.class, () -> {
+      try (ObjectStream<DependencySample> ignored = factory.create(new String[] {
+          "-data", data.toString(), "-encoding", encoding})) {
+        // Close the stream if an unsupported encoding is incorrectly accepted.
+      }
+    });
+    assertEquals(-1, error.getCode());
+    assertEquals("CoNLL-U data must use UTF-8", error.getMessage());
+  }
+
+  /** An encoding name the platform does not know is rejected by the argument parser first. */
+  @ParameterizedTest
+  @ValueSource(strings = {"UTF_8", "utf 8", "X-NO-SUCH-CHARSET", ""})
+  void testUnknownEncodingNamesAreRejectedByTheArgumentParser(String encoding) {
+    final TerminateToolException error = assertThrows(TerminateToolException.class, () -> {
+      try (ObjectStream<DependencySample> ignored = factory.create(new String[] {
+          "-data", data.toString(), "-encoding", encoding})) {
+        // Close the stream if an unknown encoding is incorrectly accepted.
+      }
+    });
+    assertEquals(1, error.getCode());
+    assertTrue(error.getMessage().startsWith("Invalid argument: -encoding " + encoding),
+        error.getMessage());
+  }
+
+  /** A missing data file ends the tool before the encoding is checked. */
+  @Test
+  void testMissingDataFileIsRejected() {
+    final Path missing = directory.resolve("missing.conllu");
+    final TerminateToolException error = assertThrows(TerminateToolException.class,
+        () -> factory.create(new String[] {"-data", missing.toString(), "-encoding", "ISO-8859-1"}));
+    assertEquals(-1, error.getCode());
+    assertEquals("The Data file does not exist! Path: " + missing.toAbsolutePath(), error.getMessage());
+  }
+}
diff --git a/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamTest.java b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamTest.java
new file mode 100644
index 000000000..3272f375c
--- /dev/null
+++ b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamTest.java
@@ -0,0 +1,453 @@
+/*
+ * 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.formats.conllu;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.stream.Stream;
+
+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.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.depparse.DependencyArc;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.util.InputStreamFactory;
+import opennlp.tools.util.InvalidFormatException;
+
+import static opennlp.tools.formats.conllu.ConlluTestLines.line;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests that the raw reader maps the basic dependency columns, keeps the syntactic
+ * words of multiword tokens while dropping the range line itself, and skips sentences
+ * without a usable annotation.
+ */
+public class ConlluDependencySampleStreamTest {
+
+  private static final String CONLLU = String.join("\n",
+      "# sent_id = test-1",
+      "# text = He bought the bonds",
+      line("1", "He", "he", "PRON", "PRP", "_", "2", "nsubj", "_", "_"),
+      line("2", "bought", "buy", "VERB", "VBD", "_", "0", "root", "_", "_"),
+      line("3", "the", "the", "DET", "DT", "_", "4", "det", "_", "_"),
+      line("4", "bonds", "bond", "NOUN", "NNS", "_", "2", "obj", "_", "_"),
+      "",
+      "# sent_id = test-2",
+      "# text = Broken",
+      line("1", "Broken", "broken", "ADJ", "JJ", "_", "_", "_", "_", "_"),
+      "",
+      "# sent_id = test-3",
+      "# text = im Haus",
+      line("1-2", "im", "_", "_", "_", "_", "_", "_", "_", "_"),
+      line("1", "in", "in", "ADP", "APPR", "_", "2", "case", "_", "_"),
+      line("2", "Haus", "Haus", "NOUN", "NN", "_", "0", "root", "_", "_"),
+      "",
+      "# sent_id = test-4",
+      "# text = Dogs bark",
+      line("1", "Dogs", "dog", "NOUN", "NNS", "_", "2", "nsubj", "_", "_"),
+      line("2", "bark", "bark", "VERB", "VBP", "_", "0", "root", "_", "_"),
+      "") + "\n";
+
+  /** An in-memory factory over the shared fixture. */
+  private static InputStreamFactory factory() {
+    return () -> new ByteArrayInputStream(CONLLU.getBytes(StandardCharsets.UTF_8));
+  }
+
+  /** A stream over the shared fixture using the universal tagset. */
+  private static ConlluDependencySampleStream stream() throws IOException {
+    return new ConlluDependencySampleStream(factory(), ConlluTagset.U);
+  }
+
+  @Test
+  void testReadsSamplesKeepsContractionsAndSkipsUnusableSentences() throws IOException {
+    try (ConlluDependencySampleStream samples = stream()) {
+      final DependencySample first = samples.read();
+      assertNotNull(first);
+      assertArrayEquals(new String[] {"He", "bought", "the", "bonds"}, first.getTokens());
+      assertArrayEquals(new String[] {"PRON", "VERB", "DET", "NOUN"}, first.getTags());
+      assertEquals(1, first.getTree().headOf(0));
+      assertEquals(DependencyArc.ROOT_HEAD, first.getTree().headOf(1));
+      assertEquals(3, first.getTree().headOf(2));
+      assertEquals("obj", first.getTree().relationOf(3));
+
+      // the underscore-head sentence is skipped; the contraction sentence is kept,
+      // with the range line dropped and its syntactic words intact
+      final DependencySample second = samples.read();
+      assertNotNull(second);
+      assertArrayEquals(new String[] {"in", "Haus"}, second.getTokens());
+      assertEquals(1, second.getTree().headOf(0));
+      assertEquals("case", second.getTree().relationOf(0));
+
+      final DependencySample third = samples.read();
+      assertNotNull(third);
+      assertArrayEquals(new String[] {"Dogs", "bark"}, third.getTokens());
+
+      assertNull(samples.read());
+    }
+  }
+
+  @Test
+  void testResetRestartsTheStream() throws IOException {
+    try (ConlluDependencySampleStream samples = stream()) {
+      assertNotNull(samples.read());
+      samples.reset();
+      final DependencySample first = samples.read();
+      assertNotNull(first);
+      assertArrayEquals(new String[] {"He", "bought", "the", "bonds"}, first.getTokens());
+    }
+  }
+
+  @Test
+  void testXposTagsetSelectsTheOtherColumn() throws IOException {
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(factory(), ConlluTagset.X)) {
+      assertArrayEquals(new String[] {"PRP", "VBD", "DT", "NNS"},
+          samples.read().getTags());
+    }
+  }
+
+  @Test
+  void testMalformedLineIsRejected() {
+    final InputStreamFactory bad = () -> new ByteArrayInputStream(
+        "1\ttoo\tfew\tcolumns\n".getBytes(StandardCharsets.UTF_8));
+    assertThrows(InvalidFormatException.class,
+        () -> new ConlluDependencySampleStream(bad, ConlluTagset.U).read());
+  }
+
+  @Test
+  void testExtraColumnIsRejected() {
+    final InputStreamFactory bad = () -> new ByteArrayInputStream(
+        (line("1", "word", "word", "NOUN", "NN", "_", "0", "root", "_", "_",
+            "extra") + "\n").getBytes(StandardCharsets.UTF_8));
+    assertThrows(InvalidFormatException.class,
+        () -> new ConlluDependencySampleStream(bad, ConlluTagset.U).read());
+  }
+
+  @Test
+  void testUtf8BomIsAccepted() throws IOException {
+    final String content = "\ufeff" + line("1", "Word", "word", "NOUN", "NN", "_",
+        "0", "root", "_", "_") + "\n";
+    final InputStreamFactory in = () -> new ByteArrayInputStream(
+        content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      final DependencySample sample = samples.read();
+      assertNotNull(sample);
+      assertArrayEquals(new String[] {"Word"}, sample.getTokens());
+    }
+  }
+
+  @Test
+  void testSemanticallyInvalidAnnotationIsSkippedNotFatal() throws IOException {
+    // Structurally well-formed lines whose annotation cannot form a valid tree, here an
+    // out-of-range head and a rootless cycle, skip the sentence instead of failing, so
+    // one broken sentence cannot abort reading a large treebank.
+    final String content = String.join("\n",
+        line("1", "far", "far", "ADV", "RB", "_", "5", "advmod", "_", "_"),
+        line("2", "off", "off", "ADP", "RP", "_", "0", "root", "_", "_"),
+        "",
+        line("1", "loop", "loop", "NOUN", "NN", "_", "2", "dep", "_", "_"),
+        line("2", "back", "back", "ADV", "RB", "_", "1", "dep", "_", "_"),
+        "",
+        line("1", "Fine", "fine", "ADJ", "JJ", "_", "0", "root", "_", "_"),
+        "") + "\n";
+    final InputStreamFactory in =
+        () -> new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      final DependencySample onlyValid = samples.read();
+      assertNotNull(onlyValid);
+      assertArrayEquals(new String[] {"Fine"}, onlyValid.getTokens());
+      assertEquals(DependencyArc.ROOT_HEAD, onlyValid.getTree().headOf(0));
+      assertNull(samples.read());
+    }
+  }
+
+  @ParameterizedTest(name = "id = \"{0}\"")
+  @ValueSource(strings = {"2", "01", "+1", "-1", "1 ", " 1", "\u0661", "\uff11", "\uD835\uDFCF", "",
+      "0", "1-2", "1.1", "2147483647", "2147483648", "4294967297", "9999999999999999999"})
+  void testWordIdsOtherThanThePositionAreSkipped(String id) throws IOException {
+    final String content = line(id, "Dogs", "dog", "NOUN", "NNS", "_", "0",
+        "root", "_", "_") + "\n";
+    final InputStreamFactory in = () -> new ByteArrayInputStream(
+        content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      assertNull(samples.read());
+    }
+  }
+
+  /**
+   * Word ID sequences that do not count up from one: a gap, a swap, a repeat, and a
+   * one-based sequence that starts at two.
+   *
+   * @return The ID columns of a three-word sentence.
+   */
+  static Stream<Arguments> misnumberedSentences() {
+    return Stream.of(
+        Arguments.of("gap", new String[] {"1", "3", "4"}),
+        Arguments.of("swap", new String[] {"2", "1", "3"}),
+        Arguments.of("repeat", new String[] {"1", "1", "2"}),
+        Arguments.of("starts at two", new String[] {"2", "3", "4"}),
+        Arguments.of("descending", new String[] {"3", "2", "1"}));
+  }
+
+  /** A sentence whose IDs do not count up from one is skipped and the next sentence is read. */
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("misnumberedSentences")
+  void testMisnumberedSentenceIsSkipped(String description, String[] ids) throws IOException {
+    final String content = String.join("\n",
+        line(ids[0], "Dogs", "dog", "NOUN", "NNS", "_", "2", "nsubj", "_", "_"),
+        line(ids[1], "bark", "bark", "VERB", "VBP", "_", "0", "root", "_", "_"),
+        line(ids[2], "loudly", "loudly", "ADV", "RB", "_", "2", "advmod", "_", "_"),
+        "",
+        line("1", "Fine", "fine", "ADJ", "JJ", "_", "0", "root", "_", "_"),
+        "") + "\n";
+    final InputStreamFactory in = () -> new ByteArrayInputStream(
+        content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      final DependencySample sample = samples.read();
+      assertNotNull(sample);
+      assertArrayEquals(new String[] {"Fine"}, sample.getTokens());
+      assertNull(samples.read());
+    }
+  }
+
+  /**
+   * A head that is not a plain decimal like the ID column is skipped: signed, with a
+   * leading zero, with a non-ASCII digit, padded, empty, or fractional.
+   *
+   * @param head The HEAD column of the second word, whose plain form is {@code 1}.
+   * @throws IOException Thrown if reading fails.
+   */
+  @ParameterizedTest(name = "head = \"{0}\"")
+  @ValueSource(strings = {"+1", "01", "\u0661", "\uff11", "-1", " 1", "1 ", "", "1.0", "1e0", "_"})
+  void testHeadsOtherThanPlainDecimalsAreSkipped(String head) throws IOException {
+    final String content = String.join("\n",
+        line("1", "Dogs", "dog", "NOUN", "NNS", "_", "0", "root", "_", "_"),
+        line("2", "bark", "bark", "VERB", "VBP", "_", head, "dep", "_", "_"),
+        "",
+        line("1", "Fine", "fine", "ADJ", "JJ", "_", "0", "root", "_", "_"),
+        "") + "\n";
+    final InputStreamFactory in = () -> new ByteArrayInputStream(
+        content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      final DependencySample sample = samples.read();
+      assertNotNull(sample);
+      assertArrayEquals(new String[] {"Fine"}, sample.getTokens());
+      assertNull(samples.read());
+    }
+  }
+
+  /** The root head {@code 0} and a two-digit head are read as written. */
+  @Test
+  void testRootAndTwoDigitHeadsAreRead() throws IOException {
+    final StringBuilder content = new StringBuilder();
+    for (int i = 1; i <= 10; i++) {
+      content.append(line(Integer.toString(i), "w" + i, "w" + i, "NOUN", "NN", "_",
+          i == 10 ? "0" : "10", i == 10 ? "root" : "nmod", "_", "_")).append('\n');
+    }
+    final InputStreamFactory in = () -> new ByteArrayInputStream(
+        content.toString().getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      final DependencySample sample = samples.read();
+      assertNotNull(sample);
+      assertEquals(DependencyArc.ROOT_HEAD, sample.getTree().headOf(9));
+      assertEquals(9, sample.getTree().headOf(0));
+      assertNull(samples.read());
+    }
+  }
+
+  /** A sentence made only of range and empty-node lines has no words and is passed over. */
+  @Test
+  void testSentenceWithoutWordLinesIsPassedOver() throws IOException {
+    final String content = String.join("\n",
+        line("1-2", "im", "_", "_", "_", "_", "_", "_", "_", "_"),
+        line("1.1", "gap", "gap", "NOUN", "NN", "_", "_", "_", "_", "_"),
+        "",
+        line("1", "Fine", "fine", "ADJ", "JJ", "_", "0", "root", "_", "_"),
+        "") + "\n";
+    final InputStreamFactory in = () -> new ByteArrayInputStream(
+        content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      assertArrayEquals(new String[] {"Fine"}, samples.read().getTokens());
+      assertNull(samples.read());
+    }
+  }
+
+  /**
+   * Reads a sentence whose tenth word has the two-digit ID {@code 10}, so the position
+   * comparison covers more than one digit.
+   *
+   * @throws IOException Thrown if reading fails.
+   */
+  @Test
+  void testTwoDigitWordIdIsRead() throws IOException {
+    final StringBuilder content = new StringBuilder();
+    for (int i = 1; i <= 10; i++) {
+      content.append(line(Integer.toString(i), "w" + i, "w" + i, "NOUN", "NN", "_",
+          i == 1 ? "0" : "1", i == 1 ? "root" : "nmod", "_", "_")).append('\n');
+    }
+    final InputStreamFactory in = () -> new ByteArrayInputStream(
+        content.toString().getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      final DependencySample sample = samples.read();
+      assertNotNull(sample);
+      assertEquals(10, sample.getTokens().length);
+      assertEquals("w10", sample.getTokens()[9]);
+      assertNull(samples.read());
+    }
+  }
+
+  /**
+   * Verifies that a word whose relation is the underscore placeholder makes the
+   * sentence incomplete: it is skipped like an underscore head, and reading continues
+   * with the next sentence.
+   *
+   * @throws IOException Thrown if reading fails.
+   */
+  @Test
+  void testUnderscoreRelationIsSkipped() throws IOException {
+    final String content = String.join("\n",
+        line("1", "Dogs", "dog", "NOUN", "NNS", "_", "2", "_", "_", "_"),
+        line("2", "bark", "bark", "VERB", "VBP", "_", "0", "root", "_", "_"),
+        "",
+        line("1", "Cats", "cat", "NOUN", "NNS", "_", "2", "nsubj", "_", "_"),
+        line("2", "purr", "purr", "VERB", "VBP", "_", "0", "root", "_", "_"),
+        "") + "\n";
+    final InputStreamFactory in = () -> new ByteArrayInputStream(
+        content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      final DependencySample sample = samples.read();
+      assertNotNull(sample);
+      assertArrayEquals(new String[] {"Cats", "purr"}, sample.getTokens());
+      assertEquals("nsubj", sample.getTree().relationOf(0));
+      assertNull(samples.read());
+    }
+  }
+
+  @ParameterizedTest
+  @EnumSource(ConlluTagset.class)
+  void testMissingSelectedPosTagIsSkipped(ConlluTagset tagset) throws IOException {
+    final String content = String.join("\n",
+        line("1", "Missing", "missing", tagset == ConlluTagset.U ? "_" : "NOUN",
+            tagset == ConlluTagset.U ? "NN" : "_", "_", "0", "root", "_", "_"),
+        "",
+        line("1", "Valid", "valid", "NOUN", "NN", "_", "0", "root", "_", "_"), "");
+    final InputStreamFactory input = () ->
+        new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples = new ConlluDependencySampleStream(input, tagset)) {
+      assertArrayEquals(new String[] {"Valid"}, samples.read().getTokens());
+      assertNull(samples.read());
+    }
+  }
+
+  @Test
+  void testEmptyNodeLinesAreDropped() throws IOException {
+    // An empty node (id 1.1) only carries enhanced dependencies; the basic tree is
+    // formed by the surrounding word lines.
+    final String content = String.join("\n",
+        line("1", "Dogs", "dog", "NOUN", "NNS", "_", "2", "nsubj", "_", "_"),
+        line("1.1", "bark", "bark", "VERB", "VBP", "_", "_", "_", "2:conj", "CopyOf=2"),
+        line("2", "bark", "bark", "VERB", "VBP", "_", "0", "root", "_", "_"),
+        "") + "\n";
+    final InputStreamFactory in = () -> new ByteArrayInputStream(
+        content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      final DependencySample sample = samples.read();
+      assertNotNull(sample);
+      assertArrayEquals(new String[] {"Dogs", "bark"}, sample.getTokens());
+      assertEquals(1, sample.getTree().headOf(0));
+      assertEquals(DependencyArc.ROOT_HEAD, sample.getTree().headOf(1));
+      assertNull(samples.read());
+    }
+  }
+
+  @Test
+  void testMalformedUtf8Throws() throws IOException {
+    final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    bytes.writeBytes("1\t".getBytes(StandardCharsets.UTF_8));
+    bytes.write(0xc3);
+    bytes.writeBytes("\t_\tNOUN\tNNS\t_\t0\troot\t_\t_\n"
+        .getBytes(StandardCharsets.UTF_8));
+    final InputStreamFactory in = () -> new ByteArrayInputStream(bytes.toByteArray());
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      assertThrows(IOException.class, samples::read);
+    }
+  }
+
+  @Test
+  void testSeparatorLineOfNonBreakingSpaceSeparatesSentences() throws IOException {
+    // A separator line carrying a stray no-break space is still a separator: OpenNLP
+    // counts U+00A0 as whitespace, so such a line must not reach the word-line parser
+    // and abort the stream.
+    final String content = String.join("\n",
+        line("1", "Dogs", "dog", "NOUN", "NNS", "_", "2", "nsubj", "_", "_"),
+        line("2", "bark", "bark", "VERB", "VBP", "_", "0", "root", "_", "_"),
+        "\u00A0",
+        line("1", "Fine", "fine", "ADJ", "JJ", "_", "0", "root", "_", "_"),
+        "") + "\n";
+    final InputStreamFactory in =
+        () -> new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      final DependencySample first = samples.read();
+      assertNotNull(first);
+      assertArrayEquals(new String[] {"Dogs", "bark"}, first.getTokens());
+      final DependencySample second = samples.read();
+      assertNotNull(second);
+      assertArrayEquals(new String[] {"Fine"}, second.getTokens());
+      assertNull(samples.read());
+    }
+  }
+
+  @Test
+  void testEmptyContentYieldsNoSample() throws IOException {
+    final InputStreamFactory in = () -> new ByteArrayInputStream(new byte[0]);
+    try (ConlluDependencySampleStream samples =
+        new ConlluDependencySampleStream(in, ConlluTagset.U)) {
+      assertNull(samples.read());
+    }
+  }
+
+  @Test
+  void testValidation() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new ConlluDependencySampleStream(null, ConlluTagset.U));
+    assertThrows(IllegalArgumentException.class,
+        () -> new ConlluDependencySampleStream(factory(), null));
+  }
+}
diff --git a/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluTagsetTest.java b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluTagsetTest.java
new file mode 100644
index 000000000..3fe843360
--- /dev/null
+++ b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluTagsetTest.java
@@ -0,0 +1,63 @@
+/*
+ * 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.formats.conllu;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.cmdline.TerminateToolException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** Tests the command line parameter mapping of {@link ConlluTagset}. */
+public class ConlluTagsetTest {
+
+  @ParameterizedTest
+  @CsvSource({"u, U", "x, X"})
+  void testKnownParameters(String parameter, ConlluTagset tagset) {
+    assertEquals(tagset, ConlluTagset.fromParameter(parameter));
+  }
+
+  @ParameterizedTest
+  @NullAndEmptySource
+  @ValueSource(strings = {"U", "X", "upos", "invalid"})
+  void testUnknownParametersAreRejected(String parameter) {
+    assertThrows(IllegalArgumentException.class, () -> ConlluTagset.fromParameter(parameter));
+  }
+
+  /** The factory parameter reader accepts the same two values as the parameter reader. */
+  @ParameterizedTest
+  @CsvSource({"u, U", "x, X"})
+  void testKnownFactoryParameters(String parameter, ConlluTagset tagset) {
+    assertEquals(tagset, ConlluTagset.fromFactoryParameter(parameter));
+  }
+
+  /** An unknown factory parameter ends the tool with code -1 and the parameter reader's message. */
+  @ParameterizedTest
+  @NullAndEmptySource
+  @ValueSource(strings = {"U", "X", "upos", "xpos", " u", "u ", "invalid"})
+  void testUnknownFactoryParametersTerminateTheTool(String parameter) {
+    final TerminateToolException exception = assertThrows(TerminateToolException.class,
+        () -> ConlluTagset.fromFactoryParameter(parameter));
+    assertEquals(-1, exception.getCode());
+    assertEquals("Unknown tagset parameter: " + parameter, exception.getMessage());
+  }
+}
diff --git a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluTestLines.java
similarity index 64%
copy from opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java
copy to opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluTestLines.java
index 3f6ee760a..d61604372 100644
--- a/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluTagset.java
+++ b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluTestLines.java
@@ -17,7 +17,22 @@
 
 package opennlp.tools.formats.conllu;
 
-public enum ConlluTagset {
-  U,
-  X
+/**
+ * Builds CoNLL-U content for the tests of this package.
+ */
+final class ConlluTestLines {
+
+  /** Prevents construction of this utility class. */
+  private ConlluTestLines() {
+  }
+
+  /**
+   * Joins the columns of one word line with tabs.
+   *
+   * @param fields The column values; the format expects exactly ten.
+   * @return The joined word line. Never {@code null}.
+   */
+  static String line(String... fields) {
+    return String.join("\t", fields);
+  }
 }
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardOracle.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardOracle.java
new file mode 100644
index 000000000..ca5e4caf3
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardOracle.java
@@ -0,0 +1,148 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+
+import opennlp.tools.util.ParamChecks;
+
+/**
+ * Converts an annotated dependency tree into the sequence of parser
+ * transitions needed to reconstruct it. These transitions provide the
+ * training targets for the arc-standard dependency parser.
+ *
+ * <p>The annotated tree, also called the gold tree, supplies the correct
+ * head and dependency relation for each token. At each step, the oracle
+ * uses this tree and the current {@link ArcStandardState} to choose a
+ * transition:</p>
+ * <ul>
+ * <li>{@code SHIFT}: move the next token from the buffer onto the stack.</li>
+ * <li>{@code LEFT_ARC(label)}: attach the token immediately below the top
+ * of the stack to the top token, then remove the dependent.</li>
+ * <li>{@code RIGHT_ARC(label)}: attach the top token to the token immediately
+ * below it, then remove the dependent.</li>
+ * </ul>
+ *
+ * <p>A token must have all its dependents attached before it is removed
+ * from the stack. Only projective trees can be reconstructed with these
+ * transitions. In a projective tree, dependency arcs do not cross when
+ * drawn above the token sequence. Non-projective trees are rejected.</p>
+ *
+ * @since 3.0.0
+ */
+final class ArcStandardOracle {
+
+  /** Prevents construction of this utility class. */
+  private ArcStandardOracle() {
+  }
+
+  /**
+   * Derives the gold transition sequence for a tree.
+   *
+   * @param gold The gold dependency tree. Must not be {@code null} and must be
+   *             projective.
+   * @return The transitions that rebuild {@code gold} from the start configuration, in
+   *         order. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code gold} is {@code null} or not
+   *         projective.
+   */
+  static List<Transition> transitions(DependencyTree gold) {
+    ParamChecks.requireNonNullArg(gold, "gold");
+    final int n = gold.size();
+    final int[] goldDependents = new int[n];
+    for (int i = 0; i < n; i++) {
+      final int head = gold.headOf(i);
+      if (head >= 0) {
+        goldDependents[head]++;
+      }
+    }
+
+    final ArcStandardState state = new ArcStandardState(n);
+    final List<Transition> transitions = new ArrayList<>(2 * n);
+    while (!state.isTerminal()) {
+      final Transition next = nextTransition(gold, goldDependents, state);
+      if (next == null) {
+        throw new IllegalArgumentException(
+            "gold tree has no arc-standard derivation (non-projective): " + gold);
+      }
+      state.apply(next);
+      transitions.add(next);
+    }
+    return transitions;
+  }
+
+  /**
+   * Picks the gold transition for the current configuration.
+   *
+   * @param gold The gold tree being derived.
+   * @param goldDependents The gold dependent count per token, indexed by head.
+   * @param state The current configuration.
+   * @return The next gold transition, or {@code null} when the configuration is stuck,
+   *         which only happens for non-projective input.
+   */
+  private static Transition nextTransition(DependencyTree gold, int[] goldDependents,
+      ArcStandardState state) {
+    final int s0 = state.stack(0);
+    final int s1 = state.stack(1);
+    if (s1 >= 0 && gold.headOf(s1) == s0) {
+      final Transition leftArc = Transition.leftArc(gold.relationOf(s1));
+      if (state.canApply(leftArc)) {
+        return leftArc;
+      }
+    }
+    if (s0 >= 0 && s1 != ArcStandardState.NONE && gold.headOf(s0) == s1
+        && state.assignedDependents(s0) == goldDependents[s0]) {
+      final Transition rightArc = Transition.rightArc(gold.relationOf(s0));
+      if (state.canApply(rightArc)) {
+        return rightArc;
+      }
+    }
+    if (state.canApply(Transition.SHIFT)) {
+      return Transition.SHIFT;
+    }
+    return null;
+  }
+
+  /**
+   * Tests whether a gold tree is projective: no pair of arcs crosses when the arcs are
+   * placed above the token sequence. The projective trees are the ones with an
+   * arc-standard derivation, so callers apply this test before requesting
+   * {@link #transitions}.
+   *
+   * @param gold The gold tree. Must not be {@code null}.
+   * @return {@code true} if no pair of arcs crosses.
+   * @throws IllegalArgumentException Thrown if {@code gold} is {@code null}.
+   */
+  static boolean isProjective(DependencyTree gold) {
+    ParamChecks.requireNonNullArg(gold, "gold");
+    for (int first = 0; first < gold.size(); first++) {
+      final int firstLow = Math.min(first, gold.headOf(first));
+      final int firstHigh = Math.max(first, gold.headOf(first));
+      for (int other = first + 1; other < gold.size(); other++) {
+        final int otherLow = Math.min(other, gold.headOf(other));
+        final int otherHigh = Math.max(other, gold.headOf(other));
+        if ((firstLow < otherLow && otherLow < firstHigh && firstHigh < otherHigh)
+            || (otherLow < firstLow && firstLow < otherHigh && otherHigh < firstHigh)) {
+          return false;
+        }
+      }
+    }
+    return true;
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardState.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardState.java
new file mode 100644
index 000000000..f193d66a1
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardState.java
@@ -0,0 +1,303 @@
+/*
+ * 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.util.Arrays;
+
+import opennlp.tools.util.ParamChecks;
+
+/**
+ * Tracks the state of an arc-standard dependency parse: a stack of tokens
+ * being processed, a buffer of unread tokens, and the dependency arcs
+ * created so far.
+ *
+ * <p>A SHIFT transition moves the next token from the buffer onto the stack.
+ * LEFT_ARC and RIGHT_ARC create a dependency arc between the top two stack
+ * entries and remove the dependent. Parsing is complete when the buffer
+ * is empty and only the artificial root remains on the stack.</p>
+ *
+ * <p>The artificial root, represented by {@link #ROOT}, stays at the bottom
+ * of the stack. The final RIGHT_ARC transition attaches the remaining token
+ * to this root, ensuring that the sentence has exactly one root token.
+ * Stack and buffer accessors return {@link #NONE} for positions that do
+ * not exist.</p>
+ *
+ * <p>The transition system is described in
+ * <a href="https://aclanthology.org/W04-0308/">Nivre (2004)</a>.</p>
+ *
+ * <p>Each instance is used for a single parse and is not thread-safe.</p>
+ *
+ * @since 3.0.0
+ */
+final class ArcStandardState {
+
+  /** The stack value representing the artificial root node. */
+  static final int ROOT = DependencyArc.ROOT_HEAD;
+
+  /** The value returned for stack or buffer positions that do not exist. */
+  static final int NONE = -2;
+
+  private final int tokenCount;
+  private final int[] stack;
+  private final int[] heads;
+  private final String[] relations;
+  private final int[] assignedDependents;
+  private final int[] leftmostDependents;
+  private final int[] rightmostDependents;
+
+  private int top;
+  private int bufferFront;
+
+  /**
+   * Initializes the start configuration for a sentence: the artificial root on the stack
+   * and every token in the buffer.
+   *
+   * @param tokenCount The number of tokens in the sentence. Must be greater than zero.
+   * @throws IllegalArgumentException Thrown if {@code tokenCount} is not positive.
+   */
+  ArcStandardState(int tokenCount) {
+    if (tokenCount <= 0) {
+      throw new IllegalArgumentException("tokenCount must be positive: " + tokenCount);
+    }
+    this.tokenCount = tokenCount;
+    this.stack = new int[tokenCount + 1];
+    this.stack[0] = ROOT;
+    this.top = 0;
+    this.bufferFront = 0;
+    this.heads = new int[tokenCount];
+    this.relations = new String[tokenCount];
+    this.assignedDependents = new int[tokenCount];
+    this.leftmostDependents = new int[tokenCount];
+    this.rightmostDependents = new int[tokenCount];
+    Arrays.fill(this.leftmostDependents, NONE);
+    Arrays.fill(this.rightmostDependents, NONE);
+  }
+
+  /**
+   * @return {@code true} if the buffer is empty and only the artificial root remains on
+   *         the stack, so the parse is complete.
+   */
+  boolean isTerminal() {
+    return bufferFront == tokenCount && top == 0;
+  }
+
+  /**
+   * Checks whether a transition may be applied in the current configuration.
+   *
+   * @param transition The transition to check. Must not be {@code null}.
+   * @return {@code true} if {@link #apply(Transition)} would succeed.
+   * @throws IllegalArgumentException Thrown if {@code transition} is {@code null}.
+   */
+  boolean canApply(Transition transition) {
+    ParamChecks.requireNonNullArg(transition, "transition");
+    return switch (transition.type()) {
+      case SHIFT -> bufferFront < tokenCount;
+      case LEFT_ARC -> top >= 2;
+      case RIGHT_ARC -> top >= 2 || (top == 1 && bufferFront == tokenCount);
+    };
+  }
+
+  /**
+   * Applies a transition, updating stack, buffer, and arcs.
+   *
+   * @param transition The transition to apply. Must not be {@code null} and must be
+   *                   applicable per {@link #canApply(Transition)}.
+   * @throws IllegalArgumentException Thrown if the transition is {@code null} or not
+   *         applicable in the current configuration.
+   */
+  void apply(Transition transition) {
+    if (!canApply(transition)) {
+      throw new IllegalArgumentException("transition not applicable: " + transition
+          + " in " + this);
+    }
+    switch (transition.type()) {
+      case SHIFT -> {
+        top++;
+        stack[top] = bufferFront++;
+      }
+      case LEFT_ARC -> {
+        final int dependent = stack[top - 1];
+        attach(stack[top], dependent, transition.label());
+        stack[top - 1] = stack[top];
+        top--;
+      }
+      case RIGHT_ARC -> {
+        attach(stack[top - 1], stack[top], transition.label());
+        top--;
+      }
+    }
+  }
+
+  /**
+   * Records the arc from {@code head} to {@code dependent} and updates the dependent
+   * bookkeeping of {@code head} when it is a token rather than the artificial root.
+   *
+   * @param head The head token index, or {@link #ROOT} for the artificial root.
+   * @param dependent The zero-based index of the token being attached.
+   * @param relation The relation label of the arc.
+   */
+  private void attach(int head, int dependent, String relation) {
+    heads[dependent] = head;
+    relations[dependent] = relation;
+    if (head >= 0) {
+      assignedDependents[head]++;
+      if (leftmostDependents[head] == NONE || dependent < leftmostDependents[head]) {
+        leftmostDependents[head] = dependent;
+      }
+      if (rightmostDependents[head] == NONE || dependent > rightmostDependents[head]) {
+        rightmostDependents[head] = dependent;
+      }
+    }
+  }
+
+  /**
+   * Retrieves a stack element counted from the top.
+   *
+   * @param fromTop Zero for the top element, one for the element below it, and so on.
+   *                Must not be negative.
+   * @return The token index at that position, {@link #ROOT} for the artificial root, or
+   *         {@link #NONE} if the position does not exist.
+   * @throws IllegalArgumentException Thrown if {@code fromTop} is negative.
+   */
+  int stack(int fromTop) {
+    if (fromTop < 0) {
+      throw new IllegalArgumentException("fromTop must not be negative: " + fromTop);
+    }
+    final int position = top - fromTop;
+    return position < 0 ? NONE : stack[position];
+  }
+
+  /**
+   * Retrieves a buffer element counted from the front.
+   *
+   * @param fromFront Zero for the next token to be shifted, one for the token after it,
+   *                  and so on. Must not be negative.
+   * @return The token index at that position, or {@link #NONE} if the position does not
+   *         exist.
+   * @throws IllegalArgumentException Thrown if {@code fromFront} is negative.
+   */
+  int buffer(int fromFront) {
+    if (fromFront < 0) {
+      throw new IllegalArgumentException("fromFront must not be negative: " + fromFront);
+    }
+    if (fromFront >= tokenCount - bufferFront) {
+      return NONE;
+    }
+    return bufferFront + fromFront;
+  }
+
+  /**
+   * @return The number of tokens in this parse.
+   */
+  int tokenCount() {
+    return tokenCount;
+  }
+
+  /**
+   * @return The number of stack elements including the artificial root.
+   */
+  int stackSize() {
+    return top + 1;
+  }
+
+  /**
+   * @return The number of tokens still in the buffer.
+   */
+  int bufferSize() {
+    return tokenCount - bufferFront;
+  }
+
+  /**
+   * Retrieves how many dependents have been attached to a token so far.
+   *
+   * @param index The zero-based token index. Must be within {@code [0, tokenCount)}.
+   * @return The number of arcs assigned with the token as head.
+   * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+   */
+  int assignedDependents(int index) {
+    checkTokenIndex(index);
+    return assignedDependents[index];
+  }
+
+  /**
+   * Rejects a token index outside {@code [0, tokenCount)}.
+   *
+   * @param index The zero-based token index to check.
+   * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+   */
+  private void checkTokenIndex(int index) {
+    if (index < 0 || index >= tokenCount) {
+      throw new IllegalArgumentException("token index out of range: " + index);
+    }
+  }
+
+  /**
+   * Retrieves the leftmost dependent attached to a token so far.
+   *
+   * @param index The zero-based token index. Must be within {@code [0, tokenCount)}.
+   * @return The dependent's token index, or {@link #NONE} when none is attached.
+   * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+   */
+  int leftmostDependent(int index) {
+    checkTokenIndex(index);
+    return leftmostDependents[index];
+  }
+
+  /**
+   * Retrieves the rightmost dependent attached to a token so far.
+   *
+   * @param index The zero-based token index. Must be within {@code [0, tokenCount)}.
+   * @return The dependent's token index, or {@link #NONE} when none is attached.
+   * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+   */
+  int rightmostDependent(int index) {
+    checkTokenIndex(index);
+    return rightmostDependents[index];
+  }
+
+  /**
+   * Retrieves the relation a token was attached under, when it has been attached.
+   *
+   * @param index The zero-based token index. Must be within {@code [0, tokenCount)}.
+   * @return The relation label, or {@code null} when the token is still unattached.
+   * @throws IllegalArgumentException Thrown if {@code index} is out of range.
+   */
+  String assignedRelation(int index) {
+    checkTokenIndex(index);
+    return relations[index];
+  }
+
+  /**
+   * Builds the {@link DependencyTree} of a completed parse.
+   *
+   * @return The parsed tree. Never {@code null}.
+   * @throws IllegalStateException Thrown if the parse is not yet terminal.
+   */
+  DependencyTree toTree() {
+    if (!isTerminal()) {
+      throw new IllegalStateException("parse is not terminal: " + this);
+    }
+    return DependencyTree.of(heads, relations);
+  }
+
+  @Override
+  public String toString() {
+    return "stackSize=" + stackSize() + ", bufferSize=" + bufferSize()
+        + ", tokenCount=" + tokenCount;
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/AttachmentScores.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/AttachmentScores.java
new file mode 100644
index 000000000..ab6327649
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/AttachmentScores.java
@@ -0,0 +1,114 @@
+/*
+ * 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 opennlp.tools.util.eval.Mean;
+
+/**
+ * Accumulates the unlabeled and labeled attachment scores over all tokens and over the
+ * tokens that are not punctuation. {@link DependencyEvaluator} adds single tokens and
+ * {@link DependencyCrossValidator} merges the totals of each fold.
+ *
+ * <p>Instances are not thread safe.</p>
+ *
+ * @since 3.0.0
+ */
+final class AttachmentScores {
+
+  private final Mean uas = new Mean();
+  private final Mean las = new Mean();
+  private final Mean uasExcludingPunctuation = new Mean();
+  private final Mean lasExcludingPunctuation = new Mean();
+
+  /**
+   * Adds the outcome of one token.
+   *
+   * @param headMatches Whether the predicted head equals the gold head.
+   * @param labelMatches Whether the predicted head and relation both equal the gold ones.
+   * @param punctuation Whether the token is punctuation and therefore left out of the
+   *                    punctuation-free scores.
+   */
+  void add(boolean headMatches, boolean labelMatches, boolean punctuation) {
+    final int head = headMatches ? 1 : 0;
+    final int label = labelMatches ? 1 : 0;
+    uas.add(head);
+    las.add(label);
+    if (!punctuation) {
+      uasExcludingPunctuation.add(head);
+      lasExcludingPunctuation.add(label);
+    }
+  }
+
+  /**
+   * Adds every token counted by another accumulator, weighted by its token counts.
+   *
+   * @param other The accumulator to merge. Must not be {@code null}.
+   */
+  void add(AttachmentScores other) {
+    uas.add(other.uas.mean(), other.uas.count());
+    las.add(other.las.mean(), other.las.count());
+    uasExcludingPunctuation.add(other.uasExcludingPunctuation.mean(),
+        other.uasExcludingPunctuation.count());
+    lasExcludingPunctuation.add(other.lasExcludingPunctuation.mean(),
+        other.lasExcludingPunctuation.count());
+  }
+
+  /**
+   * @return The unlabeled attachment score over all tokens, or {@code 0} if none.
+   */
+  double getUas() {
+    return uas.mean();
+  }
+
+  /**
+   * @return The labeled attachment score over all tokens, or {@code 0} if none.
+   */
+  double getLas() {
+    return las.mean();
+  }
+
+  /**
+   * @return The number of tokens added.
+   */
+  long getWordCount() {
+    return uas.count();
+  }
+
+  /**
+   * @return The unlabeled attachment score over the tokens that are not punctuation, or
+   *         {@code 0} if none.
+   */
+  double getUasExcludingPunctuation() {
+    return uasExcludingPunctuation.mean();
+  }
+
+  /**
+   * @return The labeled attachment score over the tokens that are not punctuation, or
+   *         {@code 0} if none.
+   */
+  double getLasExcludingPunctuation() {
+    return lasExcludingPunctuation.mean();
+  }
+
+  /**
+   * @return The number of tokens added that are not punctuation.
+   */
+  long getWordCountExcludingPunctuation() {
+    return uasExcludingPunctuation.count();
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyContextGenerator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyContextGenerator.java
new file mode 100644
index 000000000..3b0d0271b
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyContextGenerator.java
@@ -0,0 +1,348 @@
+/*
+ * 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 opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.util.ParamChecks;
+
+/**
+ * Generates the classification features for one arc-standard configuration: words and
+ * tags of the topmost stack and frontmost buffer positions, their pairings, the partial
+ * structure built so far (tags and relations of the leftmost and rightmost dependents,
+ * valency counts), and a bucketed distance between stack top and buffer front.
+ *
+ * <p>Instances hold no state and are safe to share between threads.</p>
+ *
+ * @since 3.0.0
+ */
+@ThreadSafe
+class DependencyContextGenerator {
+
+  /** The feature value standing for the artificial root position. */
+  private static final String ROOT_VALUE = "*ROOT*";
+
+  /** The feature value standing for a position that does not exist. */
+  private static final String NONE_VALUE = "*NULL*";
+
+  /** Separates a word from the tag of the same position within one feature. */
+  private static final char WORD_TAG_SEPARATOR = '/';
+
+  /** Separates the parts of a feature combining several positions. */
+  private static final char POSITION_SEPARATOR = '|';
+
+  /** The number of features {@link #getContext(ArcStandardState, String[], String[])} emits. */
+  private static final int FEATURE_COUNT = 37;
+
+  /** Feature prefix: the word on top of the stack. */
+  private static final String S0_WORD = "s0w=";
+
+  /** Feature prefix: the tag on top of the stack. */
+  private static final String S0_TAG = "s0t=";
+
+  /** Feature prefix: the second stack word. */
+  private static final String S1_WORD = "s1w=";
+
+  /** Feature prefix: the second stack tag. */
+  private static final String S1_TAG = "s1t=";
+
+  /** Feature prefix: the third stack tag. */
+  private static final String S2_TAG = "s2t=";
+
+  /** Feature prefix: the first buffer word. */
+  private static final String B0_WORD = "b0w=";
+
+  /** Feature prefix: the first buffer tag. */
+  private static final String B0_TAG = "b0t=";
+
+  /** Feature prefix: the second buffer word. */
+  private static final String B1_WORD = "b1w=";
+
+  /** Feature prefix: the second buffer tag. */
+  private static final String B1_TAG = "b1t=";
+
+  /** Feature prefix: the third buffer tag. */
+  private static final String B2_TAG = "b2t=";
+
+  /** Feature prefix: word and tag on top of the stack. */
+  private static final String S0_WORD_TAG = "s0wt=";
+
+  /** Feature prefix: word and tag of the second stack position. */
+  private static final String S1_WORD_TAG = "s1wt=";
+
+  /** Feature prefix: word and tag of the first buffer position. */
+  private static final String B0_WORD_TAG = "b0wt=";
+
+  /** Feature prefix: stack top word with first buffer word. */
+  private static final String S0_WORD_B0_WORD = "s0w,b0w=";
+
+  /** Feature prefix: stack top tag with first buffer tag. */
+  private static final String S0_TAG_B0_TAG = "s0t,b0t=";
+
+  /** Feature prefix: stack top word with first buffer tag. */
+  private static final String S0_WORD_B0_TAG = "s0w,b0t=";
+
+  /** Feature prefix: stack top tag with first buffer word. */
+  private static final String S0_TAG_B0_WORD = "s0t,b0w=";
+
+  /** Feature prefix: stack top word and tag with first buffer tag. */
+  private static final String S0_WORD_TAG_B0_TAG = "s0wt,b0t=";
+
+  /** Feature prefix: second stack tag with stack top tag. */
+  private static final String S1_TAG_S0_TAG = "s1t,s0t=";
+
+  /** Feature prefix: second stack tag with stack top word. */
+  private static final String S1_TAG_S0_WORD = "s1t,s0w=";
+
+  /** Feature prefix: second stack word with stack top tag. */
+  private static final String S1_WORD_S0_TAG = "s1w,s0t=";
+
+  /** Feature prefix: second stack, stack top and first buffer tags. */
+  private static final String S1_TAG_S0_TAG_B0_TAG = "s1t,s0t,b0t=";
+
+  /** Feature prefix: stack top, first and second buffer tags. */
+  private static final String S0_TAG_B0_TAG_B1_TAG = "s0t,b0t,b1t=";
+
+  /** Feature prefix: the three topmost stack tags. */
+  private static final String S2_TAG_S1_TAG_S0_TAG = "s2t,s1t,s0t=";
+
+  /** Feature prefix: tag of the leftmost dependent of the stack top. */
+  private static final String S0_LEFT_DEPENDENT_TAG = "s0lct=";
+
+  /** Feature prefix: tag of the rightmost dependent of the stack top. */
+  private static final String S0_RIGHT_DEPENDENT_TAG = "s0rct=";
+
+  /** Feature prefix: tag of the leftmost dependent of the second stack position. */
+  private static final String S1_LEFT_DEPENDENT_TAG = "s1lct=";
+
+  /** Feature prefix: tag of the rightmost dependent of the second stack position. */
+  private static final String S1_RIGHT_DEPENDENT_TAG = "s1rct=";
+
+  /** Feature prefix: relation of the leftmost dependent of the stack top. */
+  private static final String S0_LEFT_DEPENDENT_RELATION = "s0lcl=";
+
+  /** Feature prefix: relation of the rightmost dependent of the stack top. */
+  private static final String S0_RIGHT_DEPENDENT_RELATION = "s0rcl=";
+
+  /** Feature prefix: relation of the rightmost dependent of the second stack position. */
+  private static final String S1_RIGHT_DEPENDENT_RELATION = "s1rcl=";
+
+  /** Feature prefix: second stack tag, its rightmost dependent tag, and stack top tag. */
+  private static final String S1_TAG_S1_RIGHT_DEPENDENT_TAG_S0_TAG = "s1t,s1rct,s0t=";
+
+  /** Feature prefix: stack top tag, its leftmost dependent tag, and first buffer tag. */
+  private static final String S0_TAG_S0_LEFT_DEPENDENT_TAG_B0_TAG = "s0t,s0lct,b0t=";
+
+  /** Feature prefix: dependent count of the stack top. */
+  private static final String S0_DEPENDENTS = "s0deps=";
+
+  /** Feature prefix: dependent count of the second stack position. */
+  private static final String S1_DEPENDENTS = "s1deps=";
+
+  /** Feature prefix: distance between stack top and first buffer position. */
+  private static final String DISTANCE = "dist=";
+
+  /** Feature prefix: distance with stack top and first buffer tags. */
+  private static final String DISTANCE_S0_TAG_B0_TAG = "dist,s0t,b0t=";
+
+  /** Valency counts at or above this bound share one feature value. */
+  private static final int MAX_VALENCY = 3;
+
+  /** Distances at or above this bound share the {@link #LONG_DISTANCE} feature value. */
+  private static final int MAX_DISTANCE = 4;
+
+  /** The feature value standing for every distance of {@link #MAX_DISTANCE} or more. */
+  private static final String LONG_DISTANCE = "4+";
+
+  /**
+   * Generates the features of the current configuration.
+   *
+   * @param state The configuration to describe. Must not be {@code null}.
+   * @param tokens The input tokens. Must satisfy the token contract of
+   *               {@link DependencyParser#parse(String[], String[])} and match the state.
+   * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+   *             {@code null} and must match the state.
+   * @return The feature strings. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if a parameter is invalid or the arrays do
+   *         not match the state.
+   */
+  String[] getContext(ArcStandardState state, String[] tokens, String[] tags) {
+    ParamChecks.requireNonNullArg(state, "state");
+    // Sentence contents are validated once at the parser or sample boundary.
+    if (tokens == null || tags == null || tokens.length != state.tokenCount()
+        || tags.length != state.tokenCount()) {
+      throw new IllegalArgumentException("tokens and tags must match the state token count");
+    }
+    final int s0 = state.stack(0);
+    final int s1 = state.stack(1);
+    final int s2 = state.stack(2);
+    final int b0 = state.buffer(0);
+    final int b1 = state.buffer(1);
+    final int b2 = state.buffer(2);
+
+    final String s0w = valueAt(tokens, s0);
+    final String s0t = valueAt(tags, s0);
+    final String s1w = valueAt(tokens, s1);
+    final String s1t = valueAt(tags, s1);
+    final String s2t = valueAt(tags, s2);
+    final String b0w = valueAt(tokens, b0);
+    final String b0t = valueAt(tags, b0);
+    final String b1w = valueAt(tokens, b1);
+    final String b1t = valueAt(tags, b1);
+    final String b2t = valueAt(tags, b2);
+
+    final String s0lct = dependentTag(state, tags, s0, true);
+    final String s0rct = dependentTag(state, tags, s0, false);
+    final String s1lct = dependentTag(state, tags, s1, true);
+    final String s1rct = dependentTag(state, tags, s1, false);
+    final String s0lcl = dependentRelation(state, s0, true);
+    final String s0rcl = dependentRelation(state, s0, false);
+    final String s1rcl = dependentRelation(state, s1, false);
+
+    final String distance = distance(s0, b0);
+    final String[] features = new String[FEATURE_COUNT];
+    int f = 0;
+    features[f++] = S0_WORD + s0w;
+    features[f++] = S0_TAG + s0t;
+    features[f++] = S1_WORD + s1w;
+    features[f++] = S1_TAG + s1t;
+    features[f++] = S2_TAG + s2t;
+    features[f++] = B0_WORD + b0w;
+    features[f++] = B0_TAG + b0t;
+    features[f++] = B1_WORD + b1w;
+    features[f++] = B1_TAG + b1t;
+    features[f++] = B2_TAG + b2t;
+    features[f++] = S0_WORD_TAG + s0w + WORD_TAG_SEPARATOR + s0t;
+    features[f++] = S1_WORD_TAG + s1w + WORD_TAG_SEPARATOR + s1t;
+    features[f++] = B0_WORD_TAG + b0w + WORD_TAG_SEPARATOR + b0t;
+    features[f++] = S0_WORD_B0_WORD + s0w + POSITION_SEPARATOR + b0w;
+    features[f++] = S0_TAG_B0_TAG + s0t + POSITION_SEPARATOR + b0t;
+    features[f++] = S0_WORD_B0_TAG + s0w + POSITION_SEPARATOR + b0t;
+    features[f++] = S0_TAG_B0_WORD + s0t + POSITION_SEPARATOR + b0w;
+    features[f++] = S0_WORD_TAG_B0_TAG + s0w + WORD_TAG_SEPARATOR + s0t + POSITION_SEPARATOR + b0t;
+    features[f++] = S1_TAG_S0_TAG + s1t + POSITION_SEPARATOR + s0t;
+    features[f++] = S1_TAG_S0_WORD + s1t + POSITION_SEPARATOR + s0w;
+    features[f++] = S1_WORD_S0_TAG + s1w + POSITION_SEPARATOR + s0t;
+    features[f++] = S1_TAG_S0_TAG_B0_TAG
+        + s1t + POSITION_SEPARATOR + s0t + POSITION_SEPARATOR + b0t;
+    features[f++] = S0_TAG_B0_TAG_B1_TAG
+        + s0t + POSITION_SEPARATOR + b0t + POSITION_SEPARATOR + b1t;
+    features[f++] = S2_TAG_S1_TAG_S0_TAG
+        + s2t + POSITION_SEPARATOR + s1t + POSITION_SEPARATOR + s0t;
+    features[f++] = S0_LEFT_DEPENDENT_TAG + s0lct;
+    features[f++] = S0_RIGHT_DEPENDENT_TAG + s0rct;
+    features[f++] = S1_LEFT_DEPENDENT_TAG + s1lct;
+    features[f++] = S1_RIGHT_DEPENDENT_TAG + s1rct;
+    features[f++] = S0_LEFT_DEPENDENT_RELATION + s0lcl;
+    features[f++] = S0_RIGHT_DEPENDENT_RELATION + s0rcl;
+    features[f++] = S1_RIGHT_DEPENDENT_RELATION + s1rcl;
+    features[f++] = S1_TAG_S1_RIGHT_DEPENDENT_TAG_S0_TAG
+        + s1t + POSITION_SEPARATOR + s1rct + POSITION_SEPARATOR + s0t;
+    features[f++] = S0_TAG_S0_LEFT_DEPENDENT_TAG_B0_TAG
+        + s0t + POSITION_SEPARATOR + s0lct + POSITION_SEPARATOR + b0t;
+    features[f++] = S0_DEPENDENTS + dependents(state, s0);
+    features[f++] = S1_DEPENDENTS + dependents(state, s1);
+    features[f++] = DISTANCE + distance;
+    features[f++] = DISTANCE_S0_TAG_B0_TAG
+        + distance + POSITION_SEPARATOR + s0t + POSITION_SEPARATOR + b0t;
+    return features;
+  }
+
+  /**
+   * Looks up the word or tag at a stack or buffer position.
+   *
+   * @param values The sentence tokens or their part-of-speech tags.
+   * @param index The token index, {@link ArcStandardState#ROOT} or {@link ArcStandardState#NONE}.
+   * @return The value, or the marker value for the root and absent positions.
+   */
+  private String valueAt(String[] values, int index) {
+    if (index == ArcStandardState.ROOT) {
+      return ROOT_VALUE;
+    }
+    return index == ArcStandardState.NONE ? NONE_VALUE : values[index];
+  }
+
+  /**
+   * Looks up the tag of a token's leftmost or rightmost dependent attached so far.
+   *
+   * @param state The current configuration.
+   * @param tags The part-of-speech tags of the sentence.
+   * @param index The token index, or a negative value for the root and absent positions.
+   * @param leftmost {@code true} for the leftmost dependent, {@code false} for the rightmost.
+   * @return The dependent's tag, or the marker value if there is no such dependent.
+   */
+  private String dependentTag(ArcStandardState state, String[] tags, int index,
+      boolean leftmost) {
+    if (index < 0) {
+      return NONE_VALUE;
+    }
+    final int dependent =
+        leftmost ? state.leftmostDependent(index) : state.rightmostDependent(index);
+    return valueAt(tags, dependent);
+  }
+
+  /**
+   * Looks up the relation of a token's leftmost or rightmost dependent attached so far.
+   *
+   * @param state The current configuration.
+   * @param index The token index, or a negative value for the root and absent positions.
+   * @param leftmost {@code true} for the leftmost dependent, {@code false} for the rightmost.
+   * @return The dependent's relation, or the marker value if there is no such dependent.
+   */
+  private String dependentRelation(ArcStandardState state, int index,
+      boolean leftmost) {
+    if (index < 0) {
+      return NONE_VALUE;
+    }
+    final int dependent =
+        leftmost ? state.leftmostDependent(index) : state.rightmostDependent(index);
+    if (dependent < 0) {
+      return NONE_VALUE;
+    }
+    final String relation = state.assignedRelation(dependent);
+    return relation == null ? NONE_VALUE : relation;
+  }
+
+  /**
+   * Counts the dependents a token has been assigned so far.
+   *
+   * @param state The current configuration.
+   * @param index The token index, or a negative value for the root and absent positions.
+   * @return The count, capped at {@link #MAX_VALENCY}, or the marker value for the root
+   *         and absent positions.
+   */
+  private String dependents(ArcStandardState state, int index) {
+    return index < 0 ? NONE_VALUE
+        : Integer.toString(Math.min(state.assignedDependents(index), MAX_VALENCY));
+  }
+
+  /**
+   * Buckets the distance between the stack top and the buffer front.
+   *
+   * @param s0 The token index on top of the stack, negative for the root or none.
+   * @param b0 The token index at the buffer front, negative for none.
+   * @return The distance, {@link #LONG_DISTANCE} at {@link #MAX_DISTANCE} or more, or the
+   *         marker value if either position is not a token.
+   */
+  private String distance(int s0, int b0) {
+    if (s0 < 0 || b0 < 0) {
+      return NONE_VALUE;
+    }
+    final int distance = b0 - s0;
+    return distance >= MAX_DISTANCE ? LONG_DISTANCE : Integer.toString(distance);
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyCrossValidator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyCrossValidator.java
new file mode 100644
index 000000000..f7199064d
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyCrossValidator.java
@@ -0,0 +1,170 @@
+/*
+ * 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.IOException;
+import java.util.function.Predicate;
+
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.eval.CrossValidationPartitioner;
+
+/**
+ * Cross validator for {@link DependencyParserME}.
+ *
+ * <p>The samples are split into {@code k} folds. For each fold a parser is trained with
+ * {@link DependencyParserME#train} on the other {@code k - 1} folds and scored on the
+ * held-out fold with a {@link DependencyEvaluator}. The unlabeled and labeled attachment
+ * scores are accumulated over all evaluated tokens, so every token of the input counts
+ * once, in the fold it was held out from. The listeners given at construction are told
+ * about each held-out sample.</p>
+ *
+ * @see DependencyEvaluator
+ * @see CrossValidationPartitioner
+ * @since 3.0.0
+ */
+public class DependencyCrossValidator {
+
+  /** The smallest fold count that leaves training data for every fold. */
+  private static final int MIN_FOLDS = 2;
+
+  private final String languageCode;
+  private final TrainingParameters params;
+  private final Predicate<String> punctuationTag;
+  private final DependencyEvaluationMonitor[] listeners;
+  private final AttachmentScores scores = new AttachmentScores();
+
+  /**
+   * Initializes a {@link DependencyCrossValidator} that treats tokens tagged
+   * {@link DependencyEvaluator#UNIVERSAL_PUNCTUATION_TAG} as punctuation.
+   *
+   * @param languageCode The ISO language code of the samples. Must not be {@code null}.
+   * @param params The {@link TrainingParameters} for the parser of each fold. Must not be
+   *               {@code null}.
+   * @param listeners The {@link DependencyEvaluationMonitor listeners} told about each
+   *                  held-out sample; {@code null} entries are ignored.
+   * @throws IllegalArgumentException Thrown if {@code languageCode} or {@code params}
+   *         is {@code null}.
+   */
+  public DependencyCrossValidator(String languageCode, TrainingParameters params,
+      DependencyEvaluationMonitor... listeners) {
+    this(languageCode, params, DependencyEvaluator.UNIVERSAL_PUNCTUATION_TAG::equals,
+        listeners);
+  }
+
+  /**
+   * Initializes a {@link DependencyCrossValidator} with a custom notion of punctuation.
+   *
+   * @param languageCode The ISO language code of the samples. Must not be {@code null}.
+   * @param params The {@link TrainingParameters} for the parser of each fold. Must not be
+   *               {@code null}.
+   * @param punctuationTag Decides from a gold part-of-speech tag whether the token is
+   *                       punctuation and therefore left out of the punctuation-free
+   *                       scores. Must not be {@code null}.
+   * @param listeners The {@link DependencyEvaluationMonitor listeners} told about each
+   *                  held-out sample; {@code null} entries are ignored.
+   * @throws IllegalArgumentException Thrown if {@code languageCode}, {@code params} or
+   *         {@code punctuationTag} is {@code null}.
+   */
+  public DependencyCrossValidator(String languageCode, TrainingParameters params,
+      Predicate<String> punctuationTag, DependencyEvaluationMonitor... listeners) {
+    ParamChecks.requireNonNullArg(languageCode, "languageCode");
+    ParamChecks.requireNonNullArg(params, "params");
+    ParamChecks.requireNonNullArg(punctuationTag, "punctuationTag");
+    this.languageCode = languageCode;
+    this.params = params;
+    this.punctuationTag = punctuationTag;
+    this.listeners = listeners == null ? new DependencyEvaluationMonitor[0] : listeners.clone();
+  }
+
+  /**
+   * Runs the cross validation and adds the scores of every fold to the totals.
+   *
+   * @param samples The samples to train and test with. Must not be {@code null} and must
+   *                support {@link ObjectStream#reset()}, because every fold reads the
+   *                stream from the start. The stream is not closed.
+   * @param folds The number of folds. Must be at least {@code 2}.
+   * @throws IOException Thrown if reading the samples fails.
+   * @throws IllegalArgumentException Thrown if {@code samples} is {@code null},
+   *         {@code folds} is below {@code 2}, or the training parameters do not select
+   *         an event model trainer.
+   */
+  public void evaluate(ObjectStream<DependencySample> samples, int folds) throws IOException {
+    ParamChecks.requireNonNullArg(samples, "samples");
+    if (folds < MIN_FOLDS) {
+      throw new IllegalArgumentException("folds must be at least " + MIN_FOLDS + ": " + folds);
+    }
+    final CrossValidationPartitioner<DependencySample> partitioner =
+        new CrossValidationPartitioner<>(samples, folds);
+    while (partitioner.hasNext()) {
+      final CrossValidationPartitioner.TrainingSampleStream<DependencySample> training =
+          partitioner.next();
+      final DependencyModel model = DependencyParserME.train(languageCode, training, params);
+      final DependencyEvaluator evaluator =
+          new DependencyEvaluator(new DependencyParserME(model), punctuationTag, listeners);
+      evaluator.evaluate(training.getTestSampleStream());
+      scores.add(evaluator.scores());
+    }
+  }
+
+  /**
+   * @return The unlabeled attachment score over all tokens evaluated so far.
+   */
+  public double getUas() {
+    return scores.getUas();
+  }
+
+  /**
+   * @return The labeled attachment score over all tokens evaluated so far.
+   */
+  public double getLas() {
+    return scores.getLas();
+  }
+
+  /**
+   * @return The number of tokens evaluated so far; over one complete run this is the
+   *         number of tokens in the samples, because every token is held out once.
+   */
+  public long getWordCount() {
+    return scores.getWordCount();
+  }
+
+  /**
+   * @return The unlabeled attachment score over the evaluated tokens that are not
+   *         punctuation.
+   */
+  public double getUasExcludingPunctuation() {
+    return scores.getUasExcludingPunctuation();
+  }
+
+  /**
+   * @return The labeled attachment score over the evaluated tokens that are not
+   *         punctuation.
+   */
+  public double getLasExcludingPunctuation() {
+    return scores.getLasExcludingPunctuation();
+  }
+
+  /**
+   * @return The number of evaluated tokens that are not punctuation.
+   */
+  public long getWordCountExcludingPunctuation() {
+    return scores.getWordCountExcludingPunctuation();
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEvaluator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEvaluator.java
new file mode 100644
index 000000000..6d84c915b
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEvaluator.java
@@ -0,0 +1,157 @@
+/*
+ * 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.util.function.Predicate;
+
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.eval.Evaluator;
+
+/**
+ * Measures the quality of a {@link DependencyParser} against gold
+ * {@link DependencySample samples} with the two standard scores: the unlabeled attachment
+ * score (UAS, the fraction of tokens with the correct head) and the labeled attachment
+ * score (LAS, the fraction of tokens with the correct head and relation label).
+ *
+ * <p>Both scores are also kept over the tokens that are not punctuation, because
+ * treebank evaluations customarily leave punctuation out of the attachment scores. A
+ * token counts as punctuation when its gold part-of-speech tag satisfies the predicate
+ * given at construction; by default that is the universal tag
+ * {@link #UNIVERSAL_PUNCTUATION_TAG}.</p>
+ *
+ * @since 3.0.0
+ */
+public class DependencyEvaluator extends Evaluator<DependencySample> {
+
+  /**
+   * The universal part-of-speech tag of punctuation, which the default constructor
+   * excludes from the punctuation-free scores.
+   */
+  public static final String UNIVERSAL_PUNCTUATION_TAG = "PUNCT";
+
+  private final DependencyParser parser;
+  private final Predicate<String> punctuationTag;
+  private final AttachmentScores scores = new AttachmentScores();
+
+  /**
+   * Initializes a {@link DependencyEvaluator} that treats tokens tagged
+   * {@link #UNIVERSAL_PUNCTUATION_TAG} as punctuation.
+   *
+   * @param parser The parser to evaluate. Must not be {@code null}.
+   * @param listeners The {@link DependencyEvaluationMonitor listeners} told about each
+   *                  evaluated sample; {@code null} entries are ignored.
+   * @throws IllegalArgumentException Thrown if {@code parser} is {@code null}.
+   */
+  public DependencyEvaluator(DependencyParser parser, DependencyEvaluationMonitor... listeners) {
+    this(parser, UNIVERSAL_PUNCTUATION_TAG::equals, listeners);
+  }
+
+  /**
+   * Initializes a {@link DependencyEvaluator} with a custom notion of punctuation.
+   *
+   * @param parser The parser to evaluate. Must not be {@code null}.
+   * @param punctuationTag Decides from a gold part-of-speech tag whether the token is
+   *                       punctuation and therefore left out of the punctuation-free
+   *                       scores. Must not be {@code null}.
+   * @param listeners The {@link DependencyEvaluationMonitor listeners} told about each
+   *                  evaluated sample; {@code null} entries are ignored.
+   * @throws IllegalArgumentException Thrown if {@code parser} or
+   *         {@code punctuationTag} is {@code null}.
+   */
+  public DependencyEvaluator(DependencyParser parser, Predicate<String> punctuationTag,
+      DependencyEvaluationMonitor... listeners) {
+    super(listeners);
+    ParamChecks.requireNonNullArg(parser, "parser");
+    ParamChecks.requireNonNullArg(punctuationTag, "punctuationTag");
+    this.parser = parser;
+    this.punctuationTag = punctuationTag;
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>The returned sample carries the predicted tree over the reference tokens, and
+   * every token of the reference contributes to both scores. Listeners are told whether
+   * the predicted tree equals the gold tree.</p>
+   */
+  @Override
+  protected DependencySample processSample(DependencySample reference) {
+    final DependencyTree gold = reference.getTree();
+    final String[] tokens = reference.getTokens();
+    final String[] tags = reference.getTags();
+    final DependencyTree predicted = parser.parse(tokens, tags);
+    for (int i = 0; i < gold.size(); i++) {
+      final boolean headMatches = gold.headOf(i) == predicted.headOf(i);
+      final boolean labelMatches =
+          headMatches && gold.relationOf(i).equals(predicted.relationOf(i));
+      scores.add(headMatches, labelMatches, punctuationTag.test(tags[i]));
+    }
+    return new DependencySample(tokens, tags, predicted);
+  }
+
+  /**
+   * @return The unlabeled attachment score over all evaluated tokens.
+   */
+  public double getUas() {
+    return scores.getUas();
+  }
+
+  /**
+   * @return The labeled attachment score over all evaluated tokens.
+   */
+  public double getLas() {
+    return scores.getLas();
+  }
+
+  /**
+   * @return The number of tokens scored so far.
+   */
+  public long getWordCount() {
+    return scores.getWordCount();
+  }
+
+  /**
+   * @return The unlabeled attachment score over the evaluated tokens that are not
+   *         punctuation.
+   */
+  public double getUasExcludingPunctuation() {
+    return scores.getUasExcludingPunctuation();
+  }
+
+  /**
+   * @return The labeled attachment score over the evaluated tokens that are not
+   *         punctuation.
+   */
+  public double getLasExcludingPunctuation() {
+    return scores.getLasExcludingPunctuation();
+  }
+
+  /**
+   * @return The number of tokens scored so far that are not punctuation.
+   */
+  public long getWordCountExcludingPunctuation() {
+    return scores.getWordCountExcludingPunctuation();
+  }
+
+  /**
+   * @return The accumulated scores, for merging into a cross validation total.
+   */
+  AttachmentScores scores() {
+    return scores;
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEventStream.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEventStream.java
new file mode 100644
index 000000000..4d8fda3d3
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEventStream.java
@@ -0,0 +1,126 @@
+/*
+ * 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.IOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.ml.model.Event;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+
+/**
+ * Turns {@link DependencySample samples} into training {@link Event events}: for each
+ * sample the {@link ArcStandardOracle} derives the gold transitions, and every transition
+ * becomes one event pairing the configuration features with the encoded transition.
+ *
+ * <p>Samples whose tree has no arc-standard derivation, that is non-projective trees,
+ * are skipped and counted; the count is logged once the stream is exhausted.</p>
+ */
+class DependencyEventStream implements ObjectStream<Event> {
+
+  private static final Logger logger = LoggerFactory.getLogger(DependencyEventStream.class);
+
+  private final ObjectStream<DependencySample> samples;
+  private final DependencyContextGenerator contextGenerator;
+  private final Deque<Event> pending = new ArrayDeque<>();
+
+  private int skipped;
+
+  /**
+   * Initializes the stream.
+   *
+   * @param samples The samples to convert. Must not be {@code null}.
+   * @param contextGenerator The feature generator. Must not be {@code null}.
+   * @throws IllegalArgumentException Thrown if any parameter is {@code null}.
+   */
+  DependencyEventStream(ObjectStream<DependencySample> samples,
+      DependencyContextGenerator contextGenerator) {
+    ParamChecks.requireNonNullArg(samples, "samples");
+    ParamChecks.requireNonNullArg(contextGenerator, "contextGenerator");
+    this.samples = samples;
+    this.contextGenerator = contextGenerator;
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Returns the events of one sample at a time and moves on to the next sample once
+   * they are exhausted.</p>
+   *
+   * @throws IOException Thrown if reading the underlying samples fails.
+   */
+  @Override
+  public Event read() throws IOException {
+    while (pending.isEmpty()) {
+      final DependencySample sample = samples.read();
+      if (sample == null) {
+        if (skipped > 0) {
+          logger.warn("Skipped {} non-projective sample(s) without an arc-standard derivation.",
+              skipped);
+          skipped = 0;
+        }
+        return null;
+      }
+      if (!ArcStandardOracle.isProjective(sample.getTree())) {
+        skipped++;
+        continue;
+      }
+      final List<Transition> transitions = ArcStandardOracle.transitions(sample.getTree());
+      final ArcStandardState state = new ArcStandardState(sample.getTree().size());
+      final String[] tokens = sample.getTokens();
+      final String[] tags = sample.getTags();
+      for (final Transition transition : transitions) {
+        pending.add(new Event(transition.encode(),
+            contextGenerator.getContext(state, tokens, tags)));
+        state.apply(transition);
+      }
+    }
+    return pending.poll();
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Also discards buffered events and the skipped sample count.</p>
+   *
+   * @throws IOException Thrown if resetting the underlying samples fails.
+   * @throws UnsupportedOperationException Thrown if the underlying samples cannot be reset.
+   */
+  @Override
+  public void reset() throws IOException, UnsupportedOperationException {
+    samples.reset();
+    pending.clear();
+    skipped = 0;
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * @throws IOException Thrown if closing the underlying samples fails.
+   */
+  @Override
+  public void close() throws IOException {
+    samples.close();
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyModel.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyModel.java
new file mode 100644
index 000000000..c04385a19
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyModel.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.tools.depparse;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Serial;
+import java.nio.file.Path;
+import java.util.Map;
+
+import opennlp.tools.ml.model.AbstractModel;
+import opennlp.tools.ml.model.MaxentModel;
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.model.BaseModel;
+
+/**
+ * The persisted form of a trained {@link DependencyParserME}: the transition
+ * classification model plus the standard model manifest, serialized and loaded through
+ * the same {@link BaseModel} machinery as every other tool model.
+ *
+ * @see DependencyParserME
+ * @since 3.0.0
+ */
+public class DependencyModel extends BaseModel {
+
+  @Serial
+  private static final long serialVersionUID = 6397845119220799380L;
+
+  /** The component name recorded in the model manifest. */
+  private static final String COMPONENT_NAME = "DependencyParserME";
+
+  /** The artifact map entry holding the transition classification model. */
+  static final String PARSER_MODEL_ENTRY_NAME = "depparse.model";
+
+  /**
+   * Initializes a {@link DependencyModel} from a trained transition model.
+   *
+   * @param languageCode The ISO language code of the training data. Must not be
+   *                     {@code null}.
+   * @param parserModel The transition classification model. Must not be {@code null}.
+   * @param manifestInfoEntries Additional entries for the manifest, or {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code languageCode} or
+   *         {@code parserModel} is {@code null}.
+   */
+  public DependencyModel(String languageCode, MaxentModel parserModel,
+      Map<String, String> manifestInfoEntries) {
+    super(COMPONENT_NAME, ParamChecks.requireNonNullArg(languageCode, "languageCode"),
+        manifestInfoEntries);
+    ParamChecks.requireNonNullArg(parserModel, "parserModel");
+    artifactMap.put(PARSER_MODEL_ENTRY_NAME, parserModel);
+    checkArtifactMap();
+  }
+
+  /**
+   * Initializes a {@link DependencyModel} from a serialized model.
+   *
+   * @param in The stream to read the model from. Must not be {@code null}.
+   * @throws IOException Thrown if reading fails or the content is not a valid model.
+   * @throws IllegalArgumentException Thrown if {@code in} is {@code null}.
+   */
+  public DependencyModel(InputStream in) throws IOException {
+    super(COMPONENT_NAME, ParamChecks.requireNonNullArg(in, "in"));
+  }
+
+  /**
+   * Initializes a {@link DependencyModel} from a serialized model file.
+   *
+   * @param modelFile The file to read the model from. Must not be {@code null}.
+   * @throws IOException Thrown if reading fails or the content is not a valid model.
+   * @throws IllegalArgumentException Thrown if {@code modelFile} is {@code null}.
+   */
+  public DependencyModel(File modelFile) throws IOException {
+    super(COMPONENT_NAME, ParamChecks.requireNonNullArg(modelFile, "modelFile"));
+  }
+
+  /**
+   * Initializes a {@link DependencyModel} from a serialized model file.
+   *
+   * @param modelPath The path to read the model from. Must not be {@code null}.
+   * @throws IOException Thrown if reading fails or the content is not a valid model.
+   * @throws IllegalArgumentException Thrown if {@code modelPath} is {@code null}.
+   */
+  public DependencyModel(Path modelPath) throws IOException {
+    super(COMPONENT_NAME, ParamChecks.requireNonNullArg(modelPath, "modelPath"));
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * @throws InvalidFormatException Thrown if the transition model artifact is missing or
+   *         not a supported model type.
+   */
+  @Override
+  protected void validateArtifactMap() throws InvalidFormatException {
+    super.validateArtifactMap();
+    if (!(artifactMap.get(PARSER_MODEL_ENTRY_NAME) instanceof AbstractModel)) {
+      throw new InvalidFormatException("The " + PARSER_MODEL_ENTRY_NAME
+          + " artifact is missing or not a supported transition model.");
+    }
+  }
+
+  /**
+   * @return The transition classification model. Never {@code null}.
+   */
+  public MaxentModel getParserModel() {
+    return (MaxentModel) artifactMap.get(PARSER_MODEL_ENTRY_NAME);
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyParserME.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyParserME.java
new file mode 100644
index 000000000..739f6f8e0
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyParserME.java
@@ -0,0 +1,208 @@
+/*
+ * 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.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.ml.EventTrainer;
+import opennlp.tools.ml.TrainerFactory;
+import opennlp.tools.ml.TrainerFactory.TrainerType;
+import opennlp.tools.ml.model.Event;
+import opennlp.tools.ml.model.MaxentModel;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.TrainingParameters;
+
+/**
+ * A greedy transition-based {@link DependencyParser}: a classifier picks the next
+ * arc-standard {@link Transition} for each configuration until the parse is
+ * complete, always taking the highest scoring transition that is applicable.
+ *
+ * <p>The parser holds an immutable model and no per-parse state, so one instance can be
+ * shared between threads.</p>
+ *
+ * @see DependencyParser
+ * @since 3.0.0
+ */
+@ThreadSafe
+public class DependencyParserME implements DependencyParser {
+
+  private final MaxentModel model;
+  private final DependencyContextGenerator contextGenerator;
+  private final Transition[] transitions;
+
+  /**
+   * Initializes a {@link DependencyParserME} from a {@link DependencyModel}.
+   *
+   * @param model The model to parse with. Must not be {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code model} is {@code null} or an
+   *         outcome inventory is invalid or cannot parse a sentence.
+   */
+  public DependencyParserME(DependencyModel model) {
+    ParamChecks.requireNonNullArg(model, "model");
+    this.model = model.getParserModel();
+    this.contextGenerator = new DependencyContextGenerator();
+    this.transitions = decodeOutcomes(this.model);
+  }
+
+  /**
+   * Decodes the outcome inventory once, so that decoding a sentence indexes it instead
+   * of parsing an outcome string per configuration and outcome.
+   *
+   * @param model The transition classification model.
+   * @return The transitions by outcome index. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if an outcome does not decode to a
+   *         transition, which means the model is not a dependency parser model.
+   */
+  private Transition[] decodeOutcomes(MaxentModel model) {
+    final Transition[] decoded = new Transition[model.getNumOutcomes()];
+    final Set<Transition> seen = new HashSet<>();
+    boolean hasShift = false;
+    boolean hasRightArc = false;
+    for (int i = 0; i < decoded.length; i++) {
+      final String outcome = model.getOutcome(i);
+      try {
+        decoded[i] = Transition.decode(outcome);
+      } catch (IllegalArgumentException e) {
+        throw new IllegalArgumentException("model outcome is not a transition: " + outcome, e);
+      }
+      if (!seen.add(decoded[i])) {
+        throw new IllegalArgumentException("duplicate model transition: " + outcome);
+      }
+      hasShift |= decoded[i].type() == Transition.Type.SHIFT;
+      hasRightArc |= decoded[i].type() == Transition.Type.RIGHT_ARC;
+    }
+    if (!hasShift) {
+      throw new IllegalArgumentException("model has no SHIFT action");
+    }
+    if (!hasRightArc) {
+      throw new IllegalArgumentException("model has no RIGHT_ARC action");
+    }
+    return decoded;
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * @throws IllegalStateException Thrown if the model returns scores that do not match
+   *         its outcomes or are not finite.
+   */
+  @Override
+  public DependencyTree parse(String[] tokens, String[] tags) {
+    checkTokensAndTags(tokens, tags);
+    final ArcStandardState state = new ArcStandardState(tokens.length);
+    while (!state.isTerminal()) {
+      state.apply(bestApplicable(state, tokens, tags));
+    }
+    return state.toTree();
+  }
+
+  /**
+   * Validates the input of {@link #parse(String[], String[])}, with the same rules and
+   * messages as {@link DependencySample}.
+   *
+   * @param tokens The token array. Must not be {@code null} or empty and must not
+   *               contain {@code null} entries.
+   * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+   *             {@code null}, must have the same length as {@code tokens}, and must not
+   *             contain {@code null} entries.
+   * @throws IllegalArgumentException Thrown if an array is {@code null}, {@code tokens}
+   *         is empty, the lengths do not match, or an entry is {@code null}.
+   */
+  private void checkTokensAndTags(String[] tokens, String[] tags) {
+    ParamChecks.requireNonEmptyParallelArrays(tokens, "tokens", tags, "tags");
+  }
+
+  /**
+   * Scores all outcomes for the current configuration and picks the best transition that
+   * is applicable; inapplicable outcomes are passed over regardless of score.
+   *
+   * @param state The current configuration.
+   * @param tokens The sentence tokens.
+   * @param tags The part-of-speech tags aligned with {@code tokens}.
+   * @return The highest scoring applicable transition. Never {@code null}.
+   * @throws IllegalStateException Thrown if the model returns a score count that does not
+   *         match its outcomes, a non-finite score, or no applicable transition.
+   */
+  private Transition bestApplicable(ArcStandardState state, String[] tokens, String[] tags) {
+    final double[] probabilities = model.eval(contextGenerator.getContext(state, tokens, tags));
+    if (probabilities == null || probabilities.length != transitions.length) {
+      final int count = probabilities == null ? 0 : probabilities.length;
+      throw new IllegalStateException("model returned " + count + " scores for "
+          + transitions.length + " outcomes");
+    }
+    Transition best = null;
+    double bestProbability = Double.NEGATIVE_INFINITY;
+    for (int i = 0; i < probabilities.length; i++) {
+      if (!Double.isFinite(probabilities[i])) {
+        throw new IllegalStateException("model returned a non-finite score at index " + i);
+      }
+      if (probabilities[i] <= bestProbability) {
+        continue;
+      }
+      if (state.canApply(transitions[i])) {
+        best = transitions[i];
+        bestProbability = probabilities[i];
+      }
+    }
+    if (best == null) {
+      throw new IllegalStateException(
+          "no applicable transition among the model outcomes in " + state);
+    }
+    return best;
+  }
+
+  /**
+   * Trains a greedy arc-standard parser model from dependency samples.
+   *
+   * <p>Non-projective samples have no arc-standard derivation and are skipped during
+   * event generation.</p>
+   *
+   * @param languageCode The ISO language code of the training data. Must not be
+   *                     {@code null}.
+   * @param samples The training samples. Must not be {@code null}.
+   * @param parameters The {@link TrainingParameters}. Must not be {@code null} and must
+   *                   select an event model trainer.
+   * @return A trained {@link DependencyModel}. Never {@code null}.
+   * @throws IOException Thrown if reading the samples fails.
+   * @throws IllegalArgumentException Thrown if a parameter is {@code null} or the
+   *         configured trainer is not an event model trainer.
+   */
+  public static DependencyModel train(String languageCode,
+      ObjectStream<DependencySample> samples, TrainingParameters parameters)
+      throws IOException {
+    ParamChecks.requireNonNullArg(languageCode, "languageCode");
+    ParamChecks.requireNonNullArg(samples, "samples");
+    ParamChecks.requireNonNullArg(parameters, "parameters");
+    final TrainerType trainerType = TrainerFactory.getTrainerType(parameters);
+    if (!TrainerType.EVENT_MODEL_TRAINER.equals(trainerType)) {
+      throw new IllegalArgumentException("Trainer type is not supported: " + trainerType);
+    }
+    final Map<String, String> manifestInfoEntries = new HashMap<>();
+    final EventTrainer<TrainingParameters> trainer =
+        TrainerFactory.getEventTrainer(parameters, manifestInfoEntries);
+    final ObjectStream<Event> events =
+        new DependencyEventStream(samples, new DependencyContextGenerator());
+    return new DependencyModel(languageCode, trainer.train(events), manifestInfoEntries);
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/Transition.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/Transition.java
new file mode 100644
index 000000000..638954f50
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/Transition.java
@@ -0,0 +1,129 @@
+/*
+ * 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 opennlp.tools.util.ParamChecks;
+import opennlp.tools.util.StringUtil;
+
+/**
+ * One action of the arc-standard transition system: shift the next buffer token onto the
+ * stack, or attach one of the two topmost stack tokens to the other under a relation label.
+ *
+ * <p>{@link #encode()} renders a transition as a classification outcome and
+ * {@link #decode(String)} restores it.</p>
+ *
+ * @param type The kind of action. Must not be {@code null}.
+ * @param label The relation label for arc actions, {@code null} for {@link Type#SHIFT}.
+ *
+ * @since 3.0.0
+ */
+record Transition(Type type, String label) {
+
+  /**
+   * The kinds of arc-standard action.
+   */
+  enum Type {
+    /** Pushes the front of the buffer onto the stack. */
+    SHIFT,
+    /** Attaches the second stack token to the top one and removes the second. */
+    LEFT_ARC,
+    /** Attaches the top stack token to the second one and removes the top. */
+    RIGHT_ARC
+  }
+
+  /** The single shift transition; shifts carry no label. */
+  static final Transition SHIFT = new Transition(Type.SHIFT, null);
+
+  /** Separates the type name from the label in an encoded arc transition. */
+  private static final char SEPARATOR = ':';
+
+  /**
+   * Validates the pairing of type and label.
+   *
+   * @throws IllegalArgumentException Thrown if {@code type} is {@code null}, a shift
+   *         carries a label, or an arc action has a {@code null} or blank label.
+   */
+  Transition {
+    ParamChecks.requireNonNullArg(type, "type");
+    if (type == Type.SHIFT) {
+      if (label != null) {
+        throw new IllegalArgumentException("a shift must not carry a label: " + label);
+      }
+    } else if (label == null || StringUtil.isBlank(label)) {
+      throw new IllegalArgumentException("an arc transition needs a relation label");
+    }
+  }
+
+  /**
+   * Creates a left-arc transition.
+   *
+   * @param label The relation label. Must not be {@code null} or blank.
+   * @return A {@link Transition} of {@link Type#LEFT_ARC}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code label} is {@code null} or blank.
+   */
+  static Transition leftArc(String label) {
+    return new Transition(Type.LEFT_ARC, label);
+  }
+
+  /**
+   * Creates a right-arc transition.
+   *
+   * @param label The relation label. Must not be {@code null} or blank.
+   * @return A {@link Transition} of {@link Type#RIGHT_ARC}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code label} is {@code null} or blank.
+   */
+  static Transition rightArc(String label) {
+    return new Transition(Type.RIGHT_ARC, label);
+  }
+
+  /**
+   * Renders the transition as a model outcome string, for example {@code SHIFT} or
+   * {@code LEFT_ARC:nsubj}.
+   *
+   * @return The outcome string. Never {@code null}.
+   */
+  String encode() {
+    return type == Type.SHIFT ? type.name() : type.name() + SEPARATOR + label;
+  }
+
+  /**
+   * Restores a transition from a model outcome string produced by {@link #encode()}.
+   *
+   * @param outcome The outcome string. Must not be {@code null}.
+   * @return The decoded {@link Transition}. Never {@code null}.
+   * @throws IllegalArgumentException Thrown if {@code outcome} is {@code null} or does not
+   *         name a valid transition.
+   */
+  static Transition decode(String outcome) {
+    ParamChecks.requireNonNullArg(outcome, "outcome");
+    if (Type.SHIFT.name().equals(outcome)) {
+      return SHIFT;
+    }
+    final int separator = outcome.indexOf(SEPARATOR);
+    if (separator < 0) {
+      throw new IllegalArgumentException("not a transition outcome: " + outcome);
+    }
+    final Type type;
+    try {
+      type = Type.valueOf(outcome.substring(0, separator));
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException("not a transition outcome: " + outcome, e);
+    }
+    return new Transition(type, outcome.substring(separator + 1));
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/ArcStandardOracleTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/ArcStandardOracleTest.java
new file mode 100644
index 000000000..613db6e6d
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/ArcStandardOracleTest.java
@@ -0,0 +1,195 @@
+/*
+ * 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.util.List;
+
+import org.junit.jupiter.api.Test;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests that {@link ArcStandardOracle} derivations replay to the gold tree through
+ * {@link ArcStandardState}, and that non-projective input is rejected.
+ */
+public class ArcStandardOracleTest {
+
+  /**
+   * Derives the oracle transitions for {@code gold} and replays them on a fresh state.
+   *
+   * @param gold The gold tree to derive from.
+   * @return The tree the replayed derivation builds. Never {@code null}.
+   */
+  private static DependencyTree replay(DependencyTree gold) {
+    final List<Transition> transitions = ArcStandardOracle.transitions(gold);
+    // every token is shifted once and attached once
+    assertEquals(2 * gold.size(), transitions.size());
+    final ArcStandardState state = new ArcStandardState(gold.size());
+    for (final Transition transition : transitions) {
+      assertTrue(state.canApply(transition));
+      state.apply(transition);
+    }
+    return state.toTree();
+  }
+
+  @Test
+  void testRoundTripSimpleSentence() {
+    final DependencyTree gold = DependencyTree.of(new int[] {1, 2, -1},
+        new String[] {"det", "nsubj", "root"});
+    assertEquals(gold, replay(gold));
+  }
+
+  @Test
+  void testRoundTripSingleToken() {
+    final DependencyTree gold = DependencyTree.of(new int[] {-1}, new String[] {"root"});
+    assertEquals(gold, replay(gold));
+  }
+
+  @Test
+  void testRoundTripRightBranching() {
+    // "eat fresh fish now": root with a right dependent that has its own left dependent
+    final DependencyTree gold = DependencyTree.of(new int[] {-1, 2, 0, 0},
+        new String[] {"root", "amod", "obj", "advmod"});
+    assertEquals(gold, replay(gold));
+  }
+
+  @Test
+  void testRoundTripDeepChain() {
+    final DependencyTree gold = DependencyTree.of(new int[] {1, 2, 3, -1},
+        new String[] {"a", "b", "c", "root"});
+    assertEquals(gold, replay(gold));
+  }
+
+  /** Checks the projectivity test on a tree with crossed arcs, a chain, and a null. */
+  @Test
+  void testIsProjective() {
+    assertFalse(ArcStandardOracle.isProjective(DependencyTree.of(new int[] {2, 3, -1, 2},
+        new String[] {"a", "b", "root", "c"})));
+    assertTrue(ArcStandardOracle.isProjective(DependencyTree.of(new int[] {1, -1, 1},
+        new String[] {"nsubj", "root", "obj"})));
+    assertThrows(IllegalArgumentException.class, () -> ArcStandardOracle.isProjective(null));
+  }
+
+  @Test
+  void testNonProjectiveThrows() {
+    // arcs (2,0) and (3,1) cross, so there is no arc-standard derivation
+    final DependencyTree nonProjective = DependencyTree.of(new int[] {2, 3, -1, 2},
+        new String[] {"a", "b", "root", "c"});
+    assertThrows(IllegalArgumentException.class,
+        () -> ArcStandardOracle.transitions(nonProjective));
+  }
+
+  @ParameterizedTest(name = "all trees with {0} token(s)")
+  @ValueSource(ints = {1, 2, 3, 4, 5})
+  void testAllTreesOfSize(int size) {
+    checkHeadAssignments(new int[size], 0);
+  }
+
+  /**
+   * Enumerates every head assignment and checks each valid tree against the
+   * contiguous-subtree definition of projectivity.
+   *
+   * @param heads The head assignment under construction; positions before {@code index}
+   *              are fixed.
+   * @param index The next position to assign a head to.
+   */
+  private static void checkHeadAssignments(int[] heads, int index) {
+    if (index < heads.length) {
+      for (int head = DependencyArc.ROOT_HEAD; head < heads.length; head++) {
+        heads[index] = head;
+        checkHeadAssignments(heads, index + 1);
+      }
+      return;
+    }
+
+    final String[] relations = new String[heads.length];
+    for (int i = 0; i < relations.length; i++) {
+      relations[i] = heads[i] == DependencyArc.ROOT_HEAD ? "root" : "dep";
+    }
+    final DependencyTree tree;
+    try {
+      tree = DependencyTree.of(heads, relations);
+    } catch (IllegalArgumentException e) {
+      return;
+    }
+
+    assertEquals(isProjective(tree), ArcStandardOracle.isProjective(tree), tree.toString());
+    if (isProjective(tree)) {
+      assertEquals(tree, replay(tree), tree.toString());
+    } else {
+      assertThrows(IllegalArgumentException.class,
+          () -> ArcStandardOracle.transitions(tree), tree.toString());
+    }
+  }
+
+  /**
+   * Decides projectivity by an independent definition: every token's subtree covers a
+   * contiguous span of the sentence. This agrees with the arc-crossing test that
+   * {@link ArcStandardOracle#isProjective} implements because the artificial root sits
+   * to the left of the sentence.
+   *
+   * @param tree The tree to inspect.
+   * @return {@code true} if every subtree is a contiguous span.
+   */
+  private static boolean isProjective(DependencyTree tree) {
+    for (int token = 0; token < tree.size(); token++) {
+      int first = token;
+      int last = token;
+      int covered = 0;
+      for (int other = 0; other < tree.size(); other++) {
+        if (dominates(tree, token, other)) {
+          first = Math.min(first, other);
+          last = Math.max(last, other);
+          covered++;
+        }
+      }
+      if (last - first + 1 != covered) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Tests whether a token is another token or one of its ancestors.
+   *
+   * @param tree The tree to inspect.
+   * @param ancestor The candidate ancestor.
+   * @param token The token whose head chain is followed.
+   * @return {@code true} if {@code ancestor} is on the head chain from {@code token}.
+   */
+  private static boolean dominates(DependencyTree tree, int ancestor, int token) {
+    for (int current = token; current != DependencyArc.ROOT_HEAD;
+        current = tree.headOf(current)) {
+      if (current == ancestor) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  @Test
+  void testNullTreeThrows() {
+    assertThrows(IllegalArgumentException.class, () -> ArcStandardOracle.transitions(null));
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/ArcStandardStateTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/ArcStandardStateTest.java
new file mode 100644
index 000000000..fd54d6c50
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/ArcStandardStateTest.java
@@ -0,0 +1,135 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests the configuration mechanics of {@link ArcStandardState}: the start
+ * configuration, transition applicability at the boundaries, the bookkeeping of attached
+ * dependents, copy independence, and accessor validation.
+ */
+public class ArcStandardStateTest {
+
+  @Test
+  void testInitialConfiguration() {
+    final ArcStandardState state = new ArcStandardState(3);
+    assertEquals(ArcStandardState.ROOT, state.stack(0));
+    assertEquals(ArcStandardState.NONE, state.stack(1));
+    assertEquals(0, state.buffer(0));
+    assertEquals(1, state.buffer(1));
+    assertEquals(2, state.buffer(2));
+    assertEquals(ArcStandardState.NONE, state.buffer(3));
+    assertEquals(1, state.stackSize());
+    assertEquals(3, state.bufferSize());
+    assertFalse(state.isTerminal());
+
+    state.apply(Transition.SHIFT);
+    assertEquals(ArcStandardState.NONE, state.buffer(Integer.MAX_VALUE));
+  }
+
+  @Test
+  void testSingleTokenDerivationIsForced() {
+    // With one token the system permits exactly one derivation: shift the token, then
+    // attach it to the artificial root with a right arc.
+    final ArcStandardState state = new ArcStandardState(1);
+    assertTrue(state.canApply(Transition.SHIFT));
+    assertFalse(state.canApply(Transition.leftArc("det")));
+    assertFalse(state.canApply(Transition.rightArc("root")));
+
+    state.apply(Transition.SHIFT);
+    assertFalse(state.canApply(Transition.SHIFT));
+    assertFalse(state.canApply(Transition.leftArc("det")));
+    assertTrue(state.canApply(Transition.rightArc("root")));
+
+    state.apply(Transition.rightArc("root"));
+    assertTrue(state.isTerminal());
+    assertEquals(DependencyTree.of(new int[] {-1}, new String[] {"root"}),
+        state.toTree());
+  }
+
+  @Test
+  void testDependentBookkeepingDuringADerivation() {
+    // Derives "the dog barks" (the<-dog via det, dog<-barks via nsubj, barks<-root) and
+    // checks the partial-structure accessors after every attachment.
+    final ArcStandardState state = new ArcStandardState(3);
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.SHIFT);
+    assertEquals(0, state.assignedDependents(1));
+    assertNull(state.assignedRelation(0));
+
+    state.apply(Transition.leftArc("det"));
+    assertEquals(1, state.assignedDependents(1));
+    assertEquals(0, state.leftmostDependent(1));
+    assertEquals(0, state.rightmostDependent(1));
+    assertEquals("det", state.assignedRelation(0));
+
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.leftArc("nsubj"));
+    assertEquals(1, state.leftmostDependent(2));
+    assertEquals("nsubj", state.assignedRelation(1));
+
+    state.apply(Transition.rightArc("root"));
+    assertTrue(state.isTerminal());
+    assertEquals(DependencyTree.of(new int[] {1, 2, -1},
+        new String[] {"det", "nsubj", "root"}), state.toTree());
+  }
+
+  @Test
+  void testInapplicableTransitionIsRejected() {
+    final ArcStandardState state = new ArcStandardState(2);
+    assertThrows(IllegalArgumentException.class,
+        () -> state.apply(Transition.leftArc("det")));
+    assertThrows(IllegalArgumentException.class,
+        () -> state.apply(Transition.rightArc("root")));
+    assertThrows(IllegalArgumentException.class, () -> state.apply(null));
+    assertThrows(IllegalArgumentException.class, () -> state.canApply(null));
+  }
+
+  @Test
+  void testToTreeBeforeTerminalIsRejected() {
+    final ArcStandardState state = new ArcStandardState(2);
+    assertThrows(IllegalStateException.class, state::toTree);
+    state.apply(Transition.SHIFT);
+    assertThrows(IllegalStateException.class, state::toTree);
+  }
+
+  @Test
+  void testAccessorValidation() {
+    final ArcStandardState state = new ArcStandardState(2);
+    assertThrows(IllegalArgumentException.class, () -> state.stack(-1));
+    assertThrows(IllegalArgumentException.class, () -> state.buffer(-1));
+    assertThrows(IllegalArgumentException.class, () -> state.assignedDependents(-1));
+    assertThrows(IllegalArgumentException.class, () -> state.assignedDependents(2));
+    assertThrows(IllegalArgumentException.class, () -> state.leftmostDependent(2));
+    assertThrows(IllegalArgumentException.class, () -> state.rightmostDependent(-1));
+    assertThrows(IllegalArgumentException.class, () -> state.assignedRelation(2));
+  }
+
+  @Test
+  void testTokenCountMustBePositive() {
+    assertThrows(IllegalArgumentException.class, () -> new ArcStandardState(0));
+    assertThrows(IllegalArgumentException.class, () -> new ArcStandardState(-1));
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/AttachmentScoresTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/AttachmentScoresTest.java
new file mode 100644
index 000000000..1556d3bfa
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/AttachmentScoresTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/** Tests the accumulation and merging of {@link AttachmentScores}. */
+public class AttachmentScoresTest {
+
+  @Test
+  void testAddsTokens() {
+    final AttachmentScores scores = new AttachmentScores();
+    scores.add(true, true, false);
+    scores.add(true, false, false);
+    scores.add(false, false, true);
+    assertEquals(3, scores.getWordCount());
+    assertEquals(2.0d / 3.0d, scores.getUas(), 1e-12);
+    assertEquals(1.0d / 3.0d, scores.getLas(), 1e-12);
+    assertEquals(2, scores.getWordCountExcludingPunctuation());
+    assertEquals(1.0d, scores.getUasExcludingPunctuation());
+    assertEquals(0.5d, scores.getLasExcludingPunctuation());
+  }
+
+  @Test
+  void testMergeWeightsByTokenCount() {
+    final AttachmentScores small = new AttachmentScores();
+    small.add(false, false, false);
+    final AttachmentScores large = new AttachmentScores();
+    large.add(true, true, false);
+    large.add(true, true, false);
+    large.add(true, true, true);
+
+    final AttachmentScores total = new AttachmentScores();
+    total.add(small);
+    total.add(large);
+    assertEquals(4, total.getWordCount());
+    assertEquals(0.75d, total.getUas());
+    assertEquals(0.75d, total.getLas());
+    assertEquals(3, total.getWordCountExcludingPunctuation());
+    assertEquals(2.0d / 3.0d, total.getUasExcludingPunctuation(), 1e-12);
+  }
+
+  @Test
+  void testMergingAnEmptyAccumulatorChangesNothing() {
+    final AttachmentScores total = new AttachmentScores();
+    total.add(true, false, false);
+    total.add(new AttachmentScores());
+    assertEquals(1, total.getWordCount());
+    assertEquals(1.0d, total.getUas());
+    assertEquals(0.0d, total.getLas());
+  }
+
+  @Test
+  void testEmptyScoresAreZero() {
+    final AttachmentScores scores = new AttachmentScores();
+    assertEquals(0, scores.getWordCount());
+    assertEquals(0.0d, scores.getUas());
+    assertEquals(0.0d, scores.getLasExcludingPunctuation());
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyContextGeneratorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyContextGeneratorTest.java
new file mode 100644
index 000000000..9d4106735
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyContextGeneratorTest.java
@@ -0,0 +1,235 @@
+/*
+ * 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.util.Arrays;
+import java.util.stream.Stream;
+
+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;
+
+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.assertThrows;
+
+/**
+ * Pins the features of {@link DependencyContextGenerator}. Trained models store these
+ * strings, so a change to any of them makes existing models score differently.
+ */
+public class DependencyContextGeneratorTest {
+
+  private static final String[] TOKENS = {"the", "dog", "barks", "today"};
+  private static final String[] TAGS = {"DT", "NN", "VBZ", "RB"};
+
+  @Test
+  void testFeaturesOfAPartialParse() {
+    // stack: root, dog, barks; buffer: today. "the" is the only dependent of "dog",
+    // so it is both its leftmost and its rightmost dependent.
+    final ArcStandardState state = new ArcStandardState(TOKENS.length);
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.leftArc("det"));
+    state.apply(Transition.SHIFT);
+
+    assertArrayEquals(new String[] {
+        "s0w=barks", "s0t=VBZ", "s1w=dog", "s1t=NN", "s2t=*ROOT*",
+        "b0w=today", "b0t=RB", "b1w=*NULL*", "b1t=*NULL*", "b2t=*NULL*",
+        "s0wt=barks/VBZ", "s1wt=dog/NN", "b0wt=today/RB",
+        "s0w,b0w=barks|today", "s0t,b0t=VBZ|RB", "s0w,b0t=barks|RB", "s0t,b0w=VBZ|today",
+        "s0wt,b0t=barks/VBZ|RB", "s1t,s0t=NN|VBZ", "s1t,s0w=NN|barks", "s1w,s0t=dog|VBZ",
+        "s1t,s0t,b0t=NN|VBZ|RB", "s0t,b0t,b1t=VBZ|RB|*NULL*", "s2t,s1t,s0t=*ROOT*|NN|VBZ",
+        "s0lct=*NULL*", "s0rct=*NULL*", "s1lct=DT", "s1rct=DT",
+        "s0lcl=*NULL*", "s0rcl=*NULL*", "s1rcl=det",
+        "s1t,s1rct,s0t=NN|DT|VBZ", "s0t,s0lct,b0t=VBZ|*NULL*|RB",
+        "s0deps=0", "s1deps=1", "dist=1", "dist,s0t,b0t=1|VBZ|RB"},
+        new DependencyContextGenerator().getContext(state, TOKENS, TAGS));
+  }
+
+  @Test
+  void testFeaturesOfTheStartConfiguration() {
+    final ArcStandardState state = new ArcStandardState(1);
+    assertArrayEquals(new String[] {
+        "s0w=*ROOT*", "s0t=*ROOT*", "s1w=*NULL*", "s1t=*NULL*", "s2t=*NULL*",
+        "b0w=Run", "b0t=VB", "b1w=*NULL*", "b1t=*NULL*", "b2t=*NULL*",
+        "s0wt=*ROOT*/*ROOT*", "s1wt=*NULL*/*NULL*", "b0wt=Run/VB",
+        "s0w,b0w=*ROOT*|Run", "s0t,b0t=*ROOT*|VB", "s0w,b0t=*ROOT*|VB", "s0t,b0w=*ROOT*|Run",
+        "s0wt,b0t=*ROOT*/*ROOT*|VB", "s1t,s0t=*NULL*|*ROOT*", "s1t,s0w=*NULL*|*ROOT*",
+        "s1w,s0t=*NULL*|*ROOT*", "s1t,s0t,b0t=*NULL*|*ROOT*|VB",
+        "s0t,b0t,b1t=*ROOT*|VB|*NULL*", "s2t,s1t,s0t=*NULL*|*NULL*|*ROOT*",
+        "s0lct=*NULL*", "s0rct=*NULL*", "s1lct=*NULL*", "s1rct=*NULL*",
+        "s0lcl=*NULL*", "s0rcl=*NULL*", "s1rcl=*NULL*",
+        "s1t,s1rct,s0t=*NULL*|*NULL*|*ROOT*", "s0t,s0lct,b0t=*ROOT*|*NULL*|VB",
+        "s0deps=*NULL*", "s1deps=*NULL*", "dist=*NULL*", "dist,s0t,b0t=*NULL*|*ROOT*|VB"},
+        new DependencyContextGenerator().getContext(state, new String[] {"Run"},
+            new String[] {"VB"}));
+  }
+
+  /**
+   * Pins the features once every token is on the stack: the buffer features and the
+   * distance are the null marker.
+   */
+  @Test
+  void testFeaturesWithAnEmptyBuffer() {
+    final ArcStandardState state = new ArcStandardState(TOKENS.length);
+    for (int i = 0; i < TOKENS.length; i++) {
+      state.apply(Transition.SHIFT);
+    }
+    assertArrayEquals(new String[] {
+        "s0w=today", "s0t=RB", "s1w=barks", "s1t=VBZ", "s2t=NN", "b0w=*NULL*", "b0t=*NULL*",
+        "b1w=*NULL*", "b1t=*NULL*", "b2t=*NULL*", "s0wt=today/RB", "s1wt=barks/VBZ",
+        "b0wt=*NULL*/*NULL*", "s0w,b0w=today|*NULL*", "s0t,b0t=RB|*NULL*",
+        "s0w,b0t=today|*NULL*", "s0t,b0w=RB|*NULL*", "s0wt,b0t=today/RB|*NULL*",
+        "s1t,s0t=VBZ|RB", "s1t,s0w=VBZ|today", "s1w,s0t=barks|RB", "s1t,s0t,b0t=VBZ|RB|*NULL*",
+        "s0t,b0t,b1t=RB|*NULL*|*NULL*", "s2t,s1t,s0t=NN|VBZ|RB", "s0lct=*NULL*",
+        "s0rct=*NULL*", "s1lct=*NULL*", "s1rct=*NULL*", "s0lcl=*NULL*", "s0rcl=*NULL*",
+        "s1rcl=*NULL*", "s1t,s1rct,s0t=VBZ|*NULL*|RB", "s0t,s0lct,b0t=RB|*NULL*|*NULL*",
+        "s0deps=0", "s1deps=0", "dist=*NULL*", "dist,s0t,b0t=*NULL*|RB|*NULL*"},
+        new DependencyContextGenerator().getContext(state, TOKENS, TAGS));
+  }
+
+  /**
+   * Pins the capped features: a stack top with three right dependents reports the
+   * valency cap, and a buffer front four tokens away reports the distance bucket.
+   */
+  @Test
+  void testFeaturesAtTheValencyAndDistanceCaps() {
+    final String[] tokens = {"eat", "fish", "chips", "peas", "now"};
+    final String[] tags = {"VB", "NN", "NNS", "NNS", "RB"};
+    final ArcStandardState state = new ArcStandardState(tokens.length);
+    state.apply(Transition.SHIFT);
+    for (String label : new String[] {"obj", "conj", "conj"}) {
+      state.apply(Transition.SHIFT);
+      state.apply(Transition.rightArc(label));
+    }
+    assertArrayEquals(new String[] {
+        "s0w=eat", "s0t=VB", "s1w=*ROOT*", "s1t=*ROOT*", "s2t=*NULL*", "b0w=now", "b0t=RB",
+        "b1w=*NULL*", "b1t=*NULL*", "b2t=*NULL*", "s0wt=eat/VB", "s1wt=*ROOT*/*ROOT*",
+        "b0wt=now/RB", "s0w,b0w=eat|now", "s0t,b0t=VB|RB", "s0w,b0t=eat|RB", "s0t,b0w=VB|now",
+        "s0wt,b0t=eat/VB|RB", "s1t,s0t=*ROOT*|VB", "s1t,s0w=*ROOT*|eat", "s1w,s0t=*ROOT*|VB",
+        "s1t,s0t,b0t=*ROOT*|VB|RB", "s0t,b0t,b1t=VB|RB|*NULL*", "s2t,s1t,s0t=*NULL*|*ROOT*|VB",
+        "s0lct=NN", "s0rct=NNS", "s1lct=*NULL*", "s1rct=*NULL*", "s0lcl=obj", "s0rcl=conj",
+        "s1rcl=*NULL*", "s1t,s1rct,s0t=*ROOT*|*NULL*|VB", "s0t,s0lct,b0t=VB|NN|RB", "s0deps=3",
+        "s1deps=*NULL*", "dist=4+", "dist,s0t,b0t=4+|VB|RB"},
+        new DependencyContextGenerator().getContext(state, tokens, tags));
+  }
+
+  /**
+   * Pins the dependent features when the two topmost stack tokens each have a leftmost
+   * and a rightmost dependent, so every dependent slot is filled at once.
+   */
+  @Test
+  void testFeaturesWithDependentsOnBothSides() {
+    final String[] tokens = {"a", "b", "c", "d", "e", "f", "g"};
+    final String[] tags = {"A", "B", "C", "D", "E", "F", "G"};
+    final ArcStandardState state = new ArcStandardState(tokens.length);
+    // b takes a on the left and c on the right, e takes d on the left and f on the right;
+    // stack: root, b, e; buffer: g.
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.leftArc("la"));
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.rightArc("ra"));
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.leftArc("lb"));
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.rightArc("rb"));
+    assertArrayEquals(new String[] {
+        "s0w=e", "s0t=E", "s1w=b", "s1t=B", "s2t=*ROOT*", "b0w=g", "b0t=G", "b1w=*NULL*",
+        "b1t=*NULL*", "b2t=*NULL*", "s0wt=e/E", "s1wt=b/B", "b0wt=g/G", "s0w,b0w=e|g",
+        "s0t,b0t=E|G", "s0w,b0t=e|G", "s0t,b0w=E|g", "s0wt,b0t=e/E|G", "s1t,s0t=B|E",
+        "s1t,s0w=B|e", "s1w,s0t=b|E", "s1t,s0t,b0t=B|E|G", "s0t,b0t,b1t=E|G|*NULL*",
+        "s2t,s1t,s0t=*ROOT*|B|E", "s0lct=D", "s0rct=F", "s1lct=A", "s1rct=C", "s0lcl=lb",
+        "s0rcl=rb", "s1rcl=ra", "s1t,s1rct,s0t=B|C|E", "s0t,s0lct,b0t=E|D|G", "s0deps=2",
+        "s1deps=2", "dist=2", "dist,s0t,b0t=2|E|G"},
+        new DependencyContextGenerator().getContext(state, tokens, tags));
+  }
+
+  /**
+   * Two-token sentences whose values contain a feature separator, paired so that a
+   * combined feature reads the same although the positions hold different values.
+   *
+   * @return The two sentences and the index of the colliding feature.
+   */
+  static Stream<Arguments> collidingSentences() {
+    return Stream.of(
+        Arguments.of("tags with the position separator",
+            new String[] {"x", "y"}, new String[] {"X|Y", "Z"},
+            new String[] {"x", "y"}, new String[] {"X", "Y|Z"}, 18),
+        Arguments.of("word with the word-tag separator",
+            new String[] {"x", "a/b"}, new String[] {"X", "c"},
+            new String[] {"x", "a"}, new String[] {"X", "b/c"}, 10),
+        Arguments.of("word with the position separator",
+            new String[] {"a|b", "y"}, new String[] {"X", "T"},
+            new String[] {"a", "y"}, new String[] {"X", "b|T"}, 20));
+  }
+
+  /**
+   * Pins that separators are not escaped: a value containing {@code |} or {@code /} makes
+   * one combined feature of two different sentences read the same, while the single-position
+   * features still tell them apart.
+   */
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("collidingSentences")
+  void testUnescapedSeparatorsCollideInCombinedFeatures(String description, String[] tokensA,
+      String[] tagsA, String[] tokensB, String[] tagsB, int feature) {
+    final DependencyContextGenerator generator = new DependencyContextGenerator();
+    final String[] a = generator.getContext(stackOfTwo(), tokensA, tagsA);
+    final String[] b = generator.getContext(stackOfTwo(), tokensB, tagsB);
+    assertEquals(a[feature], b[feature]);
+    assertFalse(Arrays.equals(a, b), "the single-position features differ");
+  }
+
+  /**
+   * Builds the configuration with both tokens of a two-token sentence on the stack.
+   *
+   * @return The configuration after two shifts. Never {@code null}.
+   */
+  private static ArcStandardState stackOfTwo() {
+    final ArcStandardState state = new ArcStandardState(2);
+    state.apply(Transition.SHIFT);
+    state.apply(Transition.SHIFT);
+    return state;
+  }
+
+  /** A value equal to a marker or holding {@code =} is used as is, so a marker word is not told apart. */
+  @Test
+  void testMarkerAndEqualsSignValuesAreUsedAsIs() {
+    final ArcStandardState state = new ArcStandardState(2);
+    final String[] features = new DependencyContextGenerator().getContext(state,
+        new String[] {"*NULL*", "b0w=x"}, new String[] {"*ROOT*", "T"});
+    assertEquals("b0w=*NULL*", features[5]);
+    assertEquals("b0t=*ROOT*", features[6]);
+    assertEquals("b1w=b0w=x", features[7]);
+  }
+
+  @Test
+  void testRejectsInvalidArguments() {
+    final DependencyContextGenerator generator = new DependencyContextGenerator();
+    final ArcStandardState state = new ArcStandardState(TOKENS.length);
+    assertThrows(IllegalArgumentException.class,
+        () -> generator.getContext(null, TOKENS, TAGS));
+    assertThrows(IllegalArgumentException.class,
+        () -> generator.getContext(state, null, TAGS));
+    assertThrows(IllegalArgumentException.class,
+        () -> generator.getContext(state, TOKENS, new String[] {"DT"}));
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyCrossValidatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyCrossValidatorTest.java
new file mode 100644
index 000000000..8a3c85779
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyCrossValidatorTest.java
@@ -0,0 +1,268 @@
+/*
+ * 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.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.ml.perceptron.SimplePerceptronSequenceTrainer;
+import opennlp.tools.util.InsufficientTrainingDataException;
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.TrainingParameters;
+
+import static opennlp.tools.depparse.DependencyTestSamples.CORPUS_SENTENCES;
+import static opennlp.tools.depparse.DependencyTestSamples.CORPUS_WORDS;
+import static opennlp.tools.depparse.DependencyTestSamples.LANGUAGE;
+import static opennlp.tools.depparse.DependencyTestSamples.corpus;
+import static opennlp.tools.depparse.DependencyTestSamples.sentences;
+import static opennlp.tools.depparse.DependencyTestSamples.trainingParameters;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests {@link DependencyCrossValidator}: the tokens it counts, its punctuation handling,
+ * its argument checks, and its agreement with a single {@link DependencyEvaluator} on a
+ * corpus every fold can memorize.
+ */
+public class DependencyCrossValidatorTest {
+
+  /** The number of determiner tokens in {@link DependencyTestSamples#corpus()}. */
+  private static final int CORPUS_DETERMINERS = CORPUS_SENTENCES / 3;
+
+  /**
+   * @return A validator over the test corpus language with a zero feature cutoff.
+   */
+  private static DependencyCrossValidator validator() {
+    return new DependencyCrossValidator(LANGUAGE, trainingParameters());
+  }
+
+  @ParameterizedTest(name = "folds = {0}")
+  @ValueSource(ints = {2, 3, 5, 8})
+  void testCountsEveryTokenOnce(int folds) throws IOException {
+    final DependencyCrossValidator validator = validator();
+    validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), folds);
+    assertEquals(CORPUS_WORDS, validator.getWordCount());
+    assertEquals(CORPUS_WORDS, validator.getWordCountExcludingPunctuation(),
+        "the corpus has no punctuation, so no token is left out");
+  }
+
+  @Test
+  void testAgreesWithSingleEvaluatorOnMemorizableCorpus() throws IOException {
+    final DependencyCrossValidator validator = validator();
+    validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 4);
+
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator(new DependencyParserME(DependencyTestSamples.train(corpus())));
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(corpus()));
+
+    assertEquals(evaluator.getWordCount(), validator.getWordCount());
+    assertEquals(evaluator.getUas(), validator.getUas());
+    assertEquals(evaluator.getLas(), validator.getLas());
+    assertEquals(evaluator.getUasExcludingPunctuation(),
+        validator.getUasExcludingPunctuation());
+    assertEquals(evaluator.getLasExcludingPunctuation(),
+        validator.getLasExcludingPunctuation());
+    assertEquals(1.0d, validator.getUas());
+    assertEquals(1.0d, validator.getLas());
+  }
+
+  @Test
+  void testAccumulatesAcrossRuns() throws IOException {
+    final DependencyCrossValidator validator = validator();
+    validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 2);
+    validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 2);
+    assertEquals(2 * CORPUS_WORDS, validator.getWordCount());
+  }
+
+  @Test
+  void testLeavesUniversalPunctuationOutByDefault() throws IOException {
+    final DependencySample punctuated = DependencyTestSamples.sample(
+        new String[] {"dogs", "bark", "."}, new String[] {"NOUN", "VERB", "PUNCT"},
+        new int[] {1, -1, 1}, new String[] {"nsubj", "root", "punct"});
+    final List<DependencySample> samples = DependencyTestSamples.repeat(List.of(punctuated));
+    final DependencyCrossValidator validator = validator();
+    validator.evaluate(ObjectStreamUtils.createObjectStream(samples), 2);
+    assertEquals(3L * samples.size(), validator.getWordCount());
+    assertEquals(2L * samples.size(), validator.getWordCountExcludingPunctuation());
+    assertEquals(1.0d, validator.getUasExcludingPunctuation());
+    assertEquals(1.0d, validator.getLasExcludingPunctuation());
+  }
+
+  @Test
+  void testCustomPunctuationPredicate() throws IOException {
+    final DependencyCrossValidator validator =
+        new DependencyCrossValidator(LANGUAGE, trainingParameters(), "DT"::equals);
+    validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 2);
+    assertEquals(CORPUS_WORDS, validator.getWordCount());
+    assertEquals(CORPUS_WORDS - CORPUS_DETERMINERS,
+        validator.getWordCountExcludingPunctuation());
+  }
+
+  @Test
+  void testListenersHearEveryHeldOutSample() throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyCrossValidator validator =
+        new DependencyCrossValidator(LANGUAGE, trainingParameters(), monitor);
+    validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 4);
+    assertEquals(CORPUS_SENTENCES, monitor.correct.size(),
+        "each sentence is held out once and the corpus is memorizable");
+    assertEquals(List.of(), monitor.wrong);
+  }
+
+  @Test
+  void testListenersWithCustomPunctuation() throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyCrossValidator validator =
+        new DependencyCrossValidator(LANGUAGE, trainingParameters(), "DT"::equals, monitor);
+    validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 2);
+    assertEquals(CORPUS_SENTENCES, monitor.correct.size() + monitor.wrong.size());
+    assertEquals(CORPUS_WORDS - CORPUS_DETERMINERS,
+        validator.getWordCountExcludingPunctuation());
+  }
+
+  /** A {@code null} listener array and {@code null} entries mean no listeners. */
+  @Test
+  void testNullListenersAreIgnored() throws IOException {
+    final DependencyCrossValidator noArray = new DependencyCrossValidator(LANGUAGE,
+        trainingParameters(), (DependencyEvaluationMonitor[]) null);
+    noArray.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 2);
+    assertEquals(CORPUS_WORDS, noArray.getWordCount());
+
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyCrossValidator nullEntry = new DependencyCrossValidator(LANGUAGE,
+        trainingParameters(), "DT"::equals, null, monitor, null);
+    nullEntry.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 2);
+    assertEquals(CORPUS_SENTENCES, monitor.correct.size() + monitor.wrong.size());
+  }
+
+  /** The same listener given twice is told twice about every held-out sample. */
+  @Test
+  void testDuplicateListenerIsToldTwice() throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyCrossValidator validator =
+        new DependencyCrossValidator(LANGUAGE, trainingParameters(), monitor, monitor);
+    validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 2);
+    assertEquals(2 * CORPUS_SENTENCES, monitor.correct.size() + monitor.wrong.size());
+    assertEquals(CORPUS_WORDS, validator.getWordCount());
+  }
+
+  /** Two folds over exactly two sentences train each fold on the other sentence. */
+  @Test
+  void testTwoFoldsOverTwoSentences() throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyCrossValidator validator =
+        new DependencyCrossValidator(LANGUAGE, trainingParameters(), monitor);
+    final List<DependencySample> two = sentences().subList(0, 2);
+    validator.evaluate(ObjectStreamUtils.createObjectStream(two), 2);
+    assertEquals(5, validator.getWordCount(), "3 + 2 tokens, each held out once");
+    final List<DependencySample> heldOut = new ArrayList<>(monitor.correct);
+    heldOut.addAll(monitor.wrong);
+    assertEquals(2, heldOut.size());
+    assertTrue(heldOut.containsAll(two));
+  }
+
+  /** More folds than sentences: the extra folds hold out nothing and every token still counts once. */
+  @ParameterizedTest(name = "folds = {0}")
+  @ValueSource(ints = {4, 5, 10})
+  void testMoreFoldsThanSentences(int folds) throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyCrossValidator validator =
+        new DependencyCrossValidator(LANGUAGE, trainingParameters(), monitor);
+    validator.evaluate(ObjectStreamUtils.createObjectStream(sentences()), folds);
+    assertEquals(8, validator.getWordCount());
+    assertEquals(3, monitor.correct.size() + monitor.wrong.size());
+  }
+
+  /** A fold whose training part is empty cannot train a parser and fails loudly. */
+  @Test
+  void testFoldWithoutTrainingDataFails() {
+    final DependencyCrossValidator validator = validator();
+    final List<DependencySample> one = sentences().subList(0, 1);
+    assertThrows(InsufficientTrainingDataException.class,
+        () -> validator.evaluate(ObjectStreamUtils.createObjectStream(one), 2));
+    assertEquals(0, validator.getWordCount());
+  }
+
+  /** A feature cutoff above the event count of a fold leaves no training data and fails loudly. */
+  @Test
+  void testCutoffAboveFoldSizeFails() {
+    final TrainingParameters parameters = trainingParameters();
+    parameters.put(Parameters.CUTOFF_PARAM, 1_000);
+    final DependencyCrossValidator validator = new DependencyCrossValidator(LANGUAGE, parameters);
+    assertThrows(InsufficientTrainingDataException.class,
+        () -> validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 2));
+    assertEquals(0, validator.getWordCount());
+  }
+
+  /** An empty sample stream trains nothing and fails loudly, like an empty fold. */
+  @Test
+  void testEmptyStreamFails() {
+    final DependencyCrossValidator validator = validator();
+    assertThrows(InsufficientTrainingDataException.class, () -> validator.evaluate(
+        ObjectStreamUtils.createObjectStream(List.<DependencySample>of()), 2));
+    assertEquals(0, validator.getWordCount());
+  }
+
+  @Test
+  void testEmptyValidatorScoresZero() {
+    final DependencyCrossValidator validator = validator();
+    assertEquals(0, validator.getWordCount());
+    assertEquals(0.0d, validator.getUas());
+    assertEquals(0.0d, validator.getLas());
+  }
+
+  @ParameterizedTest(name = "folds = {0}")
+  @ValueSource(ints = {Integer.MIN_VALUE, -1, 0, 1})
+  void testRejectsFoldCountBelowTwo(int folds) {
+    final DependencyCrossValidator validator = validator();
+    assertThrows(IllegalArgumentException.class,
+        () -> validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), folds));
+    assertEquals(0, validator.getWordCount());
+  }
+
+  @Test
+  void testRejectsNullArguments() {
+    final TrainingParameters parameters = trainingParameters();
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyCrossValidator(null, parameters));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyCrossValidator(LANGUAGE, null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyCrossValidator(LANGUAGE, parameters, (Predicate<String>) null));
+    assertThrows(IllegalArgumentException.class, () -> validator().evaluate(null, 2));
+  }
+
+  @Test
+  void testRejectsSequenceTrainer() {
+    final TrainingParameters parameters = trainingParameters();
+    parameters.put(Parameters.ALGORITHM_PARAM,
+        SimplePerceptronSequenceTrainer.PERCEPTRON_SEQUENCE_VALUE);
+    final DependencyCrossValidator validator =
+        new DependencyCrossValidator(LANGUAGE, parameters);
+    assertThrows(IllegalArgumentException.class,
+        () -> validator.evaluate(ObjectStreamUtils.createObjectStream(corpus()), 2));
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyEvaluatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyEvaluatorTest.java
new file mode 100644
index 000000000..8487f1a2c
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyEvaluatorTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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.IOException;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.util.ObjectStreamUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests the scores of {@link DependencyEvaluator} against a parser with a known error
+ * pattern, in particular the split between all tokens and the tokens that are not
+ * punctuation.
+ */
+public class DependencyEvaluatorTest {
+
+  private static final String[] TOKENS = {"dogs", "bark", "loudly", "."};
+  private static final String[] UNIVERSAL_TAGS = {"NOUN", "VERB", "ADV", "PUNCT"};
+  private static final String[] PENN_TAGS = {"NNS", "VBP", "RB", "."};
+
+  private static final DependencyTree GOLD = DependencyTree.of(
+      new int[] {1, -1, 1, 1}, new String[] {"nsubj", "root", "advmod", "punct"});
+
+  /** Right heads throughout, but a wrong label on the adverb and a wrong head on the period. */
+  private static final DependencyTree PREDICTED = DependencyTree.of(
+      new int[] {1, -1, 1, 2}, new String[] {"nsubj", "root", "obl", "punct"});
+
+  /**
+   * Evaluates the fixed prediction against the gold tree with the given tags.
+   *
+   * @param evaluator The evaluator under test.
+   * @param tags The gold tags of the fixture sentence.
+   * @throws IOException Thrown if reading the in-memory sample fails.
+   */
+  private static void evaluate(DependencyEvaluator evaluator, String[] tags) throws IOException {
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(
+        List.of(new DependencySample(TOKENS, tags, GOLD))));
+  }
+
+  @Test
+  void testScoresAllTokensAndNonPunctuationSeparately() throws IOException {
+    final DependencyEvaluator evaluator = new DependencyEvaluator((tokens, tags) -> PREDICTED);
+    evaluate(evaluator, UNIVERSAL_TAGS);
+    assertEquals(4, evaluator.getWordCount());
+    assertEquals(0.75d, evaluator.getUas());
+    assertEquals(0.5d, evaluator.getLas());
+    assertEquals(3, evaluator.getWordCountExcludingPunctuation());
+    assertEquals(1.0d, evaluator.getUasExcludingPunctuation());
+    assertEquals(2.0d / 3.0d, evaluator.getLasExcludingPunctuation(), 1e-12);
+  }
+
+  @Test
+  void testDefaultPunctuationIsTheUniversalTag() throws IOException {
+    final DependencyEvaluator evaluator = new DependencyEvaluator((tokens, tags) -> PREDICTED);
+    evaluate(evaluator, PENN_TAGS);
+    assertEquals(4, evaluator.getWordCountExcludingPunctuation(),
+        "a Penn tag set has no PUNCT tag, so nothing is excluded");
+    assertEquals(evaluator.getUas(), evaluator.getUasExcludingPunctuation());
+    assertEquals(evaluator.getLas(), evaluator.getLasExcludingPunctuation());
+  }
+
+  @Test
+  void testCustomPunctuationPredicate() throws IOException {
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator((tokens, tags) -> PREDICTED, "."::equals);
+    evaluate(evaluator, PENN_TAGS);
+    assertEquals(3, evaluator.getWordCountExcludingPunctuation());
+    assertEquals(1.0d, evaluator.getUasExcludingPunctuation());
+  }
+
+  @Test
+  void testEmptyEvaluatorScoresZero() {
+    final DependencyEvaluator evaluator = new DependencyEvaluator((tokens, tags) -> PREDICTED);
+    assertEquals(0, evaluator.getWordCount());
+    assertEquals(0, evaluator.getWordCountExcludingPunctuation());
+    assertEquals(0.0d, evaluator.getUas());
+    assertEquals(0.0d, evaluator.getUasExcludingPunctuation());
+  }
+
+  @Test
+  void testListenersHearMisparsedSamples() throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator((tokens, tags) -> PREDICTED, monitor);
+    evaluate(evaluator, UNIVERSAL_TAGS);
+    assertEquals(List.of(), monitor.correct);
+    assertEquals(List.of(new DependencySample(TOKENS, UNIVERSAL_TAGS, GOLD)), monitor.wrong);
+    assertEquals(List.of(new DependencySample(TOKENS, UNIVERSAL_TAGS, PREDICTED)),
+        monitor.predictions);
+  }
+
+  @Test
+  void testListenersHearCorrectSamplesWithCustomPunctuation() throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator((tokens, tags) -> GOLD, "."::equals, monitor);
+    evaluate(evaluator, PENN_TAGS);
+    assertEquals(List.of(new DependencySample(TOKENS, PENN_TAGS, GOLD)), monitor.correct);
+    assertEquals(List.of(), monitor.wrong);
+    assertEquals(3, evaluator.getWordCountExcludingPunctuation());
+  }
+
+  @Test
+  void testNullListenersAreIgnored() throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyEvaluator evaluator = new DependencyEvaluator(
+        (tokens, tags) -> PREDICTED, (DependencyEvaluationMonitor) null, monitor);
+    evaluate(evaluator, UNIVERSAL_TAGS);
+    assertEquals(1, monitor.wrong.size());
+    assertEquals(4, evaluator.getWordCount());
+  }
+
+  /** A {@code null} listener array means no listeners, like the base evaluator. */
+  @Test
+  void testNullListenerArrayMeansNoListeners() throws IOException {
+    final DependencyEvaluator evaluator = new DependencyEvaluator(
+        (tokens, tags) -> PREDICTED, (DependencyEvaluationMonitor[]) null);
+    evaluate(evaluator, UNIVERSAL_TAGS);
+    assertEquals(4, evaluator.getWordCount());
+    final DependencyEvaluator withPredicate = new DependencyEvaluator(
+        (tokens, tags) -> PREDICTED, "."::equals, (DependencyEvaluationMonitor[]) null);
+    evaluate(withPredicate, PENN_TAGS);
+    assertEquals(3, withPredicate.getWordCountExcludingPunctuation());
+  }
+
+  /** The same listener given twice is told twice per sample; listeners are not deduplicated. */
+  @Test
+  void testDuplicateListenerIsToldTwice() throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator((tokens, tags) -> PREDICTED, monitor, monitor);
+    evaluate(evaluator, UNIVERSAL_TAGS);
+    assertEquals(2, monitor.wrong.size());
+    assertEquals(4, evaluator.getWordCount(), "the sample itself is scored once");
+  }
+
+  /** A throwing listener aborts the evaluation after the sample has been scored. */
+  @Test
+  void testThrowingListenerAbortsAfterScoring() {
+    final IllegalStateException failure = new IllegalStateException("listener failed");
+    final DependencyEvaluationMonitor throwing = new DependencyEvaluationMonitor() {
+      @Override
+      public void correctlyClassified(DependencySample reference, DependencySample prediction) {
+        throw failure;
+      }
+
+      @Override
+      public void misclassified(DependencySample reference, DependencySample prediction) {
+        throw failure;
+      }
+    };
+    final RecordingMonitor later = new RecordingMonitor();
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator((tokens, tags) -> PREDICTED, throwing, later);
+    final IllegalStateException thrown =
+        assertThrows(IllegalStateException.class, () -> evaluate(evaluator, UNIVERSAL_TAGS));
+    assertSame(failure, thrown);
+    assertEquals(4, evaluator.getWordCount());
+    assertEquals(0, later.wrong.size(), "listeners after the failing one are not told");
+  }
+
+  /** A predicate that treats every token as punctuation leaves the punctuation-free scores at zero. */
+  @Test
+  void testAllTokensPunctuationLeavesPunctuationFreeScoresAtZero() throws IOException {
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator((tokens, tags) -> PREDICTED, tag -> true);
+    evaluate(evaluator, UNIVERSAL_TAGS);
+    assertEquals(4, evaluator.getWordCount());
+    assertEquals(0.75d, evaluator.getUas());
+    assertEquals(0, evaluator.getWordCountExcludingPunctuation());
+    assertEquals(0.0d, evaluator.getUasExcludingPunctuation(), "no division by zero");
+    assertEquals(0.0d, evaluator.getLasExcludingPunctuation());
+  }
+
+  /** An empty sample stream scores zero tokens and tells no listener anything. */
+  @Test
+  void testEmptyStreamScoresZeroAndTellsNoListener() throws IOException {
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator((tokens, tags) -> PREDICTED, monitor);
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(List.<DependencySample>of()));
+    assertEquals(0, evaluator.getWordCount());
+    assertEquals(0.0d, evaluator.getUas());
+    assertEquals(0.0d, evaluator.getLas());
+    assertEquals(0.0d, evaluator.getUasExcludingPunctuation());
+    assertEquals(0.0d, evaluator.getLasExcludingPunctuation());
+    assertEquals(0, monitor.correct.size() + monitor.wrong.size());
+  }
+
+  /** A one-token sentence parsed as the root scores one correct token. */
+  @Test
+  void testSingleTokenSentence() throws IOException {
+    final DependencyTree root = DependencyTree.of(new int[] {-1}, new String[] {"root"});
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyEvaluator evaluator = new DependencyEvaluator((tokens, tags) -> root, monitor);
+    final DependencySample sample = new DependencySample(new String[] {"Run"}, new String[] {"VERB"}, root);
+    evaluator.evaluate(ObjectStreamUtils.createObjectStream(List.of(sample)));
+    assertEquals(1, evaluator.getWordCount());
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(1.0d, evaluator.getLas());
+    assertEquals(List.of(sample), monitor.correct);
+  }
+
+  /** A prediction that differs from the gold tree only in a label is misparsed for the listeners. */
+  @Test
+  void testLabelOnlyDifferenceIsMisparsed() throws IOException {
+    final DependencyTree relabeled = DependencyTree.of(
+        new int[] {1, -1, 1, 1}, new String[] {"nsubj", "root", "obl", "punct"});
+    final RecordingMonitor monitor = new RecordingMonitor();
+    final DependencyEvaluator evaluator =
+        new DependencyEvaluator((tokens, tags) -> relabeled, monitor);
+    evaluate(evaluator, UNIVERSAL_TAGS);
+    assertEquals(1.0d, evaluator.getUas());
+    assertEquals(0.75d, evaluator.getLas());
+    assertEquals(List.of(new DependencySample(TOKENS, UNIVERSAL_TAGS, GOLD)), monitor.wrong);
+    assertEquals(List.of(new DependencySample(TOKENS, UNIVERSAL_TAGS, relabeled)),
+        monitor.predictions);
+  }
+
+  @Test
+  void testRejectsNullArguments() {
+    assertThrows(IllegalArgumentException.class, () -> new DependencyEvaluator(null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyEvaluator(null, DependencyEvaluator.UNIVERSAL_PUNCTUATION_TAG::equals));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyEvaluator((tokens, tags) -> PREDICTED, (Predicate<String>) null));
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyParserEdgeCaseTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyParserEdgeCaseTest.java
new file mode 100644
index 000000000..7fd6ada21
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyParserEdgeCaseTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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.IOException;
+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;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+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.sample;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests parser boundaries, non-projective input, model persistence and thread sharing.
+ */
+public class DependencyParserEdgeCaseTest {
+
+  /** The number of threads parsing concurrently in the sharing test. */
+  private static final int THREADS = 8;
+
+  /** The number of parses each thread performs in the sharing test. */
+  private static final int ITERATIONS_PER_THREAD = 50;
+
+  private static DependencyModel model;
+  private static DependencyParserME parser;
+
+  /**
+   * Builds a four-token sample whose gold arcs (2,0) and (3,1) cross, so the tree is
+   * non-projective and has no arc-standard derivation.
+   *
+   * @return The non-projective sample. Never {@code null}.
+   */
+  private static DependencySample nonProjectiveSample() {
+    return sample(new String[] {"the", "dog", "barks", "today"},
+        new String[] {"DT", "NN", "VBZ", "RB"},
+        new int[] {2, 3, -1, 2}, new String[] {"det", "nsubj", "root", "advmod"});
+  }
+
+  /**
+   * Trains a model on the shared corpus.
+   *
+   * @throws IOException Thrown if reading the in-memory samples fails.
+   */
+  @BeforeAll
+  static void trainParser() throws IOException {
+    model = DependencyTestSamples.train(corpus());
+    parser = new DependencyParserME(model);
+  }
+
+  @Test
+  void testEmptySentenceIsRejected() {
+    assertThrows(IllegalArgumentException.class,
+        () -> parser.parse(new String[0], new String[0]));
+    // The transition system itself has no configuration for zero tokens either.
+    assertThrows(IllegalArgumentException.class, () -> new ArcStandardState(0));
+  }
+
+  @Test
+  void testNullTokenOrTagIsRejected() {
+    assertThrows(IllegalArgumentException.class,
+        () -> parser.parse(new String[] {null}, new String[] {"NN"}));
+    assertThrows(IllegalArgumentException.class,
+        () -> parser.parse(new String[] {"word"}, new String[] {null}));
+  }
+
+  @Test
+  void testContextGeneratorRejectsMisalignedInput() {
+    final DependencyContextGenerator generator = new DependencyContextGenerator();
+    final ArcStandardState state = new ArcStandardState(2);
+    assertThrows(IllegalArgumentException.class,
+        () -> generator.getContext(state, new String[] {"one"}, new String[] {"NN"}));
+  }
+
+  @Test
+  void testSingleTokenSentenceAttachesToTheRoot() {
+    // A single token permits only the derivation shift then right-arc, so the head is
+    // forced to the artificial root and the model only chooses the relation label.
+    final DependencyTree parsed =
+        parser.parse(new String[] {"Run"}, new String[] {"VB"});
+    assertEquals(DependencyTree.of(new int[] {-1}, new String[] {"root"}), parsed);
+  }
+
+  @Test
+  void testNonProjectiveSamplesAreSkippedDuringTraining() throws IOException {
+    // One non-projective sample joins the corpus; it cannot yield events, so training
+    // proceeds on the remaining samples and still memorizes the projective sentences.
+    final List<DependencySample> mixed = new ArrayList<>(corpus());
+    mixed.add(nonProjectiveSample());
+    final DependencyParserME mixedParser =
+        new DependencyParserME(DependencyTestSamples.train(mixed));
+    assertEquals(THE_DOG_BARKS_TREE,
+        mixedParser.parse(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS));
+    assertEquals(SHE_EATS_FISH_TREE,
+        mixedParser.parse(SHE_EATS_FISH_TOKENS, SHE_EATS_FISH_TAGS));
+  }
+
+  @Test
+  void testNonProjectiveGoldDecodesToAProjectiveTree() {
+    // The parser can only emit arc-standard derivations, so for a sentence whose gold
+    // tree is non-projective the prediction is necessarily a different, projective tree.
+    final DependencySample gold = nonProjectiveSample();
+    final DependencyTree parsed = parser.parse(gold.getTokens(), gold.getTags());
+    assertNotEquals(gold.getTree(), parsed);
+    assertTrue(ArcStandardOracle.isProjective(parsed), parsed.toString());
+  }
+
+  @Test
+  void testModelFileRoundTripParsesIdentically(@TempDir Path dir)
+      throws IOException {
+    final Path file = dir.resolve("depparse.bin");
+    model.serialize(file);
+    final DependencyParserME reloaded = new DependencyParserME(new DependencyModel(file));
+    for (final DependencySample sample : corpus()) {
+      assertEquals(parser.parse(sample.getTokens(), sample.getTags()),
+          reloaded.parse(sample.getTokens(), sample.getTags()),
+          Arrays.toString(sample.getTokens()));
+    }
+    assertEquals(THE_DOG_BARKS_TREE, reloaded.parse(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS));
+  }
+
+  @Test
+  void testParserInstancesCanBeSharedBetweenThreads() throws Exception {
+    final List<Callable<Void>> tasks = new ArrayList<>();
+    for (int task = 0; task < THREADS; task++) {
+      tasks.add(() -> {
+        for (int iteration = 0; iteration < ITERATIONS_PER_THREAD; iteration++) {
+          assertEquals(THE_DOG_BARKS_TREE,
+              parser.parse(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS));
+        }
+        return null;
+      });
+    }
+
+    final ExecutorService executor = Executors.newFixedThreadPool(THREADS);
+    try {
+      final List<Future<Void>> results = executor.invokeAll(tasks);
+      for (final Future<Void> result : results) {
+        result.get();
+      }
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyParserMETest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyParserMETest.java
new file mode 100644
index 000000000..e383a4e34
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyParserMETest.java
@@ -0,0 +1,314 @@
+/*
+ * 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.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeAll;
+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;
+
+import opennlp.tools.ml.model.AbstractModel;
+import opennlp.tools.ml.model.Context;
+import opennlp.tools.ml.model.MaxentModel;
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.TrainingParameters;
+
+import static opennlp.tools.depparse.DependencyTestSamples.CORPUS_WORDS;
+import static opennlp.tools.depparse.DependencyTestSamples.LANGUAGE;
+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 org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests {@link DependencyParserME} end to end: training on a small corpus must let the
+ * greedy parser reproduce the training sentences, which proves the oracle, event stream,
+ * feature generation, and decode loop agree with each other.
+ */
+public class DependencyParserMETest {
+
+  /** A custom manifest entry name. */
+  private static final String MANIFEST_ENTRY = "test-entry";
+
+  /** The value stored under {@link #MANIFEST_ENTRY}. */
+  private static final String MANIFEST_VALUE = "test-value";
+
+  /** The manifest entry in which a model records its tool factory, if it has one. */
+  private static final String FACTORY_MANIFEST_ENTRY = "factory";
+
+  /** The encoded shift outcome. */
+  private static final String SHIFT_OUTCOME = "SHIFT";
+
+  /** The encoded right arc outcome attaching a token to the root. */
+  private static final String ROOT_ARC_OUTCOME = "RIGHT_ARC:root";
+
+  private static DependencyModel model;
+  private static DependencyParserME parser;
+
+  /**
+   * Trains the shared model once for all tests; the zero cutoff keeps every feature of
+   * the test corpus.
+   *
+   * @throws IOException Thrown if reading the in-memory samples fails.
+   */
+  @BeforeAll
+  static void trainParser() throws IOException {
+    model = DependencyTestSamples.train(corpus());
+    parser = new DependencyParserME(model);
+  }
+
+  @Test
+  void testMemorizesTrainingSentences() {
+    final DependencyTree parsed = parser.parse(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS);
+    assertEquals(THE_DOG_BARKS_TREE, parsed);
+  }
+
+  @Test
+  void testParseAlwaysYieldsASingleRootedTree() {
+    // an unseen sentence must still decode to a valid tree, whatever its quality
+    final DependencyTree parsed = parser.parse(new String[] {"cats", "sleep"},
+        new String[] {"NNS", "VBP"});
+    assertEquals(2, parsed.size());
+    int roots = 0;
+    for (int i = 0; i < parsed.size(); i++) {
+      if (parsed.headOf(i) == DependencyArc.ROOT_HEAD) {
+        roots++;
+      }
+    }
+    assertEquals(1, roots);
+    assertEquals(DependencyArc.ROOT_HEAD, parsed.headOf(parsed.root()));
+  }
+
+  @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());
+    assertEquals(CORPUS_WORDS, evaluator.getWordCount());
+  }
+
+  @Test
+  void testParseValidatesArguments() {
+    assertThrows(IllegalArgumentException.class,
+        () -> parser.parse(null, new String[] {"DT"}));
+    assertThrows(IllegalArgumentException.class,
+        () -> parser.parse(new String[] {"the"}, null));
+    assertThrows(IllegalArgumentException.class,
+        () -> parser.parse(new String[0], new String[0]));
+    assertThrows(IllegalArgumentException.class,
+        () -> parser.parse(new String[] {"the"}, new String[] {"DT", "NN"}));
+  }
+
+  /**
+   * The input checks of {@link DependencyParserME#parse(String[], String[])}, with the
+   * messages {@link DependencySample} uses for the same violations.
+   *
+   * @return Token and tag arrays with the expected message.
+   */
+  static Stream<Arguments> rejectedParseInput() {
+    final String[] tags = {"DT", "NN", "VBZ"};
+    return Stream.of(
+        Arguments.of(null, tags, "tokens must not be null"),
+        Arguments.of(THE_DOG_BARKS_TOKENS, null, "tags must not be null"),
+        Arguments.of(new String[0], new String[0], "tokens must not be empty"),
+        Arguments.of(new String[] {"the"}, tags, "tokens and tags must have the same length: 1 != 3"),
+        Arguments.of(new String[] {"the", null, "barks"}, tags, "tokens[1] must not be null"),
+        Arguments.of(THE_DOG_BARKS_TOKENS, new String[] {"DT", "NN", null},
+            "tags[2] must not be null"));
+  }
+
+  @ParameterizedTest(name = "{2}")
+  @MethodSource("rejectedParseInput")
+  void testParseMessagesMatchDependencySample(String[] tokens, String[] tags, String message) {
+    final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+        () -> parser.parse(tokens, tags));
+    assertEquals(message, exception.getMessage());
+    assertEquals(message, assertThrows(IllegalArgumentException.class,
+        () -> new DependencySample(tokens, tags, THE_DOG_BARKS_TREE)).getMessage());
+  }
+
+  @Test
+  void testConstructorRejectsNullModel() {
+    assertThrows(IllegalArgumentException.class, () -> new DependencyParserME(null));
+  }
+
+  @Test
+  void testModelConstructorsRejectNullArguments() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyModel(null, model.getParserModel(), null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyModel(LANGUAGE, null, null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyModel((InputStream) null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyModel((File) null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new DependencyModel((Path) null));
+  }
+
+  @Test
+  void testTrainValidatesArguments() {
+    assertEquals("samples must not be null", assertThrows(IllegalArgumentException.class,
+        () -> DependencyParserME.train(LANGUAGE, null, TrainingParameters.defaultParams()))
+        .getMessage());
+    assertEquals("parameters must not be null", assertThrows(IllegalArgumentException.class,
+        () -> DependencyParserME.train(LANGUAGE,
+            ObjectStreamUtils.createObjectStream(corpus()), null)).getMessage());
+    assertEquals("languageCode must not be null", assertThrows(IllegalArgumentException.class,
+        () -> DependencyParserME.train(null,
+            ObjectStreamUtils.createObjectStream(corpus()),
+            TrainingParameters.defaultParams())).getMessage());
+  }
+
+  @Test
+  void testModelRoundTripThroughSerialization() throws IOException {
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    final DependencyModel reloaded = new DependencyModel(
+        new ByteArrayInputStream(out.toByteArray()));
+    final DependencyTree parsed = new DependencyParserME(reloaded)
+        .parse(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS);
+    assertEquals(THE_DOG_BARKS_TREE, parsed);
+    assertEquals(LANGUAGE, reloaded.getLanguage());
+  }
+
+  @Test
+  void testManifestEntriesRoundTripWithoutAToolFactory() throws IOException {
+    final DependencyModel withEntry = new DependencyModel(LANGUAGE, model.getParserModel(),
+        Map.of(MANIFEST_ENTRY, MANIFEST_VALUE));
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    withEntry.serialize(out);
+    final DependencyModel reloaded = new DependencyModel(
+        new ByteArrayInputStream(out.toByteArray()));
+    assertEquals(MANIFEST_VALUE, reloaded.getManifestProperty(MANIFEST_ENTRY));
+    // no tool factory is recorded, so loading needs no extension class
+    assertNull(reloaded.getManifestProperty(FACTORY_MANIFEST_ENTRY));
+    assertEquals(THE_DOG_BARKS_TREE,
+        new DependencyParserME(reloaded).parse(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS));
+  }
+
+  @Test
+  void testModelWithForeignOutcomesIsRejectedAtConstruction() {
+    // The outcome inventory is decoded once up front, so a model trained for another
+    // task is rejected when the parser is built rather than mid-sentence.
+    assertThrows(IllegalArgumentException.class,
+        () -> parserOf(new OutcomeOnlyModel("NN", "VB")));
+  }
+
+  @Test
+  void testIncompleteActionInventoriesAreRejectedAtConstruction() {
+    assertThrows(IllegalArgumentException.class,
+        () -> parserOf(new OutcomeOnlyModel(SHIFT_OUTCOME)));
+    assertThrows(IllegalArgumentException.class,
+        () -> parserOf(new OutcomeOnlyModel(ROOT_ARC_OUTCOME)));
+  }
+
+  @Test
+  void testDuplicateActionsAreRejectedAtConstruction() {
+    assertThrows(IllegalArgumentException.class,
+        () -> parserOf(new OutcomeOnlyModel(SHIFT_OUTCOME, SHIFT_OUTCOME, ROOT_ARC_OUTCOME)));
+  }
+
+  @Test
+  void testModelScoreCountIsValidated() {
+    final DependencyParserME invalid = parserOf(
+        new OutcomeOnlyModel(new double[] {1.0}, SHIFT_OUTCOME, ROOT_ARC_OUTCOME));
+    final IllegalStateException exception = assertThrows(IllegalStateException.class,
+        () -> invalid.parse(new String[] {"word"}, new String[] {"NN"}));
+    assertEquals("model returned 1 scores for 2 outcomes", exception.getMessage());
+  }
+
+  @Test
+  void testNonFiniteModelScoreIsRejected() {
+    final DependencyParserME invalid = parserOf(new OutcomeOnlyModel(
+        new double[] {Double.NaN, 1.0}, SHIFT_OUTCOME, ROOT_ARC_OUTCOME));
+    assertThrows(IllegalStateException.class,
+        () -> invalid.parse(new String[] {"word"}, new String[] {"NN"}));
+  }
+
+  /**
+   * Wraps a raw transition model the way a trained model would be wrapped.
+   *
+   * @param model The transition classification model.
+   * @return A parser over {@code model}. Never {@code null}.
+   */
+  private static DependencyParserME parserOf(MaxentModel model) {
+    return new DependencyParserME(new DependencyModel(LANGUAGE, model, null));
+  }
+
+  /**
+   * An {@link AbstractModel} that only knows its outcome inventory, enough to build a
+   * model and a parser from; any other use fails.
+   */
+  private static final class OutcomeOnlyModel extends AbstractModel {
+
+    private final double[] scores;
+
+    /**
+     * Initializes a model that fails on every evaluation.
+     *
+     * @param outcomes The outcome inventory, in index order.
+     */
+    private OutcomeOnlyModel(String... outcomes) {
+      this(null, outcomes);
+    }
+
+    /**
+     * Initializes a model returning fixed scores.
+     *
+     * @param scores The scores every evaluation returns, or {@code null} to fail instead.
+     * @param outcomes The outcome inventory, in index order.
+     */
+    private OutcomeOnlyModel(double[] scores, String... outcomes) {
+      super(new Context[0], new String[0], outcomes);
+      this.scores = scores;
+    }
+
+    @Override
+    public double[] eval(String[] context) {
+      if (scores == null) {
+        throw new UnsupportedOperationException();
+      }
+      return scores.clone();
+    }
+
+    @Override
+    public double[] eval(String[] context, double[] probs) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public double[] eval(String[] context, float[] values) {
+      throw new UnsupportedOperationException();
+    }
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyTestSamples.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyTestSamples.java
new file mode 100644
index 000000000..1ae9eb33a
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyTestSamples.java
@@ -0,0 +1,141 @@
+/*
+ * 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.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.TrainingParameters;
+
+/**
+ * The gold samples and training setup shared by the dependency parser tests.
+ */
+final class DependencyTestSamples {
+
+  /** The language code of the test corpus. */
+  static final String LANGUAGE = "eng";
+
+  /** The tokens of the first corpus sentence. */
+  static final String[] THE_DOG_BARKS_TOKENS = {"the", "dog", "barks"};
+
+  /** The tags of the first corpus sentence. */
+  static final String[] THE_DOG_BARKS_TAGS = {"DT", "NN", "VBZ"};
+
+  /** The gold tree of the first corpus sentence. */
+  static final DependencyTree THE_DOG_BARKS_TREE =
+      DependencyTree.of(new int[] {1, 2, -1}, new String[] {"det", "nsubj", "root"});
+
+  /** The tokens of the third corpus sentence. */
+  static final String[] SHE_EATS_FISH_TOKENS = {"she", "eats", "fish"};
+
+  /** The tags of the third corpus sentence. */
+  static final String[] SHE_EATS_FISH_TAGS = {"PRP", "VBZ", "NN"};
+
+  /** The gold tree of the third corpus sentence. */
+  static final DependencyTree SHE_EATS_FISH_TREE =
+      DependencyTree.of(new int[] {1, -1, 1}, new String[] {"nsubj", "root", "obj"});
+
+  /** How often the distinct sentences are repeated in {@link #corpus()}. */
+  private static final int REPETITIONS = 40;
+
+  /** The sentences in {@link #corpus()}. */
+  static final int CORPUS_SENTENCES = 3 * REPETITIONS;
+
+  /** The tokens in {@link #corpus()}: the distinct sentences have 3 + 2 + 3 tokens. */
+  static final int CORPUS_WORDS = 8 * REPETITIONS;
+
+  /** Prevents construction of this utility class. */
+  private DependencyTestSamples() {
+  }
+
+  /**
+   * Builds one gold sample from its parallel arrays.
+   *
+   * @param tokens The sentence tokens. Must not be {@code null}.
+   * @param tags The part-of-speech tags aligned with {@code tokens}.
+   * @param heads The zero-based head per token, {@code -1} for the root.
+   * @param relations The relation label per token.
+   * @return The assembled sample. Never {@code null}.
+   */
+  static DependencySample sample(String[] tokens, String[] tags, int[] heads,
+      String[] relations) {
+    return new DependencySample(tokens, tags, DependencyTree.of(heads, relations));
+  }
+
+  /**
+   * Builds the three distinct projective sentences of the test corpus.
+   *
+   * @return One sample per sentence. Never {@code null}.
+   */
+  static List<DependencySample> sentences() {
+    return List.of(
+        new DependencySample(THE_DOG_BARKS_TOKENS, THE_DOG_BARKS_TAGS, THE_DOG_BARKS_TREE),
+        sample(new String[] {"dogs", "bark"}, new String[] {"NNS", "VBP"},
+            new int[] {1, -1}, new String[] {"nsubj", "root"}),
+        new DependencySample(SHE_EATS_FISH_TOKENS, SHE_EATS_FISH_TAGS, SHE_EATS_FISH_TREE));
+  }
+
+  /**
+   * Builds the training corpus: {@link #sentences()} repeated {@code REPETITIONS}
+   * times, which makes the small parsers memorize them deterministically.
+   *
+   * @return The training samples. Never {@code null}.
+   */
+  static List<DependencySample> corpus() {
+    return repeat(sentences());
+  }
+
+  /**
+   * Repeats samples {@code REPETITIONS} times, in order.
+   *
+   * @param distinct The samples to repeat. Must not be {@code null}.
+   * @return The repeated samples. Never {@code null}.
+   */
+  static List<DependencySample> repeat(List<DependencySample> distinct) {
+    final List<DependencySample> repeated = new ArrayList<>(REPETITIONS * distinct.size());
+    for (int i = 0; i < REPETITIONS; i++) {
+      repeated.addAll(distinct);
+    }
+    return repeated;
+  }
+
+  /**
+   * @return Default training parameters with a zero cutoff, so the small test corpus
+   *         keeps every feature. Never {@code null}.
+   */
+  static TrainingParameters trainingParameters() {
+    final TrainingParameters parameters = TrainingParameters.defaultParams();
+    parameters.put(Parameters.CUTOFF_PARAM, 0);
+    return parameters;
+  }
+
+  /**
+   * Trains a model on samples with {@link #trainingParameters()}.
+   *
+   * @param samples The training samples. Must not be {@code null}.
+   * @return The trained model. Never {@code null}.
+   * @throws IOException Thrown if reading the in-memory samples fails.
+   */
+  static DependencyModel train(List<DependencySample> samples) throws IOException {
+    return DependencyParserME.train(LANGUAGE, ObjectStreamUtils.createObjectStream(samples),
+        trainingParameters());
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/RecordingMonitor.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/RecordingMonitor.java
new file mode 100644
index 000000000..1437bd1c8
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/RecordingMonitor.java
@@ -0,0 +1,48 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+
+/**
+ * A {@link DependencyEvaluationMonitor} that records the samples it is notified about,
+ * for the evaluator and cross validator tests.
+ */
+final class RecordingMonitor implements DependencyEvaluationMonitor {
+
+  /** The reference samples reported as correctly parsed, in order. */
+  final List<DependencySample> correct = new ArrayList<>();
+
+  /** The reference samples reported as misparsed, in order. */
+  final List<DependencySample> wrong = new ArrayList<>();
+
+  /** The predictions reported as misparsed, aligned with {@link #wrong}. */
+  final List<DependencySample> predictions = new ArrayList<>();
+
+  @Override
+  public void correctlyClassified(DependencySample reference, DependencySample prediction) {
+    correct.add(reference);
+  }
+
+  @Override
+  public void misclassified(DependencySample reference, DependencySample prediction) {
+    wrong.add(reference);
+    predictions.add(prediction);
+  }
+}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/TransitionTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/TransitionTest.java
new file mode 100644
index 000000000..c35489401
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/TransitionTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests valid transition encodings and rejection of malformed outcomes.
+ */
+public class TransitionTest {
+
+  @Test
+  void testEncodeRendersTheOutcomeStrings() {
+    assertEquals("SHIFT", Transition.SHIFT.encode());
+    assertEquals("LEFT_ARC:nsubj", Transition.leftArc("nsubj").encode());
+    assertEquals("RIGHT_ARC:obj", Transition.rightArc("obj").encode());
+  }
+
+  @Test
+  void testDecodeRestoresEncodedTransitions() {
+    assertSame(Transition.SHIFT, Transition.decode("SHIFT"));
+    assertEquals(Transition.leftArc("nsubj"), Transition.decode("LEFT_ARC:nsubj"));
+    assertEquals(Transition.rightArc("obj"), Transition.decode("RIGHT_ARC:obj"));
+  }
+
+  @Test
+  void testLabelContainingTheSeparatorRoundTrips() {
+    // Only the first separator divides the type from the label.
+    final Transition transition = Transition.leftArc("nmod:poss");
+    assertEquals("LEFT_ARC:nmod:poss", transition.encode());
+    assertEquals(transition, Transition.decode(transition.encode()));
+  }
+
+  @Test
+  void testDecodeRejectsMalformedOutcomes() {
+    assertThrows(IllegalArgumentException.class, () -> Transition.decode(null));
+    assertThrows(IllegalArgumentException.class, () -> Transition.decode("UNKNOWN"));
+    assertThrows(IllegalArgumentException.class, () -> Transition.decode("UNKNOWN:det"));
+    // A labeled shift is contradictory and must be rejected by the record invariant.
+    assertThrows(IllegalArgumentException.class, () -> Transition.decode("SHIFT:det"));
+  }
+
+  @Test
+  void testConstructorValidation() {
+    assertThrows(IllegalArgumentException.class, () -> new Transition(null, "det"));
+    assertThrows(IllegalArgumentException.class,
+        () -> new Transition(Transition.Type.SHIFT, "det"));
+    assertThrows(IllegalArgumentException.class, () -> Transition.leftArc(null));
+    assertThrows(IllegalArgumentException.class, () -> Transition.rightArc(" "));
+  }
+}
diff --git a/opennlp-docs/src/docbkx/dependency.xml b/opennlp-docs/src/docbkx/dependency.xml
new file mode 100644
index 000000000..598be6db0
--- /dev/null
+++ b/opennlp-docs/src/docbkx/dependency.xml
@@ -0,0 +1,135 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE chapter PUBLIC "-//OASIS//DTD DocBook XML 5.0//EN"
+"http://docbook.org/xml/5.0/dtd/docbook.dtd"[
+]>
+<!-- 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. -->
+
+<chapter xml:id="tools.dependency" xmlns:xlink="http://www.w3.org/1999/xlink">
+
+	<title>Dependency Parsing</title>
+
+	<section xml:id="tools.dependency.introduction">
+		<title>Introduction</title>
+		<para>
+			Dependency parsing assigns each token a syntactic head and a relation label.
+			<code>DependencyParserME</code> accepts tokens with aligned part-of-speech tags
+			and returns a <code>DependencyTree</code>. A classifier picks the next
+			arc-standard transition for each parser state.
+		</para>
+	</section>
+
+	<section xml:id="tools.dependency.training-data">
+		<title>Training data</title>
+		<para>
+			<code>ConlluDependencySampleStream</code> reads the basic <code>HEAD</code> and
+			<code>DEPREL</code> columns of a CoNLL-U treebank. Select
+			<code>ConlluTagset.U</code> for universal tags or <code>ConlluTagset.X</code>
+			for language-specific tags. The reader retains syntactic words from multiword
+			tokens and skips sentences without a complete dependency tree or the selected POS tags.
+		</para>
+		<para>
+			The arc-standard transition system only builds projective trees, where no two arcs
+			cross. Training skips non-projective sentences, and the parser never predicts a
+			non-projective tree.
+		</para>
+	</section>
+
+	<section xml:id="tools.dependency.maxent">
+		<title>Training and loading a model</title>
+		<para>
+			Train and save the standard OpenNLP model with <code>TrainingParameters</code>:
+			<programlisting language="java">
+<![CDATA[InputStreamFactory trainingData =
+    () -> Files.newInputStream(Path.of("train.conllu"));
+
+TrainingParameters parameters = TrainingParameters.defaultParams();
+DependencyModel model;
+try (ObjectStream<DependencySample> samples =
+    new ConlluDependencySampleStream(trainingData, ConlluTagset.U)) {
+  model = DependencyParserME.train("eng", samples, parameters);
+}
+model.serialize(Path.of("en-depparse.bin"));
+
+DependencyParser parser =
+    new DependencyParserME(new DependencyModel(Path.of("en-depparse.bin")));]]>
+			</programlisting>
+		</para>
+	</section>
+
+	<section xml:id="tools.dependency.cli">
+		<title>Command-line tools</title>
+		<para>
+			Training, evaluation, and cross validation read UTF-8 CoNLL-U files.
+			An <code>-encoding</code> value other than UTF-8 is rejected. Use
+			<code>-tagset u</code> (the default) for UPOS or <code>-tagset x</code> for XPOS.
+			Use the same tagset for training and inference. An optional <code>-params</code>
+			file supplies the standard training parameters. For example:
+		</para>
+		<programlisting>
+<![CDATA[opennlp DependencyParserTrainerME.conllu -lang eng -data train.conllu -model en-depparse.bin
+opennlp DependencyParserEvaluator.conllu -model en-depparse.bin -data dev.conllu
+opennlp DependencyParserCrossValidator.conllu -lang eng -data train.conllu -folds 5
+opennlp DependencyParserME en-depparse.bin < tagged-sentences.txt]]>
+		</programlisting>
+		<para>
+			Inference reads one sentence per line in the POS tagger's <code>token_TAG</code>
+			format, for example <code>dogs_NOUN run_VERB</code>. It writes ten-column
+			CoNLL-U output with one-based heads and <code>0</code> for the root. Input tags
+			are retained in XPOS; UPOS and other unavailable annotations are <code>_</code>.
+			The evaluation and cross-validation tools report UAS and LAS over all scored tokens
+			and over the tokens that are not punctuation. With <code>-misclassified true</code>
+			they also print each misparsed sentence next to its gold tree.
+		</para>
+	</section>
+
+	<section xml:id="tools.dependency.parsing">
+
+		<title>Parsing and evaluation</title>
+		<para>
+			Tree indices refer to the input token array:
+			<programlisting language="java">
+<![CDATA[String[] tokens = {"Alice", "sent", "Bob", "a", "message", "."};
+String[] tags = {"PROPN", "VERB", "PROPN", "DET", "NOUN", "PUNCT"};
+DependencyTree tree = parser.parse(tokens, tags);
+
+for (DependencyArc arc : tree.arcs()) {
+  String dependent = tokens[arc.dependent()];
+  String head = arc.head() == DependencyArc.ROOT_HEAD
+      ? "ROOT" : tokens[arc.head()];
+  System.out.println(dependent + " -> " + head + " (" + arc.relation() + ")");
+}]]>
+			</programlisting>
+			<code>DependencyEvaluator</code> reports the unlabeled attachment score (the fraction
+			of tokens with the correct head) and the labeled attachment score (the fraction with
+			the correct head and relation label), both over all tokens and, through the
+			<code>ExcludingPunctuation</code> getters, over the tokens whose gold tag is not
+			<code>PUNCT</code>. <code>DependencyCrossValidator</code> splits a sample stream into
+			folds, trains a parser on the other folds with the given training parameters, and
+			scores the held-out fold:
+			<programlisting language="java">
+<![CDATA[DependencyCrossValidator validator =
+    new DependencyCrossValidator("eng", parameters);
+validator.evaluate(trainingSamples, 4);
+double uas = validator.getUas();
+double lasExcludingPunctuation = validator.getLasExcludingPunctuation();]]>
+			</programlisting>
+			The provided treebank evaluation, <code>UniversalDependencyParserEval</code> in
+			<code>opennlp-eval-tests</code>, trains the parser from scratch on a Universal
+			Dependencies 2.0 training split and pins all four scores on data the parser has not
+			seen: the English, German, Spanish AnCora, and French development splits, and a
+			five-fold cross validation of the English training split. It uses gold sentence
+			boundaries, tokens, and tags, so it measures the dependency parser rather than a
+			complete text pipeline. Evaluation uses the existing <code>opennlp-data</code>
+			treebanks.
+		</para>
+	</section>
+</chapter>
diff --git a/opennlp-docs/src/docbkx/opennlp.xml b/opennlp-docs/src/docbkx/opennlp.xml
index 0761fc95f..4493b16d7 100644
--- a/opennlp-docs/src/docbkx/opennlp.xml
+++ b/opennlp-docs/src/docbkx/opennlp.xml
@@ -113,6 +113,7 @@ under the License.
 	<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="./spellcheck.xml" />
 	<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="./chunker.xml" />
 	<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="./parser.xml" />
+	<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="./dependency.xml" />
 	<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="./entitylinker.xml" />
 	<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="./coref.xml" />
 	<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="./model-loading.xml" />
diff --git a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/UniversalDependencyParserEval.java b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/UniversalDependencyParserEval.java
new file mode 100644
index 000000000..7dd7b79cb
--- /dev/null
+++ b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/UniversalDependencyParserEval.java
@@ -0,0 +1,301 @@
+/*
+ * 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.eval;
+
+import java.io.File;
+import java.io.IOException;
+import java.math.BigInteger;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.depparse.DependencyCrossValidator;
+import opennlp.tools.depparse.DependencyEvaluator;
+import opennlp.tools.depparse.DependencyModel;
+import opennlp.tools.depparse.DependencyParser;
+import opennlp.tools.depparse.DependencyParserME;
+import opennlp.tools.formats.conllu.ConlluDependencySampleStream;
+import opennlp.tools.formats.conllu.ConlluTagset;
+import opennlp.tools.util.MarkableFileInputStreamFactory;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.TrainingParameters;
+
+/**
+ * Measures the accuracy of the dependency parser on Universal Dependencies 2.0
+ * treebanks and pins the exact scores.
+ *
+ * <p>Every test trains from scratch on a treebank's training split and scores the parser
+ * on sentences it has not seen: the treebank's development split, or, for the English
+ * cross validation, the held-out fold. The treebank's sentence segmentation, tokens, and
+ * universal part-of-speech tags are the parser input, so the scores measure parsing by
+ * itself and not the errors of an upstream pipeline. Four numbers are pinned per run:
+ * the unlabeled attachment score (UAS, the fraction of tokens with the correct head), the
+ * labeled attachment score (LAS, the fraction with the correct head and relation label),
+ * and both again over the tokens that are not tagged {@code PUNCT}, the customary
+ * reporting convention for Universal Dependencies.</p>
+ *
+ * <p>The parser is trained with a feature cutoff of {@value #FEATURE_CUTOFF} and
+ * evaluated on English, German, Spanish, and French. The English cross validation uses
+ * {@value #ENGLISH_FOLDS} folds of the training split.</p>
+ */
+public class UniversalDependencyParserEval extends AbstractEvalTest {
+
+  /** The feature cutoff of the parser. */
+  private static final int FEATURE_CUTOFF = 5;
+
+  /** The fold count of the English cross validation. */
+  private static final int ENGLISH_FOLDS = 5;
+
+  /**
+   * One treebank: its language code and its training and development splits.
+   *
+   * @param language The ISO 639-3 code passed to the trainer.
+   * @param train The training split.
+   * @param dev The development split.
+   */
+  private record Treebank(String language, File train, File dev) {
+
+    /**
+     * Locates a treebank under {@code ud20} in the data directory.
+     *
+     * @param language The ISO 639-3 code.
+     * @param directory The treebank directory name, for example {@code UD_English}.
+     * @param prefix The split file prefix, for example {@code en}.
+     * @return The treebank. Never {@code null}.
+     * @throws IOException Thrown if the data directory is not set or missing.
+     */
+    static Treebank of(String language, String directory, String prefix) throws IOException {
+      final File base = new File(getOpennlpDataDir(), "ud20/" + directory);
+      return new Treebank(language, new File(base, prefix + "-ud-train.conllu"),
+          new File(base, prefix + "-ud-dev.conllu"));
+    }
+  }
+
+  /**
+   * The scores of one run.
+   *
+   * @param words The number of scored tokens.
+   * @param wordsExcludingPunctuation The number of scored tokens not tagged {@code PUNCT}.
+   * @param uas The unlabeled attachment score over all tokens.
+   * @param las The labeled attachment score over all tokens.
+   * @param uasExcludingPunctuation The unlabeled attachment score without punctuation.
+   * @param lasExcludingPunctuation The labeled attachment score without punctuation.
+   */
+  private record Scores(long words, long wordsExcludingPunctuation, double uas, double las,
+      double uasExcludingPunctuation, double lasExcludingPunctuation) {
+
+    /**
+     * Reads the scores of a finished evaluation.
+     *
+     * @param evaluator The evaluator after {@link DependencyEvaluator#evaluate}.
+     * @return The scores. Never {@code null}.
+     */
+    static Scores of(DependencyEvaluator evaluator) {
+      return new Scores(evaluator.getWordCount(), evaluator.getWordCountExcludingPunctuation(),
+          evaluator.getUas(), evaluator.getLas(), evaluator.getUasExcludingPunctuation(),
+          evaluator.getLasExcludingPunctuation());
+    }
+
+    /**
+     * Reads the scores of a finished cross validation.
+     *
+     * @param validator The validator after {@link DependencyCrossValidator#evaluate}.
+     * @return The scores. Never {@code null}.
+     */
+    static Scores of(DependencyCrossValidator validator) {
+      return new Scores(validator.getWordCount(), validator.getWordCountExcludingPunctuation(),
+          validator.getUas(), validator.getLas(), validator.getUasExcludingPunctuation(),
+          validator.getLasExcludingPunctuation());
+    }
+  }
+
+  private static Treebank english;
+  private static Treebank german;
+  private static Treebank spanish;
+  private static Treebank french;
+
+  /**
+   * Locates the treebank splits and verifies their digests.
+   *
+   * @throws Exception Thrown if the data directory or a split is missing or changed.
+   */
+  @BeforeAll
+  static void verifyTrainingData() throws Exception {
+    english = Treebank.of("eng", "UD_English", "en");
+    german = Treebank.of("deu", "UD_German", "de");
+    spanish = Treebank.of("spa", "UD_Spanish-AnCora", "es_ancora");
+    french = Treebank.of("fra", "UD_French", "fr");
+    verifyFileChecksum(english.train().toPath(),
+        new BigInteger("240180699945832517506080812986784120023"));
+    verifyFileChecksum(english.dev().toPath(),
+        new BigInteger("77584448353380940340541536944256636015"));
+    verifyFileChecksum(german.train().toPath(),
+        new BigInteger("290029973628740099051462227636742189201"));
+    verifyFileChecksum(german.dev().toPath(),
+        new BigInteger("133330826309481994758690698938095410310"));
+    verifyFileChecksum(spanish.train().toPath(),
+        new BigInteger("224942804200733453179524127037951530195"));
+    verifyFileChecksum(spanish.dev().toPath(),
+        new BigInteger("280996187464384493180190898172297941708"));
+    verifyFileChecksum(french.train().toPath(),
+        new BigInteger("147594341855033303213328942622831640563"));
+    verifyFileChecksum(french.dev().toPath(),
+        new BigInteger("146783792803312408008054489850921785757"));
+  }
+
+  /**
+   * Cross validates the parser on the English training split: five parsers,
+   * each trained on four fifths of the split and scored on the remaining fifth, so every
+   * training sentence is scored once by a parser that did not see it.
+   *
+   * @throws IOException Thrown if reading or training fails.
+   */
+  @Test
+  void crossValidateEnglish() throws IOException {
+    final DependencyCrossValidator validator =
+        new DependencyCrossValidator(english.language(), trainingParameters());
+    try (ConlluDependencySampleStream train = samples(english.train())) {
+      validator.evaluate(train, ENGLISH_FOLDS);
+    }
+    assertScores(new Scores(204585, 180906, 0.8205440281545567d, 0.7885182198108366d,
+            0.8421666500834688d, 0.8065404132532917d), Scores.of(validator));
+  }
+
+  /**
+   * Trains the parser on the English training split and scores it on the
+   * development split.
+   *
+   * @throws IOException Thrown if reading or training fails.
+   */
+  @Test
+  void trainAndEvalEnglish() throws IOException {
+    assertScores(new Scores(25148, 22065, 0.8181565134404326d, 0.7861460155877207d,
+            0.8372082483571267d, 0.8014502605937004d),
+        evaluate(train(english), english.dev()));
+  }
+
+  /**
+   * Trains the parser on the German training split and scores it on the
+   * development split.
+   *
+   * @throws IOException Thrown if reading or training fails.
+   */
+  @Test
+  void trainAndEvalGerman() throws IOException {
+    assertScores(new Scores(12348, 10730, 0.7843375445416262d, 0.7305636540330418d,
+            0.802982292637465d, 0.7413793103448276d),
+        evaluate(train(german), german.dev()));
+  }
+
+  /**
+   * Trains the parser on the Spanish AnCora training split and scores it on
+   * the development split.
+   *
+   * @throws IOException Thrown if reading or training fails.
+   */
+  @Test
+  void trainAndEvalSpanishAncora() throws IOException {
+    assertScores(new Scores(52336, 46058, 0.8355243044940385d, 0.7914437480892693d,
+            0.8598506231273612d, 0.8098918754613748d),
+        evaluate(train(spanish), spanish.dev()));
+  }
+
+  /**
+   * Trains the parser on the French training split and scores it on the
+   * development split.
+   *
+   * @throws IOException Thrown if reading or training fails.
+   */
+  @Test
+  void trainAndEvalFrench() throws IOException {
+    assertScores(new Scores(35766, 31947, 0.8501370016216518d, 0.8191578594195604d,
+            0.8794879018374182d, 0.8450245719472878d),
+        evaluate(train(french), french.dev()));
+  }
+
+  /**
+   * @return The training parameters of the parser. Never {@code null}.
+   */
+  private static TrainingParameters trainingParameters() {
+    final TrainingParameters parameters = TrainingParameters.defaultParams();
+    parameters.put(Parameters.CUTOFF_PARAM, FEATURE_CUTOFF);
+    return parameters;
+  }
+
+  /**
+   * Trains the parser on a treebank's training split.
+   *
+   * @param treebank The treebank.
+   * @return The trained parser. Never {@code null}.
+   * @throws IOException Thrown if reading or training fails.
+   */
+  private static DependencyParser train(Treebank treebank) throws IOException {
+    final DependencyModel model;
+    try (ConlluDependencySampleStream train = samples(treebank.train())) {
+      model = DependencyParserME.train(treebank.language(), train, trainingParameters());
+    }
+    return new DependencyParserME(model);
+  }
+
+  /**
+   * Scores a parser on one split.
+   *
+   * @param parser The parser to score.
+   * @param split The CoNLL-U file with the gold trees.
+   * @return The scores. Never {@code null}.
+   * @throws IOException Thrown if reading the split fails.
+   */
+  private static Scores evaluate(DependencyParser parser, File split) throws IOException {
+    final DependencyEvaluator evaluator = new DependencyEvaluator(parser);
+    try (ConlluDependencySampleStream dev = samples(split)) {
+      evaluator.evaluate(dev);
+    }
+    return Scores.of(evaluator);
+  }
+
+  /**
+   * Asserts that the measured scores match the pinned ones.
+   *
+   * @param expected The pinned scores.
+   * @param actual The measured scores.
+   */
+  private static void assertScores(Scores expected, Scores actual) {
+    Assertions.assertEquals(expected.words(), actual.words(), "words");
+    Assertions.assertEquals(expected.wordsExcludingPunctuation(),
+        actual.wordsExcludingPunctuation(), "words excluding punctuation");
+    Assertions.assertEquals(expected.uas(), actual.uas(), ACCURACY_DELTA, "UAS");
+    Assertions.assertEquals(expected.las(), actual.las(), ACCURACY_DELTA, "LAS");
+    Assertions.assertEquals(expected.uasExcludingPunctuation(),
+        actual.uasExcludingPunctuation(), ACCURACY_DELTA, "UAS excluding punctuation");
+    Assertions.assertEquals(expected.lasExcludingPunctuation(),
+        actual.lasExcludingPunctuation(), ACCURACY_DELTA, "LAS excluding punctuation");
+  }
+
+  /**
+   * Opens one split with the universal tagset.
+   *
+   * @param split The CoNLL-U file.
+   * @return The sample stream.
+   * @throws IOException Thrown if the file cannot be opened.
+   */
+  private static ConlluDependencySampleStream samples(File split) throws IOException {
+    return new ConlluDependencySampleStream(new MarkableFileInputStreamFactory(split),
+        ConlluTagset.U);
+  }
+}