1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use crate::error::Result;
5use crate::hash::compute_content_hash;
6use crate::model::event::{AgentEvent, EventType};
7use crate::model::memory::{ConsolidationState, MemoryRecord, MemoryType, SourceType};
8use crate::model::relation::Relation;
9use crate::query::MnemoEngine;
10use crate::storage::MemoryFilter;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "snake_case")]
15pub enum DecayFunction {
16 Exponential,
18 Linear,
20 StepFunction(f32),
22 PowerLaw(f32),
24}
25
26impl DecayFunction {
27 pub fn from_str_opt(s: &str) -> Option<Self> {
28 match s {
29 "exponential" => Some(DecayFunction::Exponential),
30 "linear" => Some(DecayFunction::Linear),
31 s if s.starts_with("step:") => {
32 s[5..].parse::<f32>().ok().map(DecayFunction::StepFunction)
33 }
34 s if s.starts_with("power_law:") => {
35 s[10..].parse::<f32>().ok().map(DecayFunction::PowerLaw)
36 }
37 _ => None,
38 }
39 }
40}
41
42pub fn effective_importance(record: &MemoryRecord) -> f32 {
45 let decay_fn = record
46 .decay_function
47 .as_deref()
48 .and_then(DecayFunction::from_str_opt)
49 .unwrap_or(DecayFunction::Exponential);
50 effective_importance_with(record, &decay_fn)
51}
52
53pub fn effective_importance_with(record: &MemoryRecord, decay_fn: &DecayFunction) -> f32 {
54 let decay_rate = record.decay_rate.unwrap_or(0.01);
55 let hours = hours_since_creation(&record.created_at);
56 let access_boost = 0.05 * (1.0 + record.access_count as f32).ln();
57
58 let base = match decay_fn {
59 DecayFunction::Exponential => record.importance * (-decay_rate * hours).exp(),
60 DecayFunction::Linear => record.importance * (1.0 - decay_rate * hours).max(0.0),
61 DecayFunction::StepFunction(threshold_hours) => {
62 if hours < *threshold_hours {
63 record.importance
64 } else {
65 0.0
66 }
67 }
68 DecayFunction::PowerLaw(alpha) => {
69 record.importance / (1.0 + decay_rate * hours).powf(*alpha)
70 }
71 };
72
73 (base + access_boost).min(1.0)
74}
75
76fn hours_since_creation(created_at: &str) -> f32 {
77 let now = chrono::Utc::now();
78 match chrono::DateTime::parse_from_rfc3339(created_at) {
79 Ok(dt) => {
80 let age = now - dt.with_timezone(&chrono::Utc);
81 (age.num_seconds() as f32 / 3600.0).max(0.0)
82 }
83 Err(_) => 0.0,
84 }
85}
86
87#[non_exhaustive]
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DecayPassResult {
90 pub archived: usize,
91 pub forgotten: usize,
92 pub total_processed: usize,
93}
94
95impl DecayPassResult {
96 pub fn new(archived: usize, forgotten: usize, total_processed: usize) -> Self {
97 Self {
98 archived,
99 forgotten,
100 total_processed,
101 }
102 }
103}
104
105pub async fn run_decay_pass(
109 engine: &MnemoEngine,
110 agent_id: &str,
111 archive_threshold: f32,
112 forget_threshold: f32,
113) -> Result<DecayPassResult> {
114 let filter = MemoryFilter {
115 agent_id: Some(agent_id.to_string()),
116 include_deleted: false,
117 ..Default::default()
118 };
119 let memories = engine
120 .storage
121 .list_memories(&filter, super::MAX_BATCH_QUERY_LIMIT, 0)
122 .await?;
123
124 let mut archived = 0;
125 let mut forgotten = 0;
126 let total_processed = memories.len();
127
128 for mut record in memories {
129 if record.consolidation_state == ConsolidationState::Forgotten
130 || record.consolidation_state == ConsolidationState::Archived
131 {
132 continue;
133 }
134
135 let eff = effective_importance(&record);
136
137 if eff < forget_threshold {
138 record.consolidation_state = ConsolidationState::Forgotten;
139 record.updated_at = chrono::Utc::now().to_rfc3339();
140 engine.storage.update_memory(&record).await?;
141 forgotten += 1;
142 } else if eff < archive_threshold {
143 record.consolidation_state = ConsolidationState::Archived;
144 record.updated_at = chrono::Utc::now().to_rfc3339();
145 engine.storage.update_memory(&record).await?;
146 archived += 1;
147 }
148 }
149
150 Ok(DecayPassResult {
151 archived,
152 forgotten,
153 total_processed,
154 })
155}
156
157#[non_exhaustive]
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ConsolidationResult {
160 pub clusters_found: usize,
161 pub new_memories_created: usize,
162 pub originals_consolidated: usize,
163 #[serde(default)]
169 pub clusters_skipped_below_threshold: usize,
170}
171
172impl ConsolidationResult {
173 pub fn new(
174 clusters_found: usize,
175 new_memories_created: usize,
176 originals_consolidated: usize,
177 ) -> Self {
178 Self {
179 clusters_found,
180 new_memories_created,
181 originals_consolidated,
182 clusters_skipped_below_threshold: 0,
183 }
184 }
185}
186
187pub async fn run_consolidation(
200 engine: &MnemoEngine,
201 agent_id: &str,
202 min_cluster_size: usize,
203) -> Result<ConsolidationResult> {
204 let filter = MemoryFilter {
205 agent_id: Some(agent_id.to_string()),
206 memory_type: Some(MemoryType::Episodic),
207 include_deleted: false,
208 ..Default::default()
209 };
210 let memories = engine
211 .storage
212 .list_memories(&filter, super::MAX_BATCH_QUERY_LIMIT, 0)
213 .await?;
214
215 let active: Vec<MemoryRecord> = memories
217 .into_iter()
218 .filter(|m| {
219 m.consolidation_state == ConsolidationState::Raw
220 || m.consolidation_state == ConsolidationState::Active
221 })
222 .collect();
223
224 let mut clusters: Vec<Vec<&MemoryRecord>> = Vec::new();
226
227 for record in &active {
228 let mut found_cluster = false;
229 for cluster in &mut clusters {
230 if cluster
232 .iter()
233 .any(|c| c.tags.iter().any(|t| record.tags.contains(t)))
234 {
235 cluster.push(record);
236 found_cluster = true;
237 break;
238 }
239 }
240 if !found_cluster {
241 clusters.push(vec![record]);
242 }
243 }
244
245 let mut clusters_found = 0;
246 let mut new_memories_created = 0;
247 let mut originals_consolidated = 0;
248 let mut clusters_skipped_below_threshold = 0;
249
250 let policy = engine.consolidation_policy.clone();
253
254 for cluster in &clusters {
255 let effective_min = match &policy {
256 crate::query::maturity::ConsolidationPolicy::FixedSize => min_cluster_size,
257 crate::query::maturity::ConsolidationPolicy::MaturityDriven(p) => {
258 min_cluster_size.max(p.min_cluster_size_floor)
259 }
260 };
261 if cluster.len() < effective_min {
262 continue;
263 }
264
265 if let crate::query::maturity::ConsolidationPolicy::MaturityDriven(p) = &policy {
269 match crate::query::maturity::compute_cluster_maturity(
270 engine,
271 cluster,
272 p.weights,
273 p.saturation,
274 )
275 .await?
276 {
277 Some(b) if b.combined < p.threshold => {
278 tracing::debug!(
279 agent_id,
280 cluster_size = cluster.len(),
281 score = b.combined,
282 threshold = p.threshold,
283 "consolidation: cluster below maturity threshold"
284 );
285 clusters_skipped_below_threshold += 1;
286 continue;
287 }
288 _ => {}
289 }
290 }
291
292 clusters_found += 1;
293
294 let combined_content: Vec<String> = cluster.iter().map(|m| m.content.clone()).collect();
296 let content = format!(
297 "[Consolidated from {} memories] {}",
298 cluster.len(),
299 combined_content.join(" | ")
300 );
301 let avg_importance =
302 cluster.iter().map(|m| m.importance).sum::<f32>() / cluster.len() as f32;
303 let all_tags: Vec<String> = cluster
304 .iter()
305 .flat_map(|m| m.tags.iter().cloned())
306 .collect::<std::collections::HashSet<String>>()
307 .into_iter()
308 .collect();
309
310 let now = chrono::Utc::now().to_rfc3339();
311 let new_id = Uuid::now_v7();
312 let content_hash = crate::hash::compute_content_hash(&content, agent_id, &now);
313
314 let embedding = engine.embedding.embed(&content).await?;
315
316 let prev_hash_raw = engine
317 .storage
318 .get_latest_memory_hash(agent_id, None)
319 .await
320 .ok()
321 .flatten();
322 let prev_hash = Some(crate::hash::compute_chain_hash(
323 &content_hash,
324 prev_hash_raw.as_deref(),
325 ));
326
327 let new_record = MemoryRecord {
328 id: new_id,
329 agent_id: agent_id.to_string(),
330 content,
331 memory_type: MemoryType::Semantic,
332 scope: cluster[0].scope,
333 importance: avg_importance,
334 tags: all_tags,
335 metadata: serde_json::json!({"consolidated_from": cluster.iter().map(|m| m.id.to_string()).collect::<Vec<_>>()}),
336 embedding: Some(embedding.clone()),
337 content_hash: content_hash.clone(),
338 prev_hash,
339 source_type: SourceType::Consolidation,
340 source_id: None,
341 consolidation_state: ConsolidationState::Active,
342 access_count: 0,
343 org_id: cluster[0].org_id.clone(),
344 thread_id: None,
345 created_at: now.clone(),
346 updated_at: now,
347 last_accessed_at: None,
348 expires_at: None,
349 deleted_at: None,
350 decay_rate: None,
351 created_by: Some("consolidation_engine".to_string()),
352 version: 1,
353 prev_version_id: None,
354 quarantined: false,
355 quarantine_reason: None,
356 decay_function: None,
357 };
358
359 engine.storage.insert_memory(&new_record).await?;
360 engine.index.add(new_id, &embedding)?;
361 if let Some(ref ft) = engine.full_text {
362 ft.add(new_id, &new_record.content)?;
363 ft.commit()?;
364 }
365 new_memories_created += 1;
366
367 for original in cluster {
369 let relation = Relation {
370 id: Uuid::now_v7(),
371 source_id: new_id,
372 target_id: original.id,
373 relation_type: "consolidated_from".to_string(),
374 weight: 1.0,
375 metadata: serde_json::Value::Object(serde_json::Map::new()),
376 created_at: new_record.created_at.clone(),
377 };
378 if let Err(e) = engine.storage.insert_relation(&relation).await {
379 tracing::error!(relation_id = %relation.id, error = %e, "failed to insert consolidation relation");
380 }
381
382 let mut updated = (*original).clone();
383 updated.consolidation_state = ConsolidationState::Consolidated;
384 updated.updated_at = chrono::Utc::now().to_rfc3339();
385 if let Err(e) = engine.storage.update_memory(&updated).await {
386 tracing::error!(memory_id = %updated.id, error = %e, "failed to update consolidation state");
387 }
388 originals_consolidated += 1;
389 }
390 }
391
392 Ok(ConsolidationResult {
393 clusters_found,
394 new_memories_created,
395 originals_consolidated,
396 clusters_skipped_below_threshold,
397 })
398}
399
400#[non_exhaustive]
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct TtlReport {
404 pub swept_count: usize,
405 pub errors: Vec<TtlError>,
406}
407
408impl TtlReport {
409 pub fn new(swept_count: usize, errors: Vec<TtlError>) -> Self {
410 Self {
411 swept_count,
412 errors,
413 }
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct TtlError {
419 pub memory_id: Uuid,
420 pub error: String,
421}
422
423pub async fn run_ttl_sweep(engine: &MnemoEngine) -> Result<TtlReport> {
430 let filter = MemoryFilter {
431 include_deleted: false,
432 ..Default::default()
433 };
434 let memories = engine
435 .storage
436 .list_memories(&filter, super::MAX_BATCH_QUERY_LIMIT, 0)
437 .await?;
438
439 let now = chrono::Utc::now();
440 let now_str = now.to_rfc3339();
441 let mut swept_count = 0;
442 let mut errors = Vec::new();
443
444 for record in memories {
445 let Some(ref expires_at) = record.expires_at else {
446 continue;
447 };
448 let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at) else {
449 continue;
450 };
451 if exp > now {
452 continue;
453 }
454
455 match engine.storage.hard_delete_memory(record.id).await {
456 Ok(()) => {
457 if let Err(e) = engine.index.remove(record.id) {
458 tracing::warn!(memory_id = %record.id, error = %e, "ttl sweep: vector index remove failed");
459 }
460 if let Some(ref ft) = engine.full_text {
461 if let Err(e) = ft.remove(record.id) {
462 tracing::warn!(memory_id = %record.id, error = %e, "ttl sweep: full-text remove failed");
463 }
464 let _ = ft.commit();
465 }
466 if let Some(ref cache) = engine.cache {
467 cache.invalidate(record.id);
468 }
469 emit_expiry_event(engine, &record, &now_str).await;
470 swept_count += 1;
471 }
472 Err(e) => errors.push(TtlError {
473 memory_id: record.id,
474 error: e.to_string(),
475 }),
476 }
477 }
478
479 Ok(TtlReport {
480 swept_count,
481 errors,
482 })
483}
484
485async fn emit_expiry_event(engine: &MnemoEngine, record: &MemoryRecord, now_str: &str) {
486 let event_content_hash =
487 compute_content_hash(&record.id.to_string(), &record.agent_id, now_str);
488 let prev_event_hash = match engine
489 .storage
490 .get_latest_event_hash(&record.agent_id, None)
491 .await
492 {
493 Ok(hash) => hash,
494 Err(e) => {
495 tracing::warn!(error = %e, "ttl sweep: failed to read prev event hash, starting new chain segment");
496 None
497 }
498 };
499 let event_prev_hash = Some(crate::hash::compute_chain_hash(
500 &event_content_hash,
501 prev_event_hash.as_deref(),
502 ));
503
504 let event = AgentEvent {
505 id: Uuid::now_v7(),
506 agent_id: record.agent_id.clone(),
507 thread_id: None,
508 run_id: None,
509 parent_event_id: None,
510 event_type: EventType::MemoryExpired,
511 payload: serde_json::json!({
512 "memory_id": record.id.to_string(),
513 "expired_at": record.expires_at.clone(),
514 }),
515 trace_id: None,
516 span_id: None,
517 model: None,
518 tokens_input: None,
519 tokens_output: None,
520 latency_ms: None,
521 cost_usd: None,
522 timestamp: now_str.to_string(),
523 logical_clock: 0,
524 content_hash: event_content_hash,
525 prev_hash: event_prev_hash,
526 embedding: None,
527 };
528 if let Err(e) = engine.storage.insert_event(&event).await {
529 tracing::error!(event_id = %event.id, error = %e, "ttl sweep: failed to insert MemoryExpired event");
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::model::memory::*;
537
538 #[test]
539 fn test_effective_importance_decay() {
540 let now = chrono::Utc::now().to_rfc3339();
542 let record = MemoryRecord {
543 id: Uuid::now_v7(),
544 agent_id: "agent-1".to_string(),
545 content: "test".to_string(),
546 memory_type: MemoryType::Episodic,
547 scope: Scope::Private,
548 importance: 0.8,
549 tags: vec![],
550 metadata: serde_json::json!({}),
551 embedding: None,
552 content_hash: vec![],
553 prev_hash: None,
554 source_type: SourceType::Agent,
555 source_id: None,
556 consolidation_state: ConsolidationState::Raw,
557 access_count: 0,
558 org_id: None,
559 thread_id: None,
560 created_at: now,
561 updated_at: "2025-01-01T00:00:00Z".to_string(),
562 last_accessed_at: None,
563 expires_at: None,
564 deleted_at: None,
565 decay_rate: Some(0.01),
566 created_by: None,
567 version: 1,
568 prev_version_id: None,
569 quarantined: false,
570 quarantine_reason: None,
571 decay_function: None,
572 };
573
574 let eff = effective_importance(&record);
575 assert!(
577 eff > 0.7,
578 "effective importance {eff} should be > 0.7 for fresh memory"
579 );
580
581 let old_date = (chrono::Utc::now() - chrono::Duration::hours(1000)).to_rfc3339();
583 let old_record = MemoryRecord {
584 created_at: old_date,
585 decay_rate: Some(0.01),
586 access_count: 0,
587 ..record.clone()
588 };
589 let old_eff = effective_importance(&old_record);
590 assert!(
591 old_eff < eff,
592 "old memory {old_eff} should have lower importance than fresh {eff}"
593 );
594
595 let accessed_record = MemoryRecord {
597 access_count: 100,
598 ..old_record.clone()
599 };
600 let accessed_eff = effective_importance(&accessed_record);
601 assert!(
602 accessed_eff > old_eff,
603 "accessed memory {accessed_eff} should be higher than unaccessed {old_eff}"
604 );
605 }
606
607 use crate::embedding::NoopEmbedding;
608 use crate::index::usearch::UsearchIndex;
609 use crate::query::MnemoEngine;
610 use crate::query::maturity::{
611 ConsolidationPolicy, MaturityPolicy, MaturitySaturation, MaturityWeights,
612 };
613 use crate::query::remember::RememberRequest;
614 use crate::storage::duckdb::DuckDbStorage;
615 use std::sync::Arc;
616
617 async fn seed_two_clusters(engine: &MnemoEngine, agent: &str) {
618 for i in 0..3 {
620 let mut req = RememberRequest::new(format!("a-fact-{i}"));
621 req.tags = Some(vec!["topic-a".to_string()]);
622 req.agent_id = Some(agent.to_string());
623 engine.remember(req).await.expect("remember a");
624 }
625 for i in 0..3 {
627 let mut req = RememberRequest::new(format!("b-fact-{i}"));
628 req.tags = Some(vec!["topic-b".to_string()]);
629 req.agent_id = Some(agent.to_string());
630 engine.remember(req).await.expect("remember b");
631 }
632 }
633
634 fn build_test_engine(policy: ConsolidationPolicy) -> MnemoEngine {
635 let storage = Arc::new(DuckDbStorage::open_in_memory().unwrap());
636 let index = Arc::new(UsearchIndex::new(3).unwrap());
637 let embedding = Arc::new(NoopEmbedding::new(3));
638 MnemoEngine::new(storage, index, embedding, "tester".to_string(), None)
639 .with_consolidation_policy(policy)
640 }
641
642 #[tokio::test]
643 async fn fixed_size_policy_consolidates_unconditionally() {
644 let engine = build_test_engine(ConsolidationPolicy::FixedSize);
645 seed_two_clusters(&engine, "tester").await;
646 let result = run_consolidation(&engine, "tester", 2)
647 .await
648 .expect("run_consolidation");
649 assert_eq!(result.clusters_found, 2);
650 assert_eq!(result.new_memories_created, 2);
651 assert_eq!(result.clusters_skipped_below_threshold, 0);
652 }
653
654 #[tokio::test]
655 async fn maturity_policy_skips_below_threshold() {
656 let policy = MaturityPolicy {
659 weights: MaturityWeights::balanced(),
660 saturation: MaturitySaturation::default(),
661 threshold: 0.95,
662 min_cluster_size_floor: 2,
663 trigger_on_forget: false,
664 trigger_on_checkpoint: false,
665 };
666 let engine = build_test_engine(ConsolidationPolicy::MaturityDriven(policy));
667 seed_two_clusters(&engine, "tester").await;
668 let result = run_consolidation(&engine, "tester", 2)
669 .await
670 .expect("run_consolidation");
671 assert_eq!(result.clusters_found, 0);
672 assert_eq!(result.new_memories_created, 0);
673 assert_eq!(result.clusters_skipped_below_threshold, 2);
674 }
675
676 #[tokio::test]
677 async fn maturity_policy_consolidates_when_threshold_clears() {
678 let policy = MaturityPolicy {
680 weights: MaturityWeights::balanced(),
681 saturation: MaturitySaturation::default(),
682 threshold: 0.0,
683 min_cluster_size_floor: 2,
684 trigger_on_forget: false,
685 trigger_on_checkpoint: false,
686 };
687 let engine = build_test_engine(ConsolidationPolicy::MaturityDriven(policy));
688 seed_two_clusters(&engine, "tester").await;
689 let result = run_consolidation(&engine, "tester", 2)
690 .await
691 .expect("run_consolidation");
692 assert_eq!(result.clusters_found, 2);
693 assert_eq!(result.new_memories_created, 2);
694 assert_eq!(result.clusters_skipped_below_threshold, 0);
695 }
696
697 #[tokio::test]
698 async fn maturity_policy_respects_min_cluster_floor() {
699 let policy = MaturityPolicy {
701 weights: MaturityWeights::balanced(),
702 saturation: MaturitySaturation::default(),
703 threshold: 0.0,
704 min_cluster_size_floor: 5,
705 trigger_on_forget: false,
706 trigger_on_checkpoint: false,
707 };
708 let engine = build_test_engine(ConsolidationPolicy::MaturityDriven(policy));
709 seed_two_clusters(&engine, "tester").await;
710 let result = run_consolidation(&engine, "tester", 2)
711 .await
712 .expect("run_consolidation");
713 assert_eq!(result.clusters_found, 0);
714 assert_eq!(result.new_memories_created, 0);
715 assert_eq!(result.clusters_skipped_below_threshold, 0);
717 }
718}