Skip to main content

sediment/
graph.rs

1//! Graph store using SQLite for relationship tracking between memories.
2//!
3//! Provides a graph layer alongside LanceDB for tracking
4//! relationships like RELATED, SUPERSEDES, and CO_ACCESSED between items.
5
6use std::path::Path;
7
8use rusqlite::{Connection, params};
9use tracing::debug;
10
11use crate::error::{Result, SedimentError};
12
13/// SQLite-backed graph store for memory relationships.
14pub struct GraphStore {
15    conn: Connection,
16}
17
18impl GraphStore {
19    /// Open or create the graph store using the given SQLite database path.
20    /// Shares the same file as access.db.
21    pub fn open(path: &Path) -> Result<Self> {
22        let conn = Connection::open(path).map_err(|e| {
23            SedimentError::Database(format!("Failed to open graph database: {}", e))
24        })?;
25
26        if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;") {
27            tracing::warn!("Failed to set SQLite PRAGMAs (graph): {}", e);
28        }
29
30        conn.execute_batch(
31            "CREATE TABLE IF NOT EXISTS graph_nodes (
32                id TEXT PRIMARY KEY,
33                project_id TEXT NOT NULL DEFAULT '',
34                created_at INTEGER NOT NULL
35            );
36
37            CREATE TABLE IF NOT EXISTS graph_edges (
38                from_id TEXT NOT NULL,
39                to_id TEXT NOT NULL,
40                edge_type TEXT NOT NULL,
41                strength REAL NOT NULL DEFAULT 0.0,
42                rel_type TEXT NOT NULL DEFAULT '',
43                count INTEGER NOT NULL DEFAULT 0,
44                last_at INTEGER NOT NULL DEFAULT 0,
45                created_at INTEGER NOT NULL,
46                UNIQUE(from_id, to_id, edge_type)
47            );
48
49            CREATE INDEX IF NOT EXISTS idx_edges_from ON graph_edges(from_id, edge_type);
50            CREATE INDEX IF NOT EXISTS idx_edges_to ON graph_edges(to_id, edge_type);",
51        )
52        .map_err(|e| SedimentError::Database(format!("Failed to create graph tables: {}", e)))?;
53
54        Ok(Self { conn })
55    }
56
57    /// Add a Memory node to the graph.
58    pub fn add_node(&self, id: &str, project_id: Option<&str>, created_at: i64) -> Result<()> {
59        let pid = project_id.unwrap_or("");
60
61        self.conn
62            .execute(
63                "INSERT OR IGNORE INTO graph_nodes (id, project_id, created_at) VALUES (?1, ?2, ?3)",
64                params![id, pid, created_at],
65            )
66            .map_err(|e| SedimentError::Database(format!("Failed to add node: {}", e)))?;
67
68        debug!("Added graph node: {}", id);
69        Ok(())
70    }
71
72    /// Remove a Memory node and all its edges from the graph.
73    pub fn remove_node(&self, id: &str) -> Result<()> {
74        // Preserve incoming SUPERSEDES edges (where this node is the to_id)
75        // so that lineage/provenance chains remain intact after node removal.
76        self.conn
77            .execute(
78                "DELETE FROM graph_edges WHERE from_id = ?1 OR (to_id = ?1 AND edge_type != 'supersedes')",
79                params![id],
80            )
81            .map_err(|e| SedimentError::Database(format!("Failed to remove edges: {}", e)))?;
82
83        self.conn
84            .execute("DELETE FROM graph_nodes WHERE id = ?1", params![id])
85            .map_err(|e| SedimentError::Database(format!("Failed to remove node: {}", e)))?;
86
87        debug!("Removed graph node: {}", id);
88        Ok(())
89    }
90
91    /// Add a RELATED edge between two Memory nodes.
92    pub fn add_related_edge(
93        &self,
94        from_id: &str,
95        to_id: &str,
96        strength: f64,
97        rel_type: &str,
98    ) -> Result<()> {
99        let now = chrono::Utc::now().timestamp();
100
101        self.conn
102            .execute(
103                "INSERT OR IGNORE INTO graph_edges (from_id, to_id, edge_type, strength, rel_type, created_at)
104                 VALUES (?1, ?2, 'related', ?3, ?4, ?5)",
105                params![from_id, to_id, strength, rel_type, now],
106            )
107            .map_err(|e| SedimentError::Database(format!("Failed to add related edge: {}", e)))?;
108
109        debug!(
110            "Added RELATED edge: {} -> {} ({})",
111            from_id, to_id, rel_type
112        );
113        Ok(())
114    }
115
116    /// Add a SUPERSEDES edge from new_id to old_id.
117    pub fn add_supersedes_edge(&self, new_id: &str, old_id: &str) -> Result<()> {
118        let now = chrono::Utc::now().timestamp();
119
120        self.conn
121            .execute(
122                "INSERT OR IGNORE INTO graph_edges (from_id, to_id, edge_type, strength, created_at)
123                 VALUES (?1, ?2, 'supersedes', 1.0, ?3)",
124                params![new_id, old_id, now],
125            )
126            .map_err(|e| SedimentError::Database(format!("Failed to add supersedes edge: {}", e)))?;
127
128        debug!("Added SUPERSEDES edge: {} -> {}", new_id, old_id);
129        Ok(())
130    }
131
132    /// Get 1-hop neighbors of the given item IDs via RELATED or SUPERSEDES edges.
133    /// Returns (neighbor_id, rel_type, strength) tuples.
134    ///
135    /// Note on parameter binding: SQLite reuses the same positional parameters (?1..?N)
136    /// across all three IN clauses and the CASE expression. This is correct because
137    /// SQLite binds by position, so the same parameter set is applied to each reference.
138    pub fn get_neighbors(
139        &self,
140        ids: &[&str],
141        min_strength: f64,
142    ) -> Result<Vec<(String, String, f64)>> {
143        if ids.is_empty() {
144            return Ok(Vec::new());
145        }
146
147        let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("?{}", i)).collect();
148        let ph = placeholders.join(",");
149        let strength_idx = ids.len() + 1;
150
151        let sql = format!(
152            "SELECT
153                CASE WHEN from_id IN ({ph}) THEN to_id ELSE from_id END AS neighbor,
154                CASE WHEN edge_type = 'related' THEN rel_type ELSE 'supersedes' END AS rtype,
155                strength
156             FROM graph_edges
157             WHERE (from_id IN ({ph}) OR to_id IN ({ph}))
158               AND edge_type IN ('related', 'supersedes')
159               AND strength >= ?{strength_idx}
160             LIMIT 100"
161        );
162
163        let mut stmt = self.conn.prepare(&sql).map_err(|e| {
164            SedimentError::Database(format!("Failed to prepare neighbors query: {}", e))
165        })?;
166
167        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
168        for id in ids {
169            param_values.push(Box::new(id.to_string()));
170        }
171        param_values.push(Box::new(min_strength));
172
173        let params_ref: Vec<&dyn rusqlite::types::ToSql> =
174            param_values.iter().map(|b| b.as_ref()).collect();
175
176        let rows = stmt
177            .query_map(params_ref.as_slice(), |row| {
178                Ok((
179                    row.get::<_, String>(0)?,
180                    row.get::<_, String>(1)?,
181                    row.get::<_, f64>(2)?,
182                ))
183            })
184            .map_err(|e| SedimentError::Database(format!("Failed to query neighbors: {}", e)))?;
185
186        // Filter out input IDs from results so we never return a query ID as its own neighbor
187        let input_set: std::collections::HashSet<&str> = ids.iter().copied().collect();
188        let mut results = Vec::new();
189        for row in rows {
190            let r = row
191                .map_err(|e| SedimentError::Database(format!("Failed to read neighbor: {}", e)))?;
192            if !input_set.contains(r.0.as_str()) {
193                results.push(r);
194            }
195        }
196
197        Ok(results)
198    }
199
200    /// Record co-access between pairs of item IDs.
201    /// Creates or increments CO_ACCESSED edges.
202    pub fn record_co_access(&self, item_ids: &[String]) -> Result<()> {
203        if item_ids.len() < 2 {
204            return Ok(());
205        }
206
207        // Performance optimization: limit co-access recording to the top 3 items
208        // by position to bound the O(n^2) pair generation. Items beyond position 3
209        // in recall results won't have co-access edges recorded.
210        let item_ids = if item_ids.len() > 3 {
211            &item_ids[..3]
212        } else {
213            item_ids
214        };
215
216        let now = chrono::Utc::now().timestamp();
217
218        for i in 0..item_ids.len() {
219            for j in (i + 1)..item_ids.len() {
220                // Normalize edge direction: smaller ID always goes first to prevent
221                // duplicate edges (A,B) and (B,A) from accumulating separately.
222                let (a, b) = if item_ids[i] <= item_ids[j] {
223                    (&item_ids[i], &item_ids[j])
224                } else {
225                    (&item_ids[j], &item_ids[i])
226                };
227
228                self.conn
229                    .execute(
230                        "INSERT INTO graph_edges (from_id, to_id, edge_type, count, last_at, created_at)
231                         VALUES (?1, ?2, 'co_accessed', 1, ?3, ?3)
232                         ON CONFLICT(from_id, to_id, edge_type)
233                         DO UPDATE SET count = count + 1, last_at = ?3",
234                        params![a, b, now],
235                    )
236                    .map_err(|e| {
237                        SedimentError::Database(format!("Failed to record co-access: {}", e))
238                    })?;
239            }
240        }
241
242        Ok(())
243    }
244
245    /// Get items that are frequently co-accessed with the given IDs.
246    /// Returns (neighbor_id, co_access_count) tuples.
247    pub fn get_co_accessed(&self, ids: &[&str], min_count: i64) -> Result<Vec<(String, i64)>> {
248        if ids.is_empty() {
249            return Ok(Vec::new());
250        }
251
252        let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("?{}", i)).collect();
253        let ph = placeholders.join(",");
254        let min_idx = ids.len() + 1;
255
256        let sql = format!(
257            "SELECT
258                CASE WHEN from_id IN ({ph}) THEN to_id ELSE from_id END AS neighbor,
259                count
260             FROM graph_edges
261             WHERE (from_id IN ({ph}) OR to_id IN ({ph}))
262               AND edge_type = 'co_accessed'
263               AND count >= ?{min_idx}
264             ORDER BY count DESC
265             LIMIT 50"
266        );
267
268        let mut stmt = self.conn.prepare(&sql).map_err(|e| {
269            SedimentError::Database(format!("Failed to prepare co-access query: {}", e))
270        })?;
271
272        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
273        for id in ids {
274            param_values.push(Box::new(id.to_string()));
275        }
276        param_values.push(Box::new(min_count));
277
278        let params_ref: Vec<&dyn rusqlite::types::ToSql> =
279            param_values.iter().map(|b| b.as_ref()).collect();
280
281        let rows = stmt
282            .query_map(params_ref.as_slice(), |row| {
283                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
284            })
285            .map_err(|e| SedimentError::Database(format!("Failed to query co-access: {}", e)))?;
286
287        let mut results = Vec::new();
288        for row in rows {
289            let r = row
290                .map_err(|e| SedimentError::Database(format!("Failed to read co-access: {}", e)))?;
291            results.push(r);
292        }
293
294        // Deduplicate by target_id, keeping highest count
295        results.sort_by(|a, b| b.1.cmp(&a.1));
296        let mut seen = std::collections::HashSet::new();
297        results.retain(|(id, _)| seen.insert(id.clone()));
298
299        Ok(results)
300    }
301
302    /// Transfer all edges from one node to another (used during consolidation merge).
303    pub fn transfer_edges(&self, from_id: &str, to_id: &str) -> Result<()> {
304        // Get all RELATED edges connected to from_id (excluding edges to to_id)
305        let mut stmt = self
306            .conn
307            .prepare(
308                "SELECT from_id, to_id, strength, rel_type, created_at
309             FROM graph_edges
310             WHERE (from_id = ?1 OR to_id = ?1)
311               AND edge_type = 'related'
312               AND from_id != ?2 AND to_id != ?2",
313            )
314            .map_err(|e| {
315                SedimentError::Database(format!("Failed to prepare transfer query: {}", e))
316            })?;
317
318        let edges: Vec<(String, f64, String, i64)> = stmt
319            .query_map(params![from_id, to_id], |row| {
320                let fid: String = row.get(0)?;
321                let tid: String = row.get(1)?;
322                let neighbor = if fid == from_id { tid } else { fid };
323                Ok((neighbor, row.get(2)?, row.get(3)?, row.get(4)?))
324            })
325            .map_err(|e| {
326                SedimentError::Database(format!("Failed to query edges for transfer: {}", e))
327            })?
328            .filter_map(|r| match r {
329                Ok(v) => Some(v),
330                Err(e) => {
331                    tracing::warn!("transfer_edges: failed to read row: {}", e);
332                    None
333                }
334            })
335            .collect();
336
337        // Create edges on the new node
338        for (neighbor, strength, rel_type, _) in &edges {
339            if let Err(e) = self.add_related_edge(to_id, neighbor, *strength, rel_type) {
340                tracing::warn!("transfer edge to {} failed: {}", neighbor, e);
341            }
342        }
343
344        Ok(())
345    }
346
347    /// Detect triangles of RELATED items (for clustering).
348    /// Returns sets of 3 item IDs that form triangles.
349    pub fn detect_clusters(&self) -> Result<Vec<(String, String, String)>> {
350        let mut stmt = self
351            .conn
352            .prepare(
353                "WITH biedges AS (
354                SELECT from_id AS a, to_id AS b FROM graph_edges WHERE edge_type = 'related'
355                UNION ALL
356                SELECT to_id AS a, from_id AS b FROM graph_edges WHERE edge_type = 'related'
357            )
358            SELECT DISTINCT e1.a, e1.b, e2.b
359            FROM biedges e1
360            JOIN biedges e2 ON e1.b = e2.a
361            JOIN biedges e3 ON e2.b = e3.a AND e3.b = e1.a
362            WHERE e1.a < e1.b AND e1.b < e2.b
363            LIMIT 50",
364            )
365            .map_err(|e| SedimentError::Database(format!("Failed to detect clusters: {}", e)))?;
366
367        let rows = stmt
368            .query_map([], |row| {
369                Ok((
370                    row.get::<_, String>(0)?,
371                    row.get::<_, String>(1)?,
372                    row.get::<_, String>(2)?,
373                ))
374            })
375            .map_err(|e| SedimentError::Database(format!("Failed to read clusters: {}", e)))?;
376
377        let mut clusters = Vec::new();
378        for r in rows.flatten() {
379            clusters.push(r);
380        }
381
382        Ok(clusters)
383    }
384
385    /// Get 1-hop neighbors grouped by source ID.
386    ///
387    /// For each input ID, returns a list of neighbor IDs from RELATED or SUPERSEDES edges.
388    /// Input IDs are excluded from results.
389    pub fn get_neighbors_mapped(
390        &self,
391        ids: &[&str],
392        min_strength: f64,
393    ) -> Result<std::collections::HashMap<String, Vec<String>>> {
394        if ids.is_empty() {
395            return Ok(std::collections::HashMap::new());
396        }
397
398        let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("?{}", i)).collect();
399        let ph = placeholders.join(",");
400        let strength_idx = ids.len() + 1;
401
402        let sql = format!(
403            "SELECT
404                CASE WHEN from_id IN ({ph}) THEN from_id ELSE to_id END AS source,
405                CASE WHEN from_id IN ({ph}) THEN to_id ELSE from_id END AS neighbor,
406                strength
407             FROM graph_edges
408             WHERE (from_id IN ({ph}) OR to_id IN ({ph}))
409               AND edge_type IN ('related', 'supersedes')
410               AND strength >= ?{strength_idx}
411             LIMIT 500"
412        );
413
414        let mut stmt = self.conn.prepare(&sql).map_err(|e| {
415            SedimentError::Database(format!("Failed to prepare neighbors_mapped query: {}", e))
416        })?;
417
418        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
419        for id in ids {
420            param_values.push(Box::new(id.to_string()));
421        }
422        param_values.push(Box::new(min_strength));
423
424        let params_ref: Vec<&dyn rusqlite::types::ToSql> =
425            param_values.iter().map(|b| b.as_ref()).collect();
426
427        let rows = stmt
428            .query_map(params_ref.as_slice(), |row| {
429                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
430            })
431            .map_err(|e| {
432                SedimentError::Database(format!("Failed to query neighbors_mapped: {}", e))
433            })?;
434
435        let input_set: std::collections::HashSet<&str> = ids.iter().copied().collect();
436        let mut map: std::collections::HashMap<String, Vec<String>> =
437            std::collections::HashMap::new();
438
439        for row in rows {
440            let (source, neighbor) = row.map_err(|e| {
441                SedimentError::Database(format!("Failed to read neighbor_mapped: {}", e))
442            })?;
443            if !input_set.contains(neighbor.as_str()) {
444                map.entry(source).or_default().push(neighbor);
445            }
446        }
447
448        Ok(map)
449    }
450
451    /// Get edge counts for multiple items in a single query.
452    ///
453    /// Returns a map from item ID to its total edge count (all edge types).
454    pub fn get_edge_counts(&self, ids: &[&str]) -> Result<std::collections::HashMap<String, u32>> {
455        if ids.is_empty() {
456            return Ok(std::collections::HashMap::new());
457        }
458
459        // Build a VALUES CTE for the input IDs
460        let unions: String = (1..=ids.len())
461            .map(|i| {
462                if i == 1 {
463                    format!("SELECT ?{} AS id", i)
464                } else {
465                    format!("UNION ALL SELECT ?{}", i)
466                }
467            })
468            .collect::<Vec<_>>()
469            .join(" ");
470
471        // Count edges where the item appears on either side
472        let sql = format!(
473            "SELECT ids.id, COUNT(e.rowid) FROM ({unions}) ids
474            LEFT JOIN graph_edges e ON e.from_id = ids.id OR e.to_id = ids.id
475            GROUP BY ids.id"
476        );
477
478        let mut stmt = self.conn.prepare(&sql).map_err(|e| {
479            SedimentError::Database(format!("Failed to prepare edge_counts query: {}", e))
480        })?;
481
482        let params: Vec<&dyn rusqlite::types::ToSql> = ids
483            .iter()
484            .map(|id| id as &dyn rusqlite::types::ToSql)
485            .collect();
486
487        let rows = stmt
488            .query_map(params.as_slice(), |row| {
489                Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
490            })
491            .map_err(|e| SedimentError::Database(format!("Failed to query edge_counts: {}", e)))?;
492
493        let mut map = std::collections::HashMap::new();
494        for row in rows {
495            let (id, count) = row.map_err(|e| {
496                SedimentError::Database(format!("Failed to read edge_count: {}", e))
497            })?;
498            map.insert(id, count);
499        }
500
501        Ok(map)
502    }
503
504    /// Migrate all graph nodes from one project ID to another.
505    ///
506    /// Used when a project's ID changes (e.g., UUID→git root commit hash).
507    pub fn migrate_project_id(&self, old_id: &str, new_id: &str) -> Result<usize> {
508        let updated = self
509            .conn
510            .execute(
511                "UPDATE graph_nodes SET project_id = ?1 WHERE project_id = ?2",
512                params![new_id, old_id],
513            )
514            .map_err(|e| {
515                SedimentError::Database(format!("Failed to migrate graph nodes: {}", e))
516            })?;
517
518        if updated > 0 {
519            debug!(
520                "Migrated {} graph nodes from project {} to {}",
521                updated, old_id, new_id
522            );
523        }
524        Ok(updated)
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use tempfile::NamedTempFile;
532
533    #[derive(Debug, Clone)]
534    #[allow(dead_code)]
535    struct ConnectionInfo {
536        target_id: String,
537        rel_type: String,
538        strength: f64,
539        count: Option<i64>,
540    }
541
542    impl GraphStore {
543        fn get_full_connections(&self, item_id: &str) -> Result<Vec<ConnectionInfo>> {
544            let mut stmt = self
545                .conn
546                .prepare(
547                    "SELECT
548                    CASE WHEN from_id = ?1 THEN to_id ELSE from_id END AS neighbor,
549                    edge_type, strength, rel_type, count
550                 FROM graph_edges WHERE from_id = ?1 OR to_id = ?1",
551                )
552                .map_err(|e| {
553                    SedimentError::Database(format!("Failed to prepare connections query: {}", e))
554                })?;
555
556            let rows = stmt
557                .query_map(params![item_id], |row| {
558                    let neighbor: String = row.get(0)?;
559                    let edge_type: String = row.get(1)?;
560                    let strength: f64 = row.get(2)?;
561                    let rel_type_val: String = row.get(3)?;
562                    let count: i64 = row.get(4)?;
563                    let display_type = match edge_type.as_str() {
564                        "related" => rel_type_val.clone(),
565                        "supersedes" => "supersedes".to_string(),
566                        "co_accessed" => "co_accessed".to_string(),
567                        _ => edge_type.clone(),
568                    };
569                    Ok(ConnectionInfo {
570                        target_id: neighbor,
571                        rel_type: display_type,
572                        strength,
573                        count: if edge_type == "co_accessed" {
574                            Some(count)
575                        } else {
576                            None
577                        },
578                    })
579                })
580                .map_err(|e| {
581                    SedimentError::Database(format!("Failed to query connections: {}", e))
582                })?;
583
584            let mut connections = Vec::new();
585            for row in rows {
586                let r = row.map_err(|e| {
587                    SedimentError::Database(format!("Failed to read connection: {}", e))
588                })?;
589                connections.push(r);
590            }
591            Ok(connections)
592        }
593
594        fn get_edge_count(&self, item_id: &str) -> Result<u32> {
595            let count: i64 = self
596                .conn
597                .query_row(
598                    "SELECT COUNT(*) FROM graph_edges WHERE from_id = ?1 OR to_id = ?1",
599                    params![item_id],
600                    |row| row.get(0),
601                )
602                .map_err(|e| SedimentError::Database(format!("Failed to count edges: {}", e)))?;
603            Ok(count as u32)
604        }
605    }
606
607    fn open_test_graph() -> GraphStore {
608        let tmp = NamedTempFile::new().unwrap();
609        GraphStore::open(tmp.path()).unwrap()
610    }
611
612    #[test]
613    fn test_get_neighbors_excludes_input_ids() {
614        // Fix #7: get_neighbors should never return an input ID as a neighbor
615        let graph = open_test_graph();
616        let now = chrono::Utc::now().timestamp();
617        graph.add_node("A", Some("proj"), now).unwrap();
618        graph.add_node("B", Some("proj"), now).unwrap();
619        graph.add_node("C", Some("proj"), now).unwrap();
620
621        // Create edges A-B, B-C
622        graph.add_related_edge("A", "B", 0.9, "test").unwrap();
623        graph.add_related_edge("B", "C", 0.9, "test").unwrap();
624
625        // Query neighbors for [A, B] — should only return C, not A or B
626        let neighbors = graph.get_neighbors(&["A", "B"], 0.0).unwrap();
627        let neighbor_ids: Vec<&str> = neighbors.iter().map(|(id, _, _)| id.as_str()).collect();
628        assert!(neighbor_ids.contains(&"C"));
629        assert!(!neighbor_ids.contains(&"A"));
630        assert!(!neighbor_ids.contains(&"B"));
631    }
632
633    #[test]
634    fn test_co_access_normalized_direction() {
635        // Fix #8: co-access edges should be normalized so (A,B) and (B,A) don't create duplicates
636        let graph = open_test_graph();
637        let now = chrono::Utc::now().timestamp();
638        graph.add_node("Z", Some("proj"), now).unwrap();
639        graph.add_node("A", Some("proj"), now).unwrap();
640
641        // Record co-access with Z before A (Z > A lexicographically)
642        graph
643            .record_co_access(&["Z".to_string(), "A".to_string()])
644            .unwrap();
645        // Record again with reversed order
646        graph
647            .record_co_access(&["A".to_string(), "Z".to_string()])
648            .unwrap();
649
650        // Should only have 1 edge with count=2 (not 2 edges with count=1 each)
651        let count: i64 = graph
652            .conn
653            .query_row(
654                "SELECT COUNT(*) FROM graph_edges WHERE edge_type = 'co_accessed'",
655                [],
656                |row| row.get(0),
657            )
658            .unwrap();
659        assert_eq!(count, 1, "Should have exactly 1 co-access edge");
660
661        let edge_count: i64 = graph
662            .conn
663            .query_row(
664                "SELECT count FROM graph_edges WHERE edge_type = 'co_accessed'",
665                [],
666                |row| row.get(0),
667            )
668            .unwrap();
669        assert_eq!(edge_count, 2, "Edge count should be 2 (incremented twice)");
670    }
671
672    #[test]
673    fn test_transfer_edges_preserves_relationships() {
674        // Fix #6: transfer_edges should move edges from old node to new node
675        let graph = open_test_graph();
676        let now = chrono::Utc::now().timestamp();
677        graph.add_node("old", Some("proj"), now).unwrap();
678        graph.add_node("new", Some("proj"), now).unwrap();
679        graph.add_node("friend", Some("proj"), now).unwrap();
680
681        graph
682            .add_related_edge("old", "friend", 0.9, "test")
683            .unwrap();
684
685        // Transfer edges from old to new
686        graph.transfer_edges("old", "new").unwrap();
687
688        // New node should now have edge to friend
689        let neighbors = graph.get_neighbors(&["new"], 0.0).unwrap();
690        assert!(
691            !neighbors.is_empty(),
692            "New node should have inherited edges"
693        );
694        let neighbor_ids: Vec<&str> = neighbors.iter().map(|(id, _, _)| id.as_str()).collect();
695        assert!(neighbor_ids.contains(&"friend"));
696    }
697
698    #[test]
699    fn test_remove_node_preserves_incoming_supersedes() {
700        // Bug #4: remove_node should preserve incoming SUPERSEDES edges for lineage
701        let graph = open_test_graph();
702        let now = chrono::Utc::now().timestamp();
703        graph.add_node("new", Some("proj"), now).unwrap();
704        graph.add_node("old", Some("proj"), now).unwrap();
705
706        // Simulate replace workflow: create SUPERSEDES edge new -> old
707        graph.add_supersedes_edge("new", "old").unwrap();
708
709        // Now remove the old node (as execute_store does)
710        graph.remove_node("old").unwrap();
711
712        // The SUPERSEDES edge (new -> old) should survive because old is the to_id
713        let connections = graph.get_full_connections("new").unwrap();
714        assert_eq!(connections.len(), 1, "SUPERSEDES edge should be preserved");
715        assert_eq!(connections[0].target_id, "old");
716        assert_eq!(connections[0].rel_type, "supersedes");
717    }
718
719    #[test]
720    fn test_get_neighbors_bounded() {
721        // Bug #3: get_neighbors should return at most 100 results
722        let graph = open_test_graph();
723        let now = chrono::Utc::now().timestamp();
724        graph.add_node("center", Some("proj"), now).unwrap();
725
726        for i in 0..150 {
727            let id = format!("n{}", i);
728            graph.add_node(&id, Some("proj"), now).unwrap();
729            graph.add_related_edge("center", &id, 0.9, "test").unwrap();
730        }
731
732        let neighbors = graph.get_neighbors(&["center"], 0.0).unwrap();
733        assert!(
734            neighbors.len() <= 100,
735            "get_neighbors should return at most 100, got {}",
736            neighbors.len()
737        );
738    }
739
740    #[test]
741    fn test_get_neighbors_mapped() {
742        let graph = open_test_graph();
743        let now = chrono::Utc::now().timestamp();
744        graph.add_node("A", Some("proj"), now).unwrap();
745        graph.add_node("B", Some("proj"), now).unwrap();
746        graph.add_node("C", Some("proj"), now).unwrap();
747        graph.add_node("D", Some("proj"), now).unwrap();
748
749        graph.add_related_edge("A", "C", 0.9, "test").unwrap();
750        graph.add_related_edge("B", "D", 0.9, "test").unwrap();
751
752        let map = graph.get_neighbors_mapped(&["A", "B"], 0.0).unwrap();
753
754        // A should have C as neighbor, B should have D
755        assert!(map.get("A").unwrap().contains(&"C".to_string()));
756        assert!(map.get("B").unwrap().contains(&"D".to_string()));
757        // Input IDs should not appear as neighbors
758        for neighbors in map.values() {
759            assert!(!neighbors.contains(&"A".to_string()));
760            assert!(!neighbors.contains(&"B".to_string()));
761        }
762    }
763
764    #[test]
765    fn test_get_edge_counts() {
766        let graph = open_test_graph();
767        let now = chrono::Utc::now().timestamp();
768        graph.add_node("X", Some("proj"), now).unwrap();
769        graph.add_node("Y", Some("proj"), now).unwrap();
770        graph.add_node("Z", Some("proj"), now).unwrap();
771
772        graph.add_related_edge("X", "Y", 0.9, "test").unwrap();
773        graph.add_related_edge("X", "Z", 0.9, "test").unwrap();
774        graph.add_related_edge("Y", "Z", 0.9, "test").unwrap();
775
776        let counts = graph.get_edge_counts(&["X", "Y", "Z"]).unwrap();
777
778        // Verify batch counts match individual counts
779        assert_eq!(
780            counts.get("X").copied().unwrap_or(0),
781            graph.get_edge_count("X").unwrap()
782        );
783        assert_eq!(
784            counts.get("Y").copied().unwrap_or(0),
785            graph.get_edge_count("Y").unwrap()
786        );
787        assert_eq!(
788            counts.get("Z").copied().unwrap_or(0),
789            graph.get_edge_count("Z").unwrap()
790        );
791
792        // X has 2 edges (X-Y, X-Z), Y has 2 (X-Y, Y-Z), Z has 2 (X-Z, Y-Z)
793        assert_eq!(counts["X"], 2);
794        assert_eq!(counts["Y"], 2);
795        assert_eq!(counts["Z"], 2);
796    }
797}