Turn prose into candidate relational facts, entirely local. No LLM, no network. Entities, relations, confidence scores and spans, for Rust and Node.
Install · What It Extracts · API · Guides · Contributing
narrative-graph reads prose and returns candidate (subject, relation, object) facts with a confidence score and the exact source span each one came from. No API key, no network call, no model download. It stores nothing and queries nothing — it's a producer you point at any store, or at no store at all.
The default build has zero dependencies. cargo tree prints the crate and nothing else; the extraction core compiles against the standard library alone, and CI fails if that ever stops being true. Serde, TypeScript bindings, the Node addon, and the CLI are each opt-in features that serve a boundary, never the core.
Input:
Elena is Marco's sister. Marco mentors Dev, who works at the Archive.Output (measured,
cargo run --features cli -- extract -):dev --works_at--> archive 0.75 [39..68] elena --sister_of--> marco 0.85 [0..23] marco --mentors--> dev 0.78 [25..42]
Works with Rust 1.77+ and Node.js 18+, on macOS, Linux, and Windows. Pairs naturally with holographic-memory's Meaning Memory — memorizeTriplet / relate_phase expect exactly this shape — but has no dependency on it and no opinion on what you do with a candidate.
npm install narrative-graph[dependencies]
narrative-graph = "0.1"npm install writerslogic/narrative-graph| Feature | Adds | Dependencies |
|---|---|---|
| (default) | The heuristic extraction pipeline | none |
serde |
Serialize/Deserialize on the public types |
serde |
json |
JSON output | serde, serde_json |
bindings |
TypeScript declarations via ts-rs |
serde, ts-rs |
node-api |
The Node addon | napi, napi-derive, napi-build |
cli |
The narrative-graph binary |
clap, anyhow |
Segmentation is hand-rolled rather than a naive split on ./!/?, because narrative prose is made of exactly the constructs a naive splitter breaks on: honorifics (Dr. Smith went home.), initials (J. R. R. Tolkien), ellipses (She paused... then left.), and quoted dialogue with attribution ("Who are you?" she asked. is one sentence, not two). Sentences are returned as borrowed slices of the input, so segmentation allocates nothing per sentence.
Capitalized-token detection, pronoun-antecedent linking within and across sentences, and an optional caller-supplied alias list so "Marcus" and "Mr. Marcus Hale" resolve to the same entity when you already know that. A title leading a name is dropped from the identity but kept in the surface form, so "Lady Catherine de Bourgh" and "Catherine de Bourgh" already unify without an alias.
You supply:
{ aliases: { "Marcus": "marcus_hale", "Mr Marcus Hale": "marcus_hale" } }Result: either mention resolves tomarcus_halein every candidate.Keys match the mention's surface form exactly, and
.is a separator, so the key isMr Marcus Hale.A wholly lowercase key is a lexicon entry instead: the phrase is searched for in the text as a literal, word-bounded, case-insensitive match and becomes a mention of the canonical name, which is how a phrase no capitalized-run detector can see reaches a referent.
{ aliases: { "the detective": "marcus" } }turns "The detective is Ms. Chen's brother." intomarcus brother_of ms_chen. A lexicon match overlapping a mention already detected is dropped, so a key adds a mention and never replaces one: with"detective": "marcus", "Detective Marcus" staysdetective_marcus. A lexicon key is therefore for a phrase carrying no capitalized name — a key like"ms. chen"spans one, and the runMs Chenis already a mention, so the key never fires. A pronoun can take a lexicon mention as its referent, so "The detective said he was Ms. Chen's brother." reachesmarcusrather than the sentence-openingthe; a pronoun the mention itself covers is replaced by it.
Verb-phrase heuristics between two entity candidates in the same clause — familial, social, professional, and spatial relations ("sister of," "mentors," "works at," "located in"). Surface forms normalize to a small controlled vocabulary (sister_of, mentors, works_at, ...) rather than passing through raw verb phrases, so "trains," "is the mentor of," and "mentors" all collapse to the same relation type. Supply your own ontology map to override or extend the defaults.
Every candidate reports the rule that produced it, not just a score — possessive-sister-pattern, verb-mentor-pattern, verb-works-at-pattern, relative-mentor-pattern, relative-works-at-pattern. A confidence number alone tells you nothing about why the pipeline believes something; the rule name does.
Every candidate carries a confidence score from the extraction rule's pattern strength and how close the subject and object are in the source text, plus the byte offset of its extracted span — a range covering the subject, the object, and the token that licensed the relation. Gate on a threshold, route low-confidence candidates to human review, or fetch source context for display.
Rust -- core extraction and types
| Item | What it does |
|---|---|
extract_candidate_triples(text, &Options) |
Run the heuristic pipeline over a passage |
Options { aliases, min_confidence, rejections, ontology } |
Alias map, confidence floor, rejected triples, relation-vocabulary overrides |
TripleCandidate { subject, relation, object, confidence, span, rule } |
One candidate fact; span is a byte range into the input, rule names the pattern that produced it |
extract_aggregates(text, &Options) |
Same pipeline, collapsed per fact instead of per sentence: one entry per triple, ordered by where the document first states it |
AggregateTriple { subject, relation, object, polarity, confidence, spans, rules } |
One fact with every span that stated it; confidence is the best of them, and repetition does not raise it |
find_conflicts(text, &Options) |
Claims a passage makes that cannot both be true: a fact and its denial, or two subjects where the relation admits one |
Conflict { kind, left, right } |
Both sides with their evidence; the crate does not decide which one the manuscript meant |
CLI -- extract from a file without embedding the library
narrative-graph extract chapter.txt --min-confidence 0.6 --format jsonInstalled alongside the crate (cargo install narrative-graph --features cli provides the narrative-graph binary), the same way holographic-memory ships hms-admin and hms-eval as bins in its own crate rather than separate packages. Useful for shell pipelines, CI checks, and inspecting output before wiring the library into an application.
Node.js -- extraction and types
| Function | What it does |
|---|---|
extractCandidateTriplesNapi(text, opts?) |
Run the heuristic pipeline over a passage |
opts accepts aliases, minConfidence, rejections, and ontology. Full TypeScript definitions ship in the package (index.d.ts), generated from the Rust types via ts-rs — treat them as the source of truth over this table.
- Architecture -- the extraction pipeline and confidence scoring
- Integration with holographic-memory -- extracting, thresholding, and feeding a Meaning Memory store
- Evaluation -- what is measured, what is not, and the recall and precision numbers on real prose
- Contributing -- development setup and conventions
- Rust 1.77+ or Node.js 18+
- No API key, no network access, no external service of any kind
git clone https://github.com/writerslogic/narrative-graph.git
cd narrative-graph
cargo test # segmentation + heuristic pipeline
cargo test --all-features # adds the ts-rs binding-export tests
cargo tree # should print this crate and nothing else
npm install && npm test # node --test tests/node
npm run test:types # strict TS check against generated bindingsTypeScript definitions in bindings/ are generated from the Rust types via ts-rs — don't hand-edit them.
| Hand-written regex | General-purpose NLP toolkit | LLM-based extraction | narrative-graph | |
|---|---|---|---|---|
| Runs offline, no API key | yes | yes | no | yes |
| Tuned for narrative prose (dialogue attribution, familial/social relations) | you build it | no | depends on prompt | yes |
| Confidence score per candidate | no | model-dependent | inconsistent across calls | yes |
| Source span on every candidate | you build it | sometimes | rarely | yes |
| Cost per extraction | free, but brittle | free, general-purpose | API cost + latency | free, microsecond-scale |
| Output shape ready for a relational store | you build it | you build it | you parse it | yes -- matches memorizeTriplet/relate_phase |
narrative-graph isn't trying to out-perform an LLM at open-domain relation extraction — it's trying to be the thing you reach for when a manuscript's characters and relationships need to become structured facts, offline, cheaply, and repeatably, with enough provenance on each candidate that a human or a downstream store can decide what to trust.
We welcome contributions of all sizes. Check the issue tracker for good first issue labels, or see CONTRIBUTING.md for development setup.
The sentence-level pipeline, whole-document aggregation, cross-sentence pronoun linking, and contradiction detection are all in place; the design discussion behind each is preserved in the closed enhancement issues. New relation forms and language packs follow the same path: open an issue stating the prose pattern and how the result will be measured, then a patch.
Areas where help is especially welcome:
- Additional relation-verb patterns for non-English narrative conventions
- Integration examples for stores other than
holographic-memory
Found a vulnerability? Please report it privately — see SECURITY.md.
Apache-2.0 © WritersLogic, Inc.