Skip to content
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
49b9612
OPENNLP-1888: Document annotation container: typed offset-anchored la…
krickert Jul 15, 2026
3e382b3
OPENNLP-1888: Lemma and stem layer adapters
krickert Jul 15, 2026
e8a2ca4
OPENNLP-1888: Pipeline example and contract tests for the document co…
krickert Jul 16, 2026
e9b53a7
OPENNLP-1888: Parse per sentence in the adapters, distinguish empty f…
krickert Jul 17, 2026
6b384b6
OPENNLP-1888: Lemmatize per sentence, validate adapter outputs, clear…
krickert Jul 17, 2026
99e0b83
OPENNLP-1888: Add StringUtil.isBlank following the toolkit whitespace…
krickert Jul 17, 2026
eb277fb
OPENNLP-1888: Manual chapter, review-convention pass, and key placeme…
krickert Jul 17, 2026
1ba2fcd
OPENNLP-1888: Namespace the standard layer key ids
krickert Jul 17, 2026
f8b2ee9
OPENNLP-1888: Declare per-key positional or document scope
krickert Jul 17, 2026
b39dd25
OPENNLP-1888: State the index-reference invariants in the specificati…
krickert Jul 17, 2026
e4d6ed7
OPENNLP-1888: Document the gold-layer convention
krickert Jul 17, 2026
266e780
OPENNLP-1888: Create toolkit layer keys through namespace-applying fa…
krickert Jul 17, 2026
d43c1ec
OPENNLP-1888: Javadoc container overrides and cite the pipeline examp…
krickert Jul 20, 2026
0453217
OPENNLP-1888: Anchor the Document thread-safety note to immutability
krickert Jul 20, 2026
15102d7
OPENNLP-1888: Address review: shared annotator helpers, hardened cont…
krickert Jul 28, 2026
e906225
OPENNLP-1888: Address review: fold duplicate test fixtures and pin nu…
krickert Jul 28, 2026
6896361
OPENNLP-1888: Name the null argument, avoid per-call key set wrapping…
krickert Aug 8, 2026
6084309
OPENNLP-1888: Rework the document chapter after docs review
krickert Aug 17, 2026
9442b91
OPENNLP-1888: Pin the Document.merge contract
krickert Aug 17, 2026
919cf59
OPENNLP-1888: Implement Document.merge as a default method
krickert Aug 17, 2026
aa5aeba
OPENNLP-1888: Document the parallel fan-out join in the manual
krickert Aug 17, 2026
adb15b4
OPENNLP-1888: Pin the duplicate-layer policy contract for merge
krickert Aug 17, 2026
1297f72
OPENNLP-1888: Add a duplicate-layer policy to Document.merge
krickert Aug 17, 2026
3245eff
OPENNLP-1888: Render the layer figure monospaced and tighten the prose
krickert Aug 17, 2026
4f5c0ca
OPENNLP-1888: Pin a differing-contents message for KEEP_EQUAL rejection
krickert Aug 17, 2026
844128f
OPENNLP-1888: Name the differing contents when KEEP_EQUAL rejects a l…
krickert Aug 17, 2026
75fcac0
OPENNLP-1888: Pin what layer equality compares for KEEP_EQUAL
krickert Aug 17, 2026
562af87
OPENNLP-1888: Pin merge semantics across Document implementations
krickert Aug 17, 2026
09cd47d
OPENNLP-1888: Merge with a single map copy in ImmutableDocument
krickert Aug 17, 2026
0705c5d
OPENNLP-1888: State span offsets in UTF-16 units in the manual
krickert Aug 18, 2026
550bc44
OPENNLP-1888: Pin that adapters name themselves by class name
krickert Aug 18, 2026
f4dd343
OPENNLP-1888: Name adapters by their simple class name
krickert Aug 18, 2026
e380352
OPENNLP-1888: Move concrete annotators to runtime
krickert Aug 26, 2026
22780da
OPENNLP-1888: Place annotators with their components
krickert Aug 26, 2026
65c215b
OPENNLP-1888: Add a ChunkerAnnotator providing a chunks layer
krickert Aug 30, 2026
1b24dad
OPENNLP-1888: Add a ParserAnnotator providing a phrases layer
krickert Aug 30, 2026
e37f9c1
OPENNLP-1888: List the chunker and parser adapters in the manual
krickert Aug 30, 2026
ea33ac8
OPENNLP-1888: Document the purpose, layer contents, and lineage of th…
krickert Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions opennlp-api/src/main/java/opennlp/tools/document/Annotation.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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, or a span-less value under a
* {@link LayerKey.Scope#DOCUMENT document-scoped} key.
*
* <p>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. 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.</p>
*
* @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 <T> The type of the annotation value.
*
* @since 3.0.0
*/
public record Annotation<T>(Span span, T value) {

/**
* Validates the annotation.
*
* @throws IllegalArgumentException Thrown if {@code value} is {@code null}.
*/
public Annotation {
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 <T> 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 <T> Annotation<T> of(T value) {
return new Annotation<>(null, value);
}
}
196 changes: 196 additions & 0 deletions opennlp-api/src/main/java/opennlp/tools/document/Document.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* 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 offset-anchored annotation container: the original text of one document plus any
* number of typed annotation layers over it.
*
* <p>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. A
* {@link LayerKey.Scope#DOCUMENT document-scoped} layer carries whole-document values
* without spans, for example a language id.</p>
*
* <p>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.</p>
*
* <p>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.</p>
*
* @since 3.0.0
*/
public interface Document {
Comment thread
krickert marked this conversation as resolved.

/**
* 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}.
* @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 <T> 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}.
*/
<T> List<Annotation<T>> get(LayerKey<T> layer);

/**
* @return The keys of all layers present on the document. Never {@code null}; the set
* is unmodifiable.
*/
Set<LayerKey<?>> 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}, 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 <T> 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.
*/
<T> Document with(LayerKey<T> layer, List<Annotation<T>> 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}.
* Equality is {@link Annotation} equality: spans compare by offsets and type,
* never by probability, and values by their own {@code equals}.
*/
KEEP_EQUAL
}

/**
* Returns a new document combining this document's layers with another document's
* 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
* 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) {
return merge(other, DuplicateLayerPolicy.REJECT);
}

/**
* 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) {
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)) {
if (layersEqual(merged, layer, other)) {
continue;
}
throw new IllegalArgumentException(
"layer is present on both documents with differing contents: " + layer);
}
merged = addLayer(merged, layer, other);
}
return merged;
}

/**
* @return Whether the two documents carry structurally equal contents for the layer.
*/
private static <T> boolean layersEqual(Document first, LayerKey<T> layer, Document second) {
return first.get(layer).equals(second.get(layer));
}

/**
* Adds one layer of {@code from} to {@code base} through {@link #with(LayerKey, List)},
* capturing the key's value type.
*/
private static <T> Document addLayer(Document base, LayerKey<T> layer, Document from) {
return base.with(layer, from.get(layer));
}
}
125 changes: 125 additions & 0 deletions opennlp-api/src/main/java/opennlp/tools/document/DocumentAnalyzer.java
Original file line number Diff line number Diff line change
@@ -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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* Runs a fixed sequence of {@link DocumentAnnotator annotators} over a text, producing
* one {@link Document} that carries every step's layers.
*
* <p>The pipeline is validated at build time: every annotator's required layers must be
* provided by an earlier annotator, and no two annotators may provide the same layer, so
* a misordered or conflicting 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.</p>
*
* @since 3.0.0
*/
public final class DocumentAnalyzer {

private final List<DocumentAnnotator> annotators;

private DocumentAnalyzer(List<DocumentAnnotator> 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<DocumentAnnotator> 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, 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 Map<LayerKey<?>, 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 (!providers.containsKey(required)) {
throw new IllegalArgumentException("annotator " + annotator
+ " requires layer " + required + ", which no earlier 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));
}
}
}
Loading
Loading