Skip to main content

velesdb_core/agent/
episodic_memory.rs

1//! Episodic Memory - Event timeline storage (US-003)
2//!
3//! Records events with timestamps and contextual information.
4//! Supports temporal queries and similarity-based retrieval.
5//! Uses a B-tree temporal index for efficient O(log N) time-based queries.
6
7use crate::{Database, Point};
8use serde_json::json;
9use std::sync::Arc;
10
11use super::error::AgentMemoryError;
12use super::memory_helpers;
13use super::temporal_index::TemporalIndex;
14use super::ttl::{MemoryKind, MemoryTtl};
15
16/// Episodic memory for storing event timelines with temporal context.
17///
18/// Records events with timestamps, descriptions, and embeddings.
19/// Supports similarity-based retrieval and time-range queries.
20pub struct EpisodicMemory {
21    collection_name: String,
22    db: Arc<Database>,
23    dimension: usize,
24    ttl: Arc<MemoryTtl>,
25    temporal_index: Arc<TemporalIndex>,
26    /// Edge-id allocator for [`Self::relate`] (seeded past existing edges).
27    next_edge_id: std::sync::atomic::AtomicU64,
28}
29
30impl EpisodicMemory {
31    const COLLECTION_NAME: &'static str = "_episodic_memory";
32
33    /// Returns the embedding dimension for this collection.
34    #[must_use]
35    pub fn dimension(&self) -> usize {
36        self.dimension
37    }
38
39    /// Creates or opens the episodic memory collection.
40    ///
41    /// # Errors
42    ///
43    /// Returns an error when collection creation/opening fails or dimensions mismatch.
44    pub fn new_from_db(db: Arc<Database>, dimension: usize) -> Result<Self, AgentMemoryError> {
45        Self::new(
46            db,
47            dimension,
48            Arc::new(MemoryTtl::new()),
49            Arc::new(TemporalIndex::new()),
50        )
51    }
52    pub(crate) fn new(
53        db: Arc<Database>,
54        dimension: usize,
55        ttl: Arc<MemoryTtl>,
56        temporal_index: Arc<TemporalIndex>,
57    ) -> Result<Self, AgentMemoryError> {
58        let collection_name = Self::COLLECTION_NAME.to_string();
59        let actual_dimension =
60            memory_helpers::open_or_create_collection(&db, &collection_name, dimension)?;
61
62        if temporal_index.is_empty() {
63            if let Some(collection) = db.get_vector_collection(&collection_name) {
64                Self::rebuild_temporal_index(&collection.inner, &temporal_index);
65            }
66        }
67        memory_helpers::rebuild_ttl_from_payloads(
68            &db,
69            &collection_name,
70            &ttl,
71            MemoryKind::Episodic,
72        )?;
73
74        let next_edge_id = memory_helpers::seed_edge_counter(&memory_helpers::get_collection(
75            &db,
76            &collection_name,
77        )?);
78
79        Ok(Self {
80            collection_name,
81            db,
82            dimension: actual_dimension,
83            ttl,
84            temporal_index,
85            next_edge_id,
86        })
87    }
88    fn rebuild_temporal_index(
89        collection: &crate::collection::Collection,
90        temporal_index: &TemporalIndex,
91    ) {
92        let all_ids = collection.all_ids();
93        let points = collection.get(&all_ids);
94        for point in points.into_iter().flatten() {
95            if let Some(payload) = &point.payload {
96                if let Some(ts) = payload.get("timestamp").and_then(serde_json::Value::as_i64) {
97                    temporal_index.insert(point.id, ts);
98                }
99            }
100        }
101    }
102
103    /// Returns the name of the underlying `VelesDB` collection.
104    #[must_use]
105    pub fn collection_name(&self) -> &str {
106        &self.collection_name
107    }
108
109    /// Stores an event in episodic memory.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error when the embedding dimension is invalid, when the collection
114    /// is unavailable, or when storage upsert fails.
115    pub fn record(
116        &self,
117        event_id: u64,
118        description: &str,
119        timestamp: i64,
120        embedding: Option<&[f32]>,
121    ) -> Result<(), AgentMemoryError> {
122        self.record_internal(event_id, description, timestamp, embedding, None)
123    }
124
125    /// Shared store path: persists the event, optionally with a durable
126    /// `_veles_expires_at` payload field (epoch seconds) for TTL'd records.
127    fn record_internal(
128        &self,
129        event_id: u64,
130        description: &str,
131        timestamp: i64,
132        embedding: Option<&[f32]>,
133        expires_at: Option<u64>,
134    ) -> Result<(), AgentMemoryError> {
135        let vector = memory_helpers::resolve_embedding(self.dimension, embedding)?;
136        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
137
138        let mut payload = json!({
139            "description": description,
140            "timestamp": timestamp
141        });
142        memory_helpers::attach_expiry(&mut payload, expires_at);
143        let point = Point::new(event_id, vector, Some(payload));
144
145        memory_helpers::upsert_points(&collection, vec![point])?;
146        self.temporal_index.insert(event_id, timestamp);
147
148        Ok(())
149    }
150
151    /// Stores an event and assigns a TTL for automatic expiration.
152    ///
153    /// A `ttl_seconds` of `0` means "expire immediately": rather than persisting
154    /// a live point that lingers until the next `auto_expire`, the event is
155    /// eagerly removed (and any pre-existing point for `event_id` deleted),
156    /// harmonising the behaviour with `SemanticMemory::store_with_ttl`. The
157    /// embedding is still dimension-validated so callers get the same error
158    /// contract as a real record.
159    ///
160    /// The expiry is persisted as a reserved `_veles_expires_at` (epoch
161    /// seconds) payload field, so the TTL survives a process restart: the
162    /// in-memory map is rebuilt from payloads when the collection is reopened.
163    ///
164    /// # Errors
165    ///
166    /// Returns the same errors as [`Self::record`].
167    pub fn record_with_ttl(
168        &self,
169        event_id: u64,
170        description: &str,
171        timestamp: i64,
172        embedding: Option<&[f32]>,
173        ttl_seconds: u64,
174    ) -> Result<(), AgentMemoryError> {
175        if ttl_seconds == 0 {
176            if let Some(emb) = embedding {
177                memory_helpers::validate_dimension(self.dimension, emb.len())?;
178            }
179            return self.delete(event_id);
180        }
181        let expires_at = MemoryTtl::now().saturating_add(ttl_seconds);
182        self.record_internal(
183            event_id,
184            description,
185            timestamp,
186            embedding,
187            Some(expires_at),
188        )?;
189        self.ttl
190            .set_expiry(MemoryKind::Episodic, event_id, expires_at);
191        Ok(())
192    }
193
194    /// Durably sets (or refreshes) the TTL of an existing event.
195    ///
196    /// Unlike `AgentMemory::set_episodic_ttl` (in-memory map only, lost on
197    /// restart), this persists the expiry to the reserved `_veles_expires_at`
198    /// payload field, so it survives a restart. A `ttl_seconds` of 0 expires
199    /// the event immediately.
200    ///
201    /// # Errors
202    ///
203    /// Returns `NotFound` when no event with `event_id` exists, or
204    /// `CollectionError` when persistence fails.
205    pub fn set_ttl_durable(&self, event_id: u64, ttl_seconds: u64) -> Result<(), AgentMemoryError> {
206        memory_helpers::set_ttl_durable(
207            &self.db,
208            &self.collection_name,
209            &self.ttl,
210            MemoryKind::Episodic,
211            event_id,
212            ttl_seconds,
213        )
214    }
215
216    /// Relates two live events with a typed, durable graph edge (e.g.
217    /// `CAUSED`, `FOLLOWED`); see `SemanticMemory::relate` for semantics.
218    ///
219    /// # Errors
220    ///
221    /// Returns `NotFound` when either endpoint is missing or expired, or
222    /// `CollectionError` when the edge write fails.
223    pub fn relate(
224        &self,
225        from_id: u64,
226        to_id: u64,
227        rel_type: &str,
228        properties: Option<&serde_json::Map<String, serde_json::Value>>,
229    ) -> Result<u64, AgentMemoryError> {
230        memory_helpers::relate_memory_points(
231            &memory_helpers::MemorySubsystem {
232                db: &self.db,
233                collection_name: &self.collection_name,
234                ttl: &self.ttl,
235                kind: MemoryKind::Episodic,
236                next_edge_id: &self.next_edge_id,
237            },
238            from_id,
239            to_id,
240            rel_type,
241            properties,
242        )
243    }
244
245    /// Returns the outgoing relations of an event.
246    ///
247    /// # Errors
248    ///
249    /// Returns `CollectionError` when the collection cannot be resolved.
250    pub fn relations(
251        &self,
252        id: u64,
253    ) -> Result<Vec<crate::collection::graph::GraphEdge>, AgentMemoryError> {
254        memory_helpers::relations_of(
255            &self.db,
256            &self.collection_name,
257            id,
258            &self.ttl,
259            MemoryKind::Episodic,
260        )
261    }
262
263    /// Removes a relation edge created by [`Self::relate`].
264    ///
265    /// # Errors
266    ///
267    /// Returns `CollectionError` when the collection cannot be resolved.
268    pub fn unrelate(&self, edge_id: u64) -> Result<bool, AgentMemoryError> {
269        memory_helpers::unrelate_edge(&self.db, &self.collection_name, edge_id)
270    }
271
272    /// Returns recent events, optionally filtered by a lower timestamp bound.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error when the collection is unavailable.
277    pub fn recent(
278        &self,
279        limit: usize,
280        since_timestamp: Option<i64>,
281    ) -> Result<Vec<(u64, String, i64)>, AgentMemoryError> {
282        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
283
284        Ok(self.fetch_temporal_events(
285            limit,
286            |fetch_limit| {
287                let entries = self.temporal_index.recent(fetch_limit, since_timestamp);
288                entries.iter().map(|e| e.id).collect()
289            },
290            &collection,
291        ))
292    }
293
294    /// Returns events older than `timestamp`.
295    ///
296    /// # Errors
297    ///
298    /// Returns an error when the collection is unavailable.
299    pub fn older_than(
300        &self,
301        timestamp: i64,
302        limit: usize,
303    ) -> Result<Vec<(u64, String, i64)>, AgentMemoryError> {
304        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
305
306        Ok(self.fetch_temporal_events(
307            limit,
308            |fetch_limit| {
309                let entries = self.temporal_index.older_than(timestamp, fetch_limit);
310                entries.iter().map(|e| e.id).collect()
311            },
312            &collection,
313        ))
314    }
315
316    /// Retrieves the `k` most similar episodic events to a query embedding.
317    ///
318    /// # Errors
319    ///
320    /// Returns an error when the embedding dimension is invalid, when the collection
321    /// is unavailable, or when vector search fails.
322    pub fn recall_similar(
323        &self,
324        query_embedding: &[f32],
325        k: usize,
326    ) -> Result<Vec<(u64, String, i64, f32)>, AgentMemoryError> {
327        let results = memory_helpers::search_filtered(
328            &self.db,
329            &self.collection_name,
330            self.dimension,
331            query_embedding,
332            k,
333            &self.ttl,
334            MemoryKind::Episodic,
335        )?;
336
337        Ok(results
338            .into_iter()
339            .filter_map(|r| {
340                let (desc, ts) = extract_event_fields(&r.point)?;
341                Some((r.point.id, desc, ts, r.score))
342            })
343            .collect())
344    }
345
346    /// Retrieves an event with its embedding payload.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error when the collection is unavailable.
351    pub fn get_with_embedding(
352        &self,
353        id: u64,
354    ) -> Result<Option<(String, i64, Vec<f32>)>, AgentMemoryError> {
355        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
356
357        let points = collection.get(&[id]);
358        let Some(point) = points.into_iter().flatten().next() else {
359            return Ok(None);
360        };
361
362        if self.ttl.is_expired(MemoryKind::Episodic, point.id) {
363            return Ok(None);
364        }
365
366        let Some(payload) = point.payload.as_ref() else {
367            return Ok(None);
368        };
369
370        let desc = payload
371            .get("description")
372            .and_then(serde_json::Value::as_str)
373            .unwrap_or("")
374            .to_string();
375        let ts = payload
376            .get("timestamp")
377            .and_then(serde_json::Value::as_i64)
378            .unwrap_or(0);
379
380        Ok(Some((desc, ts, point.vector.clone())))
381    }
382
383    /// Deletes an episodic event by id.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error when the collection is unavailable or delete fails.
388    pub fn delete(&self, id: u64) -> Result<(), AgentMemoryError> {
389        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
390        memory_helpers::delete_from_collection(&collection, &[id])?;
391
392        self.temporal_index.remove(id);
393        self.ttl.remove(MemoryKind::Episodic, id);
394        Ok(())
395    }
396
397    /// Serializes episodic points in temporal-order id set.
398    ///
399    /// # Errors
400    ///
401    /// Returns an error when the collection is unavailable or JSON encoding fails.
402    pub fn serialize(&self) -> Result<Vec<u8>, AgentMemoryError> {
403        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
404        let all_ids = self.temporal_index.all_ids();
405        memory_helpers::serialize_points(&collection, &all_ids)
406    }
407
408    /// Replaces episodic storage with previously serialized points.
409    ///
410    /// # Errors
411    ///
412    /// Returns an error when JSON decoding fails, collection access fails, or
413    /// persistence operations fail.
414    pub fn deserialize(&self, data: &[u8]) -> Result<(), AgentMemoryError> {
415        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
416        if let Some(points) = memory_helpers::deserialize_into_collection(data, &collection)? {
417            self.rebuild_temporal_from_points(&points);
418        }
419        Ok(())
420    }
421
422    /// Fetches temporal events with progressive widening, filtering expired entries.
423    fn fetch_temporal_events(
424        &self,
425        limit: usize,
426        id_fetcher: impl Fn(usize) -> Vec<u64>,
427        collection: &crate::collection::Collection,
428    ) -> Vec<(u64, String, i64)> {
429        // Clamp the pre-allocation: the number of events can never exceed the
430        // total indexed entries, so a huge caller-supplied `limit` must not
431        // pre-allocate beyond available data.
432        let indexed = self.temporal_index.len();
433        let mut events = Vec::with_capacity(limit.min(indexed));
434        let mut fetch_limit = limit.saturating_mul(2);
435        // Saturating to keep an attacker-supplied `limit` near `usize::MAX` from
436        // overflowing the loop ceiling (panic under `panic=abort`, silent wrap in
437        // release). The `id_count < fetch_limit` break still terminates the loop.
438        let max_fetch = indexed.max(limit).saturating_mul(2);
439
440        while events.len() < limit && fetch_limit <= max_fetch {
441            let ids = id_fetcher(fetch_limit);
442            if ids.is_empty() {
443                break;
444            }
445            let id_count = ids.len();
446
447            events = Self::filter_live_events(&self.ttl, collection, &ids, limit);
448
449            if events.len() >= limit || id_count < fetch_limit {
450                break;
451            }
452            fetch_limit = fetch_limit.saturating_mul(2);
453        }
454
455        events
456    }
457
458    /// Fetches points by IDs, filters expired ones, and extracts event fields.
459    fn filter_live_events(
460        ttl: &MemoryTtl,
461        collection: &crate::collection::Collection,
462        ids: &[u64],
463        limit: usize,
464    ) -> Vec<(u64, String, i64)> {
465        collection
466            .get(ids)
467            .into_iter()
468            .flatten()
469            .filter(|p| !ttl.is_expired(MemoryKind::Episodic, p.id))
470            .filter_map(|p| {
471                let (desc, ts) = extract_event_fields(&p)?;
472                Some((p.id, desc, ts))
473            })
474            .take(limit)
475            .collect()
476    }
477
478    /// Clears and rebuilds the temporal index from a set of points.
479    fn rebuild_temporal_from_points(&self, points: &[Point]) {
480        self.temporal_index.clear();
481        for point in points {
482            if let Some(payload) = &point.payload {
483                if let Some(ts) = payload.get("timestamp").and_then(serde_json::Value::as_i64) {
484                    self.temporal_index.insert(point.id, ts);
485                }
486            }
487        }
488    }
489}
490
491/// Extracts `(description, timestamp)` from a point's payload.
492fn extract_event_fields(point: &Point) -> Option<(String, i64)> {
493    let payload = point.payload.as_ref()?;
494    let desc = payload.get("description")?.as_str()?.to_string();
495    let ts = payload.get("timestamp")?.as_i64()?;
496    Some((desc, ts))
497}