Skip to main content

cerememory_evolution/
lib.rs

1//! Cerememory evolution engine — self-tuning decay and recall parameters.
2//!
3//! Accumulates statistics from decay ticks and recall operations, then
4//! applies rule-based adjustments to keep the system performing optimally.
5//! All adjustments are capped at ±50% of the default value.
6
7use parking_lot::Mutex;
8use std::collections::{HashMap, VecDeque};
9
10use cerememory_core::protocol::{EvolutionMetrics, ParameterAdjustment};
11use cerememory_core::types::StoreType;
12use serde::{Deserialize, Serialize};
13
14/// Default decay parameters per store type.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct StoreDecayDefaults {
17    pub decay_exponent: f64,
18    pub retrieval_boost: f64,
19    pub interference_rate: f64,
20    pub prune_threshold: f64,
21}
22
23/// Rolling average with a fixed window size backed by a ring buffer.
24struct RollingAverage {
25    values: VecDeque<f64>,
26    window: usize,
27}
28
29impl RollingAverage {
30    fn new(window: usize) -> Self {
31        Self {
32            values: VecDeque::with_capacity(window),
33            window,
34        }
35    }
36
37    fn push(&mut self, value: f64) {
38        if self.values.len() >= self.window {
39            self.values.pop_front();
40        }
41        self.values.push_back(value);
42    }
43
44    fn average(&self) -> Option<f64> {
45        if self.values.is_empty() {
46            return None;
47        }
48        Some(self.values.iter().sum::<f64>() / self.values.len() as f64)
49    }
50
51    fn len(&self) -> usize {
52        self.values.len()
53    }
54}
55
56/// Fidelity histogram with 10 buckets (0.0-0.1, 0.1-0.2, ..., 0.9-1.0).
57struct FidelityHistogram {
58    buckets: [u64; 10],
59    total: u64,
60}
61
62impl FidelityHistogram {
63    fn new() -> Self {
64        Self {
65            buckets: [0; 10],
66            total: 0,
67        }
68    }
69
70    fn observe(&mut self, fidelity: f64) {
71        let idx = ((fidelity * 10.0).floor() as usize).min(9);
72        self.buckets[idx] += 1;
73        self.total += 1;
74    }
75
76    fn median_bucket(&self) -> f64 {
77        if self.total == 0 {
78            return 0.5;
79        }
80        let mut cumulative = 0u64;
81        let half = self.total / 2;
82        for (i, &count) in self.buckets.iter().enumerate() {
83            cumulative += count;
84            if cumulative > half {
85                return (i as f64 + 0.5) / 10.0; // midpoint of bucket
86            }
87        }
88        0.95 // all in last bucket
89    }
90
91    /// Fraction of records in the lowest bucket (0.0-0.1).
92    fn lowest_bucket_fraction(&self) -> f64 {
93        if self.total == 0 {
94            return 0.0;
95        }
96        self.buckets[0] as f64 / self.total as f64
97    }
98}
99
100struct EvolutionState {
101    fidelity_histograms: HashMap<StoreType, FidelityHistogram>,
102    recall_hit_rates: HashMap<StoreType, RollingAverage>,
103    adjusted_params: HashMap<StoreType, StoreDecayDefaults>,
104    adjustments: Vec<ParameterAdjustment>,
105    detected_patterns: Vec<String>,
106}
107
108impl EvolutionState {
109    fn new() -> Self {
110        Self {
111            fidelity_histograms: HashMap::new(),
112            recall_hit_rates: HashMap::new(),
113            adjusted_params: HashMap::new(),
114            adjustments: Vec::new(),
115            detected_patterns: Vec::new(),
116        }
117    }
118}
119
120/// Maximum adjustment factor (±50% of default value).
121const MAX_ADJUSTMENT_FACTOR: f64 = 0.5;
122
123/// Rolling average window for recall hit rates.
124const RECALL_WINDOW: usize = 100;
125
126/// Self-tuning evolution engine that adjusts decay and recall parameters
127/// based on observed system behaviour.
128pub struct EvolutionEngine {
129    state: Mutex<EvolutionState>,
130}
131
132impl EvolutionEngine {
133    pub fn new() -> Self {
134        Self {
135            state: Mutex::new(EvolutionState::new()),
136        }
137    }
138
139    /// Get default decay parameters for a store type (static defaults).
140    fn static_defaults(store_type: StoreType) -> StoreDecayDefaults {
141        match store_type {
142            StoreType::Episodic => StoreDecayDefaults {
143                decay_exponent: 0.3,
144                retrieval_boost: 1.5,
145                interference_rate: 0.1,
146                prune_threshold: 0.01,
147            },
148            StoreType::Semantic => StoreDecayDefaults {
149                decay_exponent: 0.15,
150                retrieval_boost: 2.0,
151                interference_rate: 0.05,
152                prune_threshold: 0.005,
153            },
154            StoreType::Procedural => StoreDecayDefaults {
155                decay_exponent: 0.1,
156                retrieval_boost: 2.5,
157                interference_rate: 0.02,
158                prune_threshold: 0.001,
159            },
160            StoreType::Emotional => StoreDecayDefaults {
161                decay_exponent: 0.2,
162                retrieval_boost: 1.8,
163                interference_rate: 0.08,
164                prune_threshold: 0.01,
165            },
166            StoreType::Working => StoreDecayDefaults {
167                decay_exponent: 0.8,
168                retrieval_boost: 1.0,
169                interference_rate: 0.3,
170                prune_threshold: 0.1,
171            },
172        }
173    }
174
175    /// Get current (possibly adjusted) decay parameters for a store type.
176    pub fn get_decay_defaults(&self, store_type: StoreType) -> StoreDecayDefaults {
177        let state = self.state.lock();
178        state
179            .adjusted_params
180            .get(&store_type)
181            .cloned()
182            .unwrap_or_else(|| Self::static_defaults(store_type))
183    }
184
185    /// Observe fidelity scores from a decay tick for a specific store.
186    pub fn observe_decay_tick(&self, store: StoreType, fidelity_scores: &[f64]) {
187        let mut state = self.state.lock();
188        let histogram = state
189            .fidelity_histograms
190            .entry(store)
191            .or_insert_with(FidelityHistogram::new);
192        for &score in fidelity_scores {
193            histogram.observe(score);
194        }
195        Self::evaluate(&mut state);
196    }
197
198    /// Observe recall hit rate for a specific store.
199    /// hit_rate: fraction of recall results that had relevance > 0 (0.0-1.0).
200    pub fn observe_recall(&self, store: StoreType, hit_rate: f64) {
201        let mut state = self.state.lock();
202        let rolling = state
203            .recall_hit_rates
204            .entry(store)
205            .or_insert_with(|| RollingAverage::new(RECALL_WINDOW));
206        rolling.push(hit_rate);
207        Self::evaluate(&mut state);
208    }
209
210    /// Get current evolution metrics.
211    pub fn get_metrics(&self) -> EvolutionMetrics {
212        let state = self.state.lock();
213        EvolutionMetrics {
214            parameter_adjustments: state.adjustments.clone(),
215            detected_patterns: state.detected_patterns.clone(),
216            schema_adaptations: Vec::new(),
217        }
218    }
219
220    /// Rule-based evaluation and parameter adjustment.
221    fn evaluate(state: &mut EvolutionState) {
222        // Clear previous adjustments and patterns
223        state.adjustments.clear();
224        state.detected_patterns.clear();
225
226        for store_type in [
227            StoreType::Episodic,
228            StoreType::Semantic,
229            StoreType::Procedural,
230            StoreType::Emotional,
231        ] {
232            let defaults = Self::static_defaults(store_type);
233            let mut adjusted = state
234                .adjusted_params
235                .get(&store_type)
236                .cloned()
237                .unwrap_or_else(|| defaults.clone());
238
239            // Rule 1: If median fidelity < 0.3, decay is too aggressive — reduce exponent by 10%
240            if let Some(histogram) = state.fidelity_histograms.get(&store_type) {
241                let median = histogram.median_bucket();
242                if median < 0.3 {
243                    let new_val =
244                        clamp_adjustment(adjusted.decay_exponent * 0.9, defaults.decay_exponent);
245                    if (new_val - adjusted.decay_exponent).abs() > 1e-10 {
246                        state.adjustments.push(ParameterAdjustment {
247                            store: store_type,
248                            parameter: "decay_exponent".to_string(),
249                            original_value: defaults.decay_exponent,
250                            current_value: new_val,
251                            reason: format!(
252                                "Median fidelity {median:.2} < 0.3: decay too aggressive"
253                            ),
254                        });
255                        state.detected_patterns.push(format!(
256                            "{store_type}: low median fidelity ({median:.2}), reducing decay"
257                        ));
258                        adjusted.decay_exponent = new_val;
259                    }
260                }
261
262                // Rule 3: If >50% of records are in the lowest fidelity bucket, pruning may be too aggressive
263                let lowest_frac = histogram.lowest_bucket_fraction();
264                if lowest_frac > 0.5 {
265                    let new_val =
266                        clamp_adjustment(adjusted.prune_threshold * 0.9, defaults.prune_threshold);
267                    if (new_val - adjusted.prune_threshold).abs() > 1e-10 {
268                        state.adjustments.push(ParameterAdjustment {
269                            store: store_type,
270                            parameter: "prune_threshold".to_string(),
271                            original_value: defaults.prune_threshold,
272                            current_value: new_val,
273                            reason: format!(
274                                "{:.0}% in lowest fidelity bucket: over-pruning",
275                                lowest_frac * 100.0
276                            ),
277                        });
278                        state.detected_patterns.push(format!(
279                            "{store_type}: over-pruning detected ({:.0}% in lowest bucket)",
280                            lowest_frac * 100.0
281                        ));
282                        adjusted.prune_threshold = new_val;
283                    }
284                }
285            }
286
287            // Rule 2: If recall hit rate < 0.2, retrieval is insufficient — increase boost by 10%
288            if let Some(rolling) = state.recall_hit_rates.get(&store_type) {
289                if let Some(avg_hit_rate) = rolling.average() {
290                    if avg_hit_rate < 0.2 && rolling.len() >= 5 {
291                        let new_val = clamp_adjustment(
292                            adjusted.retrieval_boost * 1.1,
293                            defaults.retrieval_boost,
294                        );
295                        if (new_val - adjusted.retrieval_boost).abs() > 1e-10 {
296                            state.adjustments.push(ParameterAdjustment {
297                                store: store_type,
298                                parameter: "retrieval_boost".to_string(),
299                                original_value: defaults.retrieval_boost,
300                                current_value: new_val,
301                                reason: format!(
302                                    "Recall hit rate {avg_hit_rate:.2} < 0.2: retrieval insufficient"
303                                ),
304                            });
305                            state.detected_patterns.push(format!(
306                                "{store_type}: low recall hit rate ({avg_hit_rate:.2}), increasing boost"
307                            ));
308                            adjusted.retrieval_boost = new_val;
309                        }
310                    }
311                }
312            }
313
314            state.adjusted_params.insert(store_type, adjusted);
315        }
316    }
317}
318
319/// Clamp an adjusted value to within ±50% of the default value.
320fn clamp_adjustment(value: f64, default: f64) -> f64 {
321    let min = default * (1.0 - MAX_ADJUSTMENT_FACTOR);
322    let max = default * (1.0 + MAX_ADJUSTMENT_FACTOR);
323    value.clamp(min, max)
324}
325
326impl Default for EvolutionEngine {
327    fn default() -> Self {
328        Self::new()
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn default_params_no_observations() {
338        let engine = EvolutionEngine::new();
339        let episodic = engine.get_decay_defaults(StoreType::Episodic);
340        assert_eq!(episodic.decay_exponent, 0.3);
341        assert_eq!(episodic.retrieval_boost, 1.5);
342        assert_eq!(episodic.interference_rate, 0.1);
343        assert_eq!(episodic.prune_threshold, 0.01);
344
345        let semantic = engine.get_decay_defaults(StoreType::Semantic);
346        assert!(semantic.decay_exponent < episodic.decay_exponent);
347    }
348
349    #[test]
350    fn low_fidelity_reduces_exponent() {
351        let engine = EvolutionEngine::new();
352        let original = engine
353            .get_decay_defaults(StoreType::Episodic)
354            .decay_exponent;
355
356        // Feed many low-fidelity scores (all below 0.3)
357        let low_scores: Vec<f64> = (0..100).map(|i| i as f64 * 0.002).collect(); // 0.0 to 0.198
358        engine.observe_decay_tick(StoreType::Episodic, &low_scores);
359
360        let adjusted = engine
361            .get_decay_defaults(StoreType::Episodic)
362            .decay_exponent;
363        assert!(
364            adjusted < original,
365            "Expected decay_exponent to decrease: original={original}, adjusted={adjusted}"
366        );
367    }
368
369    #[test]
370    fn high_fidelity_no_change() {
371        let engine = EvolutionEngine::new();
372        let original = engine
373            .get_decay_defaults(StoreType::Episodic)
374            .decay_exponent;
375
376        // Feed high-fidelity scores (all above 0.7)
377        let high_scores: Vec<f64> = (0..100).map(|i| 0.7 + i as f64 * 0.003).collect();
378        engine.observe_decay_tick(StoreType::Episodic, &high_scores);
379
380        let adjusted = engine
381            .get_decay_defaults(StoreType::Episodic)
382            .decay_exponent;
383        assert_eq!(
384            adjusted, original,
385            "High fidelity should not change decay_exponent"
386        );
387    }
388
389    #[test]
390    fn low_recall_increases_boost() {
391        let engine = EvolutionEngine::new();
392        let original = engine
393            .get_decay_defaults(StoreType::Semantic)
394            .retrieval_boost;
395
396        // Feed low hit rates (need >= 5 observations)
397        for _ in 0..10 {
398            engine.observe_recall(StoreType::Semantic, 0.1);
399        }
400
401        let adjusted = engine
402            .get_decay_defaults(StoreType::Semantic)
403            .retrieval_boost;
404        assert!(
405            adjusted > original,
406            "Expected retrieval_boost to increase: original={original}, adjusted={adjusted}"
407        );
408    }
409
410    #[test]
411    fn adjustment_capped_50pct() {
412        let engine = EvolutionEngine::new();
413        let original_boost = EvolutionEngine::static_defaults(StoreType::Semantic).retrieval_boost;
414
415        // Feed extreme low hit rates many times to try to push boost beyond 50%
416        for _ in 0..500 {
417            engine.observe_recall(StoreType::Semantic, 0.0);
418        }
419
420        let adjusted = engine
421            .get_decay_defaults(StoreType::Semantic)
422            .retrieval_boost;
423        let max_allowed = original_boost * 1.5;
424        assert!(
425            adjusted <= max_allowed + 1e-10,
426            "Retrieval boost {adjusted} should not exceed 150% of default {max_allowed}"
427        );
428        assert!(
429            adjusted >= original_boost * 0.5 - 1e-10,
430            "Retrieval boost {adjusted} should not go below 50% of default"
431        );
432    }
433
434    #[test]
435    fn metrics_records_adjustments() {
436        let engine = EvolutionEngine::new();
437
438        // Initially no adjustments
439        let metrics = engine.get_metrics();
440        assert!(metrics.parameter_adjustments.is_empty());
441        assert!(metrics.detected_patterns.is_empty());
442
443        // Trigger an adjustment with low fidelity
444        let low_scores: Vec<f64> = vec![0.05; 100];
445        engine.observe_decay_tick(StoreType::Episodic, &low_scores);
446
447        let metrics = engine.get_metrics();
448        assert!(
449            !metrics.parameter_adjustments.is_empty(),
450            "Should have parameter adjustments after low fidelity"
451        );
452        assert!(
453            !metrics.detected_patterns.is_empty(),
454            "Should have detected patterns after low fidelity"
455        );
456
457        // Verify adjustment details
458        let adj = &metrics.parameter_adjustments[0];
459        assert_eq!(adj.store, StoreType::Episodic);
460        assert!(!adj.reason.is_empty());
461    }
462
463    #[test]
464    fn histogram_accumulates() {
465        let mut histogram = FidelityHistogram::new();
466        assert_eq!(histogram.total, 0);
467        assert_eq!(histogram.median_bucket(), 0.5); // default when empty
468
469        histogram.observe(0.05); // bucket 0
470        histogram.observe(0.15); // bucket 1
471        histogram.observe(0.95); // bucket 9
472
473        assert_eq!(histogram.total, 3);
474        assert_eq!(histogram.buckets[0], 1);
475        assert_eq!(histogram.buckets[1], 1);
476        assert_eq!(histogram.buckets[9], 1);
477    }
478
479    #[test]
480    fn rolling_average_windowed() {
481        let mut rolling = RollingAverage::new(3);
482        assert_eq!(rolling.average(), None);
483        assert_eq!(rolling.len(), 0);
484
485        rolling.push(1.0);
486        rolling.push(2.0);
487        rolling.push(3.0);
488        assert_eq!(rolling.len(), 3);
489        assert!((rolling.average().unwrap() - 2.0).abs() < 1e-10);
490
491        // Push a 4th value — oldest (1.0) should be evicted
492        rolling.push(4.0);
493        assert_eq!(rolling.len(), 3);
494        assert!((rolling.average().unwrap() - 3.0).abs() < 1e-10); // (2+3+4)/3 = 3.0
495    }
496
497    #[test]
498    fn over_pruning_detected() {
499        let engine = EvolutionEngine::new();
500
501        // Feed >50% scores in the lowest bucket (0.0-0.1)
502        let mut scores = vec![0.05; 60]; // 60 in lowest bucket
503        scores.extend(vec![0.5; 40]); // 40 in middle bucket
504        engine.observe_decay_tick(StoreType::Procedural, &scores);
505
506        let metrics = engine.get_metrics();
507        let has_prune_adjustment = metrics
508            .parameter_adjustments
509            .iter()
510            .any(|a| a.parameter == "prune_threshold" && a.store == StoreType::Procedural);
511        assert!(
512            has_prune_adjustment,
513            "Should detect over-pruning when >50% in lowest bucket"
514        );
515
516        let has_pattern = metrics
517            .detected_patterns
518            .iter()
519            .any(|p| p.contains("over-pruning"));
520        assert!(has_pattern, "Should have over-pruning pattern detected");
521    }
522
523    #[test]
524    fn multi_store_independent() {
525        let engine = EvolutionEngine::new();
526
527        // Trigger adjustment only for Episodic
528        let low_scores: Vec<f64> = vec![0.05; 100];
529        engine.observe_decay_tick(StoreType::Episodic, &low_scores);
530
531        // Semantic should still have default values
532        let semantic = engine.get_decay_defaults(StoreType::Semantic);
533        let semantic_default = EvolutionEngine::static_defaults(StoreType::Semantic);
534        assert_eq!(semantic.decay_exponent, semantic_default.decay_exponent);
535        assert_eq!(semantic.retrieval_boost, semantic_default.retrieval_boost);
536
537        // Episodic should be adjusted
538        let episodic = engine.get_decay_defaults(StoreType::Episodic);
539        let episodic_default = EvolutionEngine::static_defaults(StoreType::Episodic);
540        assert!(episodic.decay_exponent < episodic_default.decay_exponent);
541    }
542
543    #[test]
544    fn recall_requires_minimum_observations() {
545        let engine = EvolutionEngine::new();
546        let original = engine
547            .get_decay_defaults(StoreType::Episodic)
548            .retrieval_boost;
549
550        // Feed only 3 low hit rates (below the 5 minimum)
551        for _ in 0..3 {
552            engine.observe_recall(StoreType::Episodic, 0.1);
553        }
554
555        let adjusted = engine
556            .get_decay_defaults(StoreType::Episodic)
557            .retrieval_boost;
558        assert_eq!(
559            adjusted, original,
560            "Should not adjust with fewer than 5 recall observations"
561        );
562
563        // Now add 2 more to reach threshold
564        for _ in 0..2 {
565            engine.observe_recall(StoreType::Episodic, 0.1);
566        }
567
568        let adjusted = engine
569            .get_decay_defaults(StoreType::Episodic)
570            .retrieval_boost;
571        assert!(
572            adjusted > original,
573            "Should adjust after reaching 5 recall observations"
574        );
575    }
576}