Skip to main content

apollo/memory/
graph.rs

1//! Idea graph — nodes, edges, low-confidence "dream" hypotheses (Surreal-native).
2
3use anyhow::Result;
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6
7use super::surreal::SurrealMemory;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct GraphNode {
11    pub id: String,
12    pub kind: String,
13    pub text: String,
14    pub confidence: f32,
15    pub status: String,
16    pub created_at: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct GraphEdge {
21    pub from_id: String,
22    pub to_id: String,
23    pub rel: String,
24    pub created_at: String,
25}
26
27fn slug_id(prefix: &str, text: &str) -> String {
28    let mut s: String = text
29        .chars()
30        .map(|c| {
31            if c.is_ascii_alphanumeric() {
32                c.to_ascii_lowercase()
33            } else {
34                '-'
35            }
36        })
37        .collect();
38    while s.contains("--") {
39        s = s.replace("--", "-");
40    }
41    s = s.trim_matches('-').chars().take(48).collect();
42    format!("{prefix}-{s}")
43}
44
45impl SurrealMemory {
46    pub async fn graph_upsert_node(
47        &self,
48        kind: &str,
49        text: &str,
50        confidence: f32,
51        status: &str,
52    ) -> Result<String> {
53        let id = slug_id(kind, text);
54        let row = GraphNode {
55            id: id.clone(),
56            kind: kind.to_string(),
57            text: text.to_string(),
58            confidence,
59            status: status.to_string(),
60            created_at: Utc::now().to_rfc3339(),
61        };
62        let _: Option<GraphNode> = self
63            .db()
64            .await?
65            .upsert(("memory_nodes", id.as_str()))
66            .content(row)
67            .await?;
68        Ok(id)
69    }
70
71    pub async fn graph_link(&self, from_id: &str, to_id: &str, rel: &str) -> Result<()> {
72        let edge_id = format!("{from_id}::{rel}::{to_id}");
73        let row = GraphEdge {
74            from_id: from_id.to_string(),
75            to_id: to_id.to_string(),
76            rel: rel.to_string(),
77            created_at: Utc::now().to_rfc3339(),
78        };
79        let _: Option<GraphEdge> = self
80            .db()
81            .await?
82            .upsert(("memory_edges", edge_id.as_str()))
83            .content(row)
84            .await?;
85        Ok(())
86    }
87
88    pub async fn graph_neighbors(&self, node_id: &str, limit: usize) -> Result<Vec<GraphNode>> {
89        let mut res = self
90            .db()
91            .await?
92            .query("SELECT * FROM memory_edges WHERE from_id = $id OR to_id = $id LIMIT 50")
93            .bind(("id", node_id.to_string()))
94            .await?;
95        let edges: Vec<GraphEdge> = res.take(0)?;
96        let mut nodes = Vec::new();
97        for e in edges {
98            let other = if e.from_id == node_id {
99                e.to_id.as_str()
100            } else {
101                e.from_id.as_str()
102            };
103            let n: Option<GraphNode> = self.db().await?.select(("memory_nodes", other)).await?;
104            if let Some(n) = n {
105                nodes.push(n);
106            }
107            if nodes.len() >= limit {
108                break;
109            }
110        }
111        Ok(nodes)
112    }
113
114    pub async fn graph_match_text(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
115        let q = query.to_lowercase();
116        let words: Vec<&str> = q.split_whitespace().filter(|w| w.len() > 2).collect();
117        if words.is_empty() {
118            return Ok(Vec::new());
119        }
120        let mut res = self
121            .db()
122            .await?
123            .query("SELECT * FROM memory_nodes LIMIT 200")
124            .await?;
125        let all: Vec<GraphNode> = res.take(0)?;
126        let mut scored: Vec<(f32, GraphNode)> = all
127            .into_iter()
128            .filter_map(|n| {
129                let t = n.text.to_lowercase();
130                let score = words.iter().filter(|w| t.contains(**w)).count() as f32;
131                if score > 0.0 {
132                    Some((score, n))
133                } else {
134                    None
135                }
136            })
137            .collect();
138        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
139        Ok(scored.into_iter().take(limit).map(|(_, n)| n).collect())
140    }
141
142    /// Offline-style dream stub: link open-loop text to a low-confidence idea node.
143    pub async fn graph_dream_from_loops(&self, open_loops: &[String]) -> Result<usize> {
144        let mut n = 0usize;
145        for line in open_loops.iter().take(5) {
146            let parent = self.graph_upsert_node("loop", line, 0.9, "active").await?;
147            let dream_text = format!("What if we explored: {line}?");
148            let child = self
149                .graph_upsert_node("dream", &dream_text, 0.35, "dream")
150                .await?;
151            self.graph_link(&parent, &child, "hypothesized").await?;
152            n += 1;
153        }
154        Ok(n)
155    }
156
157    pub fn format_graph_context(&self, nodes: &[GraphNode]) -> Option<String> {
158        if nodes.is_empty() {
159            return None;
160        }
161        let mut out = String::from("<idea_graph>\n");
162        for n in nodes {
163            out.push_str(&format!(
164                "  - [{}:{}] {} (conf {:.2})\n",
165                n.kind, n.status, n.text, n.confidence
166            ));
167        }
168        out.push_str("</idea_graph>");
169        Some(out)
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn slug_stable() {
179        assert!(slug_id("idea", "Hello World").starts_with("idea-"));
180    }
181}