Skip to main content

wm_memory/
cold_storage.rs

1//! Non-Destructive Phagic Digestive Cold-Storage Subsystem.
2//!
3//! # Sacred Rule (the Law of Non-Destructive Phagocytosis)
4//! Phagic systems must NEVER delete anything. Irrelevant or distant "outer rim" memories
5//! in each galaxy are gently migrated into compressed cold storage, preserving the complete
6//! lineage, provenance, vector clocks, and retrievability.
7//!
8//! # Architecture
9//! - **Outer-Rim Metric**: Calculates distance $D(m) \in [0.0, 1.0]$ from the galactic core as:
10//!   $D(m) = f(\text{age}, \text{access\_frequency}, \text{harmonic\_resonance}, \text{emotional\_valence}, \text{importance\_weight})$
11//! - **Hot Tier**: Active memories in `MemoryStore` and Tantivy `SearchEngine`.
12//! - **Cold Tier**: Compressed archive (MessagePack + Gzip/Deflate), preserving all metadata,
13//!   vector clocks, content, embeddings, and association graph links.
14//! - **Digestion & Condensation**: Synthesizes clusters of outer-rim memories into distilled
15//!   thematic nodes (`Tier::Semantic`, `MemoryType::Symbolic`) in the active hot tier,
16//!   referencing the cold-stored records, while full original memories rest safely in cold storage.
17//! - **Thawing (Warm Restoration)**: Zero data loss — any cold-stored memory can be queried
18//!   directly or thawed back into the hot active tier with full fidelity.
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use uuid::Uuid;
24use wm_core::{CoreError, Galaxy, Result};
25
26use crate::memory::{Memory, MemoryId, MemoryType, Tier};
27use crate::search::SearchEngine;
28use crate::store::MemoryStore;
29
30// ── Compression Codec ───────────────────────────────────────────────────
31
32/// Compression algorithm used for cold storage payloads.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum CompressionCodec {
36    /// Gzip compression (balanced ratio and high compatibility)
37    #[default]
38    Gzip,
39    /// Raw deflate compression
40    Deflate,
41    /// Uncompressed MessagePack binary representation
42    Msgpack,
43}
44
45impl CompressionCodec {
46    /// String label for display/JSON.
47    #[must_use]
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::Gzip => "gzip",
51            Self::Deflate => "deflate",
52            Self::Msgpack => "msgpack",
53        }
54    }
55}
56
57// ── Outer-Rim Distance Metric ───────────────────────────────────────────
58
59/// Breakdown of the factors contributing to a memory's outer-rim distance.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct OuterRimFactors {
62    /// Age component in [0.0, 1.0]: 1.0 = ancient / past half-life.
63    pub age_factor: f32,
64    /// Access frequency component in [0.0, 1.0]: 1.0 = unaccessed, 0.0 = frequent.
65    pub access_factor: f32,
66    /// Harmonic resonance component in [0.0, 1.0]: 1.0 = zero resonance, 0.0 = peak resonance.
67    pub resonance_factor: f32,
68    /// Emotional valence & salience in [0.0, 1.0]: 1.0 = emotionally flat, 0.0 = charged.
69    pub emotional_factor: f32,
70    /// Importance component in [0.0, 1.0]: 1.0 = low importance, 0.0 = max importance.
71    pub importance_factor: f32,
72    /// Composite distance in [0.0, 1.0]: 0.0 = core, 1.0 = deep outer rim.
73    pub distance: f32,
74}
75
76/// Configuration parameters for the outer-rim metric and digestion process.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct PhagicConfig {
79    /// Weight for age factor (default 0.25).
80    pub weight_age: f32,
81    /// Weight for access frequency factor (default 0.25).
82    pub weight_access: f32,
83    /// Weight for harmonic resonance factor (default 0.15).
84    pub weight_resonance: f32,
85    /// Weight for emotional valence/weight factor (default 0.15).
86    pub weight_emotional: f32,
87    /// Weight for importance factor (default 0.20).
88    pub weight_importance: f32,
89    /// Distance threshold above which memories qualify for the outer rim (default 0.70).
90    pub outer_rim_threshold: f32,
91    /// Maximum memories digested in a single pass per galaxy (default 50).
92    pub max_digest_batch: usize,
93    /// Minimum memories in a cluster to form a distilled thematic node (default 2).
94    pub min_cluster_size: usize,
95    /// Preferred compression codec (default Gzip).
96    pub compression_codec: CompressionCodec,
97}
98
99impl Default for PhagicConfig {
100    fn default() -> Self {
101        Self {
102            weight_age: 0.25,
103            weight_access: 0.25,
104            weight_resonance: 0.15,
105            weight_emotional: 0.15,
106            weight_importance: 0.20,
107            outer_rim_threshold: 0.70,
108            max_digest_batch: 50,
109            min_cluster_size: 2,
110            compression_codec: CompressionCodec::Gzip,
111        }
112    }
113}
114
115/// Calculate the outer-rim distance $D(m) \in [0.0, 1.0]$ for a memory.
116///
117/// Protected memories (`is_protected == true`) are anchored at the galactic core
118/// and always receive a distance of 0.0.
119#[must_use]
120pub fn calculate_outer_rim_distance(
121    mem: &Memory,
122    config: &PhagicConfig,
123    now: DateTime<Utc>,
124) -> OuterRimFactors {
125    if mem.metadata.is_protected {
126        return OuterRimFactors {
127            age_factor: 0.0,
128            access_factor: 0.0,
129            resonance_factor: 0.0,
130            emotional_factor: 0.0,
131            importance_factor: 0.0,
132            distance: 0.0,
133        };
134    }
135
136    // 1. Age factor: days elapsed relative to configurable half_life_days
137    let seconds_since_access = (now - mem.metadata.accessed_at).num_seconds().max(0);
138    let days_since = seconds_since_access as f32 / 86400.0;
139    let half_life = mem.metadata.half_life_days.max(1.0);
140    let age_factor = (1.0 - 0.5f32.powf(days_since / half_life)).clamp(0.0, 1.0);
141
142    // 2. Access frequency: hyperbolic decay over combined access + recall reads
143    let reads = (mem.metadata.access_count + mem.metadata.recall_count) as f32;
144    let access_factor = (1.0 / 0.5f32.mul_add(reads, 1.0)).clamp(0.0, 1.0);
145
146    // 3. Harmonic resonance: inverse of neuro_score
147    let resonance_factor = (1.0 - mem.metadata.neuro_score.clamp(0.0, 1.0)).clamp(0.0, 1.0);
148
149    // 4. Emotional valence / salience: |valence| * weight
150    let salience =
151        (mem.metadata.emotional_valence.abs() * mem.metadata.emotional_weight).clamp(0.0, 1.0);
152    let emotional_factor = (1.0 - salience).clamp(0.0, 1.0);
153
154    // 5. Importance: inverse of semantic importance
155    let importance_factor = (1.0 - mem.metadata.importance.clamp(0.0, 1.0)).clamp(0.0, 1.0);
156
157    // Weighted composite
158    let sum_weights = config.weight_age
159        + config.weight_access
160        + config.weight_resonance
161        + config.weight_emotional
162        + config.weight_importance;
163
164    let distance = if sum_weights > 0.0 {
165        let raw = config.weight_importance.mul_add(
166            importance_factor,
167            config.weight_emotional.mul_add(
168                emotional_factor,
169                config.weight_resonance.mul_add(
170                    resonance_factor,
171                    config
172                        .weight_access
173                        .mul_add(access_factor, config.weight_age * age_factor),
174                ),
175            ),
176        );
177        (raw / sum_weights).clamp(0.0, 1.0)
178    } else {
179        0.5
180    };
181
182    OuterRimFactors {
183        age_factor,
184        access_factor,
185        resonance_factor,
186        emotional_factor,
187        importance_factor,
188        distance,
189    }
190}
191
192// ── Compression & Decompression Helpers ─────────────────────────────────
193
194/// Compress a memory into a binary payload using the specified codec.
195pub fn compress_memory(mem: &Memory, codec: CompressionCodec) -> Result<(Vec<u8>, usize)> {
196    let uncompressed = rmp_serde::to_vec_named(mem)
197        .map_err(|e| CoreError::Memory(format!("Cold storage serialization error: {e}")))?;
198    let uncompressed_size = uncompressed.len();
199
200    let compressed = match codec {
201        CompressionCodec::Gzip => {
202            use flate2::Compression;
203            use flate2::write::GzEncoder;
204            use std::io::Write;
205            let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
206            encoder
207                .write_all(&uncompressed)
208                .map_err(|e| CoreError::Memory(format!("Gzip compression error: {e}")))?;
209            encoder
210                .finish()
211                .map_err(|e| CoreError::Memory(format!("Gzip finish error: {e}")))?
212        }
213        CompressionCodec::Deflate => {
214            use flate2::Compression;
215            use flate2::write::DeflateEncoder;
216            use std::io::Write;
217            let mut encoder = DeflateEncoder::new(Vec::new(), Compression::best());
218            encoder
219                .write_all(&uncompressed)
220                .map_err(|e| CoreError::Memory(format!("Deflate compression error: {e}")))?;
221            encoder
222                .finish()
223                .map_err(|e| CoreError::Memory(format!("Deflate finish error: {e}")))?
224        }
225        CompressionCodec::Msgpack => uncompressed,
226    };
227
228    Ok((compressed, uncompressed_size))
229}
230
231/// Decompress a binary payload back into the original memory.
232pub fn decompress_memory(payload: &[u8], codec: CompressionCodec) -> Result<Memory> {
233    let uncompressed = match codec {
234        CompressionCodec::Gzip => {
235            use flate2::read::GzDecoder;
236            use std::io::Read;
237            let mut decoder = GzDecoder::new(payload);
238            let mut buf = Vec::new();
239            decoder
240                .read_to_end(&mut buf)
241                .map_err(|e| CoreError::Memory(format!("Gzip decompression error: {e}")))?;
242            buf
243        }
244        CompressionCodec::Deflate => {
245            use flate2::read::DeflateDecoder;
246            use std::io::Read;
247            let mut decoder = DeflateDecoder::new(payload);
248            let mut buf = Vec::new();
249            decoder
250                .read_to_end(&mut buf)
251                .map_err(|e| CoreError::Memory(format!("Deflate decompression error: {e}")))?;
252            buf
253        }
254        CompressionCodec::Msgpack => payload.to_vec(),
255    };
256
257    crate::codec::decode(&uncompressed)
258        .map_err(|e| CoreError::Memory(format!("Cold storage deserialization error: {e}")))
259}
260
261fn create_snippet(content: &str) -> String {
262    let trimmed = content.trim();
263    if trimmed.chars().count() <= 120 {
264        trimmed.to_string()
265    } else {
266        let mut s: String = trimmed.chars().take(117).collect();
267        s.push_str("...");
268        s
269    }
270}
271
272// ── Cold Storage Records & Summaries ────────────────────────────────────
273
274/// A persistent record stored in the cold archive.
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
276pub struct ColdRecord {
277    /// Memory UUID.
278    pub id: MemoryId,
279    /// Original galaxy this memory belonged to.
280    pub galaxy: Galaxy,
281    /// Content hash (SHA-256) for integrity verification.
282    pub content_hash: String,
283    /// Timestamp when this memory was migrated into cold storage.
284    pub cold_stored_at: DateTime<Utc>,
285    /// Outer-rim distance calculated at freezing time.
286    pub outer_rim_distance: f32,
287    /// Factor breakdown at freezing time.
288    pub distance_factors: OuterRimFactors,
289    /// ID of the distilled thematic node (if synthesized during digestion).
290    pub digest_id: Option<MemoryId>,
291    /// Lineage & provenance notes.
292    pub lineage_notes: Option<String>,
293    /// Compression codec used.
294    pub codec: CompressionCodec,
295    /// Uncompressed payload size in bytes.
296    pub uncompressed_size: usize,
297    /// Compressed binary payload containing the full original Memory.
298    pub compressed_payload: Vec<u8>,
299    /// Vector clock / version at freezing time.
300    pub version: u64,
301    /// Fast-search tags preserved in cold header.
302    pub tags: Vec<String>,
303    /// Title preserved in cold header.
304    pub title: Option<String>,
305    /// Text snippet for rapid search without decompression.
306    pub snippet: String,
307}
308
309impl ColdRecord {
310    /// Create a new cold record by compressing a hot memory.
311    pub fn new(
312        mem: &Memory,
313        outer_rim_distance: f32,
314        distance_factors: OuterRimFactors,
315        digest_id: Option<MemoryId>,
316        lineage_notes: Option<String>,
317        codec: CompressionCodec,
318    ) -> Result<Self> {
319        let (compressed_payload, uncompressed_size) = compress_memory(mem, codec)?;
320        let snippet = create_snippet(&mem.content);
321
322        Ok(Self {
323            id: mem.metadata.id,
324            galaxy: mem.metadata.galaxy,
325            content_hash: mem.metadata.content_hash.clone(),
326            cold_stored_at: Utc::now(),
327            outer_rim_distance,
328            distance_factors,
329            digest_id,
330            lineage_notes,
331            codec,
332            uncompressed_size,
333            compressed_payload,
334            version: mem.metadata.version,
335            tags: mem.metadata.tags.clone(),
336            title: mem.metadata.title.clone(),
337            snippet,
338        })
339    }
340
341    /// Extract a lightweight summary without decompressing the full memory payload.
342    #[must_use]
343    pub fn summary(&self) -> ColdRecordSummary {
344        ColdRecordSummary {
345            id: self.id,
346            galaxy: self.galaxy,
347            content_hash: self.content_hash.clone(),
348            cold_stored_at: self.cold_stored_at,
349            outer_rim_distance: self.outer_rim_distance,
350            digest_id: self.digest_id,
351            uncompressed_size: self.uncompressed_size,
352            compressed_size: self.compressed_payload.len(),
353            codec: self.codec,
354            tags: self.tags.clone(),
355            title: self.title.clone(),
356            snippet: self.snippet.clone(),
357        }
358    }
359
360    /// Decompress and restore the complete original `Memory`.
361    pub fn decompress(&self) -> Result<Memory> {
362        decompress_memory(&self.compressed_payload, self.codec)
363    }
364}
365
366/// Lightweight summary of a cold-stored memory for listings and queries.
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
368pub struct ColdRecordSummary {
369    /// Memory UUID.
370    pub id: MemoryId,
371    /// Original Galaxy.
372    pub galaxy: Galaxy,
373    /// Content hash.
374    pub content_hash: String,
375    /// Cold storage timestamp.
376    pub cold_stored_at: DateTime<Utc>,
377    /// Outer-rim distance at freeze time.
378    pub outer_rim_distance: f32,
379    /// Associated digest node ID (if any).
380    pub digest_id: Option<MemoryId>,
381    /// Uncompressed bytes.
382    pub uncompressed_size: usize,
383    /// Compressed bytes.
384    pub compressed_size: usize,
385    /// Codec used.
386    pub codec: CompressionCodec,
387    /// Tags preserved in cold header.
388    pub tags: Vec<String>,
389    /// Title preserved in cold header.
390    pub title: Option<String>,
391    /// Text snippet.
392    pub snippet: String,
393}
394
395// ── Cold Query Filter ───────────────────────────────────────────────────
396
397/// Filter for querying cold-stored memories.
398#[derive(Debug, Clone, Default)]
399pub struct ColdQuery {
400    /// Optional galaxy filter.
401    pub galaxy: Option<Galaxy>,
402    /// Must contain all specified tags.
403    pub tags: Vec<String>,
404    /// Minimum outer-rim distance filter.
405    pub min_distance: Option<f32>,
406    /// Stored after this timestamp.
407    pub stored_after: Option<DateTime<Utc>>,
408    /// Stored before this timestamp.
409    pub stored_before: Option<DateTime<Utc>>,
410    /// Substring match over title or snippet.
411    pub content_substring: Option<String>,
412    /// Maximum results to return.
413    pub limit: usize,
414}
415
416impl ColdQuery {
417    /// Create a new query matching all cold records (limit 100).
418    #[must_use]
419    pub fn new() -> Self {
420        Self {
421            limit: 100,
422            ..Default::default()
423        }
424    }
425
426    /// Filter by galaxy.
427    #[must_use]
428    pub const fn with_galaxy(mut self, galaxy: Galaxy) -> Self {
429        self.galaxy = Some(galaxy);
430        self
431    }
432
433    /// Filter by tags.
434    #[must_use]
435    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
436        self.tags = tags;
437        self
438    }
439
440    /// Filter by minimum distance.
441    #[must_use]
442    pub const fn with_min_distance(mut self, min_dist: f32) -> Self {
443        self.min_distance = Some(min_dist);
444        self
445    }
446
447    /// Filter by result limit.
448    #[must_use]
449    pub const fn with_limit(mut self, limit: usize) -> Self {
450        self.limit = limit;
451        self
452    }
453
454    /// Filter by substring.
455    #[must_use]
456    pub fn with_content_substring(mut self, substring: impl Into<String>) -> Self {
457        self.content_substring = Some(substring.into().to_lowercase());
458        self
459    }
460
461    /// Check if a cold record summary satisfies this query.
462    #[must_use]
463    pub fn matches(&self, summary: &ColdRecordSummary) -> bool {
464        if let Some(g) = self.galaxy {
465            if summary.galaxy != g {
466                return false;
467            }
468        }
469        if let Some(min_d) = self.min_distance {
470            if summary.outer_rim_distance < min_d {
471                return false;
472            }
473        }
474        if let Some(after) = self.stored_after {
475            if summary.cold_stored_at < after {
476                return false;
477            }
478        }
479        if let Some(before) = self.stored_before {
480            if summary.cold_stored_at > before {
481                return false;
482            }
483        }
484        for tag in &self.tags {
485            if !summary.tags.iter().any(|t| t == tag) {
486                return false;
487            }
488        }
489        if let Some(sub) = &self.content_substring {
490            let in_title = summary
491                .title
492                .as_ref()
493                .is_some_and(|t| t.to_lowercase().contains(sub));
494            let in_snippet = summary.snippet.to_lowercase().contains(sub);
495            if !in_title && !in_snippet {
496                return false;
497            }
498        }
499        true
500    }
501}
502
503// ── Phagic Digest Result Report ─────────────────────────────────────────
504
505/// Telemetry report of a phagic digestion pass on a galaxy.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct PhagicDigestReport {
508    /// Galaxy processed.
509    pub galaxy: Galaxy,
510    /// Total memories scanned in the galaxy.
511    pub memories_examined: usize,
512    /// Outer-rim memories identified above the distance threshold.
513    pub outer_rim_candidates: usize,
514    /// Memories migrated into cold storage.
515    pub memories_digested: usize,
516    /// ID of the synthesized thematic digest node placed in the active tier.
517    pub digest_memory_id: Option<MemoryId>,
518    /// Uncompressed raw bytes across all digested memories.
519    pub total_raw_bytes: usize,
520    /// Compressed bytes stored in cold archive.
521    pub total_cold_bytes: usize,
522    /// Space compression ratio: `total_cold_bytes / total_raw_bytes`.
523    pub compression_ratio: f32,
524    /// Average outer-rim distance of the digested memories.
525    pub average_outer_rim_distance: f32,
526}
527
528// ── Phagic Digester Engine ──────────────────────────────────────────────
529
530/// Non-destructive digestive processor.
531pub struct PhagicDigester {
532    config: PhagicConfig,
533}
534
535impl PhagicDigester {
536    /// Create a new digester with the specified configuration.
537    #[must_use]
538    pub const fn new(config: PhagicConfig) -> Self {
539        Self { config }
540    }
541
542    /// Create with default configuration.
543    #[must_use]
544    pub fn default_config() -> Self {
545        Self::new(PhagicConfig::default())
546    }
547
548    /// Access configuration.
549    #[must_use]
550    pub const fn config(&self) -> &PhagicConfig {
551        &self.config
552    }
553
554    /// Calculate distance factors for a memory.
555    #[must_use]
556    pub fn calculate_distance(&self, mem: &Memory, now: DateTime<Utc>) -> OuterRimFactors {
557        calculate_outer_rim_distance(mem, &self.config, now)
558    }
559
560    /// Scan a galaxy and identify outer-rim memories sorted by distance descending.
561    pub fn scan_outer_rim(
562        &self,
563        store: &MemoryStore,
564        galaxy: Galaxy,
565    ) -> Result<Vec<(Memory, OuterRimFactors)>> {
566        let memories = store.scan(galaxy, 10_000)?;
567        let now = Utc::now();
568        let mut candidates = Vec::new();
569
570        for mem in memories {
571            // Protected memories are immune to outer rim drift
572            if mem.metadata.is_protected {
573                continue;
574            }
575
576            let factors = self.calculate_distance(&mem, now);
577            if factors.distance >= self.config.outer_rim_threshold {
578                candidates.push((mem, factors));
579            }
580        }
581
582        // Sort descending by distance (furthest memories first)
583        candidates.sort_by(|a, b| {
584            b.1.distance
585                .partial_cmp(&a.1.distance)
586                .unwrap_or(std::cmp::Ordering::Equal)
587        });
588
589        Ok(candidates)
590    }
591
592    /// Synthesize a cluster of outer-rim memories into a distilled thematic node in the hot tier,
593    /// and gently migrate the original memories into the cold storage tier.
594    pub fn digest_cluster(
595        &self,
596        store: &MemoryStore,
597        search: Option<&SearchEngine>,
598        galaxy: Galaxy,
599        cluster: &[(Memory, OuterRimFactors)],
600    ) -> Result<PhagicDigestReport> {
601        if cluster.is_empty() {
602            return Ok(PhagicDigestReport {
603                galaxy,
604                memories_examined: 0,
605                outer_rim_candidates: 0,
606                memories_digested: 0,
607                digest_memory_id: None,
608                total_raw_bytes: 0,
609                total_cold_bytes: 0,
610                compression_ratio: 0.0,
611                average_outer_rim_distance: 0.0,
612            });
613        }
614
615        let cluster_id = Uuid::new_v4();
616        let now = Utc::now();
617
618        // Compute tag frequency and shared topics
619        let mut tag_counts: HashMap<String, usize> = HashMap::new();
620        let mut total_distance = 0.0;
621        let mut min_time = now;
622        let mut max_time = DateTime::<Utc>::MIN_UTC;
623
624        for (mem, factors) in cluster {
625            total_distance += factors.distance;
626            if mem.metadata.created_at < min_time {
627                min_time = mem.metadata.created_at;
628            }
629            if mem.metadata.created_at > max_time {
630                max_time = mem.metadata.created_at;
631            }
632            for tag in &mem.metadata.tags {
633                if !tag.starts_with("phagic:") && !tag.starts_with("cold_source:") {
634                    *tag_counts.entry(tag.clone()).or_insert(0) += 1;
635                }
636            }
637        }
638
639        let avg_distance = total_distance / cluster.len() as f32;
640
641        let mut top_tags: Vec<(String, usize)> = tag_counts.into_iter().collect();
642        top_tags.sort_by_key(|a| std::cmp::Reverse(a.1));
643        let prominent_tags: Vec<String> =
644            top_tags.into_iter().take(6).map(|(tag, _)| tag).collect();
645
646        // 1. Synthesize thematic digest memory for active hot tier
647        let mut digest_content = format!(
648            "# Phagic Thematic Digest: {} Outer-Rim Condensation\n\n\
649             **Cluster ID**: `{cluster_id}`\n\
650             **Galaxy**: `{}`\n\
651             **Digested Memories**: {} items safely migrated to Cold Storage\n\
652             **Temporal Span**: {} to {}\n\
653             **Average Outer-Rim Distance**: {:.3}\n\
654             **Prominent Themes**: {}\n\n\
655             ## Distilled Semantic Abstract\n\
656             This thematic node preserves the collective semantic essence of {} outer-rim memories\n\
657             from galaxy {}. The original memories have been gently transitioned into compressed\n\
658             cold storage without data loss, retaining complete lineage and full thawability.\n\n\
659             ## Lineage Pointers (Thawable Cold Records)\n",
660            galaxy.db_name(),
661            galaxy.db_name(),
662            cluster.len(),
663            min_time.format("%Y-%m-%d %H:%M:%S UTC"),
664            max_time.format("%Y-%m-%d %H:%M:%S UTC"),
665            avg_distance,
666            if prominent_tags.is_empty() {
667                "none".to_string()
668            } else {
669                prominent_tags.join(", ")
670            },
671            cluster.len(),
672            galaxy.db_name(),
673        );
674
675        use std::fmt::Write as _;
676        for (mem, factors) in cluster {
677            let snippet = create_snippet(&mem.content);
678            let _ = writeln!(
679                digest_content,
680                "- **[Memory `{}`]** (Distance: {:.2}): {snippet}",
681                mem.metadata.id, factors.distance
682            );
683        }
684
685        let mut digest_tags = prominent_tags;
686        digest_tags.push("phagic:digest".to_string());
687        digest_tags.push(format!("phagic:cluster:{cluster_id}"));
688
689        let digest_title = format!(
690            "Phagic Digest: {} ({} items)",
691            galaxy.db_name(),
692            cluster.len()
693        );
694        let mut digest_mem = Memory::new(galaxy, digest_content)
695            .with_tags(digest_tags)
696            .with_importance(0.65)
697            .with_memory_type(MemoryType::Symbolic)
698            .with_source("phagic:digestion".to_string(), 0.9);
699        digest_mem.metadata.tier = Tier::Semantic;
700        digest_mem.metadata.title = Some(digest_title);
701        digest_mem.metadata.topic = Some(format!("{}:phagic_digest", galaxy.db_name()));
702
703        // Store thematic digest memory in the hot store
704        store.put(galaxy, &digest_mem)?;
705
706        // Index the thematic digest node in Tantivy search if available
707        if let Some(engine) = search {
708            if let Ok(mut writer_guard) = engine.writer() {
709                let _ = engine.add_document(
710                    &mut writer_guard,
711                    &digest_mem.metadata.id.to_string(),
712                    galaxy.db_name(),
713                    &digest_mem.content,
714                    &digest_mem.metadata.tags,
715                    digest_mem.metadata.created_at.timestamp(),
716                );
717                let _ = engine.commit(&mut writer_guard);
718            }
719        }
720
721        let digest_id = digest_mem.metadata.id;
722
723        // 2. Freeze each original memory into cold storage
724        let mut total_raw_bytes = 0;
725        let mut total_cold_bytes = 0;
726
727        for (mem, factors) in cluster {
728            let notes = format!("Digested in phagic cluster {cluster_id} (digest_id: {digest_id})");
729            let cold_rec = store.freeze_to_cold(
730                search,
731                mem.metadata.id,
732                factors.distance,
733                factors.clone(),
734                Some(digest_id),
735                Some(notes),
736                self.config.compression_codec,
737            )?;
738            total_raw_bytes += cold_rec.uncompressed_size;
739            total_cold_bytes += cold_rec.compressed_payload.len();
740        }
741
742        let compression_ratio = if total_raw_bytes > 0 {
743            total_cold_bytes as f32 / total_raw_bytes as f32
744        } else {
745            0.0
746        };
747
748        Ok(PhagicDigestReport {
749            galaxy,
750            memories_examined: cluster.len(),
751            outer_rim_candidates: cluster.len(),
752            memories_digested: cluster.len(),
753            digest_memory_id: Some(digest_id),
754            total_raw_bytes,
755            total_cold_bytes,
756            compression_ratio,
757            average_outer_rim_distance: avg_distance,
758        })
759    }
760
761    /// Perform a full non-destructive digestive pass on a galaxy.
762    pub fn digest_galaxy(
763        &self,
764        store: &MemoryStore,
765        search: Option<&SearchEngine>,
766        galaxy: Galaxy,
767    ) -> Result<PhagicDigestReport> {
768        let candidates = self.scan_outer_rim(store, galaxy)?;
769        let count = store.count(galaxy).unwrap_or(0);
770
771        if candidates.is_empty() {
772            return Ok(PhagicDigestReport {
773                galaxy,
774                memories_examined: count,
775                outer_rim_candidates: 0,
776                memories_digested: 0,
777                digest_memory_id: None,
778                total_raw_bytes: 0,
779                total_cold_bytes: 0,
780                compression_ratio: 0.0,
781                average_outer_rim_distance: 0.0,
782            });
783        }
784
785        // Bound to max_digest_batch
786        let batch_size = candidates.len().min(self.config.max_digest_batch);
787        let batch = &candidates[..batch_size];
788
789        let mut report = self.digest_cluster(store, search, galaxy, batch)?;
790        report.memories_examined = count;
791        report.outer_rim_candidates = candidates.len();
792        Ok(report)
793    }
794
795    /// Perform phagic digestion across all memory galaxies.
796    pub fn digest_all(
797        &self,
798        store: &MemoryStore,
799        search: Option<&SearchEngine>,
800    ) -> Result<Vec<PhagicDigestReport>> {
801        let mut reports = Vec::new();
802        for galaxy in Galaxy::all() {
803            match galaxy {
804                Galaxy::Substrate
805                | Galaxy::Dharma
806                | Galaxy::Karma
807                | Galaxy::Embeddings
808                | Galaxy::Associations => continue,
809                _ => {}
810            }
811            if store.count(galaxy).unwrap_or(0) == 0 {
812                continue;
813            }
814            reports.push(self.digest_galaxy(store, search, galaxy)?);
815        }
816        Ok(reports)
817    }
818
819    /// Thaw a cold-stored memory back into the active hot tier.
820    pub fn thaw(
821        &self,
822        store: &MemoryStore,
823        search: Option<&SearchEngine>,
824        id: MemoryId,
825    ) -> Result<Memory> {
826        store.thaw_from_cold(search, id)
827    }
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833    use tempfile::tempdir;
834
835    fn test_store() -> MemoryStore {
836        let tmp = tempdir().unwrap();
837        MemoryStore::open_default(tmp.path()).unwrap()
838    }
839
840    #[test]
841    fn outer_rim_distance_calculation_monotonic() {
842        let config = PhagicConfig::default();
843        let now = Utc::now();
844
845        // 1. Core memory: recent, high importance, high resonance, high emotion, accessed
846        let mut core_mem = Memory::new(Galaxy::Codex, "Vital core memory".into())
847            .with_importance(0.95)
848            .with_neuro_score(0.9)
849            .with_emotional_valence(0.8, 0.9);
850        core_mem.metadata.access_count = 10;
851        core_mem.metadata.accessed_at = now;
852
853        let core_factors = calculate_outer_rim_distance(&core_mem, &config, now);
854        assert!(
855            core_factors.distance < 0.25,
856            "Core memory distance should be low: {}",
857            core_factors.distance
858        );
859
860        // 2. Outer-rim memory: ancient, 0 accesses, low importance, low resonance, neutral emotion
861        let mut rim_mem = Memory::new(Galaxy::Codex, "Faded distant note".into())
862            .with_importance(0.05)
863            .with_neuro_score(0.1)
864            .with_emotional_valence(0.0, 0.0);
865        rim_mem.metadata.access_count = 0;
866        rim_mem.metadata.recall_count = 0;
867        rim_mem.metadata.accessed_at = now - chrono::Duration::days(120);
868
869        let rim_factors = calculate_outer_rim_distance(&rim_mem, &config, now);
870        assert!(
871            rim_factors.distance >= config.outer_rim_threshold,
872            "Rim memory distance should exceed threshold: {}",
873            rim_factors.distance
874        );
875        assert!(
876            rim_factors.distance > core_factors.distance,
877            "Rim memory must be strictly further out than core memory"
878        );
879    }
880
881    #[test]
882    fn protected_memory_is_anchored_at_core() {
883        let config = PhagicConfig::default();
884        let now = Utc::now();
885
886        let mut protected_mem = Memory::new(Galaxy::Codex, "Protected sacred memory".into())
887            .with_protection(true)
888            .with_importance(0.01); // low importance, but protected
889        protected_mem.metadata.accessed_at = now - chrono::Duration::days(365);
890
891        let factors = calculate_outer_rim_distance(&protected_mem, &config, now);
892        assert_eq!(
893            factors.distance, 0.0,
894            "Protected memory must always have distance 0.0"
895        );
896    }
897
898    #[test]
899    fn cold_record_compression_roundtrip() {
900        let mem = Memory::new(
901            Galaxy::Research,
902            "Extensive research logs on cosmic non-destructive phagocytosis and entropy reversal"
903                .into(),
904        )
905        .with_tags(vec!["physics".into(), "entropy".into(), "phagic".into()])
906        .with_importance(0.3);
907
908        for codec in [
909            CompressionCodec::Gzip,
910            CompressionCodec::Deflate,
911            CompressionCodec::Msgpack,
912        ] {
913            let factors = OuterRimFactors {
914                age_factor: 0.8,
915                access_factor: 0.9,
916                resonance_factor: 0.7,
917                emotional_factor: 0.9,
918                importance_factor: 0.7,
919                distance: 0.81,
920            };
921
922            let cold = ColdRecord::new(&mem, 0.81, factors, None, Some("test notes".into()), codec)
923                .unwrap();
924            assert_eq!(cold.id, mem.metadata.id);
925            assert_eq!(cold.galaxy, Galaxy::Research);
926
927            let decompressed = cold.decompress().unwrap();
928            assert_eq!(decompressed.metadata.id, mem.metadata.id);
929            assert_eq!(decompressed.content, mem.content);
930            assert_eq!(decompressed.metadata.tags, mem.metadata.tags);
931            assert_eq!(decompressed.metadata.importance, mem.metadata.importance);
932        }
933    }
934
935    #[test]
936    fn freeze_and_thaw_zero_data_loss() {
937        let store = test_store();
938        let galaxy = Galaxy::Codex;
939
940        let content = "Detailed architectural blueprint for WhiteMagic non-destructive storage";
941        let mem = Memory::new(galaxy, content.into())
942            .with_tags(vec!["architecture".into(), "v9".into()])
943            .with_importance(0.2);
944        let id = mem.metadata.id;
945
946        store.put(galaxy, &mem).unwrap();
947        assert!(store.get(galaxy, id).unwrap().is_some());
948
949        // Freeze to cold storage
950        let digester = PhagicDigester::default_config();
951        let factors = digester.calculate_distance(&mem, Utc::now());
952        let cold_record = store
953            .freeze_to_cold(
954                None,
955                id,
956                factors.distance,
957                factors,
958                None,
959                Some("unit test freeze".into()),
960                CompressionCodec::Gzip,
961            )
962            .unwrap();
963
964        assert_eq!(cold_record.id, id);
965
966        // Hot store should no longer contain the memory
967        assert!(store.get(galaxy, id).unwrap().is_none());
968
969        // But cold store DOES contain it
970        let cold_found = store.get_cold_record(id).unwrap();
971        assert!(cold_found.is_some());
972        let cold_rec = cold_found.unwrap();
973        assert_eq!(cold_rec.content_hash, mem.metadata.content_hash);
974
975        // find_anywhere should find it with is_cold = true
976        let (found_galaxy, found_mem, is_cold) = store.find_anywhere(id).unwrap().unwrap();
977        assert_eq!(found_galaxy, galaxy);
978        assert_eq!(found_mem.content, content);
979        assert!(is_cold);
980
981        // Thaw it back into the hot store
982        let thawed = store.thaw_from_cold(None, id).unwrap();
983        assert_eq!(thawed.metadata.id, id);
984        assert_eq!(thawed.content, content);
985        assert_eq!(
986            thawed.metadata.tags,
987            vec!["architecture", "v9", "thawed:phagic"]
988        );
989        assert_eq!(thawed.metadata.tier, Tier::Episodic);
990
991        // Hot store now contains it again
992        assert!(store.get(galaxy, id).unwrap().is_some());
993
994        // Cold store no longer contains it
995        assert!(store.get_cold_record(id).unwrap().is_none());
996
997        // find_anywhere now returns is_cold = false
998        let (_, _, is_cold_after) = store.find_anywhere(id).unwrap().unwrap();
999        assert!(!is_cold_after);
1000    }
1001
1002    #[test]
1003    fn phagic_digest_cluster_synthesizes_thematic_node() {
1004        let store = test_store();
1005        let galaxy = Galaxy::Journals;
1006        let now = Utc::now();
1007
1008        // Populate with 3 distant outer-rim memories
1009        let mut ids = Vec::new();
1010        for i in 1..=3 {
1011            let mut mem = Memory::new(
1012                galaxy,
1013                format!("Ancient journal reflection #{i} about distant journeys"),
1014            )
1015            .with_tags(vec!["travel".into(), "reflection".into()])
1016            .with_importance(0.05);
1017            mem.metadata.accessed_at = now - chrono::Duration::days(150);
1018            mem.metadata.access_count = 0;
1019            store.put(galaxy, &mem).unwrap();
1020            ids.push(mem.metadata.id);
1021        }
1022
1023        let digester = PhagicDigester::default_config();
1024        let report = digester.digest_galaxy(&store, None, galaxy).unwrap();
1025
1026        assert_eq!(report.memories_digested, 3);
1027        assert!(report.digest_memory_id.is_some());
1028        assert!(report.total_cold_bytes > 0);
1029        assert!(report.total_raw_bytes > report.total_cold_bytes);
1030
1031        // All 3 original memories are in cold storage, removed from hot galaxy
1032        for id in &ids {
1033            assert!(store.get(galaxy, *id).unwrap().is_none());
1034            assert!(store.get_cold_record(*id).unwrap().is_some());
1035        }
1036
1037        // Thematic digest memory is present in hot galaxy
1038        let digest_id = report.digest_memory_id.unwrap();
1039        let digest_mem = store.get(galaxy, digest_id).unwrap().unwrap();
1040        assert_eq!(digest_mem.metadata.tier, Tier::Semantic);
1041        assert!(digest_mem.content.contains("Phagic Thematic Digest"));
1042        assert!(
1043            digest_mem
1044                .metadata
1045                .tags
1046                .contains(&"phagic:digest".to_string())
1047        );
1048
1049        // Thaw one of the cold memories back to hot
1050        let thawed = digester.thaw(&store, None, ids[0]).unwrap();
1051        assert_eq!(thawed.metadata.id, ids[0]);
1052        assert!(store.get(galaxy, ids[0]).unwrap().is_some());
1053        assert!(store.get_cold_record(ids[0]).unwrap().is_none());
1054    }
1055}