Skip to main content

plato_tile_store/
lib.rs

1//! plato-tile-store v2 — Immutable tile storage with version history,
2//! dependency cascade, and rollback (from OLMo + Qwen3 insights)
3//!
4//! Tiles are IMMUTABLE by default. Updates create new versions.
5//! Dependencies are tracked and cascaded on invalidation.
6//! Full version history enables rollback to any point.
7
8use std::collections::HashMap;
9
10/// A versioned tile reference — immutable snapshot
11#[derive(Debug, Clone)]
12pub struct TileVersion {
13    pub tile_id: String,
14    pub version: u32,
15    pub parent_id: Option<String>,
16    pub content: String,
17    pub question: String,
18    pub answer: String,
19    pub confidence: f32,
20    pub dependencies: Vec<String>,
21    pub counterpoint_ids: Vec<String>,
22    pub tags: Vec<String>,
23    pub invalidated: bool,
24    pub created_at: u64,
25}
26
27/// A cascade event — triggered when a tile is invalidated
28#[derive(Debug, Clone)]
29pub struct CascadeEvent {
30    pub source_tile_id: String,
31    pub source_version: u32,
32    pub affected_tile_ids: Vec<String>,
33    pub reason: String,
34    pub timestamp: u64,
35}
36
37/// Dependency relationship
38#[derive(Debug, Clone)]
39pub struct Dependency {
40    pub dependent_id: String,    // Tile that depends on...
41    pub dependency_id: String,   // ...this tile
42    pub strength: f32,           // How critical (0.0-1.0)
43}
44
45/// Immutable tile store with versioning and cascade
46pub struct TileStore {
47    /// tile_id -> list of versions (newest first)
48    versions: HashMap<String, Vec<TileVersion>>,
49    /// dependency relationships
50    dependencies: Vec<Dependency>,
51    /// cascade history
52    cascade_log: Vec<CascadeEvent>,
53    /// tile_id -> current (latest) version number
54    current_versions: HashMap<String, u32>,
55}
56
57impl TileStore {
58    pub fn new() -> Self {
59        Self {
60            versions: HashMap::new(),
61            dependencies: Vec::new(),
62            cascade_log: Vec::new(),
63            current_versions: HashMap::new(),
64        }
65    }
66
67    /// Insert a new tile (version 1)
68    pub fn insert(&mut self, version: TileVersion) {
69        let id = version.tile_id.clone();
70        let ver = version.version;
71        self.current_versions.insert(id.clone(), ver);
72        self.versions.entry(id).or_default().push(version);
73    }
74
75    /// Insert a new version of an existing tile
76    pub fn insert_version(&mut self, version: TileVersion) -> Result<(), String> {
77        let id = version.tile_id.clone();
78        if let Some(existing) = self.versions.get(&id) {
79            if !existing.iter().any(|v| v.version == version.version) {
80                self.current_versions.insert(id.clone(), version.version);
81                self.versions.get_mut(&id).unwrap().push(version);
82                return Ok(());
83            }
84            Err(format!("Version {} already exists for tile {}", version.version, id))
85        } else {
86            self.insert(version);
87            Ok(())
88        }
89    }
90
91    /// Get the latest version of a tile
92    pub fn get_latest(&self, tile_id: &str) -> Option<&TileVersion> {
93        self.versions.get(tile_id)
94            .and_then(|versions| versions.last())
95    }
96
97    /// Get a specific version of a tile
98    pub fn get_version(&self, tile_id: &str, version: u32) -> Option<&TileVersion> {
99        self.versions.get(tile_id)
100            .and_then(|versions| versions.iter().find(|v| v.version == version))
101    }
102
103    /// Get all versions of a tile (newest first)
104    pub fn get_history(&self, tile_id: &str) -> Vec<&TileVersion> {
105        match self.versions.get(tile_id) {
106            Some(versions) => versions.iter().rev().collect(),
107            None => Vec::new(),
108        }
109    }
110
111    /// Rollback a tile to a specific version
112    /// Creates a new version with the old content (immutable — doesn't delete)
113    pub fn rollback(&mut self, tile_id: &str, target_version: u32, now: u64) -> Result<TileVersion, String> {
114        let old = self.get_version(tile_id, target_version)
115            .ok_or_else(|| format!("Version {} not found for tile {}", target_version, tile_id))?
116            .clone();
117
118        let current_ver = self.current_versions.get(tile_id)
119            .ok_or_else(|| format!("Tile {} not found", tile_id))?;
120
121        let rolled_back = TileVersion {
122            tile_id: tile_id.to_string(),
123            version: current_ver + 1,
124            parent_id: Some(format!("{}:v{}", tile_id, target_version)),
125            content: old.content.clone(),
126            question: old.question.clone(),
127            answer: old.answer.clone(),
128            confidence: old.confidence * 0.95, // Slight confidence reduction on rollback
129            dependencies: old.dependencies.clone(),
130            counterpoint_ids: old.counterpoint_ids.clone(),
131            tags: old.tags.clone(),
132            invalidated: false,
133            created_at: now,
134        };
135
136        self.insert_version(rolled_back.clone())?;
137        Ok(rolled_back)
138    }
139
140    /// Add a dependency relationship
141    pub fn add_dependency(&mut self, dependent_id: &str, dependency_id: &str, strength: f32) {
142        self.dependencies.push(Dependency {
143            dependent_id: dependent_id.to_string(),
144            dependency_id: dependency_id.to_string(),
145            strength: strength.min(1.0).max(0.0),
146        });
147    }
148
149    /// Get all tiles that depend on the given tile
150    pub fn get_dependents(&self, tile_id: &str) -> Vec<&Dependency> {
151        self.dependencies.iter()
152            .filter(|d| d.dependency_id == tile_id)
153            .collect()
154    }
155
156    /// Get all tiles that the given tile depends on
157    pub fn get_dependencies_of(&self, tile_id: &str) -> Vec<&Dependency> {
158        self.dependencies.iter()
159            .filter(|d| d.dependent_id == tile_id)
160            .collect()
161    }
162
163    /// Invalidate a tile and cascade to dependents (OLMo's insight)
164    pub fn invalidate(&mut self, tile_id: &str, reason: &str, now: u64) -> CascadeEvent {
165        // Mark tile as invalidated
166        if let Some(versions) = self.versions.get_mut(tile_id) {
167            if let Some(latest) = versions.last_mut() {
168                latest.invalidated = true;
169            }
170        }
171
172        // Find all dependents
173        let dependents: Vec<String> = self.get_dependents(tile_id)
174            .iter()
175            .map(|d| d.dependent_id.clone())
176            .collect();
177
178        // Cascade: mark dependents as potentially affected
179        for dep_id in &dependents {
180            if let Some(versions) = self.versions.get_mut(dep_id) {
181                if let Some(latest) = versions.last_mut() {
182                    latest.confidence *= 0.8; // Reduce confidence of dependents
183                }
184            }
185        }
186
187        let event = CascadeEvent {
188            source_tile_id: tile_id.to_string(),
189            source_version: self.current_versions.get(tile_id).copied().unwrap_or(0),
190            affected_tile_ids: dependents.clone(),
191            reason: reason.to_string(),
192            timestamp: now,
193        };
194
195        self.cascade_log.push(event.clone());
196        event
197    }
198
199    /// Restore a previously invalidated tile
200    pub fn restore(&mut self, tile_id: &str, now: u64) -> Result<(), String> {
201        if let Some(versions) = self.versions.get_mut(tile_id) {
202            if let Some(latest) = versions.last_mut() {
203                if latest.invalidated {
204                    latest.invalidated = false;
205                    latest.confidence = (latest.confidence / 0.8).min(1.0);
206                    return Ok(());
207                }
208            }
209        }
210        Err(format!("Tile {} not found or not invalidated", tile_id))
211    }
212
213    /// Search tiles by tag
214    pub fn search_by_tag(&self, tag: &str) -> Vec<&TileVersion> {
215        self.versions.values()
216            .filter_map(|versions| versions.last())
217            .filter(|v| v.tags.iter().any(|t| t == tag))
218            .collect()
219    }
220
221    /// Get cascade history
222    pub fn get_cascade_log(&self) -> &[CascadeEvent] {
223        &self.cascade_log
224    }
225
226    /// Count of tiles
227    pub fn tile_count(&self) -> usize {
228        self.versions.len()
229    }
230
231    /// Total versions across all tiles
232    pub fn total_versions(&self) -> usize {
233        self.versions.values().map(|v| v.len()).sum()
234    }
235
236    /// List all tile IDs
237    pub fn tile_ids(&self) -> Vec<String> {
238        self.versions.keys().cloned().collect()
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn make_version(id: &str, ver: u32, content: &str, conf: f32) -> TileVersion {
247        TileVersion {
248            tile_id: id.to_string(),
249            version: ver,
250            parent_id: if ver > 1 { Some(format!("{}:v{}", id, ver - 1)) } else { None },
251            content: content.to_string(),
252            question: format!("Q{}", ver),
253            answer: content.to_string(),
254            confidence: conf,
255            dependencies: Vec::new(),
256            counterpoint_ids: Vec::new(),
257            tags: vec!["test".to_string()],
258            invalidated: false,
259            created_at: (ver as u64) * 1000,
260        }
261    }
262
263    #[test]
264    fn test_insert_and_get_latest() {
265        let mut store = TileStore::new();
266        store.insert(make_version("t1", 1, "content v1", 0.9));
267        let latest = store.get_latest("t1").unwrap();
268        assert_eq!(latest.content, "content v1");
269        assert_eq!(latest.version, 1);
270    }
271
272    #[test]
273    fn test_insert_version() {
274        let mut store = TileStore::new();
275        store.insert(make_version("t1", 1, "v1", 0.8));
276        store.insert_version(make_version("t1", 2, "v2", 0.9)).unwrap();
277        let latest = store.get_latest("t1").unwrap();
278        assert_eq!(latest.version, 2);
279        assert_eq!(latest.content, "v2");
280    }
281
282    #[test]
283    fn test_duplicate_version_rejected() {
284        let mut store = TileStore::new();
285        store.insert(make_version("t1", 1, "v1", 0.8));
286        let result = store.insert_version(make_version("t1", 1, "v1-dup", 0.9));
287        assert!(result.is_err());
288    }
289
290    #[test]
291    fn test_get_specific_version() {
292        let mut store = TileStore::new();
293        store.insert(make_version("t1", 1, "v1", 0.8));
294        store.insert_version(make_version("t1", 2, "v2", 0.9)).unwrap();
295        store.insert_version(make_version("t1", 3, "v3", 0.95)).unwrap();
296        let v2 = store.get_version("t1", 2).unwrap();
297        assert_eq!(v2.content, "v2");
298    }
299
300    #[test]
301    fn test_get_history() {
302        let mut store = TileStore::new();
303        store.insert(make_version("t1", 1, "v1", 0.8));
304        store.insert_version(make_version("t1", 2, "v2", 0.9)).unwrap();
305        store.insert_version(make_version("t1", 3, "v3", 0.95)).unwrap();
306        let history = store.get_history("t1");
307        assert_eq!(history.len(), 3);
308        assert_eq!(history[0].version, 3); // Newest first
309        assert_eq!(history[2].version, 1);
310    }
311
312    #[test]
313    fn test_rollback() {
314        let mut store = TileStore::new();
315        store.insert(make_version("t1", 1, "v1", 0.9));
316        store.insert_version(make_version("t1", 2, "v2-bad", 0.7)).unwrap();
317        let rolled = store.rollback("t1", 1, 3000).unwrap();
318        assert_eq!(rolled.content, "v1");
319        assert_eq!(rolled.version, 3);
320        assert!((rolled.confidence - 0.855).abs() < 0.01); // 0.9 * 0.95
321        let latest = store.get_latest("t1").unwrap();
322        assert_eq!(latest.content, "v1");
323    }
324
325    #[test]
326    fn test_rollback_nonexistent_version() {
327        let mut store = TileStore::new();
328        store.insert(make_version("t1", 1, "v1", 0.9));
329        let result = store.rollback("t1", 99, 3000);
330        assert!(result.is_err());
331    }
332
333    #[test]
334    fn test_add_dependency() {
335        let mut store = TileStore::new();
336        store.insert(make_version("t1", 1, "v1", 0.9));
337        store.insert(make_version("t2", 1, "v2", 0.8));
338        store.add_dependency("t2", "t1", 1.0);
339        let deps = store.get_dependents("t1");
340        assert_eq!(deps.len(), 1);
341        assert_eq!(deps[0].dependent_id, "t2");
342    }
343
344    #[test]
345    fn test_invalidate_cascade() {
346        let mut store = TileStore::new();
347        store.insert(make_version("t1", 1, "v1", 0.9));
348        store.insert(make_version("t2", 1, "v2", 0.8));
349        store.insert(make_version("t3", 1, "v3", 0.7));
350        store.add_dependency("t2", "t1", 1.0);
351        store.add_dependency("t3", "t1", 0.5);
352
353        let event = store.invalidate("t1", "found to be incorrect", 5000);
354        assert!(store.get_latest("t1").unwrap().invalidated);
355        assert_eq!(event.affected_tile_ids.len(), 2);
356        assert!(event.affected_tile_ids.contains(&"t2".to_string()));
357        assert!(event.affected_tile_ids.contains(&"t3".to_string()));
358        // Dependents should have reduced confidence
359        assert!(store.get_latest("t2").unwrap().confidence < 0.8);
360        assert!(store.get_latest("t3").unwrap().confidence < 0.7);
361    }
362
363    #[test]
364    fn test_restore_tile() {
365        let mut store = TileStore::new();
366        store.insert(make_version("t1", 1, "v1", 0.8));
367        store.invalidate("t1", "temporary", 5000);
368        assert!(store.get_latest("t1").unwrap().invalidated);
369        store.restore("t1", 6000).unwrap();
370        assert!(!store.get_latest("t1").unwrap().invalidated);
371    }
372
373    #[test]
374    fn test_restore_nonexistent() {
375        let mut store = TileStore::new();
376        assert!(store.restore("t99", 5000).is_err());
377    }
378
379    #[test]
380    fn test_search_by_tag() {
381        let mut store = TileStore::new();
382        let mut v1 = make_version("t1", 1, "rust content", 0.9);
383        v1.tags = vec!["rust".to_string(), "systems".to_string()];
384        let mut v2 = make_version("t2", 1, "python content", 0.8);
385        v2.tags = vec!["python".to_string()];
386        let mut v3 = make_version("t3", 1, "more rust", 0.7);
387        v3.tags = vec!["rust".to_string()];
388        store.insert(v1);
389        store.insert(v2);
390        store.insert(v3);
391        let rust_tiles = store.search_by_tag("rust");
392        assert_eq!(rust_tiles.len(), 2);
393    }
394
395    #[test]
396    fn test_cascade_log() {
397        let mut store = TileStore::new();
398        store.insert(make_version("t1", 1, "v1", 0.9));
399        store.insert(make_version("t2", 1, "v2", 0.8));
400        store.add_dependency("t2", "t1", 1.0);
401        store.invalidate("t1", "test", 5000);
402        assert_eq!(store.get_cascade_log().len(), 1);
403        assert_eq!(store.get_cascade_log()[0].reason, "test");
404    }
405
406    #[test]
407    fn test_tile_count_and_ids() {
408        let mut store = TileStore::new();
409        store.insert(make_version("t1", 1, "v1", 0.9));
410        store.insert(make_version("t2", 1, "v2", 0.8));
411        assert_eq!(store.tile_count(), 2);
412        assert_eq!(store.total_versions(), 2);
413        let ids = store.tile_ids();
414        assert!(ids.contains(&"t1".to_string()));
415        assert!(ids.contains(&"t2".to_string()));
416    }
417
418    #[test]
419    fn test_dependency_strength_clamped() {
420        let mut store = TileStore::new();
421        store.add_dependency("t2", "t1", 1.5);
422        let deps = store.get_dependents("t1");
423        assert_eq!(deps[0].strength, 1.0);
424        store.add_dependency("t3", "t1", -0.5);
425        let deps2 = store.get_dependents("t1");
426        assert_eq!(deps2[1].strength, 0.0);
427    }
428
429    #[test]
430    fn test_get_dependencies_of() {
431        let mut store = TileStore::new();
432        store.insert(make_version("t1", 1, "v1", 0.9));
433        store.insert(make_version("t2", 1, "v2", 0.8));
434        store.insert(make_version("t3", 1, "v3", 0.7));
435        store.add_dependency("t1", "t2", 1.0);
436        store.add_dependency("t1", "t3", 0.5);
437        let deps = store.get_dependencies_of("t1");
438        assert_eq!(deps.len(), 2);
439    }
440
441    #[test]
442    fn test_immutability_old_versions_preserved() {
443        let mut store = TileStore::new();
444        store.insert(make_version("t1", 1, "v1", 0.9));
445        store.insert_version(make_version("t1", 2, "v2", 0.8)).unwrap();
446        store.insert_version(make_version("t1", 3, "v3", 0.7)).unwrap();
447        // Old versions still accessible
448        assert_eq!(store.get_version("t1", 1).unwrap().content, "v1");
449        assert_eq!(store.get_version("t1", 2).unwrap().content, "v2");
450    }
451}