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}