Skip to main content

memory_crystal/
decoder.rs

1use crate::tile::{Tile, TileId};
2
3/// A reconstructed memory — decoded from a tile with context.
4#[derive(Debug, Clone)]
5pub struct Reconstruction {
6    pub content: String,
7    pub tile_id: TileId,
8    pub confidence: f64,
9    pub inferred_facts: Vec<String>,
10    pub preserved_facts: Vec<String>,
11}
12
13/// Context-dependent decoder: reconstructs content from tiles.
14pub struct TileDecoder {
15    pub context_window: usize,
16}
17
18impl Default for TileDecoder {
19    fn default() -> Self {
20        Self {
21            context_window: 4096,
22        }
23    }
24}
25
26impl TileDecoder {
27    /// Decode a single tile with the given context.
28    ///
29    /// The context string is used to enrich the reconstruction by matching
30    /// against the tile's `context_required` fields and constraints.
31    pub fn decode(&self, tile: &Tile, context: &str) -> Reconstruction {
32        let context_lower = context.to_lowercase();
33
34        // Collect preserved facts from tile constraints that appear in context.
35        let preserved_facts: Vec<String> = tile
36            .constraints
37            .values()
38            .filter(|v| context_lower.contains(&v.to_lowercase()))
39            .cloned()
40            .collect();
41
42        // Infer facts from context that relate to the tile's required context cues.
43        let mut inferred_facts = Vec::new();
44        for cue in &tile.context_required {
45            if !context_lower.contains(&cue.to_lowercase()) {
46                inferred_facts.push(format!("missing context: {}", cue));
47            }
48        }
49
50        // Build reconstruction: tile summary + enriched context.
51        let mut content = tile.summary.clone();
52        if !preserved_facts.is_empty() {
53            content.push_str("\n\nVerified facts: ");
54            content.push_str(&preserved_facts.join(", "));
55        }
56
57        // Confidence based on how much context matched.
58        let total_cues = tile.context_required.len().max(1);
59        let matched_cues = tile
60            .context_required
61            .iter()
62            .filter(|c| context_lower.contains(&c.to_lowercase()))
63            .count();
64        let context_confidence = matched_cues as f64 / total_cues as f64;
65
66        // Boost confidence by valence and access count.
67        let access_boost = (tile.access_count as f64).log2().max(0.0) * 0.05;
68        let confidence = (context_confidence * 0.7 + tile.valence * 0.2 + access_boost).clamp(0.0, 1.0);
69
70        Reconstruction {
71            content,
72            tile_id: tile.id.clone(),
73            confidence,
74            inferred_facts,
75            preserved_facts,
76        }
77    }
78
79    /// Decode multiple tiles collectively into a single reconstruction.
80    pub fn decode_collective(&self, tiles: &[&Tile], context: &str) -> Reconstruction {
81        if tiles.is_empty() {
82            return Reconstruction {
83                content: String::new(),
84                tile_id: TileId::new(),
85                confidence: 0.0,
86                inferred_facts: vec![],
87                preserved_facts: vec![],
88            };
89        }
90
91        // Use the highest-valence tile as the anchor.
92        let anchor = tiles
93            .iter()
94            .max_by(|a, b| a.valence.partial_cmp(&b.valence).unwrap_or(std::cmp::Ordering::Equal))
95            .unwrap();
96
97        let content_parts: Vec<String> = tiles.iter().map(|t| t.summary.clone()).collect();
98        let _context_lower = context.to_lowercase();
99
100        let mut all_preserved = Vec::new();
101        let mut all_inferred = Vec::new();
102        let mut total_confidence = 0.0;
103
104        for tile in tiles {
105            let rec = self.decode(tile, context);
106            all_preserved.extend(rec.preserved_facts);
107            all_inferred.extend(rec.inferred_facts);
108            total_confidence += rec.confidence;
109        }
110
111        all_preserved.sort();
112        all_preserved.dedup();
113        all_inferred.sort();
114        all_inferred.dedup();
115
116        // Truncate context window.
117        let mut combined = content_parts.join("\n---\n");
118        if combined.len() > self.context_window {
119            combined.truncate(self.context_window);
120        }
121
122        if !all_preserved.is_empty() {
123            combined.push_str("\n\nPreserved facts: ");
124            combined.push_str(&all_preserved.join(", "));
125        }
126
127        let avg_confidence = total_confidence / tiles.len() as f64;
128
129        Reconstruction {
130            content: combined,
131            tile_id: anchor.id.clone(),
132            confidence: avg_confidence,
133            inferred_facts: all_inferred,
134            preserved_facts: all_preserved,
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use std::collections::HashMap;
143
144    fn test_tile(summary: &str, constraints: HashMap<String, String>, valence: f64) -> Tile {
145        Tile {
146            id: TileId::new(),
147            source_hash: String::new(),
148            constraints,
149            summary: summary.into(),
150            context_required: vec![],
151            valence,
152            created_at: chrono::Utc::now(),
153            accessed_at: chrono::Utc::now(),
154            access_count: 1,
155            generation: 0,
156            parent_id: None,
157        }
158    }
159
160    #[test]
161    fn decode_basic() {
162        let decoder = TileDecoder::default();
163        let tile = test_tile(
164            "Alice discovered a new algorithm.",
165            HashMap::from([("person".into(), "Alice".into())]),
166            0.8,
167        );
168        let rec = decoder.decode(&tile, "Alice was working on algorithms");
169        assert!(rec.confidence > 0.0);
170        assert!(rec.content.contains("Alice discovered"));
171    }
172
173    #[test]
174    fn decode_with_preserved_facts() {
175        let decoder = TileDecoder::default();
176        let tile = test_tile(
177            "Meeting notes",
178            HashMap::from([("place".into(), "Seattle".into())]),
179            0.5,
180        );
181        let rec = decoder.decode(&tile, "We went to Seattle for the meeting.");
182        assert!(rec.preserved_facts.contains(&"Seattle".to_string()));
183    }
184
185    #[test]
186    fn decode_collective() {
187        let decoder = TileDecoder::default();
188        let t1 = test_tile("First part", HashMap::new(), 0.6);
189        let t2 = test_tile("Second part", HashMap::new(), 0.9);
190        let rec = decoder.decode_collective(&[&t1, &t2], "context");
191        assert!(rec.content.contains("First part"));
192        assert!(rec.content.contains("Second part"));
193        assert!(rec.confidence > 0.0);
194    }
195
196    #[test]
197    fn decode_empty_tiles() {
198        let decoder = TileDecoder::default();
199        let rec = decoder.decode_collective(&[], "context");
200        assert_eq!(rec.confidence, 0.0);
201        assert!(rec.content.is_empty());
202    }
203
204    #[test]
205    fn decode_missing_context() {
206        let decoder = TileDecoder::default();
207        let mut tile = test_tile("Test", HashMap::new(), 0.5);
208        tile.context_required = vec!["Python".into(), "ML".into()];
209        let rec = decoder.decode(&tile, "We discussed Rust and databases");
210        assert!(rec
211            .inferred_facts
212            .iter()
213            .any(|f| f.contains("missing context")));
214    }
215}