Skip to content

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.

Grounded extraction: a source yields verbatim quotes, each supporting a claim; an auditor passes grounded claims and flags an unquoted one; grounded claims attach to the spine

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.

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 ingest + seed hub note · runs before the graph extractor planner (parallel) · emits claim+quote candidates scribe implementer (serialized) · writes claim + quote notes auditor checker (different model) · grounding + coverage synthesizer implementer (once, last) · cross-spine + cross-source links memory record the run
One fixed pipeline per source. prep runs synchronously before the graph; the extractor reads in parallel, the scribe writes serialized, the auditor checks with a different model, the synthesizer wires cross-cutting edges once at the end, and memory records the run.
  • 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_graph does 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 + quote note 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 _cross synthesis 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>.txt cache (plus a <content_hash>.pages.json sidecar 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=…) or page_start/page_end) returns just that slice and reports total_chars/sliced so 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 → scribe pair (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.

A source document's highlighted passages each become a verbatim quote wired by a supports edge to its claim

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 dataset note, 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 opaque validation blob was malformed. These block coverage exactly like an unverified quote, surfacing as unreproduced_data_claims in 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 the verified_at / verified_in stamp 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_label so 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.

Semantically related entries from the memory graph.