Skip to main content

tsift_core/
lib.rs

1pub mod convex;
2pub mod store;
3pub mod types;
4
5pub use convex::{
6    ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow, ConvexProjectionRows,
7    ConvexRowsGraphClient,
8};
9pub use store::{
10    GraphStore, apply_graph_edge_query_page, apply_graph_query_page, shortest_path_using_outgoing,
11};
12pub use types::{
13    GraphEdge, GraphFreshness, GraphNode, GraphPagedSubgraph, GraphPath, GraphProjection,
14    GraphPropertyFilter, GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphSubgraph,
15    NeighborhoodScoring, PropertyMode, RankedNeighborhoodOptions, RankedNeighborhoodResult,
16    SQLITE_GRAPH_SCHEMA_VERSION, TerseGraphEdge, TerseGraphNode, TerseGraphSubgraph,
17    TerseHealthScore, TerseSearchHit, graph_edge_id, stable_graph_edge_id,
18};
19
20impl GraphProjection {
21    pub fn upsert_into<S: GraphStore + ?Sized>(&self, store: &S) -> anyhow::Result<()> {
22        for node in &self.nodes {
23            store.upsert_node(node)?;
24        }
25        for edge in &self.edges {
26            store.upsert_edge(edge)?;
27        }
28        Ok(())
29    }
30
31    pub fn to_convex_rows(&self) -> ConvexProjectionRows {
32        ConvexProjectionRows::from(self)
33    }
34}
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39    use std::cell::RefCell;
40    use std::collections::BTreeMap;
41
42    fn sample_provenance() -> GraphProvenance {
43        GraphProvenance::new("fixture", "src/lib.rs:1").with_content_hash("hash-1")
44    }
45
46    fn sample_projection() -> GraphProjection {
47        let source = sample_provenance();
48        GraphProjection {
49            nodes: vec![
50                GraphNode::new("doc:livekit", "document", "LiveKit guide")
51                    .with_property("domain", "livekit")
52                    .with_provenance(source.clone())
53                    .with_freshness(GraphFreshness::content_hash("node-hash")),
54                GraphNode::new("topic:rooms", "topic", "Rooms"),
55                GraphNode::new("topic:egress", "topic", "Egress"),
56            ],
57            edges: vec![
58                GraphEdge::new("doc:livekit", "topic:rooms", "mentions")
59                    .with_property("confidence", "0.91")
60                    .with_provenance(source.clone())
61                    .with_freshness(GraphFreshness::content_hash("edge-hash")),
62                GraphEdge::new("topic:rooms", "topic:egress", "related_to").with_provenance(source),
63            ],
64        }
65    }
66
67    fn assert_projection_store_contract(store: &impl GraphStore) {
68        let projection = sample_projection();
69        projection.upsert_into(store).unwrap();
70
71        assert_eq!(
72            store.node("doc:livekit").unwrap(),
73            projection
74                .nodes
75                .iter()
76                .find(|node| node.id == "doc:livekit")
77                .cloned()
78        );
79        assert_eq!(
80            store.nodes_by_kind("topic").unwrap(),
81            vec![
82                GraphNode::new("topic:egress", "topic", "Egress"),
83                GraphNode::new("topic:rooms", "topic", "Rooms"),
84            ]
85        );
86
87        let mentions = store
88            .outgoing_edges("doc:livekit", Some("mentions"))
89            .unwrap();
90        assert_eq!(mentions.len(), 1);
91        assert_eq!(mentions[0].to_id, "topic:rooms");
92        assert_eq!(
93            mentions[0].properties.get("confidence"),
94            Some(&"0.91".into())
95        );
96
97        let path = store
98            .shortest_path("doc:livekit", "topic:egress", None)
99            .unwrap()
100            .unwrap();
101        assert_eq!(
102            path.nodes,
103            vec!["doc:livekit", "topic:rooms", "topic:egress"]
104        );
105    }
106
107    #[derive(Default)]
108    struct MemoryConvexGraphClient {
109        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
110        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
111    }
112
113    impl ConvexGraphClient for MemoryConvexGraphClient {
114        fn upsert_node_row(&self, row: &ConvexNodeRow) -> anyhow::Result<()> {
115            self.nodes
116                .borrow_mut()
117                .insert(row.external_id.clone(), row.clone());
118            Ok(())
119        }
120
121        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> anyhow::Result<()> {
122            self.edges
123                .borrow_mut()
124                .insert(row.edge_key.clone(), row.clone());
125            Ok(())
126        }
127
128        fn delete_node_row(&self, external_id: &str) -> anyhow::Result<usize> {
129            Ok(usize::from(
130                self.nodes.borrow_mut().remove(external_id).is_some(),
131            ))
132        }
133
134        fn delete_edge_row(&self, edge_key: &str) -> anyhow::Result<usize> {
135            Ok(usize::from(
136                self.edges.borrow_mut().remove(edge_key).is_some(),
137            ))
138        }
139
140        fn node_row(&self, external_id: &str) -> anyhow::Result<Option<ConvexNodeRow>> {
141            Ok(self.nodes.borrow().get(external_id).cloned())
142        }
143
144        fn node_rows(&self) -> anyhow::Result<Vec<ConvexNodeRow>> {
145            Ok(self.nodes.borrow().values().cloned().collect())
146        }
147
148        fn edge_rows(&self) -> anyhow::Result<Vec<ConvexEdgeRow>> {
149            Ok(self.edges.borrow().values().cloned().collect())
150        }
151
152        fn node_rows_by_kind(&self, kind: &str) -> anyhow::Result<Vec<ConvexNodeRow>> {
153            Ok(self
154                .nodes
155                .borrow()
156                .values()
157                .filter(|row| row.kind == kind)
158                .cloned()
159                .collect())
160        }
161
162        fn outgoing_edge_rows(
163            &self,
164            from_external_id: &str,
165            kind: Option<&str>,
166        ) -> anyhow::Result<Vec<ConvexEdgeRow>> {
167            Ok(self
168                .edges
169                .borrow()
170                .values()
171                .filter(|row| row.from_external_id == from_external_id)
172                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
173                .cloned()
174                .collect())
175        }
176    }
177
178    #[test]
179    fn graph_projection_round_trips_through_backend_agnostic_store_contract() {
180        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
181        assert_projection_store_contract(&convex);
182
183        let client = convex.client();
184        assert_eq!(client.nodes.borrow().len(), 3);
185        assert_eq!(client.edges.borrow().len(), 2);
186        assert!(
187            client.nodes.borrow().contains_key("doc:livekit"),
188            "Convex rows keep GraphNode.id as the externalId upsert key"
189        );
190    }
191
192    #[test]
193    fn graph_store_contract_covers_crud_neighborhood_and_ordering() {
194        fn assert_crud_contract(store: &impl GraphStore) {
195            let projection = sample_projection();
196            projection.upsert_into(store).unwrap();
197
198            let neighborhood = store.neighborhood("doc:livekit", 2, None).unwrap().unwrap();
199            assert_eq!(
200                neighborhood
201                    .nodes
202                    .iter()
203                    .map(|node| node.id.as_str())
204                    .collect::<Vec<_>>(),
205                vec!["doc:livekit", "topic:egress", "topic:rooms"]
206            );
207            assert_eq!(
208                neighborhood
209                    .edges
210                    .iter()
211                    .map(|edge| (
212                        edge.from_id.as_str(),
213                        edge.kind.as_str(),
214                        edge.to_id.as_str()
215                    ))
216                    .collect::<Vec<_>>(),
217                vec![
218                    ("doc:livekit", "mentions", "topic:rooms"),
219                    ("topic:rooms", "related_to", "topic:egress"),
220                ]
221            );
222
223            assert_eq!(
224                store
225                    .delete_edge("topic:rooms", "topic:egress", "related_to")
226                    .unwrap(),
227                1
228            );
229            assert!(
230                store
231                    .shortest_path("doc:livekit", "topic:egress", None)
232                    .unwrap()
233                    .is_none()
234            );
235            assert_eq!(store.delete_node("topic:rooms").unwrap(), 1);
236            assert!(store.node("topic:rooms").unwrap().is_none());
237            assert!(
238                store
239                    .outgoing_edges("doc:livekit", None)
240                    .unwrap()
241                    .is_empty()
242            );
243        }
244
245        assert_crud_contract(&ConvexGraphStore::new(ConvexRowsGraphClient::default()));
246    }
247
248    #[test]
249    fn convex_projection_rows_keep_stable_ids_and_edge_keys() {
250        let projection = sample_projection();
251        let rows = projection.to_convex_rows();
252
253        let doc_row = rows
254            .nodes
255            .iter()
256            .find(|row| row.external_id == "doc:livekit")
257            .unwrap();
258        assert_eq!(doc_row.kind, "document");
259        assert_eq!(doc_row.properties.get("domain"), Some(&"livekit".into()));
260
261        let mentions = rows
262            .edges
263            .iter()
264            .find(|row| row.kind == "mentions")
265            .unwrap();
266        assert_eq!(mentions.from_external_id, "doc:livekit");
267        assert_eq!(mentions.to_external_id, "topic:rooms");
268        assert_eq!(
269            mentions.edge_key,
270            ConvexEdgeRow::stable_key("doc:livekit", "topic:rooms", "mentions")
271        );
272        assert!(mentions.edge_key.starts_with("edge:"));
273    }
274
275    #[test]
276    fn terse_graph_node_strips_provenance_and_freshness() {
277        let node = GraphNode::new("doc:livekit", "document", "LiveKit guide")
278            .with_property("domain", "livekit")
279            .with_provenance(GraphProvenance::new("fixture", "src/lib.rs:1"))
280            .with_freshness(GraphFreshness::content_hash("hash-1"));
281        let terse = TerseGraphNode::from(&node);
282        assert_eq!(terse.id, "doc:livekit");
283        assert_eq!(terse.kind, "document");
284        assert_eq!(terse.label, "LiveKit guide");
285        assert_eq!(terse.properties.get("domain"), Some(&"livekit".to_string()));
286        let json = serde_json::to_value(&terse).unwrap();
287        assert!(json.get("provenance").is_none());
288        assert!(json.get("freshness").is_none());
289        let full_json = serde_json::to_string(&node).unwrap();
290        let terse_json = serde_json::to_string(&terse).unwrap();
291        assert!(
292            terse_json.len() < full_json.len(),
293            "terse ({}) should be shorter than full ({})",
294            terse_json.len(),
295            full_json.len()
296        );
297    }
298
299    #[test]
300    fn terse_graph_edge_strips_provenance_and_freshness() {
301        let edge = GraphEdge::new("a", "b", "calls")
302            .with_property("line", "10")
303            .with_provenance(GraphProvenance::new("fixture", "src/lib.rs:5"))
304            .with_freshness(GraphFreshness::content_hash("edge-hash"));
305        let terse = TerseGraphEdge::from(&edge);
306        assert_eq!(terse.from_id, "a");
307        assert_eq!(terse.to_id, "b");
308        assert_eq!(terse.kind, "calls");
309        assert_eq!(terse.properties.get("line"), Some(&"10".to_string()));
310        let json = serde_json::to_value(&terse).unwrap();
311        assert!(json.get("provenance").is_none());
312        assert!(json.get("freshness").is_none());
313    }
314
315    #[test]
316    fn terse_graph_subgraph_rounds_trip() {
317        let subgraph = GraphSubgraph {
318            nodes: vec![
319                GraphNode::new("a", "fn", "alpha")
320                    .with_provenance(GraphProvenance::new("src", "a.rs")),
321                GraphNode::new("b", "fn", "beta"),
322            ],
323            edges: vec![
324                GraphEdge::new("a", "b", "calls").with_freshness(GraphFreshness::content_hash("h")),
325            ],
326        }
327        .sorted();
328        let terse = TerseGraphSubgraph::from(subgraph);
329        assert_eq!(terse.nodes.len(), 2);
330        assert_eq!(terse.edges.len(), 1);
331        assert_eq!(terse.edges[0].from_id, "a");
332    }
333
334    #[test]
335    fn convex_store_rejects_edges_when_projection_nodes_are_missing() {
336        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
337        store
338            .upsert_node(&GraphNode::new("doc:livekit", "document", "LiveKit guide"))
339            .unwrap();
340
341        let err = store
342            .upsert_edge(&GraphEdge::new("doc:livekit", "topic:rooms", "mentions"))
343            .unwrap_err();
344        assert!(
345            err.to_string().contains("references missing to node"),
346            "{err}"
347        );
348    }
349
350    #[test]
351    fn ranked_neighborhood_returns_none_for_missing_center() {
352        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
353        let options = RankedNeighborhoodOptions::new(2, 10);
354        let result = store.ranked_neighborhood("missing", &options).unwrap();
355        assert!(result.is_none());
356    }
357
358    #[test]
359    fn ranked_neighborhood_breadth_first_respects_max_nodes() {
360        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
361        store
362            .upsert_node(&GraphNode::new("a", "file", "a"))
363            .unwrap();
364        store
365            .upsert_node(&GraphNode::new("b", "symbol", "b"))
366            .unwrap();
367        store
368            .upsert_node(&GraphNode::new("c", "symbol", "c"))
369            .unwrap();
370        store
371            .upsert_node(&GraphNode::new("d", "symbol", "d"))
372            .unwrap();
373        store
374            .upsert_edge(&GraphEdge::new("a", "b", "calls"))
375            .unwrap();
376        store
377            .upsert_edge(&GraphEdge::new("a", "c", "calls"))
378            .unwrap();
379        store
380            .upsert_edge(&GraphEdge::new("a", "d", "calls"))
381            .unwrap();
382
383        let options = RankedNeighborhoodOptions::new(2, 2);
384        let result = store.ranked_neighborhood("a", &options).unwrap().unwrap();
385        assert!(
386            result.nodes.len() <= 3,
387            "center + max 2 neighbors, got {}",
388            result.nodes.len()
389        );
390        assert!(result.pruned_count > 0, "should have pruned some nodes");
391        assert!(result.total_discovered >= 4);
392    }
393
394    #[test]
395    fn ranked_neighborhood_edge_kind_weighted_prefers_high_score_edges() {
396        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
397        store
398            .upsert_node(&GraphNode::new("a", "file", "a"))
399            .unwrap();
400        store
401            .upsert_node(&GraphNode::new("b", "symbol", "b"))
402            .unwrap();
403        store
404            .upsert_node(&GraphNode::new("c", "symbol", "c"))
405            .unwrap();
406        store
407            .upsert_edge(&GraphEdge::new("a", "b", "semantic_relation"))
408            .unwrap();
409        store
410            .upsert_edge(&GraphEdge::new("a", "c", "unknown"))
411            .unwrap();
412
413        let options = RankedNeighborhoodOptions::new(1, 1)
414            .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
415        let result = store.ranked_neighborhood("a", &options).unwrap().unwrap();
416        let neighbor_ids: Vec<_> = result.nodes.iter().map(|n| n.id.as_str()).collect();
417        assert!(
418            neighbor_ids.contains(&"b"),
419            "semantic_relation neighbor should survive pruning"
420        );
421    }
422
423    #[test]
424    fn ranked_neighborhood_includes_center_node() {
425        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
426        store
427            .upsert_node(&GraphNode::new("center", "file", "center"))
428            .unwrap();
429        store
430            .upsert_node(&GraphNode::new("neighbor", "symbol", "neighbor"))
431            .unwrap();
432        store
433            .upsert_edge(&GraphEdge::new("center", "neighbor", "calls"))
434            .unwrap();
435
436        let options = RankedNeighborhoodOptions::new(1, 10);
437        let result = store
438            .ranked_neighborhood("center", &options)
439            .unwrap()
440            .unwrap();
441        let ids: Vec<_> = result.nodes.iter().map(|n| n.id.clone()).collect();
442        assert!(ids.contains(&"center".to_string()));
443        assert!(ids.contains(&"neighbor".to_string()));
444        assert_eq!(result.pruned_count, 0);
445    }
446
447    #[test]
448    fn ranked_neighborhood_edge_kind_filter() {
449        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
450        store
451            .upsert_node(&GraphNode::new("a", "file", "a"))
452            .unwrap();
453        store
454            .upsert_node(&GraphNode::new("b", "symbol", "b"))
455            .unwrap();
456        store
457            .upsert_node(&GraphNode::new("c", "symbol", "c"))
458            .unwrap();
459        store
460            .upsert_edge(&GraphEdge::new("a", "b", "calls"))
461            .unwrap();
462        store
463            .upsert_edge(&GraphEdge::new("a", "c", "mentions"))
464            .unwrap();
465
466        let options = RankedNeighborhoodOptions::new(1, 10).with_edge_kind("calls");
467        let result = store.ranked_neighborhood("a", &options).unwrap().unwrap();
468        let ids: Vec<_> = result.nodes.iter().map(|n| n.id.as_str()).collect();
469        assert!(ids.contains(&"b"));
470        assert!(!ids.contains(&"c"), "mentions edge should be filtered out");
471    }
472
473    #[test]
474    fn ranked_neighborhood_degree_weighted_prefers_low_degree() {
475        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
476        store
477            .upsert_node(&GraphNode::new("a", "file", "a"))
478            .unwrap();
479        store
480            .upsert_node(&GraphNode::new("b", "symbol", "b"))
481            .unwrap();
482        store
483            .upsert_node(&GraphNode::new("c", "symbol", "c"))
484            .unwrap();
485        store
486            .upsert_node(&GraphNode::new("d", "symbol", "d"))
487            .unwrap();
488        store
489            .upsert_node(&GraphNode::new("e", "symbol", "e"))
490            .unwrap();
491        store
492            .upsert_edge(&GraphEdge::new("a", "b", "calls"))
493            .unwrap();
494        store
495            .upsert_edge(&GraphEdge::new("b", "c", "calls"))
496            .unwrap();
497        store
498            .upsert_edge(&GraphEdge::new("b", "d", "calls"))
499            .unwrap();
500        store
501            .upsert_edge(&GraphEdge::new("b", "e", "calls"))
502            .unwrap();
503
504        let options =
505            RankedNeighborhoodOptions::new(2, 3).with_scoring(NeighborhoodScoring::DegreeWeighted);
506        let result = store.ranked_neighborhood("a", &options).unwrap().unwrap();
507        assert!(result.nodes.len() <= 4);
508    }
509
510    #[test]
511    fn compute_neighborhood_score_breadth_first() {
512        let score = store::compute_neighborhood_score(
513            &NeighborhoodScoring::BreadthFirst,
514            0,
515            "calls",
516            &GraphNode::new("x", "symbol", "x"),
517            &BTreeMap::new(),
518        );
519        assert_eq!(score, 120);
520        let score_d2 = store::compute_neighborhood_score(
521            &NeighborhoodScoring::BreadthFirst,
522            2,
523            "calls",
524            &GraphNode::new("x", "symbol", "x"),
525            &BTreeMap::new(),
526        );
527        assert_eq!(score_d2, 84);
528    }
529
530    #[test]
531    fn compute_neighborhood_score_edge_kind_weighted() {
532        let score_semantic = store::compute_neighborhood_score(
533            &NeighborhoodScoring::EdgeKindWeighted,
534            1,
535            "semantic_relation",
536            &GraphNode::new("x", "symbol", "x"),
537            &BTreeMap::new(),
538        );
539        let score_unknown = store::compute_neighborhood_score(
540            &NeighborhoodScoring::EdgeKindWeighted,
541            1,
542            "unknown",
543            &GraphNode::new("x", "symbol", "x"),
544            &BTreeMap::new(),
545        );
546        assert!(score_semantic > score_unknown);
547    }
548}