1use 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#[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
23struct 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
56struct 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; }
87 }
88 0.95 }
90
91 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
120const MAX_ADJUSTMENT_FACTOR: f64 = 0.5;
122
123const RECALL_WINDOW: usize = 100;
125
126pub 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 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 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 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 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 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 fn evaluate(state: &mut EvolutionState) {
222 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 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 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 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
319fn 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 let low_scores: Vec<f64> = (0..100).map(|i| i as f64 * 0.002).collect(); 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 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 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 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 let metrics = engine.get_metrics();
440 assert!(metrics.parameter_adjustments.is_empty());
441 assert!(metrics.detected_patterns.is_empty());
442
443 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 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); histogram.observe(0.05); histogram.observe(0.15); histogram.observe(0.95); 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 rolling.push(4.0);
493 assert_eq!(rolling.len(), 3);
494 assert!((rolling.average().unwrap() - 3.0).abs() < 1e-10); }
496
497 #[test]
498 fn over_pruning_detected() {
499 let engine = EvolutionEngine::new();
500
501 let mut scores = vec![0.05; 60]; scores.extend(vec![0.5; 40]); 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 let low_scores: Vec<f64> = vec![0.05; 100];
529 engine.observe_decay_tick(StoreType::Episodic, &low_scores);
530
531 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 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 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 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}