Grounded extraction¶
How do you read a hundred papers into a knowledge graph without the graph filling up with confident-sounding claims nobody can trace back to a source? Grounded extraction is angelo's answer: a fixed coordinator pipeline that reads a corpus against a goal, commits every claim next to the verbatim quote that supports it, and — when the goal calls for it — attaches those claims to a spine as it goes.

The evidence contract in motion: the extractor pulls a verbatim quote for each claim, the scribe wires quote --supports--> claim, the auditor passes the grounded claims and flags the one with no quote, and only grounded claims attach to the spine.
This page explains the pipeline for a human reader. The operating procedure for
the orchestrating agent lives in
.cursor/rules/grounded-extraction.mdc;
this page is the why behind it.
- Coordinator capability:
zettelkasten/coordinator_agents.py - Server-side attach + claim writing:
zettelkasten/server.py - Where extraction fits: Zettelkasten · Spines
Why a pipeline (and not one big prompt)¶
You could ask a single model to "read these papers and build a knowledge graph." It would produce something plausible and unverifiable — no separation between the model that proposes a claim and the model that checks it, no guarantee a claim is grounded, and no spine to attach findings to. Grounded extraction breaks the job into roles with different jobs and different failure modes, so each step's output can be checked by the next.
The whole thing is available only when the zettelkasten bundle is enabled for the
project; it is contributed to the coordinator as a generic capability rather
than hard-wired, so the coordinator stays domain-agnostic and the extraction roles
appear only where they belong (Implemented grounded-extraction bundle via a generic coordinator capability-contribution seam). When enabled, the coordinator
exposes create_extraction_graph and the extractor / scribe / auditor /
synthesizer agents; when absent, the project was set up without
--with-zettelkasten.
The pipeline¶
One fixed pipeline runs per source, parameterized by a single schema (the rubric that says which dimensions to pull):
- prep ingests each source and seeds its per-source hub note. It is not a
graph task: a planner (the extractor) cannot depend on an implementer, so the
ingest-and-seed step happens synchronously before the graph runs.
create_extraction_graphdoes it for you — and ingestion is source-first, so a document is content-hashed before any note references it (Source-first ingestion for the zettelkasten agent (ingest_source + content-hash + full-text cache)). - extractor (read-only planner) reads the ingested source against the schema and emits grounded candidates: a dimension tag, a claim sentence, its verbatim quote(s), and optional cross-source connections. Extractors run in parallel across sources because they only read.
- scribe (implementer) commits those candidates as
claim+quotenote pairs in the source's graph, tagged with the schema's dimension tags and linked to the hub. Scribes are serialized — one writer at a time — which the coordinator's path-scoped write lease enforces for free (Path-scoped write coordination: per-path coordinator lease + per-box zettelkasten locks); the zettelkasten store's per-box locking makes concurrent extraction of different sources safe. -
auditor (checker, a deliberately different model family) verifies two things: grounding integrity (every load-bearing claim has a verbatim supporting quote) and schema-dimension coverage (the rubric was actually filled in).
-
synthesizer (implementer) runs once at the end, after every source's trio has committed its notes. It does the cross-cutting work no per-source scribe could see: placing each new claim onto every other spine layout it fits (cross-spine placement), wiring the flat-graph relationships between claims across sources (the cross-source linking that needs the global view of sibling note ids), and minting shared
_crosssynthesis hubs for themes that span sources. It only adds edges and hubs — it never re-grounds or edits a claim.
Finally the memory task records the run.
Reading a book: automatic slicing¶
The pipeline above is paper-shaped by default — one extractor trio per source, reading the "full" text. A 500–666pg book breaks two assumptions at once: the full-text cache is windowed (a single read only ever sees the first ~200k chars, so later chapters are silently invisible and their claims can never be grounded), and one extractor cannot faithfully hold an entire book in a single pass.
So a large source is sliced natively. Two things change, automatically:
- The whole document is cached. Ingestion for an extraction run caches the
entire text to the disposable
<content_hash>.txtcache (plus a<content_hash>.pages.jsonsidecar of page offsets and the PDF's chapter outline). The 200k cap now applies only to what a single read returns, not to what is stored — so a later chapter is physically present and addressable. A windowed read (source(action="fulltext", offset=…)orpage_start/page_end) returns just that slice and reportstotal_chars/slicedso a reader knows there is more. - The trio fans out per slice. A source above the size threshold (~200k chars
or ~60 pages) is split into windows — snapped to the PDF's chapter bookmarks
when it has them, otherwise fixed-size char windows with a small overlap so a
quote straddling a boundary stays whole. Each slice gets its own
extractor → scribepair (all sharing the one per-source hub), and a single auditor per source runs after every slice's scribe has committed — so the coverage matrix and grounding audit stay whole-source, not per-fragment. A source at or below the threshold keeps the exact single-trio shape as before.
This is all still one schema-driven, dashboard-tracked create_extraction_graph
run: slicing changes only the fan-out, not the roles or the evidence contract.
You can override the automatic plan by passing an explicit slices list on a
source (page ranges, char ranges, or chapter titles), or tune the run with
slice_window / slice_overlap / slice_threshold.
The evidence contract: claim + quote¶
The unit of trust in this system is not the claim — it is the claim paired with
its quote. For every load-bearing claim (or finding), the scribe writes a
companion quote note and wires quote --supports--> claim. The quote is checked
verbatim against the source's cached fulltext at add time, so a claim can never
silently drift away from what the source actually said.

The unit of trust: each highlighted passage in the source becomes a verbatim quote, wired quote --supports--> claim. A claim with no quote (bottom) is what the auditor flags.
Why grounding is a hard edge, not a style guideline
A knowledge graph is only as useful as it is trustworthy. If claims and their
evidence are merely encouraged to travel together, they won't — and once a
corpus contains a mix of grounded and ungrounded claims that look identical,
the whole thing becomes un-auditable. Making the quote a first-class,
verbatim-checked note with a supports edge means "show me the source for
this" is always answerable, and the auditor can mechanically flag any claim
that lacks one. A schema can set grounded: false for goals with no quotable
source (pure cross-input synthesis), but that is an explicit opt-out, not the
default.
A second evidence channel: data-grounded claims¶
The claim + quote pair is one way to ground a claim, but not the only one. A
quote is checked verbatim against the source text — perfect for "the paper
says X," useless for "the mean of this column is 0.68." So a claim can instead be
grounded in a re-computable statistic over a stored dataset: the auditor
verifies it by re-running the computation and checking the result reproduces,
not by string-matching. This is the third grounding modality, alongside the two
that already exist:
- quote — a verbatim substring, verified against the source's cached fulltext.
- equation — a transcribed LaTeX formula, with an optional cropped page snapshot as visual ground-truth (PDF text extraction mangles math).
- data — a data-citation: a statistic over a
datasetnote, verified by recompute.
A data-grounded claim carries a grounding.method: data block naming the dataset
note, its pinned content hash, a deterministic row/column selection, the
derivation that computes the statistic, and the asserted value with a
tolerance. To verify, the auditor re-resolves that exact frozen frame, re-runs
the derivation, and checks the result lands within tolerance of value. A data
claim needs no supporting quote — its evidence is the citation.
Two ways to name a derivation — and why one travels further
A derivation is the computation that turns a dataset into a number. Angelo
supports two forms. The expr hatch is a whitelisted mini-evaluator
(mean:col, quantile:col:0.9, sum:col, …) that never calls eval — it
splits on : and dispatches to an allow-list of reducers. Because the
evaluator ships inside angelo, an expr claim re-verifies in any repo. A
registered derivation is host code contributed via register_derivation
(same trust boundary as a custom agent in agents.yaml) — this is where a
genuine model fit lives. A registered derivation only re-runs where its host
package is installed.
Epistemic status: grounded, measured, inferred¶
Grounding says whether a claim is backed; epistemic_status says what kind
of backing it is — a first-class, optional claim field independent of the
grounding method:
- grounded — asserted verbatim from a source (a verified quote or equation).
- measured — a directly-observed statistic over data (a mean, a quantile).
- inferred — a fitted quantity (an inverse-optimization or model fit).
The registrant declares the status; angelo does not assign meaning beyond
displaying it and rolling it up. The coverage matrix reports a per-dimension
epistemic mix — e.g. sizing: {grounded: 4, measured: 1, inferred: 1} — so
"is this dimension quoted, measured, or fitted?" is answerable from the ledger
rather than by reading every note. A descriptive expr derivation defaults to
measured; a derivation that declares fit: true is inferred.
The verify contract: hard violations vs soft unavailability¶
Re-computation has a subtlety a string match does not: a claim verified once in the repo where its derivation lives should not read as broken just because you're browsing a different repo where that derivation isn't installed. So verification splits its outcomes into two tiers:
- A hard violation is when angelo could re-run and it disagreed — the pinned
data changed under the claim (
dataset_hash_mismatch), the re-run missed the asserted value (value_not_reproduced), a stochastic derivation pinned no RNG seed (nondeterministic_unpinned), or the opaquevalidationblob was malformed. These block coverage exactly like an unverified quote, surfacing asunreproduced_data_claimsin the audit. - A soft unavailability is when angelo cannot re-run here — the derivation
isn't registered in this environment (
derivation_unavailable) or the dataset isn't resolvable (dataset_unavailable). These are informational, not defects: the claim keeps theverified_at/verified_instamp it earned where it was reproduced, so it stays trustworthy-by-record.
Angelo verifies and stores evidence — it never composes beliefs
This is the load-bearing boundary. A data-grounded claim is one evidence
contribution: a value, a confidence, an epistemic_status, and the pin
that reproduces it. Angelo re-runs the derivation, confirms the value, stamps
the provenance, and rolls up counts (coverage and the epistemic mix) across a
dimension — but it never interprets what a derivation means, never scores the
validation metric, and never combines several claims into a posterior. The
confidence / validation / method_meta fields are stored and displayed but
treated as opaque. Composition is the application's job: a consumer (say, a
genome estimator) reads these verified, confidence-scored cells and composes
them into whatever belief it needs. Keeping that line sharp is what lets the
generalist store stay domain-agnostic while still carrying quantitative
evidence.
The data trio¶
Data-grounded claims are produced by the same pipeline shape as prose, with two
data-channel roles. A source declared kind: data in create_extraction_graph
points at an existing dataset box rather than a document, skips ingest, and
builds a data-extractor → data-scribe → auditor trio. The data-extractor
(planner) reads the dataset and proposes data-citation candidates for the
schema's evidence: data dimensions; the data-scribe (implementer) commits
each as a method: data claim and attaches it to its dimension node through the
same server-owned attach seam the prose scribe uses; the auditor recomputes
every citation and reports the epistemic mix. Under one synthesis_label, data
and prose trios feed the same spine — a measured claim and a quoted claim sit
in the same dimension, distinguished only by their epistemic_status. See
Ground a claim in data for the concrete
recipe.
The scribe does not wire spine edges — the server does¶
For a spine-backed run, the scribe does not hand-build the cross-graph edge from
a claim to a spine dimension node. It calls
note(action="attach", tag=…, project=…, synthesis_graph=…) and the server
resolves the rest: which dimension node the tag maps to, the correct relation, and
the edge direction (keyed on the persisted attach_relation).
Why the server owns the attach
Spine membership is subtle: the direction of a spine-member edge, the exact
dimension node a tag resolves to, and the extraction context it belongs to
((project, synthesis_graph)) all have to agree, or a claim silently lands on
the wrong node — or the wrong project's spine entirely. Centralizing that logic
server-side means every writer (a scribe, a re-mining promotion, a manual
caller) gets the same, correct wiring, and a caller that omits the context is
rejected rather than guessing. The scribe's job is to say what this claim is
about; deciding where that attaches in the spine is the server's.
synthesis_status: from spine skeleton to materialized¶
A schema can declare a synthesis block, which makes prep pre-create the spine
empty — an apex node plus one node per dimension, each carrying
synthesis_status: scaffold and a placeholder "claims attach here" body
(Schemas can declare materialized structure (the synthesis spine); added a schema-generation tool). That empty, pre-created spine is the spine skeleton. The
scribes then attach every source's claims to those nodes, and the shipped
synthesizer adds the cross-cutting edges — but the nodes' own prose is written
by no shipped agent. So after the run, an orchestrator/human step reads each
dimension's rolled-up grounding, writes the node body, and flips
synthesis_status to materialized
(via note(action="update", replace_body=…, synthesis_status="materialized")).
This two-phase status is what makes "is this spine finished?" answerable without
forensics: schema(action="spines") reports each spine's incomplete list — the
apex/dimension nodes still scaffold — so a half-authored spine announces itself
instead of hiding behind body-length guesses.
Extract once, attach to many: synthesis_label¶
The most important knob after the schema is synthesis_label, because it
decides the granularity of the resulting spines — and getting it wrong by
omission is easy.
Multi-project extraction is extract-once: even when a source's claims should
feed several projects' spines, there is exactly one extractor → scribe → auditor
trio per source per schema. There is no re-reading of the document; the write
context is keyed on the (project, synthesis_graph) pair, so the same extracted
claims can be attached into multiple spines.
synthesis_label controls how those spines are minted. Each distinct label
creates one synthesis graph — one spine — and every source run under that label
rolls its claims into that one spine. The label defaults to the project name, so a
naive single run pools the whole corpus into one spine.
One spine per instance vs one pooled spine
Choose by the goal, on purpose — the same "decide the synthesis policy explicitly" discipline that applies to a spine's prose applies here.
- Comparison corpus (N comparable instances of the same kind — several
PMs, competing architectures, different models): give each instance its own
synthesis_labelso it gets its own spine, and group sources describing the same instance under one label. Because every instance's spine shares the schema's dimension column-keys, the spines auto-unite into a spine-group matrix — one row per instance, one column per dimension. This is exactly the comparison surface the schema's tags were chosen for. - Single-subject corpus (one thing, many views — one system's docs, one company's filings): a single pooled spine under one label is correct, because there is genuinely one thing to synthesize.
The only non-mechanical step is deciding which sources are the same instance; once labels are assigned the attach is deterministic.
What is authored by hand (for now)¶
The pipeline stops short of full automation on purpose. The shipped
synthesizer wires the cross-cutting edges (cross-spine placement, cross-source
links, _cross hubs), but authoring the apex/dimension spine prose is still an
orchestrator/human step — a schema's synthesis block gives cross-source
synthesis a materialized home and synthesis_status tracks progress, but the
apex/dimension prose is written by hand. Schemas also do not yet support
inheritance, and notes are not stamped with the schema version that produced them,
so a schema change does not auto-trigger a backfill. See
Spines for the spine these runs build and
Matrices for what they compare into.
Design notes¶
The decisions behind this, drawn as a slice of the memory tree.
- D Implemented grounded-extraction bundle via a generic coordinator capability-contribution seamactive
A grounded-extraction run applies a fixedextractor -> scribe -> auditor(+ trailingmemory) pipeline to N sources, parameterized by one named schema (a rubric). - D Schemas can declare materialized structure (the synthesis spine); added a schema-generation toolactive
Extended extraction schemas so a schema can declare its OWN materialized structure instead of producing a hub-and-spokes dandelion. - D Source-first ingestion for the zettelkasten agent (ingest_source + content-hash + full-text cache)active
Made every source enter the zettelkasten through one canonical front door so we always have (1) an anchored citation record and (2) a reproducible full-text reference. - D Path-scoped write coordination: per-path coordinator lease + per-box zettelkasten locksactive
Replaced the coordinator's global one-implementer-at-a-time write lease with fine-grained path-scoped coordination.
- R Angelo
- P agent-coordinatoractive
- P Phase 6: Path-scoped write coordination (no thrashing)active
- P zettelkastenactive
- P Phase 2: Literature Review Systemactive
- P Phase: Grounded Extraction Pipeline + Reusable Schemas (coordinator capability)active
- D Implemented grounded-extraction bundle via a generic coordinator capability-contribution seamactive
- D Schemas can declare materialized structure (the synthesis spine); added a schema-generation toolactive
- A Schema-authoring docs now require an explicit synthesis policy, not just an extraction policy
- P agent-coordinatoractive
Related¶
Semantically related entries from the memory graph.