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#[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
26pub struct Crystal {
28 path: PathBuf,
29 index: CrystalIndex,
30 encoder: TileEncoder,
31 decoder: TileDecoder,
32 decay: DecaySchedule,
33}
34
35impl Crystal {
36 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 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 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 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 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 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 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 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 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 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 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 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 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}