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            ..Default::default()
98        }
99    }
100
101    #[test]
102    fn dedup_inserts_new_node() {
103        let conn = mem_db();
104        let (id, was_dup) = upsert_node_dedup(&conn, &test_node("n1", "My Title"), 24).unwrap();
105        assert_eq!(id, "n1");
106        assert!(!was_dup);
107        assert!(read_node(&conn, "n1").unwrap().is_some());
108    }
109
110    #[test]
111    fn dedup_returns_existing_for_same_title() {
112        let conn = mem_db();
113        upsert_node_dedup(&conn, &test_node("n1", "Same Title"), 24).unwrap();
114
115        // Second write with same title but different ID
116        let (id, was_dup) = upsert_node_dedup(&conn, &test_node("n2", "Same Title"), 24).unwrap();
117        assert_eq!(id, "n1");
118        assert!(was_dup);
119
120        // Original node still exists, second was NOT written
121        assert!(read_node(&conn, "n1").unwrap().is_some());
122        assert!(read_node(&conn, "n2").unwrap().is_none());
123    }
124
125    #[test]
126    fn dedup_allows_after_window_expires() {
127        let conn = mem_db();
128        // Insert with a very old timestamp (before the window)
129        let mut old_node = test_node("n1", "Old Title");
130        old_node.updated = "2020-01-01T00:00:00Z".to_string();
131        upsert_node(&conn, &old_node).unwrap();
132
133        // New write with same title should succeed (old one is outside window)
134        let (id, was_dup) = upsert_node_dedup(&conn, &test_node("n2", "Old Title"), 24).unwrap();
135        assert_eq!(id, "n2");
136        assert!(!was_dup);
137    }
138
139    #[test]
140    fn find_duplicate_returns_none_when_empty() {
141        let conn = mem_db();
142        let result = find_duplicate(&conn, "nonexistent", 24).unwrap();
143        assert!(result.is_none());
144    }
145}