1use serde::{Deserialize, Serialize};
41
42use crate::error::Result;
43use crate::model::memory::MemoryRecord;
44use crate::query::MnemoEngine;
45
46#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
52pub struct MaturityWeights {
53 pub recency: f32,
54 pub hit_success: f32,
55 pub edge_degree: f32,
56 pub redundancy: f32,
57}
58
59impl MaturityWeights {
60 pub const fn balanced() -> Self {
64 Self {
65 recency: 0.25,
66 hit_success: 0.30,
67 edge_degree: 0.20,
68 redundancy: 0.25,
69 }
70 }
71
72 fn clamped(self) -> Self {
73 Self {
74 recency: self.recency.clamp(0.0, 1.0),
75 hit_success: self.hit_success.clamp(0.0, 1.0),
76 edge_degree: self.edge_degree.clamp(0.0, 1.0),
77 redundancy: self.redundancy.clamp(0.0, 1.0),
78 }
79 }
80
81 fn sum(self) -> f32 {
82 self.recency + self.hit_success + self.edge_degree + self.redundancy
83 }
84}
85
86impl Default for MaturityWeights {
87 fn default() -> Self {
88 Self::balanced()
89 }
90}
91
92#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
95pub struct MaturityBreakdown {
96 pub recency: f32,
97 pub hit_success: f32,
98 pub edge_degree: f32,
99 pub redundancy: f32,
100 pub combined: f32,
101}
102
103#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
106pub struct MaturitySaturation {
107 pub recency_half_life_hours: f32,
110 pub hit_saturation: f32,
113 pub degree_saturation: f32,
115}
116
117impl Default for MaturitySaturation {
118 fn default() -> Self {
119 Self {
120 recency_half_life_hours: 72.0,
121 hit_saturation: 8.0,
122 degree_saturation: 6.0,
123 }
124 }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
131pub struct MaturityPolicy {
132 pub weights: MaturityWeights,
133 pub saturation: MaturitySaturation,
134 pub threshold: f32,
136 pub min_cluster_size_floor: usize,
140 pub trigger_on_forget: bool,
144 pub trigger_on_checkpoint: bool,
147}
148
149impl MaturityPolicy {
150 pub fn balanced() -> Self {
153 Self {
154 weights: MaturityWeights::balanced(),
155 saturation: MaturitySaturation::default(),
156 threshold: 0.55,
157 min_cluster_size_floor: 2,
158 trigger_on_forget: true,
159 trigger_on_checkpoint: false,
160 }
161 }
162}
163
164impl Default for MaturityPolicy {
165 fn default() -> Self {
166 Self::balanced()
167 }
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
178#[serde(tag = "kind", rename_all = "snake_case")]
179pub enum ConsolidationPolicy {
180 #[default]
181 FixedSize,
182 MaturityDriven(MaturityPolicy),
183}
184
185pub async fn compute_cluster_maturity(
190 engine: &MnemoEngine,
191 cluster: &[&MemoryRecord],
192 weights: MaturityWeights,
193 saturation: MaturitySaturation,
194) -> Result<Option<MaturityBreakdown>> {
195 if cluster.is_empty() {
196 return Ok(None);
197 }
198 let weights = weights.clamped();
199 let recency = recency_component(cluster, saturation.recency_half_life_hours);
200 let hit_success = hit_success_component(cluster, saturation.hit_saturation);
201 let edge_degree = edge_degree_component(engine, cluster, saturation.degree_saturation).await?;
202 let redundancy = redundancy_component(cluster);
203
204 let combined = combined_score(
205 weights,
206 MaturityBreakdown {
207 recency,
208 hit_success,
209 edge_degree,
210 redundancy,
211 combined: 0.0,
212 },
213 );
214
215 Ok(Some(MaturityBreakdown {
216 recency,
217 hit_success,
218 edge_degree,
219 redundancy,
220 combined,
221 }))
222}
223
224fn combined_score(weights: MaturityWeights, b: MaturityBreakdown) -> f32 {
225 let total = weights.sum();
226 if total <= f32::EPSILON {
227 return 0.0;
228 }
229 let mixed = weights.recency * b.recency
230 + weights.hit_success * b.hit_success
231 + weights.edge_degree * b.edge_degree
232 + weights.redundancy * b.redundancy;
233 (mixed / total).clamp(0.0, 1.0)
234}
235
236fn recency_component(cluster: &[&MemoryRecord], half_life_hours: f32) -> f32 {
237 if cluster.is_empty() {
238 return 0.0;
239 }
240 let half_life = half_life_hours.max(f32::EPSILON);
241 let lambda = std::f32::consts::LN_2 / half_life;
243 let now = chrono::Utc::now();
244 let mut sum = 0.0_f32;
245 let mut n = 0_u32;
246 for r in cluster {
247 let last = r.last_accessed_at.as_deref().unwrap_or(&r.created_at);
248 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(last) {
249 let hours = (now - dt.with_timezone(&chrono::Utc)).num_seconds().max(0) as f32 / 3600.0;
250 sum += (-lambda * hours).exp();
251 n += 1;
252 }
253 }
254 if n == 0 { 0.0 } else { sum / n as f32 }
255}
256
257fn hit_success_component(cluster: &[&MemoryRecord], hit_saturation: f32) -> f32 {
258 if cluster.is_empty() {
259 return 0.0;
260 }
261 let sat = hit_saturation.max(1.0);
262 let denom = (1.0 + sat).ln().max(f32::EPSILON);
263 let mut sum = 0.0_f32;
264 for r in cluster {
265 sum += (1.0 + r.access_count as f32).ln() / denom;
266 }
267 (sum / cluster.len() as f32).clamp(0.0, 1.0)
268}
269
270async fn edge_degree_component(
271 engine: &MnemoEngine,
272 cluster: &[&MemoryRecord],
273 degree_saturation: f32,
274) -> Result<f32> {
275 if cluster.is_empty() {
276 return Ok(0.0);
277 }
278 let sat = degree_saturation.max(1.0);
279 let mut total_degree = 0_usize;
280 for r in cluster {
281 let outgoing = engine.storage.get_relations_from(r.id).await?.len();
282 let incoming = engine.storage.get_relations_to(r.id).await?.len();
283 total_degree += outgoing + incoming;
284 }
285 let mean = total_degree as f32 / cluster.len() as f32;
286 Ok((mean / sat).clamp(0.0, 1.0))
287}
288
289fn redundancy_component(cluster: &[&MemoryRecord]) -> f32 {
290 let with_emb: Vec<&Vec<f32>> = cluster
291 .iter()
292 .filter_map(|r| r.embedding.as_ref())
293 .collect();
294 if with_emb.len() < 2 {
295 return 0.5;
297 }
298 let mut sum = 0.0_f32;
299 let mut pairs = 0_u32;
300 for i in 0..with_emb.len() {
301 for j in (i + 1)..with_emb.len() {
302 if with_emb[i].len() != with_emb[j].len() || with_emb[i].is_empty() {
303 continue;
304 }
305 sum += cosine_similarity(with_emb[i], with_emb[j]);
306 pairs += 1;
307 }
308 }
309 if pairs == 0 {
310 0.5
311 } else {
312 (sum / pairs as f32).clamp(0.0, 1.0)
315 }
316}
317
318fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
319 let mut dot = 0.0_f32;
320 let mut na = 0.0_f32;
321 let mut nb = 0.0_f32;
322 for i in 0..a.len() {
323 dot += a[i] * b[i];
324 na += a[i] * a[i];
325 nb += b[i] * b[i];
326 }
327 let denom = (na.sqrt() * nb.sqrt()).max(f32::EPSILON);
328 dot / denom
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::embedding::NoopEmbedding;
335 use crate::index::usearch::UsearchIndex;
336 use crate::model::memory::{ConsolidationState, MemoryType, Scope, SourceType};
337 use crate::storage::duckdb::DuckDbStorage;
338 use std::sync::Arc;
339 use uuid::Uuid;
340
341 fn record(now: chrono::DateTime<chrono::Utc>, hours_ago: i64, access: u64) -> MemoryRecord {
342 let created = (now - chrono::Duration::hours(hours_ago)).to_rfc3339();
343 MemoryRecord {
344 id: Uuid::now_v7(),
345 agent_id: "a".to_string(),
346 content: "c".to_string(),
347 memory_type: MemoryType::Episodic,
348 scope: Scope::Private,
349 importance: 0.5,
350 tags: vec![],
351 metadata: serde_json::json!({}),
352 embedding: None,
353 content_hash: vec![],
354 prev_hash: None,
355 source_type: SourceType::Agent,
356 source_id: None,
357 consolidation_state: ConsolidationState::Raw,
358 access_count: access,
359 org_id: None,
360 thread_id: None,
361 created_at: created.clone(),
362 updated_at: created,
363 last_accessed_at: None,
364 expires_at: None,
365 deleted_at: None,
366 decay_rate: None,
367 created_by: None,
368 version: 1,
369 prev_version_id: None,
370 quarantined: false,
371 quarantine_reason: None,
372 decay_function: None,
373 }
374 }
375
376 #[test]
377 fn recency_decays_with_age() {
378 let now = chrono::Utc::now();
379 let fresh = record(now, 0, 0);
380 let stale = record(now, 200, 0);
381 let fresh_score = recency_component(&[&fresh], 72.0);
382 let stale_score = recency_component(&[&stale], 72.0);
383 assert!(
384 fresh_score > stale_score,
385 "fresh {fresh_score} > stale {stale_score}"
386 );
387 let half = record(now, 72, 0);
389 let half_score = recency_component(&[&half], 72.0);
390 assert!(
391 (half_score - 0.5).abs() < 0.05,
392 "half-life mapping: {half_score} ≈ 0.5"
393 );
394 }
395
396 #[test]
397 fn hit_success_saturates() {
398 let now = chrono::Utc::now();
399 let none = record(now, 0, 0);
400 let some = record(now, 0, 4);
401 let many = record(now, 0, 64);
402 let s0 = hit_success_component(&[&none], 8.0);
403 let s1 = hit_success_component(&[&some], 8.0);
404 let s2 = hit_success_component(&[&many], 8.0);
405 assert!(s0 < s1 && s1 <= s2);
406 assert!(s2 <= 1.0);
407 assert_eq!(s0, 0.0);
408 }
409
410 #[test]
411 fn redundancy_handles_short_input() {
412 let now = chrono::Utc::now();
413 let r = record(now, 0, 0);
414 let s = redundancy_component(&[&r]);
416 assert_eq!(s, 0.5);
417 }
418
419 #[test]
420 fn redundancy_detects_identical_embeddings() {
421 let now = chrono::Utc::now();
422 let mut a = record(now, 0, 0);
423 let mut b = record(now, 0, 0);
424 a.embedding = Some(vec![1.0, 0.0, 0.0]);
425 b.embedding = Some(vec![1.0, 0.0, 0.0]);
426 let s = redundancy_component(&[&a, &b]);
427 assert!((s - 1.0).abs() < 1e-5, "identical → 1.0, got {s}");
428 }
429
430 #[test]
431 fn redundancy_orthogonal_is_zero() {
432 let now = chrono::Utc::now();
433 let mut a = record(now, 0, 0);
434 let mut b = record(now, 0, 0);
435 a.embedding = Some(vec![1.0, 0.0]);
436 b.embedding = Some(vec![0.0, 1.0]);
437 let s = redundancy_component(&[&a, &b]);
438 assert!(s.abs() < 1e-5, "orthogonal → 0, got {s}");
439 }
440
441 #[test]
442 fn combined_normalises_by_weight_sum() {
443 let weights = MaturityWeights {
446 recency: 0.0,
447 hit_success: 1.0,
448 edge_degree: 0.0,
449 redundancy: 0.0,
450 };
451 let b = MaturityBreakdown {
452 recency: 0.0,
453 hit_success: 0.9,
454 edge_degree: 0.0,
455 redundancy: 0.0,
456 combined: 0.0,
457 };
458 let c = combined_score(weights, b);
459 assert!((c - 0.9).abs() < 1e-5, "expected 0.9, got {c}");
460 }
461
462 #[test]
463 fn combined_all_zero_weights_is_zero() {
464 let weights = MaturityWeights {
465 recency: 0.0,
466 hit_success: 0.0,
467 edge_degree: 0.0,
468 redundancy: 0.0,
469 };
470 let b = MaturityBreakdown {
471 recency: 1.0,
472 hit_success: 1.0,
473 edge_degree: 1.0,
474 redundancy: 1.0,
475 combined: 0.0,
476 };
477 assert_eq!(combined_score(weights, b), 0.0);
478 }
479
480 #[tokio::test]
481 async fn edge_degree_component_zero_when_no_relations() {
482 let storage = Arc::new(DuckDbStorage::open_in_memory().unwrap());
483 let index = Arc::new(UsearchIndex::new(3).unwrap());
484 let embedding = Arc::new(NoopEmbedding::new(3));
485 let engine = MnemoEngine::new(storage, index, embedding, "a".to_string(), None);
486 let r = record(chrono::Utc::now(), 0, 0);
487 engine.storage.insert_memory(&r).await.unwrap();
488 let score = edge_degree_component(&engine, &[&r], 6.0).await.unwrap();
489 assert_eq!(score, 0.0);
490 }
491}