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            .upsert(("memory_nodes", id.as_str()))
65            .content(row)
66            .await?;
67        Ok(id)
68    }
69
70    pub async fn graph_link(&self, from_id: &str, to_id: &str, rel: &str) -> Result<()> {
71        let edge_id = format!("{from_id}::{rel}::{to_id}");
72        let row = GraphEdge {
73            from_id: from_id.to_string(),
74            to_id: to_id.to_string(),
75            rel: rel.to_string(),
76            created_at: Utc::now().to_rfc3339(),
77        };
78        let _: Option<GraphEdge> = self
79            .db()
80            .upsert(("memory_edges", edge_id.as_str()))
81            .content(row)
82            .await?;
83        Ok(())
84    }
85
86    pub async fn graph_neighbors(&self, node_id: &str, limit: usize) -> Result<Vec<GraphNode>> {
87        let mut res = self
88            .db()
89            .query("SELECT * FROM memory_edges WHERE from_id = $id OR to_id = $id LIMIT 50")
90            .bind(("id", node_id.to_string()))
91            .await?;
92        let edges: Vec<GraphEdge> = res.take(0)?;
93        let mut nodes = Vec::new();
94        for e in edges {
95            let other = if e.from_id == node_id {
96                e.to_id.as_str()
97            } else {
98                e.from_id.as_str()
99            };
100            let n: Option<GraphNode> = self.db().select(("memory_nodes", other)).await?;
101            if let Some(n) = n {
102                nodes.push(n);
103            }
104            if nodes.len() >= limit {
105                break;
106            }
107        }
108        Ok(nodes)
109    }
110
111    pub async fn graph_match_text(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
112        let q = query.to_lowercase();
113        let words: Vec<&str> = q.split_whitespace().filter(|w| w.len() > 2).collect();
114        if words.is_empty() {
115            return Ok(Vec::new());
116        }
117        let mut res = self
118            .db()
119            .query("SELECT * FROM memory_nodes LIMIT 200")
120            .await?;
121        let all: Vec<GraphNode> = res.take(0)?;
122        let mut scored: Vec<(f32, GraphNode)> = all
123            .into_iter()
124            .filter_map(|n| {
125                let t = n.text.to_lowercase();
126                let score = words.iter().filter(|w| t.contains(**w)).count() as f32;
127                if score > 0.0 {
128                    Some((score, n))
129                } else {
130                    None
131                }
132            })
133            .collect();
134        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
135        Ok(scored.into_iter().take(limit).map(|(_, n)| n).collect())
136    }
137
138    /// Offline-style dream stub: link open-loop text to a low-confidence idea node.
139    pub async fn graph_dream_from_loops(&self, open_loops: &[String]) -> Result<usize> {
140        let mut n = 0usize;
141        for line in open_loops.iter().take(5) {
142            let parent = self.graph_upsert_node("loop", line, 0.9, "active").await?;
143            let dream_text = format!("What if we explored: {line}?");
144            let child = self
145                .graph_upsert_node("dream", &dream_text, 0.35, "dream")
146                .await?;
147            self.graph_link(&parent, &child, "hypothesized").await?;
148            n += 1;
149        }
150        Ok(n)
151    }
152
153    pub fn format_graph_context(&self, nodes: &[GraphNode]) -> Option<String> {
154        if nodes.is_empty() {
155            return None;
156        }
157        let mut out = String::from("<idea_graph>\n");
158        for n in nodes {
159            out.push_str(&format!(
160                "  - [{}:{}] {} (conf {:.2})\n",
161                n.kind, n.status, n.text, n.confidence
162            ));
163        }
164        out.push_str("</idea_graph>");
165        Some(out)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn slug_stable() {
175        assert!(slug_id("idea", "Hello World").starts_with("idea-"));
176    }
177}