Skip to main content

microscope_memory/
emotional_episode.rs

1//! Emotional Episode — event-bound emotional memory with epistemic backing.
2//!
3//! An emotional episode is NOT just a valence number. It is a structured
4//! memory of *why* the system felt something:
5//!
6//! - What event triggered it (trigger_evidence_id)
7//! - What PAD state it produced (pleasure, arousal, dominance)
8//! - What 21D emotion vector it generated
9//! - **Epistemic claim**: "this event triggered emotion X because of Y"
10//! - **Counterevidence**: "word match could be pattern, not genuine emotion"
11//! - Decay rate: how fast this episode fades
12//! - Resonance links: which other memories this episode connects to
13//!
14//! Binary format: emotional_episodes.bin (EEP1)
15
16use epistemic_core::gate::{EpistemicGate, GateConfig, GateDecision};
17use epistemic_core::graph::ReasoningGraph;
18use epistemic_core::rules::{InferenceRule, RuleRegistry};
19use epistemic_core::types::{CounterevidenceLink, SplitConfidence};
20use std::fs;
21use std::path::Path;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24// ─── Constants ──────────────────────────────────────
25
26const EPISODE_MAGIC: &[u8; 4] = b"EEP1";
27const MAX_EPISODES: usize = 500;
28
29// ─── PAD Model ──────────────────────────────────────
30
31/// Pleasure-Arousal-Dominance emotional model.
32///
33/// - Pleasure: -1.0 (negative) to +1.0 (positive) — valence
34/// - Arousal: 0.0 (calm) to 1.0 (highly activated) — energy
35/// - Dominance: 0.0 (submissive) to 1.0 (dominant) — control
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct PadState {
38    pub pleasure: f64,
39    pub arousal: f64,
40    pub dominance: f64,
41}
42
43impl PadState {
44    pub fn new(pleasure: f64, arousal: f64, dominance: f64) -> Self {
45        Self {
46            pleasure: pleasure.clamp(-1.0, 1.0),
47            arousal: arousal.clamp(0.0, 1.0),
48            dominance: dominance.clamp(0.0, 1.0),
49        }
50    }
51
52    pub fn neutral() -> Self {
53        Self::new(0.0, 0.0, 0.0)
54    }
55
56    /// Intensity = arousal * (1 - |1 - 2*dominance|) — dominance modulates
57    pub fn intensity(&self) -> f64 {
58        self.arousal * (1.0 - (1.0 - 2.0 * self.dominance).abs())
59    }
60
61    /// Convert PAD to a 21D emotion vector (compatible with existing EMOTION_DIMS).
62    /// Maps PAD to Plutchik-like dimensions using known PAD→emotion correspondences.
63    pub fn to_21d(&self) -> [f32; 21] {
64        let p = self.pleasure as f32;
65        let a = self.arousal as f32;
66        let d = self.dominance as f32;
67
68        // PAD → 21D mapping based on Mehrabian's correspondences
69        [
70            p * a,                           // 0: joy
71            -p * (1.0 - a),                  // 1: sadness (P-, A-, D- — low arousal)
72            -p * a * d * d,                  // 2: anger (P-, A+, D+ — assertive, d² sharpens split)
73            -p * a * (1.0 - d) * (1.0 - d), // 3: fear (P-, A+, D- — powerless, (1-d)² sharpens split)
74            a * (1.0 - d) * 0.5,            // 4: surprise
75            -p * d * (1.0 - a),             // 5: disgust (P-, A-, D+ — aversion with control)
76            p * d,                          // 6: trust
77            a * d * 0.5,                    // 7: anticipation
78            p * a * d,                      // 8: love
79            p * d * 0.8,                    // 9: gratitude
80            a * d,                          // 10: curiosity
81            -d * a * 0.5,                   // 11: confusion
82            p * a * d * 0.7,                // 12: pride
83            -p * a * (1.0 - d) * 0.7,       // 13: shame
84            -p * a * (1.0 - d),             // 14: anxiety
85            p * (1.0 - a) * d,              // 15: calm
86            p * a * d,                      // 16: excitement
87            (-p).max(0.0) * (1.0 - a) * 0.3, // 17: boredom
88            p * a * d * 0.6,                // 18: hope
89            -p * a * (1.0 - d) * 0.6,       // 19: regret
90            p * a * d * 0.5,                // 20: empathy
91        ]
92    }
93}
94
95// ─── Emotional Episode ──────────────────────────────
96
97/// An emotional episode — an event-bound emotional memory with epistemic backing.
98///
99/// Every episode carries:
100/// 1. **Trigger**: what evidence (event) caused this emotion
101/// 2. **PAD state**: the pleasure-arousal-dominance values
102/// 3. **21D vector**: the Plutchik-compatible emotion vector
103/// 4. **Epistemic claim**: why this is a genuine emotion, not just pattern-match
104/// 5. **Counterevidence**: what could weaken this claim
105/// 6. **Gate decision**: did this episode pass the epistemic gate?
106/// 7. **Decay**: how fast this episode fades from active state
107#[derive(Debug, Clone)]
108pub struct EmotionalEpisode {
109    pub episode_id: u64,
110    pub timestamp_ms: u64,
111    /// What evidence (event/memory) triggered this emotion.
112    pub trigger_evidence_id: u64,
113    /// The PAD emotional state.
114    pub pad: PadState,
115    /// The 21D emotion vector (derived from PAD + context).
116    pub emotion_21d: [f32; 21],
117    /// The dominant emotion label index (0-20).
118    pub dominant_emotion: usize,
119    /// Epistemic claim: "this event triggered emotion X because of Y"
120    pub claim_text: String,
121    /// Evidence confidence (how strong is the triggering evidence).
122    pub evidence_confidence: f64,
123    /// Counterevidence: what weakens this emotional claim.
124    pub counterevidence: Vec<CounterevidenceLink>,
125    /// Gate decision: did this episode pass?
126    pub gate_passed: bool,
127    /// The split confidence at gate time.
128    pub split_confidence: Option<SplitConfidence>,
129    /// Decay rate per day (how fast this episode fades).
130    pub decay_per_day: f64,
131    /// Connected memory block IDs (resonance links).
132    pub resonance_links: Vec<u64>,
133    /// Detected event type (Achievement, Error, Fix, Challenge, Building, Reflection, Connection, SelfCorrection, None)
134    pub event_type: String,
135    /// Detected speech act (Question, Imperative, Contemplation, Statement)
136    pub speech_act: String,
137    /// Detected context type (Code, Personal, Philosophical, Technical, Neutral)
138    pub context_type: String,
139    /// Was this a self-correction? (positive structural signal)
140    pub is_self_correction: bool,
141}
142
143impl EmotionalEpisode {
144    /// Create a new emotional episode with epistemic backing.
145    ///
146    /// This constructs the reasoning graph, counterevidence, and runs
147    /// the gate automatically.
148    #[allow(clippy::too_many_arguments)]
149    pub fn new(
150        episode_id: u64,
151        trigger_evidence_id: u64,
152        pad: PadState,
153        claim_text: impl Into<String>,
154        evidence_confidence: f64,
155        trigger_is_structural: bool,
156        counterevidence: Vec<CounterevidenceLink>,
157        gate_config: &GateConfig,
158    ) -> Self {
159        let claim_text = claim_text.into();
160        let emotion_21d = pad.to_21d();
161        let dominant_emotion = emotion_21d
162            .iter()
163            .enumerate()
164            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
165            .map(|(i, _)| i)
166            .unwrap_or(0);
167
168        // Build reasoning graph
169        let mut graph = ReasoningGraph::new();
170        let reg = RuleRegistry::new();
171
172        let ev_node = graph.add_evidence(trigger_evidence_id);
173        let root = graph.add_conclusion(episode_id, &claim_text);
174
175        // If the trigger is structural (not just word match), use ObservationToExistence.
176        // If it's word-list-based, use DesignedMechanismToEmergent (weakened).
177        let rule = if trigger_is_structural {
178            InferenceRule::ObservationToExistence
179        } else {
180            InferenceRule::DesignedMechanismToEmergent
181        };
182
183        graph.add_step(ev_node, rule, root, evidence_confidence, &reg);
184        graph.set_root(root);
185
186        // Compute counterevidence confidence
187        let ce_conf = SplitConfidence::compute_counterevidence(&counterevidence);
188
189        // Narrative confidence: structural triggers get higher narrative,
190        // word-list triggers get lower (more likely to be pattern-match)
191        let narrative = if trigger_is_structural { 0.75 } else { 0.45 };
192
193        // Run the gate
194        let gate = EpistemicGate::new(gate_config.clone());
195        let decision = gate.evaluate(evidence_confidence, ce_conf, &graph, narrative);
196
197        let (gate_passed, split_confidence) = match decision {
198            GateDecision::Pass {
199                evidence,
200                counterevidence,
201                reasoning,
202                narrative,
203                ..
204            } => (
205                true,
206                Some(SplitConfidence::new(
207                    evidence,
208                    counterevidence,
209                    reasoning,
210                    narrative,
211                )),
212            ),
213            GateDecision::Blocked {
214                evidence,
215                counterevidence,
216                reasoning,
217                narrative,
218                ..
219            } => (
220                false,
221                Some(SplitConfidence::new(
222                    evidence,
223                    counterevidence,
224                    reasoning,
225                    narrative,
226                )),
227            ),
228        };
229
230        Self {
231            episode_id,
232            timestamp_ms: now_epoch_ms(),
233            trigger_evidence_id,
234            pad,
235            emotion_21d,
236            dominant_emotion,
237            claim_text,
238            evidence_confidence,
239            counterevidence,
240            gate_passed,
241            split_confidence,
242            decay_per_day: 0.15,
243            resonance_links: Vec::new(),
244            event_type: "Unknown".to_string(),
245            speech_act: "Statement".to_string(),
246            context_type: "Neutral".to_string(),
247            is_self_correction: false,
248        }
249    }
250
251    /// Create an emotional episode from an extraction result.
252    /// This is the primary integration point: text → extract_emotion() → episode.
253    ///
254    /// Returns None if the extraction is not structural (no fake emotion).
255    pub fn from_extraction(
256        episode_id: u64,
257        trigger_evidence_id: u64,
258        extraction: &crate::emotion_extraction::ExtractionResult,
259        gate_config: &epistemic_core::gate::GateConfig,
260    ) -> Option<Self> {
261        // Principle 3: "unknown / uncertain" is legitimate.
262        // Only create episode if the extraction found a structural signal.
263        if !extraction.trigger_is_structural {
264            return None;
265        }
266
267        // Principle 3: low confidence = no episode, not forced emotion
268        if extraction.detection_confidence < 0.55 {
269            return None;
270        }
271
272        let event_type_str = format!("{:?}", extraction.event);
273        let speech_act_str = format!("{:?}", extraction.speech_act);
274        let context_type_str = format!("{:?}", extraction.context);
275
276        let mut episode = Self::new(
277            episode_id,
278            trigger_evidence_id,
279            extraction.pad,
280            &extraction.claim_text,
281            extraction.detection_confidence,
282            extraction.trigger_is_structural,
283            // Counterevidence: if detection confidence is low, weaken more
284            vec![epistemic_core::types::CounterevidenceLink {
285                evidence_id: 200,
286                weakening: (1.0 - extraction.detection_confidence).min(0.80),
287                contradicts: "structural emotion detection could be misinterpreted".into(),
288            }],
289            gate_config,
290        );
291
292        // Store the event type for later auditing (Principle 2)
293        episode.event_type = event_type_str;
294        episode.speech_act = speech_act_str;
295        episode.context_type = context_type_str;
296        episode.is_self_correction = extraction.is_self_correction;
297
298        Some(episode)
299    }
300    /// Apply time-based decay. Returns the decayed intensity (0.0–1.0).
301    pub fn decayed_intensity(&self) -> f64 {
302        let age_ms = now_epoch_ms().saturating_sub(self.timestamp_ms);
303        let age_days = age_ms as f64 / 86_400_000.0;
304        let decay = (self.decay_per_day * age_days).min(1.0);
305        self.pad.intensity() * (1.0 - decay)
306    }
307
308    /// Is this episode still active (above threshold)?
309    pub fn is_active(&self) -> bool {
310        self.decayed_intensity() > 0.05
311    }
312
313    /// Get the decayed 21D vector (scaled by decay factor).
314    pub fn decayed_21d(&self) -> [f32; 21] {
315        let intensity = self.decayed_intensity() as f32;
316        let base_intensity = self.pad.intensity() as f32;
317        if base_intensity < 1e-6 {
318            return [0.0; 21];
319        }
320        let scale = intensity / base_intensity;
321        self.emotion_21d.map(|v| v * scale)
322    }
323}
324
325// ─── Episode Store ──────────────────────────────────
326
327/// Stores emotional episodes with EEP1 binary persistence.
328pub struct EpisodeStore {
329    pub episodes: Vec<EmotionalEpisode>,
330    pub next_id: u64,
331}
332
333impl EpisodeStore {
334    pub fn new() -> Self {
335        Self {
336            episodes: Vec::new(),
337            next_id: 1,
338        }
339    }
340
341    /// Load from disk or initialize empty.
342    pub fn load_or_init(output_dir: &Path) -> Self {
343        let path = output_dir.join("emotional_episodes.bin");
344        if let Ok(data) = fs::read(&path) {
345            if data.len() >= 8 && &data[0..4] == EPISODE_MAGIC {
346                return Self::from_bytes(&data);
347            }
348        }
349        Self::new()
350    }
351
352    /// Add a new episode. Returns the episode ID.
353    pub fn add(&mut self, mut episode: EmotionalEpisode) -> u64 {
354        let id = self.next_id;
355        episode.episode_id = id;
356        self.next_id += 1;
357        self.episodes.push(episode);
358
359        // Trim to max
360        if self.episodes.len() > MAX_EPISODES {
361            self.episodes.drain(0..(self.episodes.len() - MAX_EPISODES));
362        }
363
364        id
365    }
366
367    /// Get all active (non-decayed) episodes.
368    pub fn active_episodes(&self) -> Vec<&EmotionalEpisode> {
369        self.episodes.iter().filter(|e| e.is_active()).collect()
370    }
371
372    /// Aggregate the 21D state from all active episodes.
373    /// Each episode contributes its decayed 21D vector, weighted by
374    /// intensity and gate pass status (failed episodes contribute less).
375    pub fn aggregate_21d(&self) -> [f32; 21] {
376        let mut result = [0.0f32; 21];
377        let mut total_weight = 0.0f32;
378
379        for episode in &self.episodes {
380            if !episode.is_active() {
381                continue;
382            }
383
384            let intensity = episode.decayed_intensity() as f32;
385            // Gate-passed episodes contribute fully.
386            // Gate-failed episodes contribute at 30% (suspicious emotion).
387            let gate_weight = if episode.gate_passed { 1.0 } else { 0.3 };
388            let weight = intensity * gate_weight;
389
390            let decayed = episode.decayed_21d();
391            for (i, &v) in decayed.iter().enumerate() {
392                result[i] += v * weight;
393            }
394            total_weight += weight;
395        }
396
397        if total_weight > 1e-6 {
398            // Normalize by episode count (not weight) so failed episodes
399            // contribute less in absolute magnitude
400            let n = self.episodes.iter().filter(|e| e.is_active()).count() as f32;
401            for v in &mut result {
402                *v /= n;
403            }
404        }
405
406        result
407    }
408
409    /// Aggregate PAD state from active episodes.
410    pub fn aggregate_pad(&self) -> PadState {
411        let active: Vec<_> = self.episodes.iter().filter(|e| e.is_active()).collect();
412        if active.is_empty() {
413            return PadState::neutral();
414        }
415
416        let mut p = 0.0f64;
417        let mut a = 0.0f64;
418        let mut d = 0.0f64;
419        let mut total_weight = 0.0f64;
420
421        for episode in &active {
422            let intensity = episode.decayed_intensity();
423            let gate_weight = if episode.gate_passed { 1.0 } else { 0.3 };
424            let weight = intensity * gate_weight;
425
426            p += episode.pad.pleasure * weight;
427            a += episode.pad.arousal * weight;
428            d += episode.pad.dominance * weight;
429            total_weight += weight;
430        }
431
432        if total_weight > 1e-6 {
433            PadState::new(p / total_weight, a / total_weight, d / total_weight)
434        } else {
435            PadState::neutral()
436        }
437    }
438
439    /// Save to disk (EEP1 format).
440    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
441        let path = output_dir.join("emotional_episodes.bin");
442        let tmp = output_dir.join("emotional_episodes.bin.tmp");
443        fs::write(&tmp, self.to_bytes())
444            .map_err(|e| format!("write emotional_episodes.bin: {e}"))?;
445        fs::rename(&tmp, &path).map_err(|e| format!("rename emotional_episodes.bin: {e}"))
446    }
447
448    fn to_bytes(&self) -> Vec<u8> {
449        let mut buf = Vec::with_capacity(1024);
450        buf.extend_from_slice(EPISODE_MAGIC);
451        buf.extend_from_slice(&(self.episodes.len() as u32).to_le_bytes());
452        buf.extend_from_slice(&self.next_id.to_le_bytes());
453
454        for ep in &self.episodes {
455            // Fixed header
456            buf.extend_from_slice(&ep.episode_id.to_le_bytes()); // 8
457            buf.extend_from_slice(&ep.timestamp_ms.to_le_bytes()); // 8
458            buf.extend_from_slice(&ep.trigger_evidence_id.to_le_bytes()); // 8
459            buf.extend_from_slice(&ep.pad.pleasure.to_le_bytes()); // 8
460            buf.extend_from_slice(&ep.pad.arousal.to_le_bytes()); // 8
461            buf.extend_from_slice(&ep.pad.dominance.to_le_bytes()); // 8
462            for &v in &ep.emotion_21d {
463                buf.extend_from_slice(&v.to_le_bytes()); // 21 * 4
464            }
465            buf.extend_from_slice(&(ep.dominant_emotion as u32).to_le_bytes()); // 4
466            buf.extend_from_slice(&ep.evidence_confidence.to_le_bytes()); // 8
467            buf.push(if ep.gate_passed { 1u8 } else { 0u8 }); // 1
468
469            // Split confidence (if present)
470            if let Some(sc) = ep.split_confidence {
471                buf.push(1u8); // has confidence
472                buf.extend_from_slice(&sc.evidence.to_le_bytes()); // 8
473                buf.extend_from_slice(&sc.counterevidence.to_le_bytes()); // 8
474                buf.extend_from_slice(&sc.reasoning.to_le_bytes()); // 8
475                buf.extend_from_slice(&sc.narrative.to_le_bytes()); // 8
476            } else {
477                buf.push(0u8);
478            }
479
480            // Claim text
481            let text_bytes = ep.claim_text.as_bytes();
482            buf.extend_from_slice(&(text_bytes.len() as u32).to_le_bytes()); // 4
483            buf.extend_from_slice(text_bytes);
484
485            // Decay rate
486            buf.extend_from_slice(&ep.decay_per_day.to_le_bytes()); // 8
487
488            // Resonance links
489            buf.extend_from_slice(&(ep.resonance_links.len() as u32).to_le_bytes()); // 4
490            for &link in &ep.resonance_links {
491                buf.extend_from_slice(&link.to_le_bytes()); // 8 each
492            }
493
494            // Counterevidence count + links
495            buf.extend_from_slice(&(ep.counterevidence.len() as u32).to_le_bytes()); // 4
496            for ce in &ep.counterevidence {
497                buf.extend_from_slice(&ce.evidence_id.to_le_bytes()); // 8
498                buf.extend_from_slice(&ce.weakening.to_le_bytes()); // 8
499                let ct_bytes = ce.contradicts.as_bytes();
500                buf.extend_from_slice(&(ct_bytes.len() as u32).to_le_bytes()); // 4
501                buf.extend_from_slice(ct_bytes);
502            }
503
504            // New fields: event_type, speech_act, context_type, is_self_correction
505            let et_bytes = ep.event_type.as_bytes();
506            buf.extend_from_slice(&(et_bytes.len() as u16).to_le_bytes());
507            buf.extend_from_slice(et_bytes);
508            let sa_bytes = ep.speech_act.as_bytes();
509            buf.extend_from_slice(&(sa_bytes.len() as u16).to_le_bytes());
510            buf.extend_from_slice(sa_bytes);
511            let ctx_bytes = ep.context_type.as_bytes();
512            buf.extend_from_slice(&(ctx_bytes.len() as u16).to_le_bytes());
513            buf.extend_from_slice(ctx_bytes);
514            buf.push(if ep.is_self_correction { 1u8 } else { 0u8 });
515        }
516
517        buf
518    }
519
520    fn from_bytes(data: &[u8]) -> Self {
521        let mut off = 4; // skip magic
522        let count = u32::from_le_bytes(data[off..off + 4].try_into().unwrap_or([0; 4])) as usize;
523        off += 4;
524        let next_id = u64::from_le_bytes(data[off..off + 8].try_into().unwrap_or([0; 8]));
525        off += 8;
526
527        let mut episodes = Vec::with_capacity(count);
528
529        for _ in 0..count {
530            if off + 8 + 8 + 8 + 8 + 8 + 8 + 84 + 4 + 8 + 1 > data.len() {
531                break;
532            }
533
534            let episode_id = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
535            off += 8;
536            let timestamp_ms = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
537            off += 8;
538            let trigger_evidence_id = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
539            off += 8;
540            let pleasure = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
541            off += 8;
542            let arousal = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
543            off += 8;
544            let dominance = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
545            off += 8;
546
547            let mut emotion_21d = [0.0f32; 21];
548            for v in &mut emotion_21d {
549                *v = f32::from_le_bytes(data[off..off + 4].try_into().unwrap());
550                off += 4;
551            }
552
553            let dominant_emotion =
554                u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
555            off += 4;
556            let evidence_confidence = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
557            off += 8;
558            let gate_passed = data[off] == 1;
559            off += 1;
560
561            let has_confidence = data[off] == 1;
562            off += 1;
563            let split_confidence = if has_confidence {
564                let ev = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
565                off += 8;
566                let ce = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
567                off += 8;
568                let re = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
569                off += 8;
570                let na = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
571                off += 8;
572                Some(SplitConfidence::new(ev, ce, re, na))
573            } else {
574                None
575            };
576
577            let text_len = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
578            off += 4;
579            let claim_text = String::from_utf8_lossy(&data[off..off + text_len]).into_owned();
580            off += text_len;
581
582            let decay_per_day = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
583            off += 8;
584
585            let link_count = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
586            off += 4;
587            let mut resonance_links = Vec::with_capacity(link_count);
588            for _ in 0..link_count {
589                if off + 8 > data.len() {
590                    break;
591                }
592                resonance_links.push(u64::from_le_bytes(data[off..off + 8].try_into().unwrap()));
593                off += 8;
594            }
595
596            let ce_count = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
597            off += 4;
598            let mut counterevidence = Vec::with_capacity(ce_count);
599            for _ in 0..ce_count {
600                if off + 8 + 8 + 4 > data.len() {
601                    break;
602                }
603                let ce_id = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
604                off += 8;
605                let weakening = f64::from_le_bytes(data[off..off + 8].try_into().unwrap());
606                off += 8;
607                let ct_len = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
608                off += 4;
609                let contradicts = String::from_utf8_lossy(&data[off..off + ct_len]).into_owned();
610                off += ct_len;
611                counterevidence.push(CounterevidenceLink {
612                    evidence_id: ce_id,
613                    weakening,
614                    contradicts,
615                });
616            }
617
618            // New fields (backward-compatible: use defaults if not present)
619            let (event_type, speech_act, context_type, is_self_correction) = if off + 2
620                <= data.len()
621            {
622                let et_len =
623                    u16::from_le_bytes(data[off..off + 2].try_into().unwrap_or([0; 2])) as usize;
624                off += 2;
625                let et = if off + et_len <= data.len() {
626                    let s = String::from_utf8_lossy(&data[off..off + et_len]).into_owned();
627                    off += et_len;
628                    s
629                } else {
630                    String::from("Unknown")
631                };
632                let sa = if off + 2 <= data.len() {
633                    let sa_len = u16::from_le_bytes(data[off..off + 2].try_into().unwrap_or([0; 2]))
634                        as usize;
635                    off += 2;
636                    if off + sa_len <= data.len() {
637                        let s = String::from_utf8_lossy(&data[off..off + sa_len]).into_owned();
638                        off += sa_len;
639                        s
640                    } else {
641                        String::from("Statement")
642                    }
643                } else {
644                    String::from("Statement")
645                };
646                let ct = if off + 2 <= data.len() {
647                    let ct_len = u16::from_le_bytes(data[off..off + 2].try_into().unwrap_or([0; 2]))
648                        as usize;
649                    off += 2;
650                    if off + ct_len <= data.len() {
651                        let s = String::from_utf8_lossy(&data[off..off + ct_len]).into_owned();
652                        off += ct_len;
653                        s
654                    } else {
655                        String::from("Neutral")
656                    }
657                } else {
658                    String::from("Neutral")
659                };
660                let sc = if off < data.len() {
661                    data[off] == 1
662                } else {
663                    false
664                };
665                off += 1;
666                (et, sa, ct, sc)
667            } else {
668                (
669                    "Unknown".to_string(),
670                    "Statement".to_string(),
671                    "Neutral".to_string(),
672                    false,
673                )
674            };
675
676            episodes.push(EmotionalEpisode {
677                episode_id,
678                timestamp_ms,
679                trigger_evidence_id,
680                pad: PadState::new(pleasure, arousal, dominance),
681                emotion_21d,
682                dominant_emotion,
683                claim_text,
684                evidence_confidence,
685                counterevidence,
686                gate_passed,
687                split_confidence,
688                decay_per_day,
689                resonance_links,
690                event_type,
691                speech_act,
692                context_type,
693                is_self_correction,
694            });
695        }
696
697        Self { episodes, next_id }
698    }
699}
700
701impl Default for EpisodeStore {
702    fn default() -> Self {
703        Self::new()
704    }
705}
706
707// ─── Helpers ────────────────────────────────────────
708
709fn now_epoch_ms() -> u64 {
710    SystemTime::now()
711        .duration_since(UNIX_EPOCH)
712        .map(|d| d.as_millis() as u64)
713        .unwrap_or(0)
714}
715
716// ─── Tests ──────────────────────────────────────────
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    #[test]
723    fn pad_to_21d_joy_dominant() {
724        let pad = PadState::new(0.8, 0.7, 0.6);
725        let v = pad.to_21d();
726        // joy (index 0) should be high: p * a = 0.8 * 0.7 = 0.56
727        assert!(v[0] > 0.4, "joy should be high, got {}", v[0]);
728        // sadness (index 1) should be low/negative
729        assert!(v[1] < 0.0, "sadness should be negative");
730    }
731
732    #[test]
733    fn pad_neutral_is_zero() {
734        let pad = PadState::neutral();
735        let v = pad.to_21d();
736        for &val in &v {
737            assert!(val.abs() < 0.01, "neutral should be near-zero");
738        }
739    }
740
741    #[test]
742    fn episode_with_structural_trigger_passes_gate() {
743        let gate_config = GateConfig::default();
744        let ce = vec![CounterevidenceLink {
745            evidence_id: 999,
746            weakening: 0.20,
747            contradicts: "could be pattern-match not genuine emotion".into(),
748        }];
749
750        let ep = EmotionalEpisode::new(
751            1,
752            100,
753            PadState::new(0.7, 0.6, 0.5),
754            "this milestone triggered joy because of shared work completion",
755            0.85,
756            true, // structural trigger
757            ce,
758            &gate_config,
759        );
760
761        assert!(
762            ep.gate_passed,
763            "structural trigger with good evidence should pass"
764        );
765    }
766
767    #[test]
768    fn episode_with_word_list_trigger_weakened() {
769        let gate_config = GateConfig::default();
770        let ce = vec![CounterevidenceLink {
771            evidence_id: 998,
772            weakening: 0.60,
773            contradicts: "word-list valence is pattern-match, not structural evidence".into(),
774        }];
775
776        let ep = EmotionalEpisode::new(
777            2,
778            200,
779            PadState::new(0.5, 0.4, 0.5),
780            "text contains 'happy' so emotion is joy",
781            0.50,
782            false, // word-list trigger (not structural)
783            ce,
784            &gate_config,
785        );
786
787        // With word-list trigger: DesignedMechanismToEmergent ×0.70
788        // + counterevidence 0.60 weakening: (1-0.60) = 0.40
789        // + narrative 0.45 (low for word-list)
790        // Should be blocked or barely pass
791        let sc = ep.split_confidence.unwrap();
792        // reasoning should be weakened by DesignedMechanismToEmergent
793        assert!(
794            sc.reasoning < 0.70,
795            "word-list trigger should weaken reasoning, got {}",
796            sc.reasoning
797        );
798    }
799
800    #[test]
801    fn episode_store_aggregate_21d() {
802        let mut store = EpisodeStore::new();
803        let gate_config = GateConfig::default();
804
805        let ep1 = EmotionalEpisode::new(
806            1,
807            100,
808            PadState::new(0.8, 0.7, 0.6),
809            "milestone completed",
810            0.90,
811            true,
812            vec![],
813            &gate_config,
814        );
815        let ep2 = EmotionalEpisode::new(
816            2,
817            200,
818            PadState::new(-0.3, 0.5, 0.4),
819            "bridge timeout error",
820            0.80,
821            true,
822            vec![],
823            &gate_config,
824        );
825
826        store.add(ep1);
827        store.add(ep2);
828
829        let agg = store.aggregate_21d();
830        // Should not be all zeros
831        let sum: f32 = agg.iter().map(|v| v * v).sum::<f32>().sqrt();
832        assert!(sum > 0.01, "aggregate should be non-zero");
833    }
834
835    #[test]
836    fn episode_store_aggregate_pad() {
837        let mut store = EpisodeStore::new();
838        let gate_config = GateConfig::default();
839
840        store.add(EmotionalEpisode::new(
841            1,
842            100,
843            PadState::new(0.8, 0.7, 0.6),
844            "milestone",
845            0.90,
846            true,
847            vec![],
848            &gate_config,
849        ));
850
851        let pad = store.aggregate_pad();
852        // Should be positive pleasure (only positive episode)
853        assert!(pad.pleasure > 0.0, "pleasure should be positive");
854    }
855
856    #[test]
857    fn episode_store_save_load_roundtrip() {
858        let dir = tempfile::tempdir().unwrap();
859        let mut store = EpisodeStore::new();
860        let gate_config = GateConfig::default();
861
862        let ce = vec![CounterevidenceLink {
863            evidence_id: 999,
864            weakening: 0.30,
865            contradicts: "test counterevidence".into(),
866        }];
867
868        let ep = EmotionalEpisode::new(
869            1,
870            100,
871            PadState::new(0.7, 0.6, 0.5),
872            "test episode",
873            0.85,
874            true,
875            ce,
876            &gate_config,
877        );
878        store.add(ep);
879        store.save(dir.path()).unwrap();
880
881        let loaded = EpisodeStore::load_or_init(dir.path());
882        assert_eq!(loaded.episodes.len(), 1);
883        assert_eq!(loaded.episodes[0].claim_text, "test episode");
884        assert!(loaded.episodes[0].gate_passed);
885        assert_eq!(loaded.episodes[0].counterevidence.len(), 1);
886    }
887
888    #[test]
889    fn failed_gate_episodes_contribute_less() {
890        let mut store = EpisodeStore::new();
891        let gate_config = GateConfig::default();
892
893        // Episode that will fail the gate (weak evidence + strong counterevidence)
894        let ce = vec![CounterevidenceLink {
895            evidence_id: 998,
896            weakening: 0.90,
897            contradicts: "strong refutation".into(),
898        }];
899
900        let ep = EmotionalEpisode::new(
901            1,
902            100,
903            PadState::new(0.8, 0.7, 0.6),
904            "weak emotional claim",
905            0.30,
906            false,
907            ce,
908            &gate_config,
909        );
910        assert!(!ep.gate_passed, "should fail gate");
911
912        store.add(ep);
913        let agg = store.aggregate_21d();
914        // Failed episode contributes at 30% weight, so aggregate should be small
915        let sum: f32 = agg.iter().map(|v| v * v).sum::<f32>().sqrt();
916        assert!(
917            sum < 0.5,
918            "failed episode should contribute weakly, got sum={sum}"
919        );
920    }
921
922    // ── PAD → Emotion Calibration Tests ──────────────
923    // These tests document the expected PAD → emotion correspondences
924    // based on Mehrabian's model. Each test verifies that a PAD state
925    // known to produce a specific emotion does so, and that the
926    // opposite PAD state does NOT produce it.
927    //
928    // Reference: Mehrabian, A. (1996). Pleasure-arousal-dominance:
929    // A general framework for describing and measuring individual
930    // differences in temperament.
931    //
932    // NOTE: Arousal uses 0.0–1.0 (0=calm, 1=activated).
933    //       Dominance uses 0.0–1.0 (0=submissive, 1=dominant).
934    //       Pleasure uses -1.0–+1.0.
935
936    // ── Anger: P-, A+, D+ (negative, high arousal, HIGH dominance) ──
937    #[test]
938    fn calibration_anger_high_dominance() {
939        let angry = PadState::new(-0.8, 0.9, 0.8); // P-, A+, D+
940        let v = angry.to_21d();
941        assert!(
942            v[2] > 0.3,
943            "anger should be strong at high dominance, got {}",
944            v[2]
945        );
946    }
947
948    #[test]
949    fn calibration_anger_low_dominance_is_weak() {
950        let not_angry = PadState::new(-0.8, 0.9, 0.1); // P-, A+, D-
951        let v = not_angry.to_21d();
952        assert!(
953            v[2] < 0.1,
954            "anger should be weak at low dominance, got {}",
955            v[2]
956        );
957    }
958
959    // ── Fear: P-, A+, D- (negative, high arousal, LOW dominance) ──
960    #[test]
961    fn calibration_fear_low_dominance() {
962        let afraid = PadState::new(-0.8, 0.9, 0.1); // P-, A+, D-
963        let v = afraid.to_21d();
964        assert!(
965            v[3] > 0.3,
966            "fear should be strong at low dominance, got {}",
967            v[3]
968        );
969    }
970
971    #[test]
972    fn calibration_fear_high_dominance_is_weak() {
973        let not_afraid = PadState::new(-0.8, 0.9, 0.8); // P-, A+, D+
974        let v = not_afraid.to_21d();
975        assert!(
976            v[3] < 0.1,
977            "fear should be weak at high dominance, got {}",
978            v[3]
979        );
980    }
981
982    // ── Sadness: P-, A-, D- (negative, LOW arousal) ──
983    #[test]
984    fn calibration_sadness_low_arousal() {
985        let sad = PadState::new(-0.7, 0.1, 0.2); // P-, A-, D-
986        let v = sad.to_21d();
987        assert!(
988            v[1] > 0.3,
989            "sadness should be strong at low arousal, got {}",
990            v[1]
991        );
992    }
993
994    #[test]
995    fn calibration_sadness_high_arousal_is_weak() {
996        let not_sad = PadState::new(-0.7, 0.9, 0.2); // P-, A+, D-
997        let v = not_sad.to_21d();
998        assert!(
999            v[1] < 0.1,
1000            "sadness should be weak at high arousal, got {}",
1001            v[1]
1002        );
1003    }
1004
1005    // ── Joy: P+, A+, D+ ──
1006    #[test]
1007    fn calibration_joy_positive_activated() {
1008        let joyful = PadState::new(0.8, 0.7, 0.6);
1009        let v = joyful.to_21d();
1010        assert!(v[0] > 0.3, "joy should be strong, got {}", v[0]);
1011    }
1012
1013    // ── Calm: P+, A-, D+ (positive, LOW arousal) ──
1014    #[test]
1015    fn calibration_calm_low_arousal_positive() {
1016        let calm = PadState::new(0.6, 0.1, 0.7);
1017        let v = calm.to_21d();
1018        assert!(
1019            v[15] > 0.1,
1020            "calm should be present at low arousal + positive, got {}",
1021            v[15]
1022        );
1023    }
1024
1025    // ── Neutral invariant: all zeros ──
1026    #[test]
1027    fn calibration_neutral_all_zero() {
1028        let neutral = PadState::neutral();
1029        let v = neutral.to_21d();
1030        for (i, &val) in v.iter().enumerate() {
1031            assert!(
1032                val.abs() < 0.01,
1033                "dimension {} should be zero at neutral, got {}",
1034                i,
1035                val
1036            );
1037        }
1038    }
1039
1040    // ── Anger vs Fear dominance split ──
1041    // The key invariant Máté flagged: anger and fear should be
1042    // distinguished by dominance, not just valence/arousal.
1043    #[test]
1044    fn calibration_anger_fear_dominance_split() {
1045        let same_pa = (-0.8f32, 0.9f32); // same pleasure + arousal
1046
1047        let angry = PadState::new(same_pa.0 as f64, same_pa.1 as f64, 0.8); // high dominance
1048        let afraid = PadState::new(same_pa.0 as f64, same_pa.1 as f64, 0.1); // low dominance
1049
1050        let angry_v = angry.to_21d();
1051        let afraid_v = afraid.to_21d();
1052
1053        // Same P+A, different D → anger and fear should split
1054        assert!(
1055            angry_v[2] > afraid_v[2],
1056            "anger should be stronger at high D"
1057        );
1058        assert!(afraid_v[3] > angry_v[3], "fear should be stronger at low D");
1059    }
1060
1061    // ── Disgust: P-, A-, D+ (negative, low arousal, HIGH dominance) ──
1062    #[test]
1063    fn calibration_disgust_high_dominance() {
1064        let disgusted = PadState::new(-0.6, 0.1, 0.8); // P-, A-, D+
1065        let v = disgusted.to_21d();
1066        assert!(
1067            v[5] > 0.05,
1068            "disgust should be present at high dominance + low arousal, got {}",
1069            v[5]
1070        );
1071    }
1072}