Skip to main content

velesdb_core/agent/
procedural_memory.rs

1//! Procedural Memory - Learned patterns storage (US-004)
2//!
3//! Stores action sequences and learned procedures with confidence scoring.
4//! Supports pattern matching by similarity and reinforcement learning.
5//! Includes extensible reinforcement strategies for adaptive confidence updates.
6
7// Reason: Numeric casts in procedural memory are intentional:
8// - u64->i64 casts for timestamps (SystemTime::elapsed returns u64, DB uses i64)
9// - i64->u64 casts for display purposes (timestamps are always positive)
10// - f64->f32 casts for confidence scores (f32 precision sufficient, values clamped to 0.0-1.0)
11// - All timestamp values are bounded by reasonable time ranges
12#![allow(clippy::cast_possible_wrap)]
13#![allow(clippy::cast_sign_loss)]
14#![allow(clippy::cast_precision_loss)]
15#![allow(clippy::cast_possible_truncation)]
16
17use crate::{Database, Point};
18use parking_lot::RwLock;
19use serde_json::json;
20use std::collections::HashSet;
21use std::sync::Arc;
22
23use super::error::AgentMemoryError;
24use super::memory_helpers;
25use super::reinforcement::{
26    power_law_decay, FixedRate, ReinforcementContext, ReinforcementStrategy,
27};
28use super::ttl::{MemoryKind, MemoryTtl};
29
30struct ProcedureState {
31    name: String,
32    steps: Vec<String>,
33    confidence: f32,
34    usage_count: u64,
35    created_at: i64,
36    last_used_at: i64,
37    success_count: u64,
38    failure_count: u64,
39}
40
41impl ProcedureState {
42    fn build_reinforcement_context(&self, now: i64) -> ReinforcementContext {
43        let total_uses = self.success_count + self.failure_count;
44        let success_rate = if total_uses > 0 {
45            self.success_count as f32 / total_uses as f32
46        } else {
47            0.5
48        };
49        let mut custom = std::collections::HashMap::new();
50        custom.insert("success_count".to_string(), self.success_count as f64);
51        custom.insert("failure_count".to_string(), self.failure_count as f64);
52        ReinforcementContext {
53            usage_count: self.usage_count,
54            created_at: self.created_at as u64,
55            last_used: self.last_used_at as u64,
56            current_time: now as u64,
57            recent_success_rate: Some(success_rate),
58            custom,
59        }
60    }
61}
62
63/// A procedure match result returned by [`ProceduralMemory::recall`].
64#[derive(Debug, Clone)]
65pub struct ProcedureMatch {
66    /// Unique identifier for the procedure.
67    pub id: u64,
68    /// Human-readable name for the procedure.
69    pub name: String,
70    /// Ordered sequence of steps that constitute the procedure.
71    pub steps: Vec<String>,
72    /// Confidence score of this procedure (0.0 - 1.0).
73    pub confidence: f32,
74    /// Similarity score from the vector search.
75    pub score: f32,
76}
77
78/// Procedural memory for storing learned action sequences with confidence scoring.
79///
80/// Stores procedures as embedding vectors with associated metadata.
81/// Supports confidence-based recall, reinforcement learning, and TTL expiration.
82///
83/// ### ACT-R activation decay
84///
85/// When built with [`with_activation_decay`](Self::with_activation_decay), the
86/// confidence returned by [`recall`](Self::recall) is modulated by the ACT-R
87/// base-level power-law formula: `c × max(1, t_days)^(-d)` where `d` is the
88/// decay exponent (Anderson 1996, default ≈ 0.5).  The stored value is
89/// unchanged — decay is applied read-only at retrieval time.
90pub struct ProceduralMemory {
91    collection_name: String,
92    db: Arc<Database>,
93    dimension: usize,
94    ttl: Arc<MemoryTtl>,
95    reinforcement_strategy: Arc<dyn ReinforcementStrategy>,
96    stored_ids: RwLock<HashSet<u64>>,
97    /// ACT-R decay exponent `d` for passive confidence decay at recall time.
98    /// `None` disables decay (default — backward-compatible behaviour).
99    activation_decay_exponent: Option<f32>,
100    /// Edge-id allocator for [`Self::relate`] (seeded past existing edges).
101    next_edge_id: std::sync::atomic::AtomicU64,
102}
103
104impl ProceduralMemory {
105    const COLLECTION_NAME: &'static str = "_procedural_memory";
106
107    /// Returns the name of the underlying `VelesDB` collection.
108    #[must_use]
109    pub fn collection_name(&self) -> &str {
110        &self.collection_name
111    }
112
113    /// Returns the embedding dimension for this collection.
114    #[must_use]
115    pub fn dimension(&self) -> usize {
116        self.dimension
117    }
118
119    /// Creates or opens procedural memory.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error when collection creation/opening fails or dimensions mismatch.
124    pub fn new_from_db(db: Arc<Database>, dimension: usize) -> Result<Self, AgentMemoryError> {
125        Self::new(db, dimension, Arc::new(MemoryTtl::new()))
126    }
127
128    pub(crate) fn new(
129        db: Arc<Database>,
130        dimension: usize,
131        ttl: Arc<MemoryTtl>,
132    ) -> Result<Self, AgentMemoryError> {
133        let (collection_name, dimension, stored_ids) =
134            memory_helpers::init_tracked_memory(&db, Self::COLLECTION_NAME, dimension)?;
135        memory_helpers::rebuild_ttl_from_payloads(
136            &db,
137            &collection_name,
138            &ttl,
139            MemoryKind::Procedural,
140        )?;
141
142        let next_edge_id = memory_helpers::seed_edge_counter(&memory_helpers::get_collection(
143            &db,
144            &collection_name,
145        )?);
146
147        Ok(Self {
148            collection_name,
149            db,
150            dimension,
151            ttl,
152            reinforcement_strategy: Arc::new(FixedRate::default()),
153            stored_ids,
154            activation_decay_exponent: None,
155            next_edge_id,
156        })
157    }
158
159    /// Overrides the default reinforcement strategy with a custom implementation.
160    #[must_use]
161    pub fn with_reinforcement_strategy(mut self, strategy: Arc<dyn ReinforcementStrategy>) -> Self {
162        self.reinforcement_strategy = strategy;
163        self
164    }
165
166    /// Enables ACT-R base-level activation decay at recall time.
167    ///
168    /// When set, the confidence returned by [`recall`](Self::recall) is
169    /// multiplied by `max(1, t_days)^(-decay_exponent)` where `t_days` is the
170    /// number of days since the procedure was last reinforced.
171    ///
172    /// The stored confidence is **not** modified — decay is applied read-only.
173    /// ACT-R recommends `decay_exponent ≈ 0.5` (Anderson 1996).
174    #[must_use]
175    pub fn with_activation_decay(mut self, decay_exponent: f32) -> Self {
176        self.activation_decay_exponent = Some(decay_exponent.clamp(0.0, 2.0));
177        self
178    }
179
180    /// Learns a procedure and stores it in memory.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error when embedding dimension is invalid, the collection is
185    /// unavailable, or persistence fails.
186    pub fn learn(
187        &self,
188        procedure_id: u64,
189        name: &str,
190        steps: &[String],
191        embedding: Option<&[f32]>,
192        confidence: f32,
193    ) -> Result<(), AgentMemoryError> {
194        self.learn_internal(procedure_id, name, steps, embedding, confidence, None)
195    }
196
197    /// Shared store path: persists the procedure, optionally with a durable
198    /// `_veles_expires_at` payload field (epoch seconds) for TTL'd procedures.
199    fn learn_internal(
200        &self,
201        procedure_id: u64,
202        name: &str,
203        steps: &[String],
204        embedding: Option<&[f32]>,
205        confidence: f32,
206        expires_at: Option<u64>,
207    ) -> Result<(), AgentMemoryError> {
208        let vector = memory_helpers::resolve_embedding(self.dimension, embedding)?;
209        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
210
211        let now = std::time::SystemTime::now()
212            .duration_since(std::time::UNIX_EPOCH)
213            .map_or(0, |d| d.as_secs() as i64);
214
215        let mut payload = json!({
216            "name": name,
217            "steps": steps,
218            "confidence": confidence,
219            "usage_count": 0,
220            "created_at": now,
221            "last_used_at": now,
222            "success_count": 0,
223            "failure_count": 0
224        });
225        memory_helpers::attach_expiry(&mut payload, expires_at);
226        let point = Point::new(procedure_id, vector, Some(payload));
227
228        memory_helpers::upsert_points(&collection, vec![point])?;
229        self.stored_ids.write().insert(procedure_id);
230        Ok(())
231    }
232
233    /// Learns a procedure and assigns a TTL for auto-expiration.
234    ///
235    /// A `ttl_seconds` of `0` means "expire immediately": the procedure is
236    /// eagerly removed (and any pre-existing point for `procedure_id` deleted),
237    /// harmonising the behaviour with `SemanticMemory::store_with_ttl`. The
238    /// embedding is still dimension-validated so callers get the same error
239    /// contract as a real learn.
240    ///
241    /// The expiry is persisted as a reserved `_veles_expires_at` (epoch
242    /// seconds) payload field, so the TTL survives a process restart: the
243    /// in-memory map is rebuilt from payloads when the collection is reopened.
244    ///
245    /// # Errors
246    ///
247    /// Returns the same errors as [`Self::learn`].
248    pub fn learn_with_ttl(
249        &self,
250        procedure_id: u64,
251        name: &str,
252        steps: &[String],
253        embedding: Option<&[f32]>,
254        confidence: f32,
255        ttl_seconds: u64,
256    ) -> Result<(), AgentMemoryError> {
257        if ttl_seconds == 0 {
258            if let Some(emb) = embedding {
259                memory_helpers::validate_dimension(self.dimension, emb.len())?;
260            }
261            return self.delete(procedure_id);
262        }
263        let expires_at = MemoryTtl::now().saturating_add(ttl_seconds);
264        self.learn_internal(
265            procedure_id,
266            name,
267            steps,
268            embedding,
269            confidence,
270            Some(expires_at),
271        )?;
272        self.ttl
273            .set_expiry(MemoryKind::Procedural, procedure_id, expires_at);
274        Ok(())
275    }
276
277    /// Durably sets (or refreshes) the TTL of an existing procedure.
278    ///
279    /// Unlike `AgentMemory::set_procedural_ttl` (in-memory map only, lost on
280    /// restart), this persists the expiry to the reserved `_veles_expires_at`
281    /// payload field, so it survives a restart. A `ttl_seconds` of 0 expires
282    /// the procedure immediately.
283    ///
284    /// # Errors
285    ///
286    /// Returns `NotFound` when no procedure with `procedure_id` exists, or
287    /// `CollectionError` when persistence fails.
288    pub fn set_ttl_durable(
289        &self,
290        procedure_id: u64,
291        ttl_seconds: u64,
292    ) -> Result<(), AgentMemoryError> {
293        memory_helpers::set_ttl_durable(
294            &self.db,
295            &self.collection_name,
296            &self.ttl,
297            MemoryKind::Procedural,
298            procedure_id,
299            ttl_seconds,
300        )
301    }
302
303    /// Relates two live procedures with a typed, durable graph edge (e.g.
304    /// `DEPENDS_ON`, `REFINES`); see `SemanticMemory::relate` for semantics.
305    ///
306    /// # Errors
307    ///
308    /// Returns `NotFound` when either endpoint is missing or expired, or
309    /// `CollectionError` when the edge write fails.
310    pub fn relate(
311        &self,
312        from_id: u64,
313        to_id: u64,
314        rel_type: &str,
315        properties: Option<&serde_json::Map<String, serde_json::Value>>,
316    ) -> Result<u64, AgentMemoryError> {
317        memory_helpers::relate_memory_points(
318            &memory_helpers::MemorySubsystem {
319                db: &self.db,
320                collection_name: &self.collection_name,
321                ttl: &self.ttl,
322                kind: MemoryKind::Procedural,
323                next_edge_id: &self.next_edge_id,
324            },
325            from_id,
326            to_id,
327            rel_type,
328            properties,
329        )
330    }
331
332    /// Returns the outgoing relations of a procedure.
333    ///
334    /// # Errors
335    ///
336    /// Returns `CollectionError` when the collection cannot be resolved.
337    pub fn relations(
338        &self,
339        id: u64,
340    ) -> Result<Vec<crate::collection::graph::GraphEdge>, AgentMemoryError> {
341        memory_helpers::relations_of(
342            &self.db,
343            &self.collection_name,
344            id,
345            &self.ttl,
346            MemoryKind::Procedural,
347        )
348    }
349
350    /// Removes a relation edge created by [`Self::relate`].
351    ///
352    /// # Errors
353    ///
354    /// Returns `CollectionError` when the collection cannot be resolved.
355    pub fn unrelate(&self, edge_id: u64) -> Result<bool, AgentMemoryError> {
356        memory_helpers::unrelate_edge(&self.db, &self.collection_name, edge_id)
357    }
358
359    /// Recalls matching procedures by vector similarity.
360    ///
361    /// When ACT-R activation decay is configured via
362    /// [`with_activation_decay`](Self::with_activation_decay), the returned
363    /// `confidence` reflects power-law decay since last use without modifying
364    /// the stored value.
365    ///
366    /// # Errors
367    ///
368    /// Returns an error when embedding dimension is invalid, collection access fails,
369    /// or vector search fails.
370    pub fn recall(
371        &self,
372        query_embedding: &[f32],
373        k: usize,
374        min_confidence: f32,
375    ) -> Result<Vec<ProcedureMatch>, AgentMemoryError> {
376        let results = memory_helpers::search_filtered(
377            &self.db,
378            &self.collection_name,
379            self.dimension,
380            query_embedding,
381            k,
382            &self.ttl,
383            MemoryKind::Procedural,
384        )?;
385
386        let now_secs = std::time::SystemTime::now()
387            .duration_since(std::time::UNIX_EPOCH)
388            .map_or(0, |d| d.as_secs());
389
390        Ok(results
391            .into_iter()
392            .filter_map(|r| {
393                let mut pm = extract_procedure_match(&r.point, r.score)?;
394                if let Some(exponent) = self.activation_decay_exponent {
395                    let last_used = r
396                        .point
397                        .payload
398                        .as_ref()
399                        .and_then(|p| p.get("last_used_at"))
400                        .and_then(serde_json::Value::as_i64)
401                        .unwrap_or(0);
402                    let elapsed_secs = (now_secs as i64).saturating_sub(last_used).max(0) as u64;
403                    pm.confidence = power_law_decay(pm.confidence, elapsed_secs, exponent);
404                }
405                if pm.confidence < min_confidence {
406                    return None;
407                }
408                Some(pm)
409            })
410            .collect())
411    }
412
413    /// Reinforces a stored procedure using the configured strategy.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error when procedure retrieval or update fails.
418    pub fn reinforce(&self, procedure_id: u64, success: bool) -> Result<(), AgentMemoryError> {
419        self.reinforce_with_strategy(procedure_id, success, &*self.reinforcement_strategy)
420    }
421
422    /// Reinforces a stored procedure using a custom strategy.
423    ///
424    /// # Errors
425    ///
426    /// Returns an error when procedure retrieval or update fails.
427    pub fn reinforce_with_strategy(
428        &self,
429        procedure_id: u64,
430        success: bool,
431        strategy: &dyn ReinforcementStrategy,
432    ) -> Result<(), AgentMemoryError> {
433        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
434
435        let points = collection.get(&[procedure_id]);
436        let point = points
437            .into_iter()
438            .flatten()
439            .next()
440            .ok_or_else(|| AgentMemoryError::NotFound(format!("Procedure {procedure_id}")))?;
441
442        let state = Self::extract_procedure_state(&point)?;
443        let now = std::time::SystemTime::now()
444            .duration_since(std::time::UNIX_EPOCH)
445            .map_or(0, |d| d.as_secs() as i64);
446
447        let context = state.build_reinforcement_context(now);
448        let new_confidence = strategy.update_confidence(state.confidence, success, &context);
449        let (new_success, new_failure) = if success {
450            (state.success_count + 1, state.failure_count)
451        } else {
452            (state.success_count, state.failure_count + 1)
453        };
454
455        let mut payload = json!({
456            "name": state.name,
457            "steps": state.steps,
458            "confidence": new_confidence,
459            "usage_count": state.usage_count + 1,
460            "created_at": state.created_at,
461            "last_used_at": now,
462            "success_count": new_success,
463            "failure_count": new_failure
464        });
465        // Preserve the durable TTL field: reinforcing must not strip expiry.
466        let prior_expiry = point
467            .payload
468            .as_ref()
469            .and_then(|p| p.get(memory_helpers::EXPIRES_AT_KEY))
470            .and_then(serde_json::Value::as_u64);
471        memory_helpers::attach_expiry(&mut payload, prior_expiry);
472        let updated_point = Point::new(procedure_id, point.vector.clone(), Some(payload));
473
474        memory_helpers::upsert_points(&collection, vec![updated_point])?;
475
476        Ok(())
477    }
478
479    fn extract_procedure_state(point: &Point) -> Result<ProcedureState, AgentMemoryError> {
480        let payload = point
481            .payload
482            .as_ref()
483            .ok_or_else(|| AgentMemoryError::CollectionError("Missing payload".to_string()))?;
484
485        Ok(ProcedureState {
486            name: payload
487                .get("name")
488                .and_then(|v| v.as_str())
489                .unwrap_or("")
490                .to_string(),
491            steps: payload
492                .get("steps")
493                .and_then(|v| v.as_array())
494                .map(|arr| {
495                    arr.iter()
496                        .filter_map(|v| v.as_str().map(String::from))
497                        .collect()
498                })
499                .unwrap_or_default(),
500            confidence: payload
501                .get("confidence")
502                .and_then(serde_json::Value::as_f64)
503                .unwrap_or(0.5) as f32,
504            usage_count: payload
505                .get("usage_count")
506                .and_then(serde_json::Value::as_u64)
507                .unwrap_or(0),
508            created_at: payload
509                .get("created_at")
510                .and_then(serde_json::Value::as_i64)
511                .unwrap_or(0),
512            last_used_at: payload
513                .get("last_used_at")
514                .and_then(serde_json::Value::as_i64)
515                .unwrap_or(0),
516            success_count: payload
517                .get("success_count")
518                .and_then(serde_json::Value::as_u64)
519                .unwrap_or(0),
520            failure_count: payload
521                .get("failure_count")
522                .and_then(serde_json::Value::as_u64)
523                .unwrap_or(0),
524        })
525    }
526
527    /// Lists all tracked procedures.
528    ///
529    /// # Errors
530    ///
531    /// Returns an error when collection access fails.
532    pub fn list_all(&self) -> Result<Vec<ProcedureMatch>, AgentMemoryError> {
533        let collection = memory_helpers::get_collection(&self.db, &self.collection_name)?;
534
535        let all_ids: Vec<u64> = self.stored_ids.read().iter().copied().collect();
536        let points = collection.get(&all_ids);
537
538        Ok(points
539            .into_iter()
540            .flatten()
541            .filter(|p| !self.ttl.is_expired(MemoryKind::Procedural, p.id))
542            .filter_map(|p| extract_procedure_match(&p, 0.0))
543            .collect())
544    }
545
546    /// Deletes a procedure by id.
547    ///
548    /// # Errors
549    ///
550    /// Returns an error when collection access or deletion fails.
551    pub fn delete(&self, id: u64) -> Result<(), AgentMemoryError> {
552        memory_helpers::delete_tracked_point(
553            &self.db,
554            &self.collection_name,
555            id,
556            &self.stored_ids,
557            &self.ttl,
558            MemoryKind::Procedural,
559        )
560    }
561
562    /// Serializes all procedures into snapshot bytes.
563    ///
564    /// # Errors
565    ///
566    /// Returns an error when collection access or JSON encoding fails.
567    pub fn serialize(&self) -> Result<Vec<u8>, AgentMemoryError> {
568        memory_helpers::serialize_tracked_points(&self.db, &self.collection_name, &self.stored_ids)
569    }
570
571    /// Replaces procedural memory state from serialized snapshot bytes.
572    ///
573    /// # Errors
574    ///
575    /// Returns an error when JSON decoding fails, collection access fails,
576    /// or persistence operations fail.
577    pub fn deserialize(&self, data: &[u8]) -> Result<(), AgentMemoryError> {
578        memory_helpers::deserialize_tracked_points(
579            &self.db,
580            &self.collection_name,
581            data,
582            &self.stored_ids,
583        )
584    }
585}
586
587/// Extracts a `ProcedureMatch` from a point's payload with the given similarity score.
588fn extract_procedure_match(point: &Point, score: f32) -> Option<ProcedureMatch> {
589    let payload = point.payload.as_ref()?;
590    let name = payload.get("name")?.as_str()?.to_string();
591    let steps: Vec<String> = payload
592        .get("steps")?
593        .as_array()?
594        .iter()
595        .filter_map(|v| v.as_str().map(String::from))
596        .collect();
597    #[allow(clippy::cast_possible_truncation)]
598    let confidence = payload.get("confidence")?.as_f64()? as f32;
599
600    Some(ProcedureMatch {
601        id: point.id,
602        name,
603        steps,
604        confidence,
605        score,
606    })
607}