From 49b9612dfcb0651482cb85629cddddec12457201 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Tue, 14 Jul 2026 21:27:22 -0400
Subject: [PATCH 01/92] OPENNLP-1888: Document annotation container: typed
offset-anchored layers over the original text
Adds opennlp.tools.document to opennlp-api: Document (immutable, copy-on-add layer
container over the original text), Annotation (a typed value on a Span), LayerKey
(open, typed layer identity), and DocumentAnnotator (pipeline step declaring the
layers it requires and provides). DocumentAnalyzer assembles annotators into a
pipeline validated at build time. Standard keys in Layers cover sentences, tokens,
part-of-speech tags, and entities, populated through thin adapters over the existing
SentenceDetector, Tokenizer, POSTagger, and TokenNameFinder interfaces, which stay
the primary API for single-task use and are unchanged.
All spans refer to the text as supplied. No new dependencies.
---
.../opennlp/tools/document/Annotation.java | 55 +++++
.../java/opennlp/tools/document/Document.java | 87 ++++++++
.../tools/document/DocumentAnalyzer.java | 116 +++++++++++
.../tools/document/DocumentAnnotator.java | 59 ++++++
.../tools/document/ImmutableDocument.java | 107 ++++++++++
.../java/opennlp/tools/document/LayerKey.java | 102 +++++++++
.../java/opennlp/tools/document/Layers.java | 55 +++++
.../tools/document/NameFinderAnnotator.java | 88 ++++++++
.../tools/document/POSTaggerAnnotator.java | 80 ++++++++
.../document/SentenceDetectorAnnotator.java | 70 +++++++
.../tools/document/TokenizerAnnotator.java | 84 ++++++++
.../tools/document/DocumentAnalyzerTest.java | 194 ++++++++++++++++++
.../opennlp/tools/document/DocumentTest.java | 125 +++++++++++
.../document/NameFinderAnnotatorTest.java | 87 ++++++++
14 files changed, 1309 insertions(+)
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/Annotation.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/Document.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/DocumentAnalyzer.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/Layers.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
create mode 100644 opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
create mode 100644 opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java
create mode 100644 opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Annotation.java b/opennlp-api/src/main/java/opennlp/tools/document/Annotation.java
new file mode 100644
index 0000000000..eaed6bf7f4
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Annotation.java
@@ -0,0 +1,55 @@
+/*
+ * 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.document;
+
+import opennlp.tools.util.Span;
+
+/**
+ * One annotation of a {@link Document}: a typed value anchored to a {@link Span} of the
+ * document's original text.
+ *
+ *
The span always refers to the text the document was created with, never to a
+ * normalized or otherwise derived form, so any annotation can be highlighted in what the
+ * caller supplied. Annotations that need to reference other annotations, for example a
+ * dependency arc naming its head token, do so by the index of the target annotation
+ * within its layer, never by object identity.
+ *
+ * @param span The location of the annotation in the original text. Must not be
+ * {@code null}.
+ * @param value The annotation value. Must not be {@code null}.
+ * @param The type of the annotation value.
+ *
+ * @since 3.0.0
+ */
+public record Annotation(Span span, T value) {
+
+ /**
+ * Validates the annotation.
+ *
+ * @throws IllegalArgumentException Thrown if {@code span} or {@code value} is
+ * {@code null}.
+ */
+ public Annotation {
+ if (span == null) {
+ throw new IllegalArgumentException("span must not be null");
+ }
+ if (value == null) {
+ throw new IllegalArgumentException("value must not be null");
+ }
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
new file mode 100644
index 0000000000..53453ff856
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -0,0 +1,87 @@
+/*
+ * 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.document;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * An immutable, offset-anchored annotation container: the original text of one document
+ * plus any number of typed annotation layers over it.
+ *
+ *
A layer is a list of {@link Annotation annotations} identified by a
+ * {@link LayerKey}. The container itself knows nothing about specific layers; every
+ * analysis capability contributes its results as one more layer without any change to
+ * this interface, which is what keeps new capabilities additive. All spans refer to
+ * {@link #text()} as supplied, never to a derived form.
+ *
+ *
Documents are immutable: {@link #with(LayerKey, List)} returns a new document that
+ * shares the unchanged layers. Instances are safe to share between threads.
+ *
+ * @since 3.0.0
+ */
+public interface Document {
+
+ /**
+ * Creates an empty {@link Document} over a text.
+ *
+ * @param text The original document text. Must not be {@code null}.
+ * @return A {@link Document} without any layers. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
+ static Document of(CharSequence text) {
+ return ImmutableDocument.empty(text);
+ }
+
+ /**
+ * @return The original text of the document. Never {@code null}.
+ */
+ CharSequence text();
+
+ /**
+ * Retrieves the annotations of one layer.
+ *
+ * @param layer The layer to read. Must not be {@code null}.
+ * @param The type of the layer's annotation values.
+ * @return The layer's annotations in their layer order, or an empty list when the
+ * layer is absent. Never {@code null}; the list is unmodifiable.
+ * @throws IllegalArgumentException Thrown if {@code layer} is {@code null}.
+ */
+ List> get(LayerKey layer);
+
+ /**
+ * @return The keys of all layers present on the document. Never {@code null}; the set
+ * is unmodifiable.
+ */
+ Set> layers();
+
+ /**
+ * Returns a new document with one layer added.
+ *
+ * @param layer The key of the layer to add. Must not be {@code null} and must not
+ * already be present.
+ * @param annotations The annotations of the layer. Must not be {@code null}, must not
+ * contain {@code null}, every value must be assignable to the
+ * layer's type, and every span must lie within the text bounds.
+ * @param The type of the layer's annotation values.
+ * @return A new {@link Document} sharing this document's text and existing layers.
+ * Never {@code null}.
+ * @throws IllegalArgumentException Thrown if any of the above constraints is violated.
+ */
+ Document with(LayerKey layer, List> annotations);
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnalyzer.java b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnalyzer.java
new file mode 100644
index 0000000000..4ca353ca1c
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnalyzer.java
@@ -0,0 +1,116 @@
+/*
+ * 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.document;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Runs a fixed sequence of {@link DocumentAnnotator annotators} over a text, producing
+ * one {@link Document} that carries every step's layers.
+ *
+ *
The pipeline is validated at build time: every annotator's required layers must be
+ * provided by an earlier annotator, so a misordered pipeline fails when it is assembled
+ * rather than midway through a document. The analyzer holds no per-call state; it is as
+ * thread-safe as the annotators it is built from.
+ *
+ * @since 3.0.0
+ */
+public final class DocumentAnalyzer {
+
+ private final List annotators;
+
+ private DocumentAnalyzer(List annotators) {
+ this.annotators = annotators;
+ }
+
+ /**
+ * @return A new {@link Builder}. Never {@code null}.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Analyzes a text by running every annotator in order.
+ *
+ * @param text The original document text. Must not be {@code null}.
+ * @return The annotated {@link Document}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
+ public Document analyze(CharSequence text) {
+ Document document = Document.of(text);
+ for (final DocumentAnnotator annotator : annotators) {
+ document = annotator.annotate(document);
+ }
+ return document;
+ }
+
+ /**
+ * Assembles a {@link DocumentAnalyzer} from annotators in execution order.
+ */
+ public static final class Builder {
+
+ private final List annotators = new ArrayList<>();
+
+ private Builder() {
+ }
+
+ /**
+ * Appends an annotator to the pipeline.
+ *
+ * @param annotator The annotator to run after the ones already added. Must not be
+ * {@code null}.
+ * @return This {@link Builder}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code annotator} is {@code null}.
+ */
+ public Builder add(DocumentAnnotator annotator) {
+ if (annotator == null) {
+ throw new IllegalArgumentException("annotator must not be null");
+ }
+ annotators.add(annotator);
+ return this;
+ }
+
+ /**
+ * Validates the pipeline and builds the analyzer.
+ *
+ * @return A {@link DocumentAnalyzer}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if the pipeline is empty or an annotator
+ * requires a layer no earlier annotator provides.
+ */
+ public DocumentAnalyzer build() {
+ if (annotators.isEmpty()) {
+ throw new IllegalArgumentException("a pipeline needs at least one annotator");
+ }
+ final Set> available = new HashSet<>();
+ for (final DocumentAnnotator annotator : annotators) {
+ for (final LayerKey> required : annotator.requires()) {
+ if (!available.contains(required)) {
+ throw new IllegalArgumentException("annotator " + annotator
+ + " requires layer " + required + ", which no earlier annotator provides");
+ }
+ }
+ available.addAll(annotator.provides());
+ }
+ return new DocumentAnalyzer(List.copyOf(annotators));
+ }
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java
new file mode 100644
index 0000000000..9ea2c65a4d
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java
@@ -0,0 +1,59 @@
+/*
+ * 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.document;
+
+import java.util.Set;
+
+/**
+ * A pipeline step that reads layers from a {@link Document} and returns a new document
+ * with its own layers added.
+ *
+ *
An annotator declares the layers it {@link #requires()} and {@link #provides()}, so
+ * a {@link DocumentAnalyzer} can validate a pipeline before running it. Annotators are
+ * usually thin adapters over an existing analysis component and should hold no per-call
+ * state, so one instance can serve concurrent pipelines when the wrapped component
+ * allows it.
+ *
+ * @since 3.0.0
+ */
+public interface DocumentAnnotator {
+
+ /**
+ * Annotates a document.
+ *
+ * @param document The document to annotate. Must not be {@code null} and must contain
+ * every layer named by {@link #requires()}.
+ * @return A new {@link Document} carrying the layers named by {@link #provides()} in
+ * addition to the input layers. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code document} is {@code null} or lacks
+ * a required layer.
+ */
+ Document annotate(Document document);
+
+ /**
+ * @return The keys of the layers this annotator reads. Never {@code null}.
+ */
+ default Set> requires() {
+ return Set.of();
+ }
+
+ /**
+ * @return The keys of the layers this annotator adds. Never {@code null}.
+ */
+ Set> provides();
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
new file mode 100644
index 0000000000..2ec407ee3e
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
@@ -0,0 +1,107 @@
+/*
+ * 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.document;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import opennlp.tools.util.Span;
+
+/**
+ * The default {@link Document} implementation: an unmodifiable map from layer key to an
+ * unmodifiable annotation list. Adding a layer copies the map, not the layers, so
+ * documents grown from a common ancestor share their layer lists.
+ */
+final class ImmutableDocument implements Document {
+
+ private final CharSequence text;
+ private final Map, List>> layers;
+
+ private ImmutableDocument(CharSequence text, Map, List>> layers) {
+ this.text = text;
+ this.layers = layers;
+ }
+
+ /**
+ * Creates a document without any layers.
+ *
+ * @param text The original document text. Must not be {@code null}.
+ * @return An empty {@link ImmutableDocument}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
+ static ImmutableDocument empty(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("text must not be null");
+ }
+ return new ImmutableDocument(text, Collections.emptyMap());
+ }
+
+ @Override
+ public CharSequence text() {
+ return text;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public List> get(LayerKey layer) {
+ if (layer == null) {
+ throw new IllegalArgumentException("layer must not be null");
+ }
+ final List> annotations = layers.get(layer);
+ if (annotations == null) {
+ return List.of();
+ }
+ // safe: with(LayerKey, List) verified every value against the key's type on insertion
+ return (List>) (List>) annotations;
+ }
+
+ @Override
+ public Set> layers() {
+ return Collections.unmodifiableSet(layers.keySet());
+ }
+
+ @Override
+ public Document with(LayerKey layer, List> annotations) {
+ if (layer == null || annotations == null) {
+ throw new IllegalArgumentException("layer and annotations must not be null");
+ }
+ if (layers.containsKey(layer)) {
+ throw new IllegalArgumentException("layer is already present: " + layer);
+ }
+ for (final Annotation annotation : annotations) {
+ if (annotation == null) {
+ throw new IllegalArgumentException("annotations must not contain null: " + layer);
+ }
+ if (!layer.type().isInstance(annotation.value())) {
+ throw new IllegalArgumentException("value of type "
+ + annotation.value().getClass().getName() + " does not match layer " + layer);
+ }
+ final Span span = annotation.span();
+ if (span.getEnd() > text.length()) {
+ throw new IllegalArgumentException("span " + span + " exceeds the text length "
+ + text.length() + " in layer " + layer);
+ }
+ }
+ final Map, List>> grown = new LinkedHashMap<>(layers);
+ grown.put(layer, List.copyOf(annotations));
+ return new ImmutableDocument(text, Collections.unmodifiableMap(grown));
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
new file mode 100644
index 0000000000..458a61b4c4
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
@@ -0,0 +1,102 @@
+/*
+ * 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.document;
+
+import java.util.Objects;
+
+/**
+ * Identifies one annotation layer of a {@link Document} and carries the type of that
+ * layer's annotation values, so reading a layer back is statically typed.
+ *
+ *
The key space is deliberately open: any producer may define new keys in its own
+ * package, and the container never enumerates them. Two keys are equal when both their
+ * id and their value type are equal, so independently created constants for the same
+ * layer interoperate. Standard keys for the toolkit's own results live in
+ * {@link Layers}.
+ *
+ * @param The type of the annotation values stored under this key.
+ *
+ * @since 3.0.0
+ */
+public final class LayerKey {
+
+ private final String id;
+ private final Class type;
+
+ private LayerKey(String id, Class type) {
+ this.id = id;
+ this.type = type;
+ }
+
+ /**
+ * Creates a {@link LayerKey}.
+ *
+ * @param id The layer identifier, for example {@code tokens}. Must not be {@code null}
+ * or blank.
+ * @param type The class of the annotation values stored under the key. Must not be
+ * {@code null}.
+ * @param The type of the annotation values.
+ * @return A {@link LayerKey}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code id} is {@code null} or blank, or
+ * {@code type} is {@code null}.
+ */
+ public static LayerKey of(String id, Class type) {
+ if (id == null || id.isBlank()) {
+ throw new IllegalArgumentException("id must not be null or blank");
+ }
+ if (type == null) {
+ throw new IllegalArgumentException("type must not be null");
+ }
+ return new LayerKey<>(id, type);
+ }
+
+ /**
+ * @return The layer identifier. Never {@code null}.
+ */
+ public String id() {
+ return id;
+ }
+
+ /**
+ * @return The class of the annotation values stored under this key. Never {@code null}.
+ */
+ public Class type() {
+ return type;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof LayerKey> other)) {
+ return false;
+ }
+ return id.equals(other.id) && type.equals(other.type);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, type);
+ }
+
+ @Override
+ public String toString() {
+ return id + '<' + type.getSimpleName() + '>';
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
new file mode 100644
index 0000000000..9ff47cd2c3
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
@@ -0,0 +1,55 @@
+/*
+ * 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.document;
+
+/**
+ * The standard {@link LayerKey layer keys} for the results the toolkit produces itself.
+ *
+ *
This class is a convenience, not a registry: the key space stays open, and any
+ * producer may define further keys in its own package. New capabilities must never
+ * require an addition here to function.
+ *
+ * @since 3.0.0
+ */
+public final class Layers {
+
+ /**
+ * Sentence boundaries; each annotation covers one sentence and carries its text.
+ */
+ public static final LayerKey SENTENCES = LayerKey.of("sentences", String.class);
+
+ /**
+ * Token boundaries; each annotation covers one token and carries its text.
+ */
+ public static final LayerKey TOKENS = LayerKey.of("tokens", String.class);
+
+ /**
+ * Part-of-speech tags; one annotation per token, aligned with {@link #TOKENS} by
+ * position, carrying the tag.
+ */
+ public static final LayerKey POS_TAGS = LayerKey.of("pos", String.class);
+
+ /**
+ * Named entities; each annotation covers one mention and carries the entity type.
+ */
+ public static final LayerKey ENTITIES = LayerKey.of("entities", String.class);
+
+ private Layers() {
+ // constants only
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
new file mode 100644
index 0000000000..f5bfecdaf8
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
@@ -0,0 +1,88 @@
+/*
+ * 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.document;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import opennlp.tools.namefind.TokenNameFinder;
+import opennlp.tools.util.Span;
+
+/**
+ * Adapts a {@link TokenNameFinder} to the document pipeline: reads {@link Layers#TOKENS},
+ * maps the finder's token-index spans to character spans on the original text, and
+ * provides {@link Layers#ENTITIES} carrying the entity type.
+ *
+ *
The finder's adaptive data is cleared after each document, so document order does
+ * not leak between pipeline runs.
+ *
+ * @since 3.0.0
+ */
+public class NameFinderAnnotator implements DocumentAnnotator {
+
+ private final TokenNameFinder finder;
+
+ /**
+ * Initializes the adapter.
+ *
+ * @param finder The name finder to delegate to. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code finder} is {@code null}.
+ */
+ public NameFinderAnnotator(TokenNameFinder finder) {
+ if (finder == null) {
+ throw new IllegalArgumentException("finder must not be null");
+ }
+ this.finder = finder;
+ }
+
+ @Override
+ public Document annotate(Document document) {
+ if (document == null) {
+ throw new IllegalArgumentException("document must not be null");
+ }
+ final List> tokens = document.get(Layers.TOKENS);
+ if (tokens.isEmpty()) {
+ throw new IllegalArgumentException("document lacks the required layer "
+ + Layers.TOKENS);
+ }
+ final String[] words = new String[tokens.size()];
+ for (int i = 0; i < words.length; i++) {
+ words[i] = tokens.get(i).value();
+ }
+ final List> entities = new ArrayList<>();
+ for (final Span mention : finder.find(words)) {
+ final int start = tokens.get(mention.getStart()).span().getStart();
+ final int end = tokens.get(mention.getEnd() - 1).span().getEnd();
+ final String type = mention.getType() == null ? "default" : mention.getType();
+ entities.add(new Annotation<>(new Span(start, end, type), type));
+ }
+ finder.clearAdaptiveData();
+ return document.with(Layers.ENTITIES, entities);
+ }
+
+ @Override
+ public Set> requires() {
+ return Set.of(Layers.TOKENS);
+ }
+
+ @Override
+ public Set> provides() {
+ return Set.of(Layers.ENTITIES);
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
new file mode 100644
index 0000000000..1234b6803d
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.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.document;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import opennlp.tools.postag.POSTagger;
+
+/**
+ * Adapts a {@link POSTagger} to the document pipeline: reads {@link Layers#TOKENS} and
+ * provides {@link Layers#POS_TAGS}, one tag annotation per token on the token's span.
+ *
+ * @since 3.0.0
+ */
+public class POSTaggerAnnotator implements DocumentAnnotator {
+
+ private final POSTagger tagger;
+
+ /**
+ * Initializes the adapter.
+ *
+ * @param tagger The tagger to delegate to. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code tagger} is {@code null}.
+ */
+ public POSTaggerAnnotator(POSTagger tagger) {
+ if (tagger == null) {
+ throw new IllegalArgumentException("tagger must not be null");
+ }
+ this.tagger = tagger;
+ }
+
+ @Override
+ public Document annotate(Document document) {
+ if (document == null) {
+ throw new IllegalArgumentException("document must not be null");
+ }
+ final List> tokens = document.get(Layers.TOKENS);
+ if (tokens.isEmpty()) {
+ throw new IllegalArgumentException("document lacks the required layer "
+ + Layers.TOKENS);
+ }
+ final String[] words = new String[tokens.size()];
+ for (int i = 0; i < words.length; i++) {
+ words[i] = tokens.get(i).value();
+ }
+ final String[] tags = tagger.tag(words);
+ final List> tagAnnotations = new ArrayList<>(tags.length);
+ for (int i = 0; i < tags.length; i++) {
+ tagAnnotations.add(new Annotation<>(tokens.get(i).span(), tags[i]));
+ }
+ return document.with(Layers.POS_TAGS, tagAnnotations);
+ }
+
+ @Override
+ public Set> requires() {
+ return Set.of(Layers.TOKENS);
+ }
+
+ @Override
+ public Set> provides() {
+ return Set.of(Layers.POS_TAGS);
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
new file mode 100644
index 0000000000..47e7c58c34
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.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.document;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import opennlp.tools.sentdetect.SentenceDetector;
+import opennlp.tools.util.Span;
+
+/**
+ * Adapts a {@link SentenceDetector} to the document pipeline: provides
+ * {@link Layers#SENTENCES} from the document text.
+ *
+ *
The wrapped detector stays the primary API for single-task use; this adapter calls
+ * it like any other caller would.
+ *
+ * @since 3.0.0
+ */
+public class SentenceDetectorAnnotator implements DocumentAnnotator {
+
+ private final SentenceDetector detector;
+
+ /**
+ * Initializes the adapter.
+ *
+ * @param detector The sentence detector to delegate to. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code detector} is {@code null}.
+ */
+ public SentenceDetectorAnnotator(SentenceDetector detector) {
+ if (detector == null) {
+ throw new IllegalArgumentException("detector must not be null");
+ }
+ this.detector = detector;
+ }
+
+ @Override
+ public Document annotate(Document document) {
+ if (document == null) {
+ throw new IllegalArgumentException("document must not be null");
+ }
+ final CharSequence text = document.text();
+ final List> sentences = new ArrayList<>();
+ for (final Span span : detector.sentPosDetect(text)) {
+ sentences.add(new Annotation<>(span, span.getCoveredText(text).toString()));
+ }
+ return document.with(Layers.SENTENCES, sentences);
+ }
+
+ @Override
+ public Set> provides() {
+ return Set.of(Layers.SENTENCES);
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
new file mode 100644
index 0000000000..625410a181
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
@@ -0,0 +1,84 @@
+/*
+ * 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.document;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import opennlp.tools.tokenize.Tokenizer;
+import opennlp.tools.util.Span;
+
+/**
+ * Adapts a {@link Tokenizer} to the document pipeline: provides {@link Layers#TOKENS}.
+ *
+ *
When {@link Layers#SENTENCES} is present, each sentence is tokenized separately and
+ * the token spans are shifted back to document coordinates; otherwise the whole text is
+ * tokenized at once. Either way, every token span refers to the original document
+ * text.
+ *
+ * @since 3.0.0
+ */
+public class TokenizerAnnotator implements DocumentAnnotator {
+
+ private final Tokenizer tokenizer;
+
+ /**
+ * Initializes the adapter.
+ *
+ * @param tokenizer The tokenizer to delegate to. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code tokenizer} is {@code null}.
+ */
+ public TokenizerAnnotator(Tokenizer tokenizer) {
+ if (tokenizer == null) {
+ throw new IllegalArgumentException("tokenizer must not be null");
+ }
+ this.tokenizer = tokenizer;
+ }
+
+ @Override
+ public Document annotate(Document document) {
+ if (document == null) {
+ throw new IllegalArgumentException("document must not be null");
+ }
+ final String text = document.text().toString();
+ final List> tokens = new ArrayList<>();
+ final List> sentences = document.get(Layers.SENTENCES);
+ if (sentences.isEmpty()) {
+ addTokens(tokens, text, 0);
+ } else {
+ for (final Annotation sentence : sentences) {
+ final Span span = sentence.span();
+ addTokens(tokens, text.substring(span.getStart(), span.getEnd()), span.getStart());
+ }
+ }
+ return document.with(Layers.TOKENS, tokens);
+ }
+
+ private void addTokens(List> tokens, String text, int offset) {
+ for (final Span span : tokenizer.tokenizePos(text)) {
+ final Span shifted = new Span(span.getStart() + offset, span.getEnd() + offset);
+ tokens.add(new Annotation<>(shifted, span.getCoveredText(text).toString()));
+ }
+ }
+
+ @Override
+ public Set> provides() {
+ return Set.of(Layers.TOKENS);
+ }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
new file mode 100644
index 0000000000..534fe115c4
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
@@ -0,0 +1,194 @@
+/*
+ * 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.document;
+
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.postag.POSTagger;
+import opennlp.tools.sentdetect.SentenceDetector;
+import opennlp.tools.tokenize.Tokenizer;
+import opennlp.tools.util.Span;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests the {@link DocumentAnalyzer} pipeline over the adapter annotators, using simple
+ * inline implementations of the task interfaces: whitespace tokenization, period sentence
+ * splitting, and a dictionary tagger. The point under test is the pipeline mechanics and
+ * span arithmetic, not model quality.
+ */
+public class DocumentAnalyzerTest {
+
+ private static final SentenceDetector SPLITTER = new SentenceDetector() {
+
+ @Override
+ public String[] sentDetect(CharSequence s) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Span[] sentPosDetect(CharSequence s) {
+ // split after each period; keep it simple for the test
+ final String text = s.toString();
+ int start = 0;
+ final java.util.List spans = new java.util.ArrayList<>();
+ for (int i = 0; i < text.length(); i++) {
+ if (text.charAt(i) == '.') {
+ spans.add(new Span(start, i + 1));
+ start = i + 2;
+ }
+ }
+ return spans.toArray(new Span[0]);
+ }
+ };
+
+ private static final Tokenizer WHITESPACE = new Tokenizer() {
+
+ @Override
+ public String[] tokenize(String s) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Span[] tokenizePos(String s) {
+ final java.util.List spans = new java.util.ArrayList<>();
+ int start = -1;
+ for (int i = 0; i <= s.length(); i++) {
+ final boolean boundary = i == s.length() || s.charAt(i) == ' ';
+ if (boundary && start >= 0) {
+ spans.add(new Span(start, i));
+ start = -1;
+ } else if (!boundary && start < 0) {
+ start = i;
+ }
+ }
+ return spans.toArray(new Span[0]);
+ }
+ };
+
+ private static final POSTagger TAGGER = new POSTagger() {
+
+ @Override
+ public String[] tag(String[] sentence) {
+ final String[] tags = new String[sentence.length];
+ for (int i = 0; i < sentence.length; i++) {
+ tags[i] = "barks.".contains(sentence[i]) ? "VBZ" : "X";
+ }
+ return tags;
+ }
+
+ @Override
+ public String[] tag(String[] sentence, Object[] additionalContext) {
+ return tag(sentence);
+ }
+
+ @Override
+ public opennlp.tools.util.Sequence[] topKSequences(String[] sentence) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public opennlp.tools.util.Sequence[] topKSequences(String[] sentence,
+ Object[] additionalContext) {
+ throw new UnsupportedOperationException();
+ }
+ };
+
+ @Test
+ void testPipelineProducesAlignedLayersInOriginalCoordinates() {
+ final Document document = DocumentAnalyzer.builder()
+ .add(new SentenceDetectorAnnotator(SPLITTER))
+ .add(new TokenizerAnnotator(WHITESPACE))
+ .add(new POSTaggerAnnotator(TAGGER))
+ .build()
+ .analyze("the dog barks. she eats.");
+
+ final List> sentences = document.get(Layers.SENTENCES);
+ assertEquals(2, sentences.size());
+ assertEquals("she eats.", sentences.get(1).value());
+
+ final List> tokens = document.get(Layers.TOKENS);
+ assertEquals(5, tokens.size());
+ // token of the second sentence, span in document coordinates
+ assertEquals("she", tokens.get(3).value());
+ assertEquals(new Span(15, 18), tokens.get(3).span());
+
+ final List> tags = document.get(Layers.POS_TAGS);
+ assertEquals(5, tags.size());
+ assertEquals("VBZ", tags.get(2).value());
+ assertEquals(tokens.get(2).span(), tags.get(2).span());
+ }
+
+ @Test
+ void testTokenizerWorksWithoutSentences() {
+ final Document document = DocumentAnalyzer.builder()
+ .add(new TokenizerAnnotator(WHITESPACE))
+ .build()
+ .analyze("the dog");
+ assertEquals(2, document.get(Layers.TOKENS).size());
+ }
+
+ @Test
+ void testMisorderedPipelineFailsAtBuildTime() {
+ final DocumentAnalyzer.Builder builder = DocumentAnalyzer.builder()
+ .add(new POSTaggerAnnotator(TAGGER));
+ assertThrows(IllegalArgumentException.class, builder::build);
+ }
+
+ @Test
+ void testEmptyPipelineThrows() {
+ assertThrows(IllegalArgumentException.class, () -> DocumentAnalyzer.builder().build());
+ }
+
+ @Test
+ void testCustomLayerNeedsNoContainerChange() {
+ // the additive claim: a brand-new layer type works without touching the container
+ record Sentiment(String polarity, double score) {
+ }
+ final LayerKey sentiment = LayerKey.of("sentiment", Sentiment.class);
+ final DocumentAnnotator annotator = new DocumentAnnotator() {
+
+ @Override
+ public Document annotate(Document document) {
+ final Span all = new Span(0, document.text().length());
+ return document.with(sentiment,
+ List.of(new Annotation<>(all, new Sentiment("positive", 0.9d))));
+ }
+
+ @Override
+ public Set> provides() {
+ return Set.of(sentiment);
+ }
+ };
+ final Document document = DocumentAnalyzer.builder().add(annotator).build()
+ .analyze("good dog");
+ assertEquals("positive", document.get(sentiment).get(0).value().polarity());
+ }
+
+ @Test
+ void testAnnotatorAdaptersRejectNullDelegates() {
+ assertThrows(IllegalArgumentException.class, () -> new SentenceDetectorAnnotator(null));
+ assertThrows(IllegalArgumentException.class, () -> new TokenizerAnnotator(null));
+ assertThrows(IllegalArgumentException.class, () -> new POSTaggerAnnotator(null));
+ assertThrows(IllegalArgumentException.class, () -> new NameFinderAnnotator(null));
+ }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java
new file mode 100644
index 0000000000..5af4908bc8
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.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.document;
+
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.util.Span;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests the {@link Document} container: typed layer access, copy-on-add immutability,
+ * and the insertion-time validation that protects the layer invariants.
+ */
+public class DocumentTest {
+
+ private static final LayerKey WORDS = LayerKey.of("words", String.class);
+ private static final LayerKey NUMBERS = LayerKey.of("numbers", Integer.class);
+
+ @Test
+ void testEmptyDocument() {
+ final Document document = Document.of("the dog");
+ assertEquals("the dog", document.text());
+ assertTrue(document.layers().isEmpty());
+ assertTrue(document.get(WORDS).isEmpty());
+ }
+
+ @Test
+ void testWithAddsATypedLayer() {
+ final Document document = Document.of("the dog")
+ .with(WORDS, List.of(new Annotation<>(new Span(0, 3), "the"),
+ new Annotation<>(new Span(4, 7), "dog")));
+ assertEquals(Set.of(WORDS), document.layers());
+ final List> words = document.get(WORDS);
+ assertEquals(2, words.size());
+ assertEquals("dog", words.get(1).value());
+ assertEquals(new Span(4, 7), words.get(1).span());
+ }
+
+ @Test
+ void testWithIsCopyOnAdd() {
+ final Document empty = Document.of("42");
+ final Document grown = empty.with(NUMBERS,
+ List.of(new Annotation<>(new Span(0, 2), 42)));
+ assertTrue(empty.layers().isEmpty());
+ assertEquals(Set.of(NUMBERS), grown.layers());
+ // unchanged layers are shared, not copied
+ final Document both = grown.with(WORDS, List.of());
+ assertSame(grown.get(NUMBERS), both.get(NUMBERS));
+ }
+
+ @Test
+ void testEqualKeysFromDifferentConstantsInteroperate() {
+ final Document document = Document.of("the")
+ .with(LayerKey.of("words", String.class),
+ List.of(new Annotation<>(new Span(0, 3), "the")));
+ assertEquals(1, document.get(WORDS).size());
+ assertNotEquals(WORDS, LayerKey.of("words", CharSequence.class));
+ }
+
+ @Test
+ void testDuplicateLayerThrows() {
+ final Document document = Document.of("the").with(WORDS, List.of());
+ assertThrows(IllegalArgumentException.class, () -> document.with(WORDS, List.of()));
+ }
+
+ @Test
+ void testSpanBeyondTextThrows() {
+ assertThrows(IllegalArgumentException.class, () -> Document.of("the")
+ .with(WORDS, List.of(new Annotation<>(new Span(0, 4), "the?"))));
+ }
+
+ @Test
+ void testValueTypeIsCheckedOnInsertion() {
+ // a raw-typed caller cannot smuggle a mismatched value past the layer type
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ final LayerKey
*
* @since 3.0.0
*/
@@ -93,22 +94,30 @@ public Builder add(DocumentAnnotator annotator) {
* Validates the pipeline and builds the analyzer.
*
* @return A {@link DocumentAnalyzer}. Never {@code null}.
- * @throws IllegalArgumentException Thrown if the pipeline is empty or an annotator
- * requires a layer no earlier annotator provides.
+ * @throws IllegalArgumentException Thrown if the pipeline is empty, an annotator
+ * requires a layer no earlier annotator provides, or two annotators provide
+ * the same layer.
*/
public DocumentAnalyzer build() {
if (annotators.isEmpty()) {
throw new IllegalArgumentException("a pipeline needs at least one annotator");
}
- final Set> available = new HashSet<>();
- for (final DocumentAnnotator annotator : annotators) {
+ final Map, Integer> providers = new HashMap<>();
+ for (int position = 0; position < annotators.size(); position++) {
+ final DocumentAnnotator annotator = annotators.get(position);
for (final LayerKey> required : annotator.requires()) {
- if (!available.contains(required)) {
+ if (!providers.containsKey(required)) {
throw new IllegalArgumentException("annotator " + annotator
+ " requires layer " + required + ", which no earlier annotator provides");
}
}
- available.addAll(annotator.provides());
+ for (final LayerKey> provided : annotator.provides()) {
+ final Integer earlier = providers.putIfAbsent(provided, position);
+ if (earlier != null) {
+ throw new IllegalArgumentException("annotators at positions " + earlier
+ + " and " + position + " both provide layer " + provided);
+ }
+ }
}
return new DocumentAnalyzer(List.copyOf(annotators));
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java
index 9ea2c65a4d..3f44a6d856 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java
@@ -36,6 +36,10 @@ public interface DocumentAnnotator {
/**
* Annotates a document.
*
+ *
A required layer must be present on the document, but it may be empty: an empty
+ * required layer is valid input and yields the annotator's provided layers present but
+ * empty, so a pipeline degrades gracefully on documents without content.
+ *
* @param document The document to annotate. Must not be {@code null} and must contain
* every layer named by {@link #requires()}.
* @return A new {@link Document} carrying the layers named by {@link #provides()} in
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
index f5bfecdaf8..3902d41867 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
@@ -25,17 +25,32 @@
import opennlp.tools.util.Span;
/**
- * Adapts a {@link TokenNameFinder} to the document pipeline: reads {@link Layers#TOKENS},
- * maps the finder's token-index spans to character spans on the original text, and
- * provides {@link Layers#ENTITIES} carrying the entity type.
+ * Adapts a {@link TokenNameFinder} to the document pipeline: reads
+ * {@link Layers#SENTENCES} and {@link Layers#TOKENS}, maps the finder's token-index
+ * spans to character spans on the original text, and provides {@link Layers#ENTITIES}
+ * carrying the entity type.
*
- *
The finder's adaptive data is cleared after each document, so document order does
- * not leak between pipeline runs.
+ *
Each sentence's tokens are passed to {@link TokenNameFinder#find(String[])} as one
+ * sequence, the way the finder contract expects its input, so no mention can straddle a
+ * sentence boundary. The finder's adaptive data is cleared once after all sentences of a
+ * document are processed, as the {@link TokenNameFinder#clearAdaptiveData()} contract
+ * asks, so document order does not leak between pipeline runs.
+ *
+ *
Spans the finder returns without a type are recorded with the {@link #UNTYPED}
+ * entity type.
*
* @since 3.0.0
*/
public class NameFinderAnnotator implements DocumentAnnotator {
+ /**
+ * The entity type recorded when the wrapped finder returns a span without a type. It
+ * equals {@link opennlp.tools.namefind.NameSample#DEFAULT_TYPE}. Type-aware consumers
+ * should treat this label as an unknown type rather than as a distinct one, since it
+ * carries no information about what kind of entity was found.
+ */
+ public static final String UNTYPED = "default";
+
private final TokenNameFinder finder;
/**
@@ -51,26 +66,74 @@ public NameFinderAnnotator(TokenNameFinder finder) {
this.finder = finder;
}
+ /**
+ * Finds names sentence by sentence and adds the {@link Layers#ENTITIES} layer.
+ *
+ *
For every sentence, the tokens whose spans lie inside the sentence span are
+ * passed to the finder as one sequence, and each sentence-local mention is mapped
+ * through the sentence's first token position onto character spans of the original
+ * text. The required layers must be present, but they may be empty: a document
+ * without sentences or tokens yields a present-but-empty entity layer, and a sentence
+ * containing no tokens contributes nothing. A mention without a type is recorded with
+ * the type {@link #UNTYPED}.
+ *
+ * @param document The document to annotate. Must not be {@code null} and must carry
+ * the {@link Layers#SENTENCES} and {@link Layers#TOKENS} layers, with
+ * every token lying inside a sentence.
+ * @return A new {@link Document} with the {@link Layers#ENTITIES} layer added. Never
+ * {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code document} is {@code null}, the
+ * sentence layer or the token layer is absent, or a token lies outside every
+ * sentence.
+ */
@Override
public Document annotate(Document document) {
if (document == null) {
throw new IllegalArgumentException("document must not be null");
}
- final List> tokens = document.get(Layers.TOKENS);
- if (tokens.isEmpty()) {
+ final Set> present = document.layers();
+ if (!present.contains(Layers.SENTENCES)) {
throw new IllegalArgumentException("document lacks the required layer "
- + Layers.TOKENS);
+ + Layers.SENTENCES);
}
- final String[] words = new String[tokens.size()];
- for (int i = 0; i < words.length; i++) {
- words[i] = tokens.get(i).value();
+ if (!present.contains(Layers.TOKENS)) {
+ throw new IllegalArgumentException("document lacks the required layer "
+ + Layers.TOKENS);
}
+ final List> sentences = document.get(Layers.SENTENCES);
+ final List> tokens = document.get(Layers.TOKENS);
final List> entities = new ArrayList<>();
- for (final Span mention : finder.find(words)) {
- final int start = tokens.get(mention.getStart()).span().getStart();
- final int end = tokens.get(mention.getEnd() - 1).span().getEnd();
- final String type = mention.getType() == null ? "default" : mention.getType();
- entities.add(new Annotation<>(new Span(start, end, type), type));
+ // Walk the token layer once: both layers are in text order, so each sentence
+ // consumes the contiguous run of tokens whose spans it encloses.
+ int next = 0;
+ for (final Annotation sentence : sentences) {
+ final int first = next;
+ while (next < tokens.size()
+ && tokens.get(next).span().getStart() >= sentence.span().getStart()
+ && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
+ next++;
+ }
+ final int count = next - first;
+ if (count == 0) {
+ continue;
+ }
+ final String[] words = new String[count];
+ for (int i = 0; i < count; i++) {
+ words[i] = tokens.get(first + i).value();
+ }
+ // The finder indexes within the sentence; shifting by the sentence's first token
+ // position turns every mention boundary into a document-wide token index, whose
+ // token spans already refer to the original text.
+ for (final Span mention : finder.find(words)) {
+ final int start = tokens.get(first + mention.getStart()).span().getStart();
+ final int end = tokens.get(first + mention.getEnd() - 1).span().getEnd();
+ final String type = mention.getType() == null ? UNTYPED : mention.getType();
+ entities.add(new Annotation<>(new Span(start, end, type), type));
+ }
+ }
+ if (next != tokens.size()) {
+ throw new IllegalArgumentException("token at " + tokens.get(next).span()
+ + " lies outside every sentence");
}
finder.clearAdaptiveData();
return document.with(Layers.ENTITIES, entities);
@@ -78,7 +141,7 @@ public Document annotate(Document document) {
@Override
public Set> requires() {
- return Set.of(Layers.TOKENS);
+ return Set.of(Layers.SENTENCES, Layers.TOKENS);
}
@Override
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
index 1234b6803d..478f210b47 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
@@ -24,8 +24,15 @@
import opennlp.tools.postag.POSTagger;
/**
- * Adapts a {@link POSTagger} to the document pipeline: reads {@link Layers#TOKENS} and
- * provides {@link Layers#POS_TAGS}, one tag annotation per token on the token's span.
+ * Adapts a {@link POSTagger} to the document pipeline: reads {@link Layers#SENTENCES}
+ * and {@link Layers#TOKENS} and provides {@link Layers#POS_TAGS}, one tag annotation per
+ * token on the token's span.
+ *
+ *
Each sentence is tagged separately, the way the tagger contract expects its input,
+ * so tagging decisions never cross a sentence boundary. Token spans already refer to the
+ * original document text, so only the token sequence handed to the tagger is sliced per
+ * sentence; the produced tag layer stays aligned with {@link Layers#TOKENS} by
+ * position.
*
* @since 3.0.0
*/
@@ -46,31 +53,74 @@ public POSTaggerAnnotator(POSTagger tagger) {
this.tagger = tagger;
}
+ /**
+ * Tags the document sentence by sentence and adds the {@link Layers#POS_TAGS} layer.
+ *
+ *
For every sentence, the tokens whose spans lie inside the sentence span are
+ * tagged as one sequence, and each tag is emitted on its token's span. The required
+ * layers must be present, but they may be empty: a document without sentences or
+ * tokens yields a present-but-empty tag layer, and a sentence containing no tokens
+ * contributes nothing.
+ *
+ * @param document The document to annotate. Must not be {@code null} and must carry
+ * the {@link Layers#SENTENCES} and {@link Layers#TOKENS} layers, with
+ * every token lying inside a sentence.
+ * @return A new {@link Document} with the {@link Layers#POS_TAGS} layer added. Never
+ * {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code document} is {@code null}, the
+ * sentence layer or the token layer is absent, or a token lies outside every
+ * sentence.
+ */
@Override
public Document annotate(Document document) {
if (document == null) {
throw new IllegalArgumentException("document must not be null");
}
- final List> tokens = document.get(Layers.TOKENS);
- if (tokens.isEmpty()) {
+ final Set> present = document.layers();
+ if (!present.contains(Layers.SENTENCES)) {
+ throw new IllegalArgumentException("document lacks the required layer "
+ + Layers.SENTENCES);
+ }
+ if (!present.contains(Layers.TOKENS)) {
throw new IllegalArgumentException("document lacks the required layer "
+ Layers.TOKENS);
}
- final String[] words = new String[tokens.size()];
- for (int i = 0; i < words.length; i++) {
- words[i] = tokens.get(i).value();
+ final List> sentences = document.get(Layers.SENTENCES);
+ final List> tokens = document.get(Layers.TOKENS);
+ final List> tagAnnotations = new ArrayList<>(tokens.size());
+ // Walk the token layer once: both layers are in text order, so each sentence
+ // consumes the contiguous run of tokens whose spans it encloses.
+ int next = 0;
+ for (final Annotation sentence : sentences) {
+ final int first = next;
+ while (next < tokens.size()
+ && tokens.get(next).span().getStart() >= sentence.span().getStart()
+ && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
+ next++;
+ }
+ final int count = next - first;
+ if (count == 0) {
+ continue;
+ }
+ final String[] words = new String[count];
+ for (int i = 0; i < count; i++) {
+ words[i] = tokens.get(first + i).value();
+ }
+ final String[] tags = tagger.tag(words);
+ for (int i = 0; i < count; i++) {
+ tagAnnotations.add(new Annotation<>(tokens.get(first + i).span(), tags[i]));
+ }
}
- final String[] tags = tagger.tag(words);
- final List> tagAnnotations = new ArrayList<>(tags.length);
- for (int i = 0; i < tags.length; i++) {
- tagAnnotations.add(new Annotation<>(tokens.get(i).span(), tags[i]));
+ if (next != tokens.size()) {
+ throw new IllegalArgumentException("token at " + tokens.get(next).span()
+ + " lies outside every sentence");
}
return document.with(Layers.POS_TAGS, tagAnnotations);
}
@Override
public Set> requires() {
- return Set.of(Layers.TOKENS);
+ return Set.of(Layers.SENTENCES, Layers.TOKENS);
}
@Override
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
index 625410a181..5c21111a65 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
@@ -28,9 +28,10 @@
* Adapts a {@link Tokenizer} to the document pipeline: provides {@link Layers#TOKENS}.
*
*
When {@link Layers#SENTENCES} is present, each sentence is tokenized separately and
- * the token spans are shifted back to document coordinates; otherwise the whole text is
- * tokenized at once. Either way, every token span refers to the original document
- * text.
+ * the token spans are shifted back to document coordinates; a present-but-empty sentence
+ * layer therefore yields a present-but-empty token layer. Only when the sentence layer
+ * is absent is the whole text tokenized at once. Either way, every token span refers to
+ * the original document text.
*
* @since 3.0.0
*/
@@ -58,11 +59,10 @@ public Document annotate(Document document) {
}
final String text = document.text().toString();
final List> tokens = new ArrayList<>();
- final List> sentences = document.get(Layers.SENTENCES);
- if (sentences.isEmpty()) {
+ if (!document.layers().contains(Layers.SENTENCES)) {
addTokens(tokens, text, 0);
} else {
- for (final Annotation sentence : sentences) {
+ for (final Annotation sentence : document.get(Layers.SENTENCES)) {
final Span span = sentence.span();
addTokens(tokens, text.substring(span.getStart(), span.getEnd()), span.getStart());
}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
index 534fe115c4..deb317640f 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
@@ -22,6 +22,7 @@
import org.junit.jupiter.api.Test;
+import opennlp.tools.namefind.TokenNameFinder;
import opennlp.tools.postag.POSTagger;
import opennlp.tools.sentdetect.SentenceDetector;
import opennlp.tools.tokenize.Tokenizer;
@@ -29,6 +30,7 @@
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 {@link DocumentAnalyzer} pipeline over the adapter annotators, using simple
@@ -138,6 +140,53 @@ void testPipelineProducesAlignedLayersInOriginalCoordinates() {
assertEquals(tokens.get(2).span(), tags.get(2).span());
}
+ /**
+ * Verifies that a full pipeline over empty and whitespace-only input produces a
+ * document on which every provided layer is present and empty, rather than failing:
+ * zero sentences legitimately yield zero tokens, zero tags, and zero entities.
+ */
+ @Test
+ void testEmptyAndBlankInputProduceEmptyLayers() {
+ final TokenNameFinder finder = new TokenNameFinder() {
+
+ @Override
+ public Span[] find(String[] tokens) {
+ return new Span[0];
+ }
+
+ @Override
+ public void clearAdaptiveData() {
+ }
+ };
+ final DocumentAnalyzer analyzer = DocumentAnalyzer.builder()
+ .add(new SentenceDetectorAnnotator(SPLITTER))
+ .add(new TokenizerAnnotator(WHITESPACE))
+ .add(new POSTaggerAnnotator(TAGGER))
+ .add(new NameFinderAnnotator(finder))
+ .build();
+
+ for (final String text : new String[] {"", " "}) {
+ final Document document = analyzer.analyze(text);
+ assertEquals(Set.of(Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS, Layers.ENTITIES),
+ document.layers());
+ for (final LayerKey> layer : document.layers()) {
+ assertTrue(document.get(layer).isEmpty());
+ }
+ }
+ }
+
+ /**
+ * Verifies that a present-but-empty sentence layer is honored as "no sentences": the
+ * tokenizer adds a present-but-empty token layer instead of tokenizing the whole text.
+ */
+ @Test
+ void testTokenizerHonorsPresentButEmptySentenceLayer() {
+ final Document document = new TokenizerAnnotator(WHITESPACE)
+ .annotate(Document.of("the dog").with(Layers.SENTENCES, List.of()));
+ assertTrue(document.layers().contains(Layers.TOKENS));
+ assertTrue(document.get(Layers.TOKENS).isEmpty());
+ }
+
@Test
void testTokenizerWorksWithoutSentences() {
final Document document = DocumentAnalyzer.builder()
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index c180b1bf9a..f46701ef12 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -238,6 +238,33 @@ public String toString() {
+ " which no earlier annotator provides", e.getMessage());
}
+ /**
+ * Verifies that an analyzer whose annotators would provide the same layer twice fails
+ * at build time with a message naming the layer and the positions of both providers,
+ * instead of crashing midway through the first document.
+ */
+ @Test
+ void testDuplicateProviderFailsAtBuildTimeWithExactMessage() {
+ final DocumentAnnotator provider = new DocumentAnnotator() {
+
+ @Override
+ public Document annotate(Document document) {
+ throw new IllegalStateException("must never run; the pipeline must not build");
+ }
+
+ @Override
+ public Set> provides() {
+ return Set.of(WORDS);
+ }
+ };
+ final DocumentAnalyzer.Builder builder = DocumentAnalyzer.builder()
+ .add(provider).add(provider);
+ final IllegalArgumentException e =
+ assertThrows(IllegalArgumentException.class, builder::build);
+ assertEquals("annotators at positions 0 and 1 both provide layer words",
+ e.getMessage());
+ }
+
/**
* Verifies that building an analyzer without any annotator fails with a message
* stating that a pipeline needs at least one annotator.
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
index 38478ac900..971215d898 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
@@ -17,7 +17,9 @@
package opennlp.tools.document;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
@@ -27,6 +29,7 @@
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 that {@link NameFinderAnnotator} maps token-index mentions to character spans on
@@ -52,6 +55,8 @@ public void clearAdaptiveData() {
};
final Document document = Document.of("in New York today")
+ .with(Layers.SENTENCES, List.of(
+ new Annotation<>(new Span(0, 17), "in New York today")))
.with(Layers.TOKENS, List.of(
new Annotation<>(new Span(0, 2), "in"),
new Annotation<>(new Span(3, 6), "New"),
@@ -84,4 +89,129 @@ public void clearAdaptiveData() {
assertThrows(IllegalArgumentException.class,
() -> new NameFinderAnnotator(finder).annotate(Document.of("no tokens")));
}
+
+ /**
+ * Verifies that the finder is invoked once per sentence with exactly that sentence's
+ * tokens, that sentence-local mention indices are mapped through the sentence's first
+ * token position into document character spans, and that the adaptive data is cleared
+ * exactly once after the whole document.
+ */
+ @Test
+ void testFindsPerSentenceAndMapsSentenceLocalIndices() {
+ final List> calls = new ArrayList<>();
+ final AtomicInteger cleared = new AtomicInteger();
+ final TokenNameFinder finder = new TokenNameFinder() {
+
+ @Override
+ public Span[] find(String[] tokens) {
+ calls.add(List.of(tokens));
+ // the first token of every sentence is a person mention, in sentence-local indices
+ return new Span[] {new Span(0, 1, "person")};
+ }
+
+ @Override
+ public void clearAdaptiveData() {
+ cleared.incrementAndGet();
+ }
+ };
+
+ final Document document = Document.of("Ana runs. Bob sits.")
+ .with(Layers.SENTENCES, List.of(
+ new Annotation<>(new Span(0, 9), "Ana runs."),
+ new Annotation<>(new Span(10, 19), "Bob sits.")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs."),
+ new Annotation<>(new Span(10, 13), "Bob"),
+ new Annotation<>(new Span(14, 19), "sits.")));
+
+ final Document annotated = new NameFinderAnnotator(finder).annotate(document);
+
+ assertEquals(List.of(
+ List.of("Ana", "runs."),
+ List.of("Bob", "sits.")), calls);
+
+ final List> entities = annotated.get(Layers.ENTITIES);
+ assertEquals(2, entities.size());
+ assertEquals(new Span(0, 3, "person"), entities.get(0).span());
+ assertEquals(new Span(10, 13, "person"), entities.get(1).span());
+ assertEquals("Bob",
+ entities.get(1).span().getCoveredText(annotated.text()).toString());
+ assertEquals(1, cleared.get());
+ }
+
+ /**
+ * Verifies that the annotator declares both the sentence layer and the token layer as
+ * required, so a pipeline without a sentence step fails at build time.
+ */
+ @Test
+ void testRequiresSentencesAndTokens() {
+ final TokenNameFinder finder = new TokenNameFinder() {
+
+ @Override
+ public Span[] find(String[] tokens) {
+ return new Span[0];
+ }
+
+ @Override
+ public void clearAdaptiveData() {
+ }
+ };
+ assertEquals(Set.of(Layers.SENTENCES, Layers.TOKENS),
+ new NameFinderAnnotator(finder).requires());
+ }
+
+ /**
+ * Verifies that present-but-empty sentence and token layers yield a present-but-empty
+ * entity layer without invoking the finder, rather than an exception.
+ */
+ @Test
+ void testEmptyPresentLayersYieldEmptyEntityLayer() {
+ final AtomicInteger found = new AtomicInteger();
+ final TokenNameFinder finder = new TokenNameFinder() {
+
+ @Override
+ public Span[] find(String[] tokens) {
+ found.incrementAndGet();
+ return new Span[0];
+ }
+
+ @Override
+ public void clearAdaptiveData() {
+ }
+ };
+ final Document document = Document.of("")
+ .with(Layers.SENTENCES, List.of())
+ .with(Layers.TOKENS, List.of());
+
+ final Document annotated = new NameFinderAnnotator(finder).annotate(document);
+
+ assertTrue(annotated.layers().contains(Layers.ENTITIES));
+ assertTrue(annotated.get(Layers.ENTITIES).isEmpty());
+ assertEquals(0, found.get());
+ }
+
+ /**
+ * Verifies that a document without a sentence layer is rejected with a message naming
+ * the missing layer.
+ */
+ @Test
+ void testAbsentSentenceLayerThrowsWithExactMessage() {
+ final TokenNameFinder finder = new TokenNameFinder() {
+
+ @Override
+ public Span[] find(String[] tokens) {
+ return new Span[0];
+ }
+
+ @Override
+ public void clearAdaptiveData() {
+ }
+ };
+ final Document document = Document.of("Ana")
+ .with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 3), "Ana")));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new NameFinderAnnotator(finder).annotate(document));
+ assertEquals("document lacks the required layer sentences", e.getMessage());
+ }
}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
new file mode 100644
index 0000000000..95779fdd36
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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.document;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.postag.POSTagger;
+import opennlp.tools.util.Sequence;
+import opennlp.tools.util.Span;
+
+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 that {@link POSTaggerAnnotator} tags one sentence per {@link POSTagger#tag(String[])}
+ * call, keeps the tag layer aligned with the token layer, distinguishes a present-but-empty
+ * required layer from an absent one, and rejects tokens outside every sentence.
+ */
+public class POSTaggerAnnotatorTest {
+
+ /**
+ * A tagger that records the exact token sequence of every call and answers with one
+ * {@code "X"} tag per token, so the per-call slicing is observable.
+ */
+ private static final class RecordingTagger implements POSTagger {
+
+ private final List> calls = new ArrayList<>();
+
+ @Override
+ public String[] tag(String[] sentence) {
+ calls.add(List.of(sentence));
+ final String[] tags = new String[sentence.length];
+ Arrays.fill(tags, "X");
+ return tags;
+ }
+
+ @Override
+ public String[] tag(String[] sentence, Object[] additionalContext) {
+ return tag(sentence);
+ }
+
+ @Override
+ public Sequence[] topKSequences(String[] sentence) {
+ throw new UnsupportedOperationException("the adapter only calls tag");
+ }
+
+ @Override
+ public Sequence[] topKSequences(String[] sentence, Object[] additionalContext) {
+ throw new UnsupportedOperationException("the adapter only calls tag");
+ }
+ }
+
+ /**
+ * @return A two-sentence document with sentence and token layers over
+ * {@code "The dog barks. It naps."}. Never {@code null}.
+ */
+ private static Document twoSentenceDocument() {
+ return Document.of("The dog barks. It naps.")
+ .with(Layers.SENTENCES, List.of(
+ new Annotation<>(new Span(0, 14), "The dog barks."),
+ new Annotation<>(new Span(15, 23), "It naps.")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "The"),
+ new Annotation<>(new Span(4, 7), "dog"),
+ new Annotation<>(new Span(8, 14), "barks."),
+ new Annotation<>(new Span(15, 17), "It"),
+ new Annotation<>(new Span(18, 23), "naps.")));
+ }
+
+ /**
+ * Verifies that the tagger is invoked once per sentence with exactly that sentence's
+ * tokens, and that the resulting tag layer stays aligned with the token layer by
+ * position, each tag on its token's span.
+ */
+ @Test
+ void testTagsEachSentenceSeparately() {
+ final RecordingTagger tagger = new RecordingTagger();
+ final Document annotated = new POSTaggerAnnotator(tagger).annotate(twoSentenceDocument());
+
+ assertEquals(List.of(
+ List.of("The", "dog", "barks."),
+ List.of("It", "naps.")), tagger.calls);
+
+ final List> tokens = annotated.get(Layers.TOKENS);
+ final List> tags = annotated.get(Layers.POS_TAGS);
+ assertEquals(tokens.size(), tags.size());
+ for (int i = 0; i < tags.size(); i++) {
+ assertEquals(tokens.get(i).span(), tags.get(i).span());
+ assertEquals("X", tags.get(i).value());
+ }
+ }
+
+ /**
+ * Verifies that the annotator declares both the sentence layer and the token layer as
+ * required, so a pipeline without a sentence step fails at build time.
+ */
+ @Test
+ void testRequiresSentencesAndTokens() {
+ assertEquals(Set.of(Layers.SENTENCES, Layers.TOKENS),
+ new POSTaggerAnnotator(new RecordingTagger()).requires());
+ }
+
+ /**
+ * Verifies that present-but-empty sentence and token layers yield a present-but-empty
+ * tag layer without invoking the tagger, rather than an exception.
+ */
+ @Test
+ void testEmptyPresentLayersYieldEmptyTagLayer() {
+ final RecordingTagger tagger = new RecordingTagger();
+ final Document document = Document.of("")
+ .with(Layers.SENTENCES, List.of())
+ .with(Layers.TOKENS, List.of());
+
+ final Document annotated = new POSTaggerAnnotator(tagger).annotate(document);
+
+ assertTrue(annotated.layers().contains(Layers.POS_TAGS));
+ assertTrue(annotated.get(Layers.POS_TAGS).isEmpty());
+ assertTrue(tagger.calls.isEmpty());
+ }
+
+ /**
+ * Verifies that a document without a sentence layer is rejected with a message naming
+ * the missing layer.
+ */
+ @Test
+ void testAbsentSentenceLayerThrowsWithExactMessage() {
+ final Document document = Document.of("The dog")
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "The"),
+ new Annotation<>(new Span(4, 7), "dog")));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new POSTaggerAnnotator(new RecordingTagger()).annotate(document));
+ assertEquals("document lacks the required layer sentences", e.getMessage());
+ }
+
+ /**
+ * Verifies that a document without a token layer is rejected with a message naming the
+ * missing layer.
+ */
+ @Test
+ void testAbsentTokenLayerThrowsWithExactMessage() {
+ final Document document = Document.of("The dog")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 7), "The dog")));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new POSTaggerAnnotator(new RecordingTagger()).annotate(document));
+ assertEquals("document lacks the required layer tokens", e.getMessage());
+ }
+
+ /**
+ * Verifies that a token whose span no sentence encloses is rejected with a message
+ * naming the token's span.
+ */
+ @Test
+ void testTokenOutsideEverySentenceThrowsWithExactMessage() {
+ final Document document = Document.of("The dog")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 3), "The")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "The"),
+ new Annotation<>(new Span(4, 7), "dog")));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new POSTaggerAnnotator(new RecordingTagger()).annotate(document));
+ assertEquals("token at [4..7) lies outside every sentence", e.getMessage());
+ }
+
+ /**
+ * Verifies that a sentence containing no tokens contributes nothing: the tagger is
+ * never called with an empty sequence and the tag layer still matches the token layer.
+ */
+ @Test
+ void testSentenceWithoutTokensContributesNothing() {
+ final RecordingTagger tagger = new RecordingTagger();
+ final Document document = Document.of("The ???")
+ .with(Layers.SENTENCES, List.of(
+ new Annotation<>(new Span(0, 3), "The"),
+ new Annotation<>(new Span(4, 7), "???")))
+ .with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 3), "The")));
+
+ final Document annotated = new POSTaggerAnnotator(tagger).annotate(document);
+
+ assertEquals(List.of(List.of("The")), tagger.calls);
+ assertEquals(1, annotated.get(Layers.POS_TAGS).size());
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
index 37fc3478ad..0561d667e4 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
@@ -56,11 +56,34 @@ public LemmatizerAnnotator(Lemmatizer lemmatizer) {
this.lemmatizer = lemmatizer;
}
+ /**
+ * Lemmatizes the token layer with its tags and adds the {@link #LEMMAS} layer.
+ *
+ *
The required layers must be present, but they may be empty: a document without
+ * tokens yields a present-but-empty lemma layer.
+ *
+ * @param document The document to annotate. Must not be {@code null} and must carry
+ * the {@link Layers#TOKENS} layer and a {@link Layers#POS_TAGS} layer
+ * of equal size.
+ * @return A new {@link Document} with the {@link #LEMMAS} layer added. Never
+ * {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code document} is {@code null}, the
+ * token layer or the tag layer is absent, the tag layer does not have exactly
+ * one tag per token, or the lemmatizer does not return one lemma per token.
+ */
@Override
public Document annotate(Document document) {
if (document == null) {
throw new IllegalArgumentException("document must not be null");
}
+ if (!document.layers().contains(Layers.TOKENS)) {
+ throw new IllegalArgumentException("document lacks the required layer "
+ + Layers.TOKENS);
+ }
+ if (!document.layers().contains(Layers.POS_TAGS)) {
+ throw new IllegalArgumentException("document lacks the required layer "
+ + Layers.POS_TAGS);
+ }
final List> tokens = document.get(Layers.TOKENS);
final List> tags = document.get(Layers.POS_TAGS);
if (tags.size() != tokens.size()) {
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
index 8d76d018ac..330087f5d8 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
@@ -59,11 +59,28 @@ public StemmerAnnotator(Stemmer stemmer) {
this.stemmer = stemmer;
}
+ /**
+ * Stems the token layer and adds the {@link #STEMS} layer.
+ *
+ *
The token layer must be present, but it may be empty: a document without tokens
+ * yields a present-but-empty stem layer.
+ *
+ * @param document The document to annotate. Must not be {@code null} and must carry
+ * the {@link Layers#TOKENS} layer.
+ * @return A new {@link Document} with the {@link #STEMS} layer added. Never
+ * {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code document} is {@code null} or the
+ * token layer is absent.
+ */
@Override
public Document annotate(Document document) {
if (document == null) {
throw new IllegalArgumentException("document must not be null");
}
+ if (!document.layers().contains(Layers.TOKENS)) {
+ throw new IllegalArgumentException("document lacks the required layer "
+ + Layers.TOKENS);
+ }
final List> tokens = document.get(Layers.TOKENS);
final List> layer = new ArrayList<>(tokens.size());
for (final Annotation token : tokens) {
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
index 6b2fb8b8d0..6a5596dfe8 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
@@ -79,4 +79,37 @@ void testInvalidArguments() {
Assertions.assertThrows(IllegalArgumentException.class,
() -> annotator.annotate(misaligned));
}
+
+ /**
+ * Verifies that a document lacking a required layer is rejected with a message naming
+ * the missing layer, instead of silently producing an empty lemma layer.
+ */
+ @Test
+ void testAbsentRequiredLayerThrowsWithExactMessage() {
+ final LemmatizerAnnotator annotator = new LemmatizerAnnotator(FIXTURE);
+ final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
+ () -> annotator.annotate(Document.of("no layers")));
+ Assertions.assertEquals("document lacks the required layer tokens", e.getMessage());
+
+ final Document tokensOnly = Document.of("a")
+ .with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 1), "a")));
+ final IllegalArgumentException tagless = Assertions.assertThrows(
+ IllegalArgumentException.class, () -> annotator.annotate(tokensOnly));
+ Assertions.assertEquals("document lacks the required layer pos",
+ tagless.getMessage());
+ }
+
+ /**
+ * Verifies that present-but-empty token and tag layers yield a present-but-empty lemma
+ * layer rather than an exception.
+ */
+ @Test
+ void testEmptyPresentLayersYieldEmptyLemmaLayer() {
+ final Document document = Document.of("")
+ .with(Layers.TOKENS, List.of())
+ .with(Layers.POS_TAGS, List.of());
+ final Document lemmatized = new LemmatizerAnnotator(FIXTURE).annotate(document);
+ Assertions.assertTrue(lemmatized.layers().contains(LemmatizerAnnotator.LEMMAS));
+ Assertions.assertTrue(lemmatized.get(LemmatizerAnnotator.LEMMAS).isEmpty());
+ }
}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
index 931b320e39..86b3a82ec7 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
@@ -53,4 +53,28 @@ void testInvalidArguments() {
final StemmerAnnotator annotator = new StemmerAnnotator(new PorterStemmer());
Assertions.assertThrows(IllegalArgumentException.class, () -> annotator.annotate(null));
}
+
+ /**
+ * Verifies that a document without a token layer is rejected with a message naming the
+ * missing layer, instead of silently producing an empty stem layer.
+ */
+ @Test
+ void testAbsentTokenLayerThrowsWithExactMessage() {
+ final StemmerAnnotator annotator = new StemmerAnnotator(new PorterStemmer());
+ final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
+ () -> annotator.annotate(Document.of("no tokens")));
+ Assertions.assertEquals("document lacks the required layer tokens", e.getMessage());
+ }
+
+ /**
+ * Verifies that a present-but-empty token layer yields a present-but-empty stem layer
+ * rather than an exception.
+ */
+ @Test
+ void testEmptyPresentTokenLayerYieldsEmptyStemLayer() {
+ final Document document = Document.of("").with(Layers.TOKENS, List.of());
+ final Document stemmed = new StemmerAnnotator(new PorterStemmer()).annotate(document);
+ Assertions.assertTrue(stemmed.layers().contains(StemmerAnnotator.STEMS));
+ Assertions.assertTrue(stemmed.get(StemmerAnnotator.STEMS).isEmpty());
+ }
}
From 6b384b670be9db2eae6022af725bd8f48c5b0f34 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Thu, 16 Jul 2026 23:30:37 -0400
Subject: [PATCH 05/92] OPENNLP-1888: Lemmatize per sentence, validate adapter
outputs, clear adaptive data on failure
The lemmatizer adapter now slices tokens and tags per sentence like its POS and
name-finder siblings, so lemmatization decisions never cross a sentence boundary,
and it declares the sentence layer as required. The POS adapter rejects a tagger
that returns a wrong tag count. The name-finder adapter rejects mentions whose
token indices lie outside their sentence instead of silently reading the next
sentence's tokens, clears adaptive data even when annotation fails, and derives
UNTYPED from NameSample.DEFAULT_TYPE instead of re-declaring the literal.
---
.../tools/document/NameFinderAnnotator.java | 87 +++++++-----
.../tools/document/POSTaggerAnnotator.java | 8 +-
.../document/NameFinderAnnotatorTest.java | 117 +++++++++++++++-
.../document/POSTaggerAnnotatorTest.java | 38 +++++
.../tools/lemmatizer/LemmatizerAnnotator.java | 83 ++++++++---
.../lemmatizer/LemmatizerAnnotatorTest.java | 132 +++++++++++++++++-
6 files changed, 394 insertions(+), 71 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
index 3902d41867..b129b77b29 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
@@ -21,6 +21,7 @@
import java.util.List;
import java.util.Set;
+import opennlp.tools.namefind.NameSample;
import opennlp.tools.namefind.TokenNameFinder;
import opennlp.tools.util.Span;
@@ -32,9 +33,9 @@
*
*
Each sentence's tokens are passed to {@link TokenNameFinder#find(String[])} as one
* sequence, the way the finder contract expects its input, so no mention can straddle a
- * sentence boundary. The finder's adaptive data is cleared once after all sentences of a
- * document are processed, as the {@link TokenNameFinder#clearAdaptiveData()} contract
- * asks, so document order does not leak between pipeline runs.
+ * sentence boundary. The finder's adaptive data is cleared exactly once per call, as the
+ * {@link TokenNameFinder#clearAdaptiveData()} contract asks, whether annotation succeeds
+ * or fails, so no document can leak finder state into the next one.
*
*
Spans the finder returns without a type are recorded with the {@link #UNTYPED}
* entity type.
@@ -45,11 +46,11 @@ public class NameFinderAnnotator implements DocumentAnnotator {
/**
* The entity type recorded when the wrapped finder returns a span without a type. It
- * equals {@link opennlp.tools.namefind.NameSample#DEFAULT_TYPE}. Type-aware consumers
- * should treat this label as an unknown type rather than as a distinct one, since it
- * carries no information about what kind of entity was found.
+ * is {@link NameSample#DEFAULT_TYPE}. Type-aware consumers should treat this label as
+ * an unknown type rather than as a distinct one, since it carries no information about
+ * what kind of entity was found.
*/
- public static final String UNTYPED = "default";
+ public static final String UNTYPED = NameSample.DEFAULT_TYPE;
private final TokenNameFinder finder;
@@ -83,8 +84,9 @@ public NameFinderAnnotator(TokenNameFinder finder) {
* @return A new {@link Document} with the {@link Layers#ENTITIES} layer added. Never
* {@code null}.
* @throws IllegalArgumentException Thrown if {@code document} is {@code null}, the
- * sentence layer or the token layer is absent, or a token lies outside every
- * sentence.
+ * sentence layer or the token layer is absent, a token lies outside every
+ * sentence, or the finder returns a mention whose token indices lie outside
+ * its sentence's tokens.
*/
@Override
public Document annotate(Document document) {
@@ -104,38 +106,47 @@ public Document annotate(Document document) {
final List> tokens = document.get(Layers.TOKENS);
final List> entities = new ArrayList<>();
// Walk the token layer once: both layers are in text order, so each sentence
- // consumes the contiguous run of tokens whose spans it encloses.
- int next = 0;
- for (final Annotation sentence : sentences) {
- final int first = next;
- while (next < tokens.size()
- && tokens.get(next).span().getStart() >= sentence.span().getStart()
- && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
- next++;
+ // consumes the contiguous run of tokens whose spans it encloses. The adaptive data
+ // is cleared even when annotation fails, so a rejected document cannot leak finder
+ // state into the next one.
+ try {
+ int next = 0;
+ for (final Annotation sentence : sentences) {
+ final int first = next;
+ while (next < tokens.size()
+ && tokens.get(next).span().getStart() >= sentence.span().getStart()
+ && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
+ next++;
+ }
+ final int count = next - first;
+ if (count == 0) {
+ continue;
+ }
+ final String[] words = new String[count];
+ for (int i = 0; i < count; i++) {
+ words[i] = tokens.get(first + i).value();
+ }
+ // The finder indexes within the sentence; shifting by the sentence's first token
+ // position turns every mention boundary into a document-wide token index, whose
+ // token spans already refer to the original text.
+ for (final Span mention : finder.find(words)) {
+ if (mention.getStart() < 0 || mention.getEnd() > count) {
+ throw new IllegalArgumentException("finder returned mention " + mention
+ + " outside the sentence's " + count + " tokens");
+ }
+ final int start = tokens.get(first + mention.getStart()).span().getStart();
+ final int end = tokens.get(first + mention.getEnd() - 1).span().getEnd();
+ final String type = mention.getType() == null ? UNTYPED : mention.getType();
+ entities.add(new Annotation<>(new Span(start, end, type), type));
+ }
}
- final int count = next - first;
- if (count == 0) {
- continue;
+ if (next != tokens.size()) {
+ throw new IllegalArgumentException("token at " + tokens.get(next).span()
+ + " lies outside every sentence");
}
- final String[] words = new String[count];
- for (int i = 0; i < count; i++) {
- words[i] = tokens.get(first + i).value();
- }
- // The finder indexes within the sentence; shifting by the sentence's first token
- // position turns every mention boundary into a document-wide token index, whose
- // token spans already refer to the original text.
- for (final Span mention : finder.find(words)) {
- final int start = tokens.get(first + mention.getStart()).span().getStart();
- final int end = tokens.get(first + mention.getEnd() - 1).span().getEnd();
- final String type = mention.getType() == null ? UNTYPED : mention.getType();
- entities.add(new Annotation<>(new Span(start, end, type), type));
- }
- }
- if (next != tokens.size()) {
- throw new IllegalArgumentException("token at " + tokens.get(next).span()
- + " lies outside every sentence");
+ } finally {
+ finder.clearAdaptiveData();
}
- finder.clearAdaptiveData();
return document.with(Layers.ENTITIES, entities);
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
index 478f210b47..f7564cd648 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
@@ -68,8 +68,8 @@ public POSTaggerAnnotator(POSTagger tagger) {
* @return A new {@link Document} with the {@link Layers#POS_TAGS} layer added. Never
* {@code null}.
* @throws IllegalArgumentException Thrown if {@code document} is {@code null}, the
- * sentence layer or the token layer is absent, or a token lies outside every
- * sentence.
+ * sentence layer or the token layer is absent, a token lies outside every
+ * sentence, or the tagger does not return one tag per token of a sentence.
*/
@Override
public Document annotate(Document document) {
@@ -107,6 +107,10 @@ public Document annotate(Document document) {
words[i] = tokens.get(first + i).value();
}
final String[] tags = tagger.tag(words);
+ if (tags.length != count) {
+ throw new IllegalArgumentException(
+ "tagger returned " + tags.length + " tags for " + count + " tokens");
+ }
for (int i = 0; i < count; i++) {
tagAnnotations.add(new Annotation<>(tokens.get(first + i).span(), tags[i]));
}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
index 971215d898..e6d6b6a290 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
@@ -24,6 +24,7 @@
import org.junit.jupiter.api.Test;
+import opennlp.tools.namefind.NameSample;
import opennlp.tools.namefind.TokenNameFinder;
import opennlp.tools.util.Span;
@@ -73,6 +74,11 @@ public void clearAdaptiveData() {
assertEquals(1, cleared.get());
}
+ /**
+ * Verifies that a document carrying sentences but no token layer is rejected with a
+ * message naming the token layer, so the token check is exercised on its own rather
+ * than being shadowed by the sentence check.
+ */
@Test
void testMissingTokenLayerThrows() {
final TokenNameFinder finder = new TokenNameFinder() {
@@ -86,8 +92,115 @@ public Span[] find(String[] tokens) {
public void clearAdaptiveData() {
}
};
- assertThrows(IllegalArgumentException.class,
- () -> new NameFinderAnnotator(finder).annotate(Document.of("no tokens")));
+ final Document document = Document.of("no tokens")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 9), "no tokens")));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new NameFinderAnnotator(finder).annotate(document));
+ assertEquals("document lacks the required layer tokens", e.getMessage());
+ }
+
+ /**
+ * Verifies that a mention the finder returns without a type is recorded with the
+ * {@link NameFinderAnnotator#UNTYPED} label, and that the label is the name-sample
+ * default type, so downstream consumers can rely on the two being interchangeable.
+ */
+ @Test
+ void testUntypedMentionRecordedAsUntyped() {
+ final TokenNameFinder finder = new TokenNameFinder() {
+
+ @Override
+ public Span[] find(String[] tokens) {
+ return new Span[] {new Span(0, 1)};
+ }
+
+ @Override
+ public void clearAdaptiveData() {
+ }
+ };
+ final Document document = Document.of("Ana runs.")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 9), "Ana runs.")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs.")));
+
+ final Document annotated = new NameFinderAnnotator(finder).annotate(document);
+ final List> entities = annotated.get(Layers.ENTITIES);
+ assertEquals(1, entities.size());
+ assertEquals(NameSample.DEFAULT_TYPE, NameFinderAnnotator.UNTYPED);
+ assertEquals(NameFinderAnnotator.UNTYPED, entities.get(0).value());
+ assertEquals(new Span(0, 3, NameFinderAnnotator.UNTYPED), entities.get(0).span());
+ assertEquals(NameFinderAnnotator.UNTYPED, entities.get(0).span().getType());
+ }
+
+ /**
+ * Verifies that a mention whose token indices reach beyond its sentence's tokens is
+ * rejected loudly instead of silently taking its character span from the following
+ * sentence's tokens, and that the adaptive data is still cleared on that failure.
+ */
+ @Test
+ void testMentionOutsideSentenceTokensFailsLoud() {
+ final AtomicInteger cleared = new AtomicInteger();
+ final TokenNameFinder finder = new TokenNameFinder() {
+
+ @Override
+ public Span[] find(String[] tokens) {
+ // two tokens in the sentence, but the mention claims three
+ return new Span[] {new Span(0, 3, "person")};
+ }
+
+ @Override
+ public void clearAdaptiveData() {
+ cleared.incrementAndGet();
+ }
+ };
+ final Document document = Document.of("Ana runs. Bob sits.")
+ .with(Layers.SENTENCES, List.of(
+ new Annotation<>(new Span(0, 9), "Ana runs."),
+ new Annotation<>(new Span(10, 19), "Bob sits.")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs."),
+ new Annotation<>(new Span(10, 13), "Bob"),
+ new Annotation<>(new Span(14, 19), "sits.")));
+
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new NameFinderAnnotator(finder).annotate(document));
+ assertEquals("finder returned mention [0..3) person outside the sentence's 2 tokens",
+ e.getMessage());
+ assertEquals(1, cleared.get());
+ }
+
+ /**
+ * Verifies that a token lying outside every sentence is rejected loudly and that the
+ * adaptive data is still cleared on that failure, so a rejected document cannot leak
+ * finder state into the next one.
+ */
+ @Test
+ void testTokenOutsideEverySentenceThrowsAndStillClears() {
+ final AtomicInteger cleared = new AtomicInteger();
+ final TokenNameFinder finder = new TokenNameFinder() {
+
+ @Override
+ public Span[] find(String[] tokens) {
+ return new Span[0];
+ }
+
+ @Override
+ public void clearAdaptiveData() {
+ cleared.incrementAndGet();
+ }
+ };
+ final Document document = Document.of("Ana runs. Bob")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 9), "Ana runs.")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs."),
+ new Annotation<>(new Span(10, 13), "Bob")));
+
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new NameFinderAnnotator(finder).annotate(document));
+ assertEquals("token at [10..13) lies outside every sentence", e.getMessage());
+ assertEquals(1, cleared.get());
}
/**
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
index 95779fdd36..a7d44f1280 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
@@ -201,4 +201,42 @@ void testSentenceWithoutTokensContributesNothing() {
assertEquals(List.of(List.of("The")), tagger.calls);
assertEquals(1, annotated.get(Layers.POS_TAGS).size());
}
+
+ /**
+ * Verifies that a tagger returning a wrong number of tags for a sentence is rejected
+ * loudly instead of silently misaligning the tag layer with the token layer.
+ */
+ @Test
+ void testWrongTagCountFailsLoud() {
+ final POSTagger shortTagger = new POSTagger() {
+
+ @Override
+ public String[] tag(String[] sentence) {
+ return new String[] {"X"};
+ }
+
+ @Override
+ public String[] tag(String[] sentence, Object[] additionalContext) {
+ return tag(sentence);
+ }
+
+ @Override
+ public Sequence[] topKSequences(String[] sentence) {
+ throw new UnsupportedOperationException("the adapter only calls tag");
+ }
+
+ @Override
+ public Sequence[] topKSequences(String[] sentence, Object[] additionalContext) {
+ throw new UnsupportedOperationException("the adapter only calls tag");
+ }
+ };
+ final Document document = Document.of("The dog")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 7), "The dog")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "The"),
+ new Annotation<>(new Span(4, 7), "dog")));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new POSTaggerAnnotator(shortTagger).annotate(document));
+ assertEquals("tagger returned 1 tags for 2 tokens", e.getMessage());
+ }
}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
index 0561d667e4..9c8d5f5ff8 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
@@ -28,8 +28,15 @@
import opennlp.tools.document.Layers;
/**
- * Adapts a {@link Lemmatizer} to the document pipeline: lemmatizes the token layer with
- * its tags and provides {@link #LEMMAS}, one annotation per token on the token's span.
+ * Adapts a {@link Lemmatizer} to the document pipeline: reads {@link Layers#SENTENCES},
+ * {@link Layers#TOKENS}, and {@link Layers#POS_TAGS} and provides {@link #LEMMAS}, one
+ * annotation per token on the token's span.
+ *
+ *
Each sentence is lemmatized separately, the way the lemmatizer contract expects its
+ * input, so lemmatization decisions never cross a sentence boundary. Token spans already
+ * refer to the original document text, so only the token and tag sequences handed to the
+ * lemmatizer are sliced per sentence; the produced lemma layer stays aligned with
+ * {@link Layers#TOKENS} by position.
*
* @since 3.0.0
*/
@@ -57,25 +64,35 @@ public LemmatizerAnnotator(Lemmatizer lemmatizer) {
}
/**
- * Lemmatizes the token layer with its tags and adds the {@link #LEMMAS} layer.
+ * Lemmatizes the document sentence by sentence and adds the {@link #LEMMAS} layer.
*
- *
The required layers must be present, but they may be empty: a document without
- * tokens yields a present-but-empty lemma layer.
+ *
For every sentence, the tokens whose spans lie inside the sentence span are
+ * lemmatized as one sequence together with their tags, and each lemma is emitted on
+ * its token's span. The required layers must be present, but they may be empty: a
+ * document without sentences or tokens yields a present-but-empty lemma layer, and a
+ * sentence containing no tokens contributes nothing.
*
* @param document The document to annotate. Must not be {@code null} and must carry
- * the {@link Layers#TOKENS} layer and a {@link Layers#POS_TAGS} layer
- * of equal size.
+ * the {@link Layers#SENTENCES} and {@link Layers#TOKENS} layers and a
+ * {@link Layers#POS_TAGS} layer with exactly one tag per token, with
+ * every token lying inside a sentence.
* @return A new {@link Document} with the {@link #LEMMAS} layer added. Never
* {@code null}.
* @throws IllegalArgumentException Thrown if {@code document} is {@code null}, the
- * token layer or the tag layer is absent, the tag layer does not have exactly
- * one tag per token, or the lemmatizer does not return one lemma per token.
+ * sentence layer, the token layer, or the tag layer is absent, the tag layer
+ * does not have exactly one tag per token, a token lies outside every
+ * sentence, or the lemmatizer does not return one lemma per token of a
+ * sentence.
*/
@Override
public Document annotate(Document document) {
if (document == null) {
throw new IllegalArgumentException("document must not be null");
}
+ if (!document.layers().contains(Layers.SENTENCES)) {
+ throw new IllegalArgumentException("document lacks the required layer "
+ + Layers.SENTENCES);
+ }
if (!document.layers().contains(Layers.TOKENS)) {
throw new IllegalArgumentException("document lacks the required layer "
+ Layers.TOKENS);
@@ -84,33 +101,53 @@ public Document annotate(Document document) {
throw new IllegalArgumentException("document lacks the required layer "
+ Layers.POS_TAGS);
}
+ final List> sentences = document.get(Layers.SENTENCES);
final List> tokens = document.get(Layers.TOKENS);
final List> tags = document.get(Layers.POS_TAGS);
if (tags.size() != tokens.size()) {
throw new IllegalArgumentException("document needs aligned "
+ Layers.TOKENS + " and " + Layers.POS_TAGS + " layers");
}
- final String[] words = new String[tokens.size()];
- final String[] posTags = new String[tokens.size()];
- for (int i = 0; i < words.length; i++) {
- words[i] = tokens.get(i).value();
- posTags[i] = tags.get(i).value();
- }
- final String[] lemmas = lemmatizer.lemmatize(words, posTags);
- if (lemmas.length != words.length) {
- throw new IllegalArgumentException(
- "lemmatizer returned " + lemmas.length + " lemmas for " + words.length + " tokens");
+ final List> layer = new ArrayList<>(tokens.size());
+ // Walk the token layer once: both layers are in text order, so each sentence
+ // consumes the contiguous run of tokens whose spans it encloses.
+ int next = 0;
+ for (final Annotation sentence : sentences) {
+ final int first = next;
+ while (next < tokens.size()
+ && tokens.get(next).span().getStart() >= sentence.span().getStart()
+ && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
+ next++;
+ }
+ final int count = next - first;
+ if (count == 0) {
+ continue;
+ }
+ final String[] words = new String[count];
+ final String[] posTags = new String[count];
+ for (int i = 0; i < count; i++) {
+ words[i] = tokens.get(first + i).value();
+ posTags[i] = tags.get(first + i).value();
+ }
+ final String[] lemmas = lemmatizer.lemmatize(words, posTags);
+ if (lemmas.length != count) {
+ throw new IllegalArgumentException(
+ "lemmatizer returned " + lemmas.length + " lemmas for " + count + " tokens");
+ }
+ for (int i = 0; i < count; i++) {
+ layer.add(new Annotation<>(tokens.get(first + i).span(), lemmas[i]));
+ }
}
- final List> layer = new ArrayList<>(lemmas.length);
- for (int i = 0; i < lemmas.length; i++) {
- layer.add(new Annotation<>(tokens.get(i).span(), lemmas[i]));
+ if (next != tokens.size()) {
+ throw new IllegalArgumentException("token at " + tokens.get(next).span()
+ + " lies outside every sentence");
}
return document.with(LEMMAS, layer);
}
@Override
public Set> requires() {
- return Set.of(Layers.TOKENS, Layers.POS_TAGS);
+ return Set.of(Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS);
}
@Override
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
index 6a5596dfe8..0b8fe30def 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
@@ -17,7 +17,9 @@
package opennlp.tools.lemmatizer;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -49,6 +51,8 @@ public List> lemmatize(List toks, List tags) {
@Test
void testLemmasAlignWithTokens() {
final Document document = Document.of("She ran home")
+ .with(Layers.SENTENCES, List.of(
+ new Annotation<>(new Span(0, 12), "She ran home")))
.with(Layers.TOKENS, List.of(
new Annotation<>(new Span(0, 3), "She"),
new Annotation<>(new Span(4, 7), "ran"),
@@ -67,6 +71,51 @@ void testLemmasAlignWithTokens() {
Assertions.assertEquals("home", lemmas.get(2).value());
}
+ /**
+ * Verifies that the lemmatizer is invoked once per sentence with exactly that
+ * sentence's tokens and tags, so lemmatization decisions never see material from a
+ * neighboring sentence, and that the lemma layer still aligns with the token layer.
+ */
+ @Test
+ void testLemmatizesPerSentence() {
+ final List> calls = new ArrayList<>();
+ final Lemmatizer recording = new Lemmatizer() {
+ @Override
+ public String[] lemmatize(String[] toks, String[] tags) {
+ calls.add(List.of(toks));
+ return toks.clone();
+ }
+
+ @Override
+ public List> lemmatize(List toks, List tags) {
+ throw new UnsupportedOperationException();
+ }
+ };
+ final Document document = Document.of("Ana runs. Bob sits.")
+ .with(Layers.SENTENCES, List.of(
+ new Annotation<>(new Span(0, 9), "Ana runs."),
+ new Annotation<>(new Span(10, 19), "Bob sits.")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs."),
+ new Annotation<>(new Span(10, 13), "Bob"),
+ new Annotation<>(new Span(14, 19), "sits.")))
+ .with(Layers.POS_TAGS, List.of(
+ new Annotation<>(new Span(0, 3), "PROPN"),
+ new Annotation<>(new Span(4, 9), "VERB"),
+ new Annotation<>(new Span(10, 13), "PROPN"),
+ new Annotation<>(new Span(14, 19), "VERB")));
+
+ final Document lemmatized = new LemmatizerAnnotator(recording).annotate(document);
+
+ Assertions.assertEquals(List.of(
+ List.of("Ana", "runs."),
+ List.of("Bob", "sits.")), calls);
+ Assertions.assertEquals(4, lemmatized.get(LemmatizerAnnotator.LEMMAS).size());
+ Assertions.assertEquals(new Span(10, 13),
+ lemmatized.get(LemmatizerAnnotator.LEMMAS).get(2).span());
+ }
+
@Test
void testInvalidArguments() {
Assertions.assertThrows(IllegalArgumentException.class,
@@ -74,6 +123,7 @@ void testInvalidArguments() {
final LemmatizerAnnotator annotator = new LemmatizerAnnotator(FIXTURE);
Assertions.assertThrows(IllegalArgumentException.class, () -> annotator.annotate(null));
final Document misaligned = Document.of("a b")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 3), "a b")))
.with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 1), "a")))
.with(Layers.POS_TAGS, List.of());
Assertions.assertThrows(IllegalArgumentException.class,
@@ -82,34 +132,104 @@ void testInvalidArguments() {
/**
* Verifies that a document lacking a required layer is rejected with a message naming
- * the missing layer, instead of silently producing an empty lemma layer.
+ * the missing layer, for each of the three required layers in declaration order.
*/
@Test
void testAbsentRequiredLayerThrowsWithExactMessage() {
final LemmatizerAnnotator annotator = new LemmatizerAnnotator(FIXTURE);
final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
() -> annotator.annotate(Document.of("no layers")));
- Assertions.assertEquals("document lacks the required layer tokens", e.getMessage());
+ Assertions.assertEquals("document lacks the required layer sentences",
+ e.getMessage());
+
+ final Document sentencesOnly = Document.of("a")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 1), "a")));
+ final IllegalArgumentException tokenless = Assertions.assertThrows(
+ IllegalArgumentException.class, () -> annotator.annotate(sentencesOnly));
+ Assertions.assertEquals("document lacks the required layer tokens",
+ tokenless.getMessage());
- final Document tokensOnly = Document.of("a")
+ final Document untagged = sentencesOnly
.with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 1), "a")));
final IllegalArgumentException tagless = Assertions.assertThrows(
- IllegalArgumentException.class, () -> annotator.annotate(tokensOnly));
+ IllegalArgumentException.class, () -> annotator.annotate(untagged));
Assertions.assertEquals("document lacks the required layer pos",
tagless.getMessage());
}
/**
- * Verifies that present-but-empty token and tag layers yield a present-but-empty lemma
- * layer rather than an exception.
+ * Verifies that present-but-empty layers yield a present-but-empty lemma layer rather
+ * than an exception.
*/
@Test
void testEmptyPresentLayersYieldEmptyLemmaLayer() {
final Document document = Document.of("")
+ .with(Layers.SENTENCES, List.of())
.with(Layers.TOKENS, List.of())
.with(Layers.POS_TAGS, List.of());
final Document lemmatized = new LemmatizerAnnotator(FIXTURE).annotate(document);
Assertions.assertTrue(lemmatized.layers().contains(LemmatizerAnnotator.LEMMAS));
Assertions.assertTrue(lemmatized.get(LemmatizerAnnotator.LEMMAS).isEmpty());
}
+
+ /**
+ * Verifies that a token lying outside every sentence is rejected loudly, matching the
+ * walk contract of the other per-sentence adapters.
+ */
+ @Test
+ void testTokenOutsideEverySentenceThrowsWithExactMessage() {
+ final Document document = Document.of("Ana runs. Bob")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 9), "Ana runs.")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs."),
+ new Annotation<>(new Span(10, 13), "Bob")))
+ .with(Layers.POS_TAGS, List.of(
+ new Annotation<>(new Span(0, 3), "PROPN"),
+ new Annotation<>(new Span(4, 9), "VERB"),
+ new Annotation<>(new Span(10, 13), "PROPN")));
+ final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new LemmatizerAnnotator(FIXTURE).annotate(document));
+ Assertions.assertEquals("token at [10..13) lies outside every sentence", e.getMessage());
+ }
+
+ /**
+ * Verifies that a lemmatizer returning a wrong number of lemmas for a sentence is
+ * rejected loudly instead of silently misaligning the lemma layer.
+ */
+ @Test
+ void testWrongLemmaCountFailsLoud() {
+ final Lemmatizer shortLemmatizer = new Lemmatizer() {
+ @Override
+ public String[] lemmatize(String[] toks, String[] tags) {
+ return new String[] {"a"};
+ }
+
+ @Override
+ public List> lemmatize(List toks, List tags) {
+ throw new UnsupportedOperationException();
+ }
+ };
+ final Document document = Document.of("a b")
+ .with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 3), "a b")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 1), "a"),
+ new Annotation<>(new Span(2, 3), "b")))
+ .with(Layers.POS_TAGS, List.of(
+ new Annotation<>(new Span(0, 1), "X"),
+ new Annotation<>(new Span(2, 3), "X")));
+ final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new LemmatizerAnnotator(shortLemmatizer).annotate(document));
+ Assertions.assertEquals("lemmatizer returned 1 lemmas for 2 tokens", e.getMessage());
+ }
+
+ /**
+ * Verifies that the adapter declares all three consumed layers as required, so a
+ * pipeline without a sentence step fails at build time.
+ */
+ @Test
+ void testRequiresSentencesTokensAndTags() {
+ Assertions.assertEquals(Set.of(Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS),
+ new LemmatizerAnnotator(FIXTURE).requires());
+ }
}
From 99e0b83fe695da6dc88884c06a446f06b96829d1 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Fri, 17 Jul 2026 07:14:51 -0400
Subject: [PATCH 06/92] OPENNLP-1888: Add StringUtil.isBlank following the
toolkit whitespace definition
A blank check under the toolkit's whitespace definition, which unlike
String.isBlank covers the no-break spaces, so annotators validating labels and
identifiers share one predicate instead of each carrying a private copy. Reads
whole code points; tests pin the no-break and figure spaces, the empty string,
and a supplementary-plane letter.
---
.../java/opennlp/tools/util/StringUtil.java | 21 +++++++++++++++++++
.../opennlp/tools/util/StringUtilTest.java | 19 +++++++++++++++++
2 files changed, 40 insertions(+)
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
index 98cca59891..1842556720 100644
--- a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
+++ b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
@@ -268,6 +268,27 @@ public static boolean isEmpty(CharSequence theString) {
return theString.length() == 0;
}
+ /**
+ * Determines whether a {@link CharSequence} is blank: empty, or made up entirely of
+ * code points that {@link #isWhitespace(int)} accepts. Unlike
+ * {@link String#isBlank()}, this follows the toolkit's whitespace definition, which
+ * includes the no-break spaces the JDK predicate leaves out, so a value spelled
+ * entirely from them cannot pass a blank check as content.
+ *
+ * @param theString The {@link CharSequence} to examine. Must not be {@code null}.
+ * @return {@code true} if {@code theString} is empty or all whitespace.
+ */
+ public static boolean isBlank(CharSequence theString) {
+ for (int i = 0; i < theString.length(); ) {
+ final int codePoint = Character.codePointAt(theString, i);
+ if (!isWhitespace(codePoint)) {
+ return false;
+ }
+ i += Character.charCount(codePoint);
+ }
+ return true;
+ }
+
/**
* Get the minimum of three values.
*
diff --git a/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java b/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
index a47306d3d4..73f81c209e 100644
--- a/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
+++ b/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
@@ -679,4 +679,23 @@ void testLowercaseBeyondBMP() {
String lc = StringUtil.toLowerCase(input);
Assertions.assertArrayEquals(expectedCodePoints, lc.codePoints().toArray());
}
+
+ /**
+ * Verifies the blank check against the toolkit's whitespace definition: the
+ * no-break space is blank here although the JDK's own check does not cover it,
+ * whitespace-only and empty values are blank, and any non-whitespace code point,
+ * supplementary ones included, makes a value non-blank.
+ */
+ @Test
+ void testIsBlankFollowsTheToolkitWhitespaceDefinition() {
+ Assertions.assertTrue(StringUtil.isBlank(""));
+ Assertions.assertTrue(StringUtil.isBlank(" \t\n"));
+ // U+00A0 no-break space and U+2007 figure space: JDK String.isBlank says false
+ Assertions.assertTrue(StringUtil.isBlank("\u00A0"));
+ Assertions.assertTrue(StringUtil.isBlank(" \u00A0\u2007 "));
+ Assertions.assertFalse(StringUtil.isBlank("a"));
+ Assertions.assertFalse(StringUtil.isBlank(" a "));
+ // U+10428, a supplementary-plane letter read as one code point, not two chars
+ Assertions.assertFalse(StringUtil.isBlank("\uD801\uDC28"));
+ }
}
From eb277fbb623bacece5d397fb9b9e10655d086a1d Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Fri, 17 Jul 2026 07:50:20 -0400
Subject: [PATCH 07/92] OPENNLP-1888: Manual chapter, review-convention pass,
and key placement rule
Adds the Document Annotation Container chapter to the manual, with every code
example and every stated span and value mirroring the passing pipeline example
test. The review pass aligns the branch with the project's conventions: layer
key ids validate through StringUtil.isBlank, the annotator interface leaves
thread safety implementation specific, the sentence and tokenizer adapters
document annotate like their siblings, repeated rejection-message literals
become per-class constants, and the name finder test's nine anonymous fixtures
fold into one helper. Layers now states the key placement rule: core layer
keys live there, capability layer keys on their providing annotator.
---
.../tools/document/DocumentAnnotator.java | 5 +-
.../java/opennlp/tools/document/LayerKey.java | 4 +-
.../java/opennlp/tools/document/Layers.java | 5 +
.../tools/document/NameFinderAnnotator.java | 7 +-
.../tools/document/POSTaggerAnnotator.java | 7 +-
.../document/SentenceDetectorAnnotator.java | 10 +
.../tools/document/TokenizerAnnotator.java | 18 ++
.../document/NameFinderAnnotatorTest.java | 145 +++++---------
.../document/POSTaggerAnnotatorTest.java | 23 +--
.../tools/lemmatizer/LemmatizerAnnotator.java | 9 +-
opennlp-docs/src/docbkx/document.xml | 179 ++++++++++++++++++
opennlp-docs/src/docbkx/opennlp.xml | 1 +
12 files changed, 282 insertions(+), 131 deletions(-)
create mode 100644 opennlp-docs/src/docbkx/document.xml
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java
index 3f44a6d856..6bc8417e6c 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotator.java
@@ -25,9 +25,8 @@
*
*
An annotator declares the layers it {@link #requires()} and {@link #provides()}, so
* a {@link DocumentAnalyzer} can validate a pipeline before running it. Annotators are
- * usually thin adapters over an existing analysis component and should hold no per-call
- * state, so one instance can serve concurrent pipelines when the wrapped component
- * allows it.
+ * usually thin adapters over an existing analysis component. Thread safety is
+ * implementation specific.
*
* @since 3.0.0
*/
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
index 458a61b4c4..1f8ac378d4 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
@@ -19,6 +19,8 @@
import java.util.Objects;
+import opennlp.tools.util.StringUtil;
+
/**
* Identifies one annotation layer of a {@link Document} and carries the type of that
* layer's annotation values, so reading a layer back is statically typed.
@@ -56,7 +58,7 @@ private LayerKey(String id, Class type) {
* {@code type} is {@code null}.
*/
public static LayerKey of(String id, Class type) {
- if (id == null || id.isBlank()) {
+ if (id == null || StringUtil.isBlank(id)) {
throw new IllegalArgumentException("id must not be null or blank");
}
if (type == null) {
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
index 99f0730550..f5808aed1c 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
@@ -24,6 +24,11 @@
* producer may define further keys in its own package. New capabilities must never
* require an addition here to function.
*
+ *
Placement rule: this class holds only the keys of the core linguistic layers
+ * every pipeline shares (sentences, tokens, tags, entities). A capability-specific
+ * layer's key lives on the annotator that provides it, for example the lemma layer's
+ * key on its adapter, so adding a capability never touches this class.
+ *
* @since 3.0.0
*/
public final class Layers {
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
index b129b77b29..75c33496c8 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
@@ -52,6 +52,9 @@ public class NameFinderAnnotator implements DocumentAnnotator {
*/
public static final String UNTYPED = NameSample.DEFAULT_TYPE;
+ /** The message prefix of every absent-required-layer rejection in this adapter. */
+ private static final String MISSING_LAYER = "document lacks the required layer ";
+
private final TokenNameFinder finder;
/**
@@ -95,11 +98,11 @@ public Document annotate(Document document) {
}
final Set> present = document.layers();
if (!present.contains(Layers.SENTENCES)) {
- throw new IllegalArgumentException("document lacks the required layer "
+ throw new IllegalArgumentException(MISSING_LAYER
+ Layers.SENTENCES);
}
if (!present.contains(Layers.TOKENS)) {
- throw new IllegalArgumentException("document lacks the required layer "
+ throw new IllegalArgumentException(MISSING_LAYER
+ Layers.TOKENS);
}
final List> sentences = document.get(Layers.SENTENCES);
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
index f7564cd648..08e1b81f8c 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
@@ -38,6 +38,9 @@
*/
public class POSTaggerAnnotator implements DocumentAnnotator {
+ /** The message prefix of every absent-required-layer rejection in this adapter. */
+ private static final String MISSING_LAYER = "document lacks the required layer ";
+
private final POSTagger tagger;
/**
@@ -78,11 +81,11 @@ public Document annotate(Document document) {
}
final Set> present = document.layers();
if (!present.contains(Layers.SENTENCES)) {
- throw new IllegalArgumentException("document lacks the required layer "
+ throw new IllegalArgumentException(MISSING_LAYER
+ Layers.SENTENCES);
}
if (!present.contains(Layers.TOKENS)) {
- throw new IllegalArgumentException("document lacks the required layer "
+ throw new IllegalArgumentException(MISSING_LAYER
+ Layers.TOKENS);
}
final List> sentences = document.get(Layers.SENTENCES);
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
index 47e7c58c34..282edf31dd 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
@@ -50,6 +50,16 @@ public SentenceDetectorAnnotator(SentenceDetector detector) {
this.detector = detector;
}
+ /**
+ * Detects sentences over the document text and adds the {@link Layers#SENTENCES}
+ * layer, each sentence annotated with its covered text on its span.
+ *
+ * @param document The document to annotate. Must not be {@code null}.
+ * @return A new {@link Document} with the {@link Layers#SENTENCES} layer added.
+ * Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code document} is {@code null} or
+ * already carries the {@link Layers#SENTENCES} layer.
+ */
@Override
public Document annotate(Document document) {
if (document == null) {
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
index 5c21111a65..e9384bd7b9 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
@@ -52,6 +52,16 @@ public TokenizerAnnotator(Tokenizer tokenizer) {
this.tokenizer = tokenizer;
}
+ /**
+ * Tokenizes the document and adds the {@link Layers#TOKENS} layer, sentence by
+ * sentence when a sentence layer is present and over the whole text otherwise.
+ *
+ * @param document The document to annotate. Must not be {@code null}.
+ * @return A new {@link Document} with the {@link Layers#TOKENS} layer added. Never
+ * {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code document} is {@code null} or
+ * already carries the {@link Layers#TOKENS} layer.
+ */
@Override
public Document annotate(Document document) {
if (document == null) {
@@ -70,6 +80,14 @@ public Document annotate(Document document) {
return document.with(Layers.TOKENS, tokens);
}
+ /**
+ * Tokenizes one stretch of text and appends its tokens, shifted back into document
+ * coordinates.
+ *
+ * @param tokens The layer under construction.
+ * @param text The stretch to tokenize.
+ * @param offset The stretch's start offset in the document text.
+ */
private void addTokens(List> tokens, String text, int offset) {
for (final Span span : tokenizer.tokenizePos(text)) {
final Span shifted = new Span(span.getStart() + offset, span.getEnd() + offset);
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
index e6d6b6a290..09efc6ab54 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
@@ -21,6 +21,7 @@
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
import org.junit.jupiter.api.Test;
@@ -38,22 +39,39 @@
*/
public class NameFinderAnnotatorTest {
- @Test
- void testTokenIndexSpansBecomeCharacterSpans() {
- final AtomicInteger cleared = new AtomicInteger();
- final TokenNameFinder finder = new TokenNameFinder() {
+ /**
+ * Builds a finder over a fixed find function, with adaptive-data clearing counted
+ * in the given counter when one is supplied.
+ *
+ * @param find Maps a sentence's tokens to its mentions.
+ * @param cleared Counts {@code clearAdaptiveData} calls, or {@code null} to ignore.
+ * @return The finder fixture. Never {@code null}.
+ */
+ private static TokenNameFinder finder(Function find,
+ AtomicInteger cleared) {
+ return new TokenNameFinder() {
@Override
public Span[] find(String[] tokens) {
- // "New York" as a two-token person-free location mention
- return new Span[] {new Span(1, 3, "location")};
+ return find.apply(tokens);
}
@Override
public void clearAdaptiveData() {
- cleared.incrementAndGet();
+ if (cleared != null) {
+ cleared.incrementAndGet();
+ }
}
};
+ }
+
+ @Test
+ void testTokenIndexSpansBecomeCharacterSpans() {
+ final AtomicInteger cleared = new AtomicInteger();
+ final TokenNameFinder finder = finder(tokens -> {
+ // "New York" as a two-token person-free location mention
+ return new Span[] {new Span(1, 3, "location")};
+ }, cleared);
final Document document = Document.of("in New York today")
.with(Layers.SENTENCES, List.of(
@@ -81,17 +99,7 @@ public void clearAdaptiveData() {
*/
@Test
void testMissingTokenLayerThrows() {
- final TokenNameFinder finder = new TokenNameFinder() {
-
- @Override
- public Span[] find(String[] tokens) {
- return new Span[0];
- }
-
- @Override
- public void clearAdaptiveData() {
- }
- };
+ final TokenNameFinder finder = finder(tokens -> new Span[0], null);
final Document document = Document.of("no tokens")
.with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 9), "no tokens")));
final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
@@ -106,17 +114,7 @@ public void clearAdaptiveData() {
*/
@Test
void testUntypedMentionRecordedAsUntyped() {
- final TokenNameFinder finder = new TokenNameFinder() {
-
- @Override
- public Span[] find(String[] tokens) {
- return new Span[] {new Span(0, 1)};
- }
-
- @Override
- public void clearAdaptiveData() {
- }
- };
+ final TokenNameFinder finder = finder(tokens -> new Span[] {new Span(0, 1)}, null);
final Document document = Document.of("Ana runs.")
.with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 9), "Ana runs.")))
.with(Layers.TOKENS, List.of(
@@ -140,19 +138,10 @@ public void clearAdaptiveData() {
@Test
void testMentionOutsideSentenceTokensFailsLoud() {
final AtomicInteger cleared = new AtomicInteger();
- final TokenNameFinder finder = new TokenNameFinder() {
-
- @Override
- public Span[] find(String[] tokens) {
- // two tokens in the sentence, but the mention claims three
- return new Span[] {new Span(0, 3, "person")};
- }
-
- @Override
- public void clearAdaptiveData() {
- cleared.incrementAndGet();
- }
- };
+ final TokenNameFinder finder = finder(tokens -> {
+ // two tokens in the sentence, but the mention claims three
+ return new Span[] {new Span(0, 3, "person")};
+ }, cleared);
final Document document = Document.of("Ana runs. Bob sits.")
.with(Layers.SENTENCES, List.of(
new Annotation<>(new Span(0, 9), "Ana runs."),
@@ -178,18 +167,7 @@ public void clearAdaptiveData() {
@Test
void testTokenOutsideEverySentenceThrowsAndStillClears() {
final AtomicInteger cleared = new AtomicInteger();
- final TokenNameFinder finder = new TokenNameFinder() {
-
- @Override
- public Span[] find(String[] tokens) {
- return new Span[0];
- }
-
- @Override
- public void clearAdaptiveData() {
- cleared.incrementAndGet();
- }
- };
+ final TokenNameFinder finder = finder(tokens -> new Span[0], cleared);
final Document document = Document.of("Ana runs. Bob")
.with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 9), "Ana runs.")))
.with(Layers.TOKENS, List.of(
@@ -213,20 +191,11 @@ public void clearAdaptiveData() {
void testFindsPerSentenceAndMapsSentenceLocalIndices() {
final List> calls = new ArrayList<>();
final AtomicInteger cleared = new AtomicInteger();
- final TokenNameFinder finder = new TokenNameFinder() {
-
- @Override
- public Span[] find(String[] tokens) {
- calls.add(List.of(tokens));
- // the first token of every sentence is a person mention, in sentence-local indices
- return new Span[] {new Span(0, 1, "person")};
- }
-
- @Override
- public void clearAdaptiveData() {
- cleared.incrementAndGet();
- }
- };
+ final TokenNameFinder finder = finder(tokens -> {
+ calls.add(List.of(tokens));
+ // the first token of every sentence is a person mention, in sentence-local indices
+ return new Span[] {new Span(0, 1, "person")};
+ }, cleared);
final Document document = Document.of("Ana runs. Bob sits.")
.with(Layers.SENTENCES, List.of(
@@ -259,17 +228,7 @@ public void clearAdaptiveData() {
*/
@Test
void testRequiresSentencesAndTokens() {
- final TokenNameFinder finder = new TokenNameFinder() {
-
- @Override
- public Span[] find(String[] tokens) {
- return new Span[0];
- }
-
- @Override
- public void clearAdaptiveData() {
- }
- };
+ final TokenNameFinder finder = finder(tokens -> new Span[0], null);
assertEquals(Set.of(Layers.SENTENCES, Layers.TOKENS),
new NameFinderAnnotator(finder).requires());
}
@@ -281,18 +240,10 @@ public void clearAdaptiveData() {
@Test
void testEmptyPresentLayersYieldEmptyEntityLayer() {
final AtomicInteger found = new AtomicInteger();
- final TokenNameFinder finder = new TokenNameFinder() {
-
- @Override
- public Span[] find(String[] tokens) {
- found.incrementAndGet();
- return new Span[0];
- }
-
- @Override
- public void clearAdaptiveData() {
- }
- };
+ final TokenNameFinder finder = finder(tokens -> {
+ found.incrementAndGet();
+ return new Span[0];
+ }, null);
final Document document = Document.of("")
.with(Layers.SENTENCES, List.of())
.with(Layers.TOKENS, List.of());
@@ -310,17 +261,7 @@ public void clearAdaptiveData() {
*/
@Test
void testAbsentSentenceLayerThrowsWithExactMessage() {
- final TokenNameFinder finder = new TokenNameFinder() {
-
- @Override
- public Span[] find(String[] tokens) {
- return new Span[0];
- }
-
- @Override
- public void clearAdaptiveData() {
- }
- };
+ final TokenNameFinder finder = finder(tokens -> new Span[0], null);
final Document document = Document.of("Ana")
.with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 3), "Ana")));
final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
index a7d44f1280..719bb7b6da 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
@@ -41,9 +41,10 @@ public class POSTaggerAnnotatorTest {
/**
* A tagger that records the exact token sequence of every call and answers with one
- * {@code "X"} tag per token, so the per-call slicing is observable.
+ * {@code "X"} tag per token, so the per-call slicing is observable. Tests override
+ * {@link #tag(String[])} where a deviant answer is the fixture.
*/
- private static final class RecordingTagger implements POSTagger {
+ private static class RecordingTagger implements POSTagger {
private final List> calls = new ArrayList<>();
@@ -208,27 +209,13 @@ void testSentenceWithoutTokensContributesNothing() {
*/
@Test
void testWrongTagCountFailsLoud() {
- final POSTagger shortTagger = new POSTagger() {
+ // one tag regardless of sentence length, so a two-token sentence trips the check
+ final POSTagger shortTagger = new RecordingTagger() {
@Override
public String[] tag(String[] sentence) {
return new String[] {"X"};
}
-
- @Override
- public String[] tag(String[] sentence, Object[] additionalContext) {
- return tag(sentence);
- }
-
- @Override
- public Sequence[] topKSequences(String[] sentence) {
- throw new UnsupportedOperationException("the adapter only calls tag");
- }
-
- @Override
- public Sequence[] topKSequences(String[] sentence, Object[] additionalContext) {
- throw new UnsupportedOperationException("the adapter only calls tag");
- }
};
final Document document = Document.of("The dog")
.with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 7), "The dog")))
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
index 9c8d5f5ff8..d4240ec2cf 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
@@ -48,6 +48,9 @@ public class LemmatizerAnnotator implements DocumentAnnotator {
*/
public static final LayerKey LEMMAS = LayerKey.of("lemmas", String.class);
+ /** The message prefix of every absent-required-layer rejection in this adapter. */
+ private static final String MISSING_LAYER = "document lacks the required layer ";
+
private final Lemmatizer lemmatizer;
/**
@@ -90,15 +93,15 @@ public Document annotate(Document document) {
throw new IllegalArgumentException("document must not be null");
}
if (!document.layers().contains(Layers.SENTENCES)) {
- throw new IllegalArgumentException("document lacks the required layer "
+ throw new IllegalArgumentException(MISSING_LAYER
+ Layers.SENTENCES);
}
if (!document.layers().contains(Layers.TOKENS)) {
- throw new IllegalArgumentException("document lacks the required layer "
+ throw new IllegalArgumentException(MISSING_LAYER
+ Layers.TOKENS);
}
if (!document.layers().contains(Layers.POS_TAGS)) {
- throw new IllegalArgumentException("document lacks the required layer "
+ throw new IllegalArgumentException(MISSING_LAYER
+ Layers.POS_TAGS);
}
final List> sentences = document.get(Layers.SENTENCES);
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
new file mode 100644
index 0000000000..99b1209539
--- /dev/null
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+ Document Annotation Container
+
+
+ Introduction
+
+ The package opennlp.tools.document provides an immutable container
+ that carries the original text of one document together with any number of typed
+ annotation layers over it. Every annotation is anchored to a
+ Span of the text exactly as the caller supplied it, never to a
+ normalized or otherwise derived form, so any result of any pipeline step can be
+ highlighted in the source text.
+
+
+ The design follows three rules:
+
+
+
+
+ Offset-anchored. A layer is a list of
+ Annotation values, each pairing a Span in original
+ text coordinates with a typed value. Annotations reference other annotations
+ by index within their layer, never by object identity.
+
+
+
+
+ Open key space. A layer is identified by a
+ LayerKey that also carries the type of its values, so reading a
+ layer back is statically typed. Two keys are equal when both their id and
+ their value type are equal, so independently created constants for the same
+ layer interoperate. The keys of the core linguistic layers live in
+ Layers; a capability-specific layer's key lives on the annotator
+ that provides it, and adding a capability never changes the container.
+
+
+
+
+ Immutable. Adding a layer returns a new
+ document that shares the unchanged layers with its ancestor. Documents are
+ safe to share between threads.
+
+
+
+
+
+
+ Building a pipeline
+
+ A pipeline step implements DocumentAnnotator: it reads layers from a
+ document and returns a new document with its own layers added. An annotator
+ declares the layers it requires and provides, and a
+ DocumentAnalyzer validates those declarations when the pipeline is
+ assembled: every required layer must be provided by an earlier annotator, and no
+ two annotators may provide the same layer, so a misordered pipeline fails at
+ build time rather than midway through a document.
+
+
+ Adapters for the toolkit's own components are provided:
+ SentenceDetectorAnnotator, TokenizerAnnotator,
+ POSTaggerAnnotator, NameFinderAnnotator,
+ LemmatizerAnnotator, and StemmerAnnotator. Each wraps
+ the existing single-task API, which stays the primary API for single-task use.
+ The following pipeline combines three adapters with one custom annotator and
+ analyzes a two-sentence text; the components behind the adapters are any
+ SentenceDetector, Tokenizer, and
+ POSTagger, for example the ME implementations loaded from models:
+
+
+
+
+
+ The resulting document carries exactly the four layers the pipeline provides.
+ The sentence layer holds two annotations, [0..14) covering
+ The dog barks. and [15..23) covering
+ It naps.. The token layer holds five tokens whose spans refer to
+ the document text even inside the second sentence, so
+ It is [15..17) and naps. is
+ [18..23). Layers produced per token stay aligned with the token
+ layer by position:
+
+
+ > tokens = document.get(Layers.TOKENS);
+List> tags = document.get(Layers.POS_TAGS);
+for (int i = 0; i < tags.size(); i++) {
+ // each tag sits on its token's span, e.g. "DT" on [0..3) for "The"
+ Span span = tags.get(i).span();
+}
+
+// every span refers to the original text, so covered text round-trips
+for (Annotation token : tokens) {
+ CharSequence covered = token.span().getCoveredText(document.text());
+}]]>
+
+
+
+
+ Writing a custom annotator
+
+ A new capability contributes its results as one more layer without any change to
+ the container. The annotator below defines its own key in its own code, reads
+ the token layer, and provides one integer annotation per token; the analyzer's
+ build-time validation guarantees a tokenizer ran earlier:
+
+
+ TOKEN_LENGTHS =
+ LayerKey.of("token-lengths", Integer.class);
+
+class TokenLengthAnnotator implements DocumentAnnotator {
+
+ @Override
+ public Document annotate(Document document) {
+ if (document == null) {
+ throw new IllegalArgumentException("document must not be null");
+ }
+ List> tokens = document.get(Layers.TOKENS);
+ List> lengths = new ArrayList<>(tokens.size());
+ for (Annotation token : tokens) {
+ lengths.add(new Annotation<>(token.span(), token.value().length()));
+ }
+ return document.with(TOKEN_LENGTHS, lengths);
+ }
+
+ @Override
+ public Set> requires() {
+ return Set.of(Layers.TOKENS);
+ }
+
+ @Override
+ public Set> provides() {
+ return Set.of(TOKEN_LENGTHS);
+ }
+}]]>
+
+
+ Reading the layer back is statically typed by the key, so the values are used as
+ numbers without a cast; for the text above the five values are
+ 3, 3, 6, 2, 5, each on its token's span:
+
+
+ > lengths = document.get(TOKEN_LENGTHS);
+int firstTokenLength = lengths.get(0).value(); // 3, for "The" at [0..3)]]>
+
+
+ Required layers must be present, but they may be empty: an empty token layer
+ yields the annotator's provided layers present but empty, so a pipeline degrades
+ gracefully on documents without content. An absent required layer is rejected
+ with an IllegalArgumentException naming the layer, because a
+ missing pipeline stage is an assembly error, not an empty document.
+
+
+
diff --git a/opennlp-docs/src/docbkx/opennlp.xml b/opennlp-docs/src/docbkx/opennlp.xml
index 36641c2c89..0761fc95ff 100644
--- a/opennlp-docs/src/docbkx/opennlp.xml
+++ b/opennlp-docs/src/docbkx/opennlp.xml
@@ -109,6 +109,7 @@ under the License.
+
From 1ba2fcdae336fbb18da55ff1148e42193377ab5b Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Fri, 17 Jul 2026 09:28:31 -0400
Subject: [PATCH 08/92] OPENNLP-1888: Namespace the standard layer key ids
Every key the toolkit defines now carries the opennlp: id prefix
(opennlp:sentences, opennlp:tokens, opennlp:pos, opennlp:entities,
opennlp:lemmas, opennlp:stems). An extension defines its keys under its own
prefix, and a bare id stays legal for an application-local layer, so ids from
independent producers cannot collide. The rule is stated on Layers, LayerKey,
and in the manual chapter.
---
.../main/java/opennlp/tools/document/LayerKey.java | 6 ++++--
.../main/java/opennlp/tools/document/Layers.java | 13 +++++++++----
.../tools/document/DocumentContractTest.java | 2 +-
.../tools/document/NameFinderAnnotatorTest.java | 4 ++--
.../tools/document/POSTaggerAnnotatorTest.java | 4 ++--
.../tools/lemmatizer/LemmatizerAnnotator.java | 2 +-
.../opennlp/tools/stemmer/StemmerAnnotator.java | 2 +-
.../tools/lemmatizer/LemmatizerAnnotatorTest.java | 6 +++---
.../opennlp/tools/stemmer/StemmerAnnotatorTest.java | 2 +-
opennlp-docs/src/docbkx/document.xml | 6 +++++-
10 files changed, 29 insertions(+), 18 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
index 1f8ac378d4..fdaaac84a1 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
@@ -48,8 +48,10 @@ private LayerKey(String id, Class type) {
/**
* Creates a {@link LayerKey}.
*
- * @param id The layer identifier, for example {@code tokens}. Must not be {@code null}
- * or blank.
+ * @param id The layer identifier, for example {@code opennlp:tokens}. Keys defined by
+ * the toolkit carry the {@code opennlp:} prefix, an extension uses its own
+ * prefix, and a bare id is legal for an application-local layer. Must not
+ * be {@code null} or blank.
* @param type The class of the annotation values stored under the key. Must not be
* {@code null}.
* @param The type of the annotation values.
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
index f5808aed1c..75fd4cd8d5 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
@@ -24,6 +24,11 @@
* producer may define further keys in its own package. New capabilities must never
* require an addition here to function.
*
+ *
Namespace rule: every key the toolkit itself defines carries the
+ * {@code opennlp:} id prefix. An extension defines its keys under its own prefix, and
+ * a bare id without a prefix is legal for an application-local layer, so ids from
+ * independent producers cannot collide.
+ *
*
Placement rule: this class holds only the keys of the core linguistic layers
* every pipeline shares (sentences, tokens, tags, entities). A capability-specific
* layer's key lives on the annotator that provides it, for example the lemma layer's
@@ -36,23 +41,23 @@ public final class Layers {
/**
* Sentence boundaries; each annotation covers one sentence and carries its text.
*/
- public static final LayerKey SENTENCES = LayerKey.of("sentences", String.class);
+ public static final LayerKey SENTENCES = LayerKey.of("opennlp:sentences", String.class);
/**
* Token boundaries; each annotation covers one token and carries its text.
*/
- public static final LayerKey TOKENS = LayerKey.of("tokens", String.class);
+ public static final LayerKey TOKENS = LayerKey.of("opennlp:tokens", String.class);
/**
* Part-of-speech tags; one annotation per token, aligned with {@link #TOKENS} by
* position, carrying the tag.
*/
- public static final LayerKey POS_TAGS = LayerKey.of("pos", String.class);
+ public static final LayerKey POS_TAGS = LayerKey.of("opennlp:pos", String.class);
/**
* Named entities; each annotation covers one mention and carries the entity type.
*/
- public static final LayerKey ENTITIES = LayerKey.of("entities", String.class);
+ public static final LayerKey ENTITIES = LayerKey.of("opennlp:entities", String.class);
private Layers() {
// This class holds constants only and is never instantiated.
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index f46701ef12..f71b590c16 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -234,7 +234,7 @@ public String toString() {
final DocumentAnalyzer.Builder builder = DocumentAnalyzer.builder().add(needsTags);
final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, builder::build);
- assertEquals("annotator tag-consumer requires layer pos,"
+ assertEquals("annotator tag-consumer requires layer opennlp:pos,"
+ " which no earlier annotator provides", e.getMessage());
}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
index 09efc6ab54..99f80dae03 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
@@ -104,7 +104,7 @@ void testMissingTokenLayerThrows() {
.with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 9), "no tokens")));
final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new NameFinderAnnotator(finder).annotate(document));
- assertEquals("document lacks the required layer tokens", e.getMessage());
+ assertEquals("document lacks the required layer opennlp:tokens", e.getMessage());
}
/**
@@ -266,6 +266,6 @@ void testAbsentSentenceLayerThrowsWithExactMessage() {
.with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 3), "Ana")));
final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new NameFinderAnnotator(finder).annotate(document));
- assertEquals("document lacks the required layer sentences", e.getMessage());
+ assertEquals("document lacks the required layer opennlp:sentences", e.getMessage());
}
}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
index 719bb7b6da..ccd7b0cfe0 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/POSTaggerAnnotatorTest.java
@@ -152,7 +152,7 @@ void testAbsentSentenceLayerThrowsWithExactMessage() {
new Annotation<>(new Span(4, 7), "dog")));
final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new POSTaggerAnnotator(new RecordingTagger()).annotate(document));
- assertEquals("document lacks the required layer sentences", e.getMessage());
+ assertEquals("document lacks the required layer opennlp:sentences", e.getMessage());
}
/**
@@ -165,7 +165,7 @@ void testAbsentTokenLayerThrowsWithExactMessage() {
.with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 7), "The dog")));
final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new POSTaggerAnnotator(new RecordingTagger()).annotate(document));
- assertEquals("document lacks the required layer tokens", e.getMessage());
+ assertEquals("document lacks the required layer opennlp:tokens", e.getMessage());
}
/**
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
index d4240ec2cf..c3f5897d6b 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
@@ -46,7 +46,7 @@ public class LemmatizerAnnotator implements DocumentAnnotator {
* The lemma layer. It is aligned with the token layer by position, and each annotation
* carries the lemma of its token on that token's span.
*/
- public static final LayerKey LEMMAS = LayerKey.of("lemmas", String.class);
+ public static final LayerKey LEMMAS = LayerKey.of("opennlp:lemmas", String.class);
/** The message prefix of every absent-required-layer rejection in this adapter. */
private static final String MISSING_LAYER = "document lacks the required layer ";
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
index 330087f5d8..015779ee07 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
@@ -42,7 +42,7 @@ public class StemmerAnnotator implements DocumentAnnotator {
* The stem layer. It is aligned with the token layer by position, and each annotation
* carries the stem of its token on that token's span.
*/
- public static final LayerKey STEMS = LayerKey.of("stems", String.class);
+ public static final LayerKey STEMS = LayerKey.of("opennlp:stems", String.class);
private final Stemmer stemmer;
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
index 0b8fe30def..d31f092104 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
@@ -139,21 +139,21 @@ void testAbsentRequiredLayerThrowsWithExactMessage() {
final LemmatizerAnnotator annotator = new LemmatizerAnnotator(FIXTURE);
final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
() -> annotator.annotate(Document.of("no layers")));
- Assertions.assertEquals("document lacks the required layer sentences",
+ Assertions.assertEquals("document lacks the required layer opennlp:sentences",
e.getMessage());
final Document sentencesOnly = Document.of("a")
.with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 1), "a")));
final IllegalArgumentException tokenless = Assertions.assertThrows(
IllegalArgumentException.class, () -> annotator.annotate(sentencesOnly));
- Assertions.assertEquals("document lacks the required layer tokens",
+ Assertions.assertEquals("document lacks the required layer opennlp:tokens",
tokenless.getMessage());
final Document untagged = sentencesOnly
.with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 1), "a")));
final IllegalArgumentException tagless = Assertions.assertThrows(
IllegalArgumentException.class, () -> annotator.annotate(untagged));
- Assertions.assertEquals("document lacks the required layer pos",
+ Assertions.assertEquals("document lacks the required layer opennlp:pos",
tagless.getMessage());
}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
index 86b3a82ec7..3f84b5303e 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
@@ -63,7 +63,7 @@ void testAbsentTokenLayerThrowsWithExactMessage() {
final StemmerAnnotator annotator = new StemmerAnnotator(new PorterStemmer());
final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
() -> annotator.annotate(Document.of("no tokens")));
- Assertions.assertEquals("document lacks the required layer tokens", e.getMessage());
+ Assertions.assertEquals("document lacks the required layer opennlp:tokens", e.getMessage());
}
/**
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index 99b1209539..980bd44d09 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -47,7 +47,11 @@
their value type are equal, so independently created constants for the same
layer interoperate. The keys of the core linguistic layers live in
Layers; a capability-specific layer's key lives on the annotator
- that provides it, and adding a capability never changes the container.
+ that provides it, and adding a capability never changes the container. Ids
+ are namespaced: keys the toolkit defines carry the opennlp:
+ prefix, an extension uses its own prefix, and a bare id, like the
+ token-lengths key below, is legal for an application-local
+ layer.
From f8b2ee97ebd3e150f20a82e535b14dfc4bfb7ce9 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Fri, 17 Jul 2026 09:32:17 -0400
Subject: [PATCH 09/92] OPENNLP-1888: Declare per-key positional or document
scope
A layer key now declares whether its layer is positional or document-scoped.
A positional key, the default, guarantees a span on every annotation, so
consumers never null-check one. A document-scoped key, created through
LayerKey.document, carries whole-document values without spans, the home for
a language id, a category distribution, or provenance. The scope is declared
per key, never per annotation: the container rejects a span-less annotation
under a positional key and a spanned annotation under a document-scoped key,
naming the layer either way. Scope participates in key equality.
---
.../opennlp/tools/document/Annotation.java | 35 ++++++---
.../java/opennlp/tools/document/Document.java | 10 ++-
.../tools/document/ImmutableDocument.java | 15 +++-
.../java/opennlp/tools/document/LayerKey.java | 74 ++++++++++++++++---
.../tools/document/DocumentContractTest.java | 39 ++++++++++
.../document/DocumentPipelineExampleTest.java | 16 ++++
.../opennlp/tools/document/DocumentTest.java | 8 +-
opennlp-docs/src/docbkx/document.xml | 19 ++++-
8 files changed, 186 insertions(+), 30 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Annotation.java b/opennlp-api/src/main/java/opennlp/tools/document/Annotation.java
index eaed6bf7f4..3d3aaa1fd0 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Annotation.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Annotation.java
@@ -21,16 +21,20 @@
/**
* One annotation of a {@link Document}: a typed value anchored to a {@link Span} of the
- * document's original text.
+ * document's original text, or a span-less value under a
+ * {@link LayerKey.Scope#DOCUMENT document-scoped} key.
*
*
The span always refers to the text the document was created with, never to a
* normalized or otherwise derived form, so any annotation can be highlighted in what the
- * caller supplied. Annotations that need to reference other annotations, for example a
- * dependency arc naming its head token, do so by the index of the target annotation
- * within its layer, never by object identity.
+ * caller supplied. Whether a span is present is decided by the layer key's scope, not
+ * per annotation: the container rejects a span-less annotation under a positional key
+ * and a spanned annotation under a document-scoped key. Annotations that need to
+ * reference other annotations, for example a dependency arc naming its head token, do
+ * so by the index of the target annotation within its layer, never by object
+ * identity.
*
- * @param span The location of the annotation in the original text. Must not be
- * {@code null}.
+ * @param span The location of the annotation in the original text, or {@code null} for
+ * a value under a document-scoped key.
* @param value The annotation value. Must not be {@code null}.
* @param The type of the annotation value.
*
@@ -41,15 +45,24 @@ public record Annotation(Span span, T value) {
/**
* Validates the annotation.
*
- * @throws IllegalArgumentException Thrown if {@code span} or {@code value} is
- * {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code value} is {@code null}.
*/
public Annotation {
- if (span == null) {
- throw new IllegalArgumentException("span must not be null");
- }
if (value == null) {
throw new IllegalArgumentException("value must not be null");
}
}
+
+ /**
+ * Creates a span-less annotation for a {@link LayerKey.Scope#DOCUMENT
+ * document-scoped} layer.
+ *
+ * @param value The annotation value. Must not be {@code null}.
+ * @param The type of the annotation value.
+ * @return An {@link Annotation} without a span. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code value} is {@code null}.
+ */
+ public static Annotation of(T value) {
+ return new Annotation<>(null, value);
+ }
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index 53453ff856..2c0a2f2aec 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -28,7 +28,9 @@
* {@link LayerKey}. The container itself knows nothing about specific layers; every
* analysis capability contributes its results as one more layer without any change to
* this interface, which is what keeps new capabilities additive. All spans refer to
- * {@link #text()} as supplied, never to a derived form.
+ * {@link #text()} as supplied, never to a derived form. A
+ * {@link LayerKey.Scope#DOCUMENT document-scoped} layer carries whole-document values
+ * without spans, for example a language id.
*
*
Documents are immutable: {@link #with(LayerKey, List)} returns a new document that
* shares the unchanged layers. Instances are safe to share between threads.
@@ -76,8 +78,10 @@ static Document of(CharSequence text) {
* @param layer The key of the layer to add. Must not be {@code null} and must not
* already be present.
* @param annotations The annotations of the layer. Must not be {@code null}, must not
- * contain {@code null}, every value must be assignable to the
- * layer's type, and every span must lie within the text bounds.
+ * contain {@code null}, and every value must be assignable to the
+ * layer's type. Under a positional key every annotation must carry
+ * a span within the text bounds; under a document-scoped key no
+ * annotation may carry a span.
* @param The type of the layer's annotation values.
* @return A new {@link Document} sharing this document's text and existing layers.
* Never {@code null}.
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
index 43276b4cef..e2ca8cd5f3 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
@@ -96,9 +96,18 @@ public Document with(LayerKey layer, List> annotations) {
+ annotation.value().getClass().getName() + " does not match layer " + layer);
}
final Span span = annotation.span();
- if (span.getEnd() > text.length()) {
- throw new IllegalArgumentException("span " + span + " exceeds the text length "
- + text.length() + " in layer " + layer);
+ if (layer.scope() == LayerKey.Scope.POSITIONAL) {
+ if (span == null) {
+ throw new IllegalArgumentException(
+ "positional layer " + layer + " requires a span on every annotation");
+ }
+ if (span.getEnd() > text.length()) {
+ throw new IllegalArgumentException("span " + span + " exceeds the text length "
+ + text.length() + " in layer " + layer);
+ }
+ } else if (span != null) {
+ throw new IllegalArgumentException(
+ "document-scoped layer " + layer + " must not carry spans");
}
}
final Map, List>> grown = new LinkedHashMap<>(layers);
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
index fdaaac84a1..7d72411bd5 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
@@ -26,10 +26,16 @@
* layer's annotation values, so reading a layer back is statically typed.
*
*
The key space is deliberately open: any producer may define new keys in its own
- * package, and the container never enumerates them. Two keys are equal when both their
- * id and their value type are equal, so independently created constants for the same
- * layer interoperate. Standard keys for the toolkit's own results live in
- * {@link Layers}.
+ * package, and the container never enumerates them. Two keys are equal when their id,
+ * their value type, and their {@link Scope} are equal, so independently created
+ * constants for the same layer interoperate. Standard keys for the toolkit's own
+ * results live in {@link Layers}.
+ *
+ *
A key declares its {@link Scope}: a {@link Scope#POSITIONAL positional} key
+ * guarantees a span on every annotation, and a {@link Scope#DOCUMENT document-scoped}
+ * key carries whole-document values without spans, for example a language id or a
+ * category distribution. The scope is declared per key, never per annotation, so
+ * consumers of a positional layer never null-check a span.
*
* @param The type of the annotation values stored under this key.
*
@@ -37,12 +43,22 @@
*/
public final class LayerKey {
+ /** How the annotations of a layer relate to the document text. */
+ public enum Scope {
+ /** Every annotation of the layer is anchored to a span of the text. */
+ POSITIONAL,
+ /** The layer's values describe the document as a whole and carry no spans. */
+ DOCUMENT
+ }
+
private final String id;
private final Class type;
+ private final Scope scope;
- private LayerKey(String id, Class type) {
+ private LayerKey(String id, Class type, Scope scope) {
this.id = id;
this.type = type;
+ this.scope = scope;
}
/**
@@ -55,18 +71,51 @@ private LayerKey(String id, Class type) {
* @param type The class of the annotation values stored under the key. Must not be
* {@code null}.
* @param The type of the annotation values.
- * @return A {@link LayerKey}. Never {@code null}.
+ * @return A {@link Scope#POSITIONAL positional} {@link LayerKey}. Never {@code null}.
* @throws IllegalArgumentException Thrown if {@code id} is {@code null} or blank, or
* {@code type} is {@code null}.
*/
public static LayerKey of(String id, Class type) {
+ return key(id, type, Scope.POSITIONAL);
+ }
+
+ /**
+ * Creates a {@link Scope#DOCUMENT document-scoped} {@link LayerKey} for values that
+ * describe the document as a whole, for example a language id, a category
+ * distribution, or provenance. Annotations under such a key carry no span.
+ *
+ * @param id The layer identifier, following the same prefix rules as
+ * {@link #of(String, Class)}. Must not be {@code null} or blank.
+ * @param type The class of the annotation values stored under the key. Must not be
+ * {@code null}.
+ * @param The type of the annotation values.
+ * @return A document-scoped {@link LayerKey}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code id} is {@code null} or blank, or
+ * {@code type} is {@code null}.
+ */
+ public static LayerKey document(String id, Class type) {
+ return key(id, type, Scope.DOCUMENT);
+ }
+
+ /**
+ * Validates the components and creates the key.
+ *
+ * @param id The layer identifier.
+ * @param type The value class.
+ * @param scope The declared scope.
+ * @param The type of the annotation values.
+ * @return The {@link LayerKey}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code id} is {@code null} or blank, or
+ * {@code type} is {@code null}.
+ */
+ private static LayerKey key(String id, Class type, Scope scope) {
if (id == null || StringUtil.isBlank(id)) {
throw new IllegalArgumentException("id must not be null or blank");
}
if (type == null) {
throw new IllegalArgumentException("type must not be null");
}
- return new LayerKey<>(id, type);
+ return new LayerKey<>(id, type, scope);
}
/**
@@ -83,6 +132,13 @@ public Class type() {
return type;
}
+ /**
+ * @return The declared scope of the layer. Never {@code null}.
+ */
+ public Scope scope() {
+ return scope;
+ }
+
@Override
public boolean equals(Object obj) {
if (this == obj) {
@@ -91,12 +147,12 @@ public boolean equals(Object obj) {
if (!(obj instanceof LayerKey> other)) {
return false;
}
- return id.equals(other.id) && type.equals(other.type);
+ return id.equals(other.id) && type.equals(other.type) && scope == other.scope;
}
@Override
public int hashCode() {
- return Objects.hash(id, type);
+ return Objects.hash(id, type, scope);
}
@Override
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index f71b590c16..3d0ab766d3 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -28,6 +28,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -83,6 +84,44 @@ void testKeysWithSameIdButDifferentTypesAreDifferentLayers() {
assertEquals(7, document.get(asInteger).get(0).value());
}
+ /**
+ * Verifies the per-key scope contract: a document-scoped layer carries span-less
+ * values and round-trips them, a spanned annotation under a document-scoped key is
+ * rejected with a message naming the layer, a span-less annotation under a
+ * positional key is rejected likewise, and two keys differing only in scope are
+ * unequal and never address the same layer.
+ */
+ @Test
+ void testDocumentScopedLayersCarrySpanlessValues() {
+ final LayerKey language = LayerKey.document("language", String.class);
+ assertEquals(LayerKey.Scope.DOCUMENT, language.scope());
+ assertEquals(LayerKey.Scope.POSITIONAL, WORDS.scope());
+
+ final Document document = Document.of("the dog")
+ .with(language, List.of(Annotation.of("eng")));
+ assertEquals(1, document.get(language).size());
+ assertEquals("eng", document.get(language).get(0).value());
+ assertNull(document.get(language).get(0).span());
+
+ final IllegalArgumentException spanned = assertThrows(IllegalArgumentException.class,
+ () -> Document.of("the").with(language,
+ List.of(new Annotation<>(new Span(0, 3), "eng"))));
+ assertEquals("document-scoped layer language must not carry spans",
+ spanned.getMessage());
+
+ final IllegalArgumentException spanless = assertThrows(IllegalArgumentException.class,
+ () -> Document.of("the").with(WORDS, List.of(Annotation.of("the"))));
+ assertEquals("positional layer words requires a span on every annotation",
+ spanless.getMessage());
+
+ final LayerKey positionalTwin = LayerKey.of("language", String.class);
+ assertNotEquals(language, positionalTwin);
+ final Document both = Document.of("the")
+ .with(language, List.of(Annotation.of("eng")))
+ .with(positionalTwin, List.of(new Annotation<>(new Span(0, 3), "the")));
+ assertEquals(2, both.layers().size());
+ }
+
/**
* Verifies that a layer preserves the insertion order of its annotations: the
* container does not sort by span, so a producer that wants span order must supply
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentPipelineExampleTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentPipelineExampleTest.java
index 31a2379380..14eeda39a4 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentPipelineExampleTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentPipelineExampleTest.java
@@ -31,6 +31,7 @@
import opennlp.tools.util.Span;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Walks through the document pipeline the way a first-time user would: wrap existing
@@ -190,6 +191,21 @@ public Set> provides() {
}
}
+ /**
+ * Mirrors the manual's document-scoped layer example: a language id rides a
+ * document-scoped key as a span-less value with exactly the id, value, and null span
+ * the chapter shows.
+ */
+ @Test
+ void testDocumentScopedLayerExample() {
+ final LayerKey language = LayerKey.document("app:language", String.class);
+ final Document document = Document.of("The dog barks. It naps.");
+
+ final Document tagged = document.with(language, List.of(Annotation.of("eng")));
+ assertEquals("eng", tagged.get(language).get(0).value());
+ assertNull(tagged.get(language).get(0).span());
+ }
+
/**
* Runs the full pipeline story: sentences, tokens, part-of-speech tags, and one custom
* layer over a two-sentence text, then verifies every annotation of every layer, span
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java
index 5af4908bc8..846007652f 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java
@@ -112,8 +112,11 @@ void testNullArgumentsThrow() {
@Test
void testAnnotationValidation() {
- assertThrows(IllegalArgumentException.class, () -> new Annotation<>(null, "the"));
+ // a span-less annotation is legal to build; the container judges it per key scope
assertThrows(IllegalArgumentException.class, () -> new Annotation<>(new Span(0, 3), null));
+ assertThrows(IllegalArgumentException.class, () -> Annotation.of(null));
+ assertThrows(IllegalArgumentException.class, () -> Document.of("the")
+ .with(WORDS, List.of(Annotation.of("the"))));
}
@Test
@@ -121,5 +124,8 @@ void testLayerKeyValidation() {
assertThrows(IllegalArgumentException.class, () -> LayerKey.of(" ", String.class));
assertThrows(IllegalArgumentException.class, () -> LayerKey.of(null, String.class));
assertThrows(IllegalArgumentException.class, () -> LayerKey.of("words", null));
+ assertThrows(IllegalArgumentException.class, () -> LayerKey.document(" ", String.class));
+ assertThrows(IllegalArgumentException.class, () -> LayerKey.document(null, String.class));
+ assertThrows(IllegalArgumentException.class, () -> LayerKey.document("lang", null));
}
}
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index 980bd44d09..b8ef351851 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -43,9 +43,9 @@
Open key space. A layer is identified by a
LayerKey that also carries the type of its values, so reading a
- layer back is statically typed. Two keys are equal when both their id and
- their value type are equal, so independently created constants for the same
- layer interoperate. The keys of the core linguistic layers live in
+ layer back is statically typed. Two keys are equal when their id, their
+ value type, and their scope are equal, so independently created constants
+ for the same layer interoperate. The keys of the core linguistic layers live in
Layers; a capability-specific layer's key lives on the annotator
that provides it, and adding a capability never changes the container. Ids
are namespaced: keys the toolkit defines carry the opennlp:
@@ -62,6 +62,19 @@
+
+ A key declares its scope. Keys are positional by default: every annotation
+ carries a span, and consumers never null-check it. A document-scoped key
+ carries whole-document values without spans, which is where a language id,
+ a category distribution, or provenance belongs:
+
+
+ LANGUAGE = LayerKey.document("app:language", String.class);
+
+Document tagged = document.with(LANGUAGE, List.of(Annotation.of("eng")));
+String language = tagged.get(LANGUAGE).get(0).value(); // "eng", span is null]]>
+
From b39dd25d1dfb921391fd38185908ba1006b69ae3 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Fri, 17 Jul 2026 09:32:40 -0400
Subject: [PATCH 10/92] OPENNLP-1888: State the index-reference invariants in
the specification text
The three invariants the contract tests already enforce are now stated on the
Document interface and in the manual chapter: layers preserve insertion order
and are never reordered, layers are immutable once added and detached from
the caller's input list, and adding a layer is once-only with the rejection
naming the key. Together they keep index-based references between layers
valid for the lifetime of the document.
---
.../src/main/java/opennlp/tools/document/Document.java | 9 +++++++++
opennlp-docs/src/docbkx/document.xml | 6 +++++-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index 2c0a2f2aec..dc4be9616c 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -35,6 +35,15 @@
*
Documents are immutable: {@link #with(LayerKey, List)} returns a new document that
* shares the unchanged layers. Instances are safe to share between threads.
*
+ *
Three invariants make index-based references sound. A layer preserves its
+ * insertion order, and the container never sorts or reorders it. A layer is immutable
+ * once added: the returned lists reject modification and are detached from the
+ * caller's input list. Providing a layer that already exists is rejected loudly: the
+ * add is once-only, and the exception names the offending key. An annotation that
+ * references another annotation by its index within a layer, for example a dependency
+ * arc naming its head token, therefore stays valid for the lifetime of the
+ * document.
+ *
* @since 3.0.0
*/
public interface Document {
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index b8ef351851..bf9b4b10f9 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -58,7 +58,11 @@
Immutable. Adding a layer returns a new
document that shares the unchanged layers with its ancestor. Documents are
- safe to share between threads.
+ safe to share between threads. Three invariants make index references
+ sound: a layer preserves its insertion order and is never sorted or
+ reordered, a layer is immutable once added and detached from the caller's
+ input list, and providing a layer that already exists is rejected, so an
+ index reference stays valid for the lifetime of the document.
From e4d6ed7189f167753c83e52adc90df991d1a7ff7 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Fri, 17 Jul 2026 09:33:29 -0400
Subject: [PATCH 11/92] OPENNLP-1888: Document the gold-layer convention
A corpus may carry a hand-annotated version of a layer beside a produced one.
The convention is a gold: id prefix on the same key scheme, for example
gold:opennlp:tokens beside opennlp:tokens. Because adding a layer is
once-only, competing versions of a layer always live under distinct keys and
never replace each other. Stated on Layers and in the manual chapter, with a
contract test pinning the coexistence.
---
.../java/opennlp/tools/document/Layers.java | 6 ++++++
.../tools/document/DocumentContractTest.java | 20 +++++++++++++++++++
opennlp-docs/src/docbkx/document.xml | 7 +++++++
3 files changed, 33 insertions(+)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
index 75fd4cd8d5..cec6960eb2 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
@@ -29,6 +29,12 @@
* a bare id without a prefix is legal for an application-local layer, so ids from
* independent producers cannot collide.
*
+ *
Gold versus predicted: a corpus may carry a hand-annotated version of a layer
+ * beside a produced one. The convention is a {@code gold:} id prefix on the same key
+ * scheme, for example {@code gold:opennlp:tokens} beside {@code opennlp:tokens}.
+ * Because adding a layer is once-only, competing versions of a layer always live under
+ * distinct keys and never replace each other.
+ *
*
Placement rule: this class holds only the keys of the core linguistic layers
* every pipeline shares (sentences, tokens, tags, entities). A capability-specific
* layer's key lives on the annotator that provides it, for example the lemma layer's
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index 3d0ab766d3..9ccd37457b 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -122,6 +122,26 @@ void testDocumentScopedLayersCarrySpanlessValues() {
assertEquals(2, both.layers().size());
}
+ /**
+ * Verifies the gold-layer convention: a hand-annotated version of a layer lives
+ * under the {@code gold:} prefixed key beside the produced layer, both are readable
+ * independently, and the once-only rule keeps either from replacing the other.
+ */
+ @Test
+ void testGoldLayerLivesBesideThePredictedLayer() {
+ final LayerKey gold = LayerKey.of("gold:" + Layers.TOKENS.id(), String.class);
+ final Document document = Document.of("the dog")
+ .with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 3), "the")))
+ .with(gold, List.of(
+ new Annotation<>(new Span(0, 3), "the"),
+ new Annotation<>(new Span(4, 7), "dog")));
+ assertEquals("gold:opennlp:tokens", gold.id());
+ assertEquals(1, document.get(Layers.TOKENS).size());
+ assertEquals(2, document.get(gold).size());
+ assertThrows(IllegalArgumentException.class,
+ () -> document.with(gold, List.of()));
+ }
+
/**
* Verifies that a layer preserves the insertion order of its annotations: the
* container does not sort by span, so a producer that wants span order must supply
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index bf9b4b10f9..328163b5ae 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -79,6 +79,13 @@ LayerKey LANGUAGE = LayerKey.document("app:language", String.class);
Document tagged = document.with(LANGUAGE, List.of(Annotation.of("eng")));
String language = tagged.get(LANGUAGE).get(0).value(); // "eng", span is null]]>
+
+ A corpus may carry a hand-annotated version of a layer beside a produced one.
+ The convention is a gold: id prefix on the same key scheme, for
+ example gold:opennlp:tokens beside opennlp:tokens;
+ since adding a layer is once-only, competing versions always live under
+ distinct keys and never replace each other.
+
From 266e780d920e4567bbfdc03d9ee3d72b877a590d Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Fri, 17 Jul 2026 12:14:57 -0400
Subject: [PATCH 12/92] OPENNLP-1888: Create toolkit layer keys through
namespace-applying factories on Layers
---
.../java/opennlp/tools/document/Layers.java | 72 +++++++++++++++++--
.../tools/document/DocumentContractTest.java | 28 ++++++++
.../tools/lemmatizer/LemmatizerAnnotator.java | 2 +-
.../tools/stemmer/StemmerAnnotator.java | 2 +-
opennlp-docs/src/docbkx/document.xml | 3 +-
5 files changed, 99 insertions(+), 8 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
index cec6960eb2..23322b158b 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
@@ -17,6 +17,8 @@
package opennlp.tools.document;
+import opennlp.tools.util.StringUtil;
+
/**
* The standard {@link LayerKey layer keys} for the results the toolkit produces itself.
*
@@ -27,7 +29,9 @@
*
Namespace rule: every key the toolkit itself defines carries the
* {@code opennlp:} id prefix. An extension defines its keys under its own prefix, and
* a bare id without a prefix is legal for an application-local layer, so ids from
- * independent producers cannot collide.
+ * independent producers cannot collide. Toolkit keys are created through
+ * {@link #key(String, Class)} and {@link #documentKey(String, Class)}, which apply the
+ * prefix, so no producer spells it.
*
*
Gold versus predicted: a corpus may carry a hand-annotated version of a layer
* beside a produced one. The convention is a {@code gold:} id prefix on the same key
@@ -44,26 +48,84 @@
*/
public final class Layers {
+ /** The id prefix of every key the toolkit defines. */
+ private static final String NAMESPACE = "opennlp:";
+
/**
* Sentence boundaries; each annotation covers one sentence and carries its text.
*/
- public static final LayerKey SENTENCES = LayerKey.of("opennlp:sentences", String.class);
+ public static final LayerKey SENTENCES = key("sentences", String.class);
/**
* Token boundaries; each annotation covers one token and carries its text.
*/
- public static final LayerKey TOKENS = LayerKey.of("opennlp:tokens", String.class);
+ public static final LayerKey TOKENS = key("tokens", String.class);
/**
* Part-of-speech tags; one annotation per token, aligned with {@link #TOKENS} by
* position, carrying the tag.
*/
- public static final LayerKey POS_TAGS = LayerKey.of("opennlp:pos", String.class);
+ public static final LayerKey POS_TAGS = key("pos", String.class);
/**
* Named entities; each annotation covers one mention and carries the entity type.
*/
- public static final LayerKey ENTITIES = LayerKey.of("opennlp:entities", String.class);
+ public static final LayerKey ENTITIES = key("entities", String.class);
+
+ /**
+ * Creates a {@link LayerKey.Scope#POSITIONAL positional} key in the toolkit's
+ * {@code opennlp:} namespace, for a layer the toolkit itself produces.
+ *
+ * @param name The layer name without a namespace, for example {@code tokens}. Must
+ * not be {@code null}, blank, or contain {@code ':'}.
+ * @param type The class of the annotation values stored under the key. Must not be
+ * {@code null}.
+ * @param The type of the annotation values.
+ * @return A key whose id is the name under the {@code opennlp:} prefix. Never
+ * {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code name} is {@code null}, blank, or
+ * contains {@code ':'}, or {@code type} is {@code null}.
+ */
+ public static LayerKey key(String name, Class type) {
+ return LayerKey.of(NAMESPACE + validName(name), type);
+ }
+
+ /**
+ * Creates a {@link LayerKey.Scope#DOCUMENT document-scoped} key in the toolkit's
+ * {@code opennlp:} namespace, for a whole-document value the toolkit itself produces.
+ *
+ * @param name The layer name without a namespace, for example {@code language}. Must
+ * not be {@code null}, blank, or contain {@code ':'}.
+ * @param type The class of the annotation values stored under the key. Must not be
+ * {@code null}.
+ * @param The type of the annotation values.
+ * @return A document-scoped key whose id is the name under the {@code opennlp:}
+ * prefix. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code name} is {@code null}, blank, or
+ * contains {@code ':'}, or {@code type} is {@code null}.
+ */
+ public static LayerKey documentKey(String name, Class type) {
+ return LayerKey.document(NAMESPACE + validName(name), type);
+ }
+
+ /**
+ * Validates a namespace-free layer name.
+ *
+ * @param name The name to validate.
+ * @return The validated name.
+ * @throws IllegalArgumentException Thrown if {@code name} is {@code null}, blank, or
+ * contains {@code ':'}.
+ */
+ private static String validName(String name) {
+ if (name == null || StringUtil.isBlank(name)) {
+ throw new IllegalArgumentException("name must not be null or blank");
+ }
+ if (name.indexOf(':') >= 0) {
+ throw new IllegalArgumentException(
+ "name must not contain ':', the namespace is applied by this factory: " + name);
+ }
+ return name;
+ }
private Layers() {
// This class holds constants only and is never instantiated.
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index 9ccd37457b..fb01047aa5 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -122,6 +122,34 @@ void testDocumentScopedLayersCarrySpanlessValues() {
assertEquals(2, both.layers().size());
}
+ /**
+ * Verifies the toolkit-namespace factories: {@link Layers#key(String, Class)} and
+ * {@link Layers#documentKey(String, Class)} apply the {@code opennlp:} prefix and
+ * yield keys equal to independently spelled ones, and a name that is null, blank, or
+ * already carries a namespace is rejected.
+ */
+ @Test
+ void testToolkitKeysCarryTheNamespacePrefix() {
+ assertEquals(LayerKey.of("opennlp:things", String.class),
+ Layers.key("things", String.class));
+ assertEquals("opennlp:things", Layers.key("things", String.class).id());
+ assertEquals(Layers.TOKENS, Layers.key("tokens", String.class));
+
+ final LayerKey whole = Layers.documentKey("language", String.class);
+ assertEquals("opennlp:language", whole.id());
+ assertEquals(LayerKey.Scope.DOCUMENT, whole.scope());
+
+ assertThrows(IllegalArgumentException.class, () -> Layers.key(" ", String.class));
+ assertThrows(IllegalArgumentException.class, () -> Layers.key(null, String.class));
+ assertThrows(IllegalArgumentException.class, () -> Layers.key("things", null));
+ final IllegalArgumentException nested = assertThrows(IllegalArgumentException.class,
+ () -> Layers.key("opennlp:things", String.class));
+ assertEquals("name must not contain ':', the namespace is applied by this factory: "
+ + "opennlp:things", nested.getMessage());
+ assertThrows(IllegalArgumentException.class,
+ () -> Layers.documentKey("app:x", String.class));
+ }
+
/**
* Verifies the gold-layer convention: a hand-annotated version of a layer lives
* under the {@code gold:} prefixed key beside the produced layer, both are readable
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
index c3f5897d6b..a72698e6f8 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
@@ -46,7 +46,7 @@ public class LemmatizerAnnotator implements DocumentAnnotator {
* The lemma layer. It is aligned with the token layer by position, and each annotation
* carries the lemma of its token on that token's span.
*/
- public static final LayerKey LEMMAS = LayerKey.of("opennlp:lemmas", String.class);
+ public static final LayerKey LEMMAS = Layers.key("lemmas", String.class);
/** The message prefix of every absent-required-layer rejection in this adapter. */
private static final String MISSING_LAYER = "document lacks the required layer ";
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
index 015779ee07..a8c00ba8f4 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
@@ -42,7 +42,7 @@ public class StemmerAnnotator implements DocumentAnnotator {
* The stem layer. It is aligned with the token layer by position, and each annotation
* carries the stem of its token on that token's span.
*/
- public static final LayerKey STEMS = LayerKey.of("opennlp:stems", String.class);
+ public static final LayerKey STEMS = Layers.key("stems", String.class);
private final Stemmer stemmer;
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index 328163b5ae..6e2f2d018c 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -51,7 +51,8 @@
are namespaced: keys the toolkit defines carry the opennlp:
prefix, an extension uses its own prefix, and a bare id, like the
token-lengths key below, is legal for an application-local
- layer.
+ layer. Toolkit keys are created through Layers.key and
+ Layers.documentKey, which apply the prefix.
From d43c1ec9ada7017df49b64b150039d061f5e08bb Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 20 Jul 2026 04:38:12 -0400
Subject: [PATCH 13/92] OPENNLP-1888: Javadoc container overrides and cite the
pipeline example test
Add {@inheritDoc} to the Document, LayerKey, and adapter overrides, and note in
the manual that DocumentPipelineExampleTest asserts the pipeline round-trip.
---
.../main/java/opennlp/tools/document/ImmutableDocument.java | 4 ++++
.../src/main/java/opennlp/tools/document/LayerKey.java | 3 +++
.../main/java/opennlp/tools/document/NameFinderAnnotator.java | 2 ++
.../main/java/opennlp/tools/document/POSTaggerAnnotator.java | 2 ++
.../opennlp/tools/document/SentenceDetectorAnnotator.java | 1 +
.../main/java/opennlp/tools/document/TokenizerAnnotator.java | 1 +
opennlp-docs/src/docbkx/document.xml | 4 ++++
7 files changed, 17 insertions(+)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
index e2ca8cd5f3..bcad31ec46 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
@@ -54,11 +54,13 @@ static ImmutableDocument empty(CharSequence text) {
return new ImmutableDocument(text, Collections.emptyMap());
}
+ /** {@inheritDoc} */
@Override
public CharSequence text() {
return text;
}
+ /** {@inheritDoc} */
@Override
@SuppressWarnings("unchecked")
public List> get(LayerKey layer) {
@@ -74,11 +76,13 @@ public List> get(LayerKey layer) {
return (List>) (List>) annotations;
}
+ /** {@inheritDoc} */
@Override
public Set> layers() {
return Collections.unmodifiableSet(layers.keySet());
}
+ /** {@inheritDoc} */
@Override
public Document with(LayerKey layer, List> annotations) {
if (layer == null || annotations == null) {
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
index 7d72411bd5..e29870a06c 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/LayerKey.java
@@ -139,6 +139,7 @@ public Scope scope() {
return scope;
}
+ /** {@inheritDoc} */
@Override
public boolean equals(Object obj) {
if (this == obj) {
@@ -150,11 +151,13 @@ public boolean equals(Object obj) {
return id.equals(other.id) && type.equals(other.type) && scope == other.scope;
}
+ /** {@inheritDoc} */
@Override
public int hashCode() {
return Objects.hash(id, type, scope);
}
+ /** {@inheritDoc} */
@Override
public String toString() {
return id + '<' + type.getSimpleName() + '>';
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
index 75c33496c8..8b1d6e3e7a 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
@@ -153,11 +153,13 @@ public Document annotate(Document document) {
return document.with(Layers.ENTITIES, entities);
}
+ /** {@inheritDoc} */
@Override
public Set> requires() {
return Set.of(Layers.SENTENCES, Layers.TOKENS);
}
+ /** {@inheritDoc} */
@Override
public Set> provides() {
return Set.of(Layers.ENTITIES);
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
index 08e1b81f8c..520c99cf0e 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
@@ -125,11 +125,13 @@ public Document annotate(Document document) {
return document.with(Layers.POS_TAGS, tagAnnotations);
}
+ /** {@inheritDoc} */
@Override
public Set> requires() {
return Set.of(Layers.SENTENCES, Layers.TOKENS);
}
+ /** {@inheritDoc} */
@Override
public Set> provides() {
return Set.of(Layers.POS_TAGS);
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
index 282edf31dd..136987401e 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
@@ -73,6 +73,7 @@ public Document annotate(Document document) {
return document.with(Layers.SENTENCES, sentences);
}
+ /** {@inheritDoc} */
@Override
public Set> provides() {
return Set.of(Layers.SENTENCES);
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
index e9384bd7b9..04ddccc591 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
@@ -95,6 +95,7 @@ private void addTokens(List> tokens, String text, int offset)
}
}
+ /** {@inheritDoc} */
@Override
public Set> provides() {
return Set.of(Layers.TOKENS);
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index 6e2f2d018c..29a009a884 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -122,6 +122,10 @@ DocumentAnalyzer analyzer = DocumentAnalyzer.builder()
Document document = analyzer.analyze("The dog barks. It naps.");]]>
+
+ DocumentPipelineExampleTest asserts the pipeline and layer
+ round-trip shown here.
+
The resulting document carries exactly the four layers the pipeline provides.
The sentence layer holds two annotations, [0..14) covering
From 04532170cb2cad7351457e3cc3a9158d38306e81 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 20 Jul 2026 05:27:41 -0400
Subject: [PATCH 14/92] OPENNLP-1888: Anchor the Document thread-safety note to
immutability
---
opennlp-api/src/main/java/opennlp/tools/document/Document.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index dc4be9616c..7b3a3989f3 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -33,7 +33,7 @@
* without spans, for example a language id.
*
*
Documents are immutable: {@link #with(LayerKey, List)} returns a new document that
- * shares the unchanged layers. Instances are safe to share between threads.
+ * shares the unchanged layers. That immutability makes instances safe to share between threads.
*
*
Three invariants make index-based references sound. A layer preserves its
* insertion order, and the container never sorts or reorders it. A layer is immutable
From 15102d75dddfdaa4e6394438782c8b7834a46de5 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Tue, 28 Jul 2026 02:41:27 -0400
Subject: [PATCH 15/92] OPENNLP-1888: Address review: shared annotator helpers,
hardened container contract
- Reject zero-length finder mentions in NameFinderAnnotator and pin the
second-sentence case, which was previously mapped silently wrong, with a test
- Add DocumentAnnotators with requireLayers and the per-sentence token walk,
replacing three copies of the walk loop and four spellings of the
missing-layer rejection; direct tests pin the helpers as public API
- Capture the document text as a String at construction so ImmutableDocument's
immutability and thread-safety claims hold for mutable CharSequence inputs
- Move the copy-on-add and threading narrative from the Document interface
Javadoc to ImmutableDocument; the interface now states that thread safety is
implementation specific
- Carry the entity type as the annotation value only; entity spans are untyped,
and the Javadoc names the value as the single source of the type
- Make all six adapter annotators final before the types freeze
- Align the TokenLengthAnnotator example with the documented required-layer
contract in both the manual and the example test via requireLayers
- Housekeeping per review: docbook CDATA placement, imports over qualified
names, a ParameterizedTest for the blank-input matrix, shared deterministic
test components, static assertion imports, inheritDoc on the runtime
adapters, Layers constructor comment, and the documented NPE of
StringUtil.isBlank
---
.../java/opennlp/tools/document/Document.java | 13 +-
.../tools/document/DocumentAnnotators.java | 118 +++++++++++++++++
.../tools/document/ImmutableDocument.java | 19 ++-
.../java/opennlp/tools/document/Layers.java | 5 +-
.../tools/document/NameFinderAnnotator.java | 73 +++--------
.../tools/document/POSTaggerAnnotator.java | 49 +------
.../document/SentenceDetectorAnnotator.java | 2 +-
.../tools/document/TokenizerAnnotator.java | 2 +-
.../java/opennlp/tools/util/StringUtil.java | 1 +
.../tools/document/DocumentAnalyzerTest.java | 98 ++++----------
.../document/DocumentAnnotatorsTest.java | 121 ++++++++++++++++++
.../document/DocumentPipelineExampleTest.java | 83 ++----------
.../opennlp/tools/document/DocumentTest.java | 17 +++
.../document/NameFinderAnnotatorTest.java | 48 ++++++-
.../tools/document/TestComponents.java | 92 +++++++++++++
.../tools/lemmatizer/LemmatizerAnnotator.java | 60 ++-------
.../tools/stemmer/StemmerAnnotator.java | 13 +-
.../lemmatizer/LemmatizerAnnotatorTest.java | 51 ++++----
.../tools/stemmer/StemmerAnnotatorTest.java | 25 ++--
opennlp-docs/src/docbkx/document.xml | 27 ++--
20 files changed, 553 insertions(+), 364 deletions(-)
create mode 100644 opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotators.java
create mode 100644 opennlp-api/src/test/java/opennlp/tools/document/DocumentAnnotatorsTest.java
create mode 100644 opennlp-api/src/test/java/opennlp/tools/document/TestComponents.java
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index 7b3a3989f3..4169793eff 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -21,8 +21,8 @@
import java.util.Set;
/**
- * An immutable, offset-anchored annotation container: the original text of one document
- * plus any number of typed annotation layers over it.
+ * An offset-anchored annotation container: the original text of one document plus any
+ * number of typed annotation layers over it.
*
*
A layer is a list of {@link Annotation annotations} identified by a
* {@link LayerKey}. The container itself knows nothing about specific layers; every
@@ -32,8 +32,9 @@
* {@link LayerKey.Scope#DOCUMENT document-scoped} layer carries whole-document values
* without spans, for example a language id.
*
- *
Documents are immutable: {@link #with(LayerKey, List)} returns a new document that
- * shares the unchanged layers. That immutability makes instances safe to share between threads.
+ *
A document is never modified in place: {@link #with(LayerKey, List)} leaves its
+ * receiver untouched and returns a new document. Thread safety is implementation
+ * specific.
*
*
Three invariants make index-based references sound. A layer preserves its
* insertion order, and the container never sorts or reorders it. A layer is immutable
@@ -49,7 +50,9 @@
public interface Document {
/**
- * Creates an empty {@link Document} over a text.
+ * Creates an empty {@link Document} over a text. The returned document is immutable
+ * and safe to share between threads: it captures the text's content at construction,
+ * so later changes to a mutable {@code CharSequence} do not reach the document.
*
* @param text The original document text. Must not be {@code null}.
* @return A {@link Document} without any layers. Never {@code null}.
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotators.java b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotators.java
new file mode 100644
index 0000000000..fd5f81b858
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/document/DocumentAnnotators.java
@@ -0,0 +1,118 @@
+/*
+ * 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.document;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Support methods shared by {@link DocumentAnnotator} implementations: the
+ * required-layer check and the per-sentence walk over the token layer.
+ *
+ *
These helpers keep the annotators' shared behavior identical across
+ * implementations: an absent required layer is always rejected with the same message
+ * naming the layer, and every per-sentence adapter applies the same sentence-to-token
+ * mapping, including the loud rejection of a token lying outside every sentence.
+ *
+ * @since 3.0.0
+ */
+public final class DocumentAnnotators {
+
+ /**
+ * Receives one sentence's contiguous token run during
+ * {@link #forEachSentence(List, List, SentenceTokenConsumer)}.
+ */
+ @FunctionalInterface
+ public interface SentenceTokenConsumer {
+
+ /**
+ * Consumes one sentence's tokens.
+ *
+ * @param first The position of the sentence's first token in the token layer.
+ * @param words The sentence's token values in layer order. Never {@code null} or
+ * empty; the run covers the token layer positions
+ * {@code [first, first + words.length)}.
+ */
+ void accept(int first, String[] words);
+ }
+
+ /**
+ * Verifies that a document is present and carries every given layer.
+ *
+ * @param document The document to check.
+ * @param layers The required layers, in the order they are to be reported.
+ * @throws IllegalArgumentException Thrown if {@code document} is {@code null}, or if
+ * a layer is absent; the message names the first absent layer.
+ */
+ public static void requireLayers(Document document, LayerKey>... layers) {
+ if (document == null) {
+ throw new IllegalArgumentException("document must not be null");
+ }
+ final Set> present = document.layers();
+ for (final LayerKey> layer : layers) {
+ if (!present.contains(layer)) {
+ throw new IllegalArgumentException("document lacks the required layer " + layer);
+ }
+ }
+ }
+
+ /**
+ * Walks the token layer sentence by sentence and hands each sentence's contiguous
+ * token run to the consumer.
+ *
+ *
Both layers must be in text order. Each sentence consumes the contiguous run of
+ * tokens whose spans it encloses; a sentence without tokens is skipped. Every token
+ * must belong to a sentence: a token lying outside every sentence is rejected loudly
+ * after the walk, so it can never be silently dropped.
+ *
+ * @param sentences The sentence layer, in text order. Must not be {@code null}.
+ * @param tokens The token layer, in text order. Must not be {@code null}.
+ * @param consumer Receives each token-carrying sentence's run. Must not be
+ * {@code null}.
+ * @throws IllegalArgumentException Thrown if a token lies outside every sentence.
+ */
+ public static void forEachSentence(List> sentences,
+ List> tokens, SentenceTokenConsumer consumer) {
+ int next = 0;
+ for (final Annotation sentence : sentences) {
+ final int first = next;
+ while (next < tokens.size()
+ && tokens.get(next).span().getStart() >= sentence.span().getStart()
+ && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
+ next++;
+ }
+ final int count = next - first;
+ if (count == 0) {
+ continue;
+ }
+ final String[] words = new String[count];
+ for (int i = 0; i < count; i++) {
+ words[i] = tokens.get(first + i).value();
+ }
+ consumer.accept(first, words);
+ }
+ if (next != tokens.size()) {
+ throw new IllegalArgumentException("token at " + tokens.get(next).span()
+ + " lies outside every sentence");
+ }
+ }
+
+ private DocumentAnnotators() {
+ // Not instantiated; this class provides static support methods only.
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
index bcad31ec46..b456258d3d 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
@@ -27,15 +27,21 @@
/**
* The default {@link Document} implementation: an unmodifiable map from layer key to an
- * unmodifiable annotation list. Adding a layer copies the map, not the layers, so
- * documents grown from a common ancestor share their layer lists.
+ * unmodifiable annotation list.
+ *
+ *
Instances are immutable: {@link #with(LayerKey, List)} returns a new document that
+ * shares the unchanged layers with its ancestor, copying the map but not the layers, so
+ * documents grown from a common ancestor share their layer lists. The text is captured
+ * as a {@link String} at construction, so a mutable {@link CharSequence} handed to
+ * {@link #empty(CharSequence)} cannot change the document afterwards. That immutability
+ * makes instances safe to share between threads.
*/
final class ImmutableDocument implements Document {
- private final CharSequence text;
+ private final String text;
private final Map, List>> layers;
- private ImmutableDocument(CharSequence text, Map, List>> layers) {
+ private ImmutableDocument(String text, Map, List>> layers) {
this.text = text;
this.layers = layers;
}
@@ -43,7 +49,8 @@ private ImmutableDocument(CharSequence text, Map, List
/**
* Creates a document without any layers.
*
- * @param text The original document text. Must not be {@code null}.
+ * @param text The original document text, captured as its content at this moment.
+ * Must not be {@code null}.
* @return An empty {@link ImmutableDocument}. Never {@code null}.
* @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
*/
@@ -51,7 +58,7 @@ static ImmutableDocument empty(CharSequence text) {
if (text == null) {
throw new IllegalArgumentException("text must not be null");
}
- return new ImmutableDocument(text, Collections.emptyMap());
+ return new ImmutableDocument(text.toString(), Collections.emptyMap());
}
/** {@inheritDoc} */
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
index 23322b158b..0257afc384 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Layers.java
@@ -68,7 +68,8 @@ public final class Layers {
public static final LayerKey POS_TAGS = key("pos", String.class);
/**
- * Named entities; each annotation covers one mention and carries the entity type.
+ * Named entities; each annotation covers one mention and carries the entity type as
+ * its value. The annotation's span carries offsets only.
*/
public static final LayerKey ENTITIES = key("entities", String.class);
@@ -128,6 +129,6 @@ private static String validName(String name) {
}
private Layers() {
- // This class holds constants only and is never instantiated.
+ // Not instantiated; this class provides constants and static key factories only.
}
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
index 8b1d6e3e7a..677e0dfa20 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
@@ -28,8 +28,9 @@
/**
* Adapts a {@link TokenNameFinder} to the document pipeline: reads
* {@link Layers#SENTENCES} and {@link Layers#TOKENS}, maps the finder's token-index
- * spans to character spans on the original text, and provides {@link Layers#ENTITIES}
- * carrying the entity type.
+ * spans to character spans on the original text, and provides {@link Layers#ENTITIES}.
+ * The entity type is carried as the annotation value; the annotation's span carries
+ * offsets only.
*
*
Each sentence's tokens are passed to {@link TokenNameFinder#find(String[])} as one
* sequence, the way the finder contract expects its input, so no mention can straddle a
@@ -42,7 +43,7 @@
*
* @since 3.0.0
*/
-public class NameFinderAnnotator implements DocumentAnnotator {
+public final class NameFinderAnnotator implements DocumentAnnotator {
/**
* The entity type recorded when the wrapped finder returns a span without a type. It
@@ -52,9 +53,6 @@ public class NameFinderAnnotator implements DocumentAnnotator {
*/
public static final String UNTYPED = NameSample.DEFAULT_TYPE;
- /** The message prefix of every absent-required-layer rejection in this adapter. */
- private static final String MISSING_LAYER = "document lacks the required layer ";
-
private final TokenNameFinder finder;
/**
@@ -79,7 +77,7 @@ public NameFinderAnnotator(TokenNameFinder finder) {
* text. The required layers must be present, but they may be empty: a document
* without sentences or tokens yields a present-but-empty entity layer, and a sentence
* containing no tokens contributes nothing. A mention without a type is recorded with
- * the type {@link #UNTYPED}.
+ * the type {@link #UNTYPED} as the annotation value.
*
* @param document The document to annotate. Must not be {@code null} and must carry
* the {@link Layers#SENTENCES} and {@link Layers#TOKENS} layers, with
@@ -88,65 +86,36 @@ public NameFinderAnnotator(TokenNameFinder finder) {
* {@code null}.
* @throws IllegalArgumentException Thrown if {@code document} is {@code null}, the
* sentence layer or the token layer is absent, a token lies outside every
- * sentence, or the finder returns a mention whose token indices lie outside
- * its sentence's tokens.
+ * sentence, or the finder returns a mention that is empty or whose token
+ * indices lie outside its sentence's tokens.
*/
@Override
public Document annotate(Document document) {
- if (document == null) {
- throw new IllegalArgumentException("document must not be null");
- }
- final Set> present = document.layers();
- if (!present.contains(Layers.SENTENCES)) {
- throw new IllegalArgumentException(MISSING_LAYER
- + Layers.SENTENCES);
- }
- if (!present.contains(Layers.TOKENS)) {
- throw new IllegalArgumentException(MISSING_LAYER
- + Layers.TOKENS);
- }
+ DocumentAnnotators.requireLayers(document, Layers.SENTENCES, Layers.TOKENS);
final List> sentences = document.get(Layers.SENTENCES);
final List> tokens = document.get(Layers.TOKENS);
final List> entities = new ArrayList<>();
- // Walk the token layer once: both layers are in text order, so each sentence
- // consumes the contiguous run of tokens whose spans it encloses. The adaptive data
- // is cleared even when annotation fails, so a rejected document cannot leak finder
- // state into the next one.
+ // The adaptive data is cleared even when annotation fails, so a rejected document
+ // cannot leak finder state into the next one.
try {
- int next = 0;
- for (final Annotation sentence : sentences) {
- final int first = next;
- while (next < tokens.size()
- && tokens.get(next).span().getStart() >= sentence.span().getStart()
- && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
- next++;
- }
- final int count = next - first;
- if (count == 0) {
- continue;
- }
- final String[] words = new String[count];
- for (int i = 0; i < count; i++) {
- words[i] = tokens.get(first + i).value();
- }
- // The finder indexes within the sentence; shifting by the sentence's first token
- // position turns every mention boundary into a document-wide token index, whose
- // token spans already refer to the original text.
+ DocumentAnnotators.forEachSentence(sentences, tokens, (first, words) -> {
+ // The finder indexes within the sentence; shifting by the sentence's first
+ // token position turns every mention boundary into a document-wide token
+ // index, whose token spans already refer to the original text. An empty
+ // mention is rejected with the out-of-bounds ones: it covers no token, so it
+ // has no character span.
for (final Span mention : finder.find(words)) {
- if (mention.getStart() < 0 || mention.getEnd() > count) {
+ if (mention.getStart() < 0 || mention.getEnd() > words.length
+ || mention.getStart() >= mention.getEnd()) {
throw new IllegalArgumentException("finder returned mention " + mention
- + " outside the sentence's " + count + " tokens");
+ + " outside the sentence's " + words.length + " tokens");
}
final int start = tokens.get(first + mention.getStart()).span().getStart();
final int end = tokens.get(first + mention.getEnd() - 1).span().getEnd();
final String type = mention.getType() == null ? UNTYPED : mention.getType();
- entities.add(new Annotation<>(new Span(start, end, type), type));
+ entities.add(new Annotation<>(new Span(start, end), type));
}
- }
- if (next != tokens.size()) {
- throw new IllegalArgumentException("token at " + tokens.get(next).span()
- + " lies outside every sentence");
- }
+ });
} finally {
finder.clearAdaptiveData();
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
index 520c99cf0e..b97aa98b4b 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
@@ -36,10 +36,7 @@
*
* @since 3.0.0
*/
-public class POSTaggerAnnotator implements DocumentAnnotator {
-
- /** The message prefix of every absent-required-layer rejection in this adapter. */
- private static final String MISSING_LAYER = "document lacks the required layer ";
+public final class POSTaggerAnnotator implements DocumentAnnotator {
private final POSTagger tagger;
@@ -76,52 +73,20 @@ public POSTaggerAnnotator(POSTagger tagger) {
*/
@Override
public Document annotate(Document document) {
- if (document == null) {
- throw new IllegalArgumentException("document must not be null");
- }
- final Set> present = document.layers();
- if (!present.contains(Layers.SENTENCES)) {
- throw new IllegalArgumentException(MISSING_LAYER
- + Layers.SENTENCES);
- }
- if (!present.contains(Layers.TOKENS)) {
- throw new IllegalArgumentException(MISSING_LAYER
- + Layers.TOKENS);
- }
+ DocumentAnnotators.requireLayers(document, Layers.SENTENCES, Layers.TOKENS);
final List> sentences = document.get(Layers.SENTENCES);
final List> tokens = document.get(Layers.TOKENS);
final List> tagAnnotations = new ArrayList<>(tokens.size());
- // Walk the token layer once: both layers are in text order, so each sentence
- // consumes the contiguous run of tokens whose spans it encloses.
- int next = 0;
- for (final Annotation sentence : sentences) {
- final int first = next;
- while (next < tokens.size()
- && tokens.get(next).span().getStart() >= sentence.span().getStart()
- && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
- next++;
- }
- final int count = next - first;
- if (count == 0) {
- continue;
- }
- final String[] words = new String[count];
- for (int i = 0; i < count; i++) {
- words[i] = tokens.get(first + i).value();
- }
+ DocumentAnnotators.forEachSentence(sentences, tokens, (first, words) -> {
final String[] tags = tagger.tag(words);
- if (tags.length != count) {
+ if (tags.length != words.length) {
throw new IllegalArgumentException(
- "tagger returned " + tags.length + " tags for " + count + " tokens");
+ "tagger returned " + tags.length + " tags for " + words.length + " tokens");
}
- for (int i = 0; i < count; i++) {
+ for (int i = 0; i < words.length; i++) {
tagAnnotations.add(new Annotation<>(tokens.get(first + i).span(), tags[i]));
}
- }
- if (next != tokens.size()) {
- throw new IllegalArgumentException("token at " + tokens.get(next).span()
- + " lies outside every sentence");
- }
+ });
return document.with(Layers.POS_TAGS, tagAnnotations);
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
index 136987401e..a035f0517e 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
@@ -33,7 +33,7 @@
*
* @since 3.0.0
*/
-public class SentenceDetectorAnnotator implements DocumentAnnotator {
+public final class SentenceDetectorAnnotator implements DocumentAnnotator {
private final SentenceDetector detector;
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
index 04ddccc591..6a81fcea9c 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
@@ -35,7 +35,7 @@
*
* @since 3.0.0
*/
-public class TokenizerAnnotator implements DocumentAnnotator {
+public final class TokenizerAnnotator implements DocumentAnnotator {
private final Tokenizer tokenizer;
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
index 1842556720..89694335cc 100644
--- a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
+++ b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
@@ -277,6 +277,7 @@ public static boolean isEmpty(CharSequence theString) {
*
* @param theString The {@link CharSequence} to examine. Must not be {@code null}.
* @return {@code true} if {@code theString} is empty or all whitespace.
+ * @throws NullPointerException Thrown if {@code theString} is {@code null}.
*/
public static boolean isBlank(CharSequence theString) {
for (int i = 0; i < theString.length(); ) {
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
index deb317640f..7b17c63692 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
@@ -21,11 +21,12 @@
import java.util.Set;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import opennlp.tools.namefind.TokenNameFinder;
import opennlp.tools.postag.POSTagger;
-import opennlp.tools.sentdetect.SentenceDetector;
-import opennlp.tools.tokenize.Tokenizer;
+import opennlp.tools.util.Sequence;
import opennlp.tools.util.Span;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -33,67 +34,22 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
- * Tests the {@link DocumentAnalyzer} pipeline over the adapter annotators, using simple
- * inline implementations of the task interfaces: whitespace tokenization, period sentence
- * splitting, and a dictionary tagger. The point under test is the pipeline mechanics and
- * span arithmetic, not model quality.
+ * Tests the {@link DocumentAnalyzer} pipeline over the adapter annotators, using the
+ * deterministic components from {@link TestComponents} and a fixed-vocabulary tagger.
+ * The point under test is the pipeline mechanics and span arithmetic, not model quality.
*/
public class DocumentAnalyzerTest {
- private static final SentenceDetector SPLITTER = new SentenceDetector() {
-
- @Override
- public String[] sentDetect(CharSequence s) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public Span[] sentPosDetect(CharSequence s) {
- // split after each period; keep it simple for the test
- final String text = s.toString();
- int start = 0;
- final java.util.List spans = new java.util.ArrayList<>();
- for (int i = 0; i < text.length(); i++) {
- if (text.charAt(i) == '.') {
- spans.add(new Span(start, i + 1));
- start = i + 2;
- }
- }
- return spans.toArray(new Span[0]);
- }
- };
-
- private static final Tokenizer WHITESPACE = new Tokenizer() {
-
- @Override
- public String[] tokenize(String s) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public Span[] tokenizePos(String s) {
- final java.util.List spans = new java.util.ArrayList<>();
- int start = -1;
- for (int i = 0; i <= s.length(); i++) {
- final boolean boundary = i == s.length() || s.charAt(i) == ' ';
- if (boundary && start >= 0) {
- spans.add(new Span(start, i));
- start = -1;
- } else if (!boundary && start < 0) {
- start = i;
- }
- }
- return spans.toArray(new Span[0]);
- }
- };
-
+ /** Tags the known verbs of the test texts {@code VBZ} and everything else {@code X}. */
private static final POSTagger TAGGER = new POSTagger() {
+ private final Set verbs = Set.of("barks.", "eats.");
+
@Override
public String[] tag(String[] sentence) {
final String[] tags = new String[sentence.length];
for (int i = 0; i < sentence.length; i++) {
- tags[i] = "barks.".contains(sentence[i]) ? "VBZ" : "X";
+ tags[i] = verbs.contains(sentence[i]) ? "VBZ" : "X";
}
return tags;
}
@@ -104,13 +60,12 @@ public String[] tag(String[] sentence, Object[] additionalContext) {
}
@Override
- public opennlp.tools.util.Sequence[] topKSequences(String[] sentence) {
+ public Sequence[] topKSequences(String[] sentence) {
throw new UnsupportedOperationException();
}
@Override
- public opennlp.tools.util.Sequence[] topKSequences(String[] sentence,
- Object[] additionalContext) {
+ public Sequence[] topKSequences(String[] sentence, Object[] additionalContext) {
throw new UnsupportedOperationException();
}
};
@@ -118,8 +73,8 @@ public opennlp.tools.util.Sequence[] topKSequences(String[] sentence,
@Test
void testPipelineProducesAlignedLayersInOriginalCoordinates() {
final Document document = DocumentAnalyzer.builder()
- .add(new SentenceDetectorAnnotator(SPLITTER))
- .add(new TokenizerAnnotator(WHITESPACE))
+ .add(new SentenceDetectorAnnotator(TestComponents.PERIOD_SPLITTER))
+ .add(new TokenizerAnnotator(TestComponents.SPACE_TOKENIZER))
.add(new POSTaggerAnnotator(TAGGER))
.build()
.analyze("the dog barks. she eats.");
@@ -145,8 +100,9 @@ void testPipelineProducesAlignedLayersInOriginalCoordinates() {
* document on which every provided layer is present and empty, rather than failing:
* zero sentences legitimately yield zero tokens, zero tags, and zero entities.
*/
- @Test
- void testEmptyAndBlankInputProduceEmptyLayers() {
+ @ParameterizedTest
+ @ValueSource(strings = {"", " "})
+ void testEmptyAndBlankInputProduceEmptyLayers(String text) {
final TokenNameFinder finder = new TokenNameFinder() {
@Override
@@ -159,19 +115,17 @@ public void clearAdaptiveData() {
}
};
final DocumentAnalyzer analyzer = DocumentAnalyzer.builder()
- .add(new SentenceDetectorAnnotator(SPLITTER))
- .add(new TokenizerAnnotator(WHITESPACE))
+ .add(new SentenceDetectorAnnotator(TestComponents.PERIOD_SPLITTER))
+ .add(new TokenizerAnnotator(TestComponents.SPACE_TOKENIZER))
.add(new POSTaggerAnnotator(TAGGER))
.add(new NameFinderAnnotator(finder))
.build();
- for (final String text : new String[] {"", " "}) {
- final Document document = analyzer.analyze(text);
- assertEquals(Set.of(Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS, Layers.ENTITIES),
- document.layers());
- for (final LayerKey> layer : document.layers()) {
- assertTrue(document.get(layer).isEmpty());
- }
+ final Document document = analyzer.analyze(text);
+ assertEquals(Set.of(Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS, Layers.ENTITIES),
+ document.layers());
+ for (final LayerKey> layer : document.layers()) {
+ assertTrue(document.get(layer).isEmpty());
}
}
@@ -181,7 +135,7 @@ public void clearAdaptiveData() {
*/
@Test
void testTokenizerHonorsPresentButEmptySentenceLayer() {
- final Document document = new TokenizerAnnotator(WHITESPACE)
+ final Document document = new TokenizerAnnotator(TestComponents.SPACE_TOKENIZER)
.annotate(Document.of("the dog").with(Layers.SENTENCES, List.of()));
assertTrue(document.layers().contains(Layers.TOKENS));
assertTrue(document.get(Layers.TOKENS).isEmpty());
@@ -190,7 +144,7 @@ void testTokenizerHonorsPresentButEmptySentenceLayer() {
@Test
void testTokenizerWorksWithoutSentences() {
final Document document = DocumentAnalyzer.builder()
- .add(new TokenizerAnnotator(WHITESPACE))
+ .add(new TokenizerAnnotator(TestComponents.SPACE_TOKENIZER))
.build()
.analyze("the dog");
assertEquals(2, document.get(Layers.TOKENS).size());
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnnotatorsTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnnotatorsTest.java
new file mode 100644
index 0000000000..bef5db118f
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnnotatorsTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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.document;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.util.Span;
+
+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 {@link DocumentAnnotators} support methods directly: the required-layer
+ * check's exact rejection messages and the per-sentence walk's slicing, skipping, and
+ * loud rejection of a token outside every sentence. The adapter tests exercise the same
+ * behavior through the annotators; this class pins the helpers as public API on their
+ * own.
+ */
+public class DocumentAnnotatorsTest {
+
+ @Test
+ void testRequireLayersAcceptsPresentLayers() {
+ final Document document = Document.of("the")
+ .with(Layers.SENTENCES, List.of())
+ .with(Layers.TOKENS, List.of());
+ DocumentAnnotators.requireLayers(document, Layers.SENTENCES, Layers.TOKENS);
+ DocumentAnnotators.requireLayers(document);
+ }
+
+ @Test
+ void testRequireLayersRejectsNullDocument() {
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> DocumentAnnotators.requireLayers(null, Layers.TOKENS));
+ assertEquals("document must not be null", e.getMessage());
+ }
+
+ /**
+ * Verifies that an absent layer is rejected with the shared message naming the first
+ * absent layer in the order the caller listed them.
+ */
+ @Test
+ void testRequireLayersNamesTheFirstAbsentLayer() {
+ final Document document = Document.of("the").with(Layers.TOKENS, List.of());
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> DocumentAnnotators.requireLayers(document,
+ Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS));
+ assertEquals("document lacks the required layer opennlp:sentences",
+ e.getMessage());
+ }
+
+ /**
+ * Verifies the walk contract: each sentence receives the contiguous run of tokens its
+ * span encloses with the run's first token layer position, and a sentence without
+ * tokens is skipped rather than reported as an empty run.
+ */
+ @Test
+ void testForEachSentenceSlicesContiguousRuns() {
+ final List> sentences = List.of(
+ new Annotation<>(new Span(0, 9), "Ana runs."),
+ new Annotation<>(new Span(10, 11), "!"),
+ new Annotation<>(new Span(12, 21), "Bob sits."));
+ final List> tokens = List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs."),
+ new Annotation<>(new Span(12, 15), "Bob"),
+ new Annotation<>(new Span(16, 21), "sits."));
+
+ final List firsts = new ArrayList<>();
+ final List> runs = new ArrayList<>();
+ DocumentAnnotators.forEachSentence(sentences, tokens, (first, words) -> {
+ firsts.add(first);
+ runs.add(List.of(words));
+ });
+
+ assertEquals(List.of(0, 2), firsts);
+ assertEquals(List.of(
+ List.of("Ana", "runs."),
+ List.of("Bob", "sits.")), runs);
+ }
+
+ @Test
+ void testForEachSentenceRejectsTokenOutsideEverySentence() {
+ final List> sentences = List.of(
+ new Annotation<>(new Span(0, 9), "Ana runs."));
+ final List> tokens = List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs."),
+ new Annotation<>(new Span(10, 13), "Bob"));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> DocumentAnnotators.forEachSentence(sentences, tokens, (first, words) -> {
+ }));
+ assertEquals("token at [10..13) lies outside every sentence", e.getMessage());
+ }
+
+ @Test
+ void testForEachSentenceOverEmptyLayersConsumesNothing() {
+ final List> runs = new ArrayList<>();
+ DocumentAnnotators.forEachSentence(List.of(), List.of(),
+ (first, words) -> runs.add(List.of(words)));
+ assertTrue(runs.isEmpty());
+ }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentPipelineExampleTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentPipelineExampleTest.java
index 14eeda39a4..0595b53716 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentPipelineExampleTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentPipelineExampleTest.java
@@ -25,8 +25,6 @@
import org.junit.jupiter.api.Test;
import opennlp.tools.postag.POSTagger;
-import opennlp.tools.sentdetect.SentenceDetector;
-import opennlp.tools.tokenize.Tokenizer;
import opennlp.tools.util.Sequence;
import opennlp.tools.util.Span;
@@ -39,9 +37,10 @@
* {@link DocumentAnalyzer}, analyze a two-sentence text, and read every layer back with
* its spans in original text coordinates.
*
- *
The wrapped components are tiny deterministic stand-ins defined in this class, so
- * every expected span and value below follows directly from the input text. The point
- * under demonstration is how the layers connect, not the quality of any single step.
+ *
The wrapped components are the tiny deterministic stand-ins from
+ * {@link TestComponents}, so every expected span and value below follows directly from
+ * the input text. The point under demonstration is how the layers connect, not the
+ * quality of any single step.
*/
public class DocumentPipelineExampleTest {
@@ -52,62 +51,6 @@ public class DocumentPipelineExampleTest {
private static final LayerKey TOKEN_LENGTHS =
LayerKey.of("token-lengths", Integer.class);
- /**
- * A deterministic sentence detector that ends a sentence after every period and
- * expects a single space between sentences. Only the span-producing method is
- * implemented because the adapter calls no other method.
- */
- private static final SentenceDetector PERIOD_SPLITTER = new SentenceDetector() {
-
- @Override
- public String[] sentDetect(CharSequence s) {
- throw new UnsupportedOperationException("the adapter only calls sentPosDetect");
- }
-
- @Override
- public Span[] sentPosDetect(CharSequence s) {
- final String text = s.toString();
- final List spans = new ArrayList<>();
- int start = 0;
- for (int i = 0; i < text.length(); i++) {
- if (text.charAt(i) == '.') {
- spans.add(new Span(start, i + 1));
- start = i + 2;
- }
- }
- return spans.toArray(new Span[0]);
- }
- };
-
- /**
- * A deterministic tokenizer that splits on single space characters and keeps all
- * other characters, including sentence-final periods, attached to their token. Only
- * the span-producing method is implemented because the adapter calls no other method.
- */
- private static final Tokenizer SPACE_TOKENIZER = new Tokenizer() {
-
- @Override
- public String[] tokenize(String s) {
- throw new UnsupportedOperationException("the adapter only calls tokenizePos");
- }
-
- @Override
- public Span[] tokenizePos(String s) {
- final List spans = new ArrayList<>();
- int start = -1;
- for (int i = 0; i <= s.length(); i++) {
- final boolean boundary = i == s.length() || s.charAt(i) == ' ';
- if (boundary && start >= 0) {
- spans.add(new Span(start, i));
- start = -1;
- } else if (!boundary && start < 0) {
- start = i;
- }
- }
- return spans.toArray(new Span[0]);
- }
- };
-
/**
* A deterministic tagger backed by a fixed dictionary covering exactly the tokens of
* the example text. An unknown token fails the test immediately rather than receiving
@@ -158,21 +101,15 @@ private static final class TokenLengthAnnotator implements DocumentAnnotator {
* Adds the {@link #TOKEN_LENGTHS} layer computed from {@link Layers#TOKENS}.
*
* @param document The document to annotate. Must not be {@code null} and must
- * contain the token layer.
+ * contain the token layer, which may be empty.
* @return A new {@link Document} carrying the token length layer. Never {@code null}.
* @throws IllegalArgumentException Thrown if {@code document} is {@code null} or the
* token layer is absent.
*/
@Override
public Document annotate(Document document) {
- if (document == null) {
- throw new IllegalArgumentException("document must not be null");
- }
+ DocumentAnnotators.requireLayers(document, Layers.TOKENS);
final List> tokens = document.get(Layers.TOKENS);
- if (tokens.isEmpty()) {
- throw new IllegalArgumentException("document lacks the required layer "
- + Layers.TOKENS);
- }
final List> lengths = new ArrayList<>(tokens.size());
for (final Annotation token : tokens) {
lengths.add(new Annotation<>(token.span(), token.value().length()));
@@ -214,8 +151,8 @@ void testDocumentScopedLayerExample() {
@Test
void testFullPipelineStory() {
final DocumentAnalyzer analyzer = DocumentAnalyzer.builder()
- .add(new SentenceDetectorAnnotator(PERIOD_SPLITTER))
- .add(new TokenizerAnnotator(SPACE_TOKENIZER))
+ .add(new SentenceDetectorAnnotator(TestComponents.PERIOD_SPLITTER))
+ .add(new TokenizerAnnotator(TestComponents.SPACE_TOKENIZER))
.add(new POSTaggerAnnotator(DICTIONARY_TAGGER))
.add(new TokenLengthAnnotator())
.build();
@@ -289,8 +226,8 @@ void testFullPipelineStory() {
@Test
void testAnalyzerIsReusableAcrossTexts() {
final DocumentAnalyzer analyzer = DocumentAnalyzer.builder()
- .add(new SentenceDetectorAnnotator(PERIOD_SPLITTER))
- .add(new TokenizerAnnotator(SPACE_TOKENIZER))
+ .add(new SentenceDetectorAnnotator(TestComponents.PERIOD_SPLITTER))
+ .add(new TokenizerAnnotator(TestComponents.SPACE_TOKENIZER))
.build();
final Document first = analyzer.analyze("The dog barks.");
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java
index 846007652f..787c9d8edb 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentTest.java
@@ -47,6 +47,23 @@ void testEmptyDocument() {
assertTrue(document.get(WORDS).isEmpty());
}
+ /**
+ * Verifies that the text is captured at construction: mutating a
+ * {@link StringBuilder} handed to {@link Document#of(CharSequence)} does not reach
+ * the document, so the span bounds validated on insertion stay valid for its
+ * lifetime.
+ */
+ @Test
+ void testTextIsCapturedAtConstruction() {
+ final StringBuilder mutable = new StringBuilder("the dog");
+ final Document document = Document.of(mutable)
+ .with(WORDS, List.of(new Annotation<>(new Span(4, 7), "dog")));
+ mutable.setLength(0);
+ assertEquals("the dog", document.text().toString());
+ assertEquals("dog",
+ document.get(WORDS).get(0).span().getCoveredText(document.text()).toString());
+ }
+
@Test
void testWithAddsATypedLayer() {
final Document document = Document.of("the dog")
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
index 99f80dae03..3b3e87d5bb 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
@@ -30,12 +30,14 @@
import opennlp.tools.util.Span;
import static org.junit.jupiter.api.Assertions.assertEquals;
+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 that {@link NameFinderAnnotator} maps token-index mentions to character spans on
- * the original text and clears the finder's adaptive data per document.
+ * the original text, carries the entity type as the annotation value on an untyped span,
+ * and clears the finder's adaptive data per document.
*/
public class NameFinderAnnotatorTest {
@@ -85,8 +87,10 @@ void testTokenIndexSpansBecomeCharacterSpans() {
final Document annotated = new NameFinderAnnotator(finder).annotate(document);
final List> entities = annotated.get(Layers.ENTITIES);
assertEquals(1, entities.size());
- assertEquals(new Span(3, 11, "location"), entities.get(0).span());
+ assertEquals(new Span(3, 11), entities.get(0).span());
+ // the annotation value is the single source of the entity type; the span is untyped
assertEquals("location", entities.get(0).value());
+ assertNull(entities.get(0).span().getType());
assertEquals("New York",
entities.get(0).span().getCoveredText(annotated.text()).toString());
assertEquals(1, cleared.get());
@@ -126,8 +130,8 @@ void testUntypedMentionRecordedAsUntyped() {
assertEquals(1, entities.size());
assertEquals(NameSample.DEFAULT_TYPE, NameFinderAnnotator.UNTYPED);
assertEquals(NameFinderAnnotator.UNTYPED, entities.get(0).value());
- assertEquals(new Span(0, 3, NameFinderAnnotator.UNTYPED), entities.get(0).span());
- assertEquals(NameFinderAnnotator.UNTYPED, entities.get(0).span().getType());
+ assertEquals(new Span(0, 3), entities.get(0).span());
+ assertNull(entities.get(0).span().getType());
}
/**
@@ -159,6 +163,36 @@ void testMentionOutsideSentenceTokensFailsLoud() {
assertEquals(1, cleared.get());
}
+ /**
+ * Verifies that a zero-length mention is rejected loudly. {@link Span} permits
+ * {@code start == end}, but such a mention covers no token, so mapping its end
+ * through {@code end - 1} would read the previous sentence's last token instead of
+ * failing. The finder here returns the empty mention for the second sentence, the
+ * case that would otherwise be mapped silently wrong, and the adaptive data is still
+ * cleared on the failure.
+ */
+ @Test
+ void testZeroLengthMentionFailsLoud() {
+ final AtomicInteger cleared = new AtomicInteger();
+ final TokenNameFinder finder = finder(tokens ->
+ "Bob".equals(tokens[0]) ? new Span[] {new Span(0, 0)} : new Span[0], cleared);
+ final Document document = Document.of("Ana runs. Bob sits.")
+ .with(Layers.SENTENCES, List.of(
+ new Annotation<>(new Span(0, 9), "Ana runs."),
+ new Annotation<>(new Span(10, 19), "Bob sits.")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs."),
+ new Annotation<>(new Span(10, 13), "Bob"),
+ new Annotation<>(new Span(14, 19), "sits.")));
+
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new NameFinderAnnotator(finder).annotate(document));
+ assertEquals("finder returned mention [0..0) outside the sentence's 2 tokens",
+ e.getMessage());
+ assertEquals(1, cleared.get());
+ }
+
/**
* Verifies that a token lying outside every sentence is rejected loudly and that the
* adaptive data is still cleared on that failure, so a rejected document cannot leak
@@ -215,8 +249,10 @@ void testFindsPerSentenceAndMapsSentenceLocalIndices() {
final List> entities = annotated.get(Layers.ENTITIES);
assertEquals(2, entities.size());
- assertEquals(new Span(0, 3, "person"), entities.get(0).span());
- assertEquals(new Span(10, 13, "person"), entities.get(1).span());
+ assertEquals(new Span(0, 3), entities.get(0).span());
+ assertEquals("person", entities.get(0).value());
+ assertEquals(new Span(10, 13), entities.get(1).span());
+ assertEquals("person", entities.get(1).value());
assertEquals("Bob",
entities.get(1).span().getCoveredText(annotated.text()).toString());
assertEquals(1, cleared.get());
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/TestComponents.java b/opennlp-api/src/test/java/opennlp/tools/document/TestComponents.java
new file mode 100644
index 0000000000..6145e5b790
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/document/TestComponents.java
@@ -0,0 +1,92 @@
+/*
+ * 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.document;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import opennlp.tools.sentdetect.SentenceDetector;
+import opennlp.tools.tokenize.Tokenizer;
+import opennlp.tools.util.Span;
+
+/**
+ * Deterministic stand-in components shared by the document pipeline tests, so every
+ * expected span in those tests follows directly from the definitions here.
+ */
+final class TestComponents {
+
+ /**
+ * A deterministic sentence detector that ends a sentence after every period and
+ * expects a single space between sentences. Only the span-producing method is
+ * implemented because the adapter calls no other method.
+ */
+ static final SentenceDetector PERIOD_SPLITTER = new SentenceDetector() {
+
+ @Override
+ public String[] sentDetect(CharSequence s) {
+ throw new UnsupportedOperationException("the adapter only calls sentPosDetect");
+ }
+
+ @Override
+ public Span[] sentPosDetect(CharSequence s) {
+ final String text = s.toString();
+ final List spans = new ArrayList<>();
+ int start = 0;
+ for (int i = 0; i < text.length(); i++) {
+ if (text.charAt(i) == '.') {
+ spans.add(new Span(start, i + 1));
+ start = i + 2;
+ }
+ }
+ return spans.toArray(new Span[0]);
+ }
+ };
+
+ /**
+ * A deterministic tokenizer that splits on single space characters and keeps all
+ * other characters, including sentence-final periods, attached to their token. Only
+ * the span-producing method is implemented because the adapter calls no other method.
+ */
+ static final Tokenizer SPACE_TOKENIZER = new Tokenizer() {
+
+ @Override
+ public String[] tokenize(String s) {
+ throw new UnsupportedOperationException("the adapter only calls tokenizePos");
+ }
+
+ @Override
+ public Span[] tokenizePos(String s) {
+ final List spans = new ArrayList<>();
+ int start = -1;
+ for (int i = 0; i <= s.length(); i++) {
+ final boolean boundary = i == s.length() || s.charAt(i) == ' ';
+ if (boundary && start >= 0) {
+ spans.add(new Span(start, i));
+ start = -1;
+ } else if (!boundary && start < 0) {
+ start = i;
+ }
+ }
+ return spans.toArray(new Span[0]);
+ }
+ };
+
+ private TestComponents() {
+ // Not instantiated; this class provides shared test fixtures only.
+ }
+}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
index a72698e6f8..52b9e2288d 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
@@ -24,6 +24,7 @@
import opennlp.tools.document.Annotation;
import opennlp.tools.document.Document;
import opennlp.tools.document.DocumentAnnotator;
+import opennlp.tools.document.DocumentAnnotators;
import opennlp.tools.document.LayerKey;
import opennlp.tools.document.Layers;
@@ -40,7 +41,7 @@
*
* @since 3.0.0
*/
-public class LemmatizerAnnotator implements DocumentAnnotator {
+public final class LemmatizerAnnotator implements DocumentAnnotator {
/**
* The lemma layer. It is aligned with the token layer by position, and each annotation
@@ -48,9 +49,6 @@ public class LemmatizerAnnotator implements DocumentAnnotator {
*/
public static final LayerKey LEMMAS = Layers.key("lemmas", String.class);
- /** The message prefix of every absent-required-layer rejection in this adapter. */
- private static final String MISSING_LAYER = "document lacks the required layer ";
-
private final Lemmatizer lemmatizer;
/**
@@ -89,21 +87,8 @@ public LemmatizerAnnotator(Lemmatizer lemmatizer) {
*/
@Override
public Document annotate(Document document) {
- if (document == null) {
- throw new IllegalArgumentException("document must not be null");
- }
- if (!document.layers().contains(Layers.SENTENCES)) {
- throw new IllegalArgumentException(MISSING_LAYER
- + Layers.SENTENCES);
- }
- if (!document.layers().contains(Layers.TOKENS)) {
- throw new IllegalArgumentException(MISSING_LAYER
- + Layers.TOKENS);
- }
- if (!document.layers().contains(Layers.POS_TAGS)) {
- throw new IllegalArgumentException(MISSING_LAYER
- + Layers.POS_TAGS);
- }
+ DocumentAnnotators.requireLayers(document,
+ Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS);
final List> sentences = document.get(Layers.SENTENCES);
final List> tokens = document.get(Layers.TOKENS);
final List> tags = document.get(Layers.POS_TAGS);
@@ -112,47 +97,30 @@ public Document annotate(Document document) {
+ Layers.TOKENS + " and " + Layers.POS_TAGS + " layers");
}
final List> layer = new ArrayList<>(tokens.size());
- // Walk the token layer once: both layers are in text order, so each sentence
- // consumes the contiguous run of tokens whose spans it encloses.
- int next = 0;
- for (final Annotation sentence : sentences) {
- final int first = next;
- while (next < tokens.size()
- && tokens.get(next).span().getStart() >= sentence.span().getStart()
- && tokens.get(next).span().getEnd() <= sentence.span().getEnd()) {
- next++;
- }
- final int count = next - first;
- if (count == 0) {
- continue;
- }
- final String[] words = new String[count];
- final String[] posTags = new String[count];
- for (int i = 0; i < count; i++) {
- words[i] = tokens.get(first + i).value();
+ DocumentAnnotators.forEachSentence(sentences, tokens, (first, words) -> {
+ final String[] posTags = new String[words.length];
+ for (int i = 0; i < words.length; i++) {
posTags[i] = tags.get(first + i).value();
}
final String[] lemmas = lemmatizer.lemmatize(words, posTags);
- if (lemmas.length != count) {
- throw new IllegalArgumentException(
- "lemmatizer returned " + lemmas.length + " lemmas for " + count + " tokens");
+ if (lemmas.length != words.length) {
+ throw new IllegalArgumentException("lemmatizer returned " + lemmas.length
+ + " lemmas for " + words.length + " tokens");
}
- for (int i = 0; i < count; i++) {
+ for (int i = 0; i < words.length; i++) {
layer.add(new Annotation<>(tokens.get(first + i).span(), lemmas[i]));
}
- }
- if (next != tokens.size()) {
- throw new IllegalArgumentException("token at " + tokens.get(next).span()
- + " lies outside every sentence");
- }
+ });
return document.with(LEMMAS, layer);
}
+ /** {@inheritDoc} */
@Override
public Set> requires() {
return Set.of(Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS);
}
+ /** {@inheritDoc} */
@Override
public Set> provides() {
return Set.of(LEMMAS);
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
index a8c00ba8f4..b5da3c4fe7 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
@@ -24,6 +24,7 @@
import opennlp.tools.document.Annotation;
import opennlp.tools.document.Document;
import opennlp.tools.document.DocumentAnnotator;
+import opennlp.tools.document.DocumentAnnotators;
import opennlp.tools.document.LayerKey;
import opennlp.tools.document.Layers;
@@ -36,7 +37,7 @@
*
* @since 3.0.0
*/
-public class StemmerAnnotator implements DocumentAnnotator {
+public final class StemmerAnnotator implements DocumentAnnotator {
/**
* The stem layer. It is aligned with the token layer by position, and each annotation
@@ -74,13 +75,7 @@ public StemmerAnnotator(Stemmer stemmer) {
*/
@Override
public Document annotate(Document document) {
- if (document == null) {
- throw new IllegalArgumentException("document must not be null");
- }
- if (!document.layers().contains(Layers.TOKENS)) {
- throw new IllegalArgumentException("document lacks the required layer "
- + Layers.TOKENS);
- }
+ DocumentAnnotators.requireLayers(document, Layers.TOKENS);
final List> tokens = document.get(Layers.TOKENS);
final List> layer = new ArrayList<>(tokens.size());
for (final Annotation token : tokens) {
@@ -89,11 +84,13 @@ public Document annotate(Document document) {
return document.with(STEMS, layer);
}
+ /** {@inheritDoc} */
@Override
public Set> requires() {
return Set.of(Layers.TOKENS);
}
+ /** {@inheritDoc} */
@Override
public Set> provides() {
return Set.of(STEMS);
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
index d31f092104..83a239751d 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
@@ -21,7 +21,6 @@
import java.util.List;
import java.util.Set;
-import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import opennlp.tools.document.Annotation;
@@ -29,6 +28,10 @@
import opennlp.tools.document.Layers;
import opennlp.tools.util.Span;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
public class LemmatizerAnnotatorTest {
/** Lowercases verbs and keeps everything else, enough to observe the adapter. */
@@ -65,10 +68,10 @@ void testLemmasAlignWithTokens() {
final Document lemmatized = new LemmatizerAnnotator(FIXTURE).annotate(document);
final List> lemmas = lemmatized.get(LemmatizerAnnotator.LEMMAS);
- Assertions.assertEquals(3, lemmas.size());
- Assertions.assertEquals("run", lemmas.get(1).value());
- Assertions.assertEquals(new Span(4, 7), lemmas.get(1).span());
- Assertions.assertEquals("home", lemmas.get(2).value());
+ assertEquals(3, lemmas.size());
+ assertEquals("run", lemmas.get(1).value());
+ assertEquals(new Span(4, 7), lemmas.get(1).span());
+ assertEquals("home", lemmas.get(2).value());
}
/**
@@ -108,25 +111,25 @@ public List> lemmatize(List toks, List tags) {
final Document lemmatized = new LemmatizerAnnotator(recording).annotate(document);
- Assertions.assertEquals(List.of(
+ assertEquals(List.of(
List.of("Ana", "runs."),
List.of("Bob", "sits.")), calls);
- Assertions.assertEquals(4, lemmatized.get(LemmatizerAnnotator.LEMMAS).size());
- Assertions.assertEquals(new Span(10, 13),
+ assertEquals(4, lemmatized.get(LemmatizerAnnotator.LEMMAS).size());
+ assertEquals(new Span(10, 13),
lemmatized.get(LemmatizerAnnotator.LEMMAS).get(2).span());
}
@Test
void testInvalidArguments() {
- Assertions.assertThrows(IllegalArgumentException.class,
+ assertThrows(IllegalArgumentException.class,
() -> new LemmatizerAnnotator(null));
final LemmatizerAnnotator annotator = new LemmatizerAnnotator(FIXTURE);
- Assertions.assertThrows(IllegalArgumentException.class, () -> annotator.annotate(null));
+ assertThrows(IllegalArgumentException.class, () -> annotator.annotate(null));
final Document misaligned = Document.of("a b")
.with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 3), "a b")))
.with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 1), "a")))
.with(Layers.POS_TAGS, List.of());
- Assertions.assertThrows(IllegalArgumentException.class,
+ assertThrows(IllegalArgumentException.class,
() -> annotator.annotate(misaligned));
}
@@ -137,23 +140,23 @@ void testInvalidArguments() {
@Test
void testAbsentRequiredLayerThrowsWithExactMessage() {
final LemmatizerAnnotator annotator = new LemmatizerAnnotator(FIXTURE);
- final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> annotator.annotate(Document.of("no layers")));
- Assertions.assertEquals("document lacks the required layer opennlp:sentences",
+ assertEquals("document lacks the required layer opennlp:sentences",
e.getMessage());
final Document sentencesOnly = Document.of("a")
.with(Layers.SENTENCES, List.of(new Annotation<>(new Span(0, 1), "a")));
- final IllegalArgumentException tokenless = Assertions.assertThrows(
+ final IllegalArgumentException tokenless = assertThrows(
IllegalArgumentException.class, () -> annotator.annotate(sentencesOnly));
- Assertions.assertEquals("document lacks the required layer opennlp:tokens",
+ assertEquals("document lacks the required layer opennlp:tokens",
tokenless.getMessage());
final Document untagged = sentencesOnly
.with(Layers.TOKENS, List.of(new Annotation<>(new Span(0, 1), "a")));
- final IllegalArgumentException tagless = Assertions.assertThrows(
+ final IllegalArgumentException tagless = assertThrows(
IllegalArgumentException.class, () -> annotator.annotate(untagged));
- Assertions.assertEquals("document lacks the required layer opennlp:pos",
+ assertEquals("document lacks the required layer opennlp:pos",
tagless.getMessage());
}
@@ -168,8 +171,8 @@ void testEmptyPresentLayersYieldEmptyLemmaLayer() {
.with(Layers.TOKENS, List.of())
.with(Layers.POS_TAGS, List.of());
final Document lemmatized = new LemmatizerAnnotator(FIXTURE).annotate(document);
- Assertions.assertTrue(lemmatized.layers().contains(LemmatizerAnnotator.LEMMAS));
- Assertions.assertTrue(lemmatized.get(LemmatizerAnnotator.LEMMAS).isEmpty());
+ assertTrue(lemmatized.layers().contains(LemmatizerAnnotator.LEMMAS));
+ assertTrue(lemmatized.get(LemmatizerAnnotator.LEMMAS).isEmpty());
}
/**
@@ -188,9 +191,9 @@ void testTokenOutsideEverySentenceThrowsWithExactMessage() {
new Annotation<>(new Span(0, 3), "PROPN"),
new Annotation<>(new Span(4, 9), "VERB"),
new Annotation<>(new Span(10, 13), "PROPN")));
- final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new LemmatizerAnnotator(FIXTURE).annotate(document));
- Assertions.assertEquals("token at [10..13) lies outside every sentence", e.getMessage());
+ assertEquals("token at [10..13) lies outside every sentence", e.getMessage());
}
/**
@@ -218,9 +221,9 @@ public List> lemmatize(List toks, List tags) {
.with(Layers.POS_TAGS, List.of(
new Annotation<>(new Span(0, 1), "X"),
new Annotation<>(new Span(2, 3), "X")));
- final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new LemmatizerAnnotator(shortLemmatizer).annotate(document));
- Assertions.assertEquals("lemmatizer returned 1 lemmas for 2 tokens", e.getMessage());
+ assertEquals("lemmatizer returned 1 lemmas for 2 tokens", e.getMessage());
}
/**
@@ -229,7 +232,7 @@ public List> lemmatize(List toks, List tags) {
*/
@Test
void testRequiresSentencesTokensAndTags() {
- Assertions.assertEquals(Set.of(Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS),
+ assertEquals(Set.of(Layers.SENTENCES, Layers.TOKENS, Layers.POS_TAGS),
new LemmatizerAnnotator(FIXTURE).requires());
}
}
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
index 3f84b5303e..3d4493c247 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
@@ -19,7 +19,6 @@
import java.util.List;
-import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import opennlp.tools.document.Annotation;
@@ -27,6 +26,10 @@
import opennlp.tools.document.Layers;
import opennlp.tools.util.Span;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
public class StemmerAnnotatorTest {
@Test
@@ -40,18 +43,18 @@ void testStemsAlignWithTokens() {
new PorterStemmer()).annotate(document);
final List> stems = stemmed.get(StemmerAnnotator.STEMS);
- Assertions.assertEquals(2, stems.size());
- Assertions.assertEquals("run", stems.get(0).value());
- Assertions.assertEquals(new Span(0, 7), stems.get(0).span());
- Assertions.assertEquals("dog", stems.get(1).value());
+ assertEquals(2, stems.size());
+ assertEquals("run", stems.get(0).value());
+ assertEquals(new Span(0, 7), stems.get(0).span());
+ assertEquals("dog", stems.get(1).value());
}
@Test
void testInvalidArguments() {
- Assertions.assertThrows(IllegalArgumentException.class,
+ assertThrows(IllegalArgumentException.class,
() -> new StemmerAnnotator(null));
final StemmerAnnotator annotator = new StemmerAnnotator(new PorterStemmer());
- Assertions.assertThrows(IllegalArgumentException.class, () -> annotator.annotate(null));
+ assertThrows(IllegalArgumentException.class, () -> annotator.annotate(null));
}
/**
@@ -61,9 +64,9 @@ void testInvalidArguments() {
@Test
void testAbsentTokenLayerThrowsWithExactMessage() {
final StemmerAnnotator annotator = new StemmerAnnotator(new PorterStemmer());
- final IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class,
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> annotator.annotate(Document.of("no tokens")));
- Assertions.assertEquals("document lacks the required layer opennlp:tokens", e.getMessage());
+ assertEquals("document lacks the required layer opennlp:tokens", e.getMessage());
}
/**
@@ -74,7 +77,7 @@ void testAbsentTokenLayerThrowsWithExactMessage() {
void testEmptyPresentTokenLayerYieldsEmptyStemLayer() {
final Document document = Document.of("").with(Layers.TOKENS, List.of());
final Document stemmed = new StemmerAnnotator(new PorterStemmer()).annotate(document);
- Assertions.assertTrue(stemmed.layers().contains(StemmerAnnotator.STEMS));
- Assertions.assertTrue(stemmed.get(StemmerAnnotator.STEMS).isEmpty());
+ assertTrue(stemmed.layers().contains(StemmerAnnotator.STEMS));
+ assertTrue(stemmed.get(StemmerAnnotator.STEMS).isEmpty());
}
}
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index 29a009a884..648ee288a6 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -58,8 +58,9 @@
Immutable. Adding a layer returns a new
- document that shares the unchanged layers with its ancestor. Documents are
- safe to share between threads. Three invariants make index references
+ document that shares the unchanged layers with its ancestor. Documents
+ created through Document.of capture their text at construction
+ and are safe to share between threads. Three invariants make index references
sound: a layer preserves its insertion order and is never sorted or
reordered, a layer is immutable once added and detached from the caller's
input list, and providing a layer that already exists is rejected, so an
@@ -73,8 +74,7 @@
carries whole-document values without spans, which is where a language id,
a category distribution, or provenance belongs:
-
- LANGUAGE = LayerKey.document("app:language", String.class);
Document tagged = document.with(LANGUAGE, List.of(Annotation.of("eng")));
@@ -111,8 +111,7 @@ String language = tagged.get(LANGUAGE).get(0).value(); // "eng", span is null]]>
SentenceDetector, Tokenizer, and
POSTagger, for example the ME implementations loaded from models:
-
-
[18..23). Layers produced per token stay aligned with the token
layer by position:
-
- > tokens = document.get(Layers.TOKENS);
List> tags = document.get(Layers.POS_TAGS);
for (int i = 0; i < tags.size(); i++) {
@@ -160,8 +158,7 @@ for (Annotation token : tokens) {
the token layer, and provides one integer annotation per token; the analyzer's
build-time validation guarantees a tokenizer ran earlier:
-
- TOKEN_LENGTHS =
LayerKey.of("token-lengths", Integer.class);
@@ -169,9 +166,7 @@ class TokenLengthAnnotator implements DocumentAnnotator {
@Override
public Document annotate(Document document) {
- if (document == null) {
- throw new IllegalArgumentException("document must not be null");
- }
+ DocumentAnnotators.requireLayers(document, Layers.TOKENS);
List> tokens = document.get(Layers.TOKENS);
List> lengths = new ArrayList<>(tokens.size());
for (Annotation token : tokens) {
@@ -196,8 +191,7 @@ class TokenLengthAnnotator implements DocumentAnnotator {
numbers without a cast; for the text above the five values are
3, 3, 6, 2, 5, each on its token's span:
-
- > lengths = document.get(TOKEN_LENGTHS);
int firstTokenLength = lengths.get(0).value(); // 3, for "The" at [0..3)]]>
@@ -207,6 +201,9 @@ int firstTokenLength = lengths.get(0).value(); // 3, for "The" at [0..3)]]>
gracefully on documents without content. An absent required layer is rejected
with an IllegalArgumentException naming the layer, because a
missing pipeline stage is an assembly error, not an empty document.
+ DocumentAnnotators.requireLayers performs exactly that rejection,
+ including the null check on the document, and is what the toolkit's own
+ adapters use.
From e9062250b1191c36e696a0a7490698e6f454b5bf Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Tue, 28 Jul 2026 07:00:15 -0400
Subject: [PATCH 16/92] OPENNLP-1888: Address review: fold duplicate test
fixtures and pin null rejection
- Fold the three verbatim copies of the "Ana runs. Bob sits." document into a
single twoSentenceDocument() helper in NameFinderAnnotatorTest, so the
sentence and token layers of the shared fixture are declared once instead of
drifting between the over-long mention, zero-length mention, and
per-sentence offset tests
- Hoist the no-op TokenNameFinder out of the blank-input test into a NO_NAMES
constant in DocumentAnalyzerTest, since a finder that returns no spans is
pipeline plumbing rather than part of any one test case, and document what it
is for
- Add testAnnotatorAdaptersRejectNullDocuments to pin that all four adapters
reject a null document with the same "document must not be null" message,
whether they validate directly or through DocumentAnnotators.requireLayers;
the shared message was previously unpinned and free to drift per adapter
- Trim the stale "person-free" qualifier from the New York comment in
testTokenIndexSpansBecomeCharacterSpans; the finder emits a location mention
and the extra negation described a distinction the test no longer draws
---
.../tools/document/DocumentAnalyzerTest.java | 45 ++++++++++++-----
.../document/NameFinderAnnotatorTest.java | 50 ++++++++-----------
2 files changed, 53 insertions(+), 42 deletions(-)
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
index 7b17c63692..1106f8f707 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
@@ -70,6 +70,19 @@ public Sequence[] topKSequences(String[] sentence, Object[] additionalContext) {
}
};
+ /** A finder that finds no names, for tests that only exercise the pipeline plumbing. */
+ private static final TokenNameFinder NO_NAMES = new TokenNameFinder() {
+
+ @Override
+ public Span[] find(String[] tokens) {
+ return new Span[0];
+ }
+
+ @Override
+ public void clearAdaptiveData() {
+ }
+ };
+
@Test
void testPipelineProducesAlignedLayersInOriginalCoordinates() {
final Document document = DocumentAnalyzer.builder()
@@ -103,22 +116,11 @@ void testPipelineProducesAlignedLayersInOriginalCoordinates() {
@ParameterizedTest
@ValueSource(strings = {"", " "})
void testEmptyAndBlankInputProduceEmptyLayers(String text) {
- final TokenNameFinder finder = new TokenNameFinder() {
-
- @Override
- public Span[] find(String[] tokens) {
- return new Span[0];
- }
-
- @Override
- public void clearAdaptiveData() {
- }
- };
final DocumentAnalyzer analyzer = DocumentAnalyzer.builder()
.add(new SentenceDetectorAnnotator(TestComponents.PERIOD_SPLITTER))
.add(new TokenizerAnnotator(TestComponents.SPACE_TOKENIZER))
.add(new POSTaggerAnnotator(TAGGER))
- .add(new NameFinderAnnotator(finder))
+ .add(new NameFinderAnnotator(NO_NAMES))
.build();
final Document document = analyzer.analyze(text);
@@ -194,4 +196,23 @@ void testAnnotatorAdaptersRejectNullDelegates() {
assertThrows(IllegalArgumentException.class, () -> new POSTaggerAnnotator(null));
assertThrows(IllegalArgumentException.class, () -> new NameFinderAnnotator(null));
}
+
+ /**
+ * Verifies that every adapter rejects a {@code null} document with the shared
+ * message, whether it checks itself or through
+ * {@link DocumentAnnotators#requireLayers(Document, LayerKey[])}.
+ */
+ @Test
+ void testAnnotatorAdaptersRejectNullDocuments() {
+ final List adapters = List.of(
+ new SentenceDetectorAnnotator(TestComponents.PERIOD_SPLITTER),
+ new TokenizerAnnotator(TestComponents.SPACE_TOKENIZER),
+ new POSTaggerAnnotator(TAGGER),
+ new NameFinderAnnotator(NO_NAMES));
+ for (final DocumentAnnotator adapter : adapters) {
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> adapter.annotate(null));
+ assertEquals("document must not be null", e.getMessage());
+ }
+ }
}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
index 3b3e87d5bb..e46be8bb2f 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/NameFinderAnnotatorTest.java
@@ -67,11 +67,27 @@ public void clearAdaptiveData() {
};
}
+ /**
+ * @return A two-sentence document with sentence and token layers over
+ * {@code "Ana runs. Bob sits."}. Never {@code null}.
+ */
+ private static Document twoSentenceDocument() {
+ return Document.of("Ana runs. Bob sits.")
+ .with(Layers.SENTENCES, List.of(
+ new Annotation<>(new Span(0, 9), "Ana runs."),
+ new Annotation<>(new Span(10, 19), "Bob sits.")))
+ .with(Layers.TOKENS, List.of(
+ new Annotation<>(new Span(0, 3), "Ana"),
+ new Annotation<>(new Span(4, 9), "runs."),
+ new Annotation<>(new Span(10, 13), "Bob"),
+ new Annotation<>(new Span(14, 19), "sits.")));
+ }
+
@Test
void testTokenIndexSpansBecomeCharacterSpans() {
final AtomicInteger cleared = new AtomicInteger();
final TokenNameFinder finder = finder(tokens -> {
- // "New York" as a two-token person-free location mention
+ // "New York" as a two-token location mention
return new Span[] {new Span(1, 3, "location")};
}, cleared);
@@ -146,15 +162,7 @@ void testMentionOutsideSentenceTokensFailsLoud() {
// two tokens in the sentence, but the mention claims three
return new Span[] {new Span(0, 3, "person")};
}, cleared);
- final Document document = Document.of("Ana runs. Bob sits.")
- .with(Layers.SENTENCES, List.of(
- new Annotation<>(new Span(0, 9), "Ana runs."),
- new Annotation<>(new Span(10, 19), "Bob sits.")))
- .with(Layers.TOKENS, List.of(
- new Annotation<>(new Span(0, 3), "Ana"),
- new Annotation<>(new Span(4, 9), "runs."),
- new Annotation<>(new Span(10, 13), "Bob"),
- new Annotation<>(new Span(14, 19), "sits.")));
+ final Document document = twoSentenceDocument();
final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new NameFinderAnnotator(finder).annotate(document));
@@ -176,15 +184,7 @@ void testZeroLengthMentionFailsLoud() {
final AtomicInteger cleared = new AtomicInteger();
final TokenNameFinder finder = finder(tokens ->
"Bob".equals(tokens[0]) ? new Span[] {new Span(0, 0)} : new Span[0], cleared);
- final Document document = Document.of("Ana runs. Bob sits.")
- .with(Layers.SENTENCES, List.of(
- new Annotation<>(new Span(0, 9), "Ana runs."),
- new Annotation<>(new Span(10, 19), "Bob sits.")))
- .with(Layers.TOKENS, List.of(
- new Annotation<>(new Span(0, 3), "Ana"),
- new Annotation<>(new Span(4, 9), "runs."),
- new Annotation<>(new Span(10, 13), "Bob"),
- new Annotation<>(new Span(14, 19), "sits.")));
+ final Document document = twoSentenceDocument();
final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new NameFinderAnnotator(finder).annotate(document));
@@ -231,17 +231,7 @@ void testFindsPerSentenceAndMapsSentenceLocalIndices() {
return new Span[] {new Span(0, 1, "person")};
}, cleared);
- final Document document = Document.of("Ana runs. Bob sits.")
- .with(Layers.SENTENCES, List.of(
- new Annotation<>(new Span(0, 9), "Ana runs."),
- new Annotation<>(new Span(10, 19), "Bob sits.")))
- .with(Layers.TOKENS, List.of(
- new Annotation<>(new Span(0, 3), "Ana"),
- new Annotation<>(new Span(4, 9), "runs."),
- new Annotation<>(new Span(10, 13), "Bob"),
- new Annotation<>(new Span(14, 19), "sits.")));
-
- final Document annotated = new NameFinderAnnotator(finder).annotate(document);
+ final Document annotated = new NameFinderAnnotator(finder).annotate(twoSentenceDocument());
assertEquals(List.of(
List.of("Ana", "runs."),
From 689636105f3a9b69a735442bb763e096bbae378e Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Sat, 8 Aug 2026 18:41:37 -0400
Subject: [PATCH 17/92] OPENNLP-1888: Name the null argument, avoid per-call
key set wrapping, pin blank and span edge cases
- ImmutableDocument: wrap the layer map unmodifiable at construction and expose its cached key set; split the combined null check so the message names the offending argument
- StringUtil.isBlank javadoc: state how it differs from isUnicodeBlank
- Tests: parameterize the isBlank accept and reject sides, pin the null NPE, and pin char-indexed spans over a supplementary-plane character
---
.../tools/document/ImmutableDocument.java | 15 +++++---
.../java/opennlp/tools/util/StringUtil.java | 4 ++-
.../tools/document/DocumentContractTest.java | 21 +++++++++++
.../opennlp/tools/util/StringUtilTest.java | 36 +++++++++++--------
4 files changed, 56 insertions(+), 20 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
index b456258d3d..dc23dd4106 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
@@ -43,7 +43,7 @@ final class ImmutableDocument implements Document {
private ImmutableDocument(String text, Map, List>> layers) {
this.text = text;
- this.layers = layers;
+ this.layers = Collections.unmodifiableMap(layers);
}
/**
@@ -86,14 +86,19 @@ public List> get(LayerKey layer) {
/** {@inheritDoc} */
@Override
public Set> layers() {
- return Collections.unmodifiableSet(layers.keySet());
+ // The unmodifiable map exposes an unmodifiable key set and caches it, so this
+ // accessor allocates no wrapper per call.
+ return layers.keySet();
}
/** {@inheritDoc} */
@Override
public Document with(LayerKey layer, List> annotations) {
- if (layer == null || annotations == null) {
- throw new IllegalArgumentException("layer and annotations must not be null");
+ if (layer == null) {
+ throw new IllegalArgumentException("layer must not be null");
+ }
+ if (annotations == null) {
+ throw new IllegalArgumentException("annotations must not be null");
}
if (layers.containsKey(layer)) {
throw new IllegalArgumentException("layer is already present: " + layer);
@@ -123,6 +128,6 @@ public Document with(LayerKey layer, List> annotations) {
}
final Map, List>> grown = new LinkedHashMap<>(layers);
grown.put(layer, List.copyOf(annotations));
- return new ImmutableDocument(text, Collections.unmodifiableMap(grown));
+ return new ImmutableDocument(text, grown);
}
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
index 89694335cc..82041f7764 100644
--- a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
+++ b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
@@ -273,7 +273,9 @@ public static boolean isEmpty(CharSequence theString) {
* code points that {@link #isWhitespace(int)} accepts. Unlike
* {@link String#isBlank()}, this follows the toolkit's whitespace definition, which
* includes the no-break spaces the JDK predicate leaves out, so a value spelled
- * entirely from them cannot pass a blank check as content.
+ * entirely from them cannot pass a blank check as content. Unlike
+ * {@link #isUnicodeBlank(CharSequence)}, it resolves through the active
+ * {@link WhitespaceMode} and does not treat {@code null} as blank.
*
* @param theString The {@link CharSequence} to examine. Must not be {@code null}.
* @return {@code true} if {@code theString} is empty or all whitespace.
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index fb01047aa5..75f62d34ea 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -222,6 +222,27 @@ void testZeroLengthSpansAreAccepted() {
assertEquals(0, words.get(0).span().length());
}
+ /**
+ * Verifies that spans are indexed in {@code char} units, like {@link Span} itself: a
+ * supplementary-plane character counts as two, so a span over it covers the whole
+ * surrogate pair and the char-based text length is what bounds a span.
+ */
+ @Test
+ void testSpansAreCharIndexedOverSupplementaryCharacters() {
+ // U+1F600, a supplementary-plane character, is two chars in the text
+ final String text = "\uD83D\uDE00 ok";
+ final Document document = Document.of(text)
+ .with(WORDS, List.of(
+ new Annotation<>(new Span(0, 2), "emoji"),
+ new Annotation<>(new Span(3, 5), "ok")));
+ final List> words = document.get(WORDS);
+ assertEquals("\uD83D\uDE00",
+ words.get(0).span().getCoveredText(document.text()).toString());
+ assertEquals("ok", words.get(1).span().getCoveredText(document.text()).toString());
+ assertThrows(IllegalArgumentException.class, () -> Document.of("\uD83D\uDE00")
+ .with(WORDS, List.of(new Annotation<>(new Span(0, 3), "past the end"))));
+ }
+
/**
* Verifies that a span reaching past the end of the text is rejected on insertion
* with a message naming the span, the text length, and the layer.
diff --git a/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java b/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
index 73f81c209e..1951c114a0 100644
--- a/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
+++ b/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
@@ -681,21 +681,29 @@ void testLowercaseBeyondBMP() {
}
/**
- * Verifies the blank check against the toolkit's whitespace definition: the
- * no-break space is blank here although the JDK's own check does not cover it,
- * whitespace-only and empty values are blank, and any non-whitespace code point,
- * supplementary ones included, makes a value non-blank.
+ * Verifies the accepting side of the blank check against the toolkit's whitespace
+ * definition: empty and JDK-whitespace values are blank, and so are the no-break
+ * spaces U+00A0 and U+2007, which {@link String#isBlank()} does not cover.
*/
+ @ParameterizedTest
+ @ValueSource(strings = {"", " \t\n", "\u00A0", " \u00A0\u2007 "})
+ void testIsBlankAcceptsWhitespaceOnlyValues(String input) {
+ Assertions.assertTrue(StringUtil.isBlank(input));
+ }
+
+ /**
+ * Verifies the rejecting side of the blank check: any non-whitespace code point
+ * makes a value non-blank, including the supplementary-plane letter U+10428, which
+ * must be read as one code point rather than two chars.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"a", " a ", "\uD801\uDC28"})
+ void testIsBlankRejectsValuesWithContent(String input) {
+ Assertions.assertFalse(StringUtil.isBlank(input));
+ }
+
@Test
- void testIsBlankFollowsTheToolkitWhitespaceDefinition() {
- Assertions.assertTrue(StringUtil.isBlank(""));
- Assertions.assertTrue(StringUtil.isBlank(" \t\n"));
- // U+00A0 no-break space and U+2007 figure space: JDK String.isBlank says false
- Assertions.assertTrue(StringUtil.isBlank("\u00A0"));
- Assertions.assertTrue(StringUtil.isBlank(" \u00A0\u2007 "));
- Assertions.assertFalse(StringUtil.isBlank("a"));
- Assertions.assertFalse(StringUtil.isBlank(" a "));
- // U+10428, a supplementary-plane letter read as one code point, not two chars
- Assertions.assertFalse(StringUtil.isBlank("\uD801\uDC28"));
+ void testIsBlankWithNullString() {
+ Assertions.assertThrows(NullPointerException.class, () -> StringUtil.isBlank(null));
}
}
From 60843099016addb0d2590fe2fbf2aec1b3677493 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 16:46:58 -0400
Subject: [PATCH 18/92] OPENNLP-1888: Rework the document chapter after docs
review
Addresses rzo1's review comments on the manual:
- Open with a plain-language definition and an inline typed-layer
example instead of a bolded three-item list.
- Show a stacked-layers figure for the running example up front and
reference it from the pipeline section, so readers see the shape of
a document before the API detail.
- Explain span offsets, key identity, and the opennlp: prefix
convention in prose a first-time reader can follow.
- Reword the single-task API aside; drop the redundant custom
annotator opener; cut the repeated statically-typed phrasing.
---
opennlp-docs/src/docbkx/document.xml | 113 ++++++++++++++-------------
1 file changed, 59 insertions(+), 54 deletions(-)
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index 648ee288a6..53e69de2d2 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -21,53 +21,57 @@
Introduction
The package opennlp.tools.document provides an immutable container
- that carries the original text of one document together with any number of typed
- annotation layers over it. Every annotation is anchored to a
- Span of the text exactly as the caller supplied it, never to a
- normalized or otherwise derived form, so any result of any pipeline step can be
- highlighted in the source text.
+ that carries the original text of one document together with any number of
+ annotation layers over it. A layer is a list of Annotation values,
+ each pairing a Span with a value. A span's offsets count characters
+ from the beginning of the text exactly as the caller supplied it, never a
+ normalized or otherwise derived form, so every annotation can be highlighted in
+ the source text. The value's Java type is part of the layer's identity: a token
+ layer reads back as List<Annotation<String>>, for
+ example the token The paired with the span [0..3).
+
+
+ A document with three annotation layers
+
+text: The dog barks. It naps.
+ 0 1 2
+ 01234567890123456789012
+
+sentences: [0..14) "The dog barks." [15..23) "It naps."
+tokens: [0..3) "The" [4..7) "dog" [8..14) "barks." [15..17) "It" [18..23) "naps."
+pos-tags: [0..3) "DT" [4..7) "NN" [8..14) "VBZ" [15..17) "PRP" [18..23) "VBZ"
+
+
+ Each layer in the figure above is identified by a LayerKey that
+ names the layer and declares the type of its values:
+ Layers.TOKENS is a LayerKey<String>, so
+ document.get(Layers.TOKENS) returns the token annotations with
+ string values. Two keys are equal when their id, their value type, and their
+ scope are equal, so independently created constants for the same layer
+ interoperate. The keys of the core linguistic layers live in
+ Layers; a capability-specific layer's key lives on the annotator
+ that provides it, and adding a capability never changes the container.
+
+
+ Key ids are namespaced. Keys OpenNLP defines start with the
+ opennlp: prefix; a user extension needs to use its own prefix. A
+ bare id, like the token-lengths key below, is legal for an
+ application-local layer. Toolkit keys are created through
+ Layers.key and Layers.documentKey, which apply the
+ prefix.
- The design follows three rules:
+ A document is never modified in place: adding a layer returns a new document
+ that shares the unchanged layers with its ancestor. Documents created through
+ Document.of capture their text at construction and are safe to
+ share between threads. Annotations reference other annotations by index within
+ their layer, never by object identity. Three invariants make those index
+ references sound: a layer preserves its insertion order and is never sorted or
+ reordered, a layer is
+ immutable once added and detached from the caller's input list, and providing
+ a layer that already exists is rejected, so an index reference stays valid for
+ the lifetime of the document.
-
-
-
- Offset-anchored. A layer is a list of
- Annotation values, each pairing a Span in original
- text coordinates with a typed value. Annotations reference other annotations
- by index within their layer, never by object identity.
-
-
-
-
- Open key space. A layer is identified by a
- LayerKey that also carries the type of its values, so reading a
- layer back is statically typed. Two keys are equal when their id, their
- value type, and their scope are equal, so independently created constants
- for the same layer interoperate. The keys of the core linguistic layers live in
- Layers; a capability-specific layer's key lives on the annotator
- that provides it, and adding a capability never changes the container. Ids
- are namespaced: keys the toolkit defines carry the opennlp:
- prefix, an extension uses its own prefix, and a bare id, like the
- token-lengths key below, is legal for an application-local
- layer. Toolkit keys are created through Layers.key and
- Layers.documentKey, which apply the prefix.
-
-
-
-
- Immutable. Adding a layer returns a new
- document that shares the unchanged layers with its ancestor. Documents
- created through Document.of capture their text at construction
- and are safe to share between threads. Three invariants make index references
- sound: a layer preserves its insertion order and is never sorted or
- reordered, a layer is immutable once added and detached from the caller's
- input list, and providing a layer that already exists is rejected, so an
- index reference stays valid for the lifetime of the document.
-
-
-
A key declares its scope. Keys are positional by default: every annotation
carries a span, and consumers never null-check it. A document-scoped key
@@ -105,9 +109,10 @@ String language = tagged.get(LANGUAGE).get(0).value(); // "eng", span is null]]>
SentenceDetectorAnnotator, TokenizerAnnotator,
POSTaggerAnnotator, NameFinderAnnotator,
LemmatizerAnnotator, and StemmerAnnotator. Each wraps
- the existing single-task API, which stays the primary API for single-task use.
- The following pipeline combines three adapters with one custom annotator and
- analyzes a two-sentence text; the components behind the adapters are any
+ one of the existing component APIs; an application that needs only one task
+ can keep using that component's API directly. The following pipeline combines
+ three adapters with one custom annotator and analyzes the text from the
+ introduction's figure; the components behind the adapters are any
SentenceDetector, Tokenizer, and
POSTagger, for example the ME implementations loaded from models:
@@ -126,8 +131,9 @@ Document document = analyzer.analyze("The dog barks. It naps.");]]>
round-trip shown here.
- The resulting document carries exactly the four layers the pipeline provides.
- The sentence layer holds two annotations, [0..14) covering
+ The resulting document carries exactly the four layers the pipeline provides,
+ rendered as the stacked layers of the introduction's figure. The sentence
+ layer holds two annotations, [0..14) covering
The dog barks. and [15..23) covering
It naps.. The token layer holds five tokens whose spans refer to
the document text even inside the second sentence, so
@@ -153,9 +159,8 @@ for (Annotation token : tokens) {
Writing a custom annotator
- A new capability contributes its results as one more layer without any change to
- the container. The annotator below defines its own key in its own code, reads
- the token layer, and provides one integer annotation per token; the analyzer's
+ The annotator below defines its own key in its own code, reads the token
+ layer, and provides one integer annotation per token; the analyzer's
build-time validation guarantees a tokenizer ran earlier:
- Reading the layer back is statically typed by the key, so the values are used as
- numbers without a cast; for the text above the five values are
+ Because the key declares the value type, the values come back as numbers
+ without a cast; for the text above the five values are
3, 3, 6, 2, 5, each on its token's span:
Date: Mon, 17 Aug 2026 16:49:43 -0400
Subject: [PATCH 19/92] OPENNLP-1888: Pin the Document.merge contract
Two contract tests fail red against the default method stub:
java.lang.UnsupportedOperationException: merge is not implemented yet
merge joins two documents grown independently over the same text, the
parallel fan-out join rzo1 asked for on the pull request: disjoint
layers stack into one document, the sources stay untouched, and a null
argument, a different text, or a duplicate layer key is rejected with
the offending key named.
---
.../java/opennlp/tools/document/Document.java | 19 +++++++
.../tools/document/DocumentContractTest.java | 53 +++++++++++++++++++
2 files changed, 72 insertions(+)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index 4169793eff..b03192eb8d 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -100,4 +100,23 @@ static Document of(CharSequence text) {
* @throws IllegalArgumentException Thrown if any of the above constraints is violated.
*/
Document with(LayerKey layer, List> annotations);
+
+ /**
+ * Returns a new document combining this document's layers with another document's
+ * layers over the same text: the parallel fan-out join. Two pipelines can each start
+ * from the same text and grow their own document independently; {@code merge} stacks
+ * their layers into one document.
+ *
+ * @param other The document whose layers are added on top of this document's layers.
+ * Must not be {@code null}, must carry the same text content, and must
+ * not provide a layer this document already has.
+ * @return A new {@link Document} carrying the layers of both documents. Never
+ * {@code null}; both source documents are left untouched.
+ * @throws IllegalArgumentException Thrown if {@code other} is {@code null}, if its
+ * text content differs, or if a layer key is present on both documents; the
+ * exception names the offending key.
+ */
+ default Document merge(Document other) {
+ throw new UnsupportedOperationException("merge is not implemented yet");
+ }
}
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index 75f62d34ea..d432a8d4b2 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -413,4 +413,57 @@ void testValueTypeTravelsThroughTheKey() {
assertEquals("value of type java.lang.String does not match layer counts2",
e.getMessage());
}
+
+ /**
+ * Verifies that merge joins two documents grown independently over the same text, for
+ * example by two pipelines that ran in parallel, into one document carrying both layer
+ * sets, while leaving both sources untouched. Text content decides equality, not the
+ * {@link CharSequence} implementation.
+ */
+ @Test
+ void testMergeJoinsLayersOfDocumentsOverTheSameText() {
+ final LayerKey lengths = LayerKey.of("lengths", Integer.class);
+ final Document words = Document.of("the dog")
+ .with(WORDS, List.of(
+ new Annotation<>(new Span(0, 3), "the"),
+ new Annotation<>(new Span(4, 7), "dog")));
+ final Document counted = Document.of(new StringBuilder("the dog"))
+ .with(lengths, List.of(
+ new Annotation<>(new Span(0, 3), 3),
+ new Annotation<>(new Span(4, 7), 3)));
+
+ final Document merged = words.merge(counted);
+
+ assertEquals("the dog", merged.text().toString());
+ assertEquals(Set.of(WORDS, lengths), merged.layers());
+ assertEquals("the", merged.get(WORDS).get(0).value());
+ assertEquals(3, merged.get(lengths).get(0).value().intValue());
+ // The sources are unchanged: merge, like with, never modifies in place.
+ assertEquals(Set.of(WORDS), words.layers());
+ assertEquals(Set.of(lengths), counted.layers());
+ }
+
+ /**
+ * Verifies that merge rejects a null argument, a document over a different text even
+ * when their layers are disjoint, and a layer key present on both documents, naming
+ * the offending key.
+ */
+ @Test
+ void testMergeRejectsNullDifferentTextAndDuplicateLayers() {
+ final Document words = Document.of("the dog")
+ .with(WORDS, List.of(new Annotation<>(new Span(0, 3), "the")));
+
+ final IllegalArgumentException nullOther = assertThrows(IllegalArgumentException.class,
+ () -> words.merge(null));
+ assertEquals("other must not be null", nullOther.getMessage());
+
+ final IllegalArgumentException differentText = assertThrows(
+ IllegalArgumentException.class, () -> words.merge(Document.of("the cat")));
+ assertEquals("merge requires both documents to carry the same text",
+ differentText.getMessage());
+
+ final IllegalArgumentException duplicate = assertThrows(IllegalArgumentException.class,
+ () -> words.merge(words));
+ assertEquals("layer is already present: words", duplicate.getMessage());
+ }
}
From 919cf590e9829ea5f46766000c7e1e499142f9c5 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 16:50:30 -0400
Subject: [PATCH 20/92] OPENNLP-1888: Implement Document.merge as a default
method
The default body validates the argument and the shared text, then adds
each of the other document's layers through with(), so every layer is
re-validated against this document's contract and a duplicate key is
rejected by the same once-only rule a direct add follows. The pinned
contract tests now pass; opennlp-api is 386 tests, 0 failures.
---
.../java/opennlp/tools/document/Document.java | 21 ++++++++++++++++++-
1 file changed, 20 insertions(+), 1 deletion(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index b03192eb8d..962bb0b6de 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -117,6 +117,25 @@ static Document of(CharSequence text) {
* exception names the offending key.
*/
default Document merge(Document other) {
- throw new UnsupportedOperationException("merge is not implemented yet");
+ if (other == null) {
+ throw new IllegalArgumentException("other must not be null");
+ }
+ if (!text().toString().contentEquals(other.text())) {
+ throw new IllegalArgumentException(
+ "merge requires both documents to carry the same text");
+ }
+ Document merged = this;
+ for (final LayerKey> layer : other.layers()) {
+ merged = addLayer(merged, layer, other);
+ }
+ return merged;
+ }
+
+ /**
+ * Adds one layer of {@code from} to {@code base}, capturing the key's value type so
+ * {@link #with(LayerKey, List)} re-validates the layer against this document.
+ */
+ private static Document addLayer(Document base, LayerKey layer, Document from) {
+ return base.with(layer, from.get(layer));
}
}
From aa5aebae7d5b91a8467dc1d8a31e0b92f08088a9 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 16:51:17 -0400
Subject: [PATCH 21/92] OPENNLP-1888: Document the parallel fan-out join in the
manual
---
opennlp-docs/src/docbkx/document.xml | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index 53e69de2d2..c2756c0847 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -154,6 +154,15 @@ for (Annotation token : tokens) {
CharSequence covered = token.span().getCoveredText(document.text());
}]]>
+
+ Because a document is immutable, independent pipelines can process the same
+ text in parallel, each starting from its own Document.of(text),
+ and their results can be joined afterwards: merge returns a new
+ document carrying the layers of both. The texts must match and the layer sets
+ must be disjoint, so two branches that both run a tokenizer collide on
+ opennlp:tokens; partition the branches by the layers they
+ provide.
+
From adb15b42604aaf59f6bd690a353bf3e113a05e0c Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 18:27:48 -0400
Subject: [PATCH 22/92] OPENNLP-1888: Pin the duplicate-layer policy contract
for merge
Two contract tests fail red against the stubbed two-arg merge:
java.lang.UnsupportedOperationException: merge with policy is not
implemented yet
The strict single-arg merge stays the default; the policy variant opts
into keeping one copy of a layer both documents rebuilt identically,
and still rejects differing copies with the key named.
---
.../java/opennlp/tools/document/Document.java | 36 +++++++++++++++
.../tools/document/DocumentContractTest.java | 46 +++++++++++++++++++
2 files changed, 82 insertions(+)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index 962bb0b6de..3d9e93a5b8 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -101,6 +101,23 @@ static Document of(CharSequence text) {
*/
Document with(LayerKey layer, List> annotations);
+ /**
+ * How {@link #merge(Document, DuplicateLayerPolicy)} treats a layer key that is
+ * present on both documents.
+ */
+ enum DuplicateLayerPolicy {
+
+ /** Reject any layer key present on both documents. */
+ REJECT,
+
+ /**
+ * Keep one copy of a layer key present on both documents when the two layers are
+ * structurally equal, for example when two parallel branches ran the same
+ * tokenizer. Layers whose contents differ are rejected as with {@link #REJECT}.
+ */
+ KEEP_EQUAL
+ }
+
/**
* Returns a new document combining this document's layers with another document's
* layers over the same text: the parallel fan-out join. Two pipelines can each start
@@ -131,6 +148,25 @@ default Document merge(Document other) {
return merged;
}
+ /**
+ * Returns a new document combining this document's layers with another document's
+ * layers over the same text, resolving duplicate layer keys with
+ * {@code duplicateLayers}.
+ *
+ * @param other The document whose layers are added on top of this document's layers.
+ * Must not be {@code null} and must carry the same text content.
+ * @param duplicateLayers How to treat a layer key present on both documents. Must
+ * not be {@code null}.
+ * @return A new {@link Document} carrying the layers of both documents. Never
+ * {@code null}; both source documents are left untouched.
+ * @throws IllegalArgumentException Thrown if either argument is {@code null}, if the
+ * text content differs, or if a layer key is present on both documents and
+ * the policy does not keep it; the exception names the offending key.
+ */
+ default Document merge(Document other, DuplicateLayerPolicy duplicateLayers) {
+ throw new UnsupportedOperationException("merge with policy is not implemented yet");
+ }
+
/**
* Adds one layer of {@code from} to {@code base}, capturing the key's value type so
* {@link #with(LayerKey, List)} re-validates the layer against this document.
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index d432a8d4b2..4a381176d2 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -466,4 +466,50 @@ void testMergeRejectsNullDifferentTextAndDuplicateLayers() {
() -> words.merge(words));
assertEquals("layer is already present: words", duplicate.getMessage());
}
+
+ /**
+ * Verifies that {@link Document.DuplicateLayerPolicy#KEEP_EQUAL} keeps one copy of a
+ * layer both documents rebuilt identically, for example the shared tokenizer prefix
+ * of two parallel branches, while still joining the disjoint layers.
+ */
+ @Test
+ void testMergeKeepingEqualLayersToleratesIdenticalCopies() {
+ final List> tokens = List.of(
+ new Annotation<>(new Span(0, 3), "the"),
+ new Annotation<>(new Span(4, 7), "dog"));
+ final LayerKey lengths = LayerKey.of("lengths", Integer.class);
+ final Document words = Document.of("the dog").with(WORDS, tokens);
+ final Document recounted = Document.of("the dog")
+ .with(WORDS, tokens)
+ .with(lengths, List.of(
+ new Annotation<>(new Span(0, 3), 3),
+ new Annotation<>(new Span(4, 7), 3)));
+
+ final Document merged = words.merge(recounted, Document.DuplicateLayerPolicy.KEEP_EQUAL);
+
+ assertEquals(Set.of(WORDS, lengths), merged.layers());
+ // The shared layer is kept once, not concatenated.
+ assertEquals(2, merged.get(WORDS).size());
+ assertEquals(2, merged.get(lengths).size());
+ }
+
+ /**
+ * Verifies that {@link Document.DuplicateLayerPolicy#KEEP_EQUAL} still rejects a layer
+ * whose two copies differ, naming the key, and rejects a null policy.
+ */
+ @Test
+ void testMergeKeepingEqualLayersRejectsDifferingCopiesAndNullPolicy() {
+ final Document words = Document.of("the dog")
+ .with(WORDS, List.of(new Annotation<>(new Span(0, 3), "the")));
+ final Document retokenized = Document.of("the dog")
+ .with(WORDS, List.of(new Annotation<>(new Span(0, 7), "the dog")));
+
+ final IllegalArgumentException differing = assertThrows(IllegalArgumentException.class,
+ () -> words.merge(retokenized, Document.DuplicateLayerPolicy.KEEP_EQUAL));
+ assertEquals("layer is already present: words", differing.getMessage());
+
+ final IllegalArgumentException nullPolicy = assertThrows(IllegalArgumentException.class,
+ () -> words.merge(Document.of("the dog"), null));
+ assertEquals("duplicateLayers must not be null", nullPolicy.getMessage());
+ }
}
From 1297f725de40bc9dbd2c38ede78d6fe8eb90afc2 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 18:29:03 -0400
Subject: [PATCH 23/92] OPENNLP-1888: Add a duplicate-layer policy to
Document.merge
merge(other) stays strict and now delegates to merge(other, REJECT).
The KEEP_EQUAL policy keeps one copy of a layer both documents rebuilt
identically, the shared sentence/tokenizer prefix of two parallel
branches, while differing copies are still rejected with the key named.
The pinned contract tests now pass; opennlp-api is 388 tests,
0 failures. The manual's fan-out paragraph documents the option.
---
.../java/opennlp/tools/document/Document.java | 42 +++++++++++++------
opennlp-docs/src/docbkx/document.xml | 10 +++--
2 files changed, 35 insertions(+), 17 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index 3d9e93a5b8..5f2165b498 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -134,18 +134,7 @@ enum DuplicateLayerPolicy {
* exception names the offending key.
*/
default Document merge(Document other) {
- if (other == null) {
- throw new IllegalArgumentException("other must not be null");
- }
- if (!text().toString().contentEquals(other.text())) {
- throw new IllegalArgumentException(
- "merge requires both documents to carry the same text");
- }
- Document merged = this;
- for (final LayerKey> layer : other.layers()) {
- merged = addLayer(merged, layer, other);
- }
- return merged;
+ return merge(other, DuplicateLayerPolicy.REJECT);
}
/**
@@ -164,7 +153,34 @@ default Document merge(Document other) {
* the policy does not keep it; the exception names the offending key.
*/
default Document merge(Document other, DuplicateLayerPolicy duplicateLayers) {
- throw new UnsupportedOperationException("merge with policy is not implemented yet");
+ if (other == null) {
+ throw new IllegalArgumentException("other must not be null");
+ }
+ if (duplicateLayers == null) {
+ throw new IllegalArgumentException("duplicateLayers must not be null");
+ }
+ if (!text().toString().contentEquals(other.text())) {
+ throw new IllegalArgumentException(
+ "merge requires both documents to carry the same text");
+ }
+ Document merged = this;
+ for (final LayerKey> layer : other.layers()) {
+ if (duplicateLayers == DuplicateLayerPolicy.KEEP_EQUAL
+ && merged.layers().contains(layer)
+ && layersEqual(merged, layer, other)) {
+ continue;
+ }
+ merged = addLayer(merged, layer, other);
+ }
+ return merged;
+ }
+
+ /**
+ * @return Whether the layer is present on both documents with structurally equal
+ * contents.
+ */
+ private static boolean layersEqual(Document first, LayerKey layer, Document second) {
+ return first.get(layer).equals(second.get(layer));
}
/**
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index c2756c0847..52711508ed 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -158,10 +158,12 @@ for (Annotation token : tokens) {
Because a document is immutable, independent pipelines can process the same
text in parallel, each starting from its own Document.of(text),
and their results can be joined afterwards: merge returns a new
- document carrying the layers of both. The texts must match and the layer sets
- must be disjoint, so two branches that both run a tokenizer collide on
- opennlp:tokens; partition the branches by the layers they
- provide.
+ document carrying the layers of both. The texts must match, and by default a
+ layer key present on both documents is rejected, so two branches that both
+ run a tokenizer collide on opennlp:tokens. When the branches
+ rebuild a shared prefix identically, passing
+ DuplicateLayerPolicy.KEEP_EQUAL keeps one copy of each agreeing
+ layer; copies whose contents differ are still rejected.
From 3245eff1c3b14f442d76a82bec56a208c954fb80 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 19:05:49 -0400
Subject: [PATCH 24/92] OPENNLP-1888: Render the layer figure monospaced and
tighten the prose
- literallayout class=monospaced makes the docbkx toolchain emit a pre
block, so the figure's character ruler and layer rows column-align;
plain literallayout renders in the proportional body font.
- Correct the pipeline section: the figure shows three of the four
layers; the custom token-lengths layer is the fourth.
- Trim restated clauses in the merge javadoc, the contract test
javadoc, and the introduction; align the layersEqual and addLayer
helper javadoc with what the helpers do.
---
.../java/opennlp/tools/document/Document.java | 12 +++++-------
.../tools/document/DocumentContractTest.java | 9 +++------
opennlp-docs/src/docbkx/document.xml | 19 +++++++++----------
3 files changed, 17 insertions(+), 23 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index 5f2165b498..c78d5d3b8f 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -120,9 +120,8 @@ enum DuplicateLayerPolicy {
/**
* Returns a new document combining this document's layers with another document's
- * layers over the same text: the parallel fan-out join. Two pipelines can each start
- * from the same text and grow their own document independently; {@code merge} stacks
- * their layers into one document.
+ * layers over the same text, joining documents grown independently, for example by
+ * pipelines that ran in parallel.
*
* @param other The document whose layers are added on top of this document's layers.
* Must not be {@code null}, must carry the same text content, and must
@@ -176,16 +175,15 @@ && layersEqual(merged, layer, other)) {
}
/**
- * @return Whether the layer is present on both documents with structurally equal
- * contents.
+ * @return Whether the two documents carry structurally equal contents for the layer.
*/
private static boolean layersEqual(Document first, LayerKey layer, Document second) {
return first.get(layer).equals(second.get(layer));
}
/**
- * Adds one layer of {@code from} to {@code base}, capturing the key's value type so
- * {@link #with(LayerKey, List)} re-validates the layer against this document.
+ * Adds one layer of {@code from} to {@code base} through {@link #with(LayerKey, List)},
+ * capturing the key's value type.
*/
private static Document addLayer(Document base, LayerKey layer, Document from) {
return base.with(layer, from.get(layer));
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index 4a381176d2..3fe70e175f 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -415,9 +415,8 @@ void testValueTypeTravelsThroughTheKey() {
}
/**
- * Verifies that merge joins two documents grown independently over the same text, for
- * example by two pipelines that ran in parallel, into one document carrying both layer
- * sets, while leaving both sources untouched. Text content decides equality, not the
+ * Verifies that merge joins two documents grown independently over the same text and
+ * leaves both sources untouched. Text content decides equality, not the
* {@link CharSequence} implementation.
*/
@Test
@@ -438,7 +437,6 @@ void testMergeJoinsLayersOfDocumentsOverTheSameText() {
assertEquals(Set.of(WORDS, lengths), merged.layers());
assertEquals("the", merged.get(WORDS).get(0).value());
assertEquals(3, merged.get(lengths).get(0).value().intValue());
- // The sources are unchanged: merge, like with, never modifies in place.
assertEquals(Set.of(WORDS), words.layers());
assertEquals(Set.of(lengths), counted.layers());
}
@@ -469,8 +467,7 @@ void testMergeRejectsNullDifferentTextAndDuplicateLayers() {
/**
* Verifies that {@link Document.DuplicateLayerPolicy#KEEP_EQUAL} keeps one copy of a
- * layer both documents rebuilt identically, for example the shared tokenizer prefix
- * of two parallel branches, while still joining the disjoint layers.
+ * layer both documents rebuilt identically while still joining the disjoint layers.
*/
@Test
void testMergeKeepingEqualLayersToleratesIdenticalCopies() {
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index 52711508ed..9e21bb73a7 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -25,14 +25,14 @@
annotation layers over it. A layer is a list of Annotation values,
each pairing a Span with a value. A span's offsets count characters
from the beginning of the text exactly as the caller supplied it, never a
- normalized or otherwise derived form, so every annotation can be highlighted in
+ normalized or derived form, so every annotation can be highlighted in
the source text. The value's Java type is part of the layer's identity: a token
layer reads back as List<Annotation<String>>, for
example the token The paired with the span [0..3).
A document with three annotation layers
-
+
text: The dog barks. It naps.
0 1 2
01234567890123456789012
@@ -54,7 +54,7 @@ pos-tags: [0..3) "DT" [4..7) "NN" [8..14) "VBZ" [15..17) "PRP" [18..23) "V
Key ids are namespaced. Keys OpenNLP defines start with the
- opennlp: prefix; a user extension needs to use its own prefix. A
+ opennlp: prefix; a user extension must use its own prefix. A
bare id, like the token-lengths key below, is legal for an
application-local layer. Toolkit keys are created through
Layers.key and Layers.documentKey, which apply the
@@ -66,11 +66,10 @@ pos-tags: [0..3) "DT" [4..7) "NN" [8..14) "VBZ" [15..17) "PRP" [18..23) "V
Document.of capture their text at construction and are safe to
share between threads. Annotations reference other annotations by index within
their layer, never by object identity. Three invariants make those index
- references sound: a layer preserves its insertion order and is never sorted or
- reordered, a layer is
- immutable once added and detached from the caller's input list, and providing
- a layer that already exists is rejected, so an index reference stays valid for
- the lifetime of the document.
+ references sound: a layer keeps its insertion order, a layer is immutable once
+ added and detached from the caller's input list, and providing a layer that
+ already exists is rejected, so an index reference stays valid for the lifetime
+ of the document.
A key declares its scope. Keys are positional by default: every annotation
@@ -131,8 +130,8 @@ Document document = analyzer.analyze("The dog barks. It naps.");]]>
round-trip shown here.
- The resulting document carries exactly the four layers the pipeline provides,
- rendered as the stacked layers of the introduction's figure. The sentence
+ The resulting document carries exactly the four layers the pipeline provides:
+ the figure's three plus the custom token-lengths layer. The sentence
layer holds two annotations, [0..14) covering
The dog barks. and [15..23) covering
It naps.. The token layer holds five tokens whose spans refer to
From 4f5c0cacda1dd8050ffe3e84045584179608f8b2 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 19:20:40 -0400
Subject: [PATCH 25/92] OPENNLP-1888: Pin a differing-contents message for
KEEP_EQUAL rejection
The repinned test fails red: a KEEP_EQUAL merge that rejects a layer
whose copies differ still reports 'layer is already present', which
reads as if the policy was ignored. The caller opted into duplicates;
the reason worth naming is that the contents differ.
---
.../test/java/opennlp/tools/document/DocumentContractTest.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index 3fe70e175f..cd4fc7d132 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -503,7 +503,8 @@ void testMergeKeepingEqualLayersRejectsDifferingCopiesAndNullPolicy() {
final IllegalArgumentException differing = assertThrows(IllegalArgumentException.class,
() -> words.merge(retokenized, Document.DuplicateLayerPolicy.KEEP_EQUAL));
- assertEquals("layer is already present: words", differing.getMessage());
+ assertEquals("layer is present on both documents with differing contents: words",
+ differing.getMessage());
final IllegalArgumentException nullPolicy = assertThrows(IllegalArgumentException.class,
() -> words.merge(Document.of("the dog"), null));
From 844128fad9d65796a9ef155db45676c2aac12861 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 19:21:12 -0400
Subject: [PATCH 26/92] OPENNLP-1888: Name the differing contents when
KEEP_EQUAL rejects a layer
When the policy is KEEP_EQUAL and a layer key is present on both
documents, a failed equality check now throws directly instead of
falling through to with(), so the message states the actual reason:
the copies differ, not merely that the key is a duplicate. The pinned
contract test passes; opennlp-api is 388 tests, 0 failures.
---
.../src/main/java/opennlp/tools/document/Document.java | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index c78d5d3b8f..1e3d8d40a3 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -165,9 +165,12 @@ default Document merge(Document other, DuplicateLayerPolicy duplicateLayers) {
Document merged = this;
for (final LayerKey> layer : other.layers()) {
if (duplicateLayers == DuplicateLayerPolicy.KEEP_EQUAL
- && merged.layers().contains(layer)
- && layersEqual(merged, layer, other)) {
- continue;
+ && merged.layers().contains(layer)) {
+ if (layersEqual(merged, layer, other)) {
+ continue;
+ }
+ throw new IllegalArgumentException(
+ "layer is present on both documents with differing contents: " + layer);
}
merged = addLayer(merged, layer, other);
}
From 75fcac098ab5a0bc34af54c35f13657bc07cc8ca Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 19:21:33 -0400
Subject: [PATCH 27/92] OPENNLP-1888: Pin what layer equality compares for
KEEP_EQUAL
One javadoc sentence on the constant: equality is Annotation equality,
so spans compare by offsets and type, never by probability, and values
by their own equals. Two branches running different models over the
same text can therefore agree; the kept copy is this document's.
---
opennlp-api/src/main/java/opennlp/tools/document/Document.java | 2 ++
1 file changed, 2 insertions(+)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/Document.java b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
index 1e3d8d40a3..4d22d3a18b 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/Document.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/Document.java
@@ -114,6 +114,8 @@ enum DuplicateLayerPolicy {
* Keep one copy of a layer key present on both documents when the two layers are
* structurally equal, for example when two parallel branches ran the same
* tokenizer. Layers whose contents differ are rejected as with {@link #REJECT}.
+ * Equality is {@link Annotation} equality: spans compare by offsets and type,
+ * never by probability, and values by their own {@code equals}.
*/
KEEP_EQUAL
}
From 562af8714b4f800bd97a5a8c88a267bd5dafd6a5 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 19:33:27 -0400
Subject: [PATCH 28/92] OPENNLP-1888: Pin merge semantics across Document
implementations
Two guards ahead of an ImmutableDocument merge override: the interface
default serves implementations that do not override merge with the
same join, KEEP_EQUAL, and rejection messages, and merge re-validates
the layers it takes from a foreign document instead of trusting them,
rejecting an out-of-bounds span by name.
---
.../tools/document/DocumentContractTest.java | 103 ++++++++++++++++++
1 file changed, 103 insertions(+)
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
index cd4fc7d132..e83a01508c 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentContractTest.java
@@ -510,4 +510,107 @@ void testMergeKeepingEqualLayersRejectsDifferingCopiesAndNullPolicy() {
() -> words.merge(Document.of("the dog"), null));
assertEquals("duplicateLayers must not be null", nullPolicy.getMessage());
}
+
+ /**
+ * Verifies that a document implementation that does not override merge gets the same
+ * semantics from the interface default: disjoint layers join, a layer both documents
+ * rebuilt identically is kept once under KEEP_EQUAL, and differing copies are rejected
+ * with the same message the default implementation produces.
+ */
+ @Test
+ void testMergeDefaultImplementationServesForeignDocuments() {
+ final List> tokens = List.of(new Annotation<>(new Span(0, 3), "the"));
+ final LayerKey lengths = LayerKey.of("lengths", Integer.class);
+ final Document words = new DelegatingDocument(Document.of("the dog").with(WORDS, tokens));
+ final Document counted = Document.of("the dog")
+ .with(WORDS, tokens)
+ .with(lengths, List.of(new Annotation<>(new Span(0, 3), 3)));
+
+ final Document merged = words.merge(counted, Document.DuplicateLayerPolicy.KEEP_EQUAL);
+ assertEquals(Set.of(WORDS, lengths), merged.layers());
+ assertEquals(1, merged.get(WORDS).size());
+
+ final Document retokenized = Document.of("the dog")
+ .with(WORDS, List.of(new Annotation<>(new Span(0, 7), "the dog")));
+ final IllegalArgumentException differing = assertThrows(IllegalArgumentException.class,
+ () -> words.merge(retokenized, Document.DuplicateLayerPolicy.KEEP_EQUAL));
+ assertEquals("layer is present on both documents with differing contents: words",
+ differing.getMessage());
+ }
+
+ /**
+ * Verifies that merge validates the layers it takes from the other document instead of
+ * trusting them: a foreign implementation can hand out annotations that were never
+ * checked, and an out-of-bounds span among them is rejected by name.
+ */
+ @Test
+ void testMergeRevalidatesLayersOfForeignDocuments() {
+ final Document words = Document.of("the dog")
+ .with(WORDS, List.of(new Annotation<>(new Span(0, 3), "the")));
+ final LayerKey stale = LayerKey.of("stale", String.class);
+ final Document unvalidated = new UnvalidatedDocument("the dog", stale,
+ List.of(new Annotation<>(new Span(0, 99), "out of bounds")));
+
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> words.merge(unvalidated));
+ assertEquals("span [0..99) exceeds the text length 7 in layer stale",
+ e.getMessage());
+ }
+
+ /**
+ * A pass-through wrapper that overrides none of the interface defaults, so calling
+ * merge on it runs the interface's default implementation.
+ */
+ private record DelegatingDocument(Document delegate) implements Document {
+
+ @Override
+ public CharSequence text() {
+ return delegate.text();
+ }
+
+ @Override
+ public List> get(LayerKey layer) {
+ return delegate.get(layer);
+ }
+
+ @Override
+ public Set> layers() {
+ return delegate.layers();
+ }
+
+ @Override
+ public Document with(LayerKey layer, List> annotations) {
+ return new DelegatingDocument(delegate.with(layer, annotations));
+ }
+ }
+
+ /**
+ * A document whose single layer bypassed all validation, standing in for a foreign
+ * implementation that does not enforce the layer contract itself.
+ */
+ private record UnvalidatedDocument(String rawText, LayerKey key,
+ List> annotations)
+ implements Document {
+
+ @Override
+ public CharSequence text() {
+ return rawText;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public List> get(LayerKey layer) {
+ return key.equals(layer) ? (List>) (List>) annotations : List.of();
+ }
+
+ @Override
+ public Set> layers() {
+ return Set.of(key);
+ }
+
+ @Override
+ public Document with(LayerKey layer, List> annotations) {
+ throw new UnsupportedOperationException();
+ }
+ }
}
From 09cd47d59ed2e8291b03de6b452195e88cc48f07 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 19:34:34 -0400
Subject: [PATCH 29/92] OPENNLP-1888: Merge with a single map copy in
ImmutableDocument
The interface default adds the other document's layers through with(),
building one intermediate document and one map copy per layer. The
override validates each incoming layer with the same checks with()
runs, then copies the layer map once and allocates one document; when
nothing was added it returns this, matching the default. The layer
validation moves from with() into a shared helper unchanged. Pinned by
the cross-implementation contract tests; opennlp-api is 390 tests,
0 failures, runtime annotator suites green.
---
.../tools/document/ImmutableDocument.java | 66 ++++++++++++++++++-
1 file changed, 63 insertions(+), 3 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
index dc23dd4106..e175c05f6c 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/ImmutableDocument.java
@@ -103,6 +103,69 @@ public Document with(LayerKey layer, List> annotations) {
if (layers.containsKey(layer)) {
throw new IllegalArgumentException("layer is already present: " + layer);
}
+ validate(layer, annotations);
+ final Map, List>> grown = new LinkedHashMap<>(layers);
+ grown.put(layer, List.copyOf(annotations));
+ return new ImmutableDocument(text, grown);
+ }
+
+ /**
+ * {@inheritDoc}
+ * This implementation copies the layer map once, not once per added layer.
+ */
+ @Override
+ public Document merge(Document other, DuplicateLayerPolicy duplicateLayers) {
+ if (other == null) {
+ throw new IllegalArgumentException("other must not be null");
+ }
+ if (duplicateLayers == null) {
+ throw new IllegalArgumentException("duplicateLayers must not be null");
+ }
+ if (!text.contentEquals(other.text())) {
+ throw new IllegalArgumentException(
+ "merge requires both documents to carry the same text");
+ }
+ final Map, List>> combined = new LinkedHashMap<>(layers);
+ for (final LayerKey> layer : other.layers()) {
+ if (combined.containsKey(layer)) {
+ if (duplicateLayers == DuplicateLayerPolicy.KEEP_EQUAL
+ && get(layer).equals(other.get(layer))) {
+ continue;
+ }
+ throw new IllegalArgumentException(duplicateLayers == DuplicateLayerPolicy.KEEP_EQUAL
+ ? "layer is present on both documents with differing contents: " + layer
+ : "layer is already present: " + layer);
+ }
+ combined.put(layer, copyValidated(layer, other));
+ }
+ if (combined.size() == layers.size()) {
+ return this;
+ }
+ return new ImmutableDocument(text, combined);
+ }
+
+ /**
+ * {@return a validated immutable copy of one of {@code from}'s layers, capturing the
+ * key's value type}
+ */
+ private List> copyValidated(LayerKey layer, Document from) {
+ final List> annotations = from.get(layer);
+ if (annotations == null) {
+ throw new IllegalArgumentException("annotations must not be null");
+ }
+ validate(layer, annotations);
+ return List.copyOf(annotations);
+ }
+
+ /**
+ * Checks one layer's annotations against the key's contract: no null elements, values
+ * assignable to the key's type, spans present and within the text bounds under a
+ * positional key, absent under a document-scoped key.
+ *
+ * @throws IllegalArgumentException Thrown if any check fails; the message names the
+ * layer.
+ */
+ private void validate(LayerKey layer, List> annotations) {
for (final Annotation annotation : annotations) {
if (annotation == null) {
throw new IllegalArgumentException("annotations must not contain null: " + layer);
@@ -126,8 +189,5 @@ public Document with(LayerKey layer, List> annotations) {
"document-scoped layer " + layer + " must not carry spans");
}
}
- final Map, List>> grown = new LinkedHashMap<>(layers);
- grown.put(layer, List.copyOf(annotations));
- return new ImmutableDocument(text, grown);
}
}
From 0705c5d7373df1b7be051139fa5fca4b2d7c9156 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 20:12:16 -0400
Subject: [PATCH 30/92] OPENNLP-1888: State span offsets in UTF-16 units in the
manual
The chapter said offsets count characters; the pinned contract test
shows a supplementary-plane character counts as two. Say Java chars
(UTF-16 units) so the claim matches the tested behavior.
---
opennlp-docs/src/docbkx/document.xml | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/opennlp-docs/src/docbkx/document.xml b/opennlp-docs/src/docbkx/document.xml
index 9e21bb73a7..9a1aa92410 100644
--- a/opennlp-docs/src/docbkx/document.xml
+++ b/opennlp-docs/src/docbkx/document.xml
@@ -23,8 +23,9 @@
The package opennlp.tools.document provides an immutable container
that carries the original text of one document together with any number of
annotation layers over it. A layer is a list of Annotation values,
- each pairing a Span with a value. A span's offsets count characters
- from the beginning of the text exactly as the caller supplied it, never a
+ each pairing a Span with a value. A span's offsets count Java
+ chars (UTF-16 units, so a supplementary-plane character counts as two) from
+ the beginning of the text exactly as the caller supplied it, never a
normalized or derived form, so every annotation can be highlighted in
the source text. The value's Java type is part of the layer's identity: a token
layer reads back as List<Annotation<String>>, for
From 550bc449063c0ba00c6343feb7975fdc56cce902 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 20:19:02 -0400
Subject: [PATCH 31/92] OPENNLP-1888: Pin that adapters name themselves by
class name
Three tests fail red: the adapters inherit the identity toString, so a
pipeline validation message reads 'annotator
opennlp.tools.document.SentenceDetectorAnnotator@3b96c42e requires
layer ...' instead of naming the adapter. The stemmer test pins the
full analyzer message exactly, since that adapter requires a single
layer and the message is therefore deterministic.
---
.../tools/document/DocumentAnalyzerTest.java | 14 ++++++++++++++
.../tools/lemmatizer/LemmatizerAnnotatorTest.java | 9 +++++++++
.../tools/stemmer/StemmerAnnotatorTest.java | 14 ++++++++++++++
3 files changed, 37 insertions(+)
diff --git a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
index 1106f8f707..b6d9234b66 100644
--- a/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/document/DocumentAnalyzerTest.java
@@ -83,6 +83,20 @@ public void clearAdaptiveData() {
}
};
+ /**
+ * Verifies that the adapters identify themselves by their simple class name, which is
+ * how a pipeline validation message names the offending annotator.
+ */
+ @Test
+ void testAdaptersNameThemselvesByClassName() {
+ assertEquals("SentenceDetectorAnnotator",
+ new SentenceDetectorAnnotator(TestComponents.PERIOD_SPLITTER).toString());
+ assertEquals("TokenizerAnnotator",
+ new TokenizerAnnotator(TestComponents.SPACE_TOKENIZER).toString());
+ assertEquals("POSTaggerAnnotator", new POSTaggerAnnotator(TAGGER).toString());
+ assertEquals("NameFinderAnnotator", new NameFinderAnnotator(NO_NAMES).toString());
+ }
+
@Test
void testPipelineProducesAlignedLayersInOriginalCoordinates() {
final Document document = DocumentAnalyzer.builder()
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
index 83a239751d..63654bbada 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/lemmatizer/LemmatizerAnnotatorTest.java
@@ -119,6 +119,15 @@ public List> lemmatize(List toks, List tags) {
lemmatized.get(LemmatizerAnnotator.LEMMAS).get(2).span());
}
+ /**
+ * Verifies that the adapter identifies itself by its simple class name, which is how a
+ * pipeline validation message names the offending annotator.
+ */
+ @Test
+ void testAdapterNamesItselfByClassName() {
+ assertEquals("LemmatizerAnnotator", new LemmatizerAnnotator(FIXTURE).toString());
+ }
+
@Test
void testInvalidArguments() {
assertThrows(IllegalArgumentException.class,
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
index 3d4493c247..668acd901c 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerAnnotatorTest.java
@@ -23,6 +23,7 @@
import opennlp.tools.document.Annotation;
import opennlp.tools.document.Document;
+import opennlp.tools.document.DocumentAnalyzer;
import opennlp.tools.document.Layers;
import opennlp.tools.util.Span;
@@ -57,6 +58,19 @@ void testInvalidArguments() {
assertThrows(IllegalArgumentException.class, () -> annotator.annotate(null));
}
+ /**
+ * Verifies that a misordered pipeline names the adapter by its simple class name, not
+ * by its default identity string.
+ */
+ @Test
+ void testPipelineValidationNamesTheAdapter() {
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> DocumentAnalyzer.builder()
+ .add(new StemmerAnnotator(new PorterStemmer())).build());
+ assertEquals("annotator StemmerAnnotator requires layer opennlp:tokens,"
+ + " which no earlier annotator provides", e.getMessage());
+ }
+
/**
* Verifies that a document without a token layer is rejected with a message naming the
* missing layer, instead of silently producing an empty stem layer.
From f4dd343a9ca08559ef382923799a31e9477cd311 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Mon, 17 Aug 2026 20:20:46 -0400
Subject: [PATCH 32/92] OPENNLP-1888: Name adapters by their simple class name
All six adapters override toString with the simple class name, so a
pipeline validation message names the offending annotator readably.
The pinned tests pass; opennlp-api and opennlp-runtime suites green.
---
.../java/opennlp/tools/document/NameFinderAnnotator.java | 9 +++++++++
.../java/opennlp/tools/document/POSTaggerAnnotator.java | 9 +++++++++
.../tools/document/SentenceDetectorAnnotator.java | 9 +++++++++
.../java/opennlp/tools/document/TokenizerAnnotator.java | 9 +++++++++
.../opennlp/tools/lemmatizer/LemmatizerAnnotator.java | 9 +++++++++
.../java/opennlp/tools/stemmer/StemmerAnnotator.java | 9 +++++++++
6 files changed, 54 insertions(+)
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
index 677e0dfa20..97e9d9526d 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/NameFinderAnnotator.java
@@ -133,4 +133,13 @@ public Set> requires() {
public Set> provides() {
return Set.of(Layers.ENTITIES);
}
+
+ /**
+ * {@return the adapter's simple class name, which names it in pipeline validation
+ * messages}
+ */
+ @Override
+ public String toString() {
+ return getClass().getSimpleName();
+ }
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
index b97aa98b4b..82facfba2b 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/POSTaggerAnnotator.java
@@ -101,4 +101,13 @@ public Set> requires() {
public Set> provides() {
return Set.of(Layers.POS_TAGS);
}
+
+ /**
+ * {@return the adapter's simple class name, which names it in pipeline validation
+ * messages}
+ */
+ @Override
+ public String toString() {
+ return getClass().getSimpleName();
+ }
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
index a035f0517e..183a6df13e 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/SentenceDetectorAnnotator.java
@@ -78,4 +78,13 @@ public Document annotate(Document document) {
public Set> provides() {
return Set.of(Layers.SENTENCES);
}
+
+ /**
+ * {@return the adapter's simple class name, which names it in pipeline validation
+ * messages}
+ */
+ @Override
+ public String toString() {
+ return getClass().getSimpleName();
+ }
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
index 6a81fcea9c..b7d6b9e288 100644
--- a/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
+++ b/opennlp-api/src/main/java/opennlp/tools/document/TokenizerAnnotator.java
@@ -100,4 +100,13 @@ private void addTokens(List> tokens, String text, int offset)
public Set> provides() {
return Set.of(Layers.TOKENS);
}
+
+ /**
+ * {@return the adapter's simple class name, which names it in pipeline validation
+ * messages}
+ */
+ @Override
+ public String toString() {
+ return getClass().getSimpleName();
+ }
}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
index 52b9e2288d..2c633798e5 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/lemmatizer/LemmatizerAnnotator.java
@@ -125,4 +125,13 @@ public Set> requires() {
public Set> provides() {
return Set.of(LEMMAS);
}
+
+ /**
+ * {@return the adapter's simple class name, which names it in pipeline validation
+ * messages}
+ */
+ @Override
+ public String toString() {
+ return getClass().getSimpleName();
+ }
}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
index b5da3c4fe7..900fe188a6 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/StemmerAnnotator.java
@@ -95,4 +95,13 @@ public Set> requires() {
public Set> provides() {
return Set.of(STEMS);
}
+
+ /**
+ * {@return the adapter's simple class name, which names it in pipeline validation
+ * messages}
+ */
+ @Override
+ public String toString() {
+ return getClass().getSimpleName();
+ }
}
From ffb99d009cab86f386b430c5caa60f4f98199d53 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Tue, 14 Jul 2026 21:09:58 -0400
Subject: [PATCH 33/92] depparse: Arc-standard dependency parser skeleton with
CoNLL-U reader and UAS/LAS evaluator
Adds opennlp.tools.depparse: the DependencyParser interface with DependencyArc,
DependencyGraph, and DependencySample in opennlp-api, and a greedy transition-based
implementation in the runtime: arc-standard state and static oracle, configuration
features, an oracle-driven event stream, DependencyParserME over the existing maxent
machinery, and a UAS/LAS evaluator. ConlluDependencySampleStream maps the basic
dependency columns of CoNLL-U sentences so Universal Dependencies treebanks train
directly; sentences whose multiword tokens were merged by ConlluStream are skipped
because their syntactic words are not recoverable.
Non-projective trees have no arc-standard derivation and are skipped during event
generation. Model persistence and a DL-backed implementation are follow-ups behind
the same interface.
(cherry picked from commit 7448e34080480cc52b266069f5c700557957085c)
---
.../opennlp/tools/depparse/DependencyArc.java | 63 +++++
.../tools/depparse/DependencyGraph.java | 179 ++++++++++++++
.../tools/depparse/DependencyParser.java | 47 ++++
.../tools/depparse/DependencySample.java | 113 +++++++++
.../tools/depparse/DependencyGraphTest.java | 140 +++++++++++
.../tools/depparse/DependencySampleTest.java | 78 +++++++
.../conllu/ConlluDependencySampleStream.java | 131 +++++++++++
.../ConlluDependencySampleStreamTest.java | 105 +++++++++
.../tools/depparse/ArcStandardOracle.java | 104 +++++++++
.../tools/depparse/ArcStandardState.java | 219 ++++++++++++++++++
.../depparse/DependencyContextGenerator.java | 115 +++++++++
.../tools/depparse/DependencyEvaluator.java | 88 +++++++
.../tools/depparse/DependencyEventStream.java | 107 +++++++++
.../tools/depparse/DependencyParserME.java | 140 +++++++++++
.../opennlp/tools/depparse/Transition.java | 127 ++++++++++
.../tools/depparse/ArcStandardOracleTest.java | 87 +++++++
.../depparse/DependencyParserMETest.java | 122 ++++++++++
17 files changed, 1965 insertions(+)
create mode 100644 opennlp-api/src/main/java/opennlp/tools/depparse/DependencyArc.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/depparse/DependencyGraph.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/depparse/DependencyParser.java
create mode 100644 opennlp-api/src/main/java/opennlp/tools/depparse/DependencySample.java
create mode 100644 opennlp-api/src/test/java/opennlp/tools/depparse/DependencyGraphTest.java
create mode 100644 opennlp-api/src/test/java/opennlp/tools/depparse/DependencySampleTest.java
create mode 100644 opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluDependencySampleStream.java
create mode 100644 opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamTest.java
create mode 100644 opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardOracle.java
create mode 100644 opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardState.java
create mode 100644 opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyContextGenerator.java
create mode 100644 opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEvaluator.java
create mode 100644 opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEventStream.java
create mode 100644 opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyParserME.java
create mode 100644 opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/Transition.java
create mode 100644 opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/ArcStandardOracleTest.java
create mode 100644 opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyParserMETest.java
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 0000000000..bf9dc2eea4
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyArc.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.depparse;
+
+/**
+ * One labeled edge of a {@link DependencyGraph}: the token at {@code dependent} is governed
+ * by the token at {@code head} under the given {@code relation}.
+ *
+ *
Indices are zero-based positions in the token array the graph 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.
+ *
+ * @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 || relation.isBlank()) {
+ throw new IllegalArgumentException("relation must not be null or blank");
+ }
+ }
+}
diff --git a/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyGraph.java b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyGraph.java
new file mode 100644
index 0000000000..e95234dcc8
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyGraph.java
@@ -0,0 +1,179 @@
+/*
+ * 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.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * An immutable dependency tree over one sentence: for every token, the index of its head
+ * and the label of the relation to that head.
+ *
+ *
Token indices are zero-based positions in the sentence the graph 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.
+ *
+ * @since 3.0.0
+ */
+public final class DependencyGraph {
+
+ private final int[] heads;
+ private final String[] relations;
+
+ private DependencyGraph(int[] heads, String[] relations) {
+ this.heads = heads;
+ this.relations = relations;
+ }
+
+ /**
+ * Creates a {@link DependencyGraph} 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 DependencyGraph}. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if any of the above constraints is violated.
+ */
+ public static DependencyGraph of(int[] heads, String[] relations) {
+ if (heads == null || relations == null) {
+ throw new IllegalArgumentException("heads and relations must not be null");
+ }
+ if (heads.length == 0) {
+ throw new IllegalArgumentException("a dependency graph 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;
+ for (int i = 0; i < heads.length; i++) {
+ if (heads[i] == DependencyArc.ROOT_HEAD) {
+ roots++;
+ } 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 || relations[i].isBlank()) {
+ throw new IllegalArgumentException("relation of token " + i + " must not be blank");
+ }
+ }
+ if (roots != 1) {
+ throw new IllegalArgumentException("expected exactly one root, found " + roots);
+ }
+ return new DependencyGraph(heads.clone(), relations.clone());
+ }
+
+ /**
+ * @return The number of tokens the graph 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() {
+ for (int i = 0; i < heads.length; i++) {
+ if (heads[i] == DependencyArc.ROOT_HEAD) {
+ return i;
+ }
+ }
+ throw new IllegalStateException("graph invariant violated: no root present");
+ }
+
+ /**
+ * @return All arcs of the graph in token order, one per token. Never {@code null}.
+ */
+ public List arcs() {
+ final List 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);
+ }
+
+ private void checkIndex(int index) {
+ if (index < 0 || index >= heads.length) {
+ throw new IllegalArgumentException("token index out of range: " + index
+ + ", size: " + heads.length);
+ }
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof DependencyGraph other)) {
+ return false;
+ }
+ return Arrays.equals(heads, other.heads) && Arrays.equals(relations, other.relations);
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * Arrays.hashCode(heads) + Arrays.hashCode(relations);
+ }
+
+ @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/depparse/DependencyParser.java b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyParser.java
new file mode 100644
index 0000000000..0c6e5d1303
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencyParser.java
@@ -0,0 +1,47 @@
+/*
+ * 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.
+ *
+ *
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 DependencyGraph}
+ * whose indices refer back to the input token array, so spans computed for those tokens
+ * remain valid for the parse.
+ *
+ * @see DependencyGraph
+ * @since 3.0.0
+ */
+public interface DependencyParser {
+
+ /**
+ * Parses a sentence into a {@link DependencyGraph}.
+ *
+ * @param tokens The tokens of one sentence. Must not be {@code null} and must contain
+ * at least one token.
+ * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+ * {@code null} and must have the same length as {@code tokens}.
+ * @return A {@link DependencyGraph} over the given tokens. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code tokens} or {@code tags} is
+ * {@code null}, empty, or of mismatched length.
+ */
+ DependencyGraph 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 0000000000..b805e79a51
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/depparse/DependencySample.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.depparse;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * One dependency-annotated sentence: tokens, their part-of-speech tags, and the gold
+ * {@link DependencyGraph} over them. Used for training and evaluating a
+ * {@link DependencyParser}.
+ *
+ *
Instances are immutable and safe to share between threads.
+ *
+ * @since 3.0.0
+ */
+public class DependencySample {
+
+ private final String[] tokens;
+ private final String[] tags;
+ private final DependencyGraph graph;
+
+ /**
+ * Initializes a {@link DependencySample}.
+ *
+ * @param tokens The tokens of the sentence. Must not be {@code null} or empty.
+ * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+ * {@code null} and must have the same length as {@code tokens}.
+ * @param graph The dependency graph over the tokens. Must not be {@code null} and its
+ * {@link DependencyGraph#size()} must equal the number of tokens.
+ * @throws IllegalArgumentException Thrown if any parameter is {@code null} or the
+ * lengths disagree.
+ */
+ public DependencySample(String[] tokens, String[] tags, DependencyGraph graph) {
+ if (tokens == null || tags == null || graph == null) {
+ throw new IllegalArgumentException("tokens, tags and graph must not be null");
+ }
+ if (tokens.length == 0) {
+ throw new IllegalArgumentException("a sample needs at least one token");
+ }
+ if (tokens.length != tags.length || tokens.length != graph.size()) {
+ throw new IllegalArgumentException("tokens, tags and graph must agree in length: "
+ + tokens.length + ", " + tags.length + ", " + graph.size());
+ }
+ this.tokens = tokens.clone();
+ this.tags = tags.clone();
+ this.graph = graph;
+ }
+
+ /**
+ * @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 graph over the tokens. Never {@code null}.
+ */
+ public DependencyGraph getGraph() {
+ return graph;
+ }
+
+ @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)
+ && graph.equals(other.graph);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(Arrays.hashCode(tokens), Arrays.hashCode(tags), graph);
+ }
+
+ @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(graph.headOf(i) + 1).append('\t').append(graph.relationOf(i))
+ .append(System.lineSeparator());
+ }
+ return sb.toString();
+ }
+}
diff --git a/opennlp-api/src/test/java/opennlp/tools/depparse/DependencyGraphTest.java b/opennlp-api/src/test/java/opennlp/tools/depparse/DependencyGraphTest.java
new file mode 100644
index 0000000000..91bf86bd19
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/depparse/DependencyGraphTest.java
@@ -0,0 +1,140 @@
+/*
+ * 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 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 DependencyGraph} and {@link DependencyArc}.
+ */
+public class DependencyGraphTest {
+
+ private static DependencyGraph sample() {
+ return DependencyGraph.of(new int[] {1, 2, -1},
+ new String[] {"det", "nsubj", "root"});
+ }
+
+ @Test
+ void testAccessors() {
+ final DependencyGraph graph = sample();
+ assertEquals(3, graph.size());
+ assertEquals(1, graph.headOf(0));
+ assertEquals(2, graph.headOf(1));
+ assertEquals(DependencyArc.ROOT_HEAD, graph.headOf(2));
+ assertEquals("nsubj", graph.relationOf(1));
+ assertEquals(2, graph.root());
+ }
+
+ @Test
+ void testArcsAreInTokenOrder() {
+ final List 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(), DependencyGraph.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 DependencyGraph graph = DependencyGraph.of(heads, relations);
+ heads[0] = 0;
+ relations[0] = "det";
+ assertEquals(1, graph.headOf(0));
+ assertEquals("nsubj", graph.relationOf(0));
+ }
+
+ @Test
+ void testNullArraysThrow() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(null, new String[] {"root"}));
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(new int[] {-1}, null));
+ }
+
+ @Test
+ void testEmptyGraphThrows() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(new int[0], new String[0]));
+ }
+
+ @Test
+ void testLengthMismatchThrows() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(new int[] {-1}, new String[] {"root", "nsubj"}));
+ }
+
+ @Test
+ void testRootCountIsEnforced() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(new int[] {1, 0}, new String[] {"a", "b"}));
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(new int[] {-1, -1}, new String[] {"root", "root"}));
+ }
+
+ @Test
+ void testSelfHeadThrows() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(new int[] {0, -1}, new String[] {"a", "root"}));
+ }
+
+ @Test
+ void testOutOfRangeHeadThrows() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(new int[] {2, -1}, new String[] {"a", "root"}));
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(new int[] {-3, -1}, new String[] {"a", "root"}));
+ }
+
+ @Test
+ void testBlankRelationThrows() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyGraph.of(new int[] {1, -1}, new String[] {" ", "root"}));
+ }
+
+ @Test
+ void testIndexBoundsThrow() {
+ final DependencyGraph graph = sample();
+ assertThrows(IllegalArgumentException.class, () -> graph.headOf(-1));
+ assertThrows(IllegalArgumentException.class, () -> graph.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, null));
+ }
+}
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 0000000000..03234eab9f
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/depparse/DependencySampleTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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"};
+
+ private static DependencyGraph graph() {
+ return DependencyGraph.of(new int[] {1, 2, -1},
+ new String[] {"det", "nsubj", "root"});
+ }
+
+ @Test
+ void testAccessors() {
+ final DependencySample sample = new DependencySample(TOKENS, TAGS, graph());
+ assertArrayEquals(TOKENS, sample.getTokens());
+ assertArrayEquals(TAGS, sample.getTags());
+ assertEquals(graph(), sample.getGraph());
+ }
+
+ @Test
+ void testEquals() {
+ assertEquals(new DependencySample(TOKENS, TAGS, graph()),
+ new DependencySample(TOKENS, TAGS, graph()));
+ }
+
+ @Test
+ void testNullArgumentsThrow() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new DependencySample(null, TAGS, graph()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new DependencySample(TOKENS, null, graph()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new DependencySample(TOKENS, TAGS, null));
+ }
+
+ @Test
+ void testLengthMismatchThrows() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new DependencySample(new String[] {"one"}, TAGS, graph()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new DependencySample(TOKENS, new String[] {"DT"}, graph()));
+ }
+
+ @Test
+ void testInputArraysAreCopied() {
+ final String[] tokens = TOKENS.clone();
+ final DependencySample sample = new DependencySample(tokens, TAGS, graph());
+ tokens[0] = "a";
+ assertEquals("the", sample.getTokens()[0]);
+ }
+}
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 0000000000..0dae3f7fc6
--- /dev/null
+++ b/opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/conllu/ConlluDependencySampleStream.java
@@ -0,0 +1,131 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.depparse.DependencyGraph;
+import opennlp.tools.depparse.DependencySample;
+import opennlp.tools.util.FilterObjectStream;
+import opennlp.tools.util.ObjectStream;
+
+/**
+ * Reads {@link DependencySample samples} from a stream of {@link ConlluSentence sentences},
+ * mapping the {@code HEAD} and {@code DEPREL} columns of the basic dependency annotation.
+ *
+ *
Empty nodes carry no basic dependency annotation and are dropped; the remaining word
+ * lines keep their one-based ids, which are shifted to the zero-based indices of
+ * {@link DependencyGraph}. Sentences containing a multiword token are skipped entirely:
+ * {@link ConlluStream} merges such a range with its syntactic words, so the dependency
+ * annotation of those words is no longer recoverable. Sentences whose annotation is
+ * incomplete or invalid, for example an underscore head, are skipped as well. Skips are
+ * counted and the count is logged once the stream is exhausted.
+ *
+ * @since 3.0.0
+ */
+public class ConlluDependencySampleStream
+ extends FilterObjectStream {
+
+ private static final Logger logger =
+ LoggerFactory.getLogger(ConlluDependencySampleStream.class);
+
+ private final ConlluTagset tagset;
+
+ private int skipped;
+
+ /**
+ * Initializes the stream.
+ *
+ * @param samples The sentences to convert. Must not be {@code null}.
+ * @param tagset The tagset whose part-of-speech column feeds the sample tags. Must not
+ * be {@code null}.
+ * @throws IllegalArgumentException Thrown if any parameter is {@code null}.
+ */
+ public ConlluDependencySampleStream(ObjectStream samples,
+ ConlluTagset tagset) {
+ super(samples);
+ if (tagset == null) {
+ throw new IllegalArgumentException("tagset must not be null");
+ }
+ this.tagset = tagset;
+ }
+
+ @Override
+ public DependencySample read() throws IOException {
+ ConlluSentence sentence;
+ while ((sentence = samples.read()) != null) {
+ final DependencySample sample = convert(sentence);
+ 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;
+ }
+
+ /**
+ * Converts one sentence, or returns {@code null} when its annotation is unusable.
+ */
+ private DependencySample convert(ConlluSentence sentence) {
+ final List words = new ArrayList<>();
+ for (final ConlluWordLine line : sentence.getWordLines()) {
+ final String id = line.getId();
+ if (id.indexOf('-') >= 0) {
+ // a merged multiword token: its syntactic words are gone, the sentence is unusable
+ return null;
+ }
+ if (id.indexOf('.') < 0) {
+ words.add(line);
+ }
+ }
+ if (words.isEmpty()) {
+ return null;
+ }
+ 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 ConlluWordLine word = words.get(i);
+ tokens[i] = word.getForm();
+ tags[i] = word.getPosTag(tagset);
+ relations[i] = word.getDeprel();
+ try {
+ heads[i] = Integer.parseInt(word.getHead()) - 1;
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ try {
+ return new DependencySample(tokens, tags, DependencyGraph.of(heads, relations));
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+}
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 0000000000..6105efd00e
--- /dev/null
+++ b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencySampleStreamTest.java
@@ -0,0 +1,105 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import opennlp.tools.depparse.DependencyArc;
+import opennlp.tools.depparse.DependencySample;
+
+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 {@link ConlluDependencySampleStream} maps the basic dependency columns and
+ * skips sentences without a usable annotation, including sentences whose multiword tokens
+ * were merged by {@link ConlluStream}.
+ */
+public class ConlluDependencySampleStreamTest {
+
+ private static String line(String... fields) {
+ return String.join("\t", fields);
+ }
+
+ 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";
+
+ private static ConlluDependencySampleStream stream() throws IOException {
+ return new ConlluDependencySampleStream(new ConlluStream(
+ () -> new ByteArrayInputStream(CONLLU.getBytes(StandardCharsets.UTF_8))),
+ ConlluTagset.U);
+ }
+
+ @Test
+ void testReadsSamplesAndSkipsUnusableSentences() 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.getGraph().headOf(0));
+ assertEquals(DependencyArc.ROOT_HEAD, first.getGraph().headOf(1));
+ assertEquals(3, first.getGraph().headOf(2));
+ assertEquals(1, first.getGraph().headOf(3));
+ assertEquals("obj", first.getGraph().relationOf(3));
+
+ // the underscore-head sentence and the merged-contraction sentence are both skipped
+ final DependencySample second = samples.read();
+ assertNotNull(second);
+ assertArrayEquals(new String[] {"Dogs", "bark"}, second.getTokens());
+ assertEquals(1, second.getGraph().headOf(0));
+
+ assertNull(samples.read());
+ }
+ }
+
+ @Test
+ void testNullTagsetThrows() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new ConlluDependencySampleStream(new ConlluStream(
+ () -> new ByteArrayInputStream(CONLLU.getBytes(StandardCharsets.UTF_8))), null));
+ }
+}
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 0000000000..bd2d05f0ec
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardOracle.java
@@ -0,0 +1,104 @@
+/*
+ * 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;
+
+
+/**
+ * The static oracle for the arc-standard system: derives the transition sequence that
+ * reproduces a gold {@link DependencyGraph}.
+ *
+ *
An arc is only created once all dependents of the token being attached have been
+ * collected, which is the arc-standard correctness condition. The oracle is defined for
+ * projective trees only; a non-projective gold graph has no arc-standard derivation and
+ * is rejected.
+ *
+ * @since 3.0.0
+ */
+public final class ArcStandardOracle {
+
+ private ArcStandardOracle() {
+ // static oracle, not meant to be instantiated
+ }
+
+ /**
+ * Derives the gold transition sequence for a graph.
+ *
+ * @param gold The gold dependency graph. 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.
+ */
+ public static List transitions(DependencyGraph gold) {
+ if (gold == null) {
+ throw new IllegalArgumentException("gold must not be null");
+ }
+ 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 transitions = new ArrayList<>(2 * n);
+ while (!state.isTerminal()) {
+ final Transition next = nextTransition(gold, goldDependents, state);
+ if (next == null) {
+ throw new IllegalArgumentException(
+ "gold graph has no arc-standard derivation (non-projective): " + gold);
+ }
+ state.apply(next);
+ transitions.add(next);
+ }
+ return transitions;
+ }
+
+ /**
+ * Picks the gold transition for the current configuration, or {@code null} when the
+ * configuration is stuck, which only happens for non-projective input.
+ */
+ private static Transition nextTransition(DependencyGraph 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;
+ }
+}
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 0000000000..a261d95f3e
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/ArcStandardState.java
@@ -0,0 +1,219 @@
+/*
+ * 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 mutable configuration of an arc-standard parse: a stack, a buffer of remaining
+ * tokens, and the arcs assigned so far.
+ *
+ *
The stack bottom holds the artificial root, exposed as {@link #ROOT}. Positions that
+ * do not exist, such as the second stack element in the initial configuration, are exposed
+ * as {@link #NONE}. A right arc from the artificial root is only applicable once the buffer
+ * is empty and the root is the only other stack element, which guarantees every completed
+ * parse has exactly one sentence root.
+ *
+ *
Instances are confined to a single parse and must not be shared between threads.
+ *
+ * @since 3.0.0
+ */
+public final class ArcStandardState {
+
+ /** The stack value representing the artificial root node. */
+ public static final int ROOT = -1;
+
+ /** The value returned for stack or buffer positions that do not exist. */
+ public 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 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.
+ */
+ public 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];
+ }
+
+ /**
+ * @return {@code true} if the buffer is empty and only the artificial root remains on
+ * the stack, so the parse is complete.
+ */
+ public 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}.
+ */
+ public boolean canApply(Transition transition) {
+ if (transition == null) {
+ throw new IllegalArgumentException("transition must not be null");
+ }
+ 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.
+ */
+ public 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--;
+ }
+ default -> throw new IllegalArgumentException("unsupported type: " + transition.type());
+ }
+ }
+
+ private void attach(int head, int dependent, String relation) {
+ heads[dependent] = head;
+ relations[dependent] = relation;
+ if (head >= 0) {
+ assignedDependents[head]++;
+ }
+ }
+
+ /**
+ * 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.
+ */
+ public 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.
+ */
+ public int buffer(int fromFront) {
+ if (fromFront < 0) {
+ throw new IllegalArgumentException("fromFront must not be negative: " + fromFront);
+ }
+ final int position = bufferFront + fromFront;
+ return position >= tokenCount ? NONE : position;
+ }
+
+ /**
+ * @return The number of stack elements including the artificial root.
+ */
+ public int stackSize() {
+ return top + 1;
+ }
+
+ /**
+ * @return The number of tokens still in the buffer.
+ */
+ public 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.
+ */
+ public int assignedDependents(int index) {
+ if (index < 0 || index >= tokenCount) {
+ throw new IllegalArgumentException("token index out of range: " + index);
+ }
+ return assignedDependents[index];
+ }
+
+ /**
+ * Builds the {@link DependencyGraph} of a completed parse.
+ *
+ * @return The parsed graph. Never {@code null}.
+ * @throws IllegalStateException Thrown if the parse is not yet terminal.
+ */
+ public DependencyGraph toGraph() {
+ if (!isTerminal()) {
+ throw new IllegalStateException("parse is not terminal: " + this);
+ }
+ return DependencyGraph.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/DependencyContextGenerator.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyContextGenerator.java
new file mode 100644
index 0000000000..90010cbf50
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyContextGenerator.java
@@ -0,0 +1,115 @@
+/*
+ * 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;
+
+/**
+ * Generates the classification features for one arc-standard configuration: words and tags
+ * of the topmost stack and frontmost buffer positions, their pairings, and a bucketed
+ * distance between stack top and buffer front.
+ *
+ *
Instances hold no state and are safe to share between threads.
+ *
+ * @since 3.0.0
+ */
+public class DependencyContextGenerator {
+
+ private static final String ROOT_VALUE = "*ROOT*";
+ private static final String NONE_VALUE = "*NULL*";
+
+ /**
+ * Generates the features of the current configuration.
+ *
+ * @param state The configuration to describe. Must not be {@code null}.
+ * @param tokens The sentence tokens. Must not be {@code null}.
+ * @param tags The part-of-speech tags aligned with {@code tokens}. Must not be
+ * {@code null}.
+ * @return The feature strings. Never {@code null}.
+ * @throws IllegalArgumentException Thrown if any parameter is {@code null}.
+ */
+ public String[] getContext(ArcStandardState state, String[] tokens, String[] tags) {
+ if (state == null || tokens == null || tags == null) {
+ throw new IllegalArgumentException("state, tokens and tags must not be null");
+ }
+ 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 = word(tokens, s0);
+ final String s0t = tag(tags, s0);
+ final String s1w = word(tokens, s1);
+ final String s1t = tag(tags, s1);
+ final String s2t = tag(tags, s2);
+ final String b0w = word(tokens, b0);
+ final String b0t = tag(tags, b0);
+ final String b1t = tag(tags, b1);
+ final String b2t = tag(tags, b2);
+
+ final List features = new ArrayList<>(20);
+ features.add("s0w=" + s0w);
+ features.add("s0t=" + s0t);
+ features.add("s1w=" + s1w);
+ features.add("s1t=" + s1t);
+ features.add("s2t=" + s2t);
+ features.add("b0w=" + b0w);
+ features.add("b0t=" + b0t);
+ features.add("b1t=" + b1t);
+ features.add("b2t=" + b2t);
+ features.add("s0wt=" + s0w + '/' + s0t);
+ features.add("s0w,b0w=" + s0w + '|' + b0w);
+ features.add("s0t,b0t=" + s0t + '|' + b0t);
+ features.add("s1t,s0t=" + s1t + '|' + s0t);
+ features.add("s1t,s0t,b0t=" + s1t + '|' + s0t + '|' + b0t);
+ features.add("s0t,b0t,b1t=" + s0t + '|' + b0t + '|' + b1t);
+ features.add("s0deps=" + dependents(state, s0));
+ features.add("s1deps=" + dependents(state, s1));
+ features.add("dist=" + distance(s0, b0));
+ return features.toArray(new String[0]);
+ }
+
+ private static String word(String[] tokens, int index) {
+ if (index == ArcStandardState.ROOT) {
+ return ROOT_VALUE;
+ }
+ return index == ArcStandardState.NONE ? NONE_VALUE : tokens[index];
+ }
+
+ private static String tag(String[] tags, int index) {
+ if (index == ArcStandardState.ROOT) {
+ return ROOT_VALUE;
+ }
+ return index == ArcStandardState.NONE ? NONE_VALUE : tags[index];
+ }
+
+ private static String dependents(ArcStandardState state, int index) {
+ return index < 0 ? NONE_VALUE : Integer.toString(Math.min(state.assignedDependents(index), 3));
+ }
+
+ private static String distance(int s0, int b0) {
+ if (s0 < 0 || b0 < 0) {
+ return NONE_VALUE;
+ }
+ final int distance = b0 - s0;
+ return distance >= 4 ? "4+" : Integer.toString(distance);
+ }
+}
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 0000000000..f29bada722
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEvaluator.java
@@ -0,0 +1,88 @@
+/*
+ * 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.Evaluator;
+import opennlp.tools.util.eval.Mean;
+
+/**
+ * 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).
+ *
+ * @since 3.0.0
+ */
+public class DependencyEvaluator extends Evaluator {
+
+ private final DependencyParser parser;
+ private final Mean uas = new Mean();
+ private final Mean las = new Mean();
+
+ /**
+ * Initializes a {@link DependencyEvaluator}.
+ *
+ * @param parser The parser to evaluate. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code parser} is {@code null}.
+ */
+ public DependencyEvaluator(DependencyParser parser) {
+ if (parser == null) {
+ throw new IllegalArgumentException("parser must not be null");
+ }
+ this.parser = parser;
+ }
+
+ /**
+ * Parses the sample's sentence and scores the prediction against the gold graph.
+ *
+ * @param reference The gold sample. Must not be {@code null}.
+ * @return A {@link DependencySample} carrying the predicted graph. Never {@code null}.
+ */
+ @Override
+ protected DependencySample processSample(DependencySample reference) {
+ final DependencyGraph gold = reference.getGraph();
+ final DependencyGraph predicted = parser.parse(reference.getTokens(), reference.getTags());
+ for (int i = 0; i < gold.size(); i++) {
+ final boolean headMatches = gold.headOf(i) == predicted.headOf(i);
+ uas.add(headMatches ? 1 : 0);
+ las.add(headMatches && gold.relationOf(i).equals(predicted.relationOf(i)) ? 1 : 0);
+ }
+ return new DependencySample(reference.getTokens(), reference.getTags(), predicted);
+ }
+
+ /**
+ * @return The unlabeled attachment score over all evaluated tokens.
+ */
+ public double getUas() {
+ return uas.mean();
+ }
+
+ /**
+ * @return The labeled attachment score over all evaluated tokens.
+ */
+ public double getLas() {
+ return las.mean();
+ }
+
+ /**
+ * @return The number of tokens scored so far.
+ */
+ public long getWordCount() {
+ return uas.count();
+ }
+}
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 0000000000..87fdf27300
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyEventStream.java
@@ -0,0 +1,107 @@
+/*
+ * 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;
+
+/**
+ * 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.
+ *
+ *
Samples whose graph has no arc-standard derivation, that is non-projective trees,
+ * are skipped and counted; the count is logged once the stream is exhausted.
+ */
+class DependencyEventStream implements ObjectStream {
+
+ private static final Logger logger = LoggerFactory.getLogger(DependencyEventStream.class);
+
+ private final ObjectStream samples;
+ private final DependencyContextGenerator contextGenerator;
+ private final Deque 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 samples,
+ DependencyContextGenerator contextGenerator) {
+ if (samples == null || contextGenerator == null) {
+ throw new IllegalArgumentException("samples and contextGenerator must not be null");
+ }
+ this.samples = samples;
+ this.contextGenerator = contextGenerator;
+ }
+
+ @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;
+ }
+ final List transitions;
+ try {
+ transitions = ArcStandardOracle.transitions(sample.getGraph());
+ } catch (IllegalArgumentException e) {
+ skipped++;
+ continue;
+ }
+ final ArcStandardState state = new ArcStandardState(sample.getGraph().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();
+ }
+
+ @Override
+ public void reset() throws IOException, UnsupportedOperationException {
+ samples.reset();
+ pending.clear();
+ skipped = 0;
+ }
+
+ @Override
+ public void close() throws IOException {
+ samples.close();
+ }
+}
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 0000000000..76d8814910
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyParserME.java
@@ -0,0 +1,140 @@
+/*
+ * 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 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.TrainingParameters;
+
+/**
+ * A greedy transition-based {@link DependencyParser}: a maximum entropy 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.
+ *
+ *
The parser holds an immutable model and no per-parse state, so one instance can be
+ * shared between threads.
+ *
+ * @see DependencyParser
+ * @since 3.0.0
+ */
+public class DependencyParserME implements DependencyParser {
+
+ private final MaxentModel model;
+ private final DependencyContextGenerator contextGenerator;
+
+ /**
+ * Initializes a {@link DependencyParserME} with a trained transition model.
+ *
+ * @param model The transition classification model. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code model} is {@code null}.
+ */
+ public DependencyParserME(MaxentModel model) {
+ if (model == null) {
+ throw new IllegalArgumentException("model must not be null");
+ }
+ this.model = model;
+ this.contextGenerator = new DependencyContextGenerator();
+ }
+
+ @Override
+ public DependencyGraph parse(String[] tokens, String[] tags) {
+ if (tokens == null || tags == null) {
+ throw new IllegalArgumentException("tokens and tags must not be null");
+ }
+ if (tokens.length == 0) {
+ throw new IllegalArgumentException("tokens must not be empty");
+ }
+ if (tokens.length != tags.length) {
+ throw new IllegalArgumentException("tokens and tags must have the same length: "
+ + tokens.length + " != " + tags.length);
+ }
+ final ArcStandardState state = new ArcStandardState(tokens.length);
+ while (!state.isTerminal()) {
+ state.apply(bestApplicable(state, tokens, tags));
+ }
+ return state.toGraph();
+ }
+
+ /**
+ * Scores all outcomes for the current configuration and picks the best transition that
+ * is applicable; inapplicable outcomes are passed over regardless of score.
+ */
+ private Transition bestApplicable(ArcStandardState state, String[] tokens, String[] tags) {
+ final double[] probabilities = model.eval(contextGenerator.getContext(state, tokens, tags));
+ Transition best = null;
+ double bestProbability = Double.NEGATIVE_INFINITY;
+ for (int i = 0; i < probabilities.length; i++) {
+ if (probabilities[i] <= bestProbability) {
+ continue;
+ }
+ final Transition candidate;
+ try {
+ candidate = Transition.decode(model.getOutcome(i));
+ } catch (IllegalArgumentException e) {
+ throw new IllegalStateException(
+ "model outcome is not a transition: " + model.getOutcome(i), e);
+ }
+ if (state.canApply(candidate)) {
+ best = candidate;
+ 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 from dependency samples.
+ *
+ *
Non-projective samples have no arc-standard derivation and are skipped during
+ * event generation.
+ *
+ * @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 DependencyParserME}. 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 DependencyParserME train(ObjectStream samples,
+ TrainingParameters parameters) throws IOException {
+ if (samples == null || parameters == null) {
+ throw new IllegalArgumentException("samples and parameters must not be null");
+ }
+ final TrainerType trainerType = TrainerFactory.getTrainerType(parameters);
+ if (!TrainerType.EVENT_MODEL_TRAINER.equals(trainerType)) {
+ throw new IllegalArgumentException("Trainer type is not supported: " + trainerType);
+ }
+ final EventTrainer trainer =
+ TrainerFactory.getEventTrainer(parameters, new HashMap<>());
+ final ObjectStream events =
+ new DependencyEventStream(samples, new DependencyContextGenerator());
+ return new DependencyParserME(trainer.train(events));
+ }
+}
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 0000000000..0f9977e675
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/Transition.java
@@ -0,0 +1,127 @@
+/*
+ * 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;
+
+/**
+ * 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.
+ *
+ *
A transition doubles as a classification outcome: {@link #encode()} renders it as the
+ * outcome string a model is trained on, and {@link #decode(String)} restores it.
+ *
+ * @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
+ */
+public record Transition(Type type, String label) {
+
+ /**
+ * The kinds of arc-standard action.
+ */
+ public 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. */
+ public static final Transition SHIFT = new Transition(Type.SHIFT, null);
+
+ 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.
+ */
+ public Transition {
+ if (type == null) {
+ throw new IllegalArgumentException("type must not be null");
+ }
+ if (type == Type.SHIFT) {
+ if (label != null) {
+ throw new IllegalArgumentException("a shift must not carry a label: " + label);
+ }
+ } else if (label == null || label.isBlank()) {
+ 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}.
+ */
+ public 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}.
+ */
+ public 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}.
+ */
+ public 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.
+ */
+ public static Transition decode(String outcome) {
+ if (outcome == null) {
+ throw new IllegalArgumentException("outcome must not be null");
+ }
+ 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 0000000000..6cfa9f57ca
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/ArcStandardOracleTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+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 graph through
+ * {@link ArcStandardState}, and that non-projective input is rejected.
+ */
+public class ArcStandardOracleTest {
+
+ private static DependencyGraph replay(DependencyGraph gold) {
+ final List 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.toGraph();
+ }
+
+ @Test
+ void testRoundTripSimpleSentence() {
+ final DependencyGraph gold = DependencyGraph.of(new int[] {1, 2, -1},
+ new String[] {"det", "nsubj", "root"});
+ assertEquals(gold, replay(gold));
+ }
+
+ @Test
+ void testRoundTripSingleToken() {
+ final DependencyGraph gold = DependencyGraph.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 DependencyGraph gold = DependencyGraph.of(new int[] {-1, 2, 0, 0},
+ new String[] {"root", "amod", "obj", "advmod"});
+ assertEquals(gold, replay(gold));
+ }
+
+ @Test
+ void testRoundTripDeepChain() {
+ final DependencyGraph gold = DependencyGraph.of(new int[] {1, 2, 3, -1},
+ new String[] {"a", "b", "c", "root"});
+ assertEquals(gold, replay(gold));
+ }
+
+ @Test
+ void testNonProjectiveThrows() {
+ // arcs (2,0) and (3,1) cross, so there is no arc-standard derivation
+ final DependencyGraph nonProjective = DependencyGraph.of(new int[] {2, 3, -1, 2},
+ new String[] {"a", "b", "root", "c"});
+ assertThrows(IllegalArgumentException.class,
+ () -> ArcStandardOracle.transitions(nonProjective));
+ }
+
+ @Test
+ void testNullGraphThrows() {
+ assertThrows(IllegalArgumentException.class, () -> ArcStandardOracle.transitions(null));
+ }
+}
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 0000000000..def76900fe
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyParserMETest.java
@@ -0,0 +1,122 @@
+/*
+ * 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 org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.TrainingParameters;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests {@link DependencyParserME} end to end: training on a tiny 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 {
+
+ private static DependencyParserME parser;
+
+ private static DependencySample sample(String[] tokens, String[] tags, int[] heads,
+ String[] relations) {
+ return new DependencySample(tokens, tags, DependencyGraph.of(heads, relations));
+ }
+
+ private static List corpus() {
+ final List distinct = List.of(
+ sample(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", "VBZ"},
+ new int[] {1, 2, -1}, new String[] {"det", "nsubj", "root"}),
+ sample(new String[] {"dogs", "bark"}, new String[] {"NNS", "VBP"},
+ new int[] {1, -1}, new String[] {"nsubj", "root"}),
+ sample(new String[] {"she", "eats", "fish"}, new String[] {"PRP", "VBZ", "NN"},
+ new int[] {1, -1, 1}, new String[] {"nsubj", "root", "obj"}));
+ final List corpus = new ArrayList<>();
+ for (int i = 0; i < 40; i++) {
+ corpus.addAll(distinct);
+ }
+ return corpus;
+ }
+
+ @BeforeAll
+ static void trainParser() throws IOException {
+ final TrainingParameters parameters = TrainingParameters.defaultParams();
+ parameters.put(Parameters.CUTOFF_PARAM, 0);
+ parser = DependencyParserME.train(ObjectStreamUtils.createObjectStream(corpus()),
+ parameters);
+ }
+
+ @Test
+ void testMemorizesTrainingSentences() {
+ final DependencyGraph parsed = parser.parse(new String[] {"the", "dog", "barks"},
+ new String[] {"DT", "NN", "VBZ"});
+ assertEquals(DependencyGraph.of(new int[] {1, 2, -1},
+ new String[] {"det", "nsubj", "root"}), parsed);
+ }
+
+ @Test
+ void testParseAlwaysYieldsASingleRootedTree() {
+ // an unseen sentence must still decode to a valid graph, whatever its quality
+ final DependencyGraph parsed = parser.parse(new String[] {"cats", "sleep"},
+ new String[] {"NNS", "VBP"});
+ assertEquals(2, parsed.size());
+ 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(320, 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"}));
+ }
+
+ @Test
+ void testConstructorRejectsNullModel() {
+ assertThrows(IllegalArgumentException.class, () -> new DependencyParserME(null));
+ }
+
+ @Test
+ void testTrainValidatesArguments() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyParserME.train(null, TrainingParameters.defaultParams()));
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyParserME.train(
+ ObjectStreamUtils.createObjectStream(corpus()), null));
+ }
+}
From c828b8666cd6019a8b0ec441d5b61a7ac72c8987 Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Wed, 15 Jul 2026 05:23:30 -0400
Subject: [PATCH 34/92] depparse: DependencyModel persistence and a gated
Universal Dependencies evaluation
Adds DependencyModel on the standard BaseModel machinery, so trained parsers
serialize and load like every other tool model; the declared serialVersionUID is the
serialver-computed value. Training now returns the model with the trainer's manifest
entries, and DependencyParserME gains a model constructor beside the raw one. The
round-trip test serializes, reloads, and re-parses.
ConlluDependencyParserEvalTest trains on a Universal Dependencies treebank and scores
UAS and LAS on its test split; it runs only when opennlp.depparse.ud.dir names the
downloaded splits, keeps treebank data out of the repository, and asserts only a low
regression floor, with the logged scores as the measurement.
(cherry picked from commit c5a3b946b6d8a06a01ff07b961a74ab26a671f3f)
---
.../ConlluDependencyParserEvalTest.java | 84 +++++++++++++
.../tools/depparse/DependencyModel.java | 110 ++++++++++++++++++
.../tools/depparse/DependencyParserME.java | 38 ++++--
.../depparse/DependencyParserMETest.java | 37 +++++-
4 files changed, 255 insertions(+), 14 deletions(-)
create mode 100644 opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencyParserEvalTest.java
create mode 100644 opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyModel.java
diff --git a/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencyParserEvalTest.java b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencyParserEvalTest.java
new file mode 100644
index 0000000000..558547cac8
--- /dev/null
+++ b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/conllu/ConlluDependencyParserEvalTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.depparse.DependencyEvaluator;
+import opennlp.tools.depparse.DependencyModel;
+import opennlp.tools.depparse.DependencyParserME;
+import opennlp.tools.util.InputStreamFactory;
+import opennlp.tools.util.MarkableFileInputStreamFactory;
+import opennlp.tools.util.Parameters;
+import opennlp.tools.util.TrainingParameters;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Trains the greedy arc-standard parser on a Universal Dependencies treebank and scores
+ * it on the treebank's test split, reporting UAS and LAS.
+ *
+ *
Runs only when {@code opennlp.depparse.ud.dir} names a directory containing
+ * {@code train.conllu} and {@code test.conllu} (a UD treebank's splits, renamed or
+ * linked). The data is downloaded by the runner and never enters the repository; check
+ * the treebank's own license before training models for distribution. The assertion is
+ * a low regression floor; the logged scores are the measurement.
+ */
+public class ConlluDependencyParserEvalTest {
+
+ private static final Logger logger =
+ LoggerFactory.getLogger(ConlluDependencyParserEvalTest.class);
+
+ @Test
+ @EnabledIfSystemProperty(named = "opennlp.depparse.ud.dir", matches = ".+")
+ void testTrainAndScoreOnUniversalDependencies() throws IOException {
+ final Path dir = Path.of(System.getProperty("opennlp.depparse.ud.dir"));
+
+ final TrainingParameters parameters = TrainingParameters.defaultParams();
+ parameters.put(Parameters.CUTOFF_PARAM, 5);
+ final long trainStart = System.currentTimeMillis();
+ final DependencyModel model;
+ try (ConlluDependencySampleStream train = samples(dir.resolve("train.conllu"))) {
+ model = DependencyParserME.train("eng", train, parameters);
+ }
+ logger.info("trained in {} ms", System.currentTimeMillis() - trainStart);
+
+ final DependencyEvaluator evaluator =
+ new DependencyEvaluator(new DependencyParserME(model));
+ try (ConlluDependencySampleStream test = samples(dir.resolve("test.conllu"))) {
+ evaluator.evaluate(test);
+ }
+ logger.info("UAS {} LAS {} over {} tokens",
+ evaluator.getUas(), evaluator.getLas(), evaluator.getWordCount());
+
+ // a regression floor, far below any plausible result; the log line is the measurement
+ assertTrue(evaluator.getUas() > 0.6d, "UAS regressed below the floor");
+ assertTrue(evaluator.getLas() > 0.5d, "LAS regressed below the floor");
+ }
+
+ private static ConlluDependencySampleStream samples(Path conllu) throws IOException {
+ final InputStreamFactory in = new MarkableFileInputStreamFactory(conllu.toFile());
+ return new ConlluDependencySampleStream(new ConlluStream(in), ConlluTagset.U);
+ }
+}
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 0000000000..8f2c608eeb
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/depparse/DependencyModel.java
@@ -0,0 +1,110 @@
+/*
+ * 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.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.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 {
+
+ private static final long serialVersionUID = -2928968185269611443L;
+
+ private static final String COMPONENT_NAME = "DependencyParserME";
+ 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 parserModel} is {@code null}.
+ */
+ public DependencyModel(String languageCode, MaxentModel parserModel,
+ Map manifestInfoEntries) {
+ super(COMPONENT_NAME, languageCode, manifestInfoEntries);
+ if (parserModel == null) {
+ throw new IllegalArgumentException("parserModel must not be null");
+ }
+ 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.
+ */
+ public DependencyModel(InputStream in) throws IOException {
+ super(COMPONENT_NAME, 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.
+ */
+ public DependencyModel(File modelFile) throws IOException {
+ super(COMPONENT_NAME, 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.
+ */
+ public DependencyModel(Path modelPath) throws IOException {
+ super(COMPONENT_NAME, modelPath);
+ }
+
+ @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
index 76d8814910..bbd2d219ff 100644
--- 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
@@ -19,6 +19,7 @@
import java.io.IOException;
import java.util.HashMap;
+import java.util.Map;
import opennlp.tools.ml.EventTrainer;
import opennlp.tools.ml.TrainerFactory;
@@ -45,7 +46,21 @@ public class DependencyParserME implements DependencyParser {
private final DependencyContextGenerator contextGenerator;
/**
- * Initializes a {@link DependencyParserME} with a trained transition model.
+ * 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}.
+ */
+ public DependencyParserME(DependencyModel model) {
+ if (model == null) {
+ throw new IllegalArgumentException("model must not be null");
+ }
+ this.model = model.getParserModel();
+ this.contextGenerator = new DependencyContextGenerator();
+ }
+
+ /**
+ * Initializes a {@link DependencyParserME} with a raw transition model.
*
* @param model The transition classification model. Must not be {@code null}.
* @throws IllegalArgumentException Thrown if {@code model} is {@code null}.
@@ -109,32 +124,37 @@ private Transition bestApplicable(ArcStandardState state, String[] tokens, Strin
}
/**
- * Trains a greedy arc-standard parser from dependency samples.
+ * Trains a greedy arc-standard parser model from dependency samples.
*
*
Non-projective samples have no arc-standard derivation and are skipped during
* event generation.
*
+ * @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 DependencyParserME}. Never {@code null}.
+ * @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 DependencyParserME train(ObjectStream samples,
- TrainingParameters parameters) throws IOException {
- if (samples == null || parameters == null) {
- throw new IllegalArgumentException("samples and parameters must not be null");
+ public static DependencyModel train(String languageCode,
+ ObjectStream samples, TrainingParameters parameters)
+ throws IOException {
+ if (languageCode == null || samples == null || parameters == null) {
+ throw new IllegalArgumentException(
+ "languageCode, samples and parameters must not be null");
}
final TrainerType trainerType = TrainerFactory.getTrainerType(parameters);
if (!TrainerType.EVENT_MODEL_TRAINER.equals(trainerType)) {
throw new IllegalArgumentException("Trainer type is not supported: " + trainerType);
}
+ final Map manifestInfoEntries = new HashMap<>();
final EventTrainer trainer =
- TrainerFactory.getEventTrainer(parameters, new HashMap<>());
+ TrainerFactory.getEventTrainer(parameters, manifestInfoEntries);
final ObjectStream events =
new DependencyEventStream(samples, new DependencyContextGenerator());
- return new DependencyParserME(trainer.train(events));
+ return new DependencyModel(languageCode, trainer.train(events), manifestInfoEntries);
}
}
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
index def76900fe..62550ad239 100644
--- 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
@@ -38,6 +38,7 @@
*/
public class DependencyParserMETest {
+ private static DependencyModel model;
private static DependencyParserME parser;
private static DependencySample sample(String[] tokens, String[] tags, int[] heads,
@@ -64,8 +65,9 @@ private static List corpus() {
static void trainParser() throws IOException {
final TrainingParameters parameters = TrainingParameters.defaultParams();
parameters.put(Parameters.CUTOFF_PARAM, 0);
- parser = DependencyParserME.train(ObjectStreamUtils.createObjectStream(corpus()),
- parameters);
+ model = DependencyParserME.train("eng",
+ ObjectStreamUtils.createObjectStream(corpus()), parameters);
+ parser = new DependencyParserME(model);
}
@Test
@@ -108,15 +110,40 @@ void testParseValidatesArguments() {
@Test
void testConstructorRejectsNullModel() {
- assertThrows(IllegalArgumentException.class, () -> new DependencyParserME(null));
+ assertThrows(IllegalArgumentException.class,
+ () -> new DependencyParserME((DependencyModel) null));
+ assertThrows(IllegalArgumentException.class,
+ () -> new DependencyParserME((opennlp.tools.ml.model.MaxentModel) null));
}
@Test
void testTrainValidatesArguments() {
assertThrows(IllegalArgumentException.class,
- () -> DependencyParserME.train(null, TrainingParameters.defaultParams()));
+ () -> DependencyParserME.train("eng", null, TrainingParameters.defaultParams()));
assertThrows(IllegalArgumentException.class,
- () -> DependencyParserME.train(
+ () -> DependencyParserME.train("eng",
ObjectStreamUtils.createObjectStream(corpus()), null));
+ assertThrows(IllegalArgumentException.class,
+ () -> DependencyParserME.train(null,
+ ObjectStreamUtils.createObjectStream(corpus()),
+ TrainingParameters.defaultParams()));
+ }
+
+ @Test
+ void testModelRoundTripThroughSerialization() throws IOException {
+ final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ model.serialize(out);
+ final DependencyModel reloaded = new DependencyModel(
+ new java.io.ByteArrayInputStream(out.toByteArray()));
+ final DependencyGraph parsed = new DependencyParserME(reloaded)
+ .parse(new String[] {"the", "dog", "barks"}, new String[] {"DT", "NN", "VBZ"});
+ assertEquals(DependencyGraph.of(new int[] {1, 2, -1},
+ new String[] {"det", "nsubj", "root"}), parsed);
+ }
+
+ @Test
+ void testModelRejectsNullParserModel() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new DependencyModel("eng", null, null));
}
}
From f7182f63d2ed6daf9bf0df235c82d03437f8050c Mon Sep 17 00:00:00 2001
From: Kristian Rickert
Date: Wed, 15 Jul 2026 05:39:22 -0400
Subject: [PATCH 35/92] depparse: Read CoNLL-U raw so multiword-token sentences
keep their dependencies
Rewrites ConlluDependencySampleStream to parse CoNLL-U content directly instead of
consuming the merged ConlluSentence view: ConlluStream merges multiword token ranges
with their syntactic words, which suits the token and lemma views but destroys the
dependency annotation of every contraction-bearing sentence. The raw reader drops
range lines and empty nodes while keeping the syntactic words, so those sentences
train and evaluate normally; on the English EWT treebank this recovers 2192 training
and 302 test sentences that were previously skipped, and evaluation now covers the
full test set.
(cherry picked from commit f22b0acb107e52b68b87e56a2d24863df159dd5b)
---
.../conllu/ConlluDependencySampleStream.java | 130 ++++++++++++------
.../ConlluDependencyParserEvalTest.java | 2 +-
.../ConlluDependencySampleStreamTest.java | 63 +++++++--
3 files changed, 140 insertions(+), 55 deletions(-)
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
index 0dae3f7fc6..22f9de9018 100644
--- 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
@@ -17,7 +17,10 @@
package opennlp.tools.formats.conllu;
+import java.io.BufferedReader;
import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
@@ -26,55 +29,68 @@
import opennlp.tools.depparse.DependencyGraph;
import opennlp.tools.depparse.DependencySample;
-import opennlp.tools.util.FilterObjectStream;
+import opennlp.tools.util.InputStreamFactory;
import opennlp.tools.util.ObjectStream;
/**
- * Reads {@link DependencySample samples} from a stream of {@link ConlluSentence sentences},
- * mapping the {@code HEAD} and {@code DEPREL} columns of the basic dependency annotation.
+ * Reads {@link DependencySample samples} directly from CoNLL-U content, mapping the
+ * {@code HEAD} and {@code DEPREL} columns of the basic dependency annotation.
*
- *
Empty nodes carry no basic dependency annotation and are dropped; the remaining word
- * lines keep their one-based ids, which are shifted to the zero-based indices of
- * {@link DependencyGraph}. Sentences containing a multiword token are skipped entirely:
- * {@link ConlluStream} merges such a range with its syntactic words, so the dependency
- * annotation of those words is no longer recoverable. Sentences whose annotation is
- * incomplete or invalid, for example an underscore head, are skipped as well. Skips are
- * counted and the count is logged once the stream is exhausted.
+ *
The file is parsed raw, deliberately not through {@link ConlluStream}: that stream
+ * merges multiword token ranges with their syntactic words, which suits the token and
+ * lemma views but destroys the dependency annotation of every sentence containing a
+ * contraction. Here range lines and empty nodes are dropped while their syntactic words
+ * are kept, so contraction-bearing sentences train and evaluate normally. Sentences
+ * whose annotation is incomplete or invalid, for example an underscore head, are
+ * skipped and counted; the count is logged once the stream is exhausted.
*
* @since 3.0.0
*/
-public class ConlluDependencySampleStream
- extends FilterObjectStream {
+public class ConlluDependencySampleStream implements ObjectStream {
private static final Logger logger =
LoggerFactory.getLogger(ConlluDependencySampleStream.class);
- private final ConlluTagset tagset;
+ private static final int COLUMNS = 10;
+ private static final int FORM = 1;
+ private static final int UPOS = 3;
+ private static final int XPOS = 4;
+ private static final int HEAD = 6;
+ private static final int DEPREL = 7;
+ private final InputStreamFactory in;
+ private final int tagColumn;
+
+ private BufferedReader reader;
private int skipped;
/**
* Initializes the stream.
*
- * @param samples The sentences to convert. Must not be {@code null}.
- * @param tagset The tagset whose part-of-speech column feeds the sample tags. Must not
- * be {@code null}.
+ * @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(ObjectStream samples,
- ConlluTagset tagset) {
- super(samples);
+ public ConlluDependencySampleStream(InputStreamFactory in, ConlluTagset tagset)
+ throws IOException {
+ if (in == null) {
+ throw new IllegalArgumentException("in must not be null");
+ }
if (tagset == null) {
throw new IllegalArgumentException("tagset must not be null");
}
- this.tagset = tagset;
+ this.in = in;
+ this.tagColumn = tagset == ConlluTagset.U ? UPOS : XPOS;
+ this.reader = open();
}
@Override
public DependencySample read() throws IOException {
- ConlluSentence sentence;
- while ((sentence = samples.read()) != null) {
- final DependencySample sample = convert(sentence);
+ List words;
+ while (!(words = nextSentence()).isEmpty()) {
+ final DependencySample sample = convert(words);
if (sample != null) {
return sample;
}
@@ -89,35 +105,50 @@ public DependencySample read() throws IOException {
}
/**
- * Converts one sentence, or returns {@code null} when its annotation is unusable.
+ * 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.
*/
- private DependencySample convert(ConlluSentence sentence) {
- final List words = new ArrayList<>();
- for (final ConlluWordLine line : sentence.getWordLines()) {
- final String id = line.getId();
- if (id.indexOf('-') >= 0) {
- // a merged multiword token: its syntactic words are gone, the sentence is unusable
- return null;
+ private List nextSentence() throws IOException {
+ final List