1use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use anyhow::{Context, Result};
27use serde::Serialize;
28
29use tsift_core::{GraphNode, GraphStore};
30
31pub const DEFAULT_GRAPH_DB_RELATIVE: &str = ".tsift/graph.db";
33
34pub const DEFAULT_EVIDENCE_LIMIT: usize = 20;
36
37pub const DEFAULT_EVIDENCE_MAX_SCAN_NODES: usize = 50_000;
45
46#[derive(Debug, Clone, Serialize)]
49pub struct GraphEvidenceQuery {
50 pub symbol: Option<String>,
53 pub kind: Option<String>,
55 pub limit: usize,
57}
58
59impl Default for GraphEvidenceQuery {
60 fn default() -> Self {
61 Self {
62 symbol: None,
63 kind: None,
64 limit: DEFAULT_EVIDENCE_LIMIT,
65 }
66 }
67}
68
69impl GraphEvidenceQuery {
70 pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
72 let symbol = symbol.into();
73 self.symbol = if symbol.trim().is_empty() {
74 None
75 } else {
76 Some(symbol)
77 };
78 self
79 }
80
81 pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
83 let kind = kind.into();
84 self.kind = if kind.trim().is_empty() {
85 None
86 } else {
87 Some(kind)
88 };
89 self
90 }
91
92 pub fn with_limit(mut self, limit: usize) -> Self {
94 self.limit = limit.max(1);
95 self
96 }
97}
98
99#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
103pub struct GraphEvidenceNode {
104 pub id: String,
105 pub kind: String,
106 pub label: String,
107 pub source_refs: Vec<String>,
108 pub provenance_systems: Vec<String>,
109 pub incident_edge_count: usize,
112}
113
114impl GraphEvidenceNode {
115 pub fn from_graph_node(node: &GraphNode, edge_counts: &HashMap<&str, usize>) -> Self {
118 Self {
119 id: node.id.clone(),
120 kind: node.kind.clone(),
121 label: node.label.clone(),
122 source_refs: node
123 .provenance
124 .iter()
125 .map(|p| p.source_ref.clone())
126 .collect(),
127 provenance_systems: node.provenance.iter().map(|p| p.source.clone()).collect(),
128 incident_edge_count: edge_counts.get(node.id.as_str()).copied().unwrap_or(0),
129 }
130 }
131}
132
133#[derive(Debug, Clone, Serialize)]
137pub struct GraphEvidenceReport {
138 pub graph_db: String,
139 pub exists: bool,
140 pub scanned: bool,
146 pub total_nodes_in_db: usize,
147 pub total_edges_in_db: usize,
148 pub query: GraphEvidenceQuery,
149 pub matched_nodes: Vec<GraphEvidenceNode>,
150}
151
152pub fn read_graph_evidence(
155 project_root: &Path,
156 query: &GraphEvidenceQuery,
157) -> Result<GraphEvidenceReport> {
158 let db_path: PathBuf = project_root.join(DEFAULT_GRAPH_DB_RELATIVE);
159 read_graph_evidence_from_db(&db_path, query)
160}
161
162pub fn read_graph_evidence_from_db(
166 db_path: &Path,
167 query: &GraphEvidenceQuery,
168) -> Result<GraphEvidenceReport> {
169 let db_display = db_path.display().to_string();
170 if !db_path.exists() {
171 return Ok(GraphEvidenceReport {
172 graph_db: db_display,
173 exists: false,
174 scanned: false,
175 total_nodes_in_db: 0,
176 total_edges_in_db: 0,
177 query: clone_query(query),
178 matched_nodes: Vec::new(),
179 });
180 }
181
182 let store = tsift_sqlite::SqliteGraphStore::open_read_only_resilient(db_path)
183 .with_context(|| format!("opening graph.db read-only at {}", db_path.display()))?;
184 let all_nodes = store
185 .all_nodes()
186 .with_context(|| "reading graph_nodes for kg evidence")?;
187 let all_edges = store
188 .all_edges()
189 .unwrap_or_default();
190
191 let report = build_report(db_display, &all_nodes, &all_edges, query);
192 Ok(report)
193}
194
195pub fn read_graph_evidence_bounded(
208 db_path: &Path,
209 query: &GraphEvidenceQuery,
210 max_scan_nodes: usize,
211) -> Result<GraphEvidenceReport> {
212 let db_display = db_path.display().to_string();
213 if !db_path.exists() {
214 return Ok(GraphEvidenceReport {
215 graph_db: db_display,
216 exists: false,
217 scanned: false,
218 total_nodes_in_db: 0,
219 total_edges_in_db: 0,
220 query: clone_query(query),
221 matched_nodes: Vec::new(),
222 });
223 }
224
225 let store = tsift_sqlite::SqliteGraphStore::open_read_only_resilient(db_path)
226 .with_context(|| format!("opening graph.db read-only at {}", db_path.display()))?;
227 let (total_nodes, total_edges) = store
228 .graph_counts()
229 .with_context(|| "reading graph counts for bounded kg evidence")?;
230
231 if total_nodes > max_scan_nodes {
232 return Ok(GraphEvidenceReport {
233 graph_db: db_display,
234 exists: true,
235 scanned: false,
236 total_nodes_in_db: total_nodes,
237 total_edges_in_db: total_edges,
238 query: clone_query(query),
239 matched_nodes: Vec::new(),
240 });
241 }
242
243 let all_nodes = store
244 .all_nodes()
245 .with_context(|| "reading graph_nodes for bounded kg evidence")?;
246 let all_edges = store.all_edges().unwrap_or_default();
247 Ok(build_report(db_display, &all_nodes, &all_edges, query))
248}
249
250pub fn build_report(
254 db_display: String,
255 nodes: &[GraphNode],
256 edges: &[tsift_core::GraphEdge],
257 query: &GraphEvidenceQuery,
258) -> GraphEvidenceReport {
259 let total_nodes = nodes.len();
260 let total_edges = edges.len();
261
262 let mut edge_counts: HashMap<&str, usize> = HashMap::new();
263 for edge in edges {
264 *edge_counts.entry(edge.from_id.as_str()).or_insert(0) += 1;
265 *edge_counts.entry(edge.to_id.as_str()).or_insert(0) += 1;
266 }
267
268 let needle = query
269 .symbol
270 .as_deref()
271 .map(|s| s.trim().to_lowercase())
272 .filter(|s| !s.is_empty());
273 let kind_filter = query
274 .kind
275 .as_deref()
276 .map(|s| s.trim().to_string())
277 .filter(|s| !s.is_empty());
278
279 let limit = query.limit.max(1);
280
281 let mut matched: Vec<&GraphNode> = nodes
282 .iter()
283 .filter(|node| {
284 if let Some(kind) = &kind_filter
285 && node.kind != *kind
286 {
287 return false;
288 }
289 if let Some(needle) = &needle
290 && !node.label.to_lowercase().contains(needle)
291 && !node.id.to_lowercase().contains(needle)
292 && !node.kind.to_lowercase().contains(needle)
293 {
294 return false;
295 }
296 true
297 })
298 .collect();
299
300 matched.sort_by(|a, b| {
301 let count_a = edge_counts.get(a.id.as_str()).copied().unwrap_or(0);
302 let count_b = edge_counts.get(b.id.as_str()).copied().unwrap_or(0);
303 count_b
304 .cmp(&count_a)
305 .then_with(|| a.label.cmp(&b.label))
306 });
307
308 let matched_nodes: Vec<GraphEvidenceNode> = matched
309 .into_iter()
310 .take(limit)
311 .map(|node| GraphEvidenceNode::from_graph_node(node, &edge_counts))
312 .collect();
313
314 GraphEvidenceReport {
315 graph_db: db_display,
316 exists: true,
317 scanned: true,
318 total_nodes_in_db: total_nodes,
319 total_edges_in_db: total_edges,
320 query: clone_query(query),
321 matched_nodes,
322 }
323}
324
325fn clone_query(query: &GraphEvidenceQuery) -> GraphEvidenceQuery {
326 GraphEvidenceQuery {
327 symbol: query.symbol.clone(),
328 kind: query.kind.clone(),
329 limit: query.limit,
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use tempfile::TempDir;
337 use tsift_core::{GraphEdge, GraphProjection, GraphProvenance};
338 use tsift_sqlite::SqliteGraphStore;
339
340 fn sample_nodes() -> Vec<GraphNode> {
341 vec![
342 GraphNode::new("n:kg-1", "kg_source", "tsift-kg")
343 .with_property("provider", "tsift-kg")
344 .with_provenance(GraphProvenance::new("tsift-kg", "spec.md")),
345 GraphNode::new("n:kg-2", "kg_source", "OllamaKgExtractor")
346 .with_provenance(GraphProvenance::new("tsift-kg", "ollama.rs")),
347 GraphNode::new("n:other", "concept", "lease"),
348 GraphNode::new("n:lease", "concept", "GPU lease registry"),
349 GraphNode::new("n:json", "format", "JSON"),
350 ]
351 }
352
353 fn sample_edges() -> Vec<GraphEdge> {
354 vec![
355 GraphEdge::new("n:kg-1", "n:kg-2", "calls"),
356 GraphEdge::new("n:kg-1", "n:other", "related_to"),
357 GraphEdge::new("n:kg-2", "n:json", "emits"),
358 ]
360 }
361
362 #[test]
363 fn build_report_ranks_by_incident_edge_count_desc_then_label() {
364 let report = build_report(
365 "test.db".to_string(),
366 &sample_nodes(),
367 &sample_edges(),
368 &GraphEvidenceQuery::default(),
369 );
370 assert_eq!(report.matched_nodes.len(), 5);
371 assert_eq!(report.matched_nodes[0].id, "n:kg-2");
376 assert_eq!(report.matched_nodes[0].incident_edge_count, 2);
377 assert_eq!(report.matched_nodes[1].id, "n:kg-1");
378 assert_eq!(report.matched_nodes[1].incident_edge_count, 2);
379 assert_eq!(report.matched_nodes[4].id, "n:lease");
381 assert_eq!(report.matched_nodes[4].incident_edge_count, 0);
382 }
383
384 #[test]
385 fn build_report_symbol_filter_matches_label_case_insensitively() {
386 let report = build_report(
387 "test.db".to_string(),
388 &sample_nodes(),
389 &sample_edges(),
390 &GraphEvidenceQuery::default().with_symbol("lease"),
391 );
392 let labels: Vec<&str> = report
394 .matched_nodes
395 .iter()
396 .map(|n| n.label.as_str())
397 .collect();
398 assert!(labels.contains(&"GPU lease registry"));
399 assert!(labels.contains(&"lease"));
400 assert!(!labels.contains(&"tsift-kg"));
401 }
402
403 #[test]
404 fn build_report_symbol_filter_falls_back_to_id_and_kind() {
405 let report = build_report(
408 "test.db".to_string(),
409 &sample_nodes(),
410 &sample_edges(),
411 &GraphEvidenceQuery::default().with_symbol("kg_source"),
412 );
413 assert_eq!(report.matched_nodes.len(), 2);
414 assert!(report
415 .matched_nodes
416 .iter()
417 .all(|n| n.kind == "kg_source"));
418 }
419
420 #[test]
421 fn build_report_kind_filter_restricts_to_exact_kind_match() {
422 let report = build_report(
423 "test.db".to_string(),
424 &sample_nodes(),
425 &sample_edges(),
426 &GraphEvidenceQuery::default().with_kind("concept"),
427 );
428 assert_eq!(report.matched_nodes.len(), 2);
429 assert!(report.matched_nodes.iter().all(|n| n.kind == "concept"));
430 }
431
432 #[test]
433 fn build_report_limit_caps_result_count() {
434 let report = build_report(
435 "test.db".to_string(),
436 &sample_nodes(),
437 &sample_edges(),
438 &GraphEvidenceQuery::default().with_limit(2),
439 );
440 assert_eq!(report.matched_nodes.len(), 2);
441 assert_eq!(report.total_nodes_in_db, 5);
443 assert_eq!(report.total_edges_in_db, 3);
444 }
445
446 #[test]
447 fn build_report_provenance_flattened_into_source_refs_and_systems() {
448 let report = build_report(
449 "test.db".to_string(),
450 &sample_nodes(),
451 &sample_edges(),
452 &GraphEvidenceQuery::default().with_symbol("tsift-kg"),
453 );
454 let node = report
455 .matched_nodes
456 .iter()
457 .find(|n| n.id == "n:kg-1")
458 .expect("n:kg-1 matches symbol");
459 assert_eq!(node.source_refs, vec!["spec.md".to_string()]);
460 assert_eq!(node.provenance_systems, vec!["tsift-kg".to_string()]);
461 }
462
463 #[test]
464 fn build_report_empty_db_returns_zero_totals() {
465 let report = build_report(
466 "empty.db".to_string(),
467 &[],
468 &[],
469 &GraphEvidenceQuery::default(),
470 );
471 assert!(report.exists);
472 assert_eq!(report.total_nodes_in_db, 0);
473 assert_eq!(report.total_edges_in_db, 0);
474 assert!(report.matched_nodes.is_empty());
475 }
476
477 #[test]
478 fn read_graph_evidence_from_db_returns_exists_false_for_missing_path() {
479 let dir = TempDir::new().unwrap();
480 let missing = dir.path().join("never-created.db");
481 let report = read_graph_evidence_from_db(&missing, &GraphEvidenceQuery::default())
482 .expect("missing db should not error");
483 assert!(!report.exists);
484 assert_eq!(report.total_nodes_in_db, 0);
485 assert!(report.matched_nodes.is_empty());
486 }
487
488 #[test]
489 fn read_graph_evidence_from_db_reads_populated_store() {
490 let dir = TempDir::new().unwrap();
491 let db_path = dir.path().join("graph.db");
492 let mut store = SqliteGraphStore::open(&db_path).unwrap();
493 let mut projection = GraphProjection::default();
494 projection.nodes.push(
495 GraphNode::new("n:kg-1", "kg_source", "tsift-kg")
496 .with_provenance(GraphProvenance::new("tsift-kg", "spec.md")),
497 );
498 projection
499 .nodes
500 .push(GraphNode::new("n:other", "concept", "lease"));
501 projection
502 .edges
503 .push(GraphEdge::new("n:kg-1", "n:other", "related_to"));
504 store.upsert_projection(&projection).unwrap();
505 drop(store);
507
508 let report = read_graph_evidence_from_db(
509 &db_path,
510 &GraphEvidenceQuery::default().with_kind("kg_source"),
511 )
512 .expect("populated db reads succeed");
513 assert!(report.exists);
514 assert_eq!(report.total_nodes_in_db, 2);
515 assert_eq!(report.total_edges_in_db, 1);
516 assert_eq!(report.matched_nodes.len(), 1);
517 assert_eq!(report.matched_nodes[0].id, "n:kg-1");
518 assert_eq!(report.matched_nodes[0].source_refs, vec!["spec.md"]);
519 }
520
521 #[test]
522 fn build_report_sets_scanned_true() {
523 let report = build_report(
524 "test.db".to_string(),
525 &sample_nodes(),
526 &sample_edges(),
527 &GraphEvidenceQuery::default(),
528 );
529 assert!(report.scanned);
530 }
531
532 #[test]
533 fn read_graph_evidence_bounded_missing_db_is_unscanned() {
534 let dir = TempDir::new().unwrap();
535 let missing = dir.path().join("never-created.db");
536 let report = read_graph_evidence_bounded(
537 &missing,
538 &GraphEvidenceQuery::default(),
539 DEFAULT_EVIDENCE_MAX_SCAN_NODES,
540 )
541 .expect("missing db should not error");
542 assert!(!report.exists);
543 assert!(!report.scanned);
544 assert!(report.matched_nodes.is_empty());
545 }
546
547 #[test]
548 fn read_graph_evidence_bounded_scans_small_db() {
549 let dir = TempDir::new().unwrap();
550 let db_path = dir.path().join("graph.db");
551 let mut store = SqliteGraphStore::open(&db_path).unwrap();
552 let mut projection = GraphProjection::default();
553 projection
554 .nodes
555 .push(GraphNode::new("n:kg-1", "kg_source", "tsift-kg"));
556 projection
557 .nodes
558 .push(GraphNode::new("n:other", "concept", "lease"));
559 projection
560 .edges
561 .push(GraphEdge::new("n:kg-1", "n:other", "related_to"));
562 store.upsert_projection(&projection).unwrap();
563 drop(store);
564
565 let report = read_graph_evidence_bounded(
566 &db_path,
567 &GraphEvidenceQuery::default(),
568 DEFAULT_EVIDENCE_MAX_SCAN_NODES,
569 )
570 .expect("small db scans");
571 assert!(report.exists);
572 assert!(report.scanned);
573 assert_eq!(report.total_nodes_in_db, 2);
574 assert_eq!(report.matched_nodes.len(), 2);
575 }
576
577 #[test]
578 fn read_graph_evidence_bounded_skips_oversized_db() {
579 let dir = TempDir::new().unwrap();
580 let db_path = dir.path().join("graph.db");
581 let mut store = SqliteGraphStore::open(&db_path).unwrap();
582 let mut projection = GraphProjection::default();
583 for i in 0..5 {
584 projection
585 .nodes
586 .push(GraphNode::new(format!("n:{i}"), "concept", format!("c{i}")));
587 }
588 store.upsert_projection(&projection).unwrap();
589 drop(store);
590
591 let report = read_graph_evidence_bounded(&db_path, &GraphEvidenceQuery::default(), 2)
593 .expect("oversized db reports counts without scanning");
594 assert!(report.exists);
595 assert!(!report.scanned);
596 assert_eq!(report.total_nodes_in_db, 5);
597 assert!(report.matched_nodes.is_empty());
598 }
599
600 #[test]
601 fn query_builders_treat_blank_strings_as_no_filter() {
602 let q = GraphEvidenceQuery::default()
603 .with_symbol(" ")
604 .with_kind("")
605 .with_limit(0);
606 assert!(q.symbol.is_none());
607 assert!(q.kind.is_none());
608 assert_eq!(q.limit, 1); }
610}