Skip to main content

recall_echo/graph/
mod.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//! recall-graph — Knowledge graph with semantic search for AI memory systems.
6//!
7//! Provides a structured graph layer (Layer 0) underneath flat-file memory systems.
8//! Used by recall-echo (pulse-null entities) and recall-claude (Claude Code users).
9
10pub mod confidence;
11pub mod crud;
12pub mod dedup;
13pub mod embed;
14pub mod error;
15pub mod extract;
16pub mod gc;
17pub mod ingest;
18pub mod llm;
19pub mod pipeline;
20pub mod pipeline_sync;
21pub mod query;
22pub mod search;
23pub mod store;
24pub mod traverse;
25pub mod types;
26pub mod util;
27pub mod utility;
28pub mod vigil_sync;
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33pub use confidence::{Provenance, ProvenanceWeights};
34use embed::{FastEmbedder, LazyEmbedder};
35use error::GraphError;
36pub use ingest::{IngestContext, ProvenancePolicy};
37use store::Db;
38pub use store::ServerConfig;
39#[allow(unused_imports)] // Required in scope for SurrealValue derive macro expansion
40use surrealdb::types::SurrealValue;
41use surrealdb::Surreal;
42use types::*;
43
44/// Take serde_json::Value results from a SurrealDB response and deserialize to a Rust type.
45/// This avoids needing SurrealValue derive on complex types.
46pub(crate) fn deserialize_take<T: serde::de::DeserializeOwned>(
47    response: &mut surrealdb::IndexedResults,
48    index: usize,
49) -> Result<Vec<T>, GraphError> {
50    let values: Vec<serde_json::Value> = response.take(index)?;
51    values
52        .into_iter()
53        .map(|v| serde_json::from_value(v).map_err(GraphError::from))
54        .collect()
55}
56
57pub(crate) fn deserialize_take_opt<T: serde::de::DeserializeOwned>(
58    response: &mut surrealdb::IndexedResults,
59    index: usize,
60) -> Result<Option<T>, GraphError> {
61    let values: Vec<T> = deserialize_take(response, index)?;
62    Ok(values.into_iter().next())
63}
64
65/// The main entry point for graph memory operations.
66pub struct GraphMemory {
67    db: Surreal<Db>,
68    embedder: LazyEmbedder,
69    path: PathBuf,
70    scoring: crate::config::GraphScoringConfig,
71    provenance: confidence::ProvenanceWeights,
72    dedup: crate::config::GraphDedupConfig,
73}
74
75impl GraphMemory {
76    /// Open a graph store at the given path.
77    ///
78    /// The backend is chosen at runtime from the `[graph] mode` key of
79    /// `.recall-echo.toml` in the parent directory (memory_dir):
80    /// `embedded` (default) opens SurrealKV at `path/surreal/`; `server`
81    /// connects to a SurrealDB server via the configured URL.
82    /// The `path` is used for the FastEmbed models cache in both modes.
83    pub async fn open(path: &Path) -> Result<Self, GraphError> {
84        let memory_dir = path.parent().unwrap_or(path);
85        let config = crate::config::load_from_dir(memory_dir);
86        let mode = config
87            .graph
88            .as_ref()
89            .map(|g| g.mode.clone())
90            .unwrap_or_else(|| "embedded".to_string());
91
92        match mode.as_str() {
93            "server" => Self::open_server(path).await,
94            _ => Self::open_embedded(path).await,
95        }
96    }
97
98    /// Open the embedded SurrealKV store at `path/surreal/`.
99    pub async fn open_embedded(path: &Path) -> Result<Self, GraphError> {
100        std::fs::create_dir_all(path)?;
101
102        let db = store::open(path).await?;
103        let migration = store::init_schema(&db).await?;
104        if migration.ran() {
105            eprintln!(
106                "recall-echo: graph schema migrated v{} → v{} ({} edges backfilled)",
107                migration.from_version, migration.to_version, migration.edges_backfilled
108            );
109        }
110
111        let models_dir = path.join("models");
112        std::fs::create_dir_all(&models_dir)?;
113        let embedder = LazyEmbedder::new(&models_dir);
114
115        let graph_config = load_graph_section(path);
116
117        Ok(Self {
118            db,
119            embedder,
120            path: path.to_path_buf(),
121            scoring: graph_config.scoring,
122            provenance: graph_config.provenance,
123            dedup: graph_config.dedup,
124        })
125    }
126
127    /// Connect to a SurrealDB server using `[graph]` settings from
128    /// `.recall-echo.toml` in the parent directory (memory_dir).
129    /// The `path` is still used for the FastEmbed models cache.
130    pub async fn open_server(path: &Path) -> Result<Self, GraphError> {
131        let memory_dir = path.parent().unwrap_or(path);
132        let config = crate::config::load_from_dir(memory_dir);
133
134        let graph_section = config.graph.unwrap_or_default();
135        let password = if graph_section.password_file.is_empty() {
136            String::new()
137        } else {
138            let pw_path = if graph_section.password_file.starts_with('/') {
139                std::path::PathBuf::from(&graph_section.password_file)
140            } else {
141                // Relative to entity root (memory_dir's parent)
142                let entity_root = memory_dir.parent().unwrap_or(memory_dir);
143                entity_root.join(&graph_section.password_file)
144            };
145            std::fs::read_to_string(&pw_path)
146                .map(|s| s.trim().to_string())
147                .map_err(|e| {
148                    GraphError::Io(std::io::Error::new(
149                        e.kind(),
150                        format!(
151                            "failed to read graph password file {}: {e}",
152                            pw_path.display()
153                        ),
154                    ))
155                })?
156        };
157
158        let scoring = graph_section.scoring.clone();
159        let provenance = graph_section.provenance;
160        let dedup = graph_section.dedup.clone();
161        let server_config = store::ServerConfig {
162            url: graph_section.url,
163            username: graph_section.username,
164            password,
165            namespace: graph_section.namespace,
166            database: graph_section.database,
167        };
168
169        let models_dir = path.join("models");
170        let mut gm = Self::connect(&server_config, &models_dir).await?;
171        gm.scoring = scoring;
172        gm.provenance = provenance;
173        gm.dedup = dedup;
174        Ok(gm)
175    }
176
177    /// Connect to a SurrealDB server over WebSocket with explicit config.
178    pub async fn connect(
179        config: &store::ServerConfig,
180        models_dir: &Path,
181    ) -> Result<Self, GraphError> {
182        let db = store::connect(config).await?;
183        let migration = store::init_schema(&db).await?;
184        if migration.ran() {
185            eprintln!(
186                "recall-echo: graph schema migrated v{} → v{} ({} edges backfilled)",
187                migration.from_version, migration.to_version, migration.edges_backfilled
188            );
189        }
190
191        std::fs::create_dir_all(models_dir)?;
192        let embedder = LazyEmbedder::new(models_dir);
193
194        Ok(Self {
195            db,
196            embedder,
197            path: models_dir.to_path_buf(),
198            scoring: crate::config::GraphScoringConfig::default(),
199            provenance: confidence::ProvenanceWeights::default(),
200            dedup: crate::config::GraphDedupConfig::default(),
201        })
202    }
203
204    /// Path to the graph store.
205    pub fn path(&self) -> &Path {
206        &self.path
207    }
208
209    /// Evidence weights this store applies to observations, by provenance
210    /// class (`[graph.provenance]`).
211    #[must_use]
212    pub fn provenance_weights(&self) -> &confidence::ProvenanceWeights {
213        &self.provenance
214    }
215
216    /// Similarity bands this store applies when deduplicating entities
217    /// (`[graph.dedup]`) — which candidates are worth a model call.
218    #[must_use]
219    pub fn dedup_config(&self) -> &crate::config::GraphDedupConfig {
220        &self.dedup
221    }
222
223    /// Internal access to the database handle.
224    #[allow(dead_code)]
225    pub(crate) fn db(&self) -> &Surreal<Db> {
226        &self.db
227    }
228
229    /// Internal access to the embedder (initializes it on first use).
230    #[allow(dead_code)]
231    pub(crate) fn embedder(&self) -> Result<&FastEmbedder, GraphError> {
232        self.embedder.get()
233    }
234
235    // --- Entity CRUD ---
236
237    /// Add a new entity to the graph.
238    pub async fn add_entity(&self, entity: NewEntity) -> Result<Entity, GraphError> {
239        crud::add_entity(&self.db, self.embedder.get()?, entity).await
240    }
241
242    /// Get an entity by name.
243    pub async fn get_entity(&self, name: &str) -> Result<Option<Entity>, GraphError> {
244        crud::get_entity_by_name(&self.db, name).await
245    }
246
247    /// Get an entity by its record ID.
248    pub async fn get_entity_by_id(&self, id: &str) -> Result<Option<Entity>, GraphError> {
249        crud::get_entity_by_id(&self.db, id).await
250    }
251
252    /// Update an entity's fields.
253    pub async fn update_entity(
254        &self,
255        id: &str,
256        updates: EntityUpdate,
257    ) -> Result<Entity, GraphError> {
258        crud::update_entity(&self.db, self.embedder.get()?, id, updates).await
259    }
260
261    /// Delete an entity and its relationships.
262    pub async fn delete_entity(&self, id: &str) -> Result<(), GraphError> {
263        crud::delete_entity(&self.db, id).await
264    }
265
266    /// List all entities, optionally filtered by type.
267    pub async fn list_entities(
268        &self,
269        entity_type: Option<&str>,
270    ) -> Result<Vec<Entity>, GraphError> {
271        crud::list_entities(&self.db, entity_type).await
272    }
273
274    // --- Relationships ---
275
276    /// Create a relationship between two named entities.
277    pub async fn add_relationship(&self, rel: NewRelationship) -> Result<Relationship, GraphError> {
278        crud::add_relationship(&self.db, rel).await
279    }
280
281    /// Get relationships for an entity.
282    pub async fn get_relationships(
283        &self,
284        entity_name: &str,
285        direction: Direction,
286    ) -> Result<Vec<Relationship>, GraphError> {
287        crud::get_relationships(&self.db, entity_name, direction).await
288    }
289
290    /// Supersede a relationship: close the old one, create a new one.
291    pub async fn supersede_relationship(
292        &self,
293        old_id: &str,
294        new: NewRelationship,
295    ) -> Result<Relationship, GraphError> {
296        crud::supersede_relationship(&self.db, old_id, new).await
297    }
298
299    /// Overwrite a relationship's confidence, resetting its evidence to the
300    /// prior around the new mean.
301    pub async fn update_relationship_confidence(
302        &self,
303        rel_id: &str,
304        confidence: f64,
305    ) -> Result<(), GraphError> {
306        crud::update_relationship_confidence(&self.db, rel_id, confidence).await
307    }
308
309    /// Persist updated evidence for a relationship and reset its decay clock.
310    ///
311    /// Called when a relationship is corroborated: the new posterior mean is
312    /// stored as `confidence`, the coherence tally is stored beside it, and
313    /// `last_reinforced` is set to now, preventing temporal decay from eroding
314    /// the edge.
315    pub async fn reinforce_relationship(
316        &self,
317        rel_id: &str,
318        evidence: confidence::EdgeEvidence,
319    ) -> Result<(), GraphError> {
320        crud::reinforce_relationship(&self.db, rel_id, evidence).await
321    }
322
323    // --- Episodes ---
324
325    /// Add a new episode authored by the agent itself.
326    ///
327    /// The conservative default: a caller that cannot say where the text came
328    /// from must not have it counted as independent evidence. Ingestion, which
329    /// does know, uses [`GraphMemory::add_episode_from`].
330    pub async fn add_episode(&self, episode: NewEpisode) -> Result<Episode, GraphError> {
331        crud::add_episode(&self.db, self.embedder.get()?, episode).await
332    }
333
334    /// Add a new episode stamped with the class of whoever authored it.
335    pub async fn add_episode_from(
336        &self,
337        episode: NewEpisode,
338        provenance: Provenance,
339    ) -> Result<Episode, GraphError> {
340        crud::add_episode_from(&self.db, self.embedder.get()?, episode, provenance).await
341    }
342
343    /// Get episodes by session ID.
344    pub async fn get_episodes_by_session(
345        &self,
346        session_id: &str,
347    ) -> Result<Vec<Episode>, GraphError> {
348        crud::get_episodes_by_session(&self.db, session_id).await
349    }
350
351    /// Get episode by log number.
352    pub async fn get_episode_by_log_number(
353        &self,
354        log_number: u32,
355    ) -> Result<Option<Episode>, GraphError> {
356        crud::get_episode_by_log_number(&self.db, log_number).await
357    }
358
359    // --- Ingestion ---
360
361    /// Ingest a conversation archive into the knowledge graph.
362    ///
363    /// The [`IngestContext`] carries the provenance policy: conversation
364    /// archives infer per chunk from turn roles, document ingestion forces a
365    /// class.
366    pub async fn ingest_archive(
367        &self,
368        archive_text: &str,
369        context: &IngestContext,
370        llm: Option<&dyn llm::LlmProvider>,
371    ) -> Result<IngestionReport, GraphError> {
372        ingest::ingest_archive(self, archive_text, context, llm).await
373    }
374
375    /// Run LLM extraction on an archive without creating episodes.
376    pub async fn extract_from_archive(
377        &self,
378        archive_text: &str,
379        context: &IngestContext,
380        llm: &dyn llm::LlmProvider,
381    ) -> Result<IngestionReport, GraphError> {
382        ingest::extract_from_archive(self, archive_text, context, llm).await
383    }
384
385    /// Mark all episodes with a given log_number as extracted.
386    pub async fn mark_extracted(&self, log_number: u32) -> Result<(), GraphError> {
387        crud::mark_episodes_extracted(&self.db, log_number).await
388    }
389
390    /// Get log numbers of episodes that have NOT been extracted.
391    pub async fn unextracted_log_numbers(&self) -> Result<Vec<i64>, GraphError> {
392        crud::get_unextracted_log_numbers(&self.db).await
393    }
394
395    // --- Search ---
396
397    /// Semantic search across entities (legacy — returns full Entity).
398    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, GraphError> {
399        search::search(&self.db, self.embedder.get()?, &self.scoring, query, limit).await
400    }
401
402    /// Search with options — L1 projections, type/keyword filters.
403    pub async fn search_with_options(
404        &self,
405        query: &str,
406        options: &SearchOptions,
407    ) -> Result<Vec<ScoredEntity>, GraphError> {
408        search::search_with_options(
409            &self.db,
410            self.embedder.get()?,
411            &self.scoring,
412            query,
413            options,
414        )
415        .await
416    }
417
418    /// Semantic search across episodes.
419    pub async fn search_episodes(
420        &self,
421        query: &str,
422        limit: usize,
423    ) -> Result<Vec<EpisodeSearchResult>, GraphError> {
424        search::search_episodes(&self.db, self.embedder.get()?, query, limit).await
425    }
426
427    // --- Hybrid Query ---
428
429    /// Hybrid query: semantic + graph expansion + optional episode search.
430    pub async fn query(
431        &self,
432        query_text: &str,
433        options: &QueryOptions,
434    ) -> Result<QueryResult, GraphError> {
435        query::query(
436            &self.db,
437            self.embedder.get()?,
438            &self.scoring,
439            query_text,
440            options,
441        )
442        .await
443    }
444
445    // --- Traversal ---
446
447    /// Traverse the graph from a named entity.
448    pub async fn traverse(
449        &self,
450        entity_name: &str,
451        depth: u32,
452    ) -> Result<TraversalNode, GraphError> {
453        traverse::traverse(&self.db, entity_name, depth).await
454    }
455
456    /// Traverse with type filter.
457    pub async fn traverse_filtered(
458        &self,
459        entity_name: &str,
460        depth: u32,
461        type_filter: Option<&str>,
462    ) -> Result<TraversalNode, GraphError> {
463        traverse::traverse_filtered(&self.db, entity_name, depth, type_filter).await
464    }
465
466    // --- Pipeline ---
467
468    /// Sync pipeline documents into the graph.
469    pub async fn sync_pipeline(
470        &self,
471        docs: &PipelineDocuments,
472    ) -> Result<PipelineSyncReport, GraphError> {
473        pipeline_sync::sync_pipeline(self, docs).await
474    }
475
476    /// Get pipeline stats from the graph.
477    pub async fn pipeline_stats(
478        &self,
479        staleness_days: u32,
480    ) -> Result<PipelineGraphStats, GraphError> {
481        query::pipeline_stats(&self.db, staleness_days).await
482    }
483
484    /// Get pipeline entities by stage and optional status.
485    pub async fn pipeline_entities(
486        &self,
487        stage: &str,
488        status: Option<&str>,
489    ) -> Result<Vec<EntityDetail>, GraphError> {
490        query::pipeline_entities(&self.db, stage, status).await
491    }
492
493    /// Trace pipeline flow for an entity.
494    pub async fn pipeline_flow(
495        &self,
496        entity_name: &str,
497    ) -> Result<Vec<(EntityDetail, String, EntityDetail)>, GraphError> {
498        query::pipeline_flow(&self.db, entity_name).await
499    }
500
501    // --- Vigil Sync ---
502
503    /// Sync vigil signal vectors into the graph as Measurement entities.
504    pub async fn sync_vigil_signals(
505        &self,
506        signals_path: &std::path::Path,
507    ) -> Result<VigilSyncReport, GraphError> {
508        vigil_sync::sync_vigil_signals(self, signals_path).await
509    }
510
511    /// Sync outcome records into the graph as Outcome entities.
512    pub async fn sync_outcomes(
513        &self,
514        outcomes_path: &std::path::Path,
515    ) -> Result<VigilSyncReport, GraphError> {
516        vigil_sync::sync_outcomes(self, outcomes_path).await
517    }
518
519    /// Sync both vigil signals and outcomes in one call.
520    pub async fn sync_vigil(
521        &self,
522        signals_path: &std::path::Path,
523        outcomes_path: &std::path::Path,
524    ) -> Result<VigilSyncReport, GraphError> {
525        vigil_sync::sync_vigil(self, signals_path, outcomes_path).await
526    }
527
528    /// Record outcome feedback: link retrieved entities to a session outcome and
529    /// update their `utility_score` via EMA. `used_entity_ids` distinguishes the
530    /// entities the response actually leaned on (full alpha) from retrieved-but-
531    /// unused (muted alpha). Pass `None` to treat all retrieved as used.
532    pub async fn record_outcome_feedback(
533        &self,
534        session_id: &str,
535        outcome: utility::OutcomeKind,
536        retrieved_entity_ids: &[String],
537        used_entity_ids: Option<&[String]>,
538    ) -> Result<utility::FeedbackReport, GraphError> {
539        utility::record_outcome_feedback(
540            &self.db,
541            session_id,
542            outcome,
543            retrieved_entity_ids,
544            used_entity_ids,
545        )
546        .await
547    }
548
549    /// Apply an outcome to every entity a session touched.
550    ///
551    /// Resolves the session's entities from the `contributed_to` records
552    /// ingestion left behind (falling back to the entities the session
553    /// authored), then records the outcome and moves their utility scores.
554    /// The report says which entities moved and where they landed.
555    pub async fn record_session_outcome(
556        &self,
557        session_id: &str,
558        outcome: utility::OutcomeKind,
559    ) -> Result<utility::FeedbackReport, GraphError> {
560        let session = utility::session_entities(&self.db, session_id).await?;
561        if session.is_empty() {
562            return Ok(utility::FeedbackReport::default());
563        }
564
565        utility::record_outcome_feedback(
566            &self.db,
567            session_id,
568            outcome,
569            &session.retrieved,
570            Some(&session.used),
571        )
572        .await
573    }
574
575    /// Record that a session touched these entities, without judging it.
576    pub async fn record_session_use(
577        &self,
578        session_id: &str,
579        entity_ids: &[String],
580    ) -> Result<u32, GraphError> {
581        utility::record_session_use(&self.db, session_id, entity_ids).await
582    }
583
584    // --- Garbage Collection ---
585
586    /// Run garbage collection with the given config.
587    pub async fn run_gc(&self, config: &gc::GcConfig) -> Result<gc::GcReport, GraphError> {
588        gc::run_gc(&self.db, config).await
589    }
590
591    /// Get GC health stats without running collection.
592    pub async fn gc_stats(&self) -> Result<gc::GcStatsReport, GraphError> {
593        gc::stats_only(&self.db).await
594    }
595
596    /// Delete a single relationship by ID.
597    pub async fn delete_relationship(&self, id: &str) -> Result<(), GraphError> {
598        crud::delete_relationship(&self.db, id).await
599    }
600
601    // --- Stats ---
602
603    /// Get graph statistics.
604    pub async fn stats(&self) -> Result<GraphStats, GraphError> {
605        let entity_count = db_count(&self.db, "entity").await?;
606        let relationship_count = db_count(&self.db, "relates_to").await?;
607        let episode_count = db_count(&self.db, "episode").await?;
608
609        // Count by type
610        let mut type_response = self
611            .db
612            .query("SELECT entity_type, count() AS count FROM entity GROUP BY entity_type")
613            .await?;
614
615        let type_rows: Vec<TypeCount> = type_response.take(0)?;
616        let entity_type_counts: HashMap<String, u64> = type_rows
617            .into_iter()
618            .map(|r| (r.entity_type, r.count))
619            .collect();
620
621        Ok(GraphStats {
622            entity_count,
623            relationship_count,
624            episode_count,
625            entity_type_counts,
626        })
627    }
628}
629
630/// Load `[graph]` from `.recall-echo.toml` in the memory directory (the parent
631/// of the graph store path). Returns defaults if the config file or the
632/// section is absent, preserving legacy behavior.
633fn load_graph_section(graph_path: &Path) -> crate::config::GraphSection {
634    let memory_dir = graph_path.parent().unwrap_or(graph_path);
635    crate::config::load_from_dir(memory_dir)
636        .graph
637        .unwrap_or_default()
638}
639
640async fn db_count(db: &Surreal<Db>, table: &str) -> Result<u64, GraphError> {
641    let query = format!("SELECT count() AS count FROM {table} GROUP ALL");
642    let mut response = db.query(&query).await?;
643    let rows: Vec<CountRow> = response.take(0)?;
644    Ok(rows.first().map(|r| r.count).unwrap_or(0))
645}
646
647#[derive(serde::Deserialize, surrealdb::types::SurrealValue)]
648struct CountRow {
649    count: u64,
650}
651
652#[derive(serde::Deserialize, surrealdb::types::SurrealValue)]
653struct TypeCount {
654    entity_type: String,
655    count: u64,
656}