Skip to main content

grit_core/
query.rs

1//! Read path: point lookups, validity-filtered traversal, bi-temporal history,
2//! and merge-candidate scoring.
3
4use rusqlite::params;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use crate::Grit;
9use crate::clock::TimestampMs;
10use crate::error::Result;
11use crate::model::{
12    self, EDGE_COLS, EPISODE_COLS, Edge, Episode, NODE_COLS, Node, Subgraph, collect,
13};
14
15/// Parameters for [`Grit::traverse`]. `Default` gives depth 3 (the AGENTS.md
16/// default bound), both timelines evaluated "now", no group filter.
17#[derive(Debug, Clone)]
18pub struct Traversal {
19    /// Maximum hops from the seed set (inclusive bound; default 3).
20    pub depth: u32,
21    /// Event-time instant: only walk edges whose `[valid_at, invalid_at)`
22    /// interval contains this. `None` = clock now.
23    pub as_of: Option<TimestampMs>,
24    /// System-time instant ("what did I believe at t?"): only walk edges whose
25    /// `[created_at, expired_at)` interval contains this. `None` = clock now.
26    pub as_at: Option<TimestampMs>,
27    /// Restrict the walk to one namespace.
28    pub group_id: Option<String>,
29    /// Node budget: the walk halts (breadth-first, nearest wins) once this
30    /// many nodes are reached. Keeps traversal latency independent of
31    /// neighborhood size — Layer 3 asks for "context that fits", never the
32    /// 7,000-node ball around a hub. Default 256.
33    pub max_nodes: usize,
34}
35
36impl Default for Traversal {
37    fn default() -> Self {
38        Self {
39            depth: 3,
40            as_of: None,
41            as_at: None,
42            group_id: None,
43            max_nodes: 256,
44        }
45    }
46}
47
48impl Traversal {
49    /// Set the hop bound.
50    pub fn depth(mut self, depth: u32) -> Self {
51        self.depth = depth;
52        self
53    }
54
55    /// Set the event-time instant.
56    pub fn as_of(mut self, t: TimestampMs) -> Self {
57        self.as_of = Some(t);
58        self
59    }
60
61    /// Set the system-time (belief) instant — this is the time-travel knob.
62    pub fn as_at(mut self, t: TimestampMs) -> Self {
63        self.as_at = Some(t);
64        self
65    }
66
67    /// Restrict to a namespace.
68    pub fn group(mut self, group_id: impl Into<String>) -> Self {
69        self.group_id = Some(group_id.into());
70        self
71    }
72
73    /// Set the node budget (`usize::MAX` for an unbounded walk).
74    pub fn max_nodes(mut self, n: usize) -> Self {
75        self.max_nodes = n;
76        self
77    }
78}
79
80/// Row counts, as reported by [`Grit::stats`].
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Stats {
83    /// Total node rows (including expired/merged-away ones).
84    pub nodes: usize,
85    /// Total edge rows.
86    pub edges: usize,
87    /// Total episodes.
88    pub episodes: usize,
89    /// Total mention links.
90    pub mentions: usize,
91    /// Total oplog entries.
92    pub oplog: usize,
93    /// Purged (tombstoned) ids.
94    pub purged: usize,
95}
96
97/// Bi-temporal audit trail for one node: every incident edge row ever
98/// believed, including invalidated and expired ones, oldest belief first.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct NodeHistory {
101    /// The node row (even if expired/merged away).
102    pub node: Node,
103    /// All incident edges, unfiltered, ordered by `created_at`.
104    pub edges: Vec<Edge>,
105}
106
107/// A scored suggestion from [`Grit::find_merge_candidates`]. Grit never merges
108/// on its own — deciding is Layer 2's LLM-judgment call; grit only scores.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct MergeCandidate {
111    /// The candidate duplicate.
112    pub node: Node,
113    /// Jaro-Winkler similarity of names, in `[0, 1]`.
114    pub name_score: f64,
115    /// Cosine similarity of embeddings, in `[-1, 1]`, if both are embedded.
116    pub vector_score: Option<f64>,
117    /// max(name_score, vector_score) — the ranking key.
118    pub score: f64,
119}
120
121/// One BFS level: expand every frontier node (bound as a JSON array — no SQL
122/// built from user input) across valid edges to the DISTINCT set of neighbor
123/// ids. ?1 frontier, ?2 group, ?3 as_at, ?4 as_of.
124///
125/// This is the current-belief variant: `as_at` is now, so the denormalized
126/// `edges.invalid_at` (maintained as MIN over all invalidation records) IS
127/// the belief-current value and the per-edge subquery is unnecessary.
128#[doc(hidden)]
129pub const EXPAND_SQL_CURRENT: &str = "
130SELECT DISTINCT CASE WHEN e.src = f.value THEN e.dst ELSE e.src END
131FROM json_each(?1) AS f
132JOIN edges AS e ON e.src = f.value OR e.dst = f.value
133WHERE (?2 IS NULL OR e.group_id = ?2)
134  AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
135  AND (e.valid_at IS NULL OR e.valid_at <= ?4)
136  AND (e.invalid_at IS NULL OR e.invalid_at > ?4)";
137
138/// Time-travel variant of [`EXPAND_SQL_CURRENT`]: a historical `as_at` must
139/// ignore invalidations recorded after it, so validity consults the
140/// belief-versioned `edge_invalidations` records instead of the folded
141/// `edges.invalid_at`.
142#[doc(hidden)]
143pub const EXPAND_SQL_AS_AT: &str = "
144SELECT DISTINCT CASE WHEN e.src = f.value THEN e.dst ELSE e.src END
145FROM json_each(?1) AS f
146JOIN edges AS e ON e.src = f.value OR e.dst = f.value
147WHERE (?2 IS NULL OR e.group_id = ?2)
148  AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
149  AND (e.valid_at IS NULL OR e.valid_at <= ?4)
150  AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
151                  WHERE iv.edge_id = e.id
152                    AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)";
153
154/// Keep only ids that are believed nodes at ?2 (purged and merged-away nodes
155/// fail this — the walk never passes through ghosts). ORDER BY id makes the
156/// budget cut deterministic.
157const BELIEVED_NODES_SQL: &str = "
158SELECT id FROM nodes
159WHERE id IN (SELECT value FROM json_each(?1))
160  AND created_at <= ?2 AND (expired_at IS NULL OR expired_at > ?2)
161ORDER BY id";
162
163impl Grit {
164    /// Row counts across the whole file, from one consistent snapshot.
165    pub fn stats(&self) -> Result<Stats> {
166        let conn = self.read();
167        let tx = conn.unchecked_transaction()?;
168        let count = |table: &str| -> Result<usize> {
169            // Table names are compile-time constants below, never user input.
170            let n: i64 =
171                tx.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?;
172            Ok(n as usize)
173        };
174        Ok(Stats {
175            nodes: count("nodes")?,
176            edges: count("edges")?,
177            episodes: count("episodes")?,
178            mentions: count("mentions")?,
179            oplog: count("oplog")?,
180            purged: count("purged")?,
181        })
182    }
183
184    /// Fetch one node by id (regardless of validity — point lookups see
185    /// everything; filtering is for traversal/search).
186    pub fn node(&self, id: Uuid) -> Result<Option<Node>> {
187        node_on(&self.read(), id)
188    }
189
190    /// Fetch one edge by id.
191    pub fn edge(&self, id: Uuid) -> Result<Option<Edge>> {
192        let conn = self.read();
193        let mut stmt =
194            conn.prepare_cached(&format!("SELECT {EDGE_COLS} FROM edges WHERE id = ?1"))?;
195        let mut rows = collect(stmt.query_map(params![id.to_string()], Edge::from_row)?)?;
196        Ok(rows.pop())
197    }
198
199    /// Fetch one episode by id.
200    pub fn episode(&self, id: Uuid) -> Result<Option<Episode>> {
201        let conn = self.read();
202        let mut stmt = conn.prepare_cached(&format!(
203            "SELECT {EPISODE_COLS} FROM episodes WHERE id = ?1"
204        ))?;
205        let mut rows = collect(stmt.query_map(params![id.to_string()], Episode::from_row)?)?;
206        Ok(rows.pop())
207    }
208
209    /// Episode ids that mention the given node or edge (provenance lookup).
210    /// Mention rows keep their original target ids, so this resolves `target`
211    /// to its canonical node and gathers mentions across all merged aliases.
212    pub fn mentions_of(&self, target: Uuid) -> Result<Vec<Uuid>> {
213        let conn = self.read();
214        let tx = conn.unchecked_transaction()?;
215        episodes_mentioning(&tx, &target.to_string())
216    }
217
218    /// Expand from `from` along edges valid at the requested instant, up to
219    /// `spec.depth` hops, and return the induced subgraph. Time-travel is the
220    /// public API here (Design Invariant 4): pass `as_at` for "what did I
221    /// believe at t?", `as_of` for "what was true at t?".
222    ///
223    /// # Example
224    /// ```no_run
225    /// # use grit_core::{Grit, Options, Traversal};
226    /// # let g = Grit::open("memory.db", Options::new("laptop"))?;
227    /// # let (node_id, march) = (g.new_id(), 0);
228    /// let believed_in_march = g.traverse(&[node_id], &Traversal::default().as_at(march))?;
229    /// # Ok::<(), grit_core::Error>(())
230    /// ```
231    pub fn traverse(&self, from: &[Uuid], spec: &Traversal) -> Result<Subgraph> {
232        use std::collections::HashSet;
233
234        let now = self.now_ms();
235        let as_of = spec.as_of.unwrap_or(now);
236        let as_at = spec.as_at.unwrap_or(now);
237        // A historical as_at must ignore invalidations recorded after it;
238        // otherwise the folded edges.invalid_at is exact and much cheaper.
239        let expand_sql = if spec.as_at.is_none_or(|t| t >= now) {
240            EXPAND_SQL_CURRENT
241        } else {
242            EXPAND_SQL_AS_AT
243        };
244
245        let conn = self.read();
246        // One read transaction for the whole walk: every BFS level and the
247        // final fetches see the same committed snapshot — a write landing
248        // mid-traversal cannot produce a subgraph no database state ever held.
249        let tx = conn.unchecked_transaction()?;
250
251        // Stale handles are fine: seeds follow merged_into to their canonical
252        // node before the walk starts, then must themselves be believed at
253        // as_at (purged / merged-away nodes are dropped, so the walk never
254        // starts inside a ghost).
255        let mut in_visited: HashSet<String> = HashSet::new();
256        let mut seeds: Vec<String> = Vec::with_capacity(from.len());
257        for id in from {
258            let canon = resolve_read(&tx, &id.to_string())?;
259            if in_visited.insert(canon.clone()) {
260                seeds.push(canon);
261            }
262        }
263        let believed: HashSet<String> = believed_nodes(&tx, &seeds, as_at)?.into_iter().collect();
264        let mut visited: Vec<String> = seeds
265            .into_iter()
266            .filter(|s| believed.contains(s))
267            .take(spec.max_nodes)
268            .collect();
269        in_visited = visited.iter().cloned().collect();
270
271        // Level-synchronous BFS: one batched expansion per depth level, each
272        // node expanded exactly once, believed-ness checked once per NEW node
273        // (not once per edge). The budget cut is deterministic — nearest
274        // level first, ordered by id within a level.
275        let mut frontier = visited.clone();
276        for _ in 0..spec.depth {
277            if frontier.is_empty() || visited.len() >= spec.max_nodes {
278                break;
279            }
280            let neighbors: Vec<String> = {
281                let mut stmt = tx.prepare_cached(expand_sql)?;
282                let rows = stmt.query_map(
283                    params![
284                        serde_json::to_string(&frontier)?,
285                        spec.group_id,
286                        as_at,
287                        as_of
288                    ],
289                    |r| r.get::<_, String>(0),
290                )?;
291                collect(rows)?
292            };
293            let fresh: Vec<String> = neighbors
294                .into_iter()
295                .filter(|id| !in_visited.contains(id))
296                .collect();
297            let fresh: Vec<String> = believed_nodes(&tx, &fresh, as_at)?
298                .into_iter()
299                .filter(|id| !in_visited.contains(id))
300                .take(spec.max_nodes - visited.len())
301                .collect();
302            for id in &fresh {
303                in_visited.insert(id.clone());
304            }
305            visited.extend(fresh.iter().cloned());
306            frontier = fresh;
307        }
308        let visited_json = serde_json::to_string(&visited)?;
309
310        let nodes = {
311            let mut stmt = tx.prepare_cached(&format!(
312                "SELECT {NODE_COLS} FROM nodes
313                 WHERE id IN (SELECT value FROM json_each(?1))
314                 ORDER BY id"
315            ))?;
316            collect(stmt.query_map(params![visited_json], Node::from_row)?)?
317        };
318        // Induced subgraph: both endpoints in the visited set, which contains
319        // only believed nodes by construction — ghost edges (an endpoint
320        // purged or merged away) can never qualify.
321        let invalid_clause = if spec.as_at.is_none_or(|t| t >= now) {
322            "(e.invalid_at IS NULL OR e.invalid_at > ?4)"
323        } else {
324            "NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
325                         WHERE iv.edge_id = e.id
326                           AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)"
327        };
328        let edges = {
329            let mut stmt = tx.prepare_cached(&format!(
330                "SELECT {EDGE_COLS} FROM edges AS e
331                 WHERE e.src IN (SELECT value FROM json_each(?1))
332                   AND e.dst IN (SELECT value FROM json_each(?1))
333                   AND (?2 IS NULL OR e.group_id = ?2)
334                   AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
335                   AND (e.valid_at IS NULL OR e.valid_at <= ?4)
336                   AND {invalid_clause}
337                 ORDER BY e.id"
338            ))?;
339            collect(stmt.query_map(
340                params![visited_json, spec.group_id, as_at, as_of],
341                Edge::from_row,
342            )?)?
343        };
344        Ok(Subgraph { nodes, edges })
345    }
346
347    /// Full bi-temporal audit for a node: the node row plus every incident
348    /// edge ever believed, invalidated and expired included — one snapshot.
349    pub fn node_history(&self, id: Uuid) -> Result<NodeHistory> {
350        let conn = self.read();
351        let tx = conn.unchecked_transaction()?;
352        let node = node_on(&tx, id)?.ok_or(crate::Error::NotFound(id))?;
353        let mut stmt = tx.prepare_cached(&format!(
354            "SELECT {EDGE_COLS} FROM edges WHERE src = ?1 OR dst = ?1
355             ORDER BY created_at, id"
356        ))?;
357        let edges = collect(stmt.query_map(params![id.to_string()], Edge::from_row)?)?;
358        Ok(NodeHistory { node, edges })
359    }
360
361    /// Score possible duplicates of `id` within its group: Jaro-Winkler on
362    /// names, cosine over embeddings when present. Returns candidates with
363    /// `score >= min_score`, best first. Grit never acts on these — executing
364    /// a [`crate::GraphOp::MergeNodes`] is Layer 2's decision.
365    pub fn find_merge_candidates(&self, id: Uuid, min_score: f64) -> Result<Vec<MergeCandidate>> {
366        let conn = self.read();
367        let tx = conn.unchecked_transaction()?;
368        let target = node_on(&tx, id)?.ok_or(crate::Error::NotFound(id))?;
369        let vector_scores = node_vector_neighbors(&tx, id)?;
370
371        let others = {
372            let mut stmt = tx.prepare_cached(&format!(
373                "SELECT {NODE_COLS} FROM nodes
374                 WHERE group_id = ?1 AND id != ?2 AND expired_at IS NULL"
375            ))?;
376            collect(stmt.query_map(params![target.group_id, id.to_string()], Node::from_row)?)?
377        };
378        drop(tx);
379        drop(conn);
380
381        let target_name = target.name.to_lowercase();
382        let mut out: Vec<MergeCandidate> = others
383            .into_iter()
384            .map(|node| {
385                let name_score = strsim::jaro_winkler(&target_name, &node.name.to_lowercase());
386                let vector_score = vector_scores.get(&node.id).copied();
387                let score = vector_score.map_or(name_score, |v| name_score.max(v));
388                MergeCandidate {
389                    node,
390                    name_score,
391                    vector_score,
392                    score,
393                }
394            })
395            .filter(|c| c.score >= min_score)
396            .collect();
397        out.sort_by(|a, b| b.score.total_cmp(&a.score));
398        Ok(out)
399    }
400}
401
402/// Cosine similarity of every embedded node against `id`'s embedding,
403/// empty if `id` has no embedding (or no model is registered).
404fn node_vector_neighbors(
405    conn: &rusqlite::Connection,
406    id: Uuid,
407) -> Result<std::collections::HashMap<Uuid, f64>> {
408    let has_vec: i64 = conn.query_row(
409        "SELECT COUNT(*) FROM sqlite_master WHERE name = 'vec_nodes'",
410        [],
411        |r| r.get(0),
412    )?;
413    let mut out = std::collections::HashMap::new();
414    if has_vec == 0 {
415        return Ok(out);
416    }
417    let embedding: Option<Vec<u8>> = {
418        let mut stmt = conn.prepare_cached("SELECT embedding FROM vec_nodes WHERE id = ?1")?;
419        let mut rows =
420            collect(stmt.query_map(params![id.to_string()], |r| r.get::<_, Vec<u8>>(0))?)?;
421        rows.pop()
422    };
423    let Some(embedding) = embedding else {
424        return Ok(out);
425    };
426    // distance_metric=cosine ⇒ distance = 1 - cos_sim.
427    // KNN queries only allow the MATCH + k constraints; the self-hit is
428    // filtered out here instead.
429    let mut stmt = conn
430        .prepare_cached("SELECT id, distance FROM vec_nodes WHERE embedding MATCH ?1 AND k = 64")?;
431    let rows = stmt.query_map(params![embedding], |r| {
432        Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?))
433    })?;
434    for row in rows {
435        let (nid, distance) = row?;
436        if let Ok(nid) = Uuid::parse_str(&nid)
437            && nid != id
438        {
439            out.insert(nid, 1.0 - distance);
440        }
441    }
442    Ok(out)
443}
444
445/// Point lookup on an already-held connection/transaction.
446fn node_on(conn: &rusqlite::Connection, id: Uuid) -> Result<Option<Node>> {
447    let mut stmt = conn.prepare_cached(&format!("SELECT {NODE_COLS} FROM nodes WHERE id = ?1"))?;
448    let mut rows = collect(stmt.query_map(params![id.to_string()], Node::from_row)?)?;
449    Ok(rows.pop())
450}
451
452pub(crate) fn ids_json<'a>(ids: impl Iterator<Item = &'a Uuid>) -> String {
453    serde_json::to_string(&ids.map(Uuid::to_string).collect::<Vec<_>>())
454        .expect("Vec<String> serialization cannot fail")
455}
456
457/// Filter `ids` down to believed nodes at `as_at`, sorted by id (see
458/// [`BELIEVED_NODES_SQL`]).
459fn believed_nodes(
460    conn: &rusqlite::Connection,
461    ids: &[String],
462    as_at: TimestampMs,
463) -> Result<Vec<String>> {
464    if ids.is_empty() {
465        return Ok(Vec::new());
466    }
467    let mut stmt = conn.prepare_cached(BELIEVED_NODES_SQL)?;
468    let rows = stmt.query_map(params![serde_json::to_string(ids)?, as_at], |r| {
469        r.get::<_, String>(0)
470    })?;
471    collect(rows)
472}
473
474/// Provenance across merges: resolve `id` to its canonical node, then collect
475/// mentions of every alias that folded into it (mention rows keep original
476/// target ids — the writer never re-points them).
477pub(crate) fn episodes_mentioning(
478    conn: &rusqlite::Connection,
479    id: &str,
480) -> Result<Vec<uuid::Uuid>> {
481    let canon = resolve_read(conn, id)?;
482    let mut stmt = conn.prepare_cached(
483        "WITH RECURSIVE aliases (id) AS (
484             SELECT ?1
485             UNION
486             SELECT n.id FROM nodes AS n JOIN aliases AS a ON n.merged_into = a.id
487             UNION
488             SELECT p.id FROM purged AS p JOIN aliases AS a ON p.merged_into = a.id
489         )
490         SELECT DISTINCT episode_id FROM mentions
491         WHERE target_id IN (SELECT id FROM aliases)
492         ORDER BY episode_id",
493    )?;
494    let rows = stmt.query_map(params![canon], |r| model::uuid_col(r, 0))?;
495    collect(rows)
496}
497
498/// Read-side twin of the writer's canonical resolution: follow `merged_into`
499/// to the surviving node, cycle-safe (min id wins, same rule as apply).
500fn resolve_read(conn: &rusqlite::Connection, id: &str) -> Result<String> {
501    use rusqlite::OptionalExtension;
502    let mut chain: Vec<String> = vec![id.to_owned()];
503    let mut seen: std::collections::HashSet<String> = chain.iter().cloned().collect();
504    loop {
505        let current = chain.last().expect("chain never empty");
506        let mut stmt = conn.prepare_cached(
507            "SELECT merged_into FROM nodes WHERE id = ?1
508             UNION ALL
509             SELECT merged_into FROM purged WHERE id = ?1
510             LIMIT 1",
511        )?;
512        let next: Option<Option<String>> =
513            stmt.query_row(params![current], |r| r.get(0)).optional()?;
514        match next {
515            None | Some(None) => return Ok(chain.pop().expect("chain never empty")),
516            Some(Some(next)) => {
517                if seen.contains(&next) {
518                    let pos = chain
519                        .iter()
520                        .position(|x| x == &next)
521                        .expect("seen implies present");
522                    return Ok(chain[pos..]
523                        .iter()
524                        .min()
525                        .expect("cycle slice non-empty")
526                        .clone());
527                }
528                seen.insert(next.clone());
529                chain.push(next);
530            }
531        }
532    }
533}