Skip to main content

llm_kernel/graph/
dedup.rs

1//! Node deduplication: prevent duplicate nodes by title within a time window.
2
3use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::lifecycle::days_to_ymd;
8use super::store::upsert_node;
9use super::types::GraphNode;
10
11/// Write with deduplication. If a node with the same `title` was written within
12/// `window_hours`, return the existing ID instead. Otherwise, insert and return
13/// the new ID.
14///
15/// Returns `(id, was_deduplicated)`.
16pub fn upsert_node_dedup(
17    conn: &Connection,
18    node: &GraphNode,
19    window_hours: u64,
20) -> Result<(String, bool)> {
21    if let Some(existing_id) = find_duplicate(conn, &node.title, window_hours)? {
22        return Ok((existing_id, true));
23    }
24    upsert_node(conn, node)?;
25    Ok((node.id.clone(), false))
26}
27
28/// Find a node with the given title updated within `window_hours`.
29pub fn find_duplicate(conn: &Connection, title: &str, window_hours: u64) -> Result<Option<String>> {
30    let cutoff = compute_cutoff_timestamp(window_hours);
31    let result = conn.query_row(
32        "SELECT id FROM nodes WHERE title = ?1 AND updated > ?2 ORDER BY updated DESC LIMIT 1",
33        params![title, cutoff],
34        |row| row.get::<_, String>(0),
35    );
36    match result {
37        Ok(id) => Ok(Some(id)),
38        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
39        Err(e) => Err(KernelError::Store(e.to_string())),
40    }
41}
42
43fn compute_cutoff_timestamp(window_hours: u64) -> String {
44    let secs = std::time::SystemTime::now()
45        .duration_since(std::time::SystemTime::UNIX_EPOCH)
46        .unwrap_or_default()
47        .as_secs()
48        .saturating_sub(window_hours * 3600);
49    let (y, m, d) = days_to_ymd(secs / 86400);
50    let hh = (secs / 3600) % 24;
51    let mm = (secs / 60) % 60;
52    let ss = secs % 60;
53    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use crate::graph::schema::init_graph_schema;
60    use crate::graph::store::read_node;
61    use crate::graph::types::GraphNode;
62    use rusqlite::Connection;
63
64    fn mem_db() -> Connection {
65        let conn = Connection::open_in_memory().unwrap();
66        init_graph_schema(&conn).unwrap();
67        conn
68    }
69
70    fn now_iso() -> String {
71        let secs = std::time::SystemTime::now()
72            .duration_since(std::time::SystemTime::UNIX_EPOCH)
73            .unwrap_or_default()
74            .as_secs();
75        let (y, m, d) = days_to_ymd(secs / 86400);
76        let hh = (secs / 3600) % 24;
77        let mm = (secs / 60) % 60;
78        let ss = secs % 60;
79        format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
80    }
81
82    fn test_node(id: &str, title: &str) -> GraphNode {
83        let now = now_iso();
84        GraphNode {
85            id: id.to_string(),
86            node_type: "concept".to_string(),
87            title: title.to_string(),
88            body: String::new(),
89            tags: vec![],
90            projects: vec![],
91            agents: vec![],
92            created: now.clone(),
93            updated: now,
94            importance: 0.5,
95            access_count: 0,
96            accessed_at: String::new(),
97        }
98    }
99
100    #[test]
101    fn dedup_inserts_new_node() {
102        let conn = mem_db();
103        let (id, was_dup) = upsert_node_dedup(&conn, &test_node("n1", "My Title"), 24).unwrap();
104        assert_eq!(id, "n1");
105        assert!(!was_dup);
106        assert!(read_node(&conn, "n1").unwrap().is_some());
107    }
108
109    #[test]
110    fn dedup_returns_existing_for_same_title() {
111        let conn = mem_db();
112        upsert_node_dedup(&conn, &test_node("n1", "Same Title"), 24).unwrap();
113
114        // Second write with same title but different ID
115        let (id, was_dup) = upsert_node_dedup(&conn, &test_node("n2", "Same Title"), 24).unwrap();
116        assert_eq!(id, "n1");
117        assert!(was_dup);
118
119        // Original node still exists, second was NOT written
120        assert!(read_node(&conn, "n1").unwrap().is_some());
121        assert!(read_node(&conn, "n2").unwrap().is_none());
122    }
123
124    #[test]
125    fn dedup_allows_after_window_expires() {
126        let conn = mem_db();
127        // Insert with a very old timestamp (before the window)
128        let mut old_node = test_node("n1", "Old Title");
129        old_node.updated = "2020-01-01T00:00:00Z".to_string();
130        upsert_node(&conn, &old_node).unwrap();
131
132        // New write with same title should succeed (old one is outside window)
133        let (id, was_dup) = upsert_node_dedup(&conn, &test_node("n2", "Old Title"), 24).unwrap();
134        assert_eq!(id, "n2");
135        assert!(!was_dup);
136    }
137
138    #[test]
139    fn find_duplicate_returns_none_when_empty() {
140        let conn = mem_db();
141        let result = find_duplicate(&conn, "nonexistent", 24).unwrap();
142        assert!(result.is_none());
143    }
144}