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}