Skip to main content

velesdb_core/agent/
semantic_memory.rs

1//! Semantic Memory - Long-term knowledge storage (US-002)
2//!
3//! Stores facts and knowledge as vectors with similarity search.
4//! Each fact has an ID, content text, embedding vector, and optional metadata.
5
6use crate::{Database, Point};
7use parking_lot::RwLock;
8use serde_json::{Map, Value};
9use std::collections::HashSet;
10use std::sync::Arc;
11
12use super::error::AgentMemoryError;
13use super::memory_helpers;
14use super::ttl::{MemoryKind, MemoryTtl};
15
16/// Long-term semantic memory for storing knowledge facts with vector similarity search.
17///
18/// Each fact is stored as an embedding vector with associated text content.
19/// Supports TTL-based expiration and snapshot serialization.
20pub struct SemanticMemory {
21    collection_name: String,
22    db: Arc<Database>,
23    dimension: usize,
24    ttl: Arc<MemoryTtl>,
25    stored_ids: RwLock<HashSet<u64>>,
26    /// Edge-id allocator for [`Self::relate`] (seeded past existing edges).
27    next_edge_id: std::sync::atomic::AtomicU64,
28}
29
30impl SemanticMemory {
31    const COLLECTION_NAME: &'static str = "_semantic_memory";
32
33    /// Creates or opens semantic memory with an **independent** in-memory TTL.
34    ///
35    /// # Standalone limitation
36    ///
37    /// The [`MemoryTtl`] allocated here is not shared with any snapshot
38    /// mechanism. TTLs assigned at store time ([`Self::store_with_ttl`]) are
39    /// durable: the expiry is persisted as a `_veles_expires_at` payload field and
40    /// the in-memory map is rebuilt from payloads at construction, so they
41    /// survive a restart. TTLs set only in the map (e.g. via
42    /// `AgentMemory::set_semantic_ttl`) remain in-memory, and
43    /// [`Self::serialize`] / [`Self::deserialize`] carry stored points but
44    /// intentionally omit the TTL map (see [`Self::serialize`] for the full
45    /// contract). For full TTL and snapshot support, create an
46    /// [`AgentMemory`](crate::agent::AgentMemory) instead — it owns the shared
47    /// `MemoryTtl`, snapshot manager, and all three subsystems.
48    ///
49    /// # Errors
50    ///
51    /// Returns an error when collection creation/opening fails or dimensions mismatch.
52    pub fn new_from_db(db: Arc<Database>, dimension: usize) -> Result<Self, AgentMemoryError> {
53        Self::new(db, dimension, Arc::new(MemoryTtl::new()))
54    }
55
56    pub(crate) fn new(
57        db: Arc<Database>,
58        dimension: usize,
59        ttl: Arc<MemoryTtl>,
60    ) -> Result<Self, AgentMemoryError> {
61        let (collection_name, dimension, stored_ids) =
62            memory_helpers::init_tracked_memory(&db, Self::COLLECTION_NAME, dimension)?;
63        memory_helpers::rebuild_ttl_from_payloads(
64            &db,
65            &collection_name,
66            &ttl,
67            MemoryKind::Semantic,
68        )?;
69
70        let next_edge_id = memory_helpers::seed_edge_counter(&memory_helpers::get_collection(
71            &db,
72            &collection_name,
73        )?);
74
75        Ok(Self {
76            collection_name,
77            db,
78            dimension,
79            ttl,
80            stored_ids,
81            next_edge_id,
82        })
83    }
84
85    /// Returns the name of the underlying `VelesDB` collection.
86    #[must_use]
87    pub fn collection_name(&self) -> &str {
88        &self.collection_name
89    }
90
91    /// Returns the embedding dimension for this collection.
92    #[must_use]
93    pub fn dimension(&self) -> usize {
94        self.dimension
95    }
96
97    /// Stores a semantic memory point.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error when embedding dimension is invalid, collection access fails,
102    /// or persistence fails.
103    pub fn store(&self, id: u64, content: &str, embedding: &[f32]) -> Result<(), AgentMemoryError> {
104        self.store_internal(id, content, embedding, None, None)
105    }
106
107    /// Stores a semantic memory point with additional metadata fields.
108    ///
109    /// `content` always wins: if `metadata` contains a `"content"` key, it is
110    /// overwritten by the `content` parameter. The reserved system key
111    /// `_veles_expires_at` (durable TTL, see [`Self::store_with_ttl`]) is
112    /// likewise stripped from `metadata`; a plain `expires_at` key is ordinary
113    /// business metadata and is stored verbatim.
114    ///
115    /// # Errors
116    ///
117    /// Returns the same errors as [`Self::store`].
118    pub fn store_with_metadata(
119        &self,
120        id: u64,
121        content: &str,
122        embedding: &[f32],
123        metadata: &Map<String, Value>,
124    ) -> Result<(), AgentMemoryError> {
125        self.store_internal(id, content, embedding, Some(metadata), None)
126    }
127
128    /// Updates payload fields of an existing fact without changing its embedding.
129    ///
130    /// Only facts that are tracked and not expired are updated. Any key in
131    /// `updates` is merged into the existing payload; `content` may be updated
132    /// through this method, but the vector is left untouched. The reserved
133    /// system key `_veles_expires_at` (durable TTL) is ignored in `updates`
134    /// and preserved from the existing payload.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`AgentMemoryError::NotFound`] when the id is unknown or expired.
139    /// Returns other errors when collection access or persistence fails.
140    pub fn update_metadata(
141        &self,
142        id: u64,
143        updates: &Map<String, Value>,
144    ) -> Result<(), AgentMemoryError> {
145        if !self.stored_ids.read().contains(&id) {
146            return Err(AgentMemoryError::NotFound(id.to_string()));
147        }
148        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
149        let point = memory_helpers::ensure_live(
150            &collection,
151            &self.collection_name,
152            &self.ttl,
153            MemoryKind::Semantic,
154            id,
155        )?;
156        let payload = merge_payload(point.payload, updates)?;
157        memory_helpers::upsert_points(
158            &collection,
159            vec![Point::new(id, point.vector, Some(payload))],
160        )?;
161        Ok(())
162    }
163
164    /// Shared store path. The durable expiry travels through the dedicated
165    /// `expires_at` parameter (written under the reserved
166    /// [`memory_helpers::EXPIRES_AT_KEY`]), never through user `metadata`.
167    fn store_internal(
168        &self,
169        id: u64,
170        content: &str,
171        embedding: &[f32],
172        metadata: Option<&Map<String, Value>>,
173        expires_at: Option<u64>,
174    ) -> Result<(), AgentMemoryError> {
175        memory_helpers::validate_dimension(self.dimension, embedding.len())?;
176        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
177        let mut payload = build_payload(content, metadata);
178        memory_helpers::attach_expiry(&mut payload, expires_at);
179        let point = Point::new(id, embedding.to_vec(), Some(payload));
180        memory_helpers::upsert_points(&collection, vec![point])?;
181        self.stored_ids.write().insert(id);
182        Ok(())
183    }
184
185    /// Stores a fact under `preferred_id`, or under a freshly allocated id when
186    /// `preferred_id` is already taken, and returns the id actually used.
187    ///
188    /// [`Self::store`] upserts, so reusing an id silently overwrites the
189    /// existing fact. Consolidation (which reuses the *episodic* id as the
190    /// semantic id) must never clobber an unrelated semantic fact, so it relies
191    /// on this collision-avoiding path instead.
192    ///
193    /// # Errors
194    ///
195    /// Returns the same errors as [`Self::store`].
196    pub fn store_unique(
197        &self,
198        preferred_id: u64,
199        content: &str,
200        embedding: &[f32],
201    ) -> Result<u64, AgentMemoryError> {
202        let id = self.allocate_id(preferred_id);
203        self.store(id, content, embedding)?;
204        Ok(id)
205    }
206
207    /// Returns `preferred_id` when free, otherwise the smallest id strictly
208    /// greater than every tracked id (so it cannot collide with a live fact).
209    fn allocate_id(&self, preferred_id: u64) -> u64 {
210        let ids = self.stored_ids.read();
211        if !ids.contains(&preferred_id) {
212            return preferred_id;
213        }
214        ids.iter().copied().max().map_or(0, |m| m.saturating_add(1))
215    }
216
217    /// Stores a semantic memory point and assigns a TTL.
218    ///
219    /// A `ttl_seconds` of `0` means "expire immediately": rather than persisting
220    /// a live point that then occupies an index slot until the next
221    /// `auto_expire`, the point is eagerly removed (and any pre-existing point
222    /// for `id` deleted). The embedding is still dimension-validated so callers
223    /// get the same error contract as a real store.
224    ///
225    /// The expiry is persisted as a reserved `_veles_expires_at` (epoch
226    /// seconds) payload field, so the TTL survives a process restart: the
227    /// in-memory map is rebuilt from payloads when the collection is reopened.
228    ///
229    /// # Errors
230    ///
231    /// Returns the same errors as [`Self::store`].
232    pub fn store_with_ttl(
233        &self,
234        id: u64,
235        content: &str,
236        embedding: &[f32],
237        ttl_seconds: u64,
238    ) -> Result<(), AgentMemoryError> {
239        if ttl_seconds == 0 {
240            memory_helpers::validate_dimension(self.dimension, embedding.len())?;
241            return self.delete(id);
242        }
243        let expires_at = MemoryTtl::now().saturating_add(ttl_seconds);
244        self.store_internal(id, content, embedding, None, Some(expires_at))?;
245        self.ttl.set_expiry(MemoryKind::Semantic, id, expires_at);
246        Ok(())
247    }
248
249    /// Durably sets (or refreshes) the TTL of an existing fact.
250    ///
251    /// Unlike `AgentMemory::set_semantic_ttl` (in-memory map only, lost on
252    /// restart), this persists the expiry to the reserved `_veles_expires_at`
253    /// payload field, so it survives a restart. A `ttl_seconds` of 0 expires
254    /// the fact immediately.
255    ///
256    /// # Errors
257    ///
258    /// Returns `NotFound` when no fact with `id` exists, or `CollectionError`
259    /// when persistence fails.
260    pub fn set_ttl_durable(&self, id: u64, ttl_seconds: u64) -> Result<(), AgentMemoryError> {
261        memory_helpers::set_ttl_durable(
262            &self.db,
263            &self.collection_name,
264            &self.ttl,
265            MemoryKind::Semantic,
266            id,
267            ttl_seconds,
268        )
269    }
270
271    /// Relates two live facts with a typed, durable graph edge
272    /// (`MATCH (a)-[:REL_TYPE]->(b)` becomes executable over this memory).
273    ///
274    /// Returns the allocated edge id. Edges are WAL-persisted and cascade
275    /// away when either endpoint memory is deleted.
276    ///
277    /// # Errors
278    ///
279    /// Returns `NotFound` when either endpoint is missing or expired, or
280    /// `CollectionError` when the edge write fails.
281    pub fn relate(
282        &self,
283        from_id: u64,
284        to_id: u64,
285        rel_type: &str,
286        properties: Option<&serde_json::Map<String, serde_json::Value>>,
287    ) -> Result<u64, AgentMemoryError> {
288        memory_helpers::relate_memory_points(
289            &memory_helpers::MemorySubsystem {
290                db: &self.db,
291                collection_name: &self.collection_name,
292                ttl: &self.ttl,
293                kind: MemoryKind::Semantic,
294                next_edge_id: &self.next_edge_id,
295            },
296            from_id,
297            to_id,
298            rel_type,
299            properties,
300        )
301    }
302
303    /// Returns the outgoing relations of a fact (edges it points from).
304    ///
305    /// # Errors
306    ///
307    /// Returns `CollectionError` when the collection cannot be resolved.
308    pub fn relations(
309        &self,
310        id: u64,
311    ) -> Result<Vec<crate::collection::graph::GraphEdge>, AgentMemoryError> {
312        memory_helpers::relations_of(
313            &self.db,
314            &self.collection_name,
315            id,
316            &self.ttl,
317            MemoryKind::Semantic,
318        )
319    }
320
321    /// Removes a relation edge created by [`Self::relate`].
322    ///
323    /// Returns `true` when the edge existed and was removed.
324    ///
325    /// # Errors
326    ///
327    /// Returns `CollectionError` when the collection cannot be resolved.
328    pub fn unrelate(&self, edge_id: u64) -> Result<bool, AgentMemoryError> {
329        memory_helpers::unrelate_edge(&self.db, &self.collection_name, edge_id)
330    }
331
332    /// Queries semantic memory by vector similarity.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error when embedding dimension is invalid, collection access fails,
337    /// or vector search fails.
338    pub fn query(
339        &self,
340        query_embedding: &[f32],
341        k: usize,
342    ) -> Result<Vec<(u64, f32, String)>, AgentMemoryError> {
343        let results = memory_helpers::search_filtered(
344            &self.db,
345            &self.collection_name,
346            self.dimension,
347            query_embedding,
348            k,
349            &self.ttl,
350            MemoryKind::Semantic,
351        )?;
352
353        Ok(results
354            .into_iter()
355            .map(|r| {
356                let content = extract_content(&r.point);
357                (r.point.id, r.score, content)
358            })
359            .collect())
360    }
361
362    /// Queries semantic memory with a payload filter and optional offset pagination.
363    ///
364    /// Results are ranked by vector similarity, filtered against `filter` (all
365    /// key-value pairs must match), TTL-expired points are excluded, and
366    /// `offset` leading results are skipped before taking `k`.
367    ///
368    /// The internal fetch budget is generous to survive both TTL eviction and
369    /// filter miss-rates; when the collection has very few matching entries the
370    /// returned slice may be shorter than `k`.
371    ///
372    /// # Errors
373    ///
374    /// Returns an error when embedding dimension is invalid or collection access fails.
375    pub fn query_filtered(
376        &self,
377        query_embedding: &[f32],
378        k: usize,
379        filter: &Map<String, Value>,
380        offset: usize,
381    ) -> Result<Vec<(u64, f32, String)>, AgentMemoryError> {
382        // over-fetch to absorb TTL evictions + payload filter misses + offset
383        let need = k.saturating_add(offset);
384        let fetch_k = need
385            .saturating_add(self.ttl.expired_count(MemoryKind::Semantic))
386            .saturating_mul(2)
387            .max(need.saturating_add(8));
388
389        memory_helpers::validate_dimension(self.dimension, query_embedding.len())?;
390        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
391        let raw = memory_helpers::search_collection(&collection, query_embedding, fetch_k)?;
392
393        Ok(raw
394            .into_iter()
395            .filter(|r| !self.ttl.is_expired(MemoryKind::Semantic, r.point.id))
396            .filter(|r| payload_matches(&r.point, filter))
397            .skip(offset)
398            .take(k)
399            .map(|r| (r.point.id, r.score, extract_content(&r.point)))
400            .collect())
401    }
402
403    /// Stores multiple semantic memory points in one batch.
404    ///
405    /// Each tuple is `(id, content, embedding)`. All embeddings are
406    /// dimension-validated before any write occurs.
407    ///
408    /// This is best-effort, not transactional: if `upsert_points` fails partway
409    /// the already-persisted points are kept and `stored_ids` is left untouched
410    /// (it is only updated after a fully successful upsert), matching the
411    /// single-`store` behaviour.
412    ///
413    /// # Errors
414    ///
415    /// Returns an error when any embedding dimension is invalid, collection
416    /// access fails, or persistence fails.
417    pub fn store_batch(&self, facts: &[(u64, &str, &[f32])]) -> Result<(), AgentMemoryError> {
418        let mut points = Vec::with_capacity(facts.len());
419        for (id, content, embedding) in facts {
420            memory_helpers::validate_dimension(self.dimension, embedding.len())?;
421            points.push(Point::new(
422                *id,
423                embedding.to_vec(),
424                Some(build_payload(content, None)),
425            ));
426        }
427
428        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
429        memory_helpers::upsert_points(&collection, points)?;
430
431        let mut ids = self.stored_ids.write();
432        for (id, _, _) in facts {
433            ids.insert(*id);
434        }
435        Ok(())
436    }
437
438    /// Retrieves a fact's content and embedding by id.
439    ///
440    /// Returns `None` when the id is unknown or has expired.
441    ///
442    /// # Errors
443    ///
444    /// Returns an error when collection access fails.
445    pub fn get(&self, id: u64) -> Result<Option<(String, Vec<f32>)>, AgentMemoryError> {
446        if self.ttl.is_expired(MemoryKind::Semantic, id) {
447            return Ok(None);
448        }
449        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
450        let Some(point) = collection.get(&[id]).into_iter().flatten().next() else {
451            return Ok(None);
452        };
453        Ok(Some((extract_content(&point), point.vector.clone())))
454    }
455
456    /// Lists all live (non-expired) tracked facts as `(id, content)` pairs.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error when collection access fails.
461    pub fn list_all(&self) -> Result<Vec<(u64, String)>, AgentMemoryError> {
462        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
463        let all_ids: Vec<u64> = self.stored_ids.read().iter().copied().collect();
464
465        Ok(collection
466            .get(&all_ids)
467            .into_iter()
468            .flatten()
469            .filter(|p| !self.ttl.is_expired(MemoryKind::Semantic, p.id))
470            .map(|p| (p.id, extract_content(&p)))
471            .collect())
472    }
473
474    /// Returns the number of tracked facts.
475    #[must_use]
476    pub fn count(&self) -> usize {
477        self.stored_ids.read().len()
478    }
479
480    /// Returns `true` when no facts are tracked.
481    #[must_use]
482    pub fn is_empty(&self) -> bool {
483        self.stored_ids.read().is_empty()
484    }
485
486    /// Removes all facts and their tracking entries.
487    ///
488    /// # Errors
489    ///
490    /// Returns an error when collection access or deletion fails.
491    pub fn clear(&self) -> Result<(), AgentMemoryError> {
492        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
493        let ids: Vec<u64> = self.stored_ids.read().iter().copied().collect();
494        if !ids.is_empty() {
495            memory_helpers::delete_from_collection(&collection, &ids)?;
496        }
497        for id in &ids {
498            self.ttl.remove(MemoryKind::Semantic, *id);
499        }
500        self.stored_ids.write().clear();
501        Ok(())
502    }
503
504    /// Deletes a semantic memory point by id.
505    ///
506    /// # Errors
507    ///
508    /// Returns an error when collection access or deletion fails.
509    pub fn delete(&self, id: u64) -> Result<(), AgentMemoryError> {
510        memory_helpers::delete_tracked_point(
511            &self.db,
512            &self.collection_name,
513            id,
514            &self.stored_ids,
515            &self.ttl,
516            MemoryKind::Semantic,
517        )
518    }
519
520    /// Serializes semantic memory points for snapshot persistence.
521    ///
522    /// # TTL limitation
523    ///
524    /// The returned bytes contain only the stored points (id, embedding,
525    /// payload — including any durable `_veles_expires_at` field) and intentionally
526    /// **omit the TTL map**. TTL is tracked in a single `MemoryTtl` map shared
527    /// across the semantic, episodic, and procedural subsystems (see
528    /// [`AgentMemory`](crate::agent::AgentMemory)), so it cannot be partitioned
529    /// per subsystem here. TTL is persisted and restored globally by
530    /// [`AgentMemory::snapshot`](crate::agent::AgentMemory::snapshot) /
531    /// `restore_state`. Calling [`Self::deserialize`] in isolation therefore
532    /// restores facts but refreshes the in-memory expiry map only at the next
533    /// construction (payload `_veles_expires_at` rebuild); use the snapshot manager
534    /// for an immediate full round-trip including TTL.
535    ///
536    /// # Errors
537    ///
538    /// Returns an error when collection access or JSON encoding fails.
539    pub fn serialize(&self) -> Result<Vec<u8>, AgentMemoryError> {
540        memory_helpers::serialize_tracked_points(&self.db, &self.collection_name, &self.stored_ids)
541    }
542
543    /// Replaces semantic memory state from snapshot bytes.
544    ///
545    /// # Errors
546    ///
547    /// Returns an error when JSON decoding fails, collection access fails,
548    /// or persistence operations fail.
549    pub fn deserialize(&self, data: &[u8]) -> Result<(), AgentMemoryError> {
550        memory_helpers::deserialize_tracked_points(
551            &self.db,
552            &self.collection_name,
553            data,
554            &self.stored_ids,
555        )
556    }
557}
558
559/// Builds the payload `Value` from `content` and optional extra metadata.
560///
561/// `content` is always inserted last so it wins over any `"content"` key
562/// present in `metadata`. The reserved [`memory_helpers::EXPIRES_AT_KEY`] is
563/// stripped: the durable TTL is only ever written by the system store path.
564fn build_payload(content: &str, metadata: Option<&Map<String, Value>>) -> Value {
565    let mut map = metadata.cloned().unwrap_or_default();
566    map.remove(memory_helpers::EXPIRES_AT_KEY);
567    map.insert("content".to_string(), Value::String(content.to_string()));
568    Value::Object(map)
569}
570
571/// Merges `updates` into an existing point payload, returning the new payload.
572///
573/// A missing payload starts from an empty object. Errors when the existing
574/// payload is present but not a JSON object. The reserved
575/// [`memory_helpers::EXPIRES_AT_KEY`] is skipped so a metadata update can
576/// neither inject nor clobber the durable TTL.
577fn merge_payload(
578    existing: Option<Value>,
579    updates: &Map<String, Value>,
580) -> Result<Value, AgentMemoryError> {
581    let mut payload = existing.unwrap_or_else(|| Value::Object(Map::new()));
582    let obj = payload
583        .as_object_mut()
584        .ok_or_else(|| AgentMemoryError::IoError("corrupt payload".to_string()))?;
585    for (k, v) in updates {
586        if k == memory_helpers::EXPIRES_AT_KEY {
587            continue;
588        }
589        obj.insert(k.clone(), v.clone());
590    }
591    Ok(payload)
592}
593
594/// Returns `true` when every key-value pair in `filter` matches the point payload.
595///
596/// An empty filter matches all points. A point with no payload only matches an
597/// empty filter.
598fn payload_matches(point: &Point, filter: &Map<String, Value>) -> bool {
599    if filter.is_empty() {
600        return true;
601    }
602    let Some(obj) = point.payload.as_ref().and_then(Value::as_object) else {
603        return false;
604    };
605    filter
606        .iter()
607        .all(|(k, v)| obj.get(k).is_some_and(|pv| pv == v))
608}
609
610/// Extracts the `content` string from a point's payload, or `""` when absent.
611fn extract_content(point: &Point) -> String {
612    point
613        .payload
614        .as_ref()
615        .and_then(|p| p.get("content"))
616        .and_then(Value::as_str)
617        .unwrap_or("")
618        .to_string()
619}