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/// The largest number of edges a [`tool_context`] bundle carries **per
270/// direction**.
271///
272/// This surface has no `limit` parameter — a context bundle is one node's
273/// neighbourhood, so the only honest argument is the node key — which means the
274/// bound is fixed here rather than negotiated per call, and the tool's
275/// description states this number because a model reads that even when it does
276/// not read a schema.
277///
278/// Why a bound exists at all: `context` on a large file node is the biggest
279/// answer this graph can produce for a single key. In this repository
280/// `file:crates/roteiro/src/main.rs` returns 269 outgoing edges — 44,556 bytes of
281/// pretty JSON, roughly 11k tokens — of which 244 are `defines`. The node's own
282/// `meta` is already bounded (extraction caps captured content), so the edge
283/// lists are the whole of the variance, and capping them is the whole of the fix.
284///
285/// 50 per direction holds the worst case near 18 KB while leaving every ADR,
286/// every symbol and every ordinary file untouched — in this repository only file
287/// nodes reach it at all.
288pub const TOOL_CONTEXT_EDGE_CAP: usize = 50;
289
290/// How many edges of one kind were left out of a truncated direction.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub struct OmittedEdges {
293    /// Edge kind token (e.g. `defines`).
294    pub kind: String,
295    /// How many edges of that kind the bundle does not carry.
296    pub omitted: usize,
297}
298
299/// One direction of a bounded bundle: the edges kept, and an exact account of
300/// what was dropped to fit.
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct BoundedEdges {
303    /// How many edges this node has in this direction, before the cap.
304    pub total: usize,
305    /// Whether `edges` is a subset of them.
306    pub truncated: bool,
307    /// What was dropped, by edge kind. Empty when `truncated` is false.
308    ///
309    /// This is the difference between a bounded answer and a misleading one: a
310    /// model that asks what `main.rs` imports and is handed 50 of its 269 edges
311    /// must be able to see that 23 `imports` edges exist, rather than conclude
312    /// from their absence that there are none.
313    pub omitted: Vec<OmittedEdges>,
314    /// The edges carried, in the same order [`context`] and [`explain`](crate::explain)
315    /// produce them.
316    pub edges: Vec<ContextEdge>,
317}
318
319/// A node's context bundle, bounded for a model-facing tool surface.
320///
321/// The same shape as [`NodeContext`] with each edge list replaced by a
322/// [`BoundedEdges`] that says how much of it you are looking at.
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
324pub struct ToolContext {
325    /// Stable schema tag ([`SCHEMA`]).
326    pub schema: String,
327    /// Fingerprint over the node's content and its neighbours' content.
328    pub fingerprint: String,
329    /// The cap applied to each direction ([`TOOL_CONTEXT_EDGE_CAP`]).
330    pub edge_cap: usize,
331    /// Whether either direction was truncated — the one field a caller has to
332    /// read to know the bundle is partial.
333    pub truncated: bool,
334    /// The subject node.
335    pub node: ContextNode,
336    /// Structured metadata attached to the node.
337    pub meta: serde_json::Value,
338    /// Edges where the subject is the source.
339    pub outgoing: BoundedEdges,
340    /// Edges where the subject is the destination.
341    pub incoming: BoundedEdges,
342}
343
344/// Keep at most `cap` of `refs`, drawing **round-robin across edge kinds** so
345/// every kind present survives, and account for the rest.
346///
347/// Plain truncation of the sorted list would be simpler and worse. The sort is
348/// by `(kind, node, provenance)`, so on `file:…/main.rs` — `contains` 1,
349/// `defines` 244, `imports` 23, `references` 1 — the first 50 are one `contains`
350/// and 49 `defines`, and the bundle silently contains no `imports` or
351/// `references` edge at all. Round-robin gives the small kinds their edges back;
352/// `omitted` then says exactly how much of each large one is missing.
353fn bound_edges(refs: Vec<ContextEdge>, cap: usize) -> BoundedEdges {
354    let total = refs.len();
355    if total <= cap {
356        return BoundedEdges {
357            total,
358            truncated: false,
359            omitted: Vec::new(),
360            edges: refs,
361        };
362    }
363    // `refs` is sorted by kind, so equal kinds are already adjacent: walk it once
364    // into per-kind queues, then deal one from each in turn until the cap is met.
365    let mut by_kind: Vec<(String, Vec<ContextEdge>)> = Vec::new();
366    for r in refs {
367        match by_kind.last_mut() {
368            Some((kind, group)) if *kind == r.kind => group.push(r),
369            _ => by_kind.push((r.kind.clone(), vec![r])),
370        }
371    }
372    let mut kept: Vec<ContextEdge> = Vec::with_capacity(cap);
373    let mut round = 0usize;
374    while kept.len() < cap {
375        let mut dealt = false;
376        for (_, group) in &by_kind {
377            if kept.len() == cap {
378                break;
379            }
380            if round < group.len() {
381                kept.push(group[round].clone());
382                dealt = true;
383            }
384        }
385        // Every queue is exhausted — impossible while `total > cap`, but a `while`
386        // that can only end on a counter is a hang waiting for a future change.
387        if !dealt {
388            break;
389        }
390        round += 1;
391    }
392    let omitted: Vec<OmittedEdges> = by_kind
393        .iter()
394        .filter_map(|(kind, group)| {
395            let carried = kept.iter().filter(|e| e.kind == *kind).count();
396            (group.len() > carried).then(|| OmittedEdges {
397                kind: kind.clone(),
398                omitted: group.len() - carried,
399            })
400        })
401        .collect();
402    // Restore the canonical order, so a bounded bundle reads like a whole one.
403    sort_refs(&mut kept);
404    BoundedEdges {
405        total,
406        truncated: true,
407        omitted,
408        edges: kept,
409    }
410}
411
412/// A node's context bundle for a model-facing tool surface: **read-only** and
413/// **bounded**. Returns `None` if no node has that key.
414///
415/// # Read-only, deliberately
416///
417/// This builds on [`build_context`] and not on [`context`]. `context` is the
418/// cached read, and the cache is not free of side effects: a hit writes nothing
419/// but a miss calls `context_cache_put`, and a key whose node has been deleted
420/// calls `context_cache_delete` — a prune. Pruning is the *maintenance* half of
421/// `roteiro context --refresh`, which exists so that ordinary reads never mutate
422/// the store (ADR-0013), and a tool surface is the last place it should reappear.
423/// `build_context` assembles the identical bundle from the live graph and touches
424/// nothing, which is the whole difference and the reason for the choice.
425///
426/// # Bounded, and it says so
427///
428/// Each direction is capped at [`TOOL_CONTEXT_EDGE_CAP`] and the result carries
429/// `truncated`, each direction's `total`, and per-kind `omitted` counts. A
430/// shortened bundle that did not say it was shortened would be the same defect as
431/// a `limit` that silently returns nothing (issue #393): an answer a caller
432/// cannot tell from a complete one.
433///
434/// # Errors
435/// Returns [`StoreError`] on query failure.
436pub fn tool_context(store: &Store, key: &str) -> Result<Option<ToolContext>, StoreError> {
437    let Some(bundle) = build_context(store, key)? else {
438        return Ok(None);
439    };
440    let outgoing = bound_edges(bundle.outgoing, TOOL_CONTEXT_EDGE_CAP);
441    let incoming = bound_edges(bundle.incoming, TOOL_CONTEXT_EDGE_CAP);
442    Ok(Some(ToolContext {
443        schema: bundle.schema,
444        fingerprint: bundle.fingerprint,
445        edge_cap: TOOL_CONTEXT_EDGE_CAP,
446        truncated: outgoing.truncated || incoming.truncated,
447        node: bundle.node,
448        meta: bundle.meta,
449        outgoing,
450        incoming,
451    }))
452}
453
454/// Refresh every cached context that has gone stale (its node or a neighbour
455/// changed) and prune entries whose node no longer exists. Only existing nodes
456/// that already have a cache entry are considered — this reconciles the cache
457/// with the current graph without eagerly materialising context for every node.
458///
459/// # Errors
460/// Returns [`StoreError`] on query failure or if a cached entry cannot be
461/// decoded.
462pub fn refresh_contexts(store: &Store) -> Result<ContextRefresh, StoreError> {
463    let mut out = ContextRefresh {
464        rebuilt: 0,
465        reused: 0,
466        pruned: 0,
467    };
468    for key in store.context_cache_keys()? {
469        let Some(node) = store.get_node(&key)? else {
470            store.context_cache_delete(&key)?;
471            out.pruned += 1;
472            continue;
473        };
474        let fingerprint = compute_fingerprint(store, &node)?;
475        // Only the fingerprint is needed to decide freshness — avoid reading the
476        // full cached JSON payload for entries that turn out to be fresh.
477        let fresh = store
478            .context_cache_fingerprint(&key)?
479            .is_some_and(|fp| fp == fingerprint);
480        if fresh {
481            out.reused += 1;
482        } else {
483            context(store, &key)?; // rebuilds and stores
484            out.rebuilt += 1;
485        }
486    }
487    Ok(out)
488}
489
490#[cfg(test)]
491mod tests {
492    use super::{
493        TOOL_CONTEXT_EDGE_CAP, build_context, context, dependents, refresh_contexts, tool_context,
494    };
495    use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
496
497    /// A store: doc --references--> caller --calls--> callee.
498    fn seeded() -> Store {
499        let mut store = Store::open_in_memory().expect("store");
500        let mut caller = Node::new("sym:rust:a.rs#caller", NodeKind::Fn, "caller");
501        caller.blob_hash = Some("BLOB_A".to_owned());
502        let mut target = Node::new("sym:rust:a.rs#callee", NodeKind::Fn, "callee");
503        target.blob_hash = Some("BLOB_A".to_owned());
504        let mut doc = Node::new("file:docs/g.md", NodeKind::Doc, "g.md");
505        doc.blob_hash = Some("BLOB_DOC".to_owned());
506        let facts = FactSet::new()
507            .with_node(caller)
508            .with_node(target)
509            .with_node(doc)
510            .with_edge(Edge::derived(
511                "sym:rust:a.rs#caller",
512                "sym:rust:a.rs#callee",
513                EdgeKind::Calls,
514            ))
515            .with_edge(Edge::authored(
516                "file:docs/g.md",
517                "sym:rust:a.rs#caller",
518                EdgeKind::References,
519            ));
520        store.apply_factset(&facts).expect("apply");
521        store
522    }
523
524    /// A store with `count` symbols of each of three edge kinds hanging off one
525    /// file node — the shape a large source file has, at a size that trips the cap.
526    fn wide(count: usize) -> Store {
527        let mut store = Store::open_in_memory().expect("store");
528        let mut facts =
529            FactSet::new().with_node(Node::new("file:wide.rs", NodeKind::File, "wide.rs"));
530        // Deliberately lopsided, as a real file is: many `defines`, few of the
531        // rest. Sorted order puts every `defines` before every `imports`, which is
532        // what plain truncation would drop whole.
533        for (kind, n) in [
534            (EdgeKind::Defines, count),
535            (EdgeKind::Imports, 3),
536            (EdgeKind::References, 2),
537        ] {
538            for i in 0..n {
539                let key = format!("sym:rust:wide.rs#{}{i:04}", kind.as_str());
540                facts = facts
541                    .with_node(Node::new(key.clone(), NodeKind::Fn, "s"))
542                    .with_edge(Edge::derived("file:wide.rs", key, kind.clone()));
543            }
544        }
545        store.apply_factset(&facts).expect("apply");
546        store
547    }
548
549    #[test]
550    fn a_small_bundle_is_carried_whole_and_says_it_was_not_truncated() {
551        let store = seeded();
552        let out = tool_context(&store, "sym:rust:a.rs#caller")
553            .expect("ctx")
554            .expect("present");
555        assert!(!out.truncated);
556        assert_eq!(out.edge_cap, TOOL_CONTEXT_EDGE_CAP);
557        assert_eq!(out.outgoing.total, 1);
558        assert!(!out.outgoing.truncated);
559        assert!(out.outgoing.omitted.is_empty());
560        assert_eq!(out.outgoing.edges.len(), 1);
561        assert_eq!(out.incoming.total, 1);
562        assert_eq!(out.incoming.edges.len(), 1);
563        // Identical to the unbounded bundle it wraps, edge for edge.
564        let whole = build_context(&store, "sym:rust:a.rs#caller")
565            .expect("ctx")
566            .expect("present");
567        assert_eq!(out.outgoing.edges, whole.outgoing);
568        assert_eq!(out.incoming.edges, whole.incoming);
569        assert_eq!(out.fingerprint, whole.fingerprint);
570    }
571
572    /// The bound, and the accounting that keeps it from being a silent answer.
573    #[test]
574    fn a_truncated_bundle_reports_the_cap_the_totals_and_what_it_dropped() {
575        let store = wide(200);
576        let out = tool_context(&store, "file:wide.rs")
577            .expect("ctx")
578            .expect("present");
579
580        assert!(out.truncated, "205 edges must not fit under the cap");
581        assert_eq!(out.outgoing.total, 205, "the count before the cap");
582        assert!(out.outgoing.truncated);
583        assert_eq!(out.outgoing.edges.len(), TOOL_CONTEXT_EDGE_CAP);
584        // The dropped edges are accounted for by kind, and the numbers reconcile:
585        // total = carried + omitted.
586        let omitted: usize = out.outgoing.omitted.iter().map(|o| o.omitted).sum();
587        assert_eq!(omitted + out.outgoing.edges.len(), out.outgoing.total);
588        assert_eq!(
589            out.outgoing
590                .omitted
591                .iter()
592                .find(|o| o.kind == "defines")
593                .map(|o| o.omitted),
594            // Round-robin fills the two small kinds first (3 + 2), so the cap
595            // leaves `cap - 5` of the 200 `defines`. Written as a saturating
596            // expression so raising the cap fails this test rather than failing
597            // to compile it.
598            Some(205usize.saturating_sub(TOOL_CONTEXT_EDGE_CAP)),
599            "{:?}",
600            out.outgoing.omitted
601        );
602
603        // The other direction is untouched and says so.
604        assert_eq!(out.incoming.total, 0);
605        assert!(!out.incoming.truncated);
606    }
607
608    /// Round-robin, not head-of-list. Sorted order is `defines` … then `imports`
609    /// then `references`, so a plain `truncate(50)` would hand back 50 `defines`
610    /// and let a model conclude the file imports nothing. Every kind present must
611    /// survive the cap.
612    #[test]
613    fn truncation_keeps_every_edge_kind_rather_than_the_first_fifty() {
614        let store = wide(200);
615        let out = tool_context(&store, "file:wide.rs")
616            .expect("ctx")
617            .expect("present");
618        for kind in ["defines", "imports", "references"] {
619            assert!(
620                out.outgoing.edges.iter().any(|e| e.kind == kind),
621                "`{kind}` must survive truncation: {:?}",
622                out.outgoing
623                    .edges
624                    .iter()
625                    .map(|e| &e.kind)
626                    .collect::<std::collections::BTreeSet<_>>()
627            );
628        }
629        // The small kinds fit entirely, so they are not in `omitted` at all.
630        assert!(
631            !out.outgoing
632                .omitted
633                .iter()
634                .any(|o| o.kind == "imports" || o.kind == "references"),
635            "{:?}",
636            out.outgoing.omitted
637        );
638        // And the kept edges are still in canonical order.
639        let mut sorted = out.outgoing.edges.clone();
640        super::sort_refs(&mut sorted);
641        assert_eq!(sorted, out.outgoing.edges);
642    }
643
644    /// The read-only contract. `context` writes a cache entry on a miss and
645    /// *prunes* one for a deleted node; `tool_context` must do neither, so a tool
646    /// call never mutates the store (ADR-0013).
647    #[test]
648    fn tool_context_never_writes_to_the_cache() {
649        let store = seeded();
650        assert!(store.context_cache_keys().unwrap().is_empty());
651
652        tool_context(&store, "sym:rust:a.rs#caller")
653            .expect("ctx")
654            .expect("present");
655        assert!(
656            store.context_cache_keys().unwrap().is_empty(),
657            "a tool read must not populate the cache",
658        );
659
660        // And a hit on a key with a *stale* cache entry must not prune it: pruning
661        // is `roteiro context --refresh`'s maintenance, not a read's.
662        store
663            .context_cache_put("sym:rust:a.rs#ghost", "stale-fingerprint", "{}")
664            .expect("put");
665        let missing = tool_context(&store, "sym:rust:a.rs#ghost").expect("ctx");
666        assert!(missing.is_none(), "no such node");
667        assert_eq!(
668            store.context_cache_keys().unwrap(),
669            vec!["sym:rust:a.rs#ghost".to_owned()],
670            "a missing node must not prune its cache entry",
671        );
672    }
673
674    #[test]
675    fn context_is_cached_then_served_from_cache() {
676        let store = seeded();
677        // First read is a miss: it populates the cache.
678        let first = context(&store, "sym:rust:a.rs#caller")
679            .expect("ctx")
680            .expect("present");
681        assert_eq!(first.node.key, "sym:rust:a.rs#caller");
682        assert_eq!(first.outgoing.len(), 1, "calls callee");
683        assert_eq!(first.incoming.len(), 1, "referenced by doc");
684        assert_eq!(
685            store
686                .context_cache_get("sym:rust:a.rs#caller")
687                .expect("get")
688                .expect("cached")
689                .0,
690            first.fingerprint,
691        );
692        // Second read returns the identical bundle from the cache.
693        let second = context(&store, "sym:rust:a.rs#caller")
694            .expect("ctx")
695            .expect("present");
696        assert_eq!(first, second);
697    }
698
699    #[test]
700    fn changing_a_dependency_invalidates_dependent_context() {
701        let store = seeded();
702        // Warm the cache for all three nodes.
703        let before = refresh_first_read(&store);
704        assert_eq!(before.rebuilt, 0, "warming reads are misses, not refreshes");
705
706        // The caller's cached fingerprint before the change.
707        let caller_fp_before = context(&store, "sym:rust:a.rs#caller")
708            .expect("ctx")
709            .expect("present")
710            .fingerprint;
711
712        // Change the *callee*'s content (new blob). The caller depends on it.
713        let mut target = store
714            .get_node("sym:rust:a.rs#callee")
715            .expect("get")
716            .expect("present");
717        target.blob_hash = Some("BLOB_A2".to_owned());
718        store.upsert_node(&target).expect("upsert");
719
720        // `dependents` names exactly who should be dirtied: the callee and its
721        // neighbour, the caller.
722        let deps = dependents(&store, &["sym:rust:a.rs#callee".to_owned()]).expect("deps");
723        assert!(deps.contains("sym:rust:a.rs#caller"));
724
725        // The caller's fingerprint has moved (its neighbour changed), so a fresh
726        // read rebuilds it — its cached context is invalidated.
727        let caller_fp_after = context(&store, "sym:rust:a.rs#caller")
728            .expect("ctx")
729            .expect("present")
730            .fingerprint;
731        assert_ne!(
732            caller_fp_before, caller_fp_after,
733            "dependent context must be invalidated when a dependency changes",
734        );
735
736        // The unrelated doc did not change and is not a dependent of the callee,
737        // so a refresh reuses it while rebuilding the affected nodes.
738        let report = refresh_contexts(&store).expect("refresh");
739        assert!(report.rebuilt >= 1, "affected contexts rebuilt");
740        assert_eq!(report.pruned, 0);
741    }
742
743    /// Read context for every node once, warming the cache; returns a refresh
744    /// report taken immediately after (which should show everything fresh).
745    fn refresh_first_read(store: &Store) -> super::ContextRefresh {
746        for key in store.all_keys().expect("keys") {
747            context(store, &key).expect("ctx");
748        }
749        refresh_contexts(store).expect("refresh")
750    }
751
752    #[test]
753    fn deleted_node_context_is_pruned() {
754        let mut store = seeded();
755        context(&store, "file:docs/g.md")
756            .expect("ctx")
757            .expect("present");
758        assert!(
759            store
760                .context_cache_get("file:docs/g.md")
761                .expect("get")
762                .is_some()
763        );
764        // Rebuild the graph without the doc node.
765        let mut caller = Node::new("sym:rust:a.rs#caller", NodeKind::Fn, "caller");
766        caller.blob_hash = Some("BLOB_A".to_owned());
767        store
768            .rebuild(&FactSet::new().with_node(caller), None)
769            .expect("rebuild");
770        // The cache entry survives rebuild but is pruned on refresh; a direct read
771        // also returns None and clears it.
772        assert!(context(&store, "file:docs/g.md").expect("ctx").is_none());
773        assert!(
774            store
775                .context_cache_get("file:docs/g.md")
776                .expect("get")
777                .is_none()
778        );
779    }
780
781    #[test]
782    fn build_context_matches_cached() {
783        let store = seeded();
784        let built = build_context(&store, "sym:rust:a.rs#caller")
785            .expect("build")
786            .expect("present");
787        let cached = context(&store, "sym:rust:a.rs#caller")
788            .expect("ctx")
789            .expect("present");
790        assert_eq!(built, cached);
791        assert!(
792            build_context(&store, "sym:rust:a.rs#ghost")
793                .expect("b")
794                .is_none()
795        );
796    }
797}