Skip to main content

llm_kernel/graph/
store.rs

1//! Node and edge CRUD operations.
2
3use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::types::{EdgeDirection, GraphEdge, GraphNode, NODE_COLUMNS, join_csv, row_to_node};
8
9// ── Node CRUD ─────────────────────────────────────────
10
11/// Insert or update a node, preserving first-creation and access telemetry.
12///
13/// Uses `ON CONFLICT ... DO UPDATE` rather than `INSERT OR REPLACE` so that
14/// re-inserting a fixed-id node (e.g. an identity/stock node touched on every
15/// analysis) does not destroy `created`, `access_count`, or `accessed_at`.
16/// `INSERT OR REPLACE` is DELETE+INSERT in SQLite, which zeroes the access
17/// counters and rewrites the creation timestamp on every write — diverging from
18/// the Postgres backend (`sqlx_pg.rs`), which already does `ON CONFLICT`. This
19/// brings SQLite to parity.
20///
21/// - `created`: first-write wins (`nodes.created` is kept).
22/// - `access_count`/`accessed_at`: the caller's value never *lowers* an existing
23///   one (`MAX(...)`), so an import restoring a higher count is honored while a
24///   routine `access_count: 0` write is a no-op on update.
25pub fn upsert_node(conn: &Connection, node: &GraphNode) -> Result<()> {
26    conn.execute(
27        "INSERT INTO nodes
28            (id, type, title, tags, projects, agents, created, updated, body,
29             importance, access_count, accessed_at, valid_until, last_verified)
30         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
31         ON CONFLICT(id) DO UPDATE SET
32            type = excluded.type,
33            title = excluded.title,
34            tags = excluded.tags,
35            projects = excluded.projects,
36            agents = excluded.agents,
37            updated = excluded.updated,
38            body = excluded.body,
39            importance = excluded.importance,
40            created      = nodes.created,
41            access_count = MAX(nodes.access_count, excluded.access_count),
42            accessed_at  = MAX(nodes.accessed_at, excluded.accessed_at),
43            valid_until  = excluded.valid_until,
44            last_verified = excluded.last_verified",
45        params![
46            node.id,
47            node.node_type,
48            node.title,
49            join_csv(&node.tags),
50            join_csv(&node.projects),
51            join_csv(&node.agents),
52            node.created,
53            node.updated,
54            node.body,
55            node.importance,
56            node.access_count,
57            node.accessed_at,
58            node.valid_until,
59            node.last_verified,
60        ],
61    )
62    .map_err(|e| KernelError::Store(e.to_string()))?;
63    Ok(())
64}
65
66/// Read a single node by ID. Returns `None` if not found.
67pub fn read_node(conn: &Connection, id: &str) -> Result<Option<GraphNode>> {
68    let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1");
69    match conn.query_row(&sql, params![id], row_to_node) {
70        Ok(node) => Ok(Some(node)),
71        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
72        Err(e) => Err(KernelError::Store(e.to_string())),
73    }
74}
75
76/// Batch-read multiple nodes by ID.
77pub fn read_nodes(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphNode>> {
78    if ids.is_empty() {
79        return Ok(vec![]);
80    }
81    let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
82    let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id IN ({ph})");
83    let mut stmt = conn
84        .prepare(&sql)
85        .map_err(|e| KernelError::Store(e.to_string()))?;
86    let nodes: Vec<GraphNode> = stmt
87        .query_map(rusqlite::params_from_iter(ids.iter()), row_to_node)
88        .map_err(|e| KernelError::Store(e.to_string()))?
89        .filter_map(|r| r.ok())
90        .collect();
91    Ok(nodes)
92}
93
94/// Delete a node by ID, removing its edges first in the same transaction.
95///
96/// Previously this deleted only the node row and left dangling edges behind —
97/// `remove_edges_for_node` existed but was never called here. The transaction
98/// keeps the node deletion and edge cleanup atomic.
99pub fn delete_node(conn: &Connection, id: &str) -> Result<bool> {
100    // `unchecked_transaction` is used instead of `transaction` because the
101    // function signature takes `&Connection` (not `&mut`). This is safe: in the
102    // async pool (`AsyncPoolGraph::with_conn`) the semaphore guarantees exclusive
103    // access, and in sync call sites no other statements are open on `conn`.
104    let tx = conn
105        .unchecked_transaction()
106        .map_err(|e| KernelError::Store(e.to_string()))?;
107    remove_edges_for_node(&tx, id)?;
108    let changed = tx
109        .execute("DELETE FROM nodes WHERE id = ?1", params![id])
110        .map_err(|e| KernelError::Store(e.to_string()))?;
111    tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
112    Ok(changed > 0)
113}
114
115/// List all node IDs.
116pub fn list_node_ids(conn: &Connection) -> Result<Vec<String>> {
117    let mut stmt = conn
118        .prepare("SELECT id FROM nodes")
119        .map_err(|e| KernelError::Store(e.to_string()))?;
120    let ids: Vec<String> = stmt
121        .query_map([], |row| row.get(0))
122        .map_err(|e| KernelError::Store(e.to_string()))?
123        .filter_map(|r| r.ok())
124        .collect();
125    Ok(ids)
126}
127
128/// Read nodes with optional limit, ordered by updated DESC.
129pub fn read_nodes_limited(conn: &Connection, limit: usize) -> Result<Vec<GraphNode>> {
130    let sql = format!("SELECT {NODE_COLUMNS} FROM nodes ORDER BY updated DESC LIMIT ?");
131    let mut stmt = conn
132        .prepare(&sql)
133        .map_err(|e| KernelError::Store(e.to_string()))?;
134    let nodes: Vec<GraphNode> = stmt
135        .query_map(params![limit as i64], row_to_node)
136        .map_err(|e| KernelError::Store(e.to_string()))?
137        .filter_map(|r| r.ok())
138        .collect();
139    Ok(nodes)
140}
141
142// ── Edge CRUD ─────────────────────────────────────────
143
144/// Append an edge (INSERT OR IGNORE — duplicates by ID are skipped).
145pub fn append_edge(conn: &Connection, edge: &GraphEdge) -> Result<()> {
146    conn.execute(
147        "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
148         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
149        params![
150            edge.id,
151            edge.source,
152            edge.target,
153            edge.relation,
154            edge.weight,
155            edge.ts
156        ],
157    )
158    .map_err(|e| KernelError::Store(e.to_string()))?;
159    Ok(())
160}
161
162/// Append many edges in a single transaction (INSERT OR IGNORE — duplicates
163/// by ID *or* by the `(source, target, relation)` unique index are skipped).
164///
165/// Equivalent to calling [`append_edge`] per edge, but commits once and reuses
166/// one prepared statement, so it scales to hundreds of thousands of edges
167/// (e.g. building a citation graph during indexing).
168///
169/// Opens its own transaction — **not usable inside
170/// [`SqliteGraph::with_tx`](crate::graph::backend::SqliteGraph::with_tx)**
171/// ("cannot start a transaction within a transaction"). Inside a `with_tx`
172/// closure, loop [`append_edge`] instead.
173pub fn append_edges(conn: &Connection, edges: &[GraphEdge]) -> Result<()> {
174    if edges.is_empty() {
175        return Ok(());
176    }
177    let tx = conn
178        .unchecked_transaction()
179        .map_err(|e| KernelError::Store(e.to_string()))?;
180    {
181        let mut stmt = tx
182            .prepare(
183                "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
184                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
185            )
186            .map_err(|e| KernelError::Store(e.to_string()))?;
187        for edge in edges {
188            stmt.execute(params![
189                edge.id,
190                edge.source,
191                edge.target,
192                edge.relation,
193                edge.weight,
194                edge.ts
195            ])
196            .map_err(|e| KernelError::Store(e.to_string()))?;
197        }
198    }
199    tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
200    Ok(())
201}
202
203/// Read edges touching `node_id`, filtered by direction and optional relation.
204///
205/// - [`EdgeDirection::Both`] matches `source = node_id OR target = node_id`
206///   (the historical behavior).
207/// - [`EdgeDirection::Out`] matches `source = node_id` only (out-edges).
208/// - [`EdgeDirection::In`] matches `target = node_id` only (in-edges).
209///
210/// When `relation` is `Some(r)`, additionally filters `relation = r`, using the
211/// `idx_edges_src_rel` / `idx_edges_tgt_rel` v3 indexes for the directional cases.
212pub(crate) fn edges_for_node_dir(
213    conn: &Connection,
214    node_id: &str,
215    dir: EdgeDirection,
216    relation: Option<&str>,
217) -> Result<Vec<GraphEdge>> {
218    let mut sql = String::from("SELECT id, source, target, relation, weight, ts FROM edges WHERE ");
219    match dir {
220        EdgeDirection::Out => sql.push_str("source = ?1"),
221        EdgeDirection::In => sql.push_str("target = ?1"),
222        EdgeDirection::Both => sql.push_str("(source = ?1 OR target = ?1)"),
223    }
224    if relation.is_some() {
225        sql.push_str(" AND relation = ?2");
226    }
227    sql.push_str(" ORDER BY weight DESC");
228    let mut stmt = conn
229        .prepare(&sql)
230        .map_err(|e| KernelError::Store(e.to_string()))?;
231    let mapper = |row: &rusqlite::Row<'_>| {
232        Ok(GraphEdge {
233            id: row.get(0)?,
234            source: row.get(1)?,
235            target: row.get(2)?,
236            relation: row.get(3)?,
237            weight: row.get(4)?,
238            ts: row.get(5)?,
239        })
240    };
241    let edges: Vec<GraphEdge> = if let Some(r) = relation {
242        stmt.query_map(params![node_id, r], mapper)
243    } else {
244        stmt.query_map(params![node_id], mapper)
245    }
246    .map_err(|e| KernelError::Store(e.to_string()))?
247    .filter_map(|r| r.ok())
248    .collect();
249    Ok(edges)
250}
251
252/// Read edges, capped at `limit`.
253pub fn read_edges(conn: &Connection, limit: usize) -> Result<Vec<GraphEdge>> {
254    let mut stmt = conn
255        .prepare("SELECT id, source, target, relation, weight, ts FROM edges LIMIT ?1")
256        .map_err(|e| KernelError::Store(e.to_string()))?;
257    let edges: Vec<GraphEdge> = stmt
258        .query_map(params![limit as i64], |row| {
259            Ok(GraphEdge {
260                id: row.get(0)?,
261                source: row.get(1)?,
262                target: row.get(2)?,
263                relation: row.get(3)?,
264                weight: row.get(4)?,
265                ts: row.get(5)?,
266            })
267        })
268        .map_err(|e| KernelError::Store(e.to_string()))?
269        .filter_map(|r| r.ok())
270        .collect();
271    Ok(edges)
272}
273
274/// Read edges whose source AND target are both in `ids` — the induced subgraph
275/// over a candidate node set.
276///
277/// Used to build the candidate subgraph for PageRank boosting in
278/// [`smart_recall`](super::recall::smart_recall). `ids.len()` must stay under
279/// SQLite's bind-variable limit (999 by default); `smart_recall` caps at 100.
280pub(crate) fn edges_among(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphEdge>> {
281    if ids.is_empty() {
282        return Ok(Vec::new());
283    }
284    let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
285    let sql = format!(
286        "SELECT id, source, target, relation, weight, ts FROM edges \
287         WHERE source IN ({ph}) AND target IN ({ph})"
288    );
289    let mut stmt = conn
290        .prepare(&sql)
291        .map_err(|e| KernelError::Store(e.to_string()))?;
292    let edges: Vec<GraphEdge> = stmt
293        .query_map(
294            rusqlite::params_from_iter(ids.iter().chain(ids.iter()).copied()),
295            |row| {
296                Ok(GraphEdge {
297                    id: row.get(0)?,
298                    source: row.get(1)?,
299                    target: row.get(2)?,
300                    relation: row.get(3)?,
301                    weight: row.get(4)?,
302                    ts: row.get(5)?,
303                })
304            },
305        )
306        .map_err(|e| KernelError::Store(e.to_string()))?
307        .filter_map(|r| r.ok())
308        .collect();
309    Ok(edges)
310}
311
312/// Delete an edge by ID.
313pub fn delete_edge(conn: &Connection, id: &str) -> Result<bool> {
314    let changed = conn
315        .execute("DELETE FROM edges WHERE id = ?1", params![id])
316        .map_err(|e| KernelError::Store(e.to_string()))?;
317    Ok(changed > 0)
318}
319
320/// Delete all edges connected to a node (source or target).
321pub(crate) fn remove_edges_for_node(conn: &Connection, node_id: &str) -> Result<()> {
322    conn.execute(
323        "DELETE FROM edges WHERE source = ?1 OR target = ?1",
324        params![node_id],
325    )
326    .map_err(|e| KernelError::Store(e.to_string()))?;
327    Ok(())
328}
329
330/// Read edges where the given node is source or target.
331pub(crate) fn edges_for_node(conn: &Connection, node_id: &str) -> Result<Vec<GraphEdge>> {
332    let mut stmt = conn
333        .prepare(
334            "SELECT id, source, target, relation, weight, ts FROM edges WHERE source = ?1 OR target = ?1",
335        )
336        .map_err(|e| KernelError::Store(e.to_string()))?;
337    let edges: Vec<GraphEdge> = stmt
338        .query_map(params![node_id], |row| {
339            Ok(GraphEdge {
340                id: row.get(0)?,
341                source: row.get(1)?,
342                target: row.get(2)?,
343                relation: row.get(3)?,
344                weight: row.get(4)?,
345                ts: row.get(5)?,
346            })
347        })
348        .map_err(|e| KernelError::Store(e.to_string()))?
349        .filter_map(|r| r.ok())
350        .collect();
351    Ok(edges)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::graph::schema::init_graph_schema;
358    use rusqlite::Connection;
359
360    fn mem_db() -> Connection {
361        let conn = Connection::open_in_memory().unwrap();
362        init_graph_schema(&conn).unwrap();
363        conn
364    }
365
366    fn test_node(id: &str) -> GraphNode {
367        GraphNode {
368            id: id.to_string(),
369            node_type: "concept".to_string(),
370            title: format!("Node {id}"),
371            body: "test body".to_string(),
372            tags: vec!["test".to_string()],
373            projects: vec![],
374            agents: vec![],
375            created: "2026-01-01T00:00:00Z".to_string(),
376            updated: "2026-01-01T00:00:00Z".to_string(),
377            importance: 0.7,
378            access_count: 0,
379            accessed_at: String::new(),
380            ..Default::default()
381        }
382    }
383
384    #[test]
385    fn upsert_and_read_node() {
386        let conn = mem_db();
387        let node = test_node("n1");
388        upsert_node(&conn, &node).unwrap();
389        let loaded = read_node(&conn, "n1").unwrap().unwrap();
390        assert_eq!(loaded.id, "n1");
391        assert_eq!(loaded.title, "Node n1");
392        assert_eq!(loaded.tags, vec!["test"]);
393    }
394
395    #[test]
396    fn upsert_roundtrips_temporal_validity() {
397        let conn = mem_db();
398        let mut n = test_node("tv");
399        n.valid_until = "2027-01-01T00:00:00Z".to_string();
400        n.last_verified = "2026-08-01T00:00:00Z".to_string();
401        upsert_node(&conn, &n).unwrap();
402        let got = read_node(&conn, "tv").unwrap().unwrap();
403        assert_eq!(got.valid_until, "2027-01-01T00:00:00Z");
404        assert_eq!(got.last_verified, "2026-08-01T00:00:00Z");
405    }
406
407    #[test]
408    fn read_missing_node_returns_none() {
409        let conn = mem_db();
410        assert!(read_node(&conn, "nope").unwrap().is_none());
411    }
412
413    #[test]
414    fn delete_node_returns_true_when_exists() {
415        let conn = mem_db();
416        upsert_node(&conn, &test_node("n1")).unwrap();
417        assert!(delete_node(&conn, "n1").unwrap());
418        assert!(!delete_node(&conn, "n1").unwrap());
419    }
420
421    /// Regression: upsert must not destroy `created`/`access_count`. The old
422    /// `INSERT OR REPLACE` zeroed these on every re-insert of a fixed-id node.
423    #[test]
424    fn upsert_preserves_created_and_access_on_conflict() {
425        let conn = mem_db();
426        let mut n = test_node("fixed");
427        n.created = "2026-01-01T00:00:00Z".to_string();
428        n.access_count = 42;
429        upsert_node(&conn, &n).unwrap();
430
431        // Re-insert the same id with a fresh timestamp and zeroed access count.
432        let mut again = test_node("fixed");
433        again.created = "2026-12-31T00:00:00Z".to_string();
434        again.updated = "2026-12-31T00:00:00Z".to_string();
435        again.access_count = 0;
436        upsert_node(&conn, &again).unwrap();
437
438        let got = read_node(&conn, "fixed").unwrap().unwrap();
439        assert_eq!(got.created, "2026-01-01T00:00:00Z", "created must persist");
440        assert_eq!(
441            got.access_count, 42,
442            "access_count must not be zeroed on re-upsert"
443        );
444        assert_eq!(got.updated, "2026-12-31T00:00:00Z", "updated must refresh");
445    }
446
447    /// Regression: deleting a node must also drop its edges (no orphans).
448    #[test]
449    fn delete_node_removes_edges() {
450        let conn = mem_db();
451        upsert_node(&conn, &test_node("a")).unwrap();
452        upsert_node(&conn, &test_node("b")).unwrap();
453        append_edge(
454            &conn,
455            &GraphEdge {
456                id: "e1".into(),
457                source: "a".into(),
458                target: "b".into(),
459                relation: "rel".into(),
460                weight: 1.0,
461                ts: "2026-01-01T00:00:00Z".into(),
462            },
463        )
464        .unwrap();
465
466        assert!(delete_node(&conn, "a").unwrap());
467        let edges = edges_for_node(&conn, "a").unwrap();
468        assert!(edges.is_empty(), "orphan edges remain: {edges:?}");
469    }
470
471    #[test]
472    fn list_node_ids_returns_all() {
473        let conn = mem_db();
474        upsert_node(&conn, &test_node("a")).unwrap();
475        upsert_node(&conn, &test_node("b")).unwrap();
476        let ids = list_node_ids(&conn).unwrap();
477        assert_eq!(ids.len(), 2);
478    }
479
480    #[test]
481    fn append_and_read_edges() {
482        let conn = mem_db();
483        let edge = GraphEdge {
484            id: "e1".to_string(),
485            source: "a".to_string(),
486            target: "b".to_string(),
487            relation: "related".to_string(),
488            weight: 1.0,
489            ts: "2026-01-01T00:00:00Z".to_string(),
490        };
491        append_edge(&conn, &edge).unwrap();
492        let edges = read_edges(&conn, 10).unwrap();
493        assert_eq!(edges.len(), 1);
494        assert_eq!(edges[0].source, "a");
495    }
496
497    #[test]
498    fn edges_for_node_returns_both_directions() {
499        let conn = mem_db();
500        append_edge(
501            &conn,
502            &GraphEdge {
503                id: "e1".into(),
504                source: "a".into(),
505                target: "b".into(),
506                relation: "related".into(),
507                weight: 1.0,
508                ts: "2026-01-01T00:00:00Z".into(),
509            },
510        )
511        .unwrap();
512        append_edge(
513            &conn,
514            &GraphEdge {
515                id: "e2".into(),
516                source: "c".into(),
517                target: "a".into(),
518                relation: "related".into(),
519                weight: 1.0,
520                ts: "2026-01-01T00:00:00Z".into(),
521            },
522        )
523        .unwrap();
524        let edges = edges_for_node(&conn, "a").unwrap();
525        assert_eq!(edges.len(), 2);
526    }
527
528    #[test]
529    fn test_remove_edges_for_node() {
530        let conn = mem_db();
531        append_edge(
532            &conn,
533            &GraphEdge {
534                id: "e1".into(),
535                source: "a".into(),
536                target: "b".into(),
537                relation: "related".into(),
538                weight: 1.0,
539                ts: "2026-01-01T00:00:00Z".into(),
540            },
541        )
542        .unwrap();
543        remove_edges_for_node(&conn, "a").unwrap();
544        assert!(read_edges(&conn, 10).unwrap().is_empty());
545    }
546
547    #[test]
548    fn append_edges_inserts_batch() {
549        let conn = mem_db();
550        let edges = vec![
551            GraphEdge {
552                id: "e1".into(),
553                source: "a".into(),
554                target: "b".into(),
555                relation: "cites".into(),
556                weight: 1.0,
557                ts: "2026-01-01T00:00:00Z".into(),
558            },
559            GraphEdge {
560                id: "e2".into(),
561                source: "a".into(),
562                target: "c".into(),
563                relation: "cites".into(),
564                weight: 0.5,
565                ts: "2026-01-01T00:00:00Z".into(),
566            },
567        ];
568        append_edges(&conn, &edges).unwrap();
569        assert_eq!(read_edges(&conn, 10).unwrap().len(), 2);
570    }
571
572    #[test]
573    fn append_edges_empty_is_noop() {
574        let conn = mem_db();
575        append_edges(&conn, &[]).unwrap();
576        assert!(read_edges(&conn, 10).unwrap().is_empty());
577    }
578
579    #[test]
580    fn append_edges_ignores_duplicate_id_and_unique() {
581        let conn = mem_db();
582        let e = GraphEdge {
583            id: "e1".into(),
584            source: "a".into(),
585            target: "b".into(),
586            relation: "cites".into(),
587            weight: 1.0,
588            ts: "2026-01-01T00:00:00Z".into(),
589        };
590        append_edges(&conn, std::slice::from_ref(&e)).unwrap();
591        // Same id (INSERT OR IGNORE) and same (source, target, relation) with a
592        // different id (unique index) are both skipped.
593        append_edges(
594            &conn,
595            &[
596                e,
597                GraphEdge {
598                    id: "e2".into(),
599                    source: "a".into(),
600                    target: "b".into(),
601                    relation: "cites".into(),
602                    weight: 1.0,
603                    ts: "2026-01-01T00:00:00Z".into(),
604                },
605            ],
606        )
607        .unwrap();
608        assert_eq!(read_edges(&conn, 10).unwrap().len(), 1);
609    }
610
611    #[test]
612    fn edges_for_node_dir_filters_direction_and_relation() {
613        let conn = mem_db();
614        append_edges(
615            &conn,
616            &[
617                GraphEdge {
618                    id: "e1".into(),
619                    source: "a".into(),
620                    target: "b".into(),
621                    relation: "cites".into(),
622                    weight: 1.0,
623                    ts: "t".into(),
624                },
625                GraphEdge {
626                    id: "e2".into(),
627                    source: "c".into(),
628                    target: "a".into(),
629                    relation: "cites".into(),
630                    weight: 1.0,
631                    ts: "t".into(),
632                },
633                GraphEdge {
634                    id: "e3".into(),
635                    source: "a".into(),
636                    target: "d".into(),
637                    relation: "see_also".into(),
638                    weight: 1.0,
639                    ts: "t".into(),
640                },
641            ],
642        )
643        .unwrap();
644        // Out-edges of `a`: b (cites) and d (see_also).
645        assert_eq!(
646            edges_for_node_dir(&conn, "a", EdgeDirection::Out, None)
647                .unwrap()
648                .len(),
649            2
650        );
651        // Out-edges of `a` restricted to `cites`: only b.
652        let out_cites = edges_for_node_dir(&conn, "a", EdgeDirection::Out, Some("cites")).unwrap();
653        assert_eq!(out_cites.len(), 1);
654        assert_eq!(out_cites[0].target, "b");
655        // In-edges of `a`: c→a.
656        let inc = edges_for_node_dir(&conn, "a", EdgeDirection::In, None).unwrap();
657        assert_eq!(inc.len(), 1);
658        assert_eq!(inc[0].source, "c");
659        // Both directions: b, d, c → 3.
660        assert_eq!(
661            edges_for_node_dir(&conn, "a", EdgeDirection::Both, None)
662                .unwrap()
663                .len(),
664            3
665        );
666    }
667}