Skip to content

OPENNLP-1919: Expose dependency parses as a typed Document annotation layer - #1237

Draft
krickert wants to merge 97 commits into
OPENNLP-547-dependency-parserfrom
OPENNLP-1919-dependency-annotations
Draft

OPENNLP-1919: Expose dependency parses as a typed Document annotation layer#1237
krickert wants to merge 97 commits into
OPENNLP-547-dependency-parserfrom
OPENNLP-1919-dependency-annotations

Conversation

@krickert

Copy link
Copy Markdown
Contributor

Summary

  • add a DependencyAnnotator over the typed Document API
  • parse each sentence independently and anchor dependency arcs to document token indexes
  • validate graph alignment and emit a typed opennlp:dependencies layer
  • add pipeline, edge-case, contract, and documentation tests

Stack

This draft depends on OPENNLP-547 and OPENNLP-1888. It must remain draft until both dependencies land.

krickert added 30 commits August 5, 2026 22:10
…yers 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.
…rom missing layers, validate providers at build time
… 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.
… 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.
…nt 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.
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.
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.
…on 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.
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.
…le test

Add {@inheritdoc} to the Document, LayerKey, and adapter overrides, and note in
the manual that DocumentPipelineExampleTest asserts the pipeline round-trip.
…ainer 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
…ll 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
…, 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
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.
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.
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.
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.
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.
- 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.
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.
…ayer

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.
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.
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.
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.
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.
… models

The scorer re-derived every feature's hidden-layer contribution from its
embedding on every configuration, although for a frozen model the contribution
of a (template position, embedding row) pair is a fixed vector. Parsers now
turn on a bounded lazy cache that computes each pair's vector once on first
sight and adds it thereafter, the adaptive form of the precomputation described
for this architecture by Chen and Manning (2014): tag and label rows are fully
cached within a document or two and word rows follow their frequency. Measured
on realistic dimensions (20k words, embedding 50, hidden 400, 77 transitions):
5,462 to 71,954 scored states per second, 13.2x. Training and refinement work
on uncached copies, copies never carry a cache, concurrent readers are safe by
idempotent fill, and a test pins cached-versus-direct agreement to float
rounding with identical winning transitions.
Replaces the two private blank helpers with the shared predicate; behavior is
identical since both already followed the toolkit whitespace definition.
…ed example

Add docbkx/dependency.xml, wire it into the manual, and cite ConlluDependencyParserUsageTest.
…e constants

- DependencyParserME decodes the model outcome inventory once in the constructor and
  keeps it as a Transition[]. Decoding a sentence now indexes that array instead of
  parsing an outcome string per configuration and per outcome, and a model trained for
  another task is rejected with IllegalArgumentException when the parser is built
  rather than surfacing as an IllegalStateException in the middle of a sentence. Both
  constructors document the new failure, and a pinning test builds a parser over a
  MaxentModel whose outcomes are POS tags to prove the rejection happens up front.
- Removed the private isBlank copy from ConlluDependencySampleStream and routed
  sentence separation through StringUtil.isBlank, so the toolkit whitespace definition
  lives in one place. The rationale the copy carried moved into the nextSentence
  javadoc, which also gained its missing @return and @throws. DependencyArc validates
  its relation through the same predicate, so an arc label made only of a no-break
  space is rejected exactly as a CoNLL-U separator line is.
- StringUtil.isBlank rejects a null argument with IllegalArgumentException instead of
  letting a NullPointerException escape the loop, and documents it. Its test became a
  parameterized case list plus an explicit null case.
- Extracted the magic values of DependencyContextGenerator into named constants: the
  word/tag and position separators, the feature count the list is sized to, the valency
  and distance bounds, and the long-distance feature value. The distance feature is
  computed once rather than twice.
- Named the *ROOT* vocabulary key ROOT_SYMBOL and the "*" special-symbol prefix
  SPECIAL_SYMBOL_PREFIX on FeedforwardDependencyModel and used them from
  FeedforwardContext and FeedforwardDependencyTrainer, which spelled all three as
  literals. FeedforwardContext names the first dependent position instead of indexing
  the template at a bare 6.
- Folded the three repeated special-symbol loops in the trainer vocabulary builder into
  addSpecialSymbols, and hoisted the repeated model.transitions() call out of the
  transition-decoding loop.
- FeedforwardDependencyModel.score and featureIds validate their array argument, and
  lookup fails loudly when a vocabulary carries no *UNK* row to fall back on instead of
  returning null and unboxing to a NullPointerException later.
- Documented the package-private accessors of FeedforwardDependencyModel, saying which
  of them hand out the live arrays the trainer writes into.
- Trimmed commentary to what the code does: enableScoringCache drops the literature
  reference and the restatement of how caching pays off, and
  DependencyEvaluator.processSample uses {@inheritdoc} plus only what the override adds
  over the Evaluator contract.
- Replaced fully qualified java.util.Arrays, java.util.function.Function,
  java.io.ByteArrayInputStream, java.io.ByteArrayOutputStream and MaxentModel uses with
  imports in the trainer, the arc-standard state and the tests, and dropped stray blank
  lines in DependencyGraph and Transition.
- Moved the sample() and corpus() helpers, copied verbatim in three test classes, into a
  shared DependencyTestSamples fixture with the repetition count as a named constant.
- Added pinning tests: a relation of U+00A0 alone is rejected while a label such as
  nmod:poss is kept, an empty DependencySample is rejected, and the arc and graph
  relation accessors return what was passed in.
Add reference links for CoNLL-U, the arc-standard system (Nivre 2004),
the feedforward architecture and training recipe (Chen and Manning
2014), early update (Collins and Roark 2004), and the Unicode
SpecialCasing file. State that thread safety is implementation specific
on the DependencyParser interface. Add javadoc to the remaining private
helpers in main and test code. Fold the duplicated corpus reading in
the feedforward trainer into a readAll helper, and read the
corrupt-model test fixture as UTF-8. The DependencyModel
serialVersionUID was verified to equal the serialver default.
Wires the dependency parser into the document pipeline: reads the token and tag
layers, parses, and provides a dependencies layer with one DependencyArc per token
anchored on the dependent's span. Arc head and dependent are indices into the token
layer, exercising the container rule that annotations reference each other by layer
and index; the test resolves an arc's head through the token layer back to its span
in the original text.

(cherry picked from commit 902fbb3)
… boundaries

The annotator now rejects a parser that returns a graph over a different token
count than its sentence, instead of silently misaligning the dependency layer
with the token layer, and the javadoc states the text-order requirement the
walk has always relied on. New tests pin the empty-sentence index shift, a
token straddling two sentence spans, the stuck-scan path behind a gap token,
and the graph-size rejection. The staged copy of the document container was
refreshed to the current foundation, whose adapters parse per sentence and
whose empty-versus-missing layer distinction moves the empty-text failure into
this annotator's own validation.
…ample

Add a DependencyAnnotator section to the dependency chapter citing DependencyAnnotatorPipelineTest.
Open the CDATA on its own line so the rendered code block has no leading
blank line, matching the two listings above it and parser.xml.
…t cleanup

- Check each required layer for presence on its own, so a document missing the
  sentence, token, or tag layer is rejected with a message that names the key that
  is absent instead of being folded into the alignment complaint.
- Accept present-but-empty required layers: a document with no sentences and no
  tokens now yields a present-but-empty dependencies layer rather than an
  IllegalArgumentException, which is the empty-versus-absent distinction the rest
  of the container annotators already follow.
- Extract the shared rejection prefix into a MISSING_LAYER constant so all three
  absence checks emit one message shape.
- Restate the annotate() javadoc and its @throws list against the checks that are
  actually performed, and say explicitly that the required layers may be empty.
- Trim commentary that only repeated the javadoc: the class-level narration about
  being the first graph-shaped layer, the unwrap-the-slice comment, and the
  document-coordinate tail of the index-shift comment.
- Replace the three hand-rolled absent-layer assertions with a parameterized test
  over one document per required layer, asserting the message names that layer.
- Add pinning tests for the null-document message and for the empty document
  producing a present-but-empty arc layer, and flip the pipeline test on empty
  text to assert the same pass-through instead of a failure.
- Assert the exact message in testMissingLayersThrow rather than only the
  exception type.
- Hoist the one-token parser stub to a ONE_TOKEN_ROOT constant shared by the two
  tests that had declared it inline, and extract the repeated STRAY_TOKEN and
  MISALIGNED expected messages into constants.
- Drop the two docbook sentences that pointed readers at test class names, state
  the present-but-empty layer contract in the annotator section, and correct
  "indexes" to "indices" in the example comment.
krickert added a commit that referenced this pull request Aug 26, 2026
krickert added a commit that referenced this pull request Aug 26, 2026
…he gRPC helper base

# Conflicts:
#	opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/depparse/DependencyAnnotatorPipelineTest.java
krickert added a commit that referenced this pull request Aug 30, 2026
Adapts a Chunker to the document pipeline: reads sentences, tokens, and POS
tags and provides opennlp:chunks, one annotation per phrase chunk carrying its
type on the span of its tokens.
Adapts a constituency Parser to the document pipeline: reads sentences and
tokens and provides opennlp:phrases, one annotation per phrase node above the
part-of-speech level in pre-order, carrying the label and the span of the head
token the parser's head rules select.
Also makes the ParserAnnotator helpers instance methods, matching the other
adapters.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant