Skip to main content

memory_crystal/
encoder.rs

1use std::collections::HashMap;
2
3use crate::tile::{SalienceMap, Tile, TileId};
4
5/// Lossy encoder: compresses raw content into a tile.
6pub struct TileEncoder {
7    pub max_summary_bytes: usize,
8    pub constraint_extractor: ConstraintExtractor,
9}
10
11impl Default for TileEncoder {
12    fn default() -> Self {
13        Self {
14            max_summary_bytes: 512,
15            constraint_extractor: ConstraintExtractor::default(),
16        }
17    }
18}
19
20impl TileEncoder {
21    /// Encode content into a tile using the provided salience map.
22    pub fn encode(&self, content: &str, salience: &SalienceMap) -> Tile {
23        let summary = self.compress(content);
24        let constraints = if salience.constraints.is_empty() {
25            self.constraint_extractor.extract(content)
26        } else {
27            salience.constraints.clone()
28        };
29        let valence = salience.valence.unwrap_or_else(|| self.constraint_extractor.valence(content));
30
31        Tile {
32            id: TileId::new(),
33            source_hash: Tile::hash_content(content),
34            constraints,
35            summary,
36            context_required: if salience.context_required.is_empty() {
37                self.infer_context(content)
38            } else {
39                salience.context_required.clone()
40            },
41            valence,
42            created_at: chrono::Utc::now(),
43            accessed_at: chrono::Utc::now(),
44            access_count: 0,
45            generation: 0,
46            parent_id: None,
47        }
48    }
49
50    /// Encode with explicit valence override.
51    pub fn encode_with_valence(&self, content: &str, valence: f64) -> Tile {
52        let salience = SalienceMap {
53            valence: Some(valence),
54            ..Default::default()
55        };
56        self.encode(content, &salience)
57    }
58
59    /// Compress content to fit within max_summary_bytes.
60    fn compress(&self, content: &str) -> String {
61        if content.len() <= self.max_summary_bytes {
62            return content.to_string();
63        }
64
65        // Take sentences until we hit the byte limit.
66        let mut summary = String::new();
67        for sentence in content.split_inclusive(|c: char| c == '.' || c == '!' || c == '?') {
68            if summary.len() + sentence.len() > self.max_summary_bytes {
69                break;
70            }
71            summary.push_str(sentence);
72        }
73
74        if summary.is_empty() {
75            // Fallback: truncate at byte boundary (char-aligned).
76            let end = content
77                .char_indices()
78                .take_while(|(i, _)| *i < self.max_summary_bytes)
79                .last()
80                .map(|(i, c)| i + c.len_utf8())
81                .unwrap_or(self.max_summary_bytes.min(content.len()));
82            summary = content[..end].to_string();
83        }
84
85        summary
86    }
87
88    /// Infer what context would be needed to reconstruct this content.
89    fn infer_context(&self, content: &str) -> Vec<String> {
90        let mut ctx = Vec::new();
91        // Extract capitalized phrases as potential context cues.
92        for word in content.split_whitespace() {
93            let trimmed = word.trim_matches(|c: char| !c.is_alphanumeric());
94            if trimmed.starts_with(char::is_uppercase) && trimmed.len() > 2 {
95                ctx.push(trimmed.to_string());
96            }
97        }
98        ctx.dedup();
99        ctx.truncate(10);
100        ctx
101    }
102}
103
104/// Extracts "immortal facts" — proper nouns, numbers, dramatic phrases.
105#[derive(Default)]
106pub struct ConstraintExtractor;
107
108impl ConstraintExtractor {
109    /// Extract immutable facts from content.
110    pub fn extract(&self, content: &str) -> HashMap<String, String> {
111        let mut constraints = HashMap::new();
112
113        // Extract numbers with units or standalone.
114        for word in content.split_whitespace() {
115            if word.chars().any(|c| c.is_ascii_digit()) {
116                let key = format!("num_{}", constraints.len());
117                constraints.insert(key, word.to_string());
118            }
119        }
120
121        // Extract capitalized phrases (proper nouns).
122        let mut proper_nouns = Vec::new();
123        for word in content.split_whitespace() {
124            let trimmed = word.trim_matches(|c: char| !c.is_alphanumeric());
125            if trimmed.starts_with(char::is_uppercase) && trimmed.len() > 2 && !proper_nouns.contains(&trimmed) {
126                proper_nouns.push(trimmed);
127            }
128        }
129        if !proper_nouns.is_empty() {
130            constraints.insert("proper_nouns".into(), proper_nouns.join(", "));
131        }
132
133        // Extract quoted strings as important.
134        let mut in_quote = false;
135        let mut quoted = String::new();
136        let mut quotes = Vec::new();
137        for ch in content.chars() {
138            match ch {
139                '"' | '\'' if in_quote => {
140                    in_quote = false;
141                    if !quoted.is_empty() {
142                        quotes.push(quoted.clone());
143                    }
144                    quoted.clear();
145                }
146                '"' | '\'' => {
147                    in_quote = true;
148                }
149                _ if in_quote => {
150                    quoted.push(ch);
151                }
152                _ => {}
153            }
154        }
155        if !quotes.is_empty() {
156            constraints.insert("quoted".into(), quotes.join("; "));
157        }
158
159        constraints
160    }
161
162    /// Score emotional valence from text features (0.0–1.0).
163    pub fn valence(&self, content: &str) -> f64 {
164        let high_valence = [
165            "amazing", "incredible", "excited", "love", "fantastic", "breakthrough",
166            "critical", "urgent", "important", "dramatic", "shocking", "unbelievable",
167            "urgent", "crisis", "emergency", "disaster", "triumph", "victory",
168        ];
169        let low_valence = [
170            "maybe", "perhaps", "minor", "routine", "normal", "fine", "okay",
171            "standard", "regular", "typical", "usual",
172        ];
173
174        let lower = content.to_lowercase();
175        let words: Vec<&str> = lower.split_whitespace().collect();
176
177        let high_count = words.iter().filter(|w| high_valence.contains(&w.as_ref())).count();
178        let low_count = words.iter().filter(|w| low_valence.contains(&w.as_ref())).count();
179
180        if high_count + low_count == 0 {
181            return 0.3; // Default low-medium valence.
182        }
183
184        let base = high_count as f64 / (high_count + low_count) as f64;
185        // Boost for exclamation marks and caps.
186        let excl_boost = content.matches('!').count().min(5) as f64 * 0.05;
187        let caps_boost = content
188            .chars()
189            .filter(|c| c.is_uppercase())
190            .count()
191            .min(20) as f64
192            * 0.005;
193
194        (base + excl_boost + caps_boost).clamp(0.0, 1.0)
195    }
196
197    /// Compute compression ratio.
198    pub fn compression_ratio(&self, original: &str, tile: &Tile) -> f64 {
199        if original.is_empty() {
200            return 1.0;
201        }
202        tile.summary.len() as f64 / original.len() as f64
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn encode_basic() {
212        let encoder = TileEncoder::default();
213        let tile = encoder.encode("Hello world. This is a test.", &SalienceMap::default());
214        assert!(!tile.summary.is_empty());
215        assert_eq!(tile.generation, 0);
216        assert!(tile.parent_id.is_none());
217    }
218
219    #[test]
220    fn encode_with_valence() {
221        let encoder = TileEncoder::default();
222        let tile = encoder.encode_with_valence("Boring content.", 0.95);
223        assert!((tile.valence - 0.95).abs() < 0.001);
224    }
225
226    #[test]
227    fn compress_long_content() {
228        let encoder = TileEncoder {
229            max_summary_bytes: 50,
230            ..Default::default()
231        };
232        let long = "This is sentence one. This is sentence two. This is sentence three.";
233        let tile = encoder.encode(long, &SalienceMap::default());
234        assert!(tile.summary.len() <= 60); // Some slack for sentence boundaries.
235    }
236
237    #[test]
238    fn extract_constraints() {
239        let extractor = ConstraintExtractor;
240        let constraints = extractor.extract("Alice met Bob at 42nd Street on March 15th.");
241        assert!(constraints.contains_key("proper_nouns"));
242        assert!(constraints.values().any(|v| v.contains("42nd")));
243    }
244
245    #[test]
246    fn valence_high() {
247        let extractor = ConstraintExtractor;
248        let v = extractor.valence("This is an amazing breakthrough!! Incredible results!");
249        assert!(v > 0.5, "expected high valence, got {}", v);
250    }
251
252    #[test]
253    fn valence_low() {
254        let extractor = ConstraintExtractor;
255        let v = extractor.valence("This is a routine update. Nothing special.");
256        assert!(v < 0.5, "expected low valence, got {}", v);
257    }
258
259    #[test]
260    fn compression_ratio() {
261        let extractor = ConstraintExtractor;
262        let tile = Tile {
263            id: TileId::new(),
264            source_hash: String::new(),
265            constraints: HashMap::new(),
266            summary: "short".into(),
267            context_required: vec![],
268            valence: 0.5,
269            created_at: chrono::Utc::now(),
270            accessed_at: chrono::Utc::now(),
271            access_count: 0,
272            generation: 0,
273            parent_id: None,
274        };
275        let ratio = extractor.compression_ratio("a much longer original content string", &tile);
276        assert!(ratio < 1.0);
277        assert!(ratio > 0.0);
278    }
279}