Skip to main content

memory_crystal/
crystal.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use chrono::Utc;
6
7use crate::decay::DecaySchedule;
8use crate::decoder::{Reconstruction, TileDecoder};
9use crate::encoder::TileEncoder;
10use crate::error::{CrystalError, Result};
11use crate::index::{CrystalIndex, TileMeta};
12use crate::tile::{SalienceMap, Tile, TileId};
13
14/// Statistics about a crystal.
15#[derive(Debug, Clone)]
16pub struct CrystalStats {
17    pub tile_count: usize,
18    pub total_bytes: u64,
19    pub avg_valence: f64,
20    pub max_valence: f64,
21    pub min_valence: f64,
22    pub oldest: Option<chrono::DateTime<Utc>>,
23    pub newest: Option<chrono::DateTime<Utc>>,
24}
25
26/// The Crystal — a collection of tiles persisted on disk.
27pub struct Crystal {
28    path: PathBuf,
29    index: CrystalIndex,
30    encoder: TileEncoder,
31    decoder: TileDecoder,
32    decay: DecaySchedule,
33}
34
35impl Crystal {
36    /// Open or create a crystal at the given directory path.
37    pub fn open(path: &Path) -> Result<Self> {
38        fs::create_dir_all(path)?;
39
40        let mut index = CrystalIndex::new();
41        let tiles_dir = path.join("tiles");
42        fs::create_dir_all(&tiles_dir)?;
43
44        // Load existing tiles into index.
45        for entry in fs::read_dir(&tiles_dir)? {
46            let entry = entry?;
47            if entry.path().extension().map_or(false, |e| e == "json") {
48                let data = fs::read_to_string(entry.path())?;
49                match serde_json::from_str::<Tile>(&data) {
50                    Ok(tile) => {
51                        let meta = TileMeta {
52                            id: tile.id.clone(),
53                            valence: tile.valence,
54                            created: tile.created_at,
55                            accessed: tile.accessed_at,
56                            constraints: tile.constraints.keys().cloned().collect(),
57                        };
58                        index.insert(meta);
59                    }
60                    Err(e) => {
61                        let tile_id = entry.path().file_stem().unwrap().to_string_lossy().to_string();
62                        eprintln!("Warning: corrupt tile {}: {}", tile_id, e);
63                    }
64                }
65            }
66        }
67
68        Ok(Self {
69            path: path.to_path_buf(),
70            index,
71            encoder: TileEncoder::default(),
72            decoder: TileDecoder::default(),
73            decay: DecaySchedule::default(),
74        })
75    }
76
77    /// Crystallize content into a new tile.
78    pub fn crystallize(&mut self, content: &str, salience: SalienceMap) -> Result<TileId> {
79        let tile = self.encoder.encode(content, &salience);
80        let id = tile.id.clone();
81
82        self.persist_tile(&tile)?;
83
84        let meta = TileMeta {
85            id: tile.id.clone(),
86            valence: tile.valence,
87            created: tile.created_at,
88            accessed: tile.accessed_at,
89            constraints: tile.constraints.keys().cloned().collect(),
90        };
91        self.index.insert(meta);
92
93        Ok(id)
94    }
95
96    /// Recall a specific tile with context-dependent reconstruction.
97    pub fn recall(&self, tile_id: &TileId, context: &str) -> Result<Reconstruction> {
98        let tile = self.load_tile(tile_id)?;
99        Ok(self.decoder.decode(&tile, context))
100    }
101
102    /// Recall multiple tiles matching a query.
103    pub fn recall_collective(&self, query: &str, limit: usize) -> Result<Vec<Reconstruction>> {
104        let keywords: Vec<&str> = query.split_whitespace().collect();
105        let ids = self.index.query(&keywords, limit);
106
107        let mut results = Vec::new();
108        for id in &ids {
109            if let Ok(tile) = self.load_tile(id) {
110                results.push(self.decoder.decode(&tile, query));
111            }
112        }
113
114        Ok(results)
115    }
116
117    /// Forget tiles older than the given duration (using decay schedule).
118    pub fn forget(&mut self, older_than: Duration) -> Result<usize> {
119        let cutoff = Utc::now() - chrono::Duration::from_std(older_than).unwrap_or(chrono::Duration::seconds(0));
120        let mut forgotten = 0;
121
122        let ids_to_check: Vec<TileId> = self.index.all_ids();
123        for id in &ids_to_check {
124            if let Ok(tile) = self.load_tile(id) {
125                if tile.accessed_at < cutoff && self.decay.should_forget(&tile, Utc::now()) {
126                    self.remove_tile(id)?;
127                    forgotten += 1;
128                }
129            }
130        }
131
132        Ok(forgotten)
133    }
134
135    /// Reconsolidate a tile: update it with new context, resetting decay.
136    pub fn reconsolidate(&mut self, tile_id: &TileId, new_context: &str) -> Result<()> {
137        let mut tile = self.load_tile(tile_id)?;
138        self.decay.reconsolidate(&mut tile, new_context);
139        self.persist_tile(&tile)?;
140
141        // Update index.
142        self.index.remove(tile_id);
143        let meta = TileMeta {
144            id: tile.id.clone(),
145            valence: tile.valence,
146            created: tile.created_at,
147            accessed: tile.accessed_at,
148            constraints: tile.constraints.keys().cloned().collect(),
149        };
150        self.index.insert(meta);
151
152        Ok(())
153    }
154
155    /// Get stats about the crystal.
156    pub fn stats(&self) -> CrystalStats {
157        let tile_count = self.index.len();
158
159        let tiles_dir = self.path.join("tiles");
160        let total_bytes = fs::metadata(&tiles_dir)
161            .map(|m| m.len())
162            .unwrap_or(0);
163
164        let all_meta: Vec<&TileMeta> = self.index.all_ids()
165            .iter()
166            .filter_map(|id| self.index.get(id))
167            .collect();
168
169        let (min_valence, max_valence, avg_valence) = if all_meta.is_empty() {
170            (0.0, 0.0, 0.0)
171        } else {
172            let vals: Vec<f64> = all_meta.iter().map(|m| m.valence).collect();
173            let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
174            let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
175            let avg = vals.iter().sum::<f64>() / vals.len() as f64;
176            (min, max, avg)
177        };
178
179        let oldest = all_meta.iter().map(|m| m.created).min();
180        let newest = all_meta.iter().map(|m| m.created).max();
181
182        CrystalStats {
183            tile_count,
184            total_bytes,
185            avg_valence,
186            max_valence,
187            min_valence,
188            oldest,
189            newest,
190        }
191    }
192
193    // --- Private helpers ---
194
195    fn tile_path(&self, id: &TileId) -> PathBuf {
196        self.path.join("tiles").join(format!("{}.json", id.0))
197    }
198
199    fn persist_tile(&self, tile: &Tile) -> Result<()> {
200        let path = self.tile_path(&tile.id);
201        let data = serde_json::to_string_pretty(tile)?;
202        fs::write(path, data)?;
203        Ok(())
204    }
205
206    fn load_tile(&self, id: &TileId) -> Result<Tile> {
207        let path = self.tile_path(id);
208        if !path.exists() {
209            return Err(CrystalError::TileNotFound(id.to_string()));
210        }
211        let data = fs::read_to_string(path)?;
212        let tile: Tile = serde_json::from_str(&data)?;
213        Ok(tile)
214    }
215
216    fn remove_tile(&mut self, id: &TileId) -> Result<()> {
217        let path = self.tile_path(id);
218        if path.exists() {
219            fs::remove_file(path)?;
220        }
221        self.index.remove(id);
222        Ok(())
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use tempfile::TempDir;
230
231    #[test]
232    fn open_empty_crystal() {
233        let dir = TempDir::new().unwrap();
234        let crystal = Crystal::open(dir.path()).unwrap();
235        let stats = crystal.stats();
236        assert_eq!(stats.tile_count, 0);
237    }
238
239    #[test]
240    fn crystallize_and_recall() {
241        let dir = TempDir::new().unwrap();
242        let mut crystal = Crystal::open(dir.path()).unwrap();
243
244        let id = crystal
245            .crystallize(
246                "Alice discovered a new algorithm in Seattle.",
247                SalienceMap::default(),
248            )
249            .unwrap();
250
251        let rec = crystal.recall(&id, "Alice was in Seattle").unwrap();
252        assert!(rec.confidence > 0.0);
253        assert!(!rec.content.is_empty());
254    }
255
256    #[test]
257    fn recall_collective() {
258        let dir = TempDir::new().unwrap();
259        let mut crystal = Crystal::open(dir.path()).unwrap();
260
261        // Crystallize with explicit constraints to ensure they're indexed.
262        let mut salience1 = SalienceMap::default();
263        salience1.constraints.insert("rust".into(), "Rust".into());
264        salience1.constraints.insert("programming".into(), "programming".into());
265        crystal.crystallize("Rust programming language features.", salience1).unwrap();
266
267        let mut salience2 = SalienceMap::default();
268        salience2.constraints.insert("python".into(), "Python".into());
269        crystal.crystallize("Python machine learning libraries.", salience2).unwrap();
270
271        let results = crystal.recall_collective("rust programming", 10).unwrap();
272        assert!(!results.is_empty());
273    }
274
275    #[test]
276    fn forget_old_tiles() {
277        let dir = TempDir::new().unwrap();
278        let mut crystal = Crystal::open(dir.path()).unwrap();
279
280        let id = crystal
281            .crystallize("Temporary data", SalienceMap {
282                valence: Some(0.1),
283                ..Default::default()
284            })
285            .unwrap();
286
287        assert_eq!(crystal.stats().tile_count, 1);
288
289        let forgotten = crystal.forget(Duration::from_secs(0)).unwrap();
290        // With fast enough decay or low enough retention, tile may be forgotten.
291        // (Depends on the decay schedule; at minimum, no crash.)
292        assert!(forgotten <= 1);
293    }
294
295    #[test]
296    fn reconsolidate_updates_tile() {
297        let dir = TempDir::new().unwrap();
298        let mut crystal = Crystal::open(dir.path()).unwrap();
299
300        let id = crystal
301            .crystallize("Original content", SalienceMap::default())
302            .unwrap();
303
304        crystal.reconsolidate(&id, "Updated context information").unwrap();
305
306        let rec = crystal.recall(&id, "Updated context").unwrap();
307        assert!(rec.content.contains("[updated:") || !rec.content.is_empty());
308    }
309
310    #[test]
311    fn persist_and_reload() {
312        let dir = TempDir::new().unwrap();
313
314        let id = {
315            let mut crystal = Crystal::open(dir.path()).unwrap();
316            crystal
317                .crystallize("Persistent content", SalienceMap::default())
318                .unwrap()
319        };
320
321        // Reopen and verify.
322        let crystal = Crystal::open(dir.path()).unwrap();
323        assert_eq!(crystal.stats().tile_count, 1);
324
325        let rec = crystal.recall(&id, "Persistent").unwrap();
326        assert!(!rec.content.is_empty());
327    }
328
329    #[test]
330    fn tile_not_found() {
331        let dir = TempDir::new().unwrap();
332        let crystal = Crystal::open(dir.path()).unwrap();
333        let result = crystal.recall(&TileId("nonexistent".into()), "context");
334        assert!(result.is_err());
335    }
336}