1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6use wm_core::{Coordinate5D, Galaxy, HolographicCoords};
7
8pub type MemoryId = Uuid;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum MemoryType {
15 ShortTerm,
17 #[default]
19 LongTerm,
20 Emotional,
22 Narrative,
24 Symbolic,
26 Pattern,
28 Procedural,
30 Citta,
32 Hypothesis,
34}
35
36impl MemoryType {
37 #[must_use]
39 pub const fn all() -> &'static [Self] {
40 &[
41 Self::ShortTerm,
42 Self::LongTerm,
43 Self::Emotional,
44 Self::Narrative,
45 Self::Symbolic,
46 Self::Pattern,
47 Self::Procedural,
48 Self::Citta,
49 Self::Hypothesis,
50 ]
51 }
52
53 #[must_use]
55 pub const fn as_str(self) -> &'static str {
56 match self {
57 Self::ShortTerm => "short_term",
58 Self::LongTerm => "long_term",
59 Self::Emotional => "emotional",
60 Self::Narrative => "narrative",
61 Self::Symbolic => "symbolic",
62 Self::Pattern => "pattern",
63 Self::Procedural => "procedural",
64 Self::Citta => "citta",
65 Self::Hypothesis => "hypothesis",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum Tier {
83 Working,
85 #[default]
87 Episodic,
88 Semantic,
90 Archival,
92}
93
94impl Tier {
95 #[must_use]
97 pub const fn as_str(self) -> &'static str {
98 match self {
99 Self::Working => "working",
100 Self::Episodic => "episodic",
101 Self::Semantic => "semantic",
102 Self::Archival => "archival",
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct MemoryMetadata {
110 pub id: MemoryId,
112 pub galaxy: Galaxy,
114 pub content_hash: String,
116 pub tags: Vec<String>,
118 pub importance: f32,
120 pub created_at: DateTime<Utc>,
122 pub accessed_at: DateTime<Utc>,
124 pub access_count: u64,
126 pub coords: HolographicCoords,
128 #[serde(default = "default_coord5d")]
130 pub coord5d: Coordinate5D,
131 #[serde(default)]
134 pub memory_type: MemoryType,
135 #[serde(default = "default_neuro_score")]
137 pub neuro_score: f32,
138 #[serde(default = "default_novelty_score")]
140 pub novelty_score: f32,
141 #[serde(default)]
143 pub emotional_valence: f32,
144 #[serde(default)]
146 pub emotional_weight: f32,
147 #[serde(default)]
149 pub is_protected: bool,
150 #[serde(default)]
152 pub is_private: bool,
153 #[serde(default)]
155 pub model_exclude: bool,
156 #[serde(default = "default_source")]
158 pub source: String,
159 #[serde(default = "default_source_trust")]
161 pub source_trust: f32,
162 #[serde(default = "default_half_life_days")]
164 pub half_life_days: f32,
165 #[serde(default)]
167 pub recall_count: u64,
168 #[serde(default = "default_version")]
170 pub version: u64,
171 #[serde(default = "default_agent_id")]
173 pub agent_id: String,
174 #[serde(default)]
177 pub title: Option<String>,
178 #[serde(default)]
180 pub topic: Option<String>,
181 #[serde(default)]
184 pub tier: Tier,
185 #[serde(default)]
190 pub class: Option<crate::typology::MemoryClass>,
191 #[serde(default)]
194 pub dup_count: u64,
195 #[serde(default)]
201 pub validity: wm_core::episodic::ValidityState,
202 #[serde(default)]
210 pub corroborated_by: Vec<uuid::Uuid>,
211 #[serde(default)]
215 pub revision_count: u32,
216}
217
218const fn default_coord5d() -> Coordinate5D {
219 Coordinate5D::new(0.5, 0.5, 0.5, 0.5, 0.5)
220}
221
222const fn default_neuro_score() -> f32 {
223 0.5
224}
225
226const fn default_novelty_score() -> f32 {
227 1.0
228}
229
230fn default_source() -> String {
231 "unattributed".to_string()
237}
238
239const fn default_source_trust() -> f32 {
240 0.5
245}
246
247#[must_use]
254pub fn validity_enforced() -> bool {
255 matches!(std::env::var("WM_VALIDITY_ENFORCE"), Ok(v) if v == "1")
256}
257
258#[must_use]
265pub fn corroboration_weight() -> f32 {
266 match std::env::var("WM_CORROBORATION_WEIGHT") {
267 Ok(v) => v.parse::<f32>().map_or(0.0, |w| {
268 if w.is_finite() && w >= 0.0 {
269 w.min(1.0)
270 } else {
271 0.0
272 }
273 }),
274 Err(_) => 0.0,
275 }
276}
277
278#[allow(clippy::suboptimal_flops)]
288#[must_use]
289pub fn corroboration_boost(score: f32, distinct_sessions: usize, weight: f32) -> f32 {
290 if weight <= 0.0 || distinct_sessions == 0 {
291 return score;
292 }
293 let n = distinct_sessions as f32;
294 score * (1.0 + weight * (n / (n + 2.0)))
295}
296
297#[allow(clippy::suboptimal_flops)]
307#[must_use]
308pub fn trust_weighted_score(score: f32, source_trust: f32, weight: f32) -> f32 {
309 let factor = 1.0 + weight * (source_trust.clamp(0.0, 1.0) - 0.7);
310 score * factor.max(0.0)
311}
312
313const fn default_half_life_days() -> f32 {
314 30.0
315}
316
317const fn default_version() -> u64 {
318 1
319}
320
321fn default_agent_id() -> String {
322 "system".to_string()
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct Memory {
328 pub metadata: MemoryMetadata,
330 pub content: String,
332 pub embedding: Option<Vec<f32>>,
334}
335
336impl Memory {
337 #[must_use]
339 pub fn new(galaxy: Galaxy, content: String) -> Self {
340 let now = Utc::now();
341 let id = Uuid::new_v4();
342 let content_hash = content_hash(&content);
343 let coord5d = Coordinate5D::encode_with_context(&content, 0.5, 0.5);
344 Self {
345 metadata: MemoryMetadata {
346 id,
347 galaxy,
348 content_hash,
349 tags: vec![],
350 importance: 0.5,
351 created_at: now,
352 accessed_at: now,
353 access_count: 0,
354 coords: HolographicCoords::new(galaxy, now.timestamp() as u64),
355 coord5d,
356 memory_type: MemoryType::default(),
357 neuro_score: default_neuro_score(),
358 novelty_score: default_novelty_score(),
359 emotional_valence: 0.0,
360 emotional_weight: 0.0,
361 is_protected: false,
362 is_private: false,
363 model_exclude: false,
364 source: default_source(),
365 source_trust: default_source_trust(),
366 half_life_days: default_half_life_days(),
367 recall_count: 0,
368 version: default_version(),
369 agent_id: default_agent_id(),
370 title: None,
371 topic: None,
372 tier: Tier::Working,
373 class: crate::typology::detect_class(&content, &[]),
374 dup_count: 0,
375 validity: wm_core::episodic::ValidityState::default(),
376 corroborated_by: Vec::new(),
377 revision_count: 0,
378 },
379 content,
380 embedding: None,
381 }
382 }
383
384 #[must_use]
386 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
387 self.metadata.tags = tags;
388 self
389 }
390
391 #[must_use]
393 pub const fn with_importance(mut self, importance: f32) -> Self {
394 self.metadata.importance = importance.clamp(0.0, 1.0);
395 self.metadata.coord5d.v = self.metadata.importance;
396 self
397 }
398
399 #[must_use]
401 pub const fn with_memory_type(mut self, memory_type: MemoryType) -> Self {
402 self.metadata.memory_type = memory_type;
403 self
404 }
405
406 #[must_use]
408 pub const fn with_emotional_valence(mut self, valence: f32, weight: f32) -> Self {
409 self.metadata.emotional_valence = valence.clamp(-1.0, 1.0);
410 self.metadata.emotional_weight = weight.clamp(0.0, 1.0);
411 self
412 }
413
414 #[must_use]
416 pub const fn with_protection(mut self, protected: bool) -> Self {
417 self.metadata.is_protected = protected;
418 self
419 }
420
421 #[must_use]
423 pub fn with_source(mut self, source: String, trust: f32) -> Self {
424 self.metadata.source = source;
425 self.metadata.source_trust = trust.clamp(0.0, 1.0);
426 self
427 }
428
429 #[must_use]
431 pub const fn with_half_life_days(mut self, days: f32) -> Self {
432 self.metadata.half_life_days = days.max(1.0);
433 self
434 }
435
436 #[must_use]
438 pub const fn with_neuro_score(mut self, score: f32) -> Self {
439 self.metadata.neuro_score = score.clamp(0.0, 1.0);
440 self
441 }
442
443 #[must_use]
445 pub const fn with_novelty_score(mut self, score: f32) -> Self {
446 self.metadata.novelty_score = score.clamp(0.0, 1.0);
447 self
448 }
449
450 #[must_use]
452 pub const fn with_privacy(mut self, is_private: bool, model_exclude: bool) -> Self {
453 self.metadata.is_private = is_private;
454 self.metadata.model_exclude = model_exclude;
455 self
456 }
457
458 pub fn transition_tier(&mut self, to: Tier) -> Result<(), wm_core::CoreError> {
473 let legal = matches!(
474 (self.metadata.tier, to),
475 (Tier::Working, Tier::Episodic | Tier::Archival)
476 | (Tier::Episodic, Tier::Semantic | Tier::Archival)
477 | (Tier::Semantic, Tier::Archival)
478 | (Tier::Archival, Tier::Episodic)
479 );
480 if !legal {
481 return Err(wm_core::CoreError::InvalidArgs(format!(
482 "illegal tier transition {} -> {} (dream-cycle ladder: one step forward, \
483 decay-out to archival, or archival promotion on read)",
484 self.metadata.tier.as_str(),
485 to.as_str()
486 )));
487 }
488 self.metadata.tier = to;
489 Ok(())
490 }
491
492 pub fn transition_validity(
501 &mut self,
502 transition: wm_core::episodic::MemoryTransition,
503 ) -> Result<(), wm_core::episodic::ValidityTransitionError> {
504 let id = self.metadata.id;
505 self.metadata.validity.transition(id, transition)
506 }
507
508 #[must_use]
510 pub fn with_agent(mut self, agent_id: String, version: u64) -> Self {
511 self.metadata.agent_id = agent_id;
512 self.metadata.version = version;
513 self
514 }
515
516 #[must_use]
518 pub fn with_embedding(mut self, embedding: Vec<f32>) -> Self {
519 self.embedding = Some(embedding);
520 self
521 }
522
523 pub fn record_access(&mut self) {
525 self.metadata.accessed_at = Utc::now();
526 self.metadata.access_count += 1;
527 }
528
529 pub fn recall(&mut self) {
535 let now = Utc::now();
536 self.metadata.accessed_at = now;
537 self.metadata.access_count += 1;
538 self.metadata.recall_count += 1;
539
540 let boost = 0.05 * (1.0 - self.metadata.neuro_score);
542 self.metadata.neuro_score = (self.metadata.neuro_score + boost).clamp(0.0, 1.0);
543
544 self.metadata.novelty_score = (self.metadata.novelty_score * 0.9).clamp(0.0, 1.0);
546 }
547
548 pub fn decay(&mut self, now: DateTime<Utc>) {
554 if self.metadata.is_protected {
555 return;
556 }
557 let days_since = ((now - self.metadata.accessed_at).num_seconds() as f32) / 86_400.0;
558 if days_since <= 0.0 {
559 return;
560 }
561 let half_life = self.metadata.half_life_days.max(1.0);
562 let factor = 0.5_f32.powf(days_since / half_life);
563 self.metadata.neuro_score = (self.metadata.neuro_score * factor).clamp(0.0, 1.0);
564 }
565
566 pub fn decay_importance(&mut self, factor: f32) {
568 if self.metadata.is_protected {
569 return;
570 }
571 self.metadata.importance = (self.metadata.importance * factor).clamp(0.0, 1.0);
572 }
573
574 #[must_use]
577 pub fn should_forget(&self, threshold: f32) -> bool {
578 !self.metadata.is_protected && self.metadata.importance < threshold
579 }
580
581 #[must_use]
585 pub fn is_telemetry_or_noise(&self) -> bool {
586 if self.metadata.is_protected {
588 return false;
589 }
590
591 for tag in &self.metadata.tags {
593 let t = tag.to_lowercase();
594 if t.contains("decision")
595 || t.contains("breakthrough")
596 || t.contains("architecture")
597 || t.contains("policy")
598 || t.contains("canon")
599 || t.contains("aria")
600 || t.contains("insight")
601 || t.contains("lineage")
602 {
603 return false;
604 }
605 }
606
607 for tag in &self.metadata.tags {
609 let t = tag.to_lowercase();
610 if t.contains("telemetry")
611 || t.contains("friction")
612 || t.contains("turn_type")
613 || t.contains("raw-archive")
614 || t.contains("error_dump")
615 || t.contains("benchmark")
616 || t.contains("probe")
617 || t.contains("rsi:telemetry")
618 {
619 return true;
620 }
621 }
622
623 let content = &self.content;
625 if (content.starts_with('{') || content.starts_with('['))
626 && (content.contains("\"turn_type\"")
627 || content.contains("\"friction\"")
628 || content.contains("\"latency_ms\"")
629 || content.contains("\"raw_archive\""))
630 {
631 return true;
632 }
633
634 if self.metadata.importance < 0.25 && content.len() < 50 {
636 return true;
637 }
638
639 false
640 }
641}
642
643#[must_use]
645pub fn content_hash(content: &str) -> String {
646 use sha2::{Digest, Sha256};
647 let hasher = Sha256::digest(content.as_bytes());
648 format!("{hasher:x}")
649}
650
651#[must_use]
653pub fn encode_embedding(embedding: &[f32]) -> Vec<u8> {
654 let mut bytes = Vec::with_capacity(embedding.len() * 4);
655 for &v in embedding {
656 bytes.extend_from_slice(&v.to_le_bytes());
657 }
658 bytes
659}
660
661#[must_use]
663pub fn decode_embedding(bytes: &[u8]) -> Vec<f32> {
664 bytes
665 .chunks_exact(4)
666 .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
667 .collect()
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use crate::MemoryStore;
674 use chrono::Duration;
675 use wm_core::Galaxy;
676
677 #[test]
680 fn trust_weighted_score_semantics() {
681 assert!((trust_weighted_score(2.0, 1.0, 0.0) - 2.0).abs() < 1e-5);
683 assert!((trust_weighted_score(2.0, 0.0, 0.0) - 2.0).abs() < 1e-5);
684
685 for w in [0.0f32, 0.15, 0.5, 1.0] {
687 assert!((trust_weighted_score(3.0, 0.7, w) - 3.0).abs() < 1e-5);
688 }
689
690 assert!(trust_weighted_score(1.0, 1.0, 0.5) > 1.0);
692 assert!(trust_weighted_score(1.0, 0.2, 0.5) < 1.0);
693 assert!((trust_weighted_score(1.0, 1.0, 0.5) - 1.15).abs() < 1e-5);
694 assert!((trust_weighted_score(1.0, 0.0, 0.5) - 0.65).abs() < 1e-5);
695
696 assert!((trust_weighted_score(2.0, 0.0, 1.0) - 0.6).abs() < 1e-5);
698 assert!((trust_weighted_score(2.0, 5.0, 1.0) - 2.6).abs() < 1e-5);
699 assert!((trust_weighted_score(2.0, -1.0, 1.0) - 0.6).abs() < 1e-5);
700 }
701
702 #[test]
705 fn memory_type_default_is_long_term() {
706 assert_eq!(MemoryType::default(), MemoryType::LongTerm);
707 }
708
709 #[test]
710 fn memory_type_all_has_9_variants() {
711 assert_eq!(MemoryType::all().len(), 9);
712 }
713
714 #[test]
715 fn memory_type_as_str_roundtrip() {
716 for &mt in MemoryType::all() {
717 let s = mt.as_str();
718 let json = serde_json::to_string(&mt).unwrap();
719 let expected = format!("\"{s}\"");
721 assert_eq!(json, expected);
722 }
723 }
724
725 #[test]
726 fn memory_type_serde_roundtrip() {
727 for &mt in MemoryType::all() {
728 let json = serde_json::to_string(&mt).unwrap();
729 let back: MemoryType = serde_json::from_str(&json).unwrap();
730 assert_eq!(mt, back);
731 }
732 }
733
734 #[test]
737 fn new_memory_has_enriched_defaults() {
738 let mem = Memory::new(Galaxy::Codex, "test".into());
739 let m = &mem.metadata;
740
741 assert_eq!(m.memory_type, MemoryType::LongTerm);
742 assert!((m.neuro_score - 0.5).abs() < f32::EPSILON);
743 assert!((m.novelty_score - 1.0).abs() < f32::EPSILON);
744 assert!((m.emotional_valence).abs() < f32::EPSILON);
745 assert!((m.emotional_weight).abs() < f32::EPSILON);
746 assert!(!m.is_protected);
747 assert!(!m.is_private);
748 assert!(!m.model_exclude);
749 assert_eq!(m.source, "unattributed");
750 assert!((m.source_trust - 0.5).abs() < f32::EPSILON);
751 assert!((m.half_life_days - 30.0).abs() < f32::EPSILON);
752 assert_eq!(m.recall_count, 0);
753 assert_eq!(m.version, 1);
754 assert_eq!(m.agent_id, "system");
755 }
756
757 #[test]
760 fn new_memory_validity_defaults_active() {
761 let mem = Memory::new(Galaxy::Codex, "test".into());
762 assert_eq!(
763 mem.metadata.validity,
764 wm_core::episodic::ValidityState::Active
765 );
766 assert!(mem.metadata.validity.is_current());
767 }
768
769 #[test]
770 fn legacy_metadata_without_validity_deserializes_active() {
771 let mem = Memory::new(Galaxy::Codex, "test".into());
774 let mut json = serde_json::to_value(&mem.metadata).unwrap();
775 json.as_object_mut().unwrap().remove("validity");
776 let back: MemoryMetadata = serde_json::from_value(json).unwrap();
777 assert_eq!(back.validity, wm_core::episodic::ValidityState::Active);
778 }
779
780 #[test]
781 fn transition_validity_supersede_roundtrip() {
782 let mut mem = Memory::new(Galaxy::Codex, "old claim".into());
783 let replacement = uuid::Uuid::new_v4();
784 mem.transition_validity(wm_core::episodic::MemoryTransition::Supersede { replacement })
785 .unwrap();
786 assert_eq!(
787 mem.metadata.validity,
788 wm_core::episodic::ValidityState::Superseded { by: replacement }
789 );
790 assert!(!mem.metadata.validity.is_current());
791 assert!(mem.metadata.validity.is_historical());
792 }
793
794 #[test]
795 fn transition_validity_refuses_self_supersession() {
796 let mut mem = Memory::new(Galaxy::Codex, "test".into());
797 let own = mem.metadata.id;
798 let err = mem
799 .transition_validity(wm_core::episodic::MemoryTransition::Supersede {
800 replacement: own,
801 })
802 .unwrap_err();
803 assert_eq!(
804 err,
805 wm_core::episodic::ValidityTransitionError::SelfSupersession
806 );
807 assert!(mem.metadata.validity.is_current());
808 }
809
810 #[test]
811 fn transition_validity_erased_is_terminal() {
812 let mut mem = Memory::new(Galaxy::Codex, "test".into());
813 mem.transition_validity(wm_core::episodic::MemoryTransition::Erase)
814 .unwrap();
815 assert_eq!(
816 mem.metadata.validity,
817 wm_core::episodic::ValidityState::Erased
818 );
819 let err = mem
820 .transition_validity(wm_core::episodic::MemoryTransition::Archive)
821 .unwrap_err();
822 assert_eq!(err, wm_core::episodic::ValidityTransitionError::Erased);
823 }
824
825 #[test]
828 fn new_memory_has_no_corroborators() {
829 let mem = Memory::new(Galaxy::Codex, "test".into());
830 assert!(mem.metadata.corroborated_by.is_empty());
831 }
832
833 #[test]
834 fn legacy_metadata_without_corroboration_deserializes_empty() {
835 let mem = Memory::new(Galaxy::Codex, "test".into());
836 let mut json = serde_json::to_value(&mem.metadata).unwrap();
837 json.as_object_mut().unwrap().remove("corroborated_by");
838 let back: MemoryMetadata = serde_json::from_value(json).unwrap();
839 assert!(back.corroborated_by.is_empty());
840 }
841
842 #[test]
843 fn corroboration_boost_semantics() {
844 assert!((corroboration_boost(2.0, 5, 0.0) - 2.0).abs() < 1e-5);
846 assert!((corroboration_boost(2.0, 0, 1.0) - 2.0).abs() < 1e-5);
848 let first = corroboration_boost(3.0, 1, 0.6);
850 assert!((first - 3.6).abs() < 1e-5);
851 let b1 = corroboration_boost(1.0, 1, 1.0);
853 let b2 = corroboration_boost(1.0, 2, 1.0);
854 let b100 = corroboration_boost(1.0, 100, 1.0);
855 assert!(b2 > b1 && b100 > b2 && b100 < 2.0);
856 assert!((b2 - 1.5).abs() < 1e-5);
857 }
858
859 #[test]
860 fn corroboration_weight_defaults_off() {
861 assert!((corroboration_weight() - 0.0).abs() < f32::EPSILON);
864 }
865
866 #[test]
867 fn validity_enforced_defaults_off() {
868 assert!(!validity_enforced());
873 }
874
875 #[test]
878 fn recall_boosts_neuro_score() {
879 let mut mem = Memory::new(Galaxy::Codex, "test".into());
880 let initial = mem.metadata.neuro_score;
881 mem.recall();
882 assert!(mem.metadata.neuro_score > initial);
883 assert_eq!(mem.metadata.recall_count, 1);
884 assert_eq!(mem.metadata.access_count, 1);
885 }
886
887 #[test]
888 fn recall_has_diminishing_returns() {
889 let mut mem = Memory::new(Galaxy::Codex, "test".into());
890 mem.metadata.neuro_score = 0.9;
891
892 mem.recall();
893 let boost_high = 0.05 * (1.0 - 0.9); assert!((mem.metadata.neuro_score - (0.9 + boost_high)).abs() < 1e-5);
895
896 let mut mem2 = Memory::new(Galaxy::Codex, "test".into());
898 mem2.metadata.neuro_score = 0.1;
899 mem2.recall();
900 let boost_low = 0.05 * (1.0 - 0.1); assert!((mem2.metadata.neuro_score - (0.1 + boost_low)).abs() < 1e-5);
902 }
903
904 #[test]
905 fn recall_decays_novelty() {
906 let mut mem = Memory::new(Galaxy::Codex, "test".into());
907 let initial_novelty = mem.metadata.novelty_score;
908 mem.recall();
909 assert!(mem.metadata.novelty_score < initial_novelty);
910 assert!((initial_novelty.mul_add(-0.9, mem.metadata.novelty_score)).abs() < 1e-5);
911 }
912
913 #[test]
914 fn recall_neuro_score_caps_at_1() {
915 let mut mem = Memory::new(Galaxy::Codex, "test".into());
916 mem.metadata.neuro_score = 0.99;
917 for _ in 0..100 {
918 mem.recall();
919 }
920 assert!(mem.metadata.neuro_score > 0.999);
922 assert!(mem.metadata.neuro_score <= 1.0);
923 }
924
925 #[test]
928 fn decay_reduces_neuro_score_over_time() {
929 let mut mem = Memory::new(Galaxy::Codex, "test".into());
930 mem.metadata.neuro_score = 1.0;
931 mem.metadata.half_life_days = 30.0;
932
933 mem.metadata.accessed_at = Utc::now() - Duration::days(30);
935 mem.decay(Utc::now());
936
937 assert!((mem.metadata.neuro_score - 0.5).abs() < 0.01);
939 }
940
941 #[test]
942 fn decay_zero_time_is_noop() {
943 let mut mem = Memory::new(Galaxy::Codex, "test".into());
944 let score = mem.metadata.neuro_score;
945 mem.decay(Utc::now());
946 assert!((mem.metadata.neuro_score - score).abs() < f32::EPSILON);
947 }
948
949 #[test]
950 fn decay_respects_is_protected() {
951 let mut mem = Memory::new(Galaxy::Codex, "test".into());
952 mem.metadata.neuro_score = 1.0;
953 mem.metadata.is_protected = true;
954 mem.metadata.accessed_at = Utc::now() - Duration::days(365);
955 mem.decay(Utc::now());
956 assert!((mem.metadata.neuro_score - 1.0).abs() < f32::EPSILON);
957 }
958
959 #[test]
960 fn decay_uses_per_memory_half_life() {
961 let mut mem_short = Memory::new(Galaxy::Codex, "short".into());
962 mem_short.metadata.neuro_score = 1.0;
963 mem_short.metadata.half_life_days = 7.0;
964 mem_short.metadata.accessed_at = Utc::now() - Duration::days(7);
965
966 let mut mem_long = Memory::new(Galaxy::Codex, "long".into());
967 mem_long.metadata.neuro_score = 1.0;
968 mem_long.metadata.half_life_days = 90.0;
969 mem_long.metadata.accessed_at = Utc::now() - Duration::days(7);
970
971 mem_short.decay(Utc::now());
972 mem_long.decay(Utc::now());
973
974 assert!(mem_short.metadata.neuro_score < mem_long.metadata.neuro_score);
976 }
977
978 #[test]
981 fn should_forget_respects_protection() {
982 let mut mem = Memory::new(Galaxy::Codex, "test".into());
983 mem.metadata.importance = 0.01;
984 mem.metadata.is_protected = true;
985 assert!(!mem.should_forget(0.1));
986 }
987
988 #[test]
989 fn decay_importance_respects_protection() {
990 let mut mem = Memory::new(Galaxy::Codex, "test".into());
991 mem.metadata.importance = 0.5;
992 mem.metadata.is_protected = true;
993 mem.decay_importance(0.5);
994 assert!((mem.metadata.importance - 0.5).abs() < f32::EPSILON);
995 }
996
997 #[test]
1000 fn with_memory_type_builder() {
1001 let mem = Memory::new(Galaxy::Codex, "test".into()).with_memory_type(MemoryType::Emotional);
1002 assert_eq!(mem.metadata.memory_type, MemoryType::Emotional);
1003 }
1004
1005 #[test]
1006 fn with_emotional_valence_clamps() {
1007 let mem = Memory::new(Galaxy::Codex, "test".into()).with_emotional_valence(2.0, 2.0);
1008 assert!((mem.metadata.emotional_valence - 1.0).abs() < f32::EPSILON);
1009 assert!((mem.metadata.emotional_weight - 1.0).abs() < f32::EPSILON);
1010
1011 let mem2 = Memory::new(Galaxy::Codex, "test".into()).with_emotional_valence(-2.0, -1.0);
1012 assert!((mem2.metadata.emotional_valence - (-1.0)).abs() < f32::EPSILON);
1013 assert!((mem2.metadata.emotional_weight).abs() < f32::EPSILON);
1014 }
1015
1016 #[test]
1017 fn with_protection_builder() {
1018 let mem = Memory::new(Galaxy::Codex, "test".into()).with_protection(true);
1019 assert!(mem.metadata.is_protected);
1020 }
1021
1022 #[test]
1023 fn with_source_builder() {
1024 let mem = Memory::new(Galaxy::Codex, "test".into()).with_source("web".into(), 0.5);
1025 assert_eq!(mem.metadata.source, "web");
1026 assert!((mem.metadata.source_trust - 0.5).abs() < f32::EPSILON);
1027 }
1028
1029 #[test]
1030 fn with_half_life_days_clamps_to_min_1() {
1031 let mem = Memory::new(Galaxy::Codex, "test".into()).with_half_life_days(0.1);
1032 assert!((mem.metadata.half_life_days - 1.0).abs() < f32::EPSILON);
1033 }
1034
1035 #[test]
1036 fn with_neuro_score_clamps() {
1037 let mem = Memory::new(Galaxy::Codex, "test".into()).with_neuro_score(1.5);
1038 assert!((mem.metadata.neuro_score - 1.0).abs() < f32::EPSILON);
1039
1040 let mem2 = Memory::new(Galaxy::Codex, "test".into()).with_neuro_score(-0.5);
1041 assert!((mem2.metadata.neuro_score).abs() < f32::EPSILON);
1042 }
1043
1044 #[test]
1045 fn with_novelty_score_clamps() {
1046 let mem = Memory::new(Galaxy::Codex, "test".into()).with_novelty_score(2.0);
1047 assert!((mem.metadata.novelty_score - 1.0).abs() < f32::EPSILON);
1048 }
1049
1050 #[test]
1051 fn with_privacy_builder() {
1052 let mem = Memory::new(Galaxy::Codex, "secret".into()).with_privacy(true, true);
1053 assert!(mem.metadata.is_private);
1054 assert!(mem.metadata.model_exclude);
1055 }
1056
1057 #[test]
1058 fn with_agent_builder() {
1059 let mem = Memory::new(Galaxy::Codex, "test".into()).with_agent("agent-007".into(), 42);
1060 assert_eq!(mem.metadata.agent_id, "agent-007");
1061 assert_eq!(mem.metadata.version, 42);
1062 }
1063
1064 #[test]
1067 fn serde_backward_compat_missing_enriched_fields() {
1068 let old_json = serde_json::json!({
1071 "metadata": {
1072 "id": uuid::Uuid::new_v4().to_string(),
1073 "galaxy": "Codex",
1074 "content_hash": "abc123",
1075 "tags": [],
1076 "importance": 0.5,
1077 "created_at": "2025-01-01T00:00:00Z",
1078 "accessed_at": "2025-01-01T00:00:00Z",
1079 "access_count": 0,
1080 "coords": {
1081 "galaxy": 2,
1082 "sector": 0,
1083 "radial": 0.5,
1084 "angular": 0.0,
1085 "temporal": 0,
1086 "consciousness": 0.5
1087 }
1088 },
1089 "content": "old memory",
1090 "embedding": null
1091 });
1092
1093 let mem: Memory = serde_json::from_value(old_json).unwrap();
1094 assert_eq!(mem.metadata.memory_type, MemoryType::LongTerm);
1095 assert!((mem.metadata.neuro_score - 0.5).abs() < f32::EPSILON);
1096 assert!((mem.metadata.novelty_score - 1.0).abs() < f32::EPSILON);
1097 assert!(!mem.metadata.is_protected);
1098 assert_eq!(mem.metadata.source, "unattributed");
1102 assert!((mem.metadata.source_trust - 0.5).abs() < f32::EPSILON);
1103 assert!((mem.metadata.half_life_days - 30.0).abs() < f32::EPSILON);
1104 assert_eq!(mem.metadata.recall_count, 0);
1105 assert_eq!(mem.metadata.version, 1);
1106 assert_eq!(mem.metadata.agent_id, "system");
1107 assert_eq!(mem.metadata.title, None);
1110 assert_eq!(mem.metadata.topic, None);
1111 assert_eq!(mem.metadata.tier, Tier::Episodic);
1114 assert_eq!(mem.metadata.class, None);
1115 assert_eq!(mem.metadata.dup_count, 0);
1116 }
1117
1118 #[test]
1119 fn fresh_memory_stamps_working_tier_and_detected_class() {
1120 let friction = Memory::new(
1122 Galaxy::Codex,
1123 "## Auto-logged Friction: Tool dispatch error\n\nbody".into(),
1124 );
1125 assert_eq!(friction.metadata.tier, Tier::Working);
1126 assert_eq!(
1127 friction.metadata.class,
1128 Some(crate::typology::MemoryClass::Telemetry)
1129 );
1130 let plain = Memory::new(Galaxy::Codex, "a normal thought about kumquats".into());
1132 assert_eq!(plain.metadata.tier, Tier::Working);
1133 assert_eq!(plain.metadata.class, None);
1134 }
1135
1136 #[test]
1137 fn tier_transition_ladder_is_enforced() {
1138 let mut m = Memory::new(Galaxy::Codex, "tier ladder".into());
1141 assert_eq!(m.metadata.tier, Tier::Working);
1142
1143 m.transition_tier(Tier::Episodic).unwrap();
1144 assert_eq!(m.metadata.tier, Tier::Episodic);
1145 m.transition_tier(Tier::Semantic).unwrap();
1146 assert_eq!(m.metadata.tier, Tier::Semantic);
1147 m.transition_tier(Tier::Archival).unwrap();
1148 assert_eq!(m.metadata.tier, Tier::Archival);
1149 m.transition_tier(Tier::Episodic).unwrap();
1150 assert_eq!(m.metadata.tier, Tier::Episodic);
1151
1152 m.transition_tier(Tier::Archival).unwrap();
1154 for (from, to) in [
1156 (Tier::Semantic, Tier::Episodic),
1157 (Tier::Semantic, Tier::Working),
1158 (Tier::Archival, Tier::Working),
1159 (Tier::Archival, Tier::Semantic),
1160 (Tier::Working, Tier::Semantic),
1161 ] {
1162 let mut mem = Memory::new(Galaxy::Codex, "illegal move probe".into());
1163 mem.metadata.tier = from;
1164 let err = mem.transition_tier(to).unwrap_err();
1165 assert!(
1166 err.to_string().contains("illegal tier transition"),
1167 "{from:?} -> {to:?} must be refused, got: {err}"
1168 );
1169 assert_eq!(mem.metadata.tier, from, "refused move must not mutate");
1170 }
1171 }
1172
1173 #[test]
1174 fn msgpack_roundtrip_preserves_enriched_fields() {
1175 let mem = Memory::new(Galaxy::Codex, "test".into())
1176 .with_memory_type(MemoryType::Emotional)
1177 .with_emotional_valence(0.8, 0.6)
1178 .with_protection(true)
1179 .with_source("tool".into(), 0.7)
1180 .with_half_life_days(14.0)
1181 .with_neuro_score(0.75)
1182 .with_novelty_score(0.3)
1183 .with_privacy(true, false)
1184 .with_agent("agent-x".into(), 5);
1185
1186 let bytes = rmp_serde::to_vec(&mem).unwrap();
1187 let back: Memory = rmp_serde::from_slice(&bytes).unwrap();
1188
1189 assert_eq!(back.metadata.memory_type, MemoryType::Emotional);
1190 assert!((back.metadata.neuro_score - 0.75).abs() < 1e-5);
1191 assert!((back.metadata.novelty_score - 0.3).abs() < 1e-5);
1192 assert!((back.metadata.emotional_valence - 0.8).abs() < 1e-5);
1193 assert!((back.metadata.emotional_weight - 0.6).abs() < 1e-5);
1194 assert!(back.metadata.is_protected);
1195 assert!(back.metadata.is_private);
1196 assert!(!back.metadata.model_exclude);
1197 assert_eq!(back.metadata.source, "tool");
1198 assert!((back.metadata.source_trust - 0.7).abs() < 1e-5);
1199 assert!((back.metadata.half_life_days - 14.0).abs() < 1e-5);
1200 assert_eq!(back.metadata.agent_id, "agent-x");
1201 assert_eq!(back.metadata.version, 5);
1202 }
1203
1204 #[test]
1207 fn lmdb_roundtrip_preserves_enriched_fields() {
1208 let tmp = tempfile::tempdir().unwrap();
1209 let store = MemoryStore::open_default(tmp.path()).unwrap();
1210
1211 let mem = Memory::new(Galaxy::Codex, "enriched".into())
1212 .with_memory_type(MemoryType::Pattern)
1213 .with_emotional_valence(-0.5, 0.8)
1214 .with_protection(true)
1215 .with_source("inferred".into(), 0.3)
1216 .with_half_life_days(7.0)
1217 .with_neuro_score(0.9)
1218 .with_novelty_score(0.2)
1219 .with_agent("test-agent".into(), 3);
1220
1221 let id = mem.metadata.id;
1222 store.put(Galaxy::Codex, &mem).unwrap();
1223
1224 let back = store.get(Galaxy::Codex, id).unwrap().unwrap();
1225 assert_eq!(back.metadata.memory_type, MemoryType::Pattern);
1226 assert!((back.metadata.neuro_score - 0.9).abs() < 1e-5);
1227 assert!((back.metadata.emotional_valence - (-0.5)).abs() < 1e-5);
1228 assert!(back.metadata.is_protected);
1229 assert_eq!(back.metadata.source, "inferred");
1230 assert!((back.metadata.source_trust - 0.3).abs() < 1e-5);
1231 assert!((back.metadata.half_life_days - 7.0).abs() < 1e-5);
1232 assert_eq!(back.metadata.agent_id, "test-agent");
1233 assert_eq!(back.metadata.version, 3);
1234 }
1235}