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