graph-r
An embedded, persistent knowledge graph that acts as the durable backend for link-r: link-r acquires and ranks (crawl, extract, hybrid search over a possibly in-memory index); graph-r remembers and serves (historical graph, adaptive freshness, token-budgeted local lookups). The two share one foreign key — the 64-bit xxh3 of a canonical URL — so a link-r index can be absorbed and then discarded while every lookup keeps resolving.
The point: answer "where is the resource that explains X?" locally, with a URL and a heading anchor, inside a token budget — instead of re-crawling, re-searching, or shipping page bodies to a model on every question.
Division of responsibilities
| link-r (acquire & rank) | graph-r (remember & serve) | |
|---|---|---|
| Creates | Fetches pages, extracts, builds the searchable index | Graph nodes (docs + segments), typed edges, ranks, freshness records |
| Updates | Executes crawls & conditional refreshes (network) | Decides what/when to refresh (adaptive TTL due-lists); absorbs deltas tier-scoped |
| Deletes | Evicts documents from its index | Tombstones history; prunes at compaction |
| Lookup | Hybrid dense+BM25 search over full text | Query → URL + anchor refs, related, lineage walks — zero network |
Quick start (with link-r)
= "0.5"
= { = "0.4", = ["graph"] } # the crawl → graph bridge
# async
Without link-r, graph-r is a standalone embedded graph store: feed it
DocRecord/SegmentRecords from any source through Store::writer().
One knowledge base: graph-r + link-r on object storage (link-r feature kb)
use ;
let gcs = new?;
// Writer (one instance, lease-fenced): conditional crawl -> bridge -> publish.
let mut kb = writer.await?;
kb.sync_crawl.await?;
kb.refresh_due.await?; // adaptive freshness
// Replicas (any number, no disk): hydrate in place, refresh cheaply, look up locally.
let mut replica = replica.await?.expect;
replica.refresh.await?;
for hit in replica.lookup.await?
Measured on a live bucket: replica hydration ~80 ms, unchanged refresh ~27 ms (two concurrent metadata requests), fused lookup ~0.2 ms. At 1 000 pages in memory: hydration 1 ms, fused lookup 0.6 ms, graph query 0.09 ms.
Durable on object storage (objstore-r feature mirror)
objstore-r's mirror module keeps
a store directory in sync with Google Cloud Storage (or a local directory, or
memory). graph-r itself is untouched: the mirror moves
its files byte for byte, so a stateless instance can keep the store on tmpfs
and still lose nothing.
use Mirror;
let objects = new?;
let mirror = new?;
mirror.pull_into.await?; // rebuild graph.base + graph.wal (verified)
let store = open?; // the unchanged API
mirror.acquire_lease.await?;
// ... writer.commit() as usual ...
mirror.publish.await?; // ships only the new WAL frames
Remote layout: HEAD (a checksummed manifest, compare-and-swap only),
base/{generation}.base, and wal/{generation}/{start}-{end}.seg. A second
writer publishing from the same state is fenced; replicas follow with
refresh_into (one metadata request when nothing changed). For flat commit
latency, Store::set_compact_inline(false) plus Store::compaction_due()
move compaction off the commit path.
What's inside
- Storage: two files.
graph.base— an immutable, mmapped, checksummed, sectioned snapshot (fixed-width sorted node records, CSR edges, label heap, lexicon + delta-varint postings, ranks) read zero-copy;graph.wal— an append-only op log with per-frame checksums, a torn tail truncated by the next committer. Compaction folds both into a new byte-reproducible base via atomic rename. - Concurrency: any number of snapshot readers and any number of
writers, in one process or many. Opening takes no lock; each commit takes
a cross-process
flockonly around its append, catches up with other writers' frames first, and gives up withError::LockedpastConfig::lock_wait(or at once, viaStore::try_transact).Store::transactholds the lock from its read view to its single append, so a read-modify-write is serializable across processes. Snapshots are borrow guards over a pinned generation — readers never block on commits or on the lock, and compaction swaps generations under them. NoArc, no async runtime, oneunsafeisland (mmap). - Freshness: every document carries an adaptive revalidation interval —
grown 1.5× per unchanged check, cut 4× on change, clamped, biased by
importance — and
due()emits the work list, with stored ETags for conditional revalidation. - Structure: deterministic PageRank at compaction, fused into lookups.
- Lookups: IDF-weighted tiered lexical scoring (exact/prefix/substring),
per-term seed guarantees, hub-refusing bounded BFS, importance fusion, and
seeds-first rendering that stops inside a token budget. Results are URLs +
segment anchors (
byte_rangewhen known) — never content. - Segments: sub-document anchors (heading path, depth, byte range, importance) keyed stably by heading, so a re-crawl keeps their identity.
- Lineage: containment, value derivation (exact copies flagged) and row
conditions as typed edges with reverse adjacency; one allocation-free
walkup, down or both ways; large per-node attributes (SQL text, types, provenance), numbered or named.
Features
| Feature | Adds | Default |
|---|---|---|
| (none) | The shared core: xxh3, keys, byte codecs, container, files, text rules | |
mmap |
Zero-copy images: memory-mapped files or bytes in place | |
store |
The embedded graph store, lookups, lineage walks, TTL engine | ✓ |
The link-r ingest path is link-r's graph feature, object-store publishing
is objstore-r's mirror feature, and both together are link-r's kb
feature: graph-r's core depends on neither, so the four crates form no
cycle.
Future document types (pdf, xml, …) arrive as feature-gated link-r extractors
and flow through link-r's bridge unchanged — the DocRecord/SegmentRecord seam
is the stable extension point. No harness coupling anywhere: this is a plain
Rust library, embeddable in any runtime.
Testing
cargo test (unit + hermetic lifecycle), cargo test --test proptest_model (model-based agreement through compaction/reopen,
byte-reproducibility, torn-WAL recovery), cargo test --test concurrency
(8 readers under a compacting writer), cargo bench (criterion, including
read-latency-under-write), cargo +nightly fuzz run <target> (every decoder:
arbitrary bytes must produce a typed error, never a panic or an
over-allocation).
Python
The same loop, one class, from PyPI (pip install uu-graph-r, then
import graph_r; Python >= 3.12, one abi3 wheel per platform):
=
# crawl -> absorb -> compact
sync seeds a fresh in-memory link-r index from the graph's stored
validators, so a re-sync of an unchanged site transfers zero bodies — from a
cold start included. The index is discarded when the call returns; bodies and
vectors never outlive it. Safe from any thread (asyncio.to_thread works);
blocking calls detach from the interpreter. See bindings/python/graph_r.pyi
for the full surface (add/touch for non-crawl producers, due, related,
pin, remove, compact).
License
Dual-licensed:
- AGPL-3.0-or-later for open-source use. See LICENSE.
- Commercial, for use in proprietary or closed-source software without the AGPL's copyleft requirements. Contact spicyzhug@gmail.com.
Copyright (c) 2024-2026 Lituus-io. All rights reserved.