Skip to main content

mnemo_core/query/
maturity.rs

1//! Feedback-driven consolidation trigger metric.
2//!
3//! Opt-in scalar "maturity / generalizability" score per memory cluster
4//! that gates [`crate::query::lifecycle::run_consolidation`] when the
5//! engine is configured with
6//! [`ConsolidationPolicy::MaturityDriven`]. The fixed-schedule /
7//! min-cluster-size behaviour remains the default — this module is
8//! purely additive.
9//!
10//! # Score
11//!
12//! Four components, each normalised to `[0, 1]` and combined as a
13//! weight-normalised sum so a zeroed weight contributes nothing rather
14//! than re-weighting the rest:
15//!
16//! - `recency` — mean access-recency across the cluster, decayed by
17//!   `recency_half_life_hours`. A cluster whose members were touched
18//!   recently scores higher.
19//! - `hit_success` — mean `access_count`, log-scaled to `[0, 1]` via
20//!   `ln(1 + n) / ln(1 + hit_saturation)`.
21//! - `edge_degree` — mean (incoming + outgoing) graph-relation count
22//!   per cluster member, normalised by `degree_saturation`.
23//! - `redundancy` — mean pairwise cosine similarity of cluster
24//!   embeddings (skipped when fewer than two members have embeddings,
25//!   in which case the component is `0.5` — neutral, neither helpful
26//!   nor harmful).
27//!
28//! Consolidation fires when `combined >= threshold` AND the cluster has
29//! at least `min_cluster_size_floor` members.
30//!
31//! # Prior art
32//!
33//! Internal note (not user-facing): FluxMem (arXiv:2605.28773) frames
34//! consolidation as a feedback-driven control loop with a scalar
35//! maturity score gating the trigger. mnemo's policy is a structural
36//! cousin — same four-axis intuition (recency, hit-feedback, graph
37//! degree, neighbour redundancy) wired into the existing tag-overlap
38//! clusterer rather than a learned cluster-er. Cited as prior art only.
39
40use serde::{Deserialize, Serialize};
41
42use crate::error::Result;
43use crate::model::memory::MemoryRecord;
44use crate::query::MnemoEngine;
45
46/// Per-component weights for the maturity score.
47///
48/// All weights are clamped to `[0, 1]`. The combined score normalises
49/// by the sum of weights, so setting a weight to `0` disables that
50/// component without inflating the others.
51#[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    /// Balanced default: hit-success leads, recency + redundancy
61    /// roughly equal, edge-degree lower because it depends on relation
62    /// curation which is operator-driven.
63    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/// Per-component maturity score breakdown, returned alongside the
93/// combined score for observability.
94#[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/// Tunable saturation knobs for the four normalised components. Defaults
104/// are chosen so a "typical" working set lands near `0.5` on each axis.
105#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
106pub struct MaturitySaturation {
107    /// Hours of half-life used by the recency exponential. A mean
108    /// access-age of `recency_half_life_hours` maps to `0.5`.
109    pub recency_half_life_hours: f32,
110    /// Log saturation point for `hit_success`. A mean `access_count` of
111    /// `hit_saturation` maps to `1.0`.
112    pub hit_saturation: f32,
113    /// Mean (in + out) edges per record that map to `1.0`.
114    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/// Feedback-driven consolidation policy. The cluster is consolidated
128/// iff `combined >= threshold` and it has at least
129/// `min_cluster_size_floor` members.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
131pub struct MaturityPolicy {
132    pub weights: MaturityWeights,
133    pub saturation: MaturitySaturation,
134    /// Combined-score gate, in `[0, 1]`.
135    pub threshold: f32,
136    /// Minimum cluster size still enforced even when the metric clears
137    /// the threshold. Acts as a hard floor against pathological 1-record
138    /// "clusters".
139    pub min_cluster_size_floor: usize,
140    /// Run consolidation opportunistically after every successful
141    /// `forget::execute`. Best-effort; failures are logged, never
142    /// propagated to the forget caller.
143    pub trigger_on_forget: bool,
144    /// Run consolidation opportunistically after every successful
145    /// `checkpoint::execute`. Best-effort; failures are logged.
146    pub trigger_on_checkpoint: bool,
147}
148
149impl MaturityPolicy {
150    /// Conservative starter policy: balanced weights, default
151    /// saturations, threshold = 0.55, floor = 2, hook only on forget.
152    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/// Policy for [`crate::query::lifecycle::run_consolidation`].
171///
172/// - `FixedSize` (default): preserves the v0.4.x behaviour — every
173///   tag-overlap cluster with at least `min_cluster_size` members is
174///   consolidated unconditionally.
175/// - `MaturityDriven`: the additional feedback-driven trigger metric
176///   from this module gates each cluster.
177#[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
185/// Compute the per-cluster maturity breakdown.
186///
187/// Returns `None` for empty clusters (caller should never invoke on
188/// one, but we degrade gracefully).
189pub 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    // Decay constant chosen so age == half_life maps to 0.5.
242    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        // Neutral when we cannot measure: do not punish or reward.
296        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        // cosine ∈ [-1, 1]; clamp to [0, 1] so anti-correlated pairs
313        // count as zero redundancy rather than negative.
314        (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        // At t == half_life the score should be ~0.5.
388        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        // Single record → neutral 0.5.
415        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        // Only hit_success is weighted; other components must not pull
444        // the combined score down.
445        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}