Skip to main content

zeph_memory/graph/
experience.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Experience memory store — records tool execution outcomes and evolution sweeps.
5//!
6//! [`ExperienceStore`] persists agent tool-call outcomes as `experience_nodes` and links
7//! them into a temporal chain (`experience_edges`). The [`EvolutionSweepStats`] type
8//! describes pruning results from [`ExperienceStore::evolution_sweep`].
9
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use zeph_db::{DbPool, sql};
13
14use crate::error::MemoryError;
15use crate::graph::store::GraphStore;
16
17/// Statistics from a single graph evolution sweep.
18#[derive(Debug, Default)]
19pub struct EvolutionSweepStats {
20    /// Number of self-loop edges removed from the knowledge graph.
21    pub pruned_self_loops: usize,
22    /// Number of low-confidence zero-retrieval edges removed.
23    pub pruned_low_confidence: usize,
24}
25
26/// Persistent store for experience memory nodes and edges.
27///
28/// Wraps the `experience_nodes`, `experience_edges`, and `experience_entity_links`
29/// tables created by migration `076_experience_memory.sql`.
30pub struct ExperienceStore {
31    pool: DbPool,
32}
33
34impl ExperienceStore {
35    /// Create a new experience store using the provided connection pool.
36    #[must_use]
37    pub fn new(pool: DbPool) -> Self {
38        Self { pool }
39    }
40
41    /// Record a tool execution outcome as an experience node.
42    ///
43    /// Returns the row ID of the newly inserted experience node.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`MemoryError`] if the database insert fails.
48    ///
49    /// # Examples
50    ///
51    /// ```no_run
52    /// # use zeph_memory::graph::experience::ExperienceStore;
53    /// # async fn demo(store: &ExperienceStore) -> Result<(), Box<dyn std::error::Error>> {
54    /// let id = store
55    ///     .record_tool_outcome("session-1", 3, "shell", "success", Some("exit 0"), None)
56    ///     .await?;
57    /// # Ok(())
58    /// # }
59    /// ```
60    #[tracing::instrument(
61        skip_all,
62        name = "memory.experience.record",
63        fields(tool_name, outcome)
64    )]
65    pub async fn record_tool_outcome(
66        &self,
67        session_id: &str,
68        turn: i64,
69        tool_name: &str,
70        outcome: &str,
71        detail: Option<&str>,
72        error_ctx: Option<&str>,
73    ) -> Result<i64, MemoryError> {
74        let now = SystemTime::now()
75            .duration_since(UNIX_EPOCH)
76            .map_or(0, |d| d.as_secs().cast_signed());
77        let id: i64 = zeph_db::query_scalar(sql!(
78            "INSERT INTO experience_nodes
79             (session_id, turn, tool_name, outcome, detail, error_ctx, created_at)
80             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
81             RETURNING id"
82        ))
83        .bind(session_id)
84        .bind(turn)
85        .bind(tool_name)
86        .bind(outcome)
87        .bind(detail)
88        .bind(error_ctx)
89        .bind(now)
90        .fetch_one(&self.pool)
91        .await
92        .map_err(MemoryError::from)?;
93        Ok(id)
94    }
95
96    /// Link an experience node to one or more knowledge graph entities.
97    ///
98    /// Uses `INSERT OR IGNORE` to tolerate duplicate links gracefully.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`MemoryError`] if any database insert fails.
103    pub async fn link_to_entities(
104        &self,
105        experience_id: i64,
106        entity_ids: &[i64],
107    ) -> Result<(), MemoryError> {
108        let _span = tracing::info_span!("memory.experience.link_entities", experience_id).entered();
109        for &entity_id in entity_ids {
110            zeph_db::query(sql!(
111                "INSERT OR IGNORE INTO experience_entity_links
112                 (experience_id, entity_id) VALUES (?1, ?2)"
113            ))
114            .bind(experience_id)
115            .bind(entity_id)
116            .execute(&self.pool)
117            .await
118            .map_err(MemoryError::from)?;
119        }
120        Ok(())
121    }
122
123    /// Record a sequential `followed_by` link between two experience nodes.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`MemoryError`] if the database insert fails.
128    pub async fn link_sequential(&self, prev: i64, next: i64) -> Result<(), MemoryError> {
129        zeph_db::query(sql!(
130            "INSERT INTO experience_edges
131             (source_exp_id, target_exp_id, relation)
132             VALUES (?1, ?2, 'followed_by')"
133        ))
134        .bind(prev)
135        .bind(next)
136        .execute(&self.pool)
137        .await
138        .map_err(MemoryError::from)?;
139        Ok(())
140    }
141
142    /// Run a graph evolution sweep on the knowledge graph.
143    ///
144    /// Performs two pruning passes on the `graph_edges` table via `graph_store`:
145    /// 1. Removes self-loops (`source_entity_id = target_entity_id`).
146    /// 2. Removes low-confidence edges with zero retrievals that have no expiry set.
147    ///
148    /// This is a maintenance operation; it never blocks the agent loop.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`MemoryError`] if any database operation fails.
153    #[tracing::instrument(skip_all, name = "memory.experience.sweep")]
154    pub async fn evolution_sweep(
155        &self,
156        graph_store: &GraphStore,
157        confidence_threshold: f32,
158    ) -> Result<EvolutionSweepStats, MemoryError> {
159        let self_loops = zeph_db::query(sql!(
160            "DELETE FROM graph_edges WHERE source_entity_id = target_entity_id"
161        ))
162        .execute(graph_store.pool())
163        .await
164        .map_err(MemoryError::from)?
165        .rows_affected();
166
167        let low_conf = zeph_db::query(sql!(
168            "DELETE FROM graph_edges
169             WHERE confidence < ?1 AND retrieval_count = 0 AND valid_to IS NULL"
170        ))
171        .bind(confidence_threshold)
172        .execute(graph_store.pool())
173        .await
174        .map_err(MemoryError::from)?
175        .rows_affected();
176
177        Ok(EvolutionSweepStats {
178            pruned_self_loops: usize::try_from(self_loops).unwrap_or(usize::MAX),
179            pruned_low_confidence: usize::try_from(low_conf).unwrap_or(usize::MAX),
180        })
181    }
182}