use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
use tsift_core::{GraphNode, GraphStore};
pub const DEFAULT_GRAPH_DB_RELATIVE: &str = ".tsift/graph.db";
pub const DEFAULT_EVIDENCE_LIMIT: usize = 20;
pub const DEFAULT_EVIDENCE_MAX_SCAN_NODES: usize = 50_000;
#[derive(Debug, Clone, Serialize)]
pub struct GraphEvidenceQuery {
pub symbol: Option<String>,
pub kind: Option<String>,
pub limit: usize,
}
impl Default for GraphEvidenceQuery {
fn default() -> Self {
Self {
symbol: None,
kind: None,
limit: DEFAULT_EVIDENCE_LIMIT,
}
}
}
impl GraphEvidenceQuery {
pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
let symbol = symbol.into();
self.symbol = if symbol.trim().is_empty() {
None
} else {
Some(symbol)
};
self
}
pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
let kind = kind.into();
self.kind = if kind.trim().is_empty() {
None
} else {
Some(kind)
};
self
}
pub fn with_limit(mut self, limit: usize) -> Self {
self.limit = limit.max(1);
self
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GraphEvidenceNode {
pub id: String,
pub kind: String,
pub label: String,
pub source_refs: Vec<String>,
pub provenance_systems: Vec<String>,
pub incident_edge_count: usize,
}
impl GraphEvidenceNode {
pub fn from_graph_node(node: &GraphNode, edge_counts: &HashMap<&str, usize>) -> Self {
Self {
id: node.id.clone(),
kind: node.kind.clone(),
label: node.label.clone(),
source_refs: node
.provenance
.iter()
.map(|p| p.source_ref.clone())
.collect(),
provenance_systems: node.provenance.iter().map(|p| p.source.clone()).collect(),
incident_edge_count: edge_counts.get(node.id.as_str()).copied().unwrap_or(0),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct GraphEvidenceReport {
pub graph_db: String,
pub exists: bool,
pub scanned: bool,
pub total_nodes_in_db: usize,
pub total_edges_in_db: usize,
pub query: GraphEvidenceQuery,
pub matched_nodes: Vec<GraphEvidenceNode>,
}
pub fn read_graph_evidence(
project_root: &Path,
query: &GraphEvidenceQuery,
) -> Result<GraphEvidenceReport> {
let db_path: PathBuf = project_root.join(DEFAULT_GRAPH_DB_RELATIVE);
read_graph_evidence_from_db(&db_path, query)
}
pub fn read_graph_evidence_from_db(
db_path: &Path,
query: &GraphEvidenceQuery,
) -> Result<GraphEvidenceReport> {
let db_display = db_path.display().to_string();
if !db_path.exists() {
return Ok(GraphEvidenceReport {
graph_db: db_display,
exists: false,
scanned: false,
total_nodes_in_db: 0,
total_edges_in_db: 0,
query: clone_query(query),
matched_nodes: Vec::new(),
});
}
let store = tsift_sqlite::SqliteGraphStore::open_read_only_resilient(db_path)
.with_context(|| format!("opening graph.db read-only at {}", db_path.display()))?;
let all_nodes = store
.all_nodes()
.with_context(|| "reading graph_nodes for kg evidence")?;
let all_edges = store
.all_edges()
.unwrap_or_default();
let report = build_report(db_display, &all_nodes, &all_edges, query);
Ok(report)
}
pub fn read_graph_evidence_bounded(
db_path: &Path,
query: &GraphEvidenceQuery,
max_scan_nodes: usize,
) -> Result<GraphEvidenceReport> {
let db_display = db_path.display().to_string();
if !db_path.exists() {
return Ok(GraphEvidenceReport {
graph_db: db_display,
exists: false,
scanned: false,
total_nodes_in_db: 0,
total_edges_in_db: 0,
query: clone_query(query),
matched_nodes: Vec::new(),
});
}
let store = tsift_sqlite::SqliteGraphStore::open_read_only_resilient(db_path)
.with_context(|| format!("opening graph.db read-only at {}", db_path.display()))?;
let (total_nodes, total_edges) = store
.graph_counts()
.with_context(|| "reading graph counts for bounded kg evidence")?;
if total_nodes > max_scan_nodes {
return Ok(GraphEvidenceReport {
graph_db: db_display,
exists: true,
scanned: false,
total_nodes_in_db: total_nodes,
total_edges_in_db: total_edges,
query: clone_query(query),
matched_nodes: Vec::new(),
});
}
let all_nodes = store
.all_nodes()
.with_context(|| "reading graph_nodes for bounded kg evidence")?;
let all_edges = store.all_edges().unwrap_or_default();
Ok(build_report(db_display, &all_nodes, &all_edges, query))
}
pub fn build_report(
db_display: String,
nodes: &[GraphNode],
edges: &[tsift_core::GraphEdge],
query: &GraphEvidenceQuery,
) -> GraphEvidenceReport {
let total_nodes = nodes.len();
let total_edges = edges.len();
let mut edge_counts: HashMap<&str, usize> = HashMap::new();
for edge in edges {
*edge_counts.entry(edge.from_id.as_str()).or_insert(0) += 1;
*edge_counts.entry(edge.to_id.as_str()).or_insert(0) += 1;
}
let needle = query
.symbol
.as_deref()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty());
let kind_filter = query
.kind
.as_deref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let limit = query.limit.max(1);
let mut matched: Vec<&GraphNode> = nodes
.iter()
.filter(|node| {
if let Some(kind) = &kind_filter
&& node.kind != *kind
{
return false;
}
if let Some(needle) = &needle
&& !node.label.to_lowercase().contains(needle)
&& !node.id.to_lowercase().contains(needle)
&& !node.kind.to_lowercase().contains(needle)
{
return false;
}
true
})
.collect();
matched.sort_by(|a, b| {
let count_a = edge_counts.get(a.id.as_str()).copied().unwrap_or(0);
let count_b = edge_counts.get(b.id.as_str()).copied().unwrap_or(0);
count_b
.cmp(&count_a)
.then_with(|| a.label.cmp(&b.label))
});
let matched_nodes: Vec<GraphEvidenceNode> = matched
.into_iter()
.take(limit)
.map(|node| GraphEvidenceNode::from_graph_node(node, &edge_counts))
.collect();
GraphEvidenceReport {
graph_db: db_display,
exists: true,
scanned: true,
total_nodes_in_db: total_nodes,
total_edges_in_db: total_edges,
query: clone_query(query),
matched_nodes,
}
}
fn clone_query(query: &GraphEvidenceQuery) -> GraphEvidenceQuery {
GraphEvidenceQuery {
symbol: query.symbol.clone(),
kind: query.kind.clone(),
limit: query.limit,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tsift_core::{GraphEdge, GraphProjection, GraphProvenance};
use tsift_sqlite::SqliteGraphStore;
fn sample_nodes() -> Vec<GraphNode> {
vec![
GraphNode::new("n:kg-1", "kg_source", "tsift-kg")
.with_property("provider", "tsift-kg")
.with_provenance(GraphProvenance::new("tsift-kg", "spec.md")),
GraphNode::new("n:kg-2", "kg_source", "OllamaKgExtractor")
.with_provenance(GraphProvenance::new("tsift-kg", "ollama.rs")),
GraphNode::new("n:other", "concept", "lease"),
GraphNode::new("n:lease", "concept", "GPU lease registry"),
GraphNode::new("n:json", "format", "JSON"),
]
}
fn sample_edges() -> Vec<GraphEdge> {
vec![
GraphEdge::new("n:kg-1", "n:kg-2", "calls"),
GraphEdge::new("n:kg-1", "n:other", "related_to"),
GraphEdge::new("n:kg-2", "n:json", "emits"),
]
}
#[test]
fn build_report_ranks_by_incident_edge_count_desc_then_label() {
let report = build_report(
"test.db".to_string(),
&sample_nodes(),
&sample_edges(),
&GraphEvidenceQuery::default(),
);
assert_eq!(report.matched_nodes.len(), 5);
assert_eq!(report.matched_nodes[0].id, "n:kg-2");
assert_eq!(report.matched_nodes[0].incident_edge_count, 2);
assert_eq!(report.matched_nodes[1].id, "n:kg-1");
assert_eq!(report.matched_nodes[1].incident_edge_count, 2);
assert_eq!(report.matched_nodes[4].id, "n:lease");
assert_eq!(report.matched_nodes[4].incident_edge_count, 0);
}
#[test]
fn build_report_symbol_filter_matches_label_case_insensitively() {
let report = build_report(
"test.db".to_string(),
&sample_nodes(),
&sample_edges(),
&GraphEvidenceQuery::default().with_symbol("lease"),
);
let labels: Vec<&str> = report
.matched_nodes
.iter()
.map(|n| n.label.as_str())
.collect();
assert!(labels.contains(&"GPU lease registry"));
assert!(labels.contains(&"lease"));
assert!(!labels.contains(&"tsift-kg"));
}
#[test]
fn build_report_symbol_filter_falls_back_to_id_and_kind() {
let report = build_report(
"test.db".to_string(),
&sample_nodes(),
&sample_edges(),
&GraphEvidenceQuery::default().with_symbol("kg_source"),
);
assert_eq!(report.matched_nodes.len(), 2);
assert!(report
.matched_nodes
.iter()
.all(|n| n.kind == "kg_source"));
}
#[test]
fn build_report_kind_filter_restricts_to_exact_kind_match() {
let report = build_report(
"test.db".to_string(),
&sample_nodes(),
&sample_edges(),
&GraphEvidenceQuery::default().with_kind("concept"),
);
assert_eq!(report.matched_nodes.len(), 2);
assert!(report.matched_nodes.iter().all(|n| n.kind == "concept"));
}
#[test]
fn build_report_limit_caps_result_count() {
let report = build_report(
"test.db".to_string(),
&sample_nodes(),
&sample_edges(),
&GraphEvidenceQuery::default().with_limit(2),
);
assert_eq!(report.matched_nodes.len(), 2);
assert_eq!(report.total_nodes_in_db, 5);
assert_eq!(report.total_edges_in_db, 3);
}
#[test]
fn build_report_provenance_flattened_into_source_refs_and_systems() {
let report = build_report(
"test.db".to_string(),
&sample_nodes(),
&sample_edges(),
&GraphEvidenceQuery::default().with_symbol("tsift-kg"),
);
let node = report
.matched_nodes
.iter()
.find(|n| n.id == "n:kg-1")
.expect("n:kg-1 matches symbol");
assert_eq!(node.source_refs, vec!["spec.md".to_string()]);
assert_eq!(node.provenance_systems, vec!["tsift-kg".to_string()]);
}
#[test]
fn build_report_empty_db_returns_zero_totals() {
let report = build_report(
"empty.db".to_string(),
&[],
&[],
&GraphEvidenceQuery::default(),
);
assert!(report.exists);
assert_eq!(report.total_nodes_in_db, 0);
assert_eq!(report.total_edges_in_db, 0);
assert!(report.matched_nodes.is_empty());
}
#[test]
fn read_graph_evidence_from_db_returns_exists_false_for_missing_path() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("never-created.db");
let report = read_graph_evidence_from_db(&missing, &GraphEvidenceQuery::default())
.expect("missing db should not error");
assert!(!report.exists);
assert_eq!(report.total_nodes_in_db, 0);
assert!(report.matched_nodes.is_empty());
}
#[test]
fn read_graph_evidence_from_db_reads_populated_store() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("graph.db");
let mut store = SqliteGraphStore::open(&db_path).unwrap();
let mut projection = GraphProjection::default();
projection.nodes.push(
GraphNode::new("n:kg-1", "kg_source", "tsift-kg")
.with_provenance(GraphProvenance::new("tsift-kg", "spec.md")),
);
projection
.nodes
.push(GraphNode::new("n:other", "concept", "lease"));
projection
.edges
.push(GraphEdge::new("n:kg-1", "n:other", "related_to"));
store.upsert_projection(&projection).unwrap();
drop(store);
let report = read_graph_evidence_from_db(
&db_path,
&GraphEvidenceQuery::default().with_kind("kg_source"),
)
.expect("populated db reads succeed");
assert!(report.exists);
assert_eq!(report.total_nodes_in_db, 2);
assert_eq!(report.total_edges_in_db, 1);
assert_eq!(report.matched_nodes.len(), 1);
assert_eq!(report.matched_nodes[0].id, "n:kg-1");
assert_eq!(report.matched_nodes[0].source_refs, vec!["spec.md"]);
}
#[test]
fn build_report_sets_scanned_true() {
let report = build_report(
"test.db".to_string(),
&sample_nodes(),
&sample_edges(),
&GraphEvidenceQuery::default(),
);
assert!(report.scanned);
}
#[test]
fn read_graph_evidence_bounded_missing_db_is_unscanned() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("never-created.db");
let report = read_graph_evidence_bounded(
&missing,
&GraphEvidenceQuery::default(),
DEFAULT_EVIDENCE_MAX_SCAN_NODES,
)
.expect("missing db should not error");
assert!(!report.exists);
assert!(!report.scanned);
assert!(report.matched_nodes.is_empty());
}
#[test]
fn read_graph_evidence_bounded_scans_small_db() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("graph.db");
let mut store = SqliteGraphStore::open(&db_path).unwrap();
let mut projection = GraphProjection::default();
projection
.nodes
.push(GraphNode::new("n:kg-1", "kg_source", "tsift-kg"));
projection
.nodes
.push(GraphNode::new("n:other", "concept", "lease"));
projection
.edges
.push(GraphEdge::new("n:kg-1", "n:other", "related_to"));
store.upsert_projection(&projection).unwrap();
drop(store);
let report = read_graph_evidence_bounded(
&db_path,
&GraphEvidenceQuery::default(),
DEFAULT_EVIDENCE_MAX_SCAN_NODES,
)
.expect("small db scans");
assert!(report.exists);
assert!(report.scanned);
assert_eq!(report.total_nodes_in_db, 2);
assert_eq!(report.matched_nodes.len(), 2);
}
#[test]
fn read_graph_evidence_bounded_skips_oversized_db() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("graph.db");
let mut store = SqliteGraphStore::open(&db_path).unwrap();
let mut projection = GraphProjection::default();
for i in 0..5 {
projection
.nodes
.push(GraphNode::new(format!("n:{i}"), "concept", format!("c{i}")));
}
store.upsert_projection(&projection).unwrap();
drop(store);
let report = read_graph_evidence_bounded(&db_path, &GraphEvidenceQuery::default(), 2)
.expect("oversized db reports counts without scanning");
assert!(report.exists);
assert!(!report.scanned);
assert_eq!(report.total_nodes_in_db, 5);
assert!(report.matched_nodes.is_empty());
}
#[test]
fn query_builders_treat_blank_strings_as_no_filter() {
let q = GraphEvidenceQuery::default()
.with_symbol(" ")
.with_kind("")
.with_limit(0);
assert!(q.symbol.is_none());
assert!(q.kind.is_none());
assert_eq!(q.limit, 1); }
}