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/// A public digest is itself a public/model-visible derived record. Restricted
273/// sources must not contribute snippets, tags, lineage IDs, or aggregate shape
274/// to it. Keep this at the builder boundary as well as the outer-rim scan:
275/// direct `digest_cluster` callers must not bypass the policy.
276const fn eligible_for_public_digest(mem: &Memory) -> bool {
277    !mem.metadata.is_protected && !mem.metadata.is_private && !mem.metadata.model_exclude
278}
279
280// ── Cold Storage Records & Summaries ────────────────────────────────────
281
282/// A persistent record stored in the cold archive.
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284pub struct ColdRecord {
285    /// Memory UUID.
286    pub id: MemoryId,
287    /// Original galaxy this memory belonged to.
288    pub galaxy: Galaxy,
289    /// Content hash (SHA-256) for integrity verification.
290    pub content_hash: String,
291    /// Timestamp when this memory was migrated into cold storage.
292    pub cold_stored_at: DateTime<Utc>,
293    /// Outer-rim distance calculated at freezing time.
294    pub outer_rim_distance: f32,
295    /// Factor breakdown at freezing time.
296    pub distance_factors: OuterRimFactors,
297    /// ID of the distilled thematic node (if synthesized during digestion).
298    pub digest_id: Option<MemoryId>,
299    /// Lineage & provenance notes.
300    pub lineage_notes: Option<String>,
301    /// Compression codec used.
302    pub codec: CompressionCodec,
303    /// Uncompressed payload size in bytes.
304    pub uncompressed_size: usize,
305    /// Compressed binary payload containing the full original Memory.
306    pub compressed_payload: Vec<u8>,
307    /// Vector clock / version at freezing time.
308    pub version: u64,
309    /// Fast-search tags preserved in cold header.
310    pub tags: Vec<String>,
311    /// Title preserved in cold header.
312    pub title: Option<String>,
313    /// Text snippet for rapid search without decompression.
314    pub snippet: String,
315}
316
317impl ColdRecord {
318    /// Create a new cold record by compressing a hot memory.
319    pub fn new(
320        mem: &Memory,
321        outer_rim_distance: f32,
322        distance_factors: OuterRimFactors,
323        digest_id: Option<MemoryId>,
324        lineage_notes: Option<String>,
325        codec: CompressionCodec,
326    ) -> Result<Self> {
327        let (compressed_payload, uncompressed_size) = compress_memory(mem, codec)?;
328        let snippet = create_snippet(&mem.content);
329
330        Ok(Self {
331            id: mem.metadata.id,
332            galaxy: mem.metadata.galaxy,
333            content_hash: mem.metadata.content_hash.clone(),
334            cold_stored_at: Utc::now(),
335            outer_rim_distance,
336            distance_factors,
337            digest_id,
338            lineage_notes,
339            codec,
340            uncompressed_size,
341            compressed_payload,
342            version: mem.metadata.version,
343            tags: mem.metadata.tags.clone(),
344            title: mem.metadata.title.clone(),
345            snippet,
346        })
347    }
348
349    /// Extract a lightweight summary without decompressing the full memory payload.
350    #[must_use]
351    pub fn summary(&self) -> ColdRecordSummary {
352        ColdRecordSummary {
353            id: self.id,
354            galaxy: self.galaxy,
355            content_hash: self.content_hash.clone(),
356            cold_stored_at: self.cold_stored_at,
357            outer_rim_distance: self.outer_rim_distance,
358            digest_id: self.digest_id,
359            uncompressed_size: self.uncompressed_size,
360            compressed_size: self.compressed_payload.len(),
361            codec: self.codec,
362            tags: self.tags.clone(),
363            title: self.title.clone(),
364            snippet: self.snippet.clone(),
365        }
366    }
367
368    /// Decompress and restore the complete original `Memory`.
369    pub fn decompress(&self) -> Result<Memory> {
370        decompress_memory(&self.compressed_payload, self.codec)
371    }
372}
373
374/// Lightweight summary of a cold-stored memory for listings and queries.
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
376pub struct ColdRecordSummary {
377    /// Memory UUID.
378    pub id: MemoryId,
379    /// Original Galaxy.
380    pub galaxy: Galaxy,
381    /// Content hash.
382    pub content_hash: String,
383    /// Cold storage timestamp.
384    pub cold_stored_at: DateTime<Utc>,
385    /// Outer-rim distance at freeze time.
386    pub outer_rim_distance: f32,
387    /// Associated digest node ID (if any).
388    pub digest_id: Option<MemoryId>,
389    /// Uncompressed bytes.
390    pub uncompressed_size: usize,
391    /// Compressed bytes.
392    pub compressed_size: usize,
393    /// Codec used.
394    pub codec: CompressionCodec,
395    /// Tags preserved in cold header.
396    pub tags: Vec<String>,
397    /// Title preserved in cold header.
398    pub title: Option<String>,
399    /// Text snippet.
400    pub snippet: String,
401}
402
403// ── Cold Query Filter ───────────────────────────────────────────────────
404
405/// Filter for querying cold-stored memories.
406#[derive(Debug, Clone, Default)]
407pub struct ColdQuery {
408    /// Optional galaxy filter.
409    pub galaxy: Option<Galaxy>,
410    /// Must contain all specified tags.
411    pub tags: Vec<String>,
412    /// Minimum outer-rim distance filter.
413    pub min_distance: Option<f32>,
414    /// Stored after this timestamp.
415    pub stored_after: Option<DateTime<Utc>>,
416    /// Stored before this timestamp.
417    pub stored_before: Option<DateTime<Utc>>,
418    /// Substring match over title or snippet.
419    pub content_substring: Option<String>,
420    /// Maximum results to return.
421    pub limit: usize,
422}
423
424/// Outcome of a bounded cold-storage discovery scan.
425///
426/// Discovery is opt-in and bounded: candidates are hydrated and authorized
427/// from the cold payload (decompress + integrity + visibility) rather than
428/// trusting stale index entries. No record is thawed or mutated.
429#[derive(Debug, Default, Clone)]
430pub struct ColdDiscoveryOutcome {
431    /// Cold records visited by the scan.
432    pub scanned: usize,
433    /// Records in the requested galaxy.
434    pub candidates: usize,
435    /// Records that text-matched, verified, and passed visibility.
436    pub matched: usize,
437    /// Records refused because decompression or hash verification failed.
438    pub integrity_rejected: usize,
439    /// Records skipped because they are private (never surface over MCP).
440    pub private_skipped: usize,
441    /// Records skipped because their validity is not current (superseded).
442    pub non_current_skipped: usize,
443    /// Verified, visible matching records (caller decompresses for output).
444    pub records: Vec<ColdRecord>,
445}
446
447impl ColdQuery {
448    /// Create a new query matching all cold records (limit 100).
449    #[must_use]
450    pub fn new() -> Self {
451        Self {
452            limit: 100,
453            ..Default::default()
454        }
455    }
456
457    /// Filter by galaxy.
458    #[must_use]
459    pub const fn with_galaxy(mut self, galaxy: Galaxy) -> Self {
460        self.galaxy = Some(galaxy);
461        self
462    }
463
464    /// Filter by tags.
465    #[must_use]
466    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
467        self.tags = tags;
468        self
469    }
470
471    /// Filter by minimum distance.
472    #[must_use]
473    pub const fn with_min_distance(mut self, min_dist: f32) -> Self {
474        self.min_distance = Some(min_dist);
475        self
476    }
477
478    /// Filter by result limit.
479    #[must_use]
480    pub const fn with_limit(mut self, limit: usize) -> Self {
481        self.limit = limit;
482        self
483    }
484
485    /// Filter by substring.
486    #[must_use]
487    pub fn with_content_substring(mut self, substring: impl Into<String>) -> Self {
488        self.content_substring = Some(substring.into().to_lowercase());
489        self
490    }
491
492    /// Check if a cold record summary satisfies this query.
493    #[must_use]
494    pub fn matches(&self, summary: &ColdRecordSummary) -> bool {
495        if let Some(g) = self.galaxy {
496            if summary.galaxy != g {
497                return false;
498            }
499        }
500        if let Some(min_d) = self.min_distance {
501            if summary.outer_rim_distance < min_d {
502                return false;
503            }
504        }
505        if let Some(after) = self.stored_after {
506            if summary.cold_stored_at < after {
507                return false;
508            }
509        }
510        if let Some(before) = self.stored_before {
511            if summary.cold_stored_at > before {
512                return false;
513            }
514        }
515        for tag in &self.tags {
516            if !summary.tags.iter().any(|t| t == tag) {
517                return false;
518            }
519        }
520        if let Some(sub) = &self.content_substring {
521            let in_title = summary
522                .title
523                .as_ref()
524                .is_some_and(|t| t.to_lowercase().contains(sub));
525            let in_snippet = summary.snippet.to_lowercase().contains(sub);
526            if !in_title && !in_snippet {
527                return false;
528            }
529        }
530        true
531    }
532}
533
534// ── Phagic Digest Result Report ─────────────────────────────────────────
535
536/// Telemetry report of a phagic digestion pass on a galaxy.
537#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct PhagicDigestReport {
539    /// Galaxy processed.
540    pub galaxy: Galaxy,
541    /// Total memories scanned in the galaxy.
542    pub memories_examined: usize,
543    /// Outer-rim memories identified above the distance threshold.
544    pub outer_rim_candidates: usize,
545    /// Memories migrated into cold storage.
546    pub memories_digested: usize,
547    /// ID of the synthesized thematic digest node placed in the active tier.
548    pub digest_memory_id: Option<MemoryId>,
549    /// Uncompressed raw bytes across all digested memories.
550    pub total_raw_bytes: usize,
551    /// Compressed bytes stored in cold archive.
552    pub total_cold_bytes: usize,
553    /// Space compression ratio: `total_cold_bytes / total_raw_bytes`.
554    pub compression_ratio: f32,
555    /// Average outer-rim distance of the digested memories.
556    pub average_outer_rim_distance: f32,
557}
558
559// ── Phagic Digester Engine ──────────────────────────────────────────────
560
561/// Non-destructive digestive processor.
562pub struct PhagicDigester {
563    config: PhagicConfig,
564}
565
566impl PhagicDigester {
567    /// Create a new digester with the specified configuration.
568    #[must_use]
569    pub const fn new(config: PhagicConfig) -> Self {
570        Self { config }
571    }
572
573    /// Create with default configuration.
574    #[must_use]
575    pub fn default_config() -> Self {
576        Self::new(PhagicConfig::default())
577    }
578
579    /// Access configuration.
580    #[must_use]
581    pub const fn config(&self) -> &PhagicConfig {
582        &self.config
583    }
584
585    /// Calculate distance factors for a memory.
586    #[must_use]
587    pub fn calculate_distance(&self, mem: &Memory, now: DateTime<Utc>) -> OuterRimFactors {
588        calculate_outer_rim_distance(mem, &self.config, now)
589    }
590
591    /// Scan a galaxy and identify outer-rim memories sorted by distance descending.
592    pub fn scan_outer_rim(
593        &self,
594        store: &MemoryStore,
595        galaxy: Galaxy,
596    ) -> Result<Vec<(Memory, OuterRimFactors)>> {
597        let memories = store.scan(galaxy, 10_000)?;
598        let now = Utc::now();
599        let mut candidates = Vec::new();
600
601        for mem in memories {
602            // A public digest must not synthesize restricted source material.
603            // The same check is repeated by digest_cluster for direct callers.
604            if !eligible_for_public_digest(&mem) {
605                continue;
606            }
607
608            let factors = self.calculate_distance(&mem, now);
609            if factors.distance >= self.config.outer_rim_threshold {
610                candidates.push((mem, factors));
611            }
612        }
613
614        // Sort descending by distance (furthest memories first)
615        candidates.sort_by(|a, b| {
616            b.1.distance
617                .partial_cmp(&a.1.distance)
618                .unwrap_or(std::cmp::Ordering::Equal)
619        });
620
621        Ok(candidates)
622    }
623
624    /// Synthesize a cluster of outer-rim memories into a distilled thematic node in the hot tier,
625    /// and gently migrate the original memories into the cold storage tier.
626    pub fn digest_cluster(
627        &self,
628        store: &MemoryStore,
629        search: Option<&SearchEngine>,
630        galaxy: Galaxy,
631        cluster: &[(Memory, OuterRimFactors)],
632    ) -> Result<PhagicDigestReport> {
633        // A caller may invoke this public builder directly instead of using
634        // scan_outer_rim. Filter again before collecting any tag, time,
635        // snippet, ID, or count into a public derived record. Mixed clusters
636        // retain restricted originals untouched and digest eligible sources
637        // only; a restricted-only cluster is a truthful no-op.
638        let eligible: Vec<&(Memory, OuterRimFactors)> = cluster
639            .iter()
640            .filter(|(mem, _)| eligible_for_public_digest(mem))
641            .collect();
642        if eligible.is_empty() {
643            return Ok(PhagicDigestReport {
644                galaxy,
645                memories_examined: 0,
646                outer_rim_candidates: 0,
647                memories_digested: 0,
648                digest_memory_id: None,
649                total_raw_bytes: 0,
650                total_cold_bytes: 0,
651                compression_ratio: 0.0,
652                average_outer_rim_distance: 0.0,
653            });
654        }
655
656        let cluster_id = Uuid::new_v4();
657        let now = Utc::now();
658
659        // Compute tag frequency and shared topics
660        let mut tag_counts: HashMap<String, usize> = HashMap::new();
661        let mut total_distance = 0.0;
662        let mut min_time = now;
663        let mut max_time = DateTime::<Utc>::MIN_UTC;
664
665        for entry in &eligible {
666            let (mem, factors) = *entry;
667            total_distance += factors.distance;
668            if mem.metadata.created_at < min_time {
669                min_time = mem.metadata.created_at;
670            }
671            if mem.metadata.created_at > max_time {
672                max_time = mem.metadata.created_at;
673            }
674            for tag in &mem.metadata.tags {
675                if !tag.starts_with("phagic:") && !tag.starts_with("cold_source:") {
676                    *tag_counts.entry(tag.clone()).or_insert(0) += 1;
677                }
678            }
679        }
680
681        let avg_distance = total_distance / eligible.len() as f32;
682
683        let mut top_tags: Vec<(String, usize)> = tag_counts.into_iter().collect();
684        top_tags.sort_by_key(|a| std::cmp::Reverse(a.1));
685        let prominent_tags: Vec<String> =
686            top_tags.into_iter().take(6).map(|(tag, _)| tag).collect();
687
688        // 1. Synthesize thematic digest memory for active hot tier
689        let mut digest_content = format!(
690            "# Phagic Thematic Digest: {} Outer-Rim Condensation\n\n\
691             **Cluster ID**: `{cluster_id}`\n\
692             **Galaxy**: `{}`\n\
693             **Digested Memories**: {} items safely migrated to Cold Storage\n\
694             **Temporal Span**: {} to {}\n\
695             **Average Outer-Rim Distance**: {:.3}\n\
696             **Prominent Themes**: {}\n\n\
697             ## Distilled Semantic Abstract\n\
698             This thematic node preserves the collective semantic essence of {} outer-rim memories\n\
699             from galaxy {}. The original memories have been gently transitioned into compressed\n\
700             cold storage without data loss, retaining complete lineage and full thawability.\n\n\
701             ## Lineage Pointers (Thawable Cold Records)\n",
702            galaxy.db_name(),
703            galaxy.db_name(),
704            eligible.len(),
705            min_time.format("%Y-%m-%d %H:%M:%S UTC"),
706            max_time.format("%Y-%m-%d %H:%M:%S UTC"),
707            avg_distance,
708            if prominent_tags.is_empty() {
709                "none".to_string()
710            } else {
711                prominent_tags.join(", ")
712            },
713            eligible.len(),
714            galaxy.db_name(),
715        );
716
717        use std::fmt::Write as _;
718        for entry in &eligible {
719            let (mem, factors) = *entry;
720            let snippet = create_snippet(&mem.content);
721            let _ = writeln!(
722                digest_content,
723                "- **[Memory `{}`]** (Distance: {:.2}): {snippet}",
724                mem.metadata.id, factors.distance
725            );
726        }
727
728        let mut digest_tags = prominent_tags;
729        digest_tags.push("phagic:digest".to_string());
730        digest_tags.push(format!("phagic:cluster:{cluster_id}"));
731
732        let digest_title = format!(
733            "Phagic Digest: {} ({} items)",
734            galaxy.db_name(),
735            cluster.len()
736        );
737        let mut digest_mem = Memory::new(galaxy, digest_content)
738            .with_tags(digest_tags)
739            .with_importance(0.65)
740            .with_memory_type(MemoryType::Symbolic)
741            .with_source("phagic:digestion".to_string(), 0.9);
742        digest_mem.metadata.tier = Tier::Semantic;
743        digest_mem.metadata.title = Some(digest_title);
744        digest_mem.metadata.topic = Some(format!("{}:phagic_digest", galaxy.db_name()));
745
746        // Store thematic digest memory in the hot store
747        store.put(galaxy, &digest_mem)?;
748
749        // Index the thematic digest node in Tantivy search if available
750        if let Some(engine) = search {
751            if let Ok(mut writer_guard) = engine.writer() {
752                let _ = engine.add_document(
753                    &mut writer_guard,
754                    &digest_mem.metadata.id.to_string(),
755                    galaxy.db_name(),
756                    &digest_mem.content,
757                    &digest_mem.metadata.tags,
758                    digest_mem.metadata.created_at.timestamp(),
759                );
760                let _ = engine.commit(&mut writer_guard);
761            }
762        }
763
764        let digest_id = digest_mem.metadata.id;
765
766        // 2. Freeze each original memory into cold storage
767        let mut total_raw_bytes = 0;
768        let mut total_cold_bytes = 0;
769
770        for entry in &eligible {
771            let (mem, factors) = *entry;
772            let notes = format!("Digested in phagic cluster {cluster_id} (digest_id: {digest_id})");
773            let cold_rec = store.freeze_to_cold(
774                search,
775                mem.metadata.id,
776                factors.distance,
777                factors.clone(),
778                Some(digest_id),
779                Some(notes),
780                self.config.compression_codec,
781            )?;
782            total_raw_bytes += cold_rec.uncompressed_size;
783            total_cold_bytes += cold_rec.compressed_payload.len();
784        }
785
786        let compression_ratio = if total_raw_bytes > 0 {
787            total_cold_bytes as f32 / total_raw_bytes as f32
788        } else {
789            0.0
790        };
791
792        Ok(PhagicDigestReport {
793            galaxy,
794            memories_examined: eligible.len(),
795            outer_rim_candidates: eligible.len(),
796            memories_digested: eligible.len(),
797            digest_memory_id: Some(digest_id),
798            total_raw_bytes,
799            total_cold_bytes,
800            compression_ratio,
801            average_outer_rim_distance: avg_distance,
802        })
803    }
804
805    /// Perform a full non-destructive digestive pass on a galaxy.
806    pub fn digest_galaxy(
807        &self,
808        store: &MemoryStore,
809        search: Option<&SearchEngine>,
810        galaxy: Galaxy,
811    ) -> Result<PhagicDigestReport> {
812        let candidates = self.scan_outer_rim(store, galaxy)?;
813        let count = store.count(galaxy).unwrap_or(0);
814
815        if candidates.is_empty() {
816            return Ok(PhagicDigestReport {
817                galaxy,
818                memories_examined: count,
819                outer_rim_candidates: 0,
820                memories_digested: 0,
821                digest_memory_id: None,
822                total_raw_bytes: 0,
823                total_cold_bytes: 0,
824                compression_ratio: 0.0,
825                average_outer_rim_distance: 0.0,
826            });
827        }
828
829        // Bound to max_digest_batch
830        let batch_size = candidates.len().min(self.config.max_digest_batch);
831        let batch = &candidates[..batch_size];
832
833        let mut report = self.digest_cluster(store, search, galaxy, batch)?;
834        report.memories_examined = count;
835        report.outer_rim_candidates = candidates.len();
836        Ok(report)
837    }
838
839    /// Perform phagic digestion across all memory galaxies.
840    pub fn digest_all(
841        &self,
842        store: &MemoryStore,
843        search: Option<&SearchEngine>,
844    ) -> Result<Vec<PhagicDigestReport>> {
845        let mut reports = Vec::new();
846        for galaxy in Galaxy::all() {
847            match galaxy {
848                Galaxy::Substrate
849                | Galaxy::Dharma
850                | Galaxy::Karma
851                | Galaxy::Embeddings
852                | Galaxy::Associations => continue,
853                _ => {}
854            }
855            if store.count(galaxy).unwrap_or(0) == 0 {
856                continue;
857            }
858            reports.push(self.digest_galaxy(store, search, galaxy)?);
859        }
860        Ok(reports)
861    }
862
863    /// Thaw a cold-stored memory back into the active hot tier.
864    pub fn thaw(
865        &self,
866        store: &MemoryStore,
867        search: Option<&SearchEngine>,
868        id: MemoryId,
869    ) -> Result<Memory> {
870        store.thaw_from_cold(search, id)
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877    use tempfile::tempdir;
878
879    fn test_store() -> MemoryStore {
880        let tmp = tempdir().unwrap();
881        MemoryStore::open_default(tmp.path()).unwrap()
882    }
883
884    #[test]
885    fn outer_rim_distance_calculation_monotonic() {
886        let config = PhagicConfig::default();
887        let now = Utc::now();
888
889        // 1. Core memory: recent, high importance, high resonance, high emotion, accessed
890        let mut core_mem = Memory::new(Galaxy::Codex, "Vital core memory".into())
891            .with_importance(0.95)
892            .with_neuro_score(0.9)
893            .with_emotional_valence(0.8, 0.9);
894        core_mem.metadata.access_count = 10;
895        core_mem.metadata.accessed_at = now;
896
897        let core_factors = calculate_outer_rim_distance(&core_mem, &config, now);
898        assert!(
899            core_factors.distance < 0.25,
900            "Core memory distance should be low: {}",
901            core_factors.distance
902        );
903
904        // 2. Outer-rim memory: ancient, 0 accesses, low importance, low resonance, neutral emotion
905        let mut rim_mem = Memory::new(Galaxy::Codex, "Faded distant note".into())
906            .with_importance(0.05)
907            .with_neuro_score(0.1)
908            .with_emotional_valence(0.0, 0.0);
909        rim_mem.metadata.access_count = 0;
910        rim_mem.metadata.recall_count = 0;
911        rim_mem.metadata.accessed_at = now - chrono::Duration::days(120);
912
913        let rim_factors = calculate_outer_rim_distance(&rim_mem, &config, now);
914        assert!(
915            rim_factors.distance >= config.outer_rim_threshold,
916            "Rim memory distance should exceed threshold: {}",
917            rim_factors.distance
918        );
919        assert!(
920            rim_factors.distance > core_factors.distance,
921            "Rim memory must be strictly further out than core memory"
922        );
923    }
924
925    #[test]
926    fn protected_memory_is_anchored_at_core() {
927        let config = PhagicConfig::default();
928        let now = Utc::now();
929
930        let mut protected_mem = Memory::new(Galaxy::Codex, "Protected sacred memory".into())
931            .with_protection(true)
932            .with_importance(0.01); // low importance, but protected
933        protected_mem.metadata.accessed_at = now - chrono::Duration::days(365);
934
935        let factors = calculate_outer_rim_distance(&protected_mem, &config, now);
936        assert_eq!(
937            factors.distance, 0.0,
938            "Protected memory must always have distance 0.0"
939        );
940    }
941
942    #[test]
943    fn cold_record_compression_roundtrip() {
944        let mem = Memory::new(
945            Galaxy::Research,
946            "Extensive research logs on cosmic non-destructive phagocytosis and entropy reversal"
947                .into(),
948        )
949        .with_tags(vec!["physics".into(), "entropy".into(), "phagic".into()])
950        .with_importance(0.3);
951
952        for codec in [
953            CompressionCodec::Gzip,
954            CompressionCodec::Deflate,
955            CompressionCodec::Msgpack,
956        ] {
957            let factors = OuterRimFactors {
958                age_factor: 0.8,
959                access_factor: 0.9,
960                resonance_factor: 0.7,
961                emotional_factor: 0.9,
962                importance_factor: 0.7,
963                distance: 0.81,
964            };
965
966            let cold = ColdRecord::new(&mem, 0.81, factors, None, Some("test notes".into()), codec)
967                .unwrap();
968            assert_eq!(cold.id, mem.metadata.id);
969            assert_eq!(cold.galaxy, Galaxy::Research);
970
971            let decompressed = cold.decompress().unwrap();
972            assert_eq!(decompressed.metadata.id, mem.metadata.id);
973            assert_eq!(decompressed.content, mem.content);
974            assert_eq!(decompressed.metadata.tags, mem.metadata.tags);
975            assert_eq!(decompressed.metadata.importance, mem.metadata.importance);
976        }
977    }
978
979    #[test]
980    fn freeze_and_thaw_zero_data_loss() {
981        let store = test_store();
982        let galaxy = Galaxy::Codex;
983
984        let content = "Detailed architectural blueprint for WhiteMagic non-destructive storage";
985        let mem = Memory::new(galaxy, content.into())
986            .with_tags(vec!["architecture".into(), "v9".into()])
987            .with_importance(0.2);
988        let id = mem.metadata.id;
989
990        store.put(galaxy, &mem).unwrap();
991        assert!(store.get(galaxy, id).unwrap().is_some());
992
993        // Freeze to cold storage
994        let digester = PhagicDigester::default_config();
995        let factors = digester.calculate_distance(&mem, Utc::now());
996        let cold_record = store
997            .freeze_to_cold(
998                None,
999                id,
1000                factors.distance,
1001                factors,
1002                None,
1003                Some("unit test freeze".into()),
1004                CompressionCodec::Gzip,
1005            )
1006            .unwrap();
1007
1008        assert_eq!(cold_record.id, id);
1009
1010        // Hot store should no longer contain the memory
1011        assert!(store.get(galaxy, id).unwrap().is_none());
1012
1013        // But cold store DOES contain it
1014        let cold_found = store.get_cold_record(id).unwrap();
1015        assert!(cold_found.is_some());
1016        let cold_rec = cold_found.unwrap();
1017        assert_eq!(cold_rec.content_hash, mem.metadata.content_hash);
1018
1019        // find_anywhere should find it with is_cold = true
1020        let (found_galaxy, found_mem, is_cold) = store.find_anywhere(id).unwrap().unwrap();
1021        assert_eq!(found_galaxy, galaxy);
1022        assert_eq!(found_mem.content, content);
1023        assert!(is_cold);
1024
1025        // Thaw it back into the hot store
1026        let thawed = store.thaw_from_cold(None, id).unwrap();
1027        assert_eq!(thawed.metadata.id, id);
1028        assert_eq!(thawed.content, content);
1029        assert_eq!(
1030            thawed.metadata.tags,
1031            vec!["architecture", "v9", "thawed:phagic"]
1032        );
1033        assert_eq!(thawed.metadata.tier, Tier::Episodic);
1034
1035        // Hot store now contains it again
1036        assert!(store.get(galaxy, id).unwrap().is_some());
1037
1038        // Cold store no longer contains it
1039        assert!(store.get_cold_record(id).unwrap().is_none());
1040
1041        // find_anywhere now returns is_cold = false
1042        let (_, _, is_cold_after) = store.find_anywhere(id).unwrap().unwrap();
1043        assert!(!is_cold_after);
1044    }
1045
1046    #[test]
1047    fn phagic_digest_cluster_synthesizes_thematic_node() {
1048        let store = test_store();
1049        let galaxy = Galaxy::Journals;
1050        let now = Utc::now();
1051
1052        // Populate with 3 distant outer-rim memories
1053        let mut ids = Vec::new();
1054        for i in 1..=3 {
1055            let mut mem = Memory::new(
1056                galaxy,
1057                format!("Ancient journal reflection #{i} about distant journeys"),
1058            )
1059            .with_tags(vec!["travel".into(), "reflection".into()])
1060            .with_importance(0.05);
1061            mem.metadata.accessed_at = now - chrono::Duration::days(150);
1062            mem.metadata.access_count = 0;
1063            store.put(galaxy, &mem).unwrap();
1064            ids.push(mem.metadata.id);
1065        }
1066
1067        let digester = PhagicDigester::default_config();
1068        let report = digester.digest_galaxy(&store, None, galaxy).unwrap();
1069
1070        assert_eq!(report.memories_digested, 3);
1071        assert!(report.digest_memory_id.is_some());
1072        assert!(report.total_cold_bytes > 0);
1073        assert!(report.total_raw_bytes > report.total_cold_bytes);
1074
1075        // All 3 original memories are in cold storage, removed from hot galaxy
1076        for id in &ids {
1077            assert!(store.get(galaxy, *id).unwrap().is_none());
1078            assert!(store.get_cold_record(*id).unwrap().is_some());
1079        }
1080
1081        // Thematic digest memory is present in hot galaxy
1082        let digest_id = report.digest_memory_id.unwrap();
1083        let digest_mem = store.get(galaxy, digest_id).unwrap().unwrap();
1084        assert_eq!(digest_mem.metadata.tier, Tier::Semantic);
1085        assert!(digest_mem.content.contains("Phagic Thematic Digest"));
1086        assert!(
1087            digest_mem
1088                .metadata
1089                .tags
1090                .contains(&"phagic:digest".to_string())
1091        );
1092
1093        // Thaw one of the cold memories back to hot
1094        let thawed = digester.thaw(&store, None, ids[0]).unwrap();
1095        assert_eq!(thawed.metadata.id, ids[0]);
1096        assert!(store.get(galaxy, ids[0]).unwrap().is_some());
1097        assert!(store.get_cold_record(ids[0]).unwrap().is_none());
1098    }
1099
1100    #[test]
1101    fn direct_digest_cluster_does_not_launder_restricted_source_material() {
1102        let store = test_store();
1103        let galaxy = Galaxy::Codex;
1104        let factors = OuterRimFactors {
1105            age_factor: 1.0,
1106            access_factor: 1.0,
1107            resonance_factor: 1.0,
1108            emotional_factor: 1.0,
1109            importance_factor: 1.0,
1110            distance: 1.0,
1111        };
1112
1113        let public =
1114            Memory::new(galaxy, "public digest source".into()).with_tags(vec!["public-tag".into()]);
1115        let mut private = Memory::new(galaxy, "PRIVATE-SOURCE-CONTENT".into())
1116            .with_tags(vec!["private-source-tag".into()]);
1117        private.metadata.is_private = true;
1118        let mut model_excluded = Memory::new(galaxy, "MODEL-EXCLUDED-SOURCE-CONTENT".into())
1119            .with_tags(vec!["model-excluded-source-tag".into()]);
1120        model_excluded.metadata.model_exclude = true;
1121        let protected = Memory::new(galaxy, "PROTECTED-SOURCE-CONTENT".into())
1122            .with_tags(vec!["protected-source-tag".into()])
1123            .with_protection(true);
1124
1125        for memory in [&public, &private, &model_excluded, &protected] {
1126            store.put(galaxy, memory).unwrap();
1127        }
1128        let restricted = [private.clone(), model_excluded.clone(), protected.clone()];
1129        let cluster = vec![
1130            (public.clone(), factors.clone()),
1131            (private, factors.clone()),
1132            (model_excluded, factors.clone()),
1133            (protected, factors),
1134        ];
1135
1136        // Direct builder invocation is the discriminating boundary: relying
1137        // only on scan_outer_rim would leave this path able to synthesize a
1138        // public digest from restricted input.
1139        let report = PhagicDigester::default_config()
1140            .digest_cluster(&store, None, galaxy, &cluster)
1141            .unwrap();
1142        assert_eq!(report.memories_digested, 1);
1143        let digest = store
1144            .get(galaxy, report.digest_memory_id.unwrap())
1145            .unwrap()
1146            .unwrap();
1147        assert!(!digest.metadata.is_private);
1148        assert!(!digest.metadata.model_exclude);
1149        assert!(digest.content.contains("public digest source"));
1150        for source in &restricted {
1151            assert!(!digest.content.contains(&source.content));
1152            assert!(!digest.content.contains(&source.metadata.id.to_string()));
1153            for tag in &source.metadata.tags {
1154                assert!(!digest.metadata.tags.contains(tag));
1155                assert!(!digest.content.contains(tag));
1156            }
1157            let retained = store.get(galaxy, source.metadata.id).unwrap().unwrap();
1158            assert_eq!(
1159                serde_json::to_value(retained).unwrap(),
1160                serde_json::to_value(source).unwrap()
1161            );
1162            assert!(store.get_cold_record(source.metadata.id).unwrap().is_none());
1163        }
1164
1165        assert!(store.get(galaxy, public.metadata.id).unwrap().is_none());
1166        assert!(store.get_cold_record(public.metadata.id).unwrap().is_some());
1167    }
1168}