Skip to main content

bones_triage/graph/
build.rs

1//! Graph construction from `SQLite` projection database.
2//!
3//! # Overview
4//!
5//! This module queries the `item_dependencies` table in the `SQLite` projection
6//! database and builds a [`petgraph`] directed graph suitable for triage
7//! computations (centrality metrics, cycle detection, scheduling analysis).
8//!
9//! ## Edge Direction
10//!
11//! An edge `A → B` in the graph means "A **blocks** B" — A must be completed
12//! before B can start. This matches the `item_dependencies` schema where:
13//!
14//! ```sql
15//! item_id         -- the dependent (blocked item)
16//! depends_on_item_id  -- the dependency (the blocker)
17//! ```
18//!
19//! So for each row `(item_id=B, depends_on_item_id=A, link_type='blocks')`
20//! we insert edge `A → B`.
21//!
22//! ## Cache Invalidation
23//!
24//! The graph is associated with a content hash of the edge set (BLAKE3 of
25//! the sorted edge list). Callers can compare the hash against a stored
26//! value to avoid rebuilding the graph on every access.
27//!
28//! ## Only Blocking Edges
29//!
30//! Only `link_type = 'blocks'` edges are included in the dependency graph.
31//! Informational `related_to` links are excluded.
32
33#![allow(clippy::module_name_repetitions)]
34
35use std::collections::HashMap;
36
37use anyhow::{Context, Result};
38use petgraph::graph::{DiGraph, NodeIndex};
39use rusqlite::Connection;
40use tracing::instrument;
41
42// ---------------------------------------------------------------------------
43// RawGraph
44// ---------------------------------------------------------------------------
45
46/// A directed dependency graph built from `SQLite`.
47///
48/// Nodes are item IDs (strings). An edge `A → B` means "A blocks B".
49///
50/// `RawGraph` preserves all edges as stored in the projection database,
51/// including any cycles that may exist due to concurrent or inconsistent
52/// link events. Use [`crate::graph::normalize`] to condense SCCs and
53/// optionally reduce transitive edges.
54#[derive(Debug)]
55pub struct RawGraph {
56    /// Directed graph: nodes = item IDs, edges = blocking relationships.
57    pub graph: DiGraph<String, ()>,
58    /// Mapping from item ID to petgraph `NodeIndex`.
59    pub node_map: HashMap<String, NodeIndex>,
60    /// BLAKE3 content hash of the edge set used for cache invalidation.
61    pub content_hash: String,
62}
63
64impl RawGraph {
65    /// Build a [`RawGraph`] by querying `item_dependencies` in `conn`.
66    ///
67    /// Only rows where `link_type IN ('blocks', 'blocked_by')` are used.
68    /// All non-deleted items from the `items` table are included as nodes
69    /// (even those with no dependencies) so downstream metrics see the
70    /// full node set.
71    ///
72    /// The content hash is derived from the sorted list of `(blocker, blocked)`
73    /// pairs, so it changes only when edges change.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if the `SQLite` query fails.
78    #[instrument(skip(conn))]
79    #[allow(clippy::similar_names)]
80    pub fn from_sqlite(conn: &Connection) -> Result<Self> {
81        // Step 1: load all non-deleted item IDs as graph nodes.
82        let item_ids = load_item_ids(conn)?;
83
84        let mut graph = DiGraph::<String, ()>::new();
85        let mut node_map: HashMap<String, NodeIndex> = HashMap::with_capacity(item_ids.len());
86
87        for id in item_ids {
88            let idx = graph.add_node(id.clone());
89            node_map.insert(id, idx);
90        }
91
92        // Step 2: load blocking edges and add them to the graph.
93        let edges = load_blocking_edges(conn)?;
94
95        // Compute content hash before mutating graph.
96        let content_hash = compute_edge_hash(&edges);
97
98        for (blocker, blocked) in edges {
99            // If either endpoint is not already a node, add it.
100            // This handles references to items not in the items table
101            // (e.g., deleted items still referenced in dependencies).
102            let blocker_idx = *node_map
103                .entry(blocker.clone())
104                .or_insert_with(|| graph.add_node(blocker.clone()));
105            let blocked_idx = *node_map
106                .entry(blocked.clone())
107                .or_insert_with(|| graph.add_node(blocked.clone()));
108
109            // Avoid duplicate edges (petgraph allows them by default).
110            if !graph.contains_edge(blocker_idx, blocked_idx) {
111                graph.add_edge(blocker_idx, blocked_idx, ());
112            }
113        }
114
115        Ok(Self {
116            graph,
117            node_map,
118            content_hash,
119        })
120    }
121
122    /// Return the number of nodes (items) in the graph.
123    #[must_use]
124    pub fn node_count(&self) -> usize {
125        self.graph.node_count()
126    }
127
128    /// Return the number of edges (blocking relationships) in the graph.
129    #[must_use]
130    pub fn edge_count(&self) -> usize {
131        self.graph.edge_count()
132    }
133
134    /// Look up the `NodeIndex` for an item ID.
135    #[must_use]
136    pub fn node_index(&self, item_id: &str) -> Option<NodeIndex> {
137        self.node_map.get(item_id).copied()
138    }
139
140    /// Return the item ID label for a node.
141    #[must_use]
142    pub fn item_id(&self, idx: NodeIndex) -> Option<&str> {
143        self.graph.node_weight(idx).map(String::as_str)
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Internal helpers
149// ---------------------------------------------------------------------------
150
151/// Load all non-deleted item IDs from the projection database.
152fn load_item_ids(conn: &Connection) -> Result<Vec<String>> {
153    let mut stmt = conn
154        .prepare("SELECT item_id FROM items WHERE is_deleted = 0")
155        .context("prepare item_ids query")?;
156
157    let ids = stmt
158        .query_map([], |row| row.get::<_, String>(0))
159        .context("execute item_ids query")?
160        .collect::<Result<Vec<_>, _>>()
161        .context("collect item_ids")?;
162
163    Ok(ids)
164}
165
166/// Load blocking dependency edges from `item_dependencies`.
167///
168/// Returns `Vec<(blocker_id, blocked_id)>` where `blocker_id → blocked_id`.
169#[allow(clippy::similar_names)]
170fn load_blocking_edges(conn: &Connection) -> Result<Vec<(String, String)>> {
171    // link_type 'blocks': depends_on_item_id blocks item_id
172    // link_type 'blocked_by': item_id is blocked_by depends_on_item_id
173    // Both map to the same edge direction: depends_on_item_id → item_id
174    let mut stmt = conn
175        .prepare(
176            "SELECT depends_on_item_id, item_id
177             FROM item_dependencies
178             WHERE link_type IN ('blocks', 'blocked_by')
179             ORDER BY depends_on_item_id, item_id",
180        )
181        .context("prepare blocking_edges query")?;
182
183    let edges = stmt
184        .query_map([], |row| {
185            let blocker: String = row.get(0)?;
186            let blocked: String = row.get(1)?;
187            Ok((blocker, blocked))
188        })
189        .context("execute blocking_edges query")?
190        .collect::<Result<Vec<_>, _>>()
191        .context("collect blocking edges")?;
192
193    Ok(edges)
194}
195
196/// Compute a BLAKE3 hash of the sorted edge list for cache invalidation.
197fn compute_edge_hash(edges: &[(String, String)]) -> String {
198    let mut hasher = blake3::Hasher::new();
199    for (blocker, blocked) in edges {
200        hasher.update(blocker.as_bytes());
201        hasher.update(b"\x00");
202        hasher.update(blocked.as_bytes());
203        hasher.update(b"\x00");
204    }
205    format!("blake3:{}", hasher.finalize())
206}
207
208// ---------------------------------------------------------------------------
209// Tests
210// ---------------------------------------------------------------------------
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use bones_core::db::migrations;
216    use rusqlite::params;
217
218    fn setup_db() -> rusqlite::Connection {
219        let mut conn = rusqlite::Connection::open_in_memory().expect("in-memory db");
220        migrations::migrate(&mut conn).expect("migrate");
221        conn
222    }
223
224    fn insert_item(conn: &rusqlite::Connection, item_id: &str) {
225        conn.execute(
226            "INSERT INTO items (item_id, title, kind, state, urgency, is_deleted, created_at_us, updated_at_us)
227             VALUES (?1, ?1, 'task', 'open', 'default', 0, 1000, 1000)",
228            params![item_id],
229        )
230        .expect("insert item");
231    }
232
233    fn insert_dep(conn: &rusqlite::Connection, item_id: &str, depends_on: &str) {
234        conn.execute(
235            "INSERT INTO item_dependencies (item_id, depends_on_item_id, link_type, created_at_us)
236             VALUES (?1, ?2, 'blocks', 1000)",
237            params![item_id, depends_on],
238        )
239        .expect("insert dependency");
240    }
241
242    #[test]
243    fn empty_db_produces_empty_graph() {
244        let conn = setup_db();
245        let graph = RawGraph::from_sqlite(&conn).expect("build graph");
246        assert_eq!(graph.node_count(), 0);
247        assert_eq!(graph.edge_count(), 0);
248        // Hash of empty edge set is stable.
249        assert!(graph.content_hash.starts_with("blake3:"));
250    }
251
252    #[test]
253    fn items_without_deps_are_nodes_only() {
254        let conn = setup_db();
255        insert_item(&conn, "bn-001");
256        insert_item(&conn, "bn-002");
257
258        let graph = RawGraph::from_sqlite(&conn).expect("build graph");
259        assert_eq!(graph.node_count(), 2);
260        assert_eq!(graph.edge_count(), 0);
261        assert!(graph.node_index("bn-001").is_some());
262        assert!(graph.node_index("bn-002").is_some());
263    }
264
265    #[test]
266    fn single_blocking_edge_direction() {
267        let conn = setup_db();
268        insert_item(&conn, "bn-001");
269        insert_item(&conn, "bn-002");
270        // bn-002 depends on bn-001 → bn-001 blocks bn-002
271        insert_dep(&conn, "bn-002", "bn-001");
272
273        let graph = RawGraph::from_sqlite(&conn).expect("build graph");
274        assert_eq!(graph.edge_count(), 1);
275
276        let a = graph.node_index("bn-001").expect("bn-001 node");
277        let b = graph.node_index("bn-002").expect("bn-002 node");
278        // Edge should go bn-001 → bn-002 (blocker → blocked)
279        assert!(graph.graph.contains_edge(a, b), "expected bn-001 → bn-002");
280        assert!(!graph.graph.contains_edge(b, a), "no reverse edge");
281    }
282
283    #[test]
284    fn deleted_items_excluded_as_nodes() {
285        let conn = setup_db();
286        insert_item(&conn, "bn-001");
287        conn.execute(
288            "INSERT INTO items (item_id, title, kind, state, urgency, is_deleted, created_at_us, updated_at_us)
289             VALUES ('bn-deleted', 'deleted', 'task', 'open', 'default', 1, 1000, 1000)",
290            [],
291        )
292        .expect("insert deleted item");
293
294        let graph = RawGraph::from_sqlite(&conn).expect("build graph");
295        assert_eq!(graph.node_count(), 1);
296        assert!(graph.node_index("bn-001").is_some());
297        assert!(graph.node_index("bn-deleted").is_none());
298    }
299
300    #[test]
301    fn duplicate_edges_not_added() {
302        let conn = setup_db();
303        insert_item(&conn, "bn-001");
304        insert_item(&conn, "bn-002");
305        insert_dep(&conn, "bn-002", "bn-001");
306
307        // Add another dependency with different link_type (blocked_by)
308        // pointing to the same logical edge — should still be 1 graph edge.
309        conn.execute(
310            "INSERT INTO item_dependencies (item_id, depends_on_item_id, link_type, created_at_us)
311             VALUES ('bn-002', 'bn-001', 'blocked_by', 2000)",
312            [],
313        )
314        .expect("insert blocked_by dep");
315
316        let graph = RawGraph::from_sqlite(&conn).expect("build graph");
317        // Both 'blocks' and 'blocked_by' map to same directed edge, deduplicated.
318        assert_eq!(graph.edge_count(), 1);
319    }
320
321    #[test]
322    fn content_hash_changes_with_edges() {
323        let conn = setup_db();
324        insert_item(&conn, "bn-001");
325        insert_item(&conn, "bn-002");
326
327        let empty_hash = RawGraph::from_sqlite(&conn)
328            .expect("build graph")
329            .content_hash;
330
331        insert_dep(&conn, "bn-002", "bn-001");
332        let with_edge_hash = RawGraph::from_sqlite(&conn)
333            .expect("build graph")
334            .content_hash;
335
336        assert_ne!(
337            empty_hash, with_edge_hash,
338            "hash must change when edges added"
339        );
340    }
341
342    #[test]
343    fn chain_of_deps() {
344        let conn = setup_db();
345        insert_item(&conn, "bn-001");
346        insert_item(&conn, "bn-002");
347        insert_item(&conn, "bn-003");
348        insert_dep(&conn, "bn-002", "bn-001"); // bn-001 → bn-002
349        insert_dep(&conn, "bn-003", "bn-002"); // bn-002 → bn-003
350
351        let graph = RawGraph::from_sqlite(&conn).expect("build graph");
352        assert_eq!(graph.node_count(), 3);
353        assert_eq!(graph.edge_count(), 2);
354
355        let n1 = graph.node_index("bn-001").unwrap();
356        let n2 = graph.node_index("bn-002").unwrap();
357        let n3 = graph.node_index("bn-003").unwrap();
358        assert!(graph.graph.contains_edge(n1, n2));
359        assert!(graph.graph.contains_edge(n2, n3));
360    }
361}