1use 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
24const EPISODE_MAGIC: &[u8; 4] = b"EEP1";
27const MAX_EPISODES: usize = 500;
28
29#[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 pub fn intensity(&self) -> f64 {
58 self.arousal * (1.0 - (1.0 - 2.0 * self.dominance).abs())
59 }
60
61 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 [
70 p * a, -p * (1.0 - a), -p * a * d * d, -p * a * (1.0 - d) * (1.0 - d), a * (1.0 - d) * 0.5, -p * d * (1.0 - a), p * d, a * d * 0.5, p * a * d, p * d * 0.8, a * d, -d * a * 0.5, p * a * d * 0.7, -p * a * (1.0 - d) * 0.7, -p * a * (1.0 - d), p * (1.0 - a) * d, p * a * d, (-p).max(0.0) * (1.0 - a) * 0.3, p * a * d * 0.6, -p * a * (1.0 - d) * 0.6, p * a * d * 0.5, ]
92 }
93}
94
95#[derive(Debug, Clone)]
108pub struct EmotionalEpisode {
109 pub episode_id: u64,
110 pub timestamp_ms: u64,
111 pub trigger_evidence_id: u64,
113 pub pad: PadState,
115 pub emotion_21d: [f32; 21],
117 pub dominant_emotion: usize,
119 pub claim_text: String,
121 pub evidence_confidence: f64,
123 pub counterevidence: Vec<CounterevidenceLink>,
125 pub gate_passed: bool,
127 pub split_confidence: Option<SplitConfidence>,
129 pub decay_per_day: f64,
131 pub resonance_links: Vec<u64>,
133 pub event_type: String,
135 pub speech_act: String,
137 pub context_type: String,
139 pub is_self_correction: bool,
141}
142
143impl EmotionalEpisode {
144 #[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 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 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, ®);
184 graph.set_root(root);
185
186 let ce_conf = SplitConfidence::compute_counterevidence(&counterevidence);
188
189 let narrative = if trigger_is_structural { 0.75 } else { 0.45 };
192
193 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 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 if !extraction.trigger_is_structural {
264 return None;
265 }
266
267 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 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 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 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 pub fn is_active(&self) -> bool {
310 self.decayed_intensity() > 0.05
311 }
312
313 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
325pub 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 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 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 if self.episodes.len() > MAX_EPISODES {
361 self.episodes.drain(0..(self.episodes.len() - MAX_EPISODES));
362 }
363
364 id
365 }
366
367 pub fn active_episodes(&self) -> Vec<&EmotionalEpisode> {
369 self.episodes.iter().filter(|e| e.is_active()).collect()
370 }
371
372 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 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 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 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 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 buf.extend_from_slice(&ep.episode_id.to_le_bytes()); buf.extend_from_slice(&ep.timestamp_ms.to_le_bytes()); buf.extend_from_slice(&ep.trigger_evidence_id.to_le_bytes()); buf.extend_from_slice(&ep.pad.pleasure.to_le_bytes()); buf.extend_from_slice(&ep.pad.arousal.to_le_bytes()); buf.extend_from_slice(&ep.pad.dominance.to_le_bytes()); for &v in &ep.emotion_21d {
463 buf.extend_from_slice(&v.to_le_bytes()); }
465 buf.extend_from_slice(&(ep.dominant_emotion as u32).to_le_bytes()); buf.extend_from_slice(&ep.evidence_confidence.to_le_bytes()); buf.push(if ep.gate_passed { 1u8 } else { 0u8 }); if let Some(sc) = ep.split_confidence {
471 buf.push(1u8); buf.extend_from_slice(&sc.evidence.to_le_bytes()); buf.extend_from_slice(&sc.counterevidence.to_le_bytes()); buf.extend_from_slice(&sc.reasoning.to_le_bytes()); buf.extend_from_slice(&sc.narrative.to_le_bytes()); } else {
477 buf.push(0u8);
478 }
479
480 let text_bytes = ep.claim_text.as_bytes();
482 buf.extend_from_slice(&(text_bytes.len() as u32).to_le_bytes()); buf.extend_from_slice(text_bytes);
484
485 buf.extend_from_slice(&ep.decay_per_day.to_le_bytes()); buf.extend_from_slice(&(ep.resonance_links.len() as u32).to_le_bytes()); for &link in &ep.resonance_links {
491 buf.extend_from_slice(&link.to_le_bytes()); }
493
494 buf.extend_from_slice(&(ep.counterevidence.len() as u32).to_le_bytes()); for ce in &ep.counterevidence {
497 buf.extend_from_slice(&ce.evidence_id.to_le_bytes()); buf.extend_from_slice(&ce.weakening.to_le_bytes()); let ct_bytes = ce.contradicts.as_bytes();
500 buf.extend_from_slice(&(ct_bytes.len() as u32).to_le_bytes()); buf.extend_from_slice(ct_bytes);
502 }
503
504 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; 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 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
707fn 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#[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 assert!(v[0] > 0.4, "joy should be high, got {}", v[0]);
728 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, 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, ce,
784 &gate_config,
785 );
786
787 let sc = ep.split_confidence.unwrap();
792 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 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 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 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 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 #[test]
938 fn calibration_anger_high_dominance() {
939 let angry = PadState::new(-0.8, 0.9, 0.8); 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); 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 #[test]
961 fn calibration_fear_low_dominance() {
962 let afraid = PadState::new(-0.8, 0.9, 0.1); 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); 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 #[test]
984 fn calibration_sadness_low_arousal() {
985 let sad = PadState::new(-0.7, 0.1, 0.2); 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); 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 #[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 #[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 #[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 #[test]
1044 fn calibration_anger_fear_dominance_split() {
1045 let same_pa = (-0.8f32, 0.9f32); let angry = PadState::new(same_pa.0 as f64, same_pa.1 as f64, 0.8); let afraid = PadState::new(same_pa.0 as f64, same_pa.1 as f64, 0.1); let angry_v = angry.to_21d();
1051 let afraid_v = afraid.to_21d();
1052
1053 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 #[test]
1063 fn calibration_disgust_high_dominance() {
1064 let disgusted = PadState::new(-0.6, 0.1, 0.8); 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}