Skip to main content

tsift_agent_doc/
graph_evidence.rs

1//! Read-only Knowledge Graph evidence lookup for agent-doc (#kgadactivate).
2//!
3//! Realizes the `tsift-agent-doc` side of the Local KG Model Contract in
4//! [`../../specs/local-kg-model.md`](../../specs/local-kg-model.md) lines 29-30:
5//! agent-doc may READ `.tsift/graph.db` evidence via the tsift-sqlite/tsift-core
6//! `GraphStore` layer, but must not own a separate extraction pipeline or
7//! local-model lifecycle. This module is the read seam; it produces typed
8//! `GraphEvidenceReport` snapshots that planning/orchestration callers (or the
9//! `tsift kg evidence` CLI) can consume without coupling to extraction.
10//!
11//! Design notes:
12//! - Read-only via `SqliteGraphStore::open_read_only_resilient`. Missing db is
13//!   reported as `exists: false` rather than an error — callers must tolerate
14//!   workspaces that have not yet run KG extraction.
15//! - Substring + kind filters run in Rust over `all_nodes()` so the agent-doc
16//!   crate stays independent of store-specific SQL dialects. The store layer's
17//!   own query helpers (`outgoing_edges`, `incident_edges`) are available for
18//!   deeper traversals, but the evidence lookup intentionally stays bounded so
19//!   a single call is cheap enough to run on every planning cycle.
20//! - Node ranking is by incident edge count (a cheap proxy for "how connected
21//!   is this entity"), then label alphabetical for determinism.
22
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use anyhow::{Context, Result};
27use serde::Serialize;
28
29use tsift_core::{GraphNode, GraphStore};
30
31/// Relative location of the KG graph store under a project root.
32pub const DEFAULT_GRAPH_DB_RELATIVE: &str = ".tsift/graph.db";
33
34/// Cap on returned evidence nodes when the caller does not specify `limit`.
35pub const DEFAULT_EVIDENCE_LIMIT: usize = 20;
36
37/// Default cap on total `graph_nodes` before bounded evidence scanning is
38/// skipped. The evidence lookup loads every node into memory to rank by
39/// connectivity (see [`build_report`]); on large workspace graphs (hundreds of
40/// thousands of nodes) that is too expensive to run on every planning/digest
41/// cycle. [`read_graph_evidence_bounded`] checks the cheap `graph_counts()`
42/// first and returns a `scanned: false` report when the store exceeds this cap,
43/// so callers still learn the KG exists without paying the full scan.
44pub const DEFAULT_EVIDENCE_MAX_SCAN_NODES: usize = 50_000;
45
46/// Query parameters for an evidence lookup. All fields optional except
47/// `limit` (defaults to [`DEFAULT_EVIDENCE_LIMIT`]).
48#[derive(Debug, Clone, Serialize)]
49pub struct GraphEvidenceQuery {
50    /// Substring matched case-insensitively against node `label`, `id`, and
51    /// `kind`. `None` means "no symbol filter" (return top-K by connectivity).
52    pub symbol: Option<String>,
53    /// Restrict matches to a single node `kind` (e.g. `kg_source`, `concept`).
54    pub kind: Option<String>,
55    /// Maximum number of matched nodes to return. Always ≥ 1.
56    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    /// Builder: set the symbol substring.
71    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    /// Builder: restrict to a node kind.
82    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    /// Builder: cap the number of returned nodes.
93    pub fn with_limit(mut self, limit: usize) -> Self {
94        self.limit = limit.max(1);
95        self
96    }
97}
98
99/// One matched node in the evidence report. Provenance fields are flattened
100/// out of `GraphNode::provenance` for easy caller consumption — agents want
101/// `source_ref` and `source_system` directly, not a nested provenance chain.
102#[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    /// Number of edges in the store that reference this node id on either
110    /// side. Cheap connectivity proxy used for ranking.
111    pub incident_edge_count: usize,
112}
113
114impl GraphEvidenceNode {
115    /// Project a `GraphNode` into an evidence row, looking up its connectivity
116    /// from the precomputed edge-count map.
117    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/// Result of an evidence lookup. Always returned (errors only for unreadable
134/// stores); a missing graph.db produces `exists: false` so callers can branch
135/// without try/catching.
136#[derive(Debug, Clone, Serialize)]
137pub struct GraphEvidenceReport {
138    pub graph_db: String,
139    pub exists: bool,
140    /// Whether the full node/edge scan ran. `false` means either the store was
141    /// missing (`exists: false`) or it exceeded the bounded-scan cap (see
142    /// [`read_graph_evidence_bounded`]); in the latter case `total_nodes_in_db`
143    /// / `total_edges_in_db` are still populated from the cheap `graph_counts()`
144    /// query but `matched_nodes` is empty.
145    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
152/// Resolve the graph.db under a project root and read evidence. Equivalent to
153/// `read_graph_evidence_from_db(project_root.join(".tsift/graph.db"), query)`.
154pub 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
162/// Read evidence from an explicit graph.db path. The canonical entry point —
163/// CLI callers pass `--graph-db` here directly; library callers usually go
164/// through `read_graph_evidence` with a project root.
165pub 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
195/// Read evidence with a node-count guard so large workspace graphs do not pay
196/// the full `all_nodes()` / `all_edges()` scan on every call. Intended for the
197/// planning/session-digest hot path (#kgwiring) where the lookup runs on every
198/// cycle but the graph may hold hundreds of thousands of nodes.
199///
200/// Behavior:
201/// - Missing store → `exists: false`, `scanned: false` (same as
202///   [`read_graph_evidence_from_db`]).
203/// - Store present but `total_nodes > max_scan_nodes` → `exists: true`,
204///   `scanned: false`, totals from the cheap `graph_counts()` query, and an
205///   empty `matched_nodes` (the caller learns the KG exists without the scan).
206/// - Otherwise → full scan via [`build_report`], `scanned: true`.
207pub 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
250/// Pure projection from loaded nodes/edges to an evidence report. Separated
251/// from `read_graph_evidence_from_db` so unit tests can drive it without a
252/// tempfile or sqlite — just pass synthetic node/edge vecs.
253pub 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            // n:lease has no edges on purpose — exercises the zero-connectivity rank.
359        ]
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        // n:kg-1 has 2 incident edges (calls n:kg-2, related_to n:other);
372        // n:kg-2 has 2 incident edges (called by n:kg-1, emits n:json).
373        // Tie broken by ascending label byte order: 'O' (0x4F) < 't' (0x74),
374        // so "OllamaKgExtractor" (n:kg-2) sorts before "tsift-kg" (n:kg-1).
375        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        // n:lease comes last (0 edges).
380        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        // Matches label "GPU lease registry" and label "lease". NOT "tsift-kg".
393        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        // "kg_source" is a kind; a symbol search should pick up nodes by kind
406        // even when their label does not contain the substring.
407        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        // Limit does not change total counts.
442        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 the writable handle so the read-only open is clean.
506        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        // Cap of 2 < 5 nodes → totals reported from graph_counts, no scan.
592        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); // .with_limit clamps to ≥ 1
609    }
610}