Skip to main content

rto_graph/
context.rs

1//! Per-node **context bundles** with a dependency-aware cache.
2//!
3//! A node's *context* is the node plus its one-hop, provenance-labelled
4//! neighbourhood — the same shape as [`crate::query::explain`], but **cached**
5//! and **fingerprinted**. The fingerprint folds in the node's own content *and*
6//! every neighbour's content signature, so a change to the node or to any of its
7//! neighbours (callers, callees, referencing docs) moves the fingerprint and the
8//! cached entry is rebuilt on the next read. This is the codegraph-style
9//! "dirty-propagation" invalidation: because context reaches one hop out, a
10//! changed symbol invalidates exactly its dependents' cached context.
11//!
12//! The cache is content-addressed (the fingerprint *is* the validity check), so
13//! it needs no manual bookkeeping beyond pruning entries for deleted nodes. The
14//! bundle itself is cheap to rebuild today; the cache slot is the durable place a
15//! future, expensive per-node summary would live.
16
17use std::collections::BTreeSet;
18
19use serde::{Deserialize, Serialize};
20
21use crate::store::{Store, StoreError};
22use crate::{Edge, Node, SCHEMA};
23
24/// A compact node summary within a [`NodeContext`]. Owned and round-trippable so
25/// the whole bundle can be cached as JSON and read back.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct ContextNode {
28    /// Natural key.
29    pub key: String,
30    /// Kind token (e.g. `fn`, `adr`).
31    pub kind: String,
32    /// Human-facing name.
33    pub name: String,
34    /// Repository-relative path, if any.
35    pub path: Option<String>,
36    /// Language token, if any.
37    pub lang: Option<String>,
38}
39
40impl ContextNode {
41    fn from_node(node: &Node) -> Self {
42        Self {
43            key: node.key.clone(),
44            kind: node.kind.as_str().to_owned(),
45            name: node.name.clone(),
46            path: node.path.clone(),
47            lang: node.lang.clone(),
48        }
49    }
50}
51
52/// One incident edge as seen from the subject: the relationship, its provenance,
53/// and the node on the other end. Owned/round-trippable for caching.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct ContextEdge {
56    /// Edge kind token (e.g. `calls`, `references`).
57    pub kind: String,
58    /// How the edge was produced (`derived` | `authored` | `inferred`).
59    pub provenance: String,
60    /// Confidence score, present only for inferred edges.
61    pub confidence: Option<f64>,
62    /// The natural key of the node at the other end.
63    pub node: String,
64}
65
66/// A node together with its one-hop neighbourhood and a validity `fingerprint`.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct NodeContext {
69    /// Stable schema tag ([`crate::SCHEMA`]).
70    pub schema: String,
71    /// Fingerprint over the node's content and its neighbours' content; a change
72    /// to either moves it, invalidating a cached entry.
73    pub fingerprint: String,
74    /// The subject node.
75    pub node: ContextNode,
76    /// Structured metadata attached to the node.
77    pub meta: serde_json::Value,
78    /// Edges where the subject is the source.
79    pub outgoing: Vec<ContextEdge>,
80    /// Edges where the subject is the destination.
81    pub incoming: Vec<ContextEdge>,
82}
83
84/// Counts from a [`refresh_contexts`] pass.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86pub struct ContextRefresh {
87    /// Cached entries that were rebuilt because their fingerprint had changed
88    /// (the node or a neighbour changed).
89    pub rebuilt: usize,
90    /// Cached entries that were still fresh and reused as-is.
91    pub reused: usize,
92    /// Stale entries pruned because their node no longer exists.
93    pub pruned: usize,
94}
95
96/// FNV-1a (64-bit). Dependency-free and deterministic; used only to fold content
97/// signatures into a fingerprint, so it needs no cryptographic properties.
98fn fnv1a64(bytes: &[u8]) -> u64 {
99    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
100    for &b in bytes {
101        hash ^= u64::from(b);
102        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
103    }
104    hash
105}
106
107/// A content signature for a node: its git blob hash when present (whole-content
108/// identity), else a hash of its kind, name, and metadata (which includes any
109/// captured `content`). Any change to what the node *means* moves this value.
110fn node_signature(node: &Node) -> u64 {
111    if let Some(blob) = &node.blob_hash {
112        return fnv1a64(blob.as_bytes());
113    }
114    let mut s = String::new();
115    s.push_str(node.kind.as_str());
116    s.push('\u{0}');
117    s.push_str(&node.name);
118    s.push('\u{0}');
119    // serde_json serialises object keys in sorted order, so this is stable.
120    s.push_str(&serde_json::to_string(&node.meta).unwrap_or_default());
121    fnv1a64(s.as_bytes())
122}
123
124/// A canonical, sortable descriptor of one incident edge for the fingerprint:
125/// direction, kind, provenance, confidence, the neighbour's key, and the
126/// neighbour's content signature. The confidence is captured by its exact bit
127/// pattern, so any change to it — however small — moves the fingerprint.
128fn edge_descriptor(edge: &Edge, direction: &str, neighbour: &str, neighbour_sig: u64) -> String {
129    let confidence = edge
130        .confidence
131        .map_or_else(String::new, |c| format!("{:016x}", c.to_bits()));
132    format!(
133        "{direction}|{}|{}|{confidence}|{neighbour}|{neighbour_sig:016x}",
134        edge.kind.as_str(),
135        edge.provenance.as_str(),
136    )
137}
138
139/// Compute the fingerprint of `node`'s context: its own signature plus a sorted
140/// list of its incident edges' descriptors (each carrying the neighbour's
141/// signature). Deterministic for a given graph state.
142fn compute_fingerprint(store: &Store, node: &Node) -> Result<String, StoreError> {
143    let mut descriptors: Vec<String> = Vec::new();
144    for edge in store.edges_from(&node.key)? {
145        let sig = store.get_node(&edge.dst)?.map_or(0, |n| node_signature(&n));
146        descriptors.push(edge_descriptor(&edge, "out", &edge.dst, sig));
147    }
148    for edge in store.edges_to(&node.key)? {
149        let sig = store.get_node(&edge.src)?.map_or(0, |n| node_signature(&n));
150        descriptors.push(edge_descriptor(&edge, "in", &edge.src, sig));
151    }
152    // Sort so the fingerprint is independent of edge storage/query order.
153    descriptors.sort();
154    let mut buf = format!("ctx/v1|{:016x}", node_signature(node));
155    for d in &descriptors {
156        buf.push('\n');
157        buf.push_str(d);
158    }
159    Ok(format!("{:016x}", fnv1a64(buf.as_bytes())))
160}
161
162fn out_ref(edge: &Edge) -> ContextEdge {
163    ContextEdge {
164        kind: edge.kind.as_str().to_owned(),
165        provenance: edge.provenance.as_str().to_owned(),
166        confidence: edge.confidence,
167        node: edge.dst.clone(),
168    }
169}
170
171fn in_ref(edge: &Edge) -> ContextEdge {
172    ContextEdge {
173        kind: edge.kind.as_str().to_owned(),
174        provenance: edge.provenance.as_str().to_owned(),
175        confidence: edge.confidence,
176        node: edge.src.clone(),
177    }
178}
179
180fn sort_refs(refs: &mut [ContextEdge]) {
181    refs.sort_by(|a, b| (&a.kind, &a.node, &a.provenance).cmp(&(&b.kind, &b.node, &b.provenance)));
182}
183
184/// Assemble a fresh bundle for `node` from the current graph, with the given
185/// `fingerprint`. Shared by [`build_context`] and the cache-miss path.
186fn fresh_bundle(
187    store: &Store,
188    node: &Node,
189    fingerprint: String,
190) -> Result<NodeContext, StoreError> {
191    let mut outgoing: Vec<ContextEdge> = store.edges_from(&node.key)?.iter().map(out_ref).collect();
192    let mut incoming: Vec<ContextEdge> = store.edges_to(&node.key)?.iter().map(in_ref).collect();
193    sort_refs(&mut outgoing);
194    sort_refs(&mut incoming);
195    Ok(NodeContext {
196        schema: SCHEMA.to_owned(),
197        fingerprint,
198        node: ContextNode::from_node(node),
199        meta: node.meta.clone(),
200        outgoing,
201        incoming,
202    })
203}
204
205/// Build a node's context bundle from the current graph (ignoring the cache).
206/// Returns `None` if no node has that key.
207///
208/// # Errors
209/// Returns [`StoreError`] on query failure.
210pub fn build_context(store: &Store, key: &str) -> Result<Option<NodeContext>, StoreError> {
211    let Some(node) = store.get_node(key)? else {
212        return Ok(None);
213    };
214    let fingerprint = compute_fingerprint(store, &node)?;
215    Ok(Some(fresh_bundle(store, &node, fingerprint)?))
216}
217
218/// Fetch a node's context through the cache: return the cached bundle when its
219/// fingerprint still matches the current graph, otherwise rebuild it, store it,
220/// and return the fresh bundle. Returns `None` (and prunes any stale entry) if
221/// the node no longer exists.
222///
223/// # Errors
224/// Returns [`StoreError`] on query failure, or if a cached entry cannot be
225/// decoded.
226pub fn context(store: &Store, key: &str) -> Result<Option<NodeContext>, StoreError> {
227    let Some(node) = store.get_node(key)? else {
228        store.context_cache_delete(key)?;
229        return Ok(None);
230    };
231    let fingerprint = compute_fingerprint(store, &node)?;
232    if let Some((cached_fp, json)) = store.context_cache_get(key)?
233        && cached_fp == fingerprint
234    {
235        return Ok(Some(serde_json::from_str(&json)?));
236    }
237    // Miss or stale: rebuild from the current graph and cache it.
238    let bundle = fresh_bundle(store, &node, fingerprint.clone())?;
239    store.context_cache_put(key, &fingerprint, &serde_json::to_string(&bundle)?)?;
240    Ok(Some(bundle))
241}
242
243/// The set of nodes whose cached context a change to any of `changed` would
244/// invalidate: the changed nodes themselves plus their one-hop neighbours in
245/// either direction (a node's context reaches exactly one hop out). This makes
246/// the dependency-propagation contract explicit; [`refresh_contexts`] realises
247/// it via fingerprints.
248///
249/// # Errors
250/// Returns [`StoreError`] on query failure.
251pub fn dependents(store: &Store, changed: &[String]) -> Result<BTreeSet<String>, StoreError> {
252    let mut set = BTreeSet::new();
253    for key in changed {
254        // A changed node's own context is dirty, and so is each neighbour's
255        // (their context reaches one hop and includes this node). This holds even
256        // if the node was deleted — its former neighbours are still reachable via
257        // their edges to/from it.
258        set.insert(key.clone());
259        for edge in store.edges_from(key)? {
260            set.insert(edge.dst);
261        }
262        for edge in store.edges_to(key)? {
263            set.insert(edge.src);
264        }
265    }
266    Ok(set)
267}
268
269/// Refresh every cached context that has gone stale (its node or a neighbour
270/// changed) and prune entries whose node no longer exists. Only existing nodes
271/// that already have a cache entry are considered — this reconciles the cache
272/// with the current graph without eagerly materialising context for every node.
273///
274/// # Errors
275/// Returns [`StoreError`] on query failure or if a cached entry cannot be
276/// decoded.
277pub fn refresh_contexts(store: &Store) -> Result<ContextRefresh, StoreError> {
278    let mut out = ContextRefresh {
279        rebuilt: 0,
280        reused: 0,
281        pruned: 0,
282    };
283    for key in store.context_cache_keys()? {
284        let Some(node) = store.get_node(&key)? else {
285            store.context_cache_delete(&key)?;
286            out.pruned += 1;
287            continue;
288        };
289        let fingerprint = compute_fingerprint(store, &node)?;
290        // Only the fingerprint is needed to decide freshness — avoid reading the
291        // full cached JSON payload for entries that turn out to be fresh.
292        let fresh = store
293            .context_cache_fingerprint(&key)?
294            .is_some_and(|fp| fp == fingerprint);
295        if fresh {
296            out.reused += 1;
297        } else {
298            context(store, &key)?; // rebuilds and stores
299            out.rebuilt += 1;
300        }
301    }
302    Ok(out)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::{build_context, context, dependents, refresh_contexts};
308    use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
309
310    /// A store: doc --references--> caller --calls--> callee.
311    fn seeded() -> Store {
312        let mut store = Store::open_in_memory().expect("store");
313        let mut caller = Node::new("sym:rust:a.rs#caller", NodeKind::Fn, "caller");
314        caller.blob_hash = Some("BLOB_A".to_owned());
315        let mut target = Node::new("sym:rust:a.rs#callee", NodeKind::Fn, "callee");
316        target.blob_hash = Some("BLOB_A".to_owned());
317        let mut doc = Node::new("file:docs/g.md", NodeKind::Doc, "g.md");
318        doc.blob_hash = Some("BLOB_DOC".to_owned());
319        let facts = FactSet::new()
320            .with_node(caller)
321            .with_node(target)
322            .with_node(doc)
323            .with_edge(Edge::derived(
324                "sym:rust:a.rs#caller",
325                "sym:rust:a.rs#callee",
326                EdgeKind::Calls,
327            ))
328            .with_edge(Edge::authored(
329                "file:docs/g.md",
330                "sym:rust:a.rs#caller",
331                EdgeKind::References,
332            ));
333        store.apply_factset(&facts).expect("apply");
334        store
335    }
336
337    #[test]
338    fn context_is_cached_then_served_from_cache() {
339        let store = seeded();
340        // First read is a miss: it populates the cache.
341        let first = context(&store, "sym:rust:a.rs#caller")
342            .expect("ctx")
343            .expect("present");
344        assert_eq!(first.node.key, "sym:rust:a.rs#caller");
345        assert_eq!(first.outgoing.len(), 1, "calls callee");
346        assert_eq!(first.incoming.len(), 1, "referenced by doc");
347        assert_eq!(
348            store
349                .context_cache_get("sym:rust:a.rs#caller")
350                .expect("get")
351                .expect("cached")
352                .0,
353            first.fingerprint,
354        );
355        // Second read returns the identical bundle from the cache.
356        let second = context(&store, "sym:rust:a.rs#caller")
357            .expect("ctx")
358            .expect("present");
359        assert_eq!(first, second);
360    }
361
362    #[test]
363    fn changing_a_dependency_invalidates_dependent_context() {
364        let store = seeded();
365        // Warm the cache for all three nodes.
366        let before = refresh_first_read(&store);
367        assert_eq!(before.rebuilt, 0, "warming reads are misses, not refreshes");
368
369        // The caller's cached fingerprint before the change.
370        let caller_fp_before = context(&store, "sym:rust:a.rs#caller")
371            .expect("ctx")
372            .expect("present")
373            .fingerprint;
374
375        // Change the *callee*'s content (new blob). The caller depends on it.
376        let mut target = store
377            .get_node("sym:rust:a.rs#callee")
378            .expect("get")
379            .expect("present");
380        target.blob_hash = Some("BLOB_A2".to_owned());
381        store.upsert_node(&target).expect("upsert");
382
383        // `dependents` names exactly who should be dirtied: the callee and its
384        // neighbour, the caller.
385        let deps = dependents(&store, &["sym:rust:a.rs#callee".to_owned()]).expect("deps");
386        assert!(deps.contains("sym:rust:a.rs#caller"));
387
388        // The caller's fingerprint has moved (its neighbour changed), so a fresh
389        // read rebuilds it — its cached context is invalidated.
390        let caller_fp_after = context(&store, "sym:rust:a.rs#caller")
391            .expect("ctx")
392            .expect("present")
393            .fingerprint;
394        assert_ne!(
395            caller_fp_before, caller_fp_after,
396            "dependent context must be invalidated when a dependency changes",
397        );
398
399        // The unrelated doc did not change and is not a dependent of the callee,
400        // so a refresh reuses it while rebuilding the affected nodes.
401        let report = refresh_contexts(&store).expect("refresh");
402        assert!(report.rebuilt >= 1, "affected contexts rebuilt");
403        assert_eq!(report.pruned, 0);
404    }
405
406    /// Read context for every node once, warming the cache; returns a refresh
407    /// report taken immediately after (which should show everything fresh).
408    fn refresh_first_read(store: &Store) -> super::ContextRefresh {
409        for key in store.all_keys().expect("keys") {
410            context(store, &key).expect("ctx");
411        }
412        refresh_contexts(store).expect("refresh")
413    }
414
415    #[test]
416    fn deleted_node_context_is_pruned() {
417        let mut store = seeded();
418        context(&store, "file:docs/g.md")
419            .expect("ctx")
420            .expect("present");
421        assert!(
422            store
423                .context_cache_get("file:docs/g.md")
424                .expect("get")
425                .is_some()
426        );
427        // Rebuild the graph without the doc node.
428        let mut caller = Node::new("sym:rust:a.rs#caller", NodeKind::Fn, "caller");
429        caller.blob_hash = Some("BLOB_A".to_owned());
430        store
431            .rebuild(&FactSet::new().with_node(caller), None)
432            .expect("rebuild");
433        // The cache entry survives rebuild but is pruned on refresh; a direct read
434        // also returns None and clears it.
435        assert!(context(&store, "file:docs/g.md").expect("ctx").is_none());
436        assert!(
437            store
438                .context_cache_get("file:docs/g.md")
439                .expect("get")
440                .is_none()
441        );
442    }
443
444    #[test]
445    fn build_context_matches_cached() {
446        let store = seeded();
447        let built = build_context(&store, "sym:rust:a.rs#caller")
448            .expect("build")
449            .expect("present");
450        let cached = context(&store, "sym:rust:a.rs#caller")
451            .expect("ctx")
452            .expect("present");
453        assert_eq!(built, cached);
454        assert!(
455            build_context(&store, "sym:rust:a.rs#ghost")
456                .expect("b")
457                .is_none()
458        );
459    }
460}