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}