Skip to main content

rustbrain_core/
graph.rs

1//! Neighborhood inspection of the knowledge graph.
2//!
3//! Complements FTS [`crate::query`] and agent [`crate::context`]: those pack
4//! content under a token budget; this module **shows structure** — who links to
5//! whom, with relation types and weights — as ASCII or JSON for humans/agents.
6//!
7//! Edges come from SQLite (preserves `relation_type`). CSR `graph.mmap` is not
8//! required; run `sync` so edges exist.
9
10use crate::autolink::is_auto_relation;
11use crate::error::{BrainError, Result};
12use crate::id::node_id_from_rel_path;
13use crate::storage::Database;
14use crate::symbols::{parse_symbol_path, resolve_symbol_ref};
15use crate::types::{Edge, Node, NodeType};
16use serde::{Deserialize, Serialize};
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::path::Path;
19
20/// Which edge directions to follow when expanding from the root.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum GraphDirection {
24    /// Only follow `source → target` edges from the frontier.
25    Out,
26    /// Only follow reverse edges into the frontier.
27    In,
28    /// Expand along both outgoing and incoming edges (default).
29    #[default]
30    Both,
31}
32
33impl GraphDirection {
34    /// Parse `out` / `in` / `both` (case-insensitive).
35    pub fn parse(s: &str) -> Option<Self> {
36        match s.trim().to_ascii_lowercase().as_str() {
37            "out" | "outgoing" | "from" => Some(Self::Out),
38            "in" | "incoming" | "to" => Some(Self::In),
39            "both" | "all" | "undirected" => Some(Self::Both),
40            _ => None,
41        }
42    }
43}
44
45/// Options for [`neighborhood`].
46#[derive(Debug, Clone)]
47pub struct GraphOptions {
48    /// Max BFS depth from the root (`1` = direct neighbors only).
49    pub hops: usize,
50    /// Include soft `auto_*` edges (default true).
51    pub include_auto: bool,
52    /// Include symbol nodes as neighbors (default true).
53    pub include_symbols: bool,
54    /// Edge direction filter.
55    pub direction: GraphDirection,
56    /// Cap on edges returned (prevents huge dumps).
57    pub max_edges: usize,
58    /// If set, only include neighbors whose type is in this list.
59    pub type_filter: Option<Vec<NodeType>>,
60}
61
62impl Default for GraphOptions {
63    fn default() -> Self {
64        Self {
65            hops: 1,
66            include_auto: true,
67            include_symbols: true,
68            direction: GraphDirection::Both,
69            max_edges: 200,
70            type_filter: None,
71        }
72    }
73}
74
75/// Compact node identity for graph reports.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct GraphNodeRef {
78    /// Stable node id.
79    pub id: String,
80    /// Domain type as snake_case string.
81    pub node_type: String,
82    /// Display title.
83    pub title: String,
84    /// Repo-relative path when known.
85    pub file_path: Option<String>,
86}
87
88impl GraphNodeRef {
89    fn from_node(n: &Node) -> Self {
90        Self {
91            id: n.id.clone(),
92            node_type: n.node_type.as_str().to_string(),
93            title: n.title.clone(),
94            file_path: n.file_path.clone(),
95        }
96    }
97}
98
99/// One edge in the neighborhood expansion (BFS order).
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct GraphHopEdge {
102    /// Hop distance from the root (1 = direct neighbor).
103    pub hop: usize,
104    /// `out` if root-side was source; `in` if root-side was target.
105    pub direction: String,
106    /// Relation label (`relates_to`, `anchors`, `doc_links`, `auto_*`, …).
107    pub relation_type: String,
108    /// Edge weight.
109    pub weight: f32,
110    /// Node id on the parent side of this hop (closer to root).
111    pub from_id: String,
112    /// Neighbor node reached by this hop.
113    pub to: GraphNodeRef,
114}
115
116/// Result of a neighborhood query around one node.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct GraphNeighborhood {
119    /// Resolved seed node.
120    pub root: GraphNodeRef,
121    /// Requested hop depth.
122    pub hops: usize,
123    /// Unique nodes in the subgraph including the root.
124    pub nodes_in_subgraph: usize,
125    /// Edges returned (may be capped by [`GraphOptions::max_edges`]).
126    pub edges: Vec<GraphHopEdge>,
127    /// True when `max_edges` truncated the expansion.
128    pub truncated: bool,
129    /// Total nodes in the database (context).
130    pub db_nodes: usize,
131    /// Total edges in the database (context).
132    pub db_edges: usize,
133}
134
135impl GraphNeighborhood {
136    /// Human/agent-readable ASCII (tree-style for hop 1+, indented by hop).
137    pub fn to_ascii(&self) -> String {
138        let mut out = String::new();
139        let path = self.root.file_path.as_deref().unwrap_or("-");
140        out.push_str(&format!(
141            "graph: {}  ({})  \"{}\"\n",
142            self.root.id, self.root.node_type, self.root.title
143        ));
144        out.push_str(&format!("  path: {path}\n"));
145        out.push_str(&format!(
146            "  hops={}  edges_shown={}  nodes_in_subgraph={}  db={}/{} n/e{}\n",
147            self.hops,
148            self.edges.len(),
149            self.nodes_in_subgraph,
150            self.db_nodes,
151            self.db_edges,
152            if self.truncated { "  [truncated]" } else { "" }
153        ));
154        if self.edges.is_empty() {
155            out.push_str("  (no neighbors — orphan or filters excluded all edges)\n");
156            out.push_str(
157                "  tip: `rustbrain links --auto` soft-links orphans; explicit WikiLinks preferred\n",
158            );
159            return out;
160        }
161
162        // Tree: children of root first, then recursively by parent id.
163        render_tree_level(&mut out, &self.edges, &self.root.id, 1, "");
164        out
165    }
166}
167
168fn render_tree_level(
169    out: &mut String,
170    all: &[GraphHopEdge],
171    parent_id: &str,
172    hop: usize,
173    prefix: &str,
174) {
175    let kids: Vec<&GraphHopEdge> = all
176        .iter()
177        .filter(|e| e.from_id == parent_id && e.hop == hop)
178        .collect();
179    let n = kids.len();
180    for (i, e) in kids.iter().enumerate() {
181        let last = i + 1 == n;
182        let branch = if last { "└──" } else { "├──" };
183        let cont = if last { "    " } else { "│   " };
184        let arrow = if e.direction == "in" { "←" } else { "→" };
185        let path = e
186            .to
187            .file_path
188            .as_deref()
189            .map(|p| format!("  [{p}]"))
190            .unwrap_or_default();
191        out.push_str(&format!(
192            "{prefix}{branch}[{arrow} {} w={:.2}] {}  ({})  \"{}\"{path}\n",
193            e.relation_type, e.weight, e.to.id, e.to.node_type, e.to.title
194        ));
195        let child_prefix = format!("{prefix}{cont}");
196        render_tree_level(out, all, &e.to.id, hop + 1, &child_prefix);
197    }
198}
199
200/// Aggregate graph statistics (no seed required).
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct GraphStats {
203    /// Total nodes.
204    pub nodes: usize,
205    /// Total edges.
206    pub edges: usize,
207    /// Counts by `node_type`.
208    pub by_type: Vec<(String, usize)>,
209    /// Counts by `relation_type`.
210    pub by_relation: Vec<(String, usize)>,
211    /// Highest-degree nodes (undirected: in+out), up to 15.
212    pub hubs: Vec<GraphHub>,
213}
214
215/// A high-degree node for [`GraphStats`].
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct GraphHub {
218    /// Node id.
219    pub id: String,
220    /// Type string.
221    pub node_type: String,
222    /// Title.
223    pub title: String,
224    /// Degree (in + out edge endpoints).
225    pub degree: usize,
226}
227
228impl GraphStats {
229    /// Human-readable summary.
230    pub fn to_ascii(&self) -> String {
231        let mut out = String::new();
232        out.push_str(&format!(
233            "graph stats: nodes={} edges={}\n",
234            self.nodes, self.edges
235        ));
236        if !self.by_type.is_empty() {
237            out.push_str("  by type:\n");
238            for (t, n) in &self.by_type {
239                out.push_str(&format!("    {t}: {n}\n"));
240            }
241        }
242        if !self.by_relation.is_empty() {
243            out.push_str("  by relation:\n");
244            for (r, n) in &self.by_relation {
245                out.push_str(&format!("    {r}: {n}\n"));
246            }
247        }
248        if !self.hubs.is_empty() {
249            out.push_str("  hubs (degree = in+out):\n");
250            for h in &self.hubs {
251                out.push_str(&format!(
252                    "    deg={:>3}  {}  ({})  \"{}\"\n",
253                    h.degree, h.id, h.node_type, h.title
254                ));
255            }
256        }
257        out
258    }
259}
260
261/// Database-wide graph statistics and hubs.
262pub fn graph_stats(db: &Database) -> Result<GraphStats> {
263    let nodes = db.get_all_nodes()?;
264    let edges = db.get_all_edges()?;
265    let mut by_type: HashMap<String, usize> = HashMap::new();
266    for n in &nodes {
267        *by_type.entry(n.node_type.as_str().to_string()).or_default() += 1;
268    }
269    let mut by_relation: HashMap<String, usize> = HashMap::new();
270    let mut degree: HashMap<String, usize> = HashMap::new();
271    for e in &edges {
272        *by_relation.entry(e.relation_type.clone()).or_default() += 1;
273        *degree.entry(e.source_id.clone()).or_default() += 1;
274        *degree.entry(e.target_id.clone()).or_default() += 1;
275    }
276    let mut by_type: Vec<(String, usize)> = by_type.into_iter().collect();
277    by_type.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
278    let mut by_relation: Vec<(String, usize)> = by_relation.into_iter().collect();
279    by_relation.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
280
281    let id_to_node: HashMap<&str, &Node> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
282    let mut hubs: Vec<GraphHub> = degree
283        .into_iter()
284        .filter_map(|(id, deg)| {
285            let n = id_to_node.get(id.as_str())?;
286            Some(GraphHub {
287                id,
288                node_type: n.node_type.as_str().to_string(),
289                title: n.title.clone(),
290                degree: deg,
291            })
292        })
293        .collect();
294    hubs.sort_by(|a, b| {
295        b.degree
296            .cmp(&a.degree)
297            .then_with(|| a.id.cmp(&b.id))
298    });
299    hubs.truncate(15);
300
301    Ok(GraphStats {
302        nodes: nodes.len(),
303        edges: edges.len(),
304        by_type,
305        by_relation,
306        hubs,
307    })
308}
309
310/// Expand the k-hop neighborhood around `target` (path, id, title, or `symbol:…`).
311pub fn neighborhood(db: &Database, target: &str, opts: &GraphOptions) -> Result<GraphNeighborhood> {
312    let root = resolve_graph_target(db, target)?;
313    let all_edges = db.get_all_edges()?;
314    let all_nodes = db.get_all_nodes()?;
315    let node_map: HashMap<String, Node> = all_nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
316
317    let hops = opts.hops.max(1);
318
319    // Adjacency: out and in lists of (edge, neighbor_id)
320    let mut out_adj: HashMap<String, Vec<&Edge>> = HashMap::new();
321    let mut in_adj: HashMap<String, Vec<&Edge>> = HashMap::new();
322    for e in &all_edges {
323        if !opts.include_auto && is_auto_relation(&e.relation_type) {
324            continue;
325        }
326        out_adj.entry(e.source_id.clone()).or_default().push(e);
327        in_adj.entry(e.target_id.clone()).or_default().push(e);
328    }
329
330    let mut visited: HashSet<String> = HashSet::new();
331    visited.insert(root.id.clone());
332    // queue: (node_id, hop_from_root)
333    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
334    queue.push_back((root.id.clone(), 0));
335
336    let mut result_edges: Vec<GraphHopEdge> = Vec::new();
337    let mut truncated = false;
338
339    while let Some((curr, depth)) = queue.pop_front() {
340        if depth >= hops {
341            continue;
342        }
343        if result_edges.len() >= opts.max_edges {
344            truncated = true;
345            break;
346        }
347
348        let mut candidates: Vec<(&Edge, &str, &str)> = Vec::new(); // edge, neighbor, direction
349
350        if matches!(opts.direction, GraphDirection::Out | GraphDirection::Both) {
351            if let Some(list) = out_adj.get(&curr) {
352                for e in list {
353                    candidates.push((e, e.target_id.as_str(), "out"));
354                }
355            }
356        }
357        if matches!(opts.direction, GraphDirection::In | GraphDirection::Both) {
358            if let Some(list) = in_adj.get(&curr) {
359                for e in list {
360                    candidates.push((e, e.source_id.as_str(), "in"));
361                }
362            }
363        }
364
365        // Stable order: higher weight first, then id
366        candidates.sort_by(|a, b| {
367            b.0.weight
368                .partial_cmp(&a.0.weight)
369                .unwrap_or(std::cmp::Ordering::Equal)
370                .then_with(|| a.1.cmp(b.1))
371                .then_with(|| a.0.relation_type.cmp(&b.0.relation_type))
372        });
373
374        for (e, neighbor_id, dir) in candidates {
375            if result_edges.len() >= opts.max_edges {
376                truncated = true;
377                break;
378            }
379            if visited.contains(neighbor_id) {
380                continue;
381            }
382            let Some(neighbor) = node_map.get(neighbor_id) else {
383                continue;
384            };
385            if !opts.include_symbols && neighbor.node_type == NodeType::Symbol {
386                continue;
387            }
388            if let Some(filter) = &opts.type_filter {
389                if !filter.contains(&neighbor.node_type) {
390                    continue;
391                }
392            }
393
394            visited.insert(neighbor_id.to_string());
395            let hop = depth + 1;
396            result_edges.push(GraphHopEdge {
397                hop,
398                direction: dir.to_string(),
399                relation_type: e.relation_type.clone(),
400                weight: e.weight,
401                from_id: curr.clone(),
402                to: GraphNodeRef::from_node(neighbor),
403            });
404            if hop < hops {
405                queue.push_back((neighbor_id.to_string(), hop));
406            }
407        }
408    }
409
410    Ok(GraphNeighborhood {
411        root: GraphNodeRef::from_node(&root),
412        hops,
413        nodes_in_subgraph: visited.len(),
414        edges: result_edges,
415        truncated,
416        db_nodes: node_map.len(),
417        db_edges: all_edges.len(),
418    })
419}
420
421/// Resolve a user target string to a [`Node`].
422///
423/// Accepts:
424/// - exact node id
425/// - file path (`docs/concepts/raft.md` or without extension)
426/// - `symbol:Name` / `symbol:mod::Name`
427/// - exact title (case-insensitive), unique match only
428pub fn resolve_graph_target(db: &Database, target: &str) -> Result<Node> {
429    let raw = target.trim().trim_start_matches("./").replace('\\', "/");
430    if raw.is_empty() {
431        return Err(BrainError::other(
432            "empty graph target — pass a node id, path, title, or symbol:…",
433        ));
434    }
435
436    // Exact id
437    if let Some(n) = db.get_node(&raw)? {
438        return Ok(n);
439    }
440
441    // Path → id slug
442    let as_path = Path::new(&raw);
443    let slug = node_id_from_rel_path(as_path);
444    if let Some(n) = db.get_node(&slug)? {
445        return Ok(n);
446    }
447    // docs/ prefix variants
448    if !raw.starts_with("docs/") {
449        let with_docs = format!("docs/{raw}");
450        if let Some(n) = db.get_node(&with_docs)? {
451            return Ok(n);
452        }
453        let slug2 = node_id_from_rel_path(Path::new(&with_docs));
454        if let Some(n) = db.get_node(&slug2)? {
455            return Ok(n);
456        }
457    }
458
459    // symbol: refs
460    let sym_raw = raw
461        .strip_prefix("symbol:")
462        .or_else(|| raw.strip_prefix("[[symbol:").and_then(|s| s.strip_suffix("]]")))
463        .unwrap_or(&raw);
464    if raw.starts_with("symbol:") || raw.contains("::") {
465        if let Some(sym) = parse_symbol_path(sym_raw) {
466            let ids: HashSet<String> = db
467                .get_all_node_ids()?
468                .into_iter()
469                .filter(|id| id.starts_with("symbol/"))
470                .collect();
471            if let Some(id) = resolve_symbol_ref(&sym, &ids) {
472                if let Some(n) = db.get_node(&id)? {
473                    return Ok(n);
474                }
475            }
476        }
477    }
478
479    // file_path match among all nodes
480    let all = db.get_all_nodes()?;
481    let path_hits: Vec<&Node> = all
482        .iter()
483        .filter(|n| {
484            n.file_path
485                .as_ref()
486                .map(|p| {
487                    let p = p.replace('\\', "/");
488                    p == raw
489                        || p.ends_with(&raw)
490                        || p.ends_with(&format!("/{raw}"))
491                        || p.trim_end_matches(".md") == raw
492                        || p.trim_end_matches(".md").ends_with(&format!("/{raw}"))
493                })
494                .unwrap_or(false)
495        })
496        .collect();
497    if path_hits.len() == 1 {
498        return Ok(path_hits[0].clone());
499    }
500    if path_hits.len() > 1 {
501        return Err(BrainError::other(format!(
502            "ambiguous path `{raw}` matches {} nodes — use a fuller path or exact id",
503            path_hits.len()
504        )));
505    }
506
507    // Unique title match (case-insensitive)
508    let lower = raw.to_ascii_lowercase();
509    let title_hits: Vec<&Node> = all
510        .iter()
511        .filter(|n| n.title.to_ascii_lowercase() == lower)
512        .collect();
513    if title_hits.len() == 1 {
514        return Ok(title_hits[0].clone());
515    }
516    if title_hits.len() > 1 {
517        return Err(BrainError::other(format!(
518            "ambiguous title `{raw}` matches {} nodes — use path or id",
519            title_hits.len()
520        )));
521    }
522
523    // Substring title (unique)
524    let sub_hits: Vec<&Node> = all
525        .iter()
526        .filter(|n| n.title.to_ascii_lowercase().contains(&lower) && lower.len() >= 3)
527        .collect();
528    if sub_hits.len() == 1 {
529        return Ok(sub_hits[0].clone());
530    }
531
532    Err(BrainError::other(format!(
533        "no node found for `{raw}` — try path (docs/…md), node id, exact title, or symbol:Name after `rustbrain sync`"
534    )))
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::types::{Edge, Node, NodeType};
541    use tempfile::tempdir;
542
543    fn mk_db() -> (tempfile::TempDir, Database) {
544        let dir = tempdir().unwrap();
545        let db = Database::open(dir.path().join("db.sqlite")).unwrap();
546        let now = 1_700_000_000i64;
547        let nodes = [
548            Node {
549                id: "docs/concepts/raft".into(),
550                node_type: NodeType::Concept,
551                title: "Raft".into(),
552                file_path: Some("docs/concepts/raft.md".into()),
553                symbol_hash: None,
554                summary: Some("consensus".into()),
555                content_hash: None,
556                created_at: now,
557                updated_at: now,
558            },
559            Node {
560                id: "docs/concepts/logcompaction".into(),
561                node_type: NodeType::Concept,
562                title: "Log Compaction".into(),
563                file_path: Some("docs/concepts/logcompaction.md".into()),
564                symbol_hash: None,
565                summary: None,
566                content_hash: None,
567                created_at: now,
568                updated_at: now,
569            },
570            Node {
571                id: "docs/adr/0001-use-raft".into(),
572                node_type: NodeType::Adr,
573                title: "Use Raft".into(),
574                file_path: Some("docs/adr/0001-use-raft.md".into()),
575                symbol_hash: None,
576                summary: None,
577                content_hash: None,
578                created_at: now,
579                updated_at: now,
580            },
581            Node {
582                id: "symbol/demo/lib/storageengine".into(),
583                node_type: NodeType::Symbol,
584                title: "StorageEngine".into(),
585                file_path: Some("src/lib.rs".into()),
586                symbol_hash: Some(1),
587                summary: None,
588                content_hash: None,
589                created_at: now,
590                updated_at: now,
591            },
592        ];
593        for n in &nodes {
594            db.insert_node(n).unwrap();
595        }
596        db.insert_edge(&Edge {
597            source_id: "docs/concepts/raft".into(),
598            target_id: "docs/concepts/logcompaction".into(),
599            relation_type: "relates_to".into(),
600            weight: 1.0,
601            decay_rate: 0.0,
602            created_at: now,
603        })
604        .unwrap();
605        db.insert_edge(&Edge {
606            source_id: "docs/concepts/raft".into(),
607            target_id: "symbol/demo/lib/storageengine".into(),
608            relation_type: "anchors".into(),
609            weight: 1.0,
610            decay_rate: 0.0,
611            created_at: now,
612        })
613        .unwrap();
614        db.insert_edge(&Edge {
615            source_id: "docs/adr/0001-use-raft".into(),
616            target_id: "docs/concepts/raft".into(),
617            relation_type: "relates_to".into(),
618            weight: 0.9,
619            decay_rate: 0.0,
620            created_at: now,
621        })
622        .unwrap();
623        db.insert_edge(&Edge {
624            source_id: "docs/concepts/raft".into(),
625            target_id: "docs/concepts/logcompaction".into(),
626            relation_type: "auto_filename".into(),
627            weight: 0.4,
628            decay_rate: 0.0,
629            created_at: now,
630        })
631        .unwrap();
632        (dir, db)
633    }
634
635    #[test]
636    fn neighborhood_both_directions() {
637        let (_dir, db) = mk_db();
638        let nb = neighborhood(&db, "docs/concepts/raft", &GraphOptions::default()).unwrap();
639        assert_eq!(nb.root.id, "docs/concepts/raft");
640        // out relates_to logcompaction, out anchors symbol, in from adr, + auto
641        assert!(nb.edges.len() >= 3);
642        let ids: HashSet<_> = nb.edges.iter().map(|e| e.to.id.as_str()).collect();
643        assert!(ids.contains("docs/concepts/logcompaction"));
644        assert!(ids.contains("symbol/demo/lib/storageengine"));
645        assert!(ids.contains("docs/adr/0001-use-raft"));
646        let ascii = nb.to_ascii();
647        assert!(ascii.contains("relates_to"));
648        assert!(ascii.contains("anchors"));
649    }
650
651    #[test]
652    fn no_auto_and_no_symbols_filters() {
653        let (_dir, db) = mk_db();
654        let opts = GraphOptions {
655            include_auto: false,
656            include_symbols: false,
657            ..GraphOptions::default()
658        };
659        let nb = neighborhood(&db, "docs/concepts/raft.md", &opts).unwrap();
660        for e in &nb.edges {
661            assert!(!e.relation_type.starts_with("auto_"));
662            assert_ne!(e.to.node_type, "symbol");
663        }
664        assert!(nb.edges.iter().any(|e| e.to.id == "docs/concepts/logcompaction"));
665        assert!(nb.edges.iter().any(|e| e.to.id == "docs/adr/0001-use-raft"));
666    }
667
668    #[test]
669    fn resolve_by_title() {
670        let (_dir, db) = mk_db();
671        let n = resolve_graph_target(&db, "Raft").unwrap();
672        assert_eq!(n.id, "docs/concepts/raft");
673    }
674
675    #[test]
676    fn stats_counts() {
677        let (_dir, db) = mk_db();
678        let s = graph_stats(&db).unwrap();
679        assert_eq!(s.nodes, 4);
680        assert!(s.edges >= 3);
681        assert!(!s.hubs.is_empty());
682        assert!(s.to_ascii().contains("by type"));
683    }
684
685    #[test]
686    fn direction_out_only() {
687        let (_dir, db) = mk_db();
688        let opts = GraphOptions {
689            direction: GraphDirection::Out,
690            include_auto: false,
691            ..GraphOptions::default()
692        };
693        let nb = neighborhood(&db, "docs/concepts/raft", &opts).unwrap();
694        assert!(nb.edges.iter().all(|e| e.direction == "out"));
695        assert!(!nb.edges.iter().any(|e| e.to.id == "docs/adr/0001-use-raft"));
696    }
697}