Skip to main content

wm_memory/
memory.rs

1//! Memory type — a single memory entry stored in LMDB.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6use wm_core::{Coordinate5D, Galaxy, HolographicCoords};
7
8/// Unique identifier for a memory.
9pub type MemoryId = Uuid;
10
11/// Classification of memory kind, inspired by cognitive science memory systems.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum MemoryType {
15    /// Temporary, easily displaced (working memory)
16    ShortTerm,
17    /// Stable, enduring knowledge
18    #[default]
19    LongTerm,
20    /// Affectively charged (tied to emotions)
21    Emotional,
22    /// Sequential life-event story
23    Narrative,
24    /// Abstract representation / archetype
25    Symbolic,
26    /// Recognized regularity across experiences
27    Pattern,
28    /// Skill / how-to knowledge
29    Procedural,
30    /// Consciousness-cycle observation
31    Citta,
32    /// Imagined outcome / research hypothesis (Imagination Engine)
33    Hypothesis,
34}
35
36impl MemoryType {
37    /// All variants in canonical order.
38    #[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    /// String label for JSON / display.
54    #[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/// Lifecycle tier (V8 S5, `MEMORY_TYPOLOGY_V8.md` §6) — how a memory is
71/// *served* as it ages.
72///
73/// Distinct from [`MemoryType`] (what it *is* cognitively) and from the
74/// typology class (`crate::typology` — which write-gate family it belongs
75/// to). Fresh writes land in [`Tier::Working`]; the dream cycle is the
76/// only tier-transition path (promotion on read, decay-out — §6).
77/// Records written before S5 deserialize as [`Tier::Episodic`] (the warm
78/// default recall surface — today's serving reality), never as something
79/// they were never stamped.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum Tier {
83    /// Hot: current-session working set — briefing-served, decay-out first.
84    Working,
85    /// Warm: Tantivy-indexed, the default recall surface.
86    #[default]
87    Episodic,
88    /// Warm knowledge: consolidated, promotion-eligible survivor.
89    Semantic,
90    /// Cold: sealed archive — explicit queries only.
91    Archival,
92}
93
94impl Tier {
95    /// String label for JSON / display.
96    #[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/// Metadata for a memory entry.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct MemoryMetadata {
110    /// Memory UUID
111    pub id: MemoryId,
112    /// Which galaxy this memory lives in
113    pub galaxy: Galaxy,
114    /// Content hash for deduplication
115    pub content_hash: String,
116    /// Tags for categorization
117    pub tags: Vec<String>,
118    /// Importance score (0.0 to 1.0)
119    pub importance: f32,
120    /// Creation timestamp
121    pub created_at: DateTime<Utc>,
122    /// Last accessed timestamp
123    pub accessed_at: DateTime<Utc>,
124    /// Access count
125    pub access_count: u64,
126    /// Holographic coordinates
127    pub coords: HolographicCoords,
128    /// 5D holographic coordinate for spatial indexing
129    #[serde(default = "default_coord5d")]
130    pub coord5d: Coordinate5D,
131    // ── Phase 6.1: Enriched fields ───────────────────────────────────
132    /// Memory classification
133    #[serde(default)]
134    pub memory_type: MemoryType,
135    /// Dynamic neural strength (0.0-1.0) — decays over time, boosts on recall
136    #[serde(default = "default_neuro_score")]
137    pub neuro_score: f32,
138    /// Novelty score (0.0-1.0) — decays as info becomes familiar
139    #[serde(default = "default_novelty_score")]
140    pub novelty_score: f32,
141    /// Emotional valence (-1.0 = negative, 1.0 = positive)
142    #[serde(default)]
143    pub emotional_valence: f32,
144    /// Emotional weight / resonance (0.0-1.0)
145    #[serde(default)]
146    pub emotional_weight: f32,
147    /// Hard protection from forgetting
148    #[serde(default)]
149    pub is_protected: bool,
150    /// Exclude from MCP tool responses
151    #[serde(default)]
152    pub is_private: bool,
153    /// Exclude from AI model context windows
154    #[serde(default)]
155    pub model_exclude: bool,
156    /// Provenance: "user", "tool", "inferred", "web"
157    #[serde(default = "default_source")]
158    pub source: String,
159    /// Trust score for source (0.0-1.0, defends against memory poisoning)
160    #[serde(default = "default_source_trust")]
161    pub source_trust: f32,
162    /// Per-memory configurable decay half-life in days
163    #[serde(default = "default_half_life_days")]
164    pub half_life_days: f32,
165    /// Recall count (independent from access_count)
166    #[serde(default)]
167    pub recall_count: u64,
168    /// Version for multi-agent cache coherence
169    #[serde(default = "default_version")]
170    pub version: u64,
171    /// Last writer identity
172    #[serde(default = "default_agent_id")]
173    pub agent_id: String,
174    /// Human-readable title (envelope v2, S4). None = untitled; absent in
175    /// old records deserializes as None, never as a fabricated value.
176    #[serde(default)]
177    pub title: Option<String>,
178    /// Topic label for subject-scoped retrieval (envelope v2, S4)
179    #[serde(default)]
180    pub topic: Option<String>,
181    /// Lifecycle tier (V8 S5) — stamped at creation, dream-cycle-only
182    /// transitions. Pre-S5 rows default to [`Tier::Episodic`].
183    #[serde(default)]
184    pub tier: Tier,
185    /// Typology class (V8 S5, `crate::typology`) — stamped at creation
186    /// when confidently recognized. `None` = unstamped (pre-S5 row, or
187    /// content the detector does not confidently recognize); never
188    /// fabricated as a claim about the content.
189    #[serde(default)]
190    pub class: Option<crate::typology::MemoryClass>,
191    /// Duplicate-write counter (V8 S5 dedup gate) — bumped instead of
192    /// re-inserting identical content; importance decays with it.
193    #[serde(default)]
194    pub dup_count: u64,
195    /// Lifecycle validity (V8 Slice B, D1+D2) — reuses
196    /// [`wm_core::episodic::ValidityState`]; the dream cycle is the only
197    /// transition path (see `validity_sweep`). Pre-Slice-B rows default to
198    /// `Active`, so the recall surface is byte-identical until the
199    /// `WM_VALIDITY_ENFORCE` knob turns on.
200    #[serde(default)]
201    pub validity: wm_core::episodic::ValidityState,
202    /// Corroborating session ids (bridging consensus counter, 2nd PR after
203    /// Slice B) — distinct sessions whose agents independently stand behind
204    /// this memory, recorded via `memory.corroborate`. Empty = uncorroborated
205    /// (the pre-counter default for every row). Sessions nest agents and
206    /// machines (maintainer ruling 2026-09-04: sessions are the working unit for
207    /// now); each entry is client-asserted like `user_id` — attribution,
208    /// never authentication.
209    #[serde(default)]
210    pub corroborated_by: Vec<uuid::Uuid>,
211    /// Content-revision counter (V8 S11c) — how many times this memory's
212    /// content has changed through `memory.update`. The per-entry chain
213    /// itself lives in the store's `revisions` DBI (`memory.revisions`).
214    #[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    // Unstamped writes claim nothing. "user" as a default was an
232    // attribution lie: agent-authored session turns and machine outputs
233    // all claimed user provenance because Memory::new defaulted there
234    // (sessions-galaxy archaeology finding, 2026-08-29). Write paths that
235    // know authorship stamp explicitly — memory.create, the session tools.
236    "unattributed".to_string()
237}
238
239const fn default_source_trust() -> f32 {
240    // Below tool-neutral (0.7): unstamped content must not outrank any
241    // attributed class under trust weighting. Heritage records are
242    // unaffected — their stored JSON carries whatever was stamped at
243    // write time.
244    0.5
245}
246
247/// Validity enforcement knob (V8 Slice B, D1+D2).
248///
249/// Off by default: returns true only when `WM_VALIDITY_ENFORCE=1` (exact
250/// match — anything else, including unset, stays off). While off, the
251/// validity predicate is identically true and the recall surface is
252/// byte-identical with or without validity stamps (the S8 doctrine).
253#[must_use]
254pub fn validity_enforced() -> bool {
255    matches!(std::env::var("WM_VALIDITY_ENFORCE"), Ok(v) if v == "1")
256}
257
258/// Corroboration knob (bridging consensus counter, maintainer ruling 2026-09-04).
259///
260/// Post-fusion ranking multiplier, off by default: reads
261/// `WM_CORROBORATION_WEIGHT` (finite, clamped to [0, 1]; unset/invalid =
262/// 0.0). While 0.0 the boost is identically 1.0 and recall is byte-identical
263/// with or without corroboration stamps (the S8 doctrine, same as trust).
264#[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/// Corroboration ranking boost — monotone, bounded, saturating.
279///
280/// `factor = 1 + weight * n/(n+2)` over the distinct-session count `n`:
281/// uncorroborated passes through (factor 1.0 at any weight), the first
282/// corroboration moves most (+w/3), later ones diminish toward the `1+w`
283/// ceiling. Weight 0.0 (the default) disables weighting entirely.
284// Deterministic scorer: `mul_add` would change float rounding and with it
285// the ranking — the same deliberate `suboptimal_flops` allow class the
286// deterministic scorer documents (AGENTS.md).
287#[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/// Retrieval trust factor (V8.1, evidence-gated via `WM_TRUST_WEIGHT`).
298///
299/// Semantics per the master plan: user-confirmed (trust 1.0) ranks up,
300/// tool-ingested neutral (0.7) is unchanged, low trust ranks down. `weight`
301/// scales the whole effect — 0.0 (the default) disables weighting entirely.
302/// Bounded: with weight 1.0 the factor spans 0.3..1.3.
303// Deterministic scorer: `mul_add` would change float rounding and with it
304// the ranking — the same deliberate `suboptimal_flops` allow class the
305// deterministic scorer documents (AGENTS.md).
306#[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/// A complete memory entry: metadata + content.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct Memory {
328    /// Metadata
329    pub metadata: MemoryMetadata,
330    /// Content (text, JSON, or binary encoded as base64)
331    pub content: String,
332    /// Optional embedding vector (stored separately in Embeddings galaxy)
333    pub embedding: Option<Vec<f32>>,
334}
335
336impl Memory {
337    /// Create a new memory in the given galaxy.
338    #[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    /// Set tags on this memory.
385    #[must_use]
386    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
387        self.metadata.tags = tags;
388        self
389    }
390
391    /// Set importance score (0.0 to 1.0).
392    #[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    /// Set memory type.
400    #[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    /// Set emotional valence (-1.0 to 1.0) and weight (0.0 to 1.0).
407    #[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    /// Mark this memory as protected from forgetting.
415    #[must_use]
416    pub const fn with_protection(mut self, protected: bool) -> Self {
417        self.metadata.is_protected = protected;
418        self
419    }
420
421    /// Set provenance source and trust score.
422    #[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    /// Set per-memory decay half-life in days.
430    #[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    /// Set initial neuro_score (0.0 to 1.0).
437    #[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    /// Set initial novelty_score (0.0 to 1.0).
444    #[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    /// Set privacy flags.
451    #[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    /// Transition the serving tier (`MEMORY_TYPOLOGY_V8.md` §6).
459    ///
460    /// The dream cycle is the ONLY legitimate caller — tier moves are a
461    /// sleep-time lifecycle decision, never a request-path one (no tool
462    /// accepts a tier argument; `memory.update` whitelists its fields).
463    /// Legal moves:
464    /// - `Working → Episodic` — age-out of the current-session working set
465    /// - `Episodic → Semantic` — consolidation promotion
466    /// - `Working | Episodic | Semantic → Archival` — decay-out
467    /// - `Archival → Episodic` — promotion on read (warm re-serving)
468    ///
469    /// Anything else — demotion out of a consolidated or sealed state,
470    /// skipping ahead on the ladder — is refused. One move per call: the
471    /// dream cycle paces the ladder one step per cycle.
472    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    /// Transition the lifecycle validity (V8 Slice B, D1+D2).
493    ///
494    /// The dream cycle's `validity_sweep` is the ONLY legitimate caller —
495    /// validity moves are a sleep-time lifecycle decision, never a
496    /// request-path one (no tool accepts a validity argument).
497    /// Legality is exactly [`wm_core::episodic::ValidityState::transition`];
498    /// this wrapper only supplies the record id so callers cannot
499    /// fabricate a mismatched identity.
500    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    /// Set agent identity and version.
509    #[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    /// Attach an embedding vector.
517    #[must_use]
518    pub fn with_embedding(mut self, embedding: Vec<f32>) -> Self {
519        self.embedding = Some(embedding);
520        self
521    }
522
523    /// Record an access (updates `accessed_at` and increments `access_count`).
524    pub fn record_access(&mut self) {
525        self.metadata.accessed_at = Utc::now();
526        self.metadata.access_count += 1;
527    }
528
529    /// Record a recall — Hebbian-style strengthening of `neuro_score`.
530    ///
531    /// Boosts `neuro_score` by `0.05 * (1.0 - current_neuro_score)` (diminishing
532    /// returns), increments `recall_count`, updates `accessed_at`, and decays
533    /// `novelty_score` (familiarity reduces novelty).
534    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        // Hebbian boost: diminishing returns as neuro_score approaches 1.0
541        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        // Novelty decays with each recall (familiarity effect)
545        self.metadata.novelty_score = (self.metadata.novelty_score * 0.9).clamp(0.0, 1.0);
546    }
547
548    /// Apply exponential decay to `neuro_score` based on time since last access
549    /// and the memory's `half_life_days`.
550    ///
551    /// `neuro_score *= 0.5 ^ (days_since_access / half_life_days)`
552    /// Protected memories are exempt from decay.
553    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    /// Decay importance by a given factor (used by mindful forgetting).
567    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    /// Whether this memory should be forgotten (importance below threshold).
575    /// Protected memories are never forgotten.
576    #[must_use]
577    pub fn should_forget(&self, threshold: f32) -> bool {
578        !self.metadata.is_protected && self.metadata.importance < threshold
579    }
580
581    /// Checks whether this memory is operational telemetry, friction logging, or raw noise
582    /// that should be excluded from dream consolidation, tier promotion, and serendipity
583    /// association mining (D1/D5/D8 hygiene rules).
584    #[must_use]
585    pub fn is_telemetry_or_noise(&self) -> bool {
586        // Protected memories are never noise
587        if self.metadata.is_protected {
588            return false;
589        }
590
591        // High-signal knowledge tags protect memories from being classified as noise
592        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        // Tag-based noise detection (D1/D5/D8)
608        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        // Content heuristics: raw JSON with telemetry markers
624        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        // Low importance with minimal content (< 50 chars)
635        if self.metadata.importance < 0.25 && content.len() < 50 {
636            return true;
637        }
638
639        false
640    }
641}
642
643/// Compute SHA-256 content hash, returned as hex string.
644#[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/// Encode an f32 embedding vector as raw bytes for LMDB storage.
652#[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/// Decode raw bytes back into an f32 embedding vector.
662#[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    // ── V8.1 trust scoring ─────────────────────────────────────────────
678
679    #[test]
680    fn trust_weighted_score_semantics() {
681        // Weight 0 = off: score passes through untouched.
682        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        // Neutral point: tool-ingested (0.7) is unchanged at any weight.
686        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        // User-confirmed ranks up, unverified ranks down, proportionally.
691        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        // Weight 1.0 spans 0.3..1.3; clamped inputs stay in [0,1].
697        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    // ── MemoryType enum tests ──────────────────────────────────────────
703
704    #[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            // serde uses snake_case rename
720            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    // ── Enriched field defaults ────────────────────────────────────────
735
736    #[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    // ── Validity states (V8 Slice B, D1+D2) ─────────────────────────────
758
759    #[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        // Pre-Slice-B rows carry no `validity` key — serde default must be
772        // Active so old stores read byte-identical.
773        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    // ── Bridging consensus counter (2nd PR after Slice B) ─────────────
826
827    #[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        // Weight 0 = off: score passes through untouched at any count.
845        assert!((corroboration_boost(2.0, 5, 0.0) - 2.0).abs() < 1e-5);
846        // Zero corroborations = passthrough at any weight.
847        assert!((corroboration_boost(2.0, 0, 1.0) - 2.0).abs() < 1e-5);
848        // First corroboration moves most: factor 1 + w/3.
849        let first = corroboration_boost(3.0, 1, 0.6);
850        assert!((first - 3.6).abs() < 1e-5);
851        // Monotone in count, bounded by 1 + weight.
852        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        // Load-bearing for the S8 byte-identical doctrine (same caveat as
862        // validity: the ON case is covered by inspection, not env mutation).
863        assert!((corroboration_weight() - 0.0).abs() < f32::EPSILON);
864    }
865
866    #[test]
867    fn validity_enforced_defaults_off() {
868        // Load-bearing for the S8 byte-identical doctrine: unless a session
869        // deliberately opts in via WM_VALIDITY_ENFORCE=1, enforcement is off.
870        // (Env mutation is `unsafe` under edition-2024 `forbid(unsafe)`, so
871        // the ON case is covered by inspection + the benchmark gate, not here.)
872        assert!(!validity_enforced());
873    }
874
875    // ── recall() Hebbian dynamics ──────────────────────────────────────
876
877    #[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); // 0.005
894        assert!((mem.metadata.neuro_score - (0.9 + boost_high)).abs() < 1e-5);
895
896        // Now from a lower starting point
897        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); // 0.045
901        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        // Asymptotic approach to 1.0 — should be very close but not exact
921        assert!(mem.metadata.neuro_score > 0.999);
922        assert!(mem.metadata.neuro_score <= 1.0);
923    }
924
925    // ── decay() exponential dynamics ───────────────────────────────────
926
927    #[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        // Simulate 30 days since last access = one half-life
934        mem.metadata.accessed_at = Utc::now() - Duration::days(30);
935        mem.decay(Utc::now());
936
937        // After one half-life, neuro_score should be ~0.5
938        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        // Short half-life should have decayed more
975        assert!(mem_short.metadata.neuro_score < mem_long.metadata.neuro_score);
976    }
977
978    // ── Protection logic ───────────────────────────────────────────────
979
980    #[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    // ── Builder methods ────────────────────────────────────────────────
998
999    #[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    // ── Serde backward compatibility ───────────────────────────────────
1065
1066    #[test]
1067    fn serde_backward_compat_missing_enriched_fields() {
1068        // Simulate an old-format memory (pre-6.1) serialized without enriched fields.
1069        // serde defaults should fill them in.
1070        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        // Heritage JSON missing the source fields deserializes as
1099        // UNATTRIBUTED — absent stamps claim nothing; they must not
1100        // materialize as a user claim.
1101        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        // Envelope-v2 fields (S4): absent in old records → None, never
1108        // fabricated.
1109        assert_eq!(mem.metadata.title, None);
1110        assert_eq!(mem.metadata.topic, None);
1111        // S5 tier/class/dup fields: old records are warm-served (episodic)
1112        // and unstamped as to class — never fabricated.
1113        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        // Friction template content is telemetry by construction.
1121        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        // Ordinary prose is not confidently recognized → unstamped.
1131        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        // Legal ladder: forward one step, decay-out to archival, and the
1139        // archival promotion-on-read. Every other move is refused.
1140        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        // Decay-out is legal from any warm tier.
1153        m.transition_tier(Tier::Archival).unwrap();
1154        // No demotion out of a consolidated or sealed state, no skipping.
1155        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    // ── LMDB store roundtrip with enriched fields ──────────────────────
1205
1206    #[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}