1#![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#[derive(Debug, Clone)]
65pub struct ProcedureMatch {
66 pub id: u64,
68 pub name: String,
70 pub steps: Vec<String>,
72 pub confidence: f32,
74 pub score: f32,
76}
77
78pub 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 activation_decay_exponent: Option<f32>,
100 next_edge_id: std::sync::atomic::AtomicU64,
102}
103
104impl ProceduralMemory {
105 const COLLECTION_NAME: &'static str = "_procedural_memory";
106
107 #[must_use]
109 pub fn collection_name(&self) -> &str {
110 &self.collection_name
111 }
112
113 #[must_use]
115 pub fn dimension(&self) -> usize {
116 self.dimension
117 }
118
119 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
587fn 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}