Skip to main content

shodh_memory/memory/
replay.rs

1//! Memory Replay and Interference Module (SHO-105, SHO-106)
2//!
3//! This module implements biologically-inspired memory consolidation mechanisms:
4//!
5//! ## Memory Replay (SHO-105)
6//! Based on Rasch & Born (2013) - sleep consolidation research:
7//! - Hippocampus replays recent experiences during rest/sleep
8//! - Co-activation strengthens related memories and their associations
9//! - High-value memories (important + recent + emotional) get priority
10//!
11//! ## Memory Interference (SHO-106)
12//! Based on Anderson & Neely (1996) - retrieval competition:
13//! - Retroactive interference: new learning disrupts old memories
14//! - Proactive interference: old memories interfere with new learning
15//! - Similar memories compete during retrieval
16
17use crate::constants::{
18    INTERFERENCE_COMPETITION_FACTOR, INTERFERENCE_MAX_TRACKED, INTERFERENCE_PROACTIVE_DECAY,
19    INTERFERENCE_PROACTIVE_THRESHOLD, INTERFERENCE_RETROACTIVE_DECAY,
20    INTERFERENCE_SEVERE_THRESHOLD, INTERFERENCE_SIMILARITY_THRESHOLD,
21    INTERFERENCE_VULNERABILITY_HOURS, REPLAY_AROUSAL_THRESHOLD, REPLAY_BATCH_SIZE,
22    REPLAY_EDGE_BOOST, REPLAY_IMPORTANCE_THRESHOLD, REPLAY_MAX_AGE_DAYS, REPLAY_STRENGTH_BOOST,
23};
24use crate::memory::introspection::{ConsolidationEvent, InterferenceType};
25use chrono::{DateTime, Duration, Utc};
26use serde::{Deserialize, Serialize};
27use std::collections::{HashMap, HashSet};
28
29/// Candidate memory for replay, scored by priority
30#[derive(Debug, Clone)]
31pub struct ReplayCandidate {
32    pub memory_id: String,
33    pub content_preview: String,
34    pub importance: f32,
35    pub arousal: f32,
36    pub age_days: f64,
37    pub connection_count: usize,
38    pub priority_score: f32,
39    pub connected_memory_ids: Vec<String>,
40}
41
42/// Result of a replay cycle
43#[derive(Debug, Clone, Default)]
44pub struct ReplayCycleResult {
45    pub memories_replayed: usize,
46    pub edges_strengthened: usize,
47    pub total_priority_score: f32,
48    pub events: Vec<ConsolidationEvent>,
49    /// Edge boosts: (from_memory_id, to_memory_id, boost_value)
50    /// To be applied via GraphMemory at API layer
51    pub edge_boosts: Vec<(String, String, f32)>,
52    /// Memory IDs that were replayed — used for entity-entity edge strengthening
53    pub replay_memory_ids: Vec<String>,
54}
55
56/// Manager for memory replay during consolidation
57///
58/// Implements sleep-like consolidation by:
59/// 1. Identifying high-value memories for replay
60/// 2. Simulating co-activation during replay
61/// 3. Strengthening both memories and their associations
62pub struct ReplayManager {
63    /// Last replay cycle timestamp
64    last_replay: DateTime<Utc>,
65    /// Minimum interval between replay cycles (hours)
66    replay_interval_hours: i64,
67    /// Replay statistics
68    total_replays: usize,
69}
70
71impl Default for ReplayManager {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl ReplayManager {
78    pub fn new() -> Self {
79        Self {
80            last_replay: Utc::now() - Duration::hours(24), // Allow immediate first replay
81            replay_interval_hours: 1,                      // Replay every hour during active use
82            total_replays: 0,
83        }
84    }
85
86    /// Check if replay cycle should run
87    pub fn should_replay(&self) -> bool {
88        let elapsed = Utc::now() - self.last_replay;
89        elapsed.num_hours() >= self.replay_interval_hours
90    }
91
92    /// Identify memories eligible for replay
93    ///
94    /// Selection criteria:
95    /// - Recent (within REPLAY_MAX_AGE_DAYS)
96    /// - Important (above REPLAY_IMPORTANCE_THRESHOLD)
97    /// - Connected (at least REPLAY_MIN_CONNECTIONS)
98    /// - Optionally: high emotional arousal for priority
99    pub fn identify_replay_candidates(
100        &self,
101        memories: &[(String, f32, f32, DateTime<Utc>, Vec<String>, String)], // (id, importance, arousal, created_at, connections, content_preview)
102    ) -> Vec<ReplayCandidate> {
103        let now = Utc::now();
104        let mut candidates: Vec<ReplayCandidate> = memories
105            .iter()
106            .filter_map(
107                |(id, importance, arousal, created_at, connections, preview)| {
108                    let age = now - *created_at;
109                    let age_days = age.num_hours() as f64 / 24.0;
110
111                    // Check eligibility
112                    if age_days > REPLAY_MAX_AGE_DAYS as f64 {
113                        return None;
114                    }
115                    if *importance < REPLAY_IMPORTANCE_THRESHOLD {
116                        return None;
117                    }
118                    // REPLAY_MIN_CONNECTIONS=0: importance alone qualifies for replay.
119                    // Connections still boost priority via connectivity_factor below.
120
121                    // Calculate priority score
122                    // Priority = importance × recency_factor × (1 + arousal_boost) × connectivity_factor
123                    let recency_factor = 1.0 - (age_days / REPLAY_MAX_AGE_DAYS as f64) as f32;
124                    let arousal_boost = if *arousal > REPLAY_AROUSAL_THRESHOLD {
125                        (*arousal - REPLAY_AROUSAL_THRESHOLD) * 0.5
126                    } else {
127                        0.0
128                    };
129                    let connectivity_factor = 1.0 + (connections.len() as f32 / 10.0).min(0.5); // Max 50% boost
130
131                    let priority =
132                        importance * recency_factor * (1.0 + arousal_boost) * connectivity_factor;
133
134                    Some(ReplayCandidate {
135                        memory_id: id.clone(),
136                        content_preview: preview.clone(),
137                        importance: *importance,
138                        arousal: *arousal,
139                        age_days,
140                        connection_count: connections.len(),
141                        priority_score: priority,
142                        connected_memory_ids: connections.clone(),
143                    })
144                },
145            )
146            .collect();
147
148        // Sort by priority (highest first)
149        candidates.sort_by(|a, b| b.priority_score.total_cmp(&a.priority_score));
150
151        // Take top REPLAY_BATCH_SIZE candidates
152        candidates.truncate(REPLAY_BATCH_SIZE);
153        candidates
154    }
155
156    /// Execute replay for a batch of candidates
157    ///
158    /// Returns strength boosts to apply to memories and edges
159    pub fn execute_replay(
160        &mut self,
161        candidates: &[ReplayCandidate],
162    ) -> (
163        Vec<(String, f32)>,
164        Vec<(String, String, f32)>,
165        Vec<ConsolidationEvent>,
166    ) {
167        // (memory_id, boost), (from_id, to_id, boost), events
168        let mut memory_boosts: Vec<(String, f32)> = Vec::new();
169        let mut edge_boosts: Vec<(String, String, f32)> = Vec::new();
170        let mut events: Vec<ConsolidationEvent> = Vec::new();
171        let now = Utc::now();
172
173        // Track replayed memories to avoid duplicate boosts
174        let mut replayed: HashSet<String> = HashSet::new();
175
176        for candidate in candidates {
177            if replayed.contains(&candidate.memory_id) {
178                continue;
179            }
180
181            // Boost the primary memory
182            memory_boosts.push((candidate.memory_id.clone(), REPLAY_STRENGTH_BOOST));
183            replayed.insert(candidate.memory_id.clone());
184
185            // Co-activate connected memories
186            let mut connected_replayed = 0;
187            for connected_id in &candidate.connected_memory_ids {
188                if !replayed.contains(connected_id) {
189                    // Boost connected memory (slightly less than primary)
190                    memory_boosts.push((connected_id.clone(), REPLAY_STRENGTH_BOOST * 0.5));
191                    replayed.insert(connected_id.clone());
192                }
193
194                // Strengthen the edge between them
195                edge_boosts.push((
196                    candidate.memory_id.clone(),
197                    connected_id.clone(),
198                    REPLAY_EDGE_BOOST,
199                ));
200                connected_replayed += 1;
201            }
202
203            // Create replay event
204            events.push(ConsolidationEvent::MemoryReplayed {
205                memory_id: candidate.memory_id.clone(),
206                content_preview: candidate.content_preview.clone(),
207                activation_before: candidate.importance,
208                activation_after: (candidate.importance + REPLAY_STRENGTH_BOOST).min(1.0),
209                replay_priority: candidate.priority_score,
210                connected_memories_replayed: connected_replayed,
211                timestamp: now,
212            });
213        }
214
215        self.last_replay = now;
216        self.total_replays += replayed.len();
217
218        (memory_boosts, edge_boosts, events)
219    }
220
221    /// Get replay statistics
222    pub fn stats(&self) -> (usize, DateTime<Utc>) {
223        (self.total_replays, self.last_replay)
224    }
225}
226
227// =============================================================================
228// MEMORY INTERFERENCE (SHO-106)
229// =============================================================================
230
231/// Record of an interference event
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct InterferenceRecord {
234    pub interfering_memory_id: String,
235    pub similarity: f32,
236    pub interference_type: InterferenceType,
237    pub strength_change: f32,
238    pub timestamp: DateTime<Utc>,
239}
240
241/// Result of interference check during memory storage
242#[derive(Debug, Clone, Default)]
243pub struct InterferenceCheckResult {
244    /// Retroactive interference: old memories to weaken
245    pub retroactive_targets: Vec<(String, f32, f32)>, // (memory_id, similarity, decay_amount)
246    /// Proactive interference: strength reduction for new memory
247    pub proactive_decay: f32,
248    /// Whether memories are duplicates (should merge instead of interfere)
249    pub is_duplicate: bool,
250    /// Events generated
251    pub events: Vec<ConsolidationEvent>,
252}
253
254/// Result of retrieval competition
255#[derive(Debug, Clone)]
256pub struct CompetitionResult {
257    /// Memory IDs that won (survive suppression)
258    pub winners: Vec<(String, f32)>, // (memory_id, final_score)
259    /// Memory IDs that were suppressed
260    pub suppressed: Vec<String>,
261    /// Competition factor applied
262    pub competition_factor: f32,
263    /// Event generated
264    pub event: Option<ConsolidationEvent>,
265}
266
267/// Detector for memory interference effects
268pub struct InterferenceDetector {
269    /// Tracked interference records per memory
270    interference_history: HashMap<String, Vec<InterferenceRecord>>,
271    /// Total interference events
272    total_interference_events: usize,
273}
274
275impl Default for InterferenceDetector {
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281impl InterferenceDetector {
282    pub fn new() -> Self {
283        Self {
284            interference_history: HashMap::new(),
285            total_interference_events: 0,
286        }
287    }
288
289    /// Check for interference when storing a new memory
290    ///
291    /// Compares the new memory's embedding against existing memories
292    /// and determines interference effects.
293    pub fn check_interference(
294        &mut self,
295        new_memory_id: &str,
296        new_memory_importance: f32,
297        _new_memory_created: DateTime<Utc>,
298        similar_memories: &[(String, f32, f32, DateTime<Utc>, String)], // (id, similarity, importance, created_at, content_preview)
299    ) -> InterferenceCheckResult {
300        let mut result = InterferenceCheckResult::default();
301        let now = Utc::now();
302
303        for (old_id, similarity, old_importance, old_created, old_preview) in similar_memories {
304            // Skip self
305            if old_id == new_memory_id {
306                continue;
307            }
308
309            // Check if similarity exceeds threshold
310            if *similarity < INTERFERENCE_SIMILARITY_THRESHOLD {
311                continue;
312            }
313
314            // Check for duplicates (very high similarity)
315            if *similarity >= INTERFERENCE_SEVERE_THRESHOLD {
316                result.is_duplicate = true;
317                // Return early - should merge, not interfere
318                return result;
319            }
320
321            // Calculate interference effects
322            let age_hours = (now - *old_created).num_hours();
323            let is_vulnerable = age_hours < INTERFERENCE_VULNERABILITY_HOURS;
324
325            // Retroactive interference: new memory weakens old
326            if is_vulnerable || *old_importance < new_memory_importance {
327                // Stronger interference for more similar memories
328                let interference_strength = (*similarity - INTERFERENCE_SIMILARITY_THRESHOLD)
329                    / (1.0 - INTERFERENCE_SIMILARITY_THRESHOLD);
330
331                let decay = INTERFERENCE_RETROACTIVE_DECAY * interference_strength;
332                result
333                    .retroactive_targets
334                    .push((old_id.clone(), *similarity, decay));
335
336                // Record event
337                result
338                    .events
339                    .push(ConsolidationEvent::InterferenceDetected {
340                        new_memory_id: new_memory_id.to_string(),
341                        old_memory_id: old_id.clone(),
342                        similarity: *similarity,
343                        interference_type: InterferenceType::Retroactive,
344                        timestamp: now,
345                    });
346
347                result.events.push(ConsolidationEvent::MemoryWeakened {
348                    memory_id: old_id.clone(),
349                    content_preview: old_preview.clone(),
350                    activation_before: *old_importance,
351                    activation_after: (*old_importance - decay).max(0.05),
352                    interfering_memory_id: new_memory_id.to_string(),
353                    interference_type: InterferenceType::Retroactive,
354                    timestamp: now,
355                });
356
357                // Track in history
358                self.record_interference(
359                    old_id,
360                    new_memory_id,
361                    *similarity,
362                    InterferenceType::Retroactive,
363                    decay,
364                );
365            }
366
367            // Proactive interference: strong old memory suppresses new
368            if *old_importance > INTERFERENCE_PROACTIVE_THRESHOLD {
369                let interference_strength = (*similarity - INTERFERENCE_SIMILARITY_THRESHOLD)
370                    / (1.0 - INTERFERENCE_SIMILARITY_THRESHOLD);
371
372                let decay = INTERFERENCE_PROACTIVE_DECAY
373                    * interference_strength
374                    * (*old_importance - INTERFERENCE_PROACTIVE_THRESHOLD);
375
376                result.proactive_decay += decay;
377
378                result
379                    .events
380                    .push(ConsolidationEvent::InterferenceDetected {
381                        new_memory_id: new_memory_id.to_string(),
382                        old_memory_id: old_id.clone(),
383                        similarity: *similarity,
384                        interference_type: InterferenceType::Proactive,
385                        timestamp: now,
386                    });
387
388                self.record_interference(
389                    new_memory_id,
390                    old_id,
391                    *similarity,
392                    InterferenceType::Proactive,
393                    decay,
394                );
395            }
396        }
397
398        self.total_interference_events += result.events.len();
399        result
400    }
401
402    /// Apply retrieval competition between similar memories
403    ///
404    /// When multiple similar memories are retrieved, they compete
405    /// for activation. Stronger memories suppress weaker ones.
406    pub fn apply_retrieval_competition(
407        &mut self,
408        candidates: &[(String, f32, f32)], // (memory_id, relevance_score, similarity_to_query)
409        query_preview: &str,
410    ) -> CompetitionResult {
411        if candidates.len() <= 1 {
412            return CompetitionResult {
413                winners: candidates
414                    .iter()
415                    .map(|(id, score, _)| (id.clone(), *score))
416                    .collect(),
417                suppressed: Vec::new(),
418                competition_factor: 0.0,
419                event: None,
420            };
421        }
422
423        // Find groups of competing memories (high similarity to each other)
424        let mut scores: Vec<(String, f32)> = candidates
425            .iter()
426            .map(|(id, score, _)| (id.clone(), *score))
427            .collect();
428
429        // Sort by score descending
430        scores.sort_by(|a, b| b.1.total_cmp(&a.1));
431
432        let mut winners: Vec<(String, f32)> = Vec::new();
433        let mut suppressed: Vec<String> = Vec::new();
434
435        if let Some((winner_id, winner_score)) = scores.first() {
436            winners.push((winner_id.clone(), *winner_score));
437
438            // Guard: skip competition if winner has zero score (all remaining are also zero)
439            if *winner_score <= 0.0 {
440                for (id, score) in scores.iter().skip(1) {
441                    winners.push((id.clone(), *score));
442                }
443            } else {
444                // Apply competition suppression to lower-ranked memories
445                // RIF feedback: record interference for suppressed and close-survivor memories
446                // so Layer 4.6 (PIPE-3) can boost survivors and suppress chronic losers
447                for (id, score) in scores.iter().skip(1) {
448                    let score_ratio = score / winner_score;
449
450                    // Strong suppression for very close competitors
451                    if score_ratio > 0.9 {
452                        let suppression =
453                            INTERFERENCE_COMPETITION_FACTOR * (1.0 - score_ratio) * 10.0;
454                        let new_score = (score - suppression).max(0.0);
455
456                        if new_score > 0.1 {
457                            winners.push((id.clone(), new_score));
458                            // Mild interference record for close survivors ("battle-tested")
459                            self.record_interference(
460                                id,
461                                winner_id,
462                                score_ratio,
463                                InterferenceType::RetrievalCompetition,
464                                suppression * 0.3,
465                            );
466                        } else {
467                            suppressed.push(id.clone());
468                            // Strong interference record for fully suppressed memories
469                            self.record_interference(
470                                id,
471                                winner_id,
472                                score_ratio,
473                                InterferenceType::RetrievalCompetition,
474                                suppression,
475                            );
476                        }
477                    } else {
478                        winners.push((id.clone(), *score));
479                    }
480                }
481
482                self.total_interference_events += suppressed.len();
483            }
484        }
485
486        let event = if !suppressed.is_empty() {
487            Some(ConsolidationEvent::RetrievalCompetition {
488                query_preview: query_preview.to_string(),
489                winner_memory_id: winners
490                    .first()
491                    .map(|(id, _)| id.clone())
492                    .unwrap_or_default(),
493                suppressed_memory_ids: suppressed.clone(),
494                competition_factor: INTERFERENCE_COMPETITION_FACTOR,
495                timestamp: Utc::now(),
496            })
497        } else {
498            None
499        };
500
501        CompetitionResult {
502            winners,
503            suppressed,
504            competition_factor: INTERFERENCE_COMPETITION_FACTOR,
505            event,
506        }
507    }
508
509    /// Record an interference event
510    pub(crate) fn record_interference(
511        &mut self,
512        affected_memory_id: &str,
513        interfering_memory_id: &str,
514        similarity: f32,
515        interference_type: InterferenceType,
516        strength_change: f32,
517    ) {
518        let record = InterferenceRecord {
519            interfering_memory_id: interfering_memory_id.to_string(),
520            similarity,
521            interference_type,
522            strength_change,
523            timestamp: Utc::now(),
524        };
525
526        let history = self
527            .interference_history
528            .entry(affected_memory_id.to_string())
529            .or_default();
530
531        history.push(record);
532
533        // Limit history size
534        if history.len() > INTERFERENCE_MAX_TRACKED {
535            history.remove(0);
536        }
537    }
538
539    /// Get interference history for a memory
540    pub fn get_history(&self, memory_id: &str) -> Option<&Vec<InterferenceRecord>> {
541        self.interference_history.get(memory_id)
542    }
543
544    /// Get statistics
545    pub fn stats(&self) -> (usize, usize) {
546        (
547            self.total_interference_events,
548            self.interference_history.len(),
549        )
550    }
551
552    /// Clear history for a deleted memory
553    pub fn clear_memory(&mut self, memory_id: &str) {
554        self.interference_history.remove(memory_id);
555    }
556
557    // =========================================================================
558    // PIPE-3: INTERFERENCE-AWARE RETRIEVAL SCORING
559    // =========================================================================
560    //
561    // Research basis:
562    // - Anderson & Neely (1996): "Interference and inhibition in memory retrieval"
563    // - Anderson et al. (1994): Retrieval-induced forgetting (RIF)
564    // - Postman & Underwood (1973): "Critical issues in interference theory"
565    //
566    // Key insight: Retrieval is a competitive process where:
567    // 1. Memories that frequently "lose" competitions become harder to retrieve
568    // 2. Memories that survive despite competition become STRONGER (robust encoding)
569    //
570    // The "fan effect" (Anderson 1974): Memories with many competing associations
571    // are harder to retrieve, BUT memories that maintain strength despite fans
572    // are extra-reliable.
573    // =========================================================================
574
575    /// Calculate retrieval score adjustment based on interference history
576    ///
577    /// This implements Anderson's retrieval-induced forgetting (RIF) theory:
578    /// - Memories with high interference that maintained activation → BOOST (survivors)
579    /// - Memories with high interference and low activation → SUPPRESS (chronic losers)
580    ///
581    /// # Arguments
582    /// * `memory_id` - The memory to score
583    /// * `current_activation` - Current importance/activation level (0.0-1.0)
584    ///
585    /// # Returns
586    /// Score adjustment factor:
587    /// - > 1.0: boost (multiply score)
588    /// - < 1.0: suppress (multiply score)
589    /// - 1.0: no adjustment
590    ///
591    /// # Research Reference
592    /// Anderson, M.C. & Neely, J.H. (1996). Interference and inhibition in
593    /// memory retrieval. In E.L. Bjork & R.A. Bjork (Eds.), Memory (pp. 237-313).
594    pub fn calculate_retrieval_adjustment(&self, memory_id: &str, current_activation: f32) -> f32 {
595        let history = match self.interference_history.get(memory_id) {
596            Some(h) if !h.is_empty() => h,
597            _ => return 1.0, // No interference history → no adjustment
598        };
599
600        // Calculate interference metrics
601        let interference_count = history.len();
602        let total_strength_lost: f32 = history.iter().map(|r| r.strength_change.abs()).sum();
603        let avg_similarity: f32 =
604            history.iter().map(|r| r.similarity).sum::<f32>() / interference_count as f32;
605
606        // Normalize interference intensity (0-1 scale)
607        // Combines: event count + similarity + cumulative damage
608        // More events + higher similarity + more damage = more intense competition history
609        let count_factor = (interference_count as f32 / INTERFERENCE_MAX_TRACKED as f32).min(1.0);
610        let damage_factor = (total_strength_lost / 0.5).min(1.0); // 0.5 total loss = max damage
611        let interference_intensity = (count_factor * 0.5 + damage_factor * 0.5) * avg_similarity;
612
613        // The key insight from Anderson's RIF research:
614        // - High interference + high activation = SURVIVOR (boost)
615        // - High interference + low activation = CHRONIC LOSER (suppress)
616        //
617        // Formula: adjustment = 1.0 + intensity * (2 * activation - 1)
618        // - When activation = 1.0: adjustment = 1.0 + intensity (boost up to 2x)
619        // - When activation = 0.5: adjustment = 1.0 (neutral)
620        // - When activation = 0.0: adjustment = 1.0 - intensity (suppress down to 0x)
621
622        let activation_factor = 2.0 * current_activation - 1.0; // Maps [0,1] to [-1,1]
623        let adjustment = 1.0 + interference_intensity * activation_factor * 0.5;
624
625        // Clamp to reasonable bounds (0.5x to 1.5x)
626        adjustment.clamp(0.5, 1.5)
627    }
628
629    /// Batch calculate retrieval adjustments for multiple memories
630    ///
631    /// Efficient for scoring entire result sets.
632    ///
633    /// # Arguments
634    /// * `memories` - Vec of (memory_id, current_activation)
635    ///
636    /// # Returns
637    /// HashMap of memory_id → adjustment factor
638    pub fn batch_retrieval_adjustments(&self, memories: &[(String, f32)]) -> HashMap<String, f32> {
639        memories
640            .iter()
641            .map(|(id, activation)| {
642                (
643                    id.clone(),
644                    self.calculate_retrieval_adjustment(id, *activation),
645                )
646            })
647            .collect()
648    }
649
650    /// Check if a memory has significant interference history
651    ///
652    /// Useful for deciding whether to apply interference adjustments.
653    pub fn has_significant_interference(&self, memory_id: &str) -> bool {
654        self.interference_history
655            .get(memory_id)
656            .map(|h| h.len() >= 2) // At least 2 interference events
657            .unwrap_or(false)
658    }
659
660    // =========================================================================
661    // PERSISTENCE HELPERS
662    // =========================================================================
663
664    /// Bulk load interference history from persistent storage on startup
665    ///
666    /// Replaces the in-memory HashMap with persisted data. Called once during
667    /// MemorySystem initialization.
668    pub fn load_history(
669        &mut self,
670        history: HashMap<String, Vec<InterferenceRecord>>,
671        total_events: usize,
672    ) {
673        self.interference_history = history;
674        self.total_interference_events = total_events;
675        tracing::info!(
676            memories_tracked = self.interference_history.len(),
677            total_events = self.total_interference_events,
678            "Loaded interference history from persistent storage"
679        );
680    }
681
682    /// Get memory IDs affected by a storage interference check
683    ///
684    /// Returns IDs that had interference records modified, for targeted persistence.
685    pub fn get_affected_ids_from_check(
686        &self,
687        new_memory_id: &str,
688        result: &InterferenceCheckResult,
689    ) -> Vec<String> {
690        let mut ids: Vec<String> = result
691            .retroactive_targets
692            .iter()
693            .map(|(id, _, _)| id.clone())
694            .collect();
695
696        if result.proactive_decay > 0.0 {
697            ids.push(new_memory_id.to_string());
698        }
699
700        ids
701    }
702
703    /// Get memory IDs affected by retrieval competition
704    ///
705    /// Returns IDs of all memories that had interference recorded
706    /// (both suppressed and close survivors with score_ratio > 0.9).
707    pub fn get_affected_ids_from_competition(&self, result: &CompetitionResult) -> Vec<String> {
708        let mut ids = result.suppressed.clone();
709
710        // Include close survivors that had mild interference recorded
711        if let Some((_, winner_score)) = result.winners.first() {
712            if *winner_score > 0.0 {
713                for (id, score) in result.winners.iter().skip(1) {
714                    let score_ratio = score / winner_score;
715                    if score_ratio > 0.9 {
716                        ids.push(id.clone());
717                    }
718                }
719            }
720        }
721
722        ids
723    }
724
725    /// Get interference records for specific memory IDs (for targeted persistence)
726    pub fn get_records_for_ids<'a>(
727        &'a self,
728        ids: &'a [String],
729    ) -> Vec<(&'a str, &'a Vec<InterferenceRecord>)> {
730        ids.iter()
731            .filter_map(|id| {
732                self.interference_history
733                    .get(id)
734                    .map(|records| (id.as_str(), records))
735            })
736            .collect()
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn test_replay_candidate_identification() {
746        let manager = ReplayManager::new();
747        let now = Utc::now();
748
749        // Create test memories
750        let memories = vec![
751            (
752                "mem-1".to_string(),
753                0.8,                                            // High importance
754                0.7,                                            // High arousal
755                now - Duration::hours(12),                      // Recent
756                vec!["mem-2".to_string(), "mem-3".to_string()], // Connected
757                "Important memory".to_string(),
758            ),
759            (
760                "mem-2".to_string(),
761                0.2, // Low importance - should be excluded
762                0.3,
763                now - Duration::hours(6),
764                vec!["mem-1".to_string()],
765                "Unimportant memory".to_string(),
766            ),
767            (
768                "mem-3".to_string(),
769                0.6,
770                0.4,
771                now - Duration::days(15), // Too old (>14 day max) - should be excluded
772                vec!["mem-1".to_string(), "mem-4".to_string()],
773                "Old memory".to_string(),
774            ),
775        ];
776
777        let candidates = manager.identify_replay_candidates(&memories);
778
779        // Only mem-1 should be eligible
780        assert_eq!(candidates.len(), 1);
781        assert_eq!(candidates[0].memory_id, "mem-1");
782        assert!(candidates[0].priority_score > 0.0);
783    }
784
785    #[test]
786    fn test_replay_execution() {
787        let mut manager = ReplayManager::new();
788        let _now = Utc::now();
789
790        let candidates = vec![ReplayCandidate {
791            memory_id: "mem-1".to_string(),
792            content_preview: "Test memory".to_string(),
793            importance: 0.7,
794            arousal: 0.6,
795            age_days: 1.0,
796            connection_count: 2,
797            priority_score: 0.8,
798            connected_memory_ids: vec!["mem-2".to_string(), "mem-3".to_string()],
799        }];
800
801        let (memory_boosts, edge_boosts, events) = manager.execute_replay(&candidates);
802
803        // Primary memory should get a boost
804        assert!(memory_boosts.iter().any(|(id, _)| id == "mem-1"));
805
806        // Connected memories should get boosts
807        assert!(memory_boosts.iter().any(|(id, _)| id == "mem-2"));
808        assert!(memory_boosts.iter().any(|(id, _)| id == "mem-3"));
809
810        // Edges should be strengthened
811        assert_eq!(edge_boosts.len(), 2);
812
813        // Event should be generated
814        assert_eq!(events.len(), 1);
815    }
816
817    #[test]
818    fn test_interference_detection() {
819        let mut detector = InterferenceDetector::new();
820        let now = Utc::now();
821
822        // Test retroactive interference
823        let similar_memories = vec![(
824            "old-mem".to_string(),
825            0.90,                      // High similarity
826            0.5,                       // Moderate importance
827            now - Duration::hours(12), // Recent, vulnerable
828            "Old memory content".to_string(),
829        )];
830
831        let result = detector.check_interference(
832            "new-mem",
833            0.7, // Higher importance than old
834            now,
835            &similar_memories,
836        );
837
838        // Should detect retroactive interference
839        assert!(!result.retroactive_targets.is_empty());
840        assert!(!result.events.is_empty());
841    }
842
843    #[test]
844    fn test_duplicate_detection() {
845        let mut detector = InterferenceDetector::new();
846        let now = Utc::now();
847
848        // Very similar memory (near duplicate)
849        let similar_memories = vec![(
850            "existing-mem".to_string(),
851            0.98, // Very high similarity - duplicate
852            0.5,
853            now - Duration::hours(1),
854            "Existing content".to_string(),
855        )];
856
857        let result = detector.check_interference("new-mem", 0.6, now, &similar_memories);
858
859        // Should detect as duplicate
860        assert!(result.is_duplicate);
861        // No interference events for duplicates
862        assert!(result.events.is_empty());
863    }
864
865    #[test]
866    fn test_retrieval_competition() {
867        let mut detector = InterferenceDetector::new();
868
869        let candidates = vec![
870            ("mem-1".to_string(), 0.9, 0.85),  // Winner
871            ("mem-2".to_string(), 0.88, 0.82), // Close competitor
872            ("mem-3".to_string(), 0.5, 0.70),  // Lower, should survive
873        ];
874
875        let result = detector.apply_retrieval_competition(&candidates, "test query");
876
877        // Winner should be first
878        assert_eq!(result.winners[0].0, "mem-1");
879        // Close competitor may be suppressed depending on competition factor
880        assert!(!result.winners.is_empty());
881    }
882
883    // =========================================================================
884    // PIPE-3: Interference-Aware Retrieval Tests
885    // =========================================================================
886
887    #[test]
888    fn test_retrieval_adjustment_no_history() {
889        let detector = InterferenceDetector::new();
890
891        // Memory with no interference history should get neutral adjustment
892        let adjustment = detector.calculate_retrieval_adjustment("unknown-mem", 0.8);
893        assert_eq!(
894            adjustment, 1.0,
895            "No history should return neutral adjustment"
896        );
897    }
898
899    #[test]
900    fn test_retrieval_adjustment_survivor_boost() {
901        let mut detector = InterferenceDetector::new();
902        let now = Utc::now();
903
904        // Simulate interference history for a "survivor" memory
905        // (high interference but maintained high activation)
906        let _similar_memories = vec![(
907            "survivor-mem".to_string(),
908            0.90,
909            0.85, // High importance maintained despite interference
910            now - Duration::hours(12),
911            "Survivor content".to_string(),
912        )];
913
914        // Record multiple interference events
915        for i in 0..5 {
916            detector.record_interference(
917                "survivor-mem",
918                &format!("interferer-{}", i),
919                0.88,
920                InterferenceType::Retroactive,
921                0.05,
922            );
923        }
924
925        // High activation (0.9) + interference history = BOOST
926        let adjustment = detector.calculate_retrieval_adjustment("survivor-mem", 0.9);
927        assert!(
928            adjustment > 1.0,
929            "Survivor (high activation despite interference) should be boosted: {}",
930            adjustment
931        );
932    }
933
934    #[test]
935    fn test_retrieval_adjustment_chronic_loser_suppress() {
936        let mut detector = InterferenceDetector::new();
937
938        // Simulate interference history for a "chronic loser" memory
939        // (high interference and low activation = weak memory)
940        for i in 0..5 {
941            detector.record_interference(
942                "loser-mem",
943                &format!("winner-{}", i),
944                0.88,
945                InterferenceType::Retroactive,
946                0.1,
947            );
948        }
949
950        // Low activation (0.2) + interference history = SUPPRESS
951        let adjustment = detector.calculate_retrieval_adjustment("loser-mem", 0.2);
952        assert!(
953            adjustment < 1.0,
954            "Chronic loser (low activation with interference) should be suppressed: {}",
955            adjustment
956        );
957    }
958
959    #[test]
960    fn test_retrieval_adjustment_neutral_midpoint() {
961        let mut detector = InterferenceDetector::new();
962
963        // Record some interference
964        for i in 0..3 {
965            detector.record_interference(
966                "neutral-mem",
967                &format!("other-{}", i),
968                0.87,
969                InterferenceType::Retroactive,
970                0.05,
971            );
972        }
973
974        // Medium activation (0.5) should be near neutral
975        let adjustment = detector.calculate_retrieval_adjustment("neutral-mem", 0.5);
976        assert!(
977            (adjustment - 1.0).abs() < 0.1,
978            "Medium activation should be near neutral: {}",
979            adjustment
980        );
981    }
982
983    #[test]
984    fn test_batch_retrieval_adjustments() {
985        let mut detector = InterferenceDetector::new();
986
987        // Set up different interference histories
988        for i in 0..3 {
989            detector.record_interference(
990                "mem-with-history",
991                &format!("interferer-{}", i),
992                0.88,
993                InterferenceType::Retroactive,
994                0.05,
995            );
996        }
997
998        let memories = vec![
999            ("mem-with-history".to_string(), 0.9), // Has history, high activation
1000            ("mem-no-history".to_string(), 0.9),   // No history
1001        ];
1002
1003        let adjustments = detector.batch_retrieval_adjustments(&memories);
1004
1005        // Memory with history and high activation should be boosted
1006        assert!(adjustments.get("mem-with-history").unwrap() > &1.0);
1007        // Memory without history should be neutral
1008        assert_eq!(adjustments.get("mem-no-history").unwrap(), &1.0);
1009    }
1010}