1use 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#[derive(Debug, Clone)]
18pub struct Traversal {
19 pub depth: u32,
21 pub as_of: Option<TimestampMs>,
24 pub as_at: Option<TimestampMs>,
27 pub group_id: Option<String>,
29 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 pub fn depth(mut self, depth: u32) -> Self {
51 self.depth = depth;
52 self
53 }
54
55 pub fn as_of(mut self, t: TimestampMs) -> Self {
57 self.as_of = Some(t);
58 self
59 }
60
61 pub fn as_at(mut self, t: TimestampMs) -> Self {
63 self.as_at = Some(t);
64 self
65 }
66
67 pub fn group(mut self, group_id: impl Into<String>) -> Self {
69 self.group_id = Some(group_id.into());
70 self
71 }
72
73 pub fn max_nodes(mut self, n: usize) -> Self {
75 self.max_nodes = n;
76 self
77 }
78}
79
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Stats {
83 pub nodes: usize,
85 pub edges: usize,
87 pub episodes: usize,
89 pub mentions: usize,
91 pub oplog: usize,
93 pub purged: usize,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct NodeHistory {
101 pub node: Node,
103 pub edges: Vec<Edge>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct MergeCandidate {
111 pub node: Node,
113 pub name_score: f64,
115 pub vector_score: Option<f64>,
117 pub score: f64,
119}
120
121#[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#[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
154const 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 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 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 pub fn node(&self, id: Uuid) -> Result<Option<Node>> {
187 node_on(&self.read(), id)
188 }
189
190 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 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 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 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 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 let tx = conn.unchecked_transaction()?;
250
251 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 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 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 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 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
402fn 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 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
445fn 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
457fn 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
474pub(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
498fn 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}