Entity mentions are first-class inter-Stage Records; the zero-shot label set lives in config; the deterministic extractor ships in core, the learned one is a separate package¶
S4 adds Entity Extraction (CONTEXT): the Stage that finds typed entity mentions in chunked text (zero-shot NER). Four design questions had non-obvious answers, recorded here.
An EntityMention is a first-class inter-Stage Record, not a field on the chunk¶
A mention could be modelled as a list nested inside each ChunkRecord. It is not.
EntityMention is its own versioned core contract (SCHEMA_VERSION → 4), a
subclass of Record — so it carries Provenance and Classification like every other
inter-Stage carrier, is threaded through the DAG as a Stage output, is checkpointed
and resumed by the Runner uniformly, and is exported to the corpus directly.
Why. The downstream corpus-level Stages consume mentions, not chunks:
Relation Extraction (S5) relates mentions; Disambiguation (S8) links mentions
to canonical entities and attaches Evidence at mention offsets; Graph Assembly
builds nodes from mentions. Making the mention the record (with its own
Provenance span) means every downstream Stage receives the exact contract it needs
with offsets already resolved to the source document — no re-derivation from a
chunk-nested list, no second offset code path. It also keeps the Runner's
one-output-list-per-Stage invariant intact (the DAG threads EntityMention the
same way it threads ChunkRecord), and lets Export materialize mentions with zero
special-casing. Rejected: mentions-inside-chunks (couples every consumer to the
chunk shape, hides the mention's own Provenance, and forces each downstream Stage
to re-resolve offsets).
A mention's offsets index the ORIGINAL document, resolved through the chunk's offset map¶
Correction (2026-07-07, superseded by ADR-0031). As first shipped, this section claimed the shift
doc_offset = chunk.provenance.char_start + local_offsetyields the mention's original-document offset. That is false for a chunk that contains markup. Chunkcontentis markup-stripped (chunking.strip_markupdeletes 18 constructs), solocal_offsetis measured over shorter text; because stripping only ever deletes characters, the shift is a lower bound on the true offset, exact only when no markup was stripped before the mention within its chunk. The S4 fixtures passed because they were all markup-free plain text. ADR-0031 corrects the root cause: Chunk now carries a stripped-content→original offset map (OffsetMap) on eachChunkRecord, and the extractor resolves a mention's local span through it (OffsetIndex) to the EXACT original offset. The description below is retained for history; readchar_start + local_offsetas "the lower-bound shift the offset map replaced". The page-resolution reasoning is unchanged — it simply now runs on the corrected offset.C1 amendment (2026-07-27, schema v19 — a chunk describes its OWN pages). The decision below originally read: "Chunk now carries the parent document's
page_mapforward on eachChunkRecord(optional field; absent ⇒ the mention degrades to the inherited chunk range)." That mechanism was superseded twice, and the degradation contract it prescribed is a bug.First (schema v18, 2026-07-26).
PageMapis DOCUMENT-scoped state — contiguous spans covering the whole assembled markdown, invariant across every chunk of its document — so carrying it by value on all N chunks was pure duplication: on a 519-page document chunked at 640 tokens, ~30 KB of JSON on each of ~399 records, ~92% of every chunk checkpoint line, paid again on every checkpoint write, streaming read-back and decode. v18 kept that payload win by stamping the map on a document's first chunk only and having a document-scopedPageIndexResolveradopt it from the stream. That shape is gone too, and it is instructive why: it made page resolution depend on two things that are not properties of the chunk being resolved — that the map-carrying record was OBSERVED first (every batching extractor resolves out of stream order), and that it SURVIVED every filtering Stage (any chunk→chunk Capability may drop chunk 0). Both failures degraded silently to the chunk's inherited page range. Three patches (rehome_page_maps, an LRU retention window, then that window's justification) hardened the consequences, never the shape.Now (schema v19). No page map rides any chunk. The Chunk Stage stamps a
PageSliceon every chunk (stages/chunk.py:page_slice=PageSlice.for_span(doc.page_map, ch.char_start, ch.char_end)) — exactly the document page spans overlapping that chunk's own[char_start, char_end), in the document's original coordinates. One span for a chunk inside a page, two for a chunk straddling a break. That is the whole of what a chunk needs, because the only page question asked of it is sub-chunk: which page does an offset INSIDE it fall on. Order- and drop-independence are now properties of the record, so the v18 failure mode is not mitigated but inexpressible, and ~99.8% of the payload win is kept (~2 spans, ~60 bytes, against a ~30 KB whole-document map).latence_core.page_index.PageIndexResolveris correspondingly stateless: noobserve, no per-document memoisation, no retention window;rehome_page_mapsandPageIndexResolver.from_documentswere deleted.What this changes for a Provider author. Do not read the page field off the record at all. Create one
PageIndexResolverper Capability call (for the corpus-level drift roll-up, not for correctness) and stamp provenance fromresolver.pages_for_span(chunk, doc_start, doc_end), which returns(page_start, page_end)and raisesPageSliceMissingErrorif the producing Chunker stamped no spans — there is noNoneto degrade on, because "degrade" meant emitting a page that looks right and is not (ADR-0034). For a relation's covering span, whose endpoints may sit in DIFFERENT chunks, usepages_for_covering_span(head.provenance, tail.provenance)instead; no single chunk can answer for it. The "absent ⇒ inherit the chunk range" fallback this section used to prescribe hands a mention the parent chunk's whole page range — a plausible-looking WRONG page, and exactly the ADR-0021/0031 violation audit R1 found in four shipped ecosystem Providers that had followed this ADR verbatim. Seedocs/writing-an-adapter.mdand the schema-v19 CHANGELOG entry. The page-resolution reasoning below is unchanged: a mention still resolves its OWN page from its own document offset, never inherits the chunk's.
An extractor sees a chunk's text and returns chunk-local offsets. But a mention
must resolve back to its source page via PageOffsetIndex, which indexes the
parent document's assembled markdown (ADR-0020/0021). The Provider maps each
chunk-local match back to the parent document through the chunk's offset map
(ADR-0031; originally, and incorrectly for markup, a bare shift by the chunk's own
provenance.char_start): doc_offset = resolve(local_offset). The mention's page is
then re-resolved from that document offset through PageOffsetIndex — the same
resolver the chunk Provider uses (ADR-0021) — not inherited wholesale from the
chunk's page_start/page_end. A default chunk budget (512 tokens) routinely spans
several short source pages, so a mention on a later page of a multi-page chunk must
cite its own page; inheriting the chunk's first page would violate the S4 core
requirement "Provenance resolving to the correct source page". To make this
resolvable at the mention Stage, Chunk stamps each ChunkRecord with its own page_slice
— the document page spans overlapping that chunk (schema v19), with the mention Stage
resolving through the stateless PageIndexResolver (C1 amendment above). The whole
document page_map no longer rides the chunk stream: it did on each ChunkRecord as first
shipped, and on the document's first chunk only under v18, and both shapes were replaced.
A boundary-straddling mention resolves page_start from its
start offset and page_end from its last included character, so it records both
pages. This completes the S4 round-trip (mention → offset → original page). The
EntityMention contract enforces the span: a mention with no char span or no page
span fails validation, so a Provider that forgets the shift cannot silently emit
un-resolvable mentions. This is the same subtle invariant ADR-0021 called out for
chunks — offsets index the original, never the local text — carried one Stage
further, and now the page is resolved at the same granularity as the offset.
The zero-shot label set is Pipeline config; the Capability names no types¶
Entity Extraction is zero-shot: the entity types are a per-corpus choice, not a
framework constant. The EntityExtractor Capability names no labels, core hardcodes
none, and the label set is supplied entirely through the Stage config. The same
Provider extracts person/organization for one Pipeline and gene/drug for
another with no code change — the S4 acceptance criterion ("label set is
configurable per Pipeline; no label set hardcoded in core"). This is also what lets
the GLiNER Provider (a genuinely zero-shot model) and the gazetteer Provider share
one seam: both take their vocabulary from config.
The deterministic extractor ships in core; the learned GLiNER Provider is its own package¶
Two reference Providers, split on the ADR-0007/0016 line:
-
entity.gazetteerships inlatence-core(like the S3 Chunker/Screeners): a pure-Python, dependency-free, deterministic zero-shot gazetteer/regex extractor. It is what makes the end-to-end demo run on firstgit clonewith no model download, and what makes the S4 tests hash-stable for the Baseline bar. It is not a weaker reinvention of a learned NER model — it is the CPU-zero-dep reference, and it is genuinely zero-shot (labels + recognisers from config). -
entity.glineris the separate packagelatence-ner-gliner: a learned zero-shot Provider wrapping a GLiNER checkpoint via plaintransformers/ONNX (CPU path, no vLLM). Its heavy stack (gliner→ torch/transformers) is isolated in its own package sopip install latence-corestays lean (ADR-0016) and the laptop- first promise holds (ADR-0007). Swapping to it is a one-line provider/config change, no pipeline change. Licensing verified permissive both ways (ADR-0012, 2026-07-06): theglinerlibrary is Apache-2.0 (code) and the default checkpointurchade/gliner_multi-v2.1is Apache-2.0 (weights) — v2.1 relicensed from the earlier CC-BY-NC-4.0, so the commercial-safe checkpoint is the default and a CC-BY-NC one is a deliberate opt-in, never shipped as the default.
Because the learned stack must stay out of the deterministic/offline dev+test env,
latence-ner-gliner is a workspace member but is not pinned into the dev
meta-project's dependencies; CI installs only its source (--no-deps) so its src
is strict-type-checked and its Provider tests (which monkeypatch the gliner
dependency with a fake to exercise the offset math offline) collect and run without
torch. Rejected: hardcoding GLiNER as THE reference (forces torch into first-run,
contradicts ADR-0007) and shipping only a rule stub (undercuts the NER quality
story — the learned Provider must exist behind the same seam, per ADR-0013's
empirical-choice principle, here applied to Entity Extraction).