Skip to main content

recall_echo/graph/
traverse.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Graph traversal — recursive depth-first with cycle detection.
6
7use std::fmt::Write as _;
8
9use surrealdb::Surreal;
10
11use super::confidence;
12use super::error::GraphError;
13use super::store::Db;
14use super::types::*;
15
16/// Traverse the graph from a named entity up to a given depth.
17/// Skips superseded relationships (valid_until IS NOT NULL).
18pub async fn traverse(
19    db: &Surreal<Db>,
20    entity_name: &str,
21    max_depth: u32,
22) -> Result<TraversalNode, GraphError> {
23    traverse_filtered(db, entity_name, max_depth, None).await
24}
25
26/// Traverse with an optional entity type filter.
27/// When `type_filter` is set, only neighbors matching that type are expanded.
28pub async fn traverse_filtered(
29    db: &Surreal<Db>,
30    entity_name: &str,
31    max_depth: u32,
32    type_filter: Option<&str>,
33) -> Result<TraversalNode, GraphError> {
34    // Load root as full entity (for access_count increment), project to L0
35    let full = super::crud::get_entity_by_name(db, entity_name)
36        .await?
37        .ok_or_else(|| GraphError::NotFound(entity_name.to_string()))?;
38
39    // Increment access count on root entity only
40    super::crud::increment_access_counts(db, &[full.id_string()]).await?;
41
42    let root = EntitySummary {
43        id: full.id.clone(),
44        name: full.name,
45        entity_type: full.entity_type,
46        abstract_text: full.abstract_text,
47    };
48
49    traverse_from(db, &root, max_depth, 0, &mut vec![], type_filter).await
50}
51
52/// `Send` matters: the serve daemon traverses inside a spawned tokio task.
53type TraversalFuture<'a> = std::pin::Pin<
54    Box<dyn std::future::Future<Output = Result<TraversalNode, GraphError>> + Send + 'a>,
55>;
56
57/// Recursive traversal with cycle detection, using L0 projections.
58fn traverse_from<'a>(
59    db: &'a Surreal<Db>,
60    entity: &'a EntitySummary,
61    max_depth: u32,
62    current_depth: u32,
63    visited: &'a mut Vec<String>,
64    type_filter: Option<&'a str>,
65) -> TraversalFuture<'a> {
66    Box::pin(async move {
67        visited.push(entity.id_string());
68
69        if current_depth >= max_depth {
70            return Ok(TraversalNode {
71                entity: entity.clone(),
72                edges: vec![],
73            });
74        }
75
76        let mut edges = Vec::new();
77
78        let now = chrono::Utc::now();
79
80        // Get outgoing relationships that are still active
81        let mut response = db
82            .query(
83                r#"
84            SELECT
85                rel_type,
86                valid_from,
87                valid_until,
88                confidence,
89                last_reinforced,
90                out AS target_id
91            FROM relates_to
92            WHERE in = type::record($id)
93              AND valid_until IS NONE
94            "#,
95            )
96            .bind(("id", entity.id_string()))
97            .await?;
98
99        let outgoing: Vec<EdgeRow> = super::deserialize_take(&mut response, 0)?;
100        collect_edges(
101            db,
102            outgoing,
103            "->",
104            max_depth,
105            current_depth,
106            visited,
107            type_filter,
108            &mut edges,
109            &now,
110        )
111        .await?;
112
113        // Get incoming relationships
114        let mut response = db
115            .query(
116                r#"
117            SELECT
118                rel_type,
119                valid_from,
120                valid_until,
121                confidence,
122                last_reinforced,
123                in AS target_id
124            FROM relates_to
125            WHERE out = type::record($id)
126              AND valid_until IS NONE
127            "#,
128            )
129            .bind(("id", entity.id_string()))
130            .await?;
131
132        let incoming: Vec<EdgeRow> = super::deserialize_take(&mut response, 0)?;
133        collect_edges(
134            db,
135            incoming,
136            "<-",
137            max_depth,
138            current_depth,
139            visited,
140            type_filter,
141            &mut edges,
142            &now,
143        )
144        .await?;
145
146        Ok(TraversalNode {
147            entity: entity.clone(),
148            edges,
149        })
150    })
151}
152
153/// Process edge rows, load targets as L0, apply type filter and decay, recurse.
154#[allow(clippy::too_many_arguments)]
155async fn collect_edges<'a>(
156    db: &'a Surreal<Db>,
157    edge_rows: Vec<EdgeRow>,
158    direction: &str,
159    max_depth: u32,
160    current_depth: u32,
161    visited: &'a mut Vec<String>,
162    type_filter: Option<&'a str>,
163    edges: &'a mut Vec<TraversalEdge>,
164    now: &'a chrono::DateTime<chrono::Utc>,
165) -> Result<(), GraphError> {
166    for edge in edge_rows {
167        // Apply temporal decay at read time
168        let effective = confidence::effective_confidence(
169            edge.confidence,
170            edge.last_reinforced.as_ref(),
171            &edge.valid_from,
172            now,
173        );
174
175        // Filter by effective confidence (not stored)
176        if effective < 0.1 {
177            continue;
178        }
179
180        let tid = edge.target_id_string();
181
182        // Load L0 projection
183        let target: Option<EntitySummary> = super::crud::get_entity_summary(db, &tid).await?;
184        if let Some(target) = target {
185            if visited.contains(&target.id_string()) {
186                continue;
187            }
188
189            // Apply type filter
190            if let Some(filter) = type_filter {
191                if target.entity_type.to_string() != filter {
192                    continue;
193                }
194            }
195
196            let child = traverse_from(
197                db,
198                &target,
199                max_depth,
200                current_depth + 1,
201                visited,
202                type_filter,
203            )
204            .await?;
205
206            edges.push(TraversalEdge {
207                rel_type: edge.rel_type,
208                direction: direction.to_string(),
209                target: child,
210                valid_from: edge.valid_from,
211                valid_until: edge.valid_until,
212                confidence: effective,
213            });
214        }
215    }
216
217    Ok(())
218}
219
220/// Format a traversal tree as an indented string for display.
221#[must_use]
222pub fn format_traversal(node: &TraversalNode, indent: usize) -> String {
223    let mut out = String::new();
224    let prefix = "  ".repeat(indent);
225
226    if indent == 0 {
227        let _ = writeln!(out, "{} ({})", node.entity.name, node.entity.entity_type);
228    }
229
230    for edge in &node.edges {
231        let superseded = if edge.valid_until.is_some() {
232            " [superseded]"
233        } else {
234            ""
235        };
236
237        let confidence_tag = if edge.confidence < 1.0 {
238            format!(" [{}%]", (edge.confidence * 100.0).round() as u32)
239        } else {
240            String::new()
241        };
242
243        let _ = writeln!(
244            out,
245            "{prefix}├── {} {} {}{confidence_tag}{superseded}",
246            edge.direction, edge.rel_type, edge.target.entity.name,
247        );
248
249        if !edge.target.edges.is_empty() {
250            out.push_str(&format_traversal(&edge.target, indent + 1));
251        }
252    }
253
254    out
255}