3 · From corpus to knowledge graph¶
Chapter 2 left the pipeline holding a pile of local observations: one EntityMention per span
a model found in one chunk, one RelationMention per pair of those spans, each carrying a label
that chunk induced for itself, a confidence, and character offsets that resolve back to a page.
Nothing in that pile knows that the ORG called "Acme Corp." in document 14 and the org called
"ACME CORP" in document 900 are the same company — or that they are the same kind of thing.
This chapter is where that gets decided, and where the decisions become a graph you can query and
audit.
Four stages do it — type consolidation (ADR-0054), disambiguation, graph assembly with hyperedges (ADR-0057) and graph features (ADR-0051) — and in the five campaign pipelines they are configured exactly like this:
- name: consolidate_types
capability: type_consolidation
provider: type_consolidation.exact_surface
depends_on: [extract]
- name: disambiguate
capability: disambiguation
provider: disambiguation.exact_surface
depends_on: [consolidate_types]
- name: graph
capability: graph_assembly
provider: graph.canonical
depends_on: [disambiguate]
config:
corpus_id: s3-multihop_rag
hyperedges:
weak_hyperedges: true
max_arity: 12
min_tokens: 25
max_tokens: 180
max_window_sentences: 3
graph_features:
provider: graph_features.degree
Four stages, one model between them: none. Every provider named above is pure Python, deterministic and dependency-free. The learned options exist — and this chapter covers them — but the measured stack ran without them, which is why a campaign run reproduces byte for byte.
Extract narrow, resolve wide¶
The single design commitment behind this chapter is that consistency is earned downstream, never imposed upstream.
The alternative — the one most KG pipelines pick — is to freeze one schema before extraction: union
the corpus's candidate types, cap the union to something a model can score against, and hand that
fixed label set to every chunk. It buys consistency at the extractor, and the price is paid three
times over, all of it recorded in packages/latence-core/src/latence_core/stacks/phase_boundary.py:
- Recall. The cap is where recall goes to die. A type a corpus mentions in exactly one chunk ranks below the salience cut and is dropped — from the one chunk that needed it, invisibly.
- Precision. A chunk scored against a ~40-type corpus union competes against ~35 types that are not in it.
- Memory. GLiNER2's span-scoring tensor is
batch × tokens × labels. Whenlabelswas corpus-wide, that was the CUDA OOM.
So extraction stays local and narrow: each chunk gets its own small label set (chapter 2), and it gets to be inconsistent. Then the pipeline crosses a line, and everything after it sees the whole corpus at once and earns the consistency: one canonical type vocabulary, one canonical entity per real-world thing, one graph.
The line has teeth, and it was once permeable¶
The line is a machine-checked invariant: no chunk-level stage may transitively depend on a corpus-level one. Since redaction is chunk-level, that is exactly "no corpus-level operation runs before redaction" — but expressed over the DAG rather than over one topological schedule, so a clean result holds for every valid execution order, not just the one the runner happened to pick.
That invariant is a privacy control, not a tidiness rule. A corpus-level stage in the front half collapses the whole corpus into one shared artifact and stamps it back onto every chunk. Which means: a fact from an unredacted document can reach a chunk it was never part of, before redaction had a chance to run. Whatever the artifact is — a type vocabulary, an entity table, a co-occurrence matrix — it is a covert channel across document boundaries.
The instructive part is that the invariant failed silently, and the module docstring of
packages/latence-core/src/latence_core/capability_descriptor.py records exactly how:
TYPE_CONSOLIDATION(T3, ADR-0054) was added to the Runner's accumulator tuple and to the DeltaRunner's corpus-level tuple but to neither set instacks/phase_boundary.is_corpus_leveltherefore answered False for a Stage that collapses the whole run's mentions into one canonical type vocabulary, andcheck_phase_boundary— the invariant whose entire job is "no corpus-level operation runs before redaction" — waved a hand-written config that putconsolidate_typesin the front half straight through.
Read that carefully. Nothing crashed. No test went red. The guard ran, and passed, on a
pipeline that violated the exact property it exists to enforce — because the new capability was
registered as corpus-level in two places out of three, and the third place was the one the guard
read. And the same module notes the twist that makes it worse: phase_boundary.CHUNK_LEVEL_KINDS,
the set that should have caught the omission from the other side, "was worse than stale: it was
referenced by no code at all, documentation posing as a guard."
The fix is the transferable lesson, and it is not "be more careful". A capability's level is a fact about the capability, so it is declared once, as a field, next to its siblings:
CapabilityDescriptor(
kind=CapabilityKind.TYPE_CONSOLIDATION,
protocol=TypeConsolidator,
carrier=Record,
# Corpus-level BY CONSTRUCTION: a canonical type vocabulary cannot be elected from a prefix
# of the labels, only from all of them. THIS is the fact that was missing from
# phase_boundary — declaring it here is what closes the hole for every consumer at once.
level=CapabilityLevel.CORPUS,
packages/latence-core/src/latence_core/capability_descriptor.py:522
The derived sets every other module used to hand-maintain — CORPUS_LEVEL_KINDS,
DOCUMENT_LEVEL_KINDS, ACCUMULATOR_KINDS — are now computed from the descriptors, and
DOCUMENT_LEVEL_KINDS is the exact complement of CORPUS_LEVEL_KINDS over the DAG kinds. A
partition, not two lists that hopefully agree; a kind can no longer land in neither. And the table
is checked for exhaustiveness at import: add a CapabilityKind without declaring its facts and
the first import latence_core raises a typed error. There is no code path where a new capability
merely behaves wrongly.
For an architect evaluating this, that is the shape of control worth looking for: the guarantee is not that someone remembered, it is that forgetting is unrepresentable.
The shape of the whole chapter¶
text · label · confidence
document char offsets"] REL["RelationMention
head/tail mention id
label · confidence"] CHK["ChunkRecord
content · offset_map
pii_spans · masked_content"] end subgraph Resolve["corpus-level — the whole run at once"] direction TB TC["consolidate_types
type_consolidation.exact_surface
normalize_type + operator aliases"] DIS["disambiguate
disambiguation.exact_surface
NFKC · casefold · whitespace blocks"] CE["CanonicalEntity
record_id = entity:normalize_text canonical"] NR["NormalizedRelation
label folded · direction canonicalised"] TC --> DIS DIS --> CE DIS --> NR end subgraph Assemble["graph — graph.canonical"] direction TB NODE["NODE scope
one per canonical entity
content-addressed node_id"] EDGE["EDGE scope
one per normalized relation
endpoint-validated"] HYPE["HYPEREDGE scope
one per bounded sentence window
m-ary · deduped corpus-wide"] GF["graph_features.degree
centrality + community"] end MEN --> TC REL --> TC CE --> NODE NR --> EDGE NODE --> GF EDGE --> GF GF -.->|stamped onto node properties| NODE CHK -.->|streamed only when hyperedges configured| HYPE MEN -.-> HYPE REL -.-> HYPE NODE -.->|node ids + arity| HYPE NODE --> OUT[("graph-nodes · graph-edges
graph-hyperedges .parquet + .kv")] EDGE --> OUT HYPE --> OUT
The dotted arrows into HYPEREDGE are the interesting wiring. graph declares
depends_on: [disambiguate] and nothing else — yet the hyperedge projection needs the chunks,
mentions and relations that were produced five stages earlier. The runner resolves that without a
DAG edge, via an opt-in attribute on the provider:
@property
def wants_document_streams(self) -> bool:
"""True when the Runner should feed chunks/mentions/relations (hyperedges on)."""
return self._hyperedge_config is not None
packages/latence-core/src/latence_core/stages/graph_assembly.py:335-344
When it answers True, the runner gathers those three carriers from the whole pipeline — every
stage's live output or checkpoint — as three independent lazy streams, and calls
assemble_with_documents instead of assemble. When it answers False (the default on every
other provider, where the attribute is simply absent), nothing extra is read and the emitted record
stream is byte-identical. That is what "genuinely optional" means here: not a branch that costs
nothing at run time, but a branch that changes what the runner even loads.
Stage 9 · Type consolidation¶
The extractor induced org in one chunk, Organization in another, ORGS in a third and
company in a fourth. All four name the same type. This stage folds them into one vocabulary and
rewrites every record onto it, before entity resolution — because the resolver votes a cluster's
type from its members' labels and gates its low-precision rungs on type compatibility. Feed it a
drifted vocabulary and it both splits clusters that belong together (org vs organization reads
as a type mismatch) and mistypes the ones that survive.
What "exact surface" actually matches on¶
type_consolidation.exact_surface is the cascade provider with three rungs hard-disabled — and not
overridable, because the rung set is the varying behaviour behind the seam:
def __init__(self, config: dict[str, Any] | None = None) -> None:
cfg = dict(config or {})
# The defining properties; not overridable (the seam's varying behaviour IS the rung set).
cfg["enable_acronym"] = False
cfg["enable_prefix"] = False
cfg["use_embedding_merge"] = False
super().__init__(cfg)
packages/latence-core/src/latence_core/stages/type_consolidation.py:319-325
What survives is normalized-surface equality plus an operator alias map. The campaign configures no aliases, so in the measured stack only the first rung ever fires, and the whole behaviour of the stage reduces to one function:
def normalize_type(label: str) -> str:
lowered = label.strip().lower().translate(_PUNCTUATION_TO_SPACE)
tokens = [_depluralize(token) for token in lowered.split() if token]
return " ".join(tokens)
packages/latence-core/src/latence_core/type_consolidation.py:84-94
_PUNCTUATION_TO_SPACE maps _-/\.,;:()[]{}"'`` to spaces; depluralisation strips a trailing s
only from tokens longer than three characters that do not end in ss, us or is — so
organizations → organization while address, status and basis survive intact. ORG,
org., Org, orgs and ORG_ all collapse to org; Point of Contact and point-of-contact
collapse to point of contact. Nothing else collapses. org and organization stay two types
unless an operator writes the alias.
That is the point. An over-merged type retypes every node of that type at once, which is a far more expensive error than leaving two near-synonyms apart — so the regulated posture is the one that never folds two distinct strings without an explicit instruction.
The full cascade, when you want more¶
type_consolidation.cascade is the same engine with the rungs on. Entity types and relation types
are clustered separately throughout (an entity type organization and a relation type
organization of must never fold together), each with its own alias map.
| rung | fires when | default | why it is gated the way it is |
|---|---|---|---|
normalized |
normalize_type forms are equal |
always on | pure surface drift, no judgement involved |
alias |
an operator's {label: label} map links them, matched on normalized forms |
always on | the escape hatch that always wins; the correction loop is config, not a model's head |
acronym |
one label's initials equal a single-token label (point of contact ↔ poc) |
on in cascade |
needs ≥2 words and ≥2 characters, so a single word is never its own one-letter acronym |
prefix |
one short single token abbreviates a longer one (org ↔ organization) |
on in cascade |
two gates, both required: max_abbreviation_len 5 and min_expansion_ratio 2.0. org/organization is (3, 4.0×) and passes; product/production fails on length alone. Ungated, this rung merges person/personnel |
embedding |
cosine over the type strings ≥ embedding_threshold |
off (0.86 when on) | org ↔ company is a synonym no lexical rule can see. Threshold deliberately strict; borrows the framework's existing Embedder seam rather than inventing one, and never loads a model unless enabled |
Folds go through Union-Find with a lexicographic root and labels are processed in sorted order, so the fold order can never affect the result. The canonical label is then elected by a salience vote with total tiebreaks:
canonical = min(
members,
key=lambda label: (
-counts.get(label, 0),
-len(label.split()),
-len(label),
label,
),
)
packages/latence-core/src/latence_core/type_consolidation.py:364-372
Most-observed wins; ties break on more tokens, then more characters (an expansion reads better than
an abbreviation at equal frequency), then lexicographic order. There is deliberately no LLM
naming pass: the corpus's own dominant usage already answers "what should this cluster be called",
and the vocabulary is emitted as data on the Quality Report — so an operator who prefers
organization over org sets aliases and gets it permanently and reproducibly. A review loop
beats a hidden model.
The audit trail, and why raw_label is unset most of the time¶
Every rewritten record keeps its original induced type:
def _relabel(record: _R, table: Mapping[str, str], /) -> _R:
canonical = table.get(record.label)
if canonical is None:
return record
return record.model_copy(update={"label": canonical, "raw_label": record.label})
packages/latence-core/src/latence_core/stages/type_consolidation.py:353-363
A record whose label was already canonical is returned by identity, not copied, and its
raw_label stays unset. So raw_label means exactly "this was rewritten" and adds no noise to
records nothing happened to — and a run whose vocabulary consolidated nothing is byte-identical to
a run with no consolidation stage at all. That is the off-by-default guarantee made verifiable
rather than asserted.
Incremental runs: append, never churn¶
For a delta, the stage is seeded with the vocabulary the previous run committed. The seed's labels
are clustered together with the new run's — not merged afterwards — so a delta's brand-new
surface org folds into the committed organization cluster through the ordinary rungs instead of
appending a second canonical type. Then the election pins the persisted canonical: a label that was
already canonical stays canonical no matter how strongly the current corpus prefers another
surface, because re-electing it would silently retype every node already in the graph. New types
simply append. The corpus's type space grows monotonically.
Stage 10 · Disambiguation¶
Now the mentions themselves. Given a corpus-wide stream of EntityMentions (relabelled) and
RelationMentions, this stage emits two kinds of record behind one carrier: one ENTITY-scope
DisambiguationRecord per resolved CanonicalEntity, then one RELATION-scope record per
NormalizedRelation whose endpoints both resolved.
The blocking step, which is the only thing that varies¶
Everything downstream of clustering — record building, external-KB linking, relation normalisation,
page-accurate provenance, deterministic emission order — is shared by all four shipped
disambiguators. They differ in exactly one overridable method, _resolve_clusters. That is what
makes them a one-line swap.
The in-core engine starts by grouping every mention by its normalized surface:
def normalize_text(text: str) -> str:
return _WS_RE.sub(" ", unicodedata.normalize("NFKC", text).strip().casefold())
packages/latence-core/src/latence_core/disambiguation.py:58-79
NFKC compatibility normalisation runs before case folding, so compatibility-confusable surfaces
— fullwidth Latin ACME, the ligature file, superscript letters, the Roman numeral glyph Ⅳ —
canonicalise to the same key as their plain-ASCII spelling and resolve into one entity. That closes
a real split vector: an adversary hiding a duplicate behind a look-alike glyph. casefold rather
than lower makes the folding Unicode-correct (ß → ss).
NFKC deliberately does not fold cross-script homoglyphs: a Cyrillic А (U+0410) stays distinct
from a Latin A (U+0041), because they are genuinely different characters. The asymmetry is the
safe posture — legitimate compatibility variants merge, a mixed-script look-alike is never silently
over-merged into an unrelated entity.
The five rungs, and what exact_surface keeps¶
disambiguation.cascade compares every ordered pair of distinct surfaces through a cascade in
descending precision — exact, alias, acronym, substring, embedding — and the first rung that
fires decides the pair. If its confidence clears min_confidence (0.82) the pair is unioned; if
not, the decision is written to the audit log with applied=False and not folded. Nothing is
ever silently over-merged, and nothing is ever silently dropped.
The two low-precision rungs carry restored precision guards. require_type_compatibility (default
on) demands the two surfaces share at least one entity type; an untyped surface counts as
compatible, so an untyped mention stream is not blocked wholesale. require_shared_document
(default off) demands they co-occur in a document — left opt-in precisely because it also
blocks the legitimate cross-document merges the stage exists to make. A fired rung that fails an
enabled guard is recorded as a below-guard decision, reason and all:
"acronym: 'IT' ~ 'Information Technology' [blocked: type-incompatible]".
disambiguation.exact_surface sets exact_only=True, which stops the cascade after alias. And
here the implementation does something worth reading, because it is a production fix with a measured
cost attached:
scan = surfaces
if self._exact_only:
named = {*self._aliases, *self._aliases.values()}
scan = [s for s in surfaces if s in named]
packages/latence-core/src/latence_core/disambiguation.py:400-403
The reasoning in the surrounding comment: under exact_only, the exact rung is a no-op on a
distinct pair by construction — equal normalized forms are the same hash-group key, so the
grouping already resolved them — which leaves ALIAS as the only rung that can fire. A surface named
by no alias can therefore never produce a decision, at any position, against any partner. Scanning
it is provably pure waste: uda's ~1M distinct surfaces are ~5×10¹¹ pair evaluations that each
return None. Measured: 5.8 hours of 100%-CPU producing nothing, with an ETA in days.
Restricting the scan preserves the enumeration order, so audits, merges and clusters are
byte-identical — a dead-iteration elimination, not a behaviour change.
The consequence for the measured campaign is worth stating flatly, because it is the single most important fact about the benchmark's entity layer. The campaign configures no aliases. So the scan list is empty, no pair is ever compared, and:
In the campaign configuration, a canonical entity is exactly the set of mentions whose surface strings NFKC-normalise, casefold and whitespace-collapse to the same string. Nothing else.
Everything the graph achieves downstream — every multi-hop join, every hyperedge, everything the
graph_rescue lanes reach — is built on that austere a notion of identity. Which is either
reassuring or alarming, depending on your priors, and is exactly why the alternatives below matter.
(How much they reach, measured: very little. See 05-retrieval.md §5.4.)
What a resolved cluster becomes¶
packages/latence-core/src/latence_core/stages/disambiguation.py:259
canonical_text— the first-seen original spelling of the cluster's lexicographically smallest normalized member. Union-Find always points a set at its lexicographic minimum, so the canonical surface is independent of merge order.entity_type— the majority label across members, ties broken lexicographically.confidence— the arithmetic mean of member confidences, rounded to 6 places.member_mention_ids/source_document_ids— sorted; the latter is the cluster's document reach.merge_decisions— the applied merges that built it plus the below-policy decisions proposed against its canonical surface. Attaching rejected decisions to the target's cluster is what keeps them visible: a rejected merge's source stays in a different cluster, so without this rule it would be attached to neither and lost.provenance— inherited from the first surviving member mention, so the entity cites the exact page and character range of a real mention rather than a synthetic corpus-level span.
Note what entity_id is derived from: the normalized canonical surface, and nothing else. Not
the type, not the member set, not the document. Two clusters that ended up with the same canonical
surface would collide in the id space — which is a structural obligation on any disambiguator
plugged in here, and the GLinker provider explicitly honours it by injecting the framework's own
normaliser into its ported resolver so that "a confusable surface never survives as a second cluster
that collapses onto an existing id, losing provenance"
(packages/latence-disambig-glinker/src/latence_disambig_glinker/provider.py:254-258).
Relations, normalised¶
Relations flow through RelationNormalizer before their endpoints are remapped. A configured
label_aliases map folds a surface label onto its canonical form (employed_by → works_for); an
unmapped label is fuzzy-matched against the canonical vocabulary by token Jaccard above
label_threshold (0.6), else kept lowercased with whitespace turned to underscores. When the
normalized label is listed in inverses, head and tail are swapped and the canonical label
substituted, so the graph stores one direction. When allowed_labels is configured, anything
outside the set is dropped and counted rather than emitted mis-typed.
Then two hard drops, both in _relation_record: a relation whose head or tail did not resolve to a
canonical entity is dropped, and a relation whose head and tail collapsed onto the same entity
is dropped as a self-loop. Neither is an error; both are silent-by-design and counted.
External KB linking, when you have a KB¶
Independently of clustering, each cluster's canonical surface is run through a ported GLinker-shaped
cascade against an optional in-memory knowledge base: L2 candidate generation by shared
normalized token (an inverted index, so scoring is not O(KB) per query), L3/L4 scoring of each
candidate against its name and aliases keeping the max, L0 an acceptance threshold (0.5). A
type mismatch between a typed query and a typed candidate rejects. Below threshold, the entity stays
unlinked — kb_id=None — rather than being force-linked to the best wrong answer. An empty KB
links nothing, which is the valid configuration, not a degraded one. The campaign configures no KB,
so every node's kb_id is None.
Choosing a resolver¶
This is the swappable part of the chapter, and the four options are genuinely different animals. Two of them are in-core references — the framework's rule is that a capability with only one provider has not proven its seam (ADR-0035) — and two ship as their own packages, the enterprise linker being ADR-0046.
| provider | package | how it decides two mentions are one entity | model / weights | compute | deterministic |
|---|---|---|---|---|---|
disambiguation.exact_surface |
in-core | NFKC-casefold-collapse surface equality, plus configured aliases. Fuzzy rungs disabled and the pair scan restricted to alias-named surfaces | none | CPU, ~64 MB | yes |
disambiguation.cascade |
in-core | the above plus acronym, whole-token substring (min_substring_ratio 0.5), and a token-Jaccard "embedding" stand-in (embedding_threshold 0.75) — the last three gated on type compatibility |
none | CPU, ~64 MB | yes |
disambiguation.embedding |
latence-disambig-embedding |
exact-surface blocking first, then cosine over surface + surrounding chunk context ≥ threshold (default 0.90, context window 160 chars), type-gated, pair count capped |
any Embedder provider; default is the in-core deterministic embedding.hashing |
either | inherited from the resolved embedder |
disambiguation.glinker |
latence-disambig-glinker |
builds a corpus-internal KB from the run's own mentions, links each mention through the neural L2→L3→L4→L0 pipeline, then fuses linked entities with an audited resolver (exact / alias / acronym / substring / fuzzy edit-distance) | knowledgator/gliner-linker-large-v1.0 + gliner-linker-rerank-v1.0, FlashDeBERTa — Apache-2.0 weights and code, verified 2026-07-14 |
GPU | no (the string rungs are; the biencoder is not) |
What exact-surface genuinely cannot do. Four things, in rough order of how often they bite:
- Abbreviations and acronyms.
IBMandInternational Business Machinesare two nodes. So areAcmeandAcme Corporation. On a corpus that uses both forms — which is most enterprise corpora — this fragments the highest-degree nodes in the graph, precisely the ones multi-hop retrieval depends on. - Spelling and OCR variants.
Müller/Mueller,rnread asm. Nothing folds them. - Cross-script look-alikes. Deliberate, as discussed — but it means a Cyrillic-
ААcmeis a separate node fromAcme, forever. - Anything requiring semantics.
the Commissionandthe European Commission.
disambiguation.embedding buys (1), (2) and some of (4) — but the honest reading is that it buys
them at a threshold nobody has validated for the shipped default embedder. The 0.90 default comes
from a pod measurement on multilingual-e5-small where same-entity pairs with context scored
0.96–0.99 and distinct pairs 0.76–0.81. The code says so out loud:
0.90 is NOT yet validated for Granite. It is kept as a sensible documented default; it needs pod re-validation of the same/distinct cosine margins under Granite before it can be claimed calibrated for it (the pod is currently paused — a deferred follow-up, never faked).
The same file records why the surface+context design exists at all: a bare-surface embedding
disambiguator over-merges, because short entity surfaces are not discriminative to a sentence
embedder. Measured, bare surface: IBM ~ International Business Machines 0.883, but
IBM ~ Apple (distinct) 0.870 — a distinct pair outranking a same pair's margin, so no threshold
exists. With context: 0.964 versus 0.808. That is a negative result that changed a design, kept in
the source where the next person will find it.
disambiguation.glinker buys the most: real entity linking rather than string comparison, with a
corpus-internal KB so it needs no external ontology, plus a fuzzy edit-distance rung
(min_similarity 0.84) for the OCR class. Note two of its defaults, both deliberate: the embedding
merge rung is off by default because "it over-merges short names with a retrieval embedder", and
the reranker is on. It degrades rather than crashes — a backend load or inference failure falls
back to the deterministic direct-KB path, and a missing FAISS falls back to brute force.
What none of them can do. No shipped disambiguator splits a homonym that shares a surface form.
Every one of them blocks on exact normalized surface first (glinker reaches the same place by
construction, via the injected normaliser), so Washington the person and Washington the location
become one node carrying the majority label. If your corpus needs that distinction, it is not a
threshold you can tune; it is a provider that does not exist yet.
Swapping is one config line plus an install:
- name: disambiguate
capability: disambiguation
provider: disambiguation.glinker # was: disambiguation.exact_surface
depends_on: [consolidate_types]
config:
device: cuda
threshold: 0.3 # neural linking acceptance
use_reranker: true # the L4 stage
use_fuzzy_merge: true # OCR / spelling variants
context_window: 200 # ±chars of chunk context per mention
pip install latence-disambig-glinker registers the provider under the latence.providers
entry-point group; nothing in core changes, and the emitted DisambiguationRecord contract is
identical, which is what lets the rest of this chapter stay true regardless of which one you pick.
Stage 11 · Graph assembly¶
graph.canonical turns the disambiguation stream into GraphRecords at three scopes: NODE,
EDGE and — when configured — HYPEREDGE.
How a node id is derived¶
Content-addressed, in one function, truncated to 16 hex characters:
def make_graph_id(corpus_id: str, *parts: str) -> str:
"""Deterministic content-addressed id: sha256 over ``corpus_id | parts`` (ported ``_make_id``).
Truncated to 16 hex chars — collision-safe at corpus scale and short enough to read in a
GraphML/TTL export. The same inputs always yield the same id (byte-stable), so the graph
is reproducible across runs (S9 determinism).
"""
raw = "|".join([corpus_id, *parts])
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:_ID_LEN]
packages/latence-core/src/latence_core/graph.py:42-50
def node_id_for(self, entity_id: str) -> str:
"""The deterministic node id an entity maps to (shared by edge endpoint resolution)."""
return make_graph_id(self._corpus_id, "node", entity_id)
packages/latence-core/src/latence_core/graph.py:106-108
Chain the two together with the entity id from the previous stage and the whole derivation is:
node_id = sha256("s3-multihop_rag" + "|" + "node" + "|" + "entity:" + normalize_text(canonical_text))
.hexdigest()[:16]
Three properties fall out of that, and each one matters to a different reader.
For a developer: the id is computable without the graph. Given a corpus id and a surface string
you can derive the node id in three lines, in any language, and join to it. That is why the corpus
export can denormalise kg_node_ids onto chunks (chapter 4) without a foreign-key lookup.
For an operator: the id is stable across runs and across incremental deltas. Re-running the same corpus produces the same ids; adding documents that grow a cluster leaves its id unchanged, as long as the canonical surface does not move. That is what makes the graph updatable rather than rebuildable, and it is why the type-consolidation seed pins persisted canonical labels — re-electing one would change surfaces and therefore ids, retyping and re-keying nodes already downstream.
For a researcher: the id is a function of the decision, not of the evidence. Two corpora
that resolve Acme Corp. identically will disagree on node ids only if their corpus_id differs.
Set the same corpus_id and node ids are comparable across runs — which is how a bake-off compares
two disambiguators' graphs node-for-node.
Edge ids use the same function with a different part list — make_graph_id(corpus_id, "edge",
relation.record_id) at graph.py:172 — which means edge identity is per source relation, not
per endpoint pair. graph.canonical is therefore a multigraph: two documents both asserting
Acme —acquired→ Beta produce two parallel edges, each with its own evidence. That is deliberate;
graph.weighted is the alternative that folds them (below).
What a node and an edge concretely are¶
A GraphNode carries node_id, entity_id (the source CanonicalEntity record id), label (the
entity type), canonical_name, member_mention_ids, source_document_ids, kb_id, confidence,
an open properties bag, Evidence, and the provenance and classification inherited from its
source record. The properties bag is populated in layers:
| key | written by | value |
|---|---|---|
mention_count |
GraphBuilder |
number of mentions folded into the entity |
document_count |
GraphBuilder |
number of distinct source documents |
confidence |
GraphBuilder |
the cluster's mean member confidence |
linked |
GraphBuilder |
whether the external KB link cleared L0 |
centrality, community |
graph_features.* |
see below; absent when unconfigured |
description, hyperedge_degree |
the hyperedge pass | see below; absent when hyperedges are off |
A GraphEdge carries edge_id, relation_id, source_node_id, target_node_id, label,
confidence, properties {inverted, is_predicted, confidence}, and its own Evidence.
Endpoint validation. An edge is emitted only when both its head and tail entity resolved to a
node that exists, and the two node ids are distinct. A relation pointing at a missing endpoint is
dropped — never emitted as a dangling edge — and one whose endpoints collapsed onto the same node is
dropped as a self-loop. Both guards live in _build_edges (graph.py:154-193), and both are
continue, not exceptions: a relation whose entity got filtered out is a normal outcome of
resolution, not a failure.
Evidence: the reason this is auditable rather than asserted¶
Every node and every edge carries an Evidence record — mention ids, document ids, a
human-readable snippet, and a confidence. It is not decoration. It is the reason a business user
can be shown a graph edge and click through to the sentence that justified it.
evidence = Evidence(
mention_ids=list(edge.evidence_mention_ids),
document_ids=list(edge.evidence_document_ids),
snippet=f"edge {edge.label!r} "
f"{edge.source_node_id} -> {edge.target_node_id}",
confidence=edge.evidence_confidence,
)
packages/latence-core/src/latence_core/stages/graph_assembly.py:437-443
An edge's evidence mention ids are its normalized relation's head and tail mentions; those mentions carry document-coordinate character offsets and, via the page map, page numbers. So the chain from "the graph says Acme acquired Beta" to "document 14, page 3, characters 1180–1214" is a join over ids that are already in the parquet — no framework code required to walk it.
One bound worth knowing about: max_evidence_per_node (default 0, unlimited) caps the mention ids
folded into a node's evidence, so a hub entity with tens of thousands of mentions does not
produce an unbounded evidence blob. Note the asymmetry in _build_nodes — the cap applies to
evidence_mention_ids but member_mention_ids still carries the full set, so nothing is lost, only
the justification list is trimmed.
The other assembler, and a trap in swapping to it¶
graph.weighted satisfies the same seam and emits the same contract, but folds every parallel
same-(head, tail, label) relation into one weighted edge: edge_id content-addressed over the
endpoint pair plus label (not the relation id), confidence the maximum over the folded relations,
evidence the deduplicated union of their mention and document ids, and
properties["weight"] recording the multiplicity. It is the right choice when a downstream ranker
wants "how many independent sources assert this" as a first-class feature rather than as a group-by.
The trap: WeightedGraphAssembler.__init__ reads only corpus_id, max_evidence_per_node and
graph_features. It has no hyperedges handling and no wants_document_streams property. Move a
campaign-shaped config from graph.canonical to graph.weighted and the hyperedges block is
silently ignored — the whole hypergraph disappears, the graph-hyperedges.parquet and .kv
files are never written, and nothing warns you. Worth knowing before you swap.
Hyperedges: the m-ary retrieval unit¶
This is the least-documented and most interesting mechanism in the system, so it gets the space.
The decisions behind it are
ADR-0057
(what a hyperedge is, and that its text follows corpus redaction),
ADR-0058
(why token_cost is approximate) and
ADR-0056
(why the query-time face is a point-lookup KV).
What a hyperedge is here¶
A binary edge says Acme —acquired→ Beta. It cannot say what the sentence actually said, which was
that Acme acquired Beta from Gamma in 2021 for €4bn, subject to approval by the Commission — a
single joint fact over five entities. Decompose it into binary edges and you get a star of pairwise
claims, each individually true and collectively unable to reconstruct the original. Retrieve on
them and you retrieve fragments.
A hyperedge in this system is the minimal contiguous sentence window that covers all arguments of at least one extracted relation, carrying:
entity_node_ids— the full set of canonical entities in that window, not just the relation's arguments (arity m, not 2);relations— the directed relations extracted from the window, deduplicated by triple;tau_text— the verbatim span text, in the corpus's redaction variant;token_cost+token_counter— the knapsack weight and the identity of the counter that produced it, so a later index/query tokenizer mismatch is detectable rather than silent;gamma— a composed extraction confidence;support— how many distinct source spans in the corpus asserted this same fact;has_relations—Falsemarks a weak hyperedge (entities only);merged_sources— the non-primary spans folded in by dedup;- plus the usual
Evidence, provenance (in original document coordinates) and classification.
The governing distinction, stated in the module docstring: chunks are storage granularity;
hyperedges are reasoning granularity. Which is why the sentence boundaries do not come from the
chunker's [.!?]\s regex — that regex only needs to prefer a break near a budget ceiling, so a
false boundary there costs nothing. A false boundary here splits a fact in half. Hyperedge
segmentation uses RuleSentenceSplitter, a zero-dependency abbreviation-safe splitter that knows
about z. B., Dr., e.g., initials (J. Smith), decimals (3.14), German ordinal dates
(am 3. Mai) and markdown hard breaks.
The build, step by step¶
Per chunk, in record_id order (not stream order — the traversal order is part of the output,
because dedup keeps the first-seen instance primary until a strictly higher γ displaces it):
- Strong seeds. For each relation whose two arguments both have resolvable offsets, take the
sentence range spanning them. If it spans more than
max_window_sentences, the relation is skipped as a seed — it stays a binary edge and never becomes an m-ary unit.
lo, hi = min(hs, ts), max(hs, ts)
if hi - lo + 1 > self._config.max_window_sentences:
continue # arguments too far apart for one reasoning unit
packages/latence-core/src/latence_core/hyperedge.py:462-464
- Merge overlapping seeds in the same chunk: union of spans, mentions and relations.
- Absorb. Every mention whose start offset falls inside a strong span joins it. This is the step that makes the unit m-ary — the relation contributed two arguments, the window contributes everyone else who was standing there.
- Weak spans, if enabled (below).
- Size targets: split above
max_tokens, then merge belowmin_tokens. - Arity groups: split above
max_arity. - Emit, with corpus-wide dedup.
Exactly what weak_hyperedges: true changes¶
Two things, and one of them is easy to miss.
First, it adds spans. After the strong spans are built, every mention not covered by one is grouped by its sentence index, and each such sentence becomes its own span with no relations:
if self._config.weak_hyperedges:
covered = {
mid for span in spans for mid in span.mention_ids
}
weak_by_sentence: dict[int, set[str]] = defaultdict(set)
for mid, sent in mention_sentence.items():
if mid not in covered:
weak_by_sentence[sent].add(mid)
for sent, mids in sorted(weak_by_sentence.items()):
spans.append(
_Span(
chunk=chunk,
start=sentences[sent][0],
end=sentences[sent][1],
mention_ids=mids,
)
)
spans = self._merge_overlapping(spans)
packages/latence-core/src/latence_core/hyperedge.py:484-501
Second, it is the gate on emission — and this catches more than the weak spans it just created:
packages/latence-core/src/latence_core/hyperedge.py:675
A strong span can arrive here with has_relations=False: its relations may have been discarded
because an argument was never canonicalised, or because both arguments collapsed onto one entity,
or because the span's entity set is a single node. With weak_hyperedges: false those spans are
dropped too.
So the toggle is really the answer to: is the hypergraph an index over relation-bearing text, or over all entity-bearing text? With it off, only windows that carry a surviving directed relation become units — a smaller, denser, higher-precision hypergraph. With it on (the campaign setting), every sentence that mentions a canonicalised entity becomes retrievable as a unit, which is what makes the hypergraph usable as a general entity-anchored index rather than only as a relation index.
Weak units are not treated as equals. Their γ is halved:
packages/latence-core/src/latence_core/hyperedge.py:108-110
The bounds, and why bounding matters¶
| knob | campaign | code default | what it bounds | behaviour at the limit |
|---|---|---|---|---|
max_window_sentences |
3 | 3 | how far apart a relation's two arguments may sit for the relation to seed a unit | the relation is skipped as a seed; it remains a binary edge |
min_tokens |
25 | 25 | the floor on a unit's tau_text |
the span is absorbed into the previous same-chunk span — but only if the combined range still fits max_tokens; otherwise it stands undersized |
max_tokens |
180 | 180 | the ceiling on a unit's tau_text |
recursive split at the sentence boundary minimising cut relations, ties broken by most balanced token split. A single oversized sentence is left whole |
max_arity |
12 | 12 | the number of entities one unit may connect | split into relation-connected components; a component still over the cap is truncated to the first max_arity node ids |
weak_hyperedges |
true | true | whether entity-only spans become units | see above |
token_counter |
(unset) | latence:estimate_tokens:v1 |
which tokenizer produced token_cost |
an operator may name tiktoken:<encoding>; a missing package or unknown encoding is a loud wiring-time error, never a silent fallback |
Every one of the campaign's five hyperedge values is the code default. Nothing was tuned.
The token counts above come from the in-core heuristic — word/punctuation tokens plus one extra per
~6 characters of any word longer than 6 — not from a real tokenizer. That is deliberate and it is
declared: token_cost is an approximate index-time weight whose budget guarantee comes from an
exact query-time verification pass, and the counter identity travels beside the value so a
downstream consumer using a different tokenizer can detect the mismatch instead of silently
over-spending its budget.
Why bound at all? Three distinct failure modes, one per bound.
- Without an arity cap, a single dense paragraph — a table row, a list of signatories, an author block — produces one hyperedge connecting fifty entities. Its postings list appears under all fifty node ids, so it becomes a candidate for every query touching any of them, while telling the reader almost nothing specific. It is the hypergraph's version of a stop word.
- Without a token ceiling,
tau_textgrows without limit and the unit stops being a selectable item: the whole point oftoken_costis that a chain-of-evidence selector packs units into a budget, and an item that alone exceeds the budget can never be selected. - Without a token floor, you get units whose text is
"Acme."— technically an entity occurrence, useless as evidence, and pure index bloat at corpus scale.
And the window cap is the one that most directly protects meaning. Two entities four sentences apart, related by a model that read a 640-token chunk, are not a joint fact — they are a co-occurrence the extractor labelled. Refusing to seed a reasoning unit from them is the system declining to assert more structure than the text supports.
Two honest notes on the bounds. The min_tokens merge does not do what the module docstring
says it does — the docstring promises "the same-chunk neighbour sharing the most entities", the
implementation takes the previous span, and the inline comment admits it ("previous vs next is
unknowable in a single pass"). And the max_arity truncation keeps comp_nodes[:max_arity] after
sorting by node id — which, since node ids are sha256 prefixes, is arbitrary-but-deterministic hash
order, and unlike the candidate cap in link prediction it is not logged. Neither affects the
campaign numbers, but both are worth knowing before you tune.
Identity, dedup and support¶
hyperedge_id = make_graph_id(
self._corpus_id, "hyperedge", *node_ids, *(":".join(s) for s in signature)
)
packages/latence-core/src/latence_core/hyperedge.py:754-756
The id is content-addressed over the corpus id, the sorted entity-node set and the sorted relation signature — and pointedly not over the span position. So the same fact stated in two documents hashes to the same id, and dedup is the identity operation rather than a post-hoc pass:
- identical node set and identical relation signature → merge.
supportincrements, member mention ids union, the other source is appended tomerged_sources. - the highest-γ instance stays primary: it owns
tau_text,token_costand the provenance. A later instance with strictly higher γ promotes itself, demoting the previous primary intomerged_sources. - same entity set, different signature → distinct facts, never merged. "Acme sold Beta" and "Acme acquired Beta" over the same two nodes are not the same claim.
Relations inside one window are deduplicated by triple before they enter the signature — the highest extractor confidence wins, ties broken by smallest source relation id. That dedup is id-affecting by design: a fact stated twice in one span must hash like a fact stated once. It came from a live finding on the enterprise-gold calibration pass, where 2 of 2 multi-sentence relation-bearing spans emitted duplicate relations under two relation ids.
γ, and the rule about missing signals¶
links = [link_conf[mid] for mid in member_ids if mid in link_conf]
if links:
parts.append((w_link, sum(links) / len(links)))
if relations:
parts.append((w_rel, sum(r.confidence for r in relations) / len(relations)))
ner = [by_id[mid].confidence for mid in member_ids if mid in by_id]
if ner:
parts.append((w_ner, min(ner)))
if not parts:
return 0.0
total = sum(w for w, _ in parts)
gamma = sum(w * v for w, v in parts) / total
packages/latence-core/src/latence_core/hyperedge.py:901-913
Three components — mean link confidence (0.3), mean relation confidence (0.5), weakest member
mention confidence (0.2) — renormalised over the components actually present. The rule that matters
is the one in the if guards: a missing signal is UNKNOWN and drops out of the weighting; it is
never counted as zero. Treating an absent link confidence as 0.0 would systematically penalise
every unit produced by a stack that does not link, which is every stack in the campaign.
Concretely, in the measured configuration the algorithmic cascade populates no link confidences, so
a strong hyperedge's γ is (0.5·mean_relation_conf + 0.2·min_mention_conf) / 0.7, and a weak one's
is min_mention_conf × 0.5. γ is explicitly uncalibrated — the calibration decision is its own
open ticket — so treat it as a ranking signal, not as a probability.
tau_text follows the corpus redaction variant, enforced at the writer¶
This is the privacy control most likely to be missed, because the hypergraph is a second text store. The chunk export can mask PII; if the hyperedge sidecar carried raw spans, a masked corpus would sit beside an unmasked one. So the rule is enforced where the text is written, not where it is read:
packages/latence-core/src/latence_core/hyperedge.py:918-950
Every non-TAG PII span overlapping the window is applied to the raw slice, substituting the span's
placeholder (or a typed mask). TAG-action spans leave text intact by definition and are dropped at
projection time. When no redaction ran, the corpus itself is raw, so the raw slice is the corpus
variant. The provenance span meanwhile stays in original document coordinates, round-tripped
through the chunk's offset map — so audit and purge still work against the source document even
though the stored text is masked.
There is a related trap the code documents from a live incident. The runner gathers document-level
records from the whole pipeline, and several stages legitimately re-emit the same chunk: redaction
yields its input chunks with masked_content added, an export sink passes records through verbatim.
Without dedup, the same record_id arrives two or three times — which inflated support by the
copy count and risked the pre-redaction copy becoming the primary tau_text on a γ tie. The
fix dedups by record_id preferring the most-processed variant (_project_chunks,
hyperedge.py:248-272). A PII bypass that only exists because a record was seen twice is exactly
the class of bug that a "the writer enforces it" rule is supposed to prevent, and nearly didn't.
Memory: why this module looks the way it does¶
The uda run — 220,129 chunks, 2,603,887 mentions, 586,415 disambiguation records — was OOM-killed
inside a 29 GB container, because an earlier shape held the whole run as pydantic records. A
measured 3.5 kB per EntityMention (each dragging its own Provenance and Classification
submodels) is 8.5 GB of mentions alone, before the chunks.
So every input record is projected on arrival into a __slots__ dataclass holding only the handful
of fields this module reads — _MentionMeta is ~100 B instead of ~3.5 kB — and the pydantic record
is dropped inside the function that consumed it. _ChunkView additionally drops masked_content,
a full second copy of the chunk text that the module never reads and only tests for presence. The
whole-run mention index survives only long enough to build the per-chunk groups and the two
genuinely cross-chunk maps, then is del'd. Chunk-scoped state is popped as the chunk is
processed, so it is released the moment its hyperedges are emitted:
for chunk_id in sorted(chunks_by_id):
chunk = chunks_by_id.pop(chunk_id)
chunk_mentions = mentions_of_chunk.pop(chunk_id, [])
chunk_relations = relations_of_chunk.pop(chunk_id, [])
packages/latence-core/src/latence_core/hyperedge.py:402-405
What deliberately stays whole-run is the built dedup table — because dedup is corpus-wide and
γ-driven, a later chunk can promote itself to primary, and that table is the output rather than
held input. A memory-shape regression test counts live _MentionMeta instances, which is why the
__slots__ are spelled out by hand rather than via @dataclass(slots=True): __weakref__ has to
survive.
Two consequences follow from the chunk-local design and both are worth stating plainly. A relation whose head and tail mentions land in different chunks is dropped at grouping time — it has no single-chunk sentence window, and it is already a binary edge. And a hyperedge never spans a chunk boundary, which means the chunker's overlap (80 tokens in the campaign) is what keeps a fact straddling a boundary recoverable at all.
The query-time face: a point-lookup sidecar¶
The parquet is the durable, inspectable, engine-agnostic form. The .kv file beside it is a derived
index the parquet can always rebuild, because the online read path was measured: point lookups are
flat in corpus size (~0.5 ms for a whole query's reads at 1M units) while columnar scans degrade to
tens of milliseconds.
The reference implementation is deliberately dependency-free — one file of sorted keys, mmaped,
binary-searched, O(log n) per lookup, opened and closed per call so a reader holds nothing between
queries. An LMDB or RocksDB adapter is a provider-package option behind the same read interface;
the format is the contract, the library is not. Four keyspaces share the one search structure,
each key being a one-letter prefix, a vertical bar, and the id:
| prefix | key | value |
|---|---|---|
e |
hyperedge id | the solver fields as sorted-key JSON — entity_node_ids, token_cost, token_counter, gamma, support, has_relations. No text: the index is built for the selector |
p |
node id | the node's postings, γ-sorted descending with γ inline, so top-k truncation needs no row fetch |
t |
hyperedge id | tau_text (already the corpus redaction variant; this file only carries it) |
a |
normalized surface | the node ids whose canonical name matches — the alias table a zero-model query entry point reads |
Entries are sorted before writing and values serialise with sort_keys=True, so the same parquet
always rebuilds the byte-identical file.
Graph features¶
graph_features is a nested sub-provider slot on graph assembly, resolved the same way the export
resolves its embedder. Absent, the node stream is byte-identical to a run without it. Present, the
computer sees the whole assembled graph — all nodes and all edges — and returns one score and
one community id per node, which are stamped additively onto properties via model_copy; offsets,
evidence and provenance are untouched. A node the computer failed to score is a loud contract error,
never a silently unenriched node.
| provider | centrality | community |
|---|---|---|
graph_features.degree (campaign) |
normalised undirected degree — distinct neighbours / (n−1), the Freeman normalisation, in [0,1]; a single-node graph scores 0.0 |
connected components via Union-Find |
graph_features.pagerank |
PageRank over the directed edges: damping 0.85, uniform init, at most 100 sweeps, early stop at L1 delta < 1e-12, dangling nodes redistributing uniformly | deterministic semi-synchronous label propagation |
Both are parameter-free — the bounds are fixed rather than configurable, precisely so a run is reproducible. Every score is rounded to 8 decimals, which is what makes an iterative engine byte-deterministic: the last ulps of accumulated float error are discarded so two processes agree exactly. Every community id is the community's lexicographically smallest member node id — a content-addressed, order-independent label shared by both families, so switching families changes the partition but not the id scheme.
The label-propagation implementation carries a small piece of hard-won correctness worth noting: each node counts its own current label alongside its neighbours'. Without that, the classic synchronous LPA oscillates on bipartite structures — a bare two-node component would swap labels forever and never merge. With it, both nodes converge to the smaller id.
These two properties are not ornamental. They flow into graph-nodes.parquet columns and, via
context enrichment, onto every chunk's ContextHeader as kg_node_centrality and
kg_node_community — aligned 1:1 with kg_node_ids — which is how a downstream ranker reads a
chunk's own entities' structural importance off the chunk it already holds, with no graph database
in the query path.
The other thing the hyperedge pass stamps on nodes¶
When hyperedges are configured, nodes additionally get hyperedge_degree (how many units the node
participates in) and a deterministic description:
"{canonical_name} — {label}" # always
" — {rel}: {other}, {rel} by: {other}, …" # top-3 relation contexts, if any
Composed from the node's own hyperedges, most-frequent first, ties lexicographic, capped at three. It exists so the entity index is query-matchable without any LLM summarisation — a tier-0 embedding input that costs nothing and reproduces exactly. Nodes touched by no hyperedge get degree 0 and the name+type description, never a missing key on one node and not another.
Edge prediction — and a prominent caveat¶
graph_completionis not in any of the five campaign pipelines. Not asreference, not asultra, not at all. Every benchmark number in this guide was produced from asserted edges only — edges that a model read out of actual text. No inferred edge contributed to any reported recall, and the numbers should be read that way.
The capability exists, it is wired, it is tested, and it ships in two providers
(ADR-0037).
It is simply not part of the measured stack. The stacks that do configure it are
stacks/linkpred-base.yaml (which exists specifically so a bake-off can hold every other stage
constant) and stacks/gpu-sota.yaml.
The contract an inferred edge must satisfy¶
Whatever proposes it, an inferred edge is emitted as an additional EDGE-scope GraphRecord,
appended — the completer never re-emits the input nodes and edges and never mutates an asserted one:
properties={
"inferred": True,
"scorer": _SCORER,
"score": pred.score,
"rank": pred.rank,
"calibrated": False,
"support": pred.support,
"rule": pred.rule,
},
packages/latence-core/src/latence_core/stages/graph_completion.py:211-219
edge_id=sha256(corpus_id, "predicted", head, label, tail)[:16]— the samemake_graph_idfunction with a distinct part list, so an inferred id can never collide with an asserted one.relation_id=f"predicted:{scorer}:{edge_id}"— visibly not a source relation.- Evidence is honest and model-derived, not fabricated.
mention_idsis[], because a predicted edge has no textual mention and asserting one would be a lie.document_idsis the union of the head and tail nodes' source documents.snippetnames the scorer and the score. - An inferred edge colliding with an asserted one (same endpoints and label) is dropped — it is already asserted — logged, not emitted.
Two small inconsistencies in this area are worth flagging, because a downstream consumer can trip
over them. Asserted edges carry properties["is_predicted"] = False and no inferred key;
inferred edges carry properties["inferred"] = True and no is_predicted key. Filtering on
inferred is safe (asserted edges read falsy); filtering on is_predicted is not (an inferred
edge reads as missing, which a naive .get() treats as "extracted"). inferred is the real marker
— it is what the Quality Report tallies. And the module docstring of graph.py still asserts that
"v1 has no link-prediction Stage (that is the post-v1 P3 plugin)", which stopped being true when
GraphCompletion became its own capability; capability.py:518 records the transition.
The two scorers¶
graph_completion.reference (in-core, zero-dep, deterministic). A bounded 2-hop
transitive/symmetric closure: if A —p→ B and B —p→ C and p is declared transitive, propose
A —p→ C; if A —p→ B and p is declared symmetric, propose B —p→ A. The score is an
explainable path-support fraction — distinct supporting 2-hop paths over a normaliser, default
the run's max — and fan-out is capped per source node (32) so a hub cannot explode the candidate
set. It sets calibrated: False, honestly, because a raw fraction is not a calibrated probability.
It exists to prove the seam and to give the learned scorer a baseline to beat.
graph_completion.ultra (package latence-linkpred-ultra). Four pieces:
- Candidate generation — type-aware, tiered 2-hop, bounded, with a rare-relation recall
guarantee. From each relation's observed edges it derives a type signature (which head types
and tail types it connects), so
partner_ofnever proposes org→person; a relation with no consistent signature admits any type, because the filter must never invent a constraint that costs recall. Candidates are drawn from the 2-hop neighbourhood of the relation's observed endpoints, falling back to the full type-valid pool for an isolated component. A rare relation (≤ 4 observed edges) keeps all its type-valid candidates — rare relations are exactly where a learned scorer earns its keep — while a dense one is capped at 512, and the drop is logged with its count. - Scoring — inductive ULTRA (ICLR'24), zero-shot from a pretrained checkpoint, default
ultra_3g. The adapter is faithful to the real API: relations doubled with inverses,tasks.build_relation_graph, all-negative tail batches,[B, num_nodes]score vectors. The graceful fallback is a transductive PyKEEN model (defaultDistMult) trained on the observed triples. If neither is available the provider raises a typed error, never a silent no-op — unless a stack explicitly opts intoskip_if_unavailable, which the GPU stack does for the optional trailing stage. - Calibration — because a raw ULTRA score is an unbounded logit-like number whose scale is
arbitrary, and presenting it as a
0..1confidence would be a fabricated calibration. Temperature scaling (Guo et al. 2017) fitsTover a fixed 14-value grid by minimising NLL on a held-out labelled set, then a precision-targeted threshold picks the lowest probability cut at which precision ≥target_precision(0.9), maximising recall at that precision. Two refusals are built in: belowmin_calibration_samples(8) it falls back to the configured floor rather than over-fit a cut off one example, and if no cut reaches the target it sets the threshold just above the maximum positive probability so nothing is emitted. Refusing to emit is the honest disposition; lowering the bar silently is not. - Evaluation — the harness in
evaluation.pycomputes held-out MRR and Hits@k under the filtered protocol (other true tails for the same query are not counted against the target) over a seeded, reproducible split. The non-negotiable part is leak detection:assert_no_leakraises if a held-out edge or its inverse still sits in the observed graph — ULTRA doubles relations with inverses, so a surviving inverse leaks the answer just as badly. The provider then re-prepares a fresh scorer over the observed-minus-held-out graph before ranking. AndRankingMetrics.leak_checkedhas no default: the caller must pass it, so a path that skipped the guard cannot silently inheritTrue.
Licence. Framework code (this package and latence-core) is Apache-2.0. The vendored ULTRA
inference subset is MIT (MilaGraph / DeepGraphLearning, cited in NOTICE and
THIRD-PARTY-LICENSES); the ULTRA weights are MIT (Hugging Face mgalkin/ultra_3g,
ultra_4g, ultra_50g model cards); PyKEEN is MIT. Weights and code verified separately on
2026-07-09. No weights are vendored — the checkpoint is a config path, downloaded only when a
pipeline actually configures the provider on a host with the extras installed. The profile declares
deterministic=False, because learned float inference is not guaranteed byte-identical across
hardware and library versions; the candidate order and the greedy scoring are fixed, the floats
are not.
When to enable it, and how to know it helped¶
Enable it when your graph is sparse relative to its schema — when a relation type that clearly holds between two entities is asserted in only some of the documents where it applies. That is the regime where completion adds reach. Do not enable it when your downstream consumers cannot distinguish an inferred edge from an asserted one, which is a governance question, not a technical one.
Validating that inferred edges help rather than hallucinate takes three things, and the package supplies the first two:
- The held-out metric, honestly computed. MRR and Hits@k with the leak guard asserted, not assumed. A model that ranks held-out truths well is at least measuring something real.
- A controlled bake-off.
stacks/linkpred-base.yamlexists sograph_completion.referenceandgraph_completion.ultracan be compared with every other stage byte-identical. A learned scorer that cannot beat a 2-hop transitive closure is not earning its dependencies. - The end-to-end lens, which is on you. Held-out MRR measures whether the scorer reconstructs
edges the extractor already found. It does not measure whether inferred edges improve retrieval.
The only test that answers that is the campaign's own: run the retrieval matrix with and without
the completion stage and compare recall@k per dataset. Because
inferredis a first-class property on every predicted edge, you can also run the ablation the other way — keep the stage on and filter inferred edges out at query time — which isolates their contribution without re-running the pipeline.
The honest posture, and the one the campaign took: an inferred edge is a hypothesis with a calibrated probability and no textual evidence. It is marked as such at every layer, it never merges into the asserted graph, and it did not contribute to a single published number.
What crosses the line into chapter 4¶
Three artefacts leave this chapter, and they are joined by ids rather than by a database.
The graph itself, as GraphRecords at three scopes, which the KG export writes as
graph-nodes.parquet, graph-edges.parquet, graph.ttl, graph.graphml,
graph-hyperedges.parquet and the graph-hyperedges.kv sidecar. Every node and edge carries its
evidence; every hyperedge carries its span in original document coordinates.
The node ids, which are the join key. Context enrichment reads the graph and the chunk stream
together and writes each chunk a compact ContextHeader carrying kg_node_ids (the chunk's own
entities), neighbor_node_ids (what its triples reach), the rendered neighbor_triples, and —
because graph_features.degree ran — kg_node_centrality and kg_node_community aligned 1:1 with
kg_node_ids. Those columns are denormalised onto every row of the corpus export. That is the whole
crossing from text to graph: a retriever that got a chunk back from a vector search already holds
the graph keys for it, and the 1-hop rescore in chapter 5 reads them off the row without touching a
graph store.
The operational shape, for anyone sizing this. Everything in this chapter is corpus-level: it runs once, after the document phase, on already-extracted records rather than on raw text. Its cost scales with the size of the graph — mentions, relations, entities — not with corpus bytes, and not with worker count. The document phase is the opposite: it scales with workers and is independent of corpus size. That asymmetry is why a 220k-chunk corpus and a 2.4k-chunk corpus run the same DAG, and why the engineering effort in this chapter went into memory shape rather than parallelism.
Next: 4 · Enrichment and export — how the graph is projected back onto every chunk as a context header, and how the two exports turn all of this into files that need no framework code to read.