Skip to main content

mnemo_core/query/
lifecycle.rs

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/// Custom decay function types.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "snake_case")]
15pub enum DecayFunction {
16    /// Exponential: base * e^(-rate * hours)  (default, Ebbinghaus-inspired)
17    Exponential,
18    /// Linear: base * max(0, 1 - rate * hours)
19    Linear,
20    /// Step function: base importance until threshold hours, then 0
21    StepFunction(f32),
22    /// Power law: base / (1 + rate * hours)^alpha
23    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
42/// Compute effective importance using the specified or default decay curve.
43/// Default (Exponential): `base_importance * e^(-decay_rate * hours) + 0.05 * ln(1 + access_count)`
44pub 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
105/// Run a decay pass over all active memories for the given agent.
106/// Memories below `forget_threshold` are marked Forgotten.
107/// Memories below `archive_threshold` (but above forget) are marked Archived.
108pub 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    /// v0.4.10 — number of clusters skipped because the engine has a
164    /// [`crate::query::maturity::ConsolidationPolicy::MaturityDriven`]
165    /// policy attached and the cluster's combined maturity score did
166    /// not clear the configured threshold. Always zero under the
167    /// default `FixedSize` policy.
168    #[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
187/// Consolidate episodic memories into semantic summaries.
188///
189/// Clusters by tag overlap. Whether a cluster is actually consolidated
190/// depends on the engine's
191/// [`crate::query::maturity::ConsolidationPolicy`]:
192///
193/// - `FixedSize` (default): every cluster with at least
194///   `min_cluster_size` members is consolidated unconditionally — the
195///   v0.4.x behaviour.
196/// - `MaturityDriven`: a cluster is consolidated iff its combined
197///   maturity score `>=` the policy's `threshold` AND it has at least
198///   `max(min_cluster_size, policy.min_cluster_size_floor)` members.
199pub 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    // Only consider memories that are Raw or Active
216    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    // Cluster by tag overlap: group memories sharing at least one tag
225    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            // Check if this record shares any tag with any record in cluster
231            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    // v0.4.10 — read the per-engine consolidation policy once. Default
251    // FixedSize preserves the legacy unconditional path.
252    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        // Feedback-driven trigger gate: skip the cluster when its
266        // combined maturity score does not clear the configured
267        // threshold. FixedSize policy never enters this branch.
268        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        // Create a consolidated semantic memory
295        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        // Create relations and mark originals as consolidated
368        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/// Report from a single TTL sweep pass.
401#[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
423/// Hard-delete every memory whose `expires_at` is in the past.
424///
425/// Each deletion emits an `EventType::MemoryExpired` audit event so the chain
426/// records which memories were purged and when. The function is idempotent
427/// under concurrent callers (storage deletes of an already-absent row surface
428/// as a storage error and are reported, not retried).
429pub 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        // Fresh memory with high importance
541        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        // Fresh memory should be close to base importance
576        assert!(
577            eff > 0.7,
578            "effective importance {eff} should be > 0.7 for fresh memory"
579        );
580
581        // Old memory with high decay rate
582        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        // Access count boosts importance
596        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        // Cluster A: 3 records sharing tag "topic-a".
619        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        // Cluster B: 3 records sharing tag "topic-b".
626        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        // Threshold high enough that fresh, never-accessed, edge-less
657        // clusters with NoopEmbedding will never clear it.
658        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        // Threshold of 0.0 means every non-degenerate cluster passes.
679        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        // floor = 5 — clusters of 3 must be rejected on size alone.
700        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        // Skipped on size, not on threshold — the size check runs first.
716        assert_eq!(result.clusters_skipped_below_threshold, 0);
717    }
718}