Skip to main content

plato_tile_cascade/
lib.rs

1//! # plato-tile-cascade
2//! Dependency cascade engine.
3//!
4//! From OLMo's insight: corrections to knowledge tiles propagate downstream.
5//! When a tile changes, all tiles that depend on it may need re-validation,
6//! re-scoring, or invalidation.
7//!
8//! This is the propagation engine that sits on top of plato-tile-graph.
9
10use std::collections::{HashMap, HashSet, VecDeque};
11
12/// Cascade event type.
13#[derive(Debug, Clone, PartialEq)]
14pub enum CascadeEvent {
15    /// A tile was updated.
16    Updated { tile_id: String },
17    /// A tile was invalidated.
18    Invalidated { tile_id: String },
19    /// A tile was re-validated after cascade.
20    Revalidated { tile_id: String },
21}
22
23/// Cascade effect on a downstream tile.
24#[derive(Debug, Clone)]
25pub struct CascadeEffect {
26    pub tile_id: String,
27    pub event: CascadeEvent,
28    pub depth: usize,
29    pub reason: String,
30}
31
32/// Cascade configuration.
33#[derive(Debug, Clone)]
34pub struct CascadeConfig {
35    /// Maximum depth to propagate.
36    pub max_depth: usize,
37    /// Whether to auto-invalidate downstream tiles.
38    pub auto_invalidate: bool,
39    /// Whether to re-validate after invalidation.
40    pub auto_revalidate: bool,
41}
42
43impl Default for CascadeConfig {
44    fn default() -> Self {
45        Self { max_depth: 10, auto_invalidate: true, auto_revalidate: false }
46    }
47}
48
49/// A tile with dependency metadata.
50#[derive(Debug, Clone)]
51pub struct CascadableTile {
52    pub id: String,
53    pub content: String,
54    pub dependencies: Vec<String>,
55    pub valid: bool,
56    pub version: u32,
57}
58
59/// Dependency cascade engine.
60pub struct CascadeEngine {
61    tiles: HashMap<String, CascadableTile>,
62    config: CascadeConfig,
63    cascade_log: Vec<CascadeEffect>,
64}
65
66impl CascadeEngine {
67    pub fn new(config: CascadeConfig) -> Self {
68        Self { tiles: HashMap::new(), config, cascade_log: Vec::new() }
69    }
70
71    pub fn with_defaults() -> Self { Self::new(CascadeConfig::default()) }
72
73    /// Register a tile.
74    pub fn register(&mut self, tile: CascadableTile) {
75        self.tiles.insert(tile.id.clone(), tile);
76    }
77
78    /// Update a tile's content and trigger cascade.
79    pub fn update_tile(&mut self, tile_id: &str, new_content: &str) -> Vec<CascadeEffect> {
80        let mut effects = Vec::new();
81        if let Some(tile) = self.tiles.get_mut(tile_id) {
82            tile.content = new_content.to_string();
83            tile.version += 1;
84            effects.push(CascadeEffect {
85                tile_id: tile_id.to_string(),
86                event: CascadeEvent::Updated { tile_id: tile_id.to_string() },
87                depth: 0,
88                reason: "direct update".to_string(),
89            });
90        }
91        // Propagate downstream
92        let downstream_effects = self.propagate(tile_id, 1);
93        effects.extend(downstream_effects);
94        self.cascade_log.extend(effects.clone());
95        effects
96    }
97
98    /// Invalidate a tile and propagate.
99    pub fn invalidate_tile(&mut self, tile_id: &str) -> Vec<CascadeEffect> {
100        let mut effects = Vec::new();
101        if let Some(tile) = self.tiles.get_mut(tile_id) {
102            tile.valid = false;
103            effects.push(CascadeEffect {
104                tile_id: tile_id.to_string(),
105                event: CascadeEvent::Invalidated { tile_id: tile_id.to_string() },
106                depth: 0,
107                reason: "direct invalidation".to_string(),
108            });
109        }
110        let downstream = self.propagate(tile_id, 1);
111        effects.extend(downstream);
112        self.cascade_log.extend(effects.clone());
113        effects
114    }
115
116    /// Get all tiles that depend on `tile_id` (direct).
117    fn direct_dependents(&self, tile_id: &str) -> Vec<String> {
118        self.tiles.values()
119            .filter(|t| t.dependencies.iter().any(|d| d == tile_id))
120            .map(|t| t.id.clone())
121            .collect()
122    }
123
124    /// BFS propagation downstream.
125    fn propagate(&mut self, source_id: &str, start_depth: usize) -> Vec<CascadeEffect> {
126        let mut effects = Vec::new();
127        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
128        for dep in self.direct_dependents(source_id) {
129            queue.push_back((dep, start_depth));
130        }
131        while let Some((current_id, depth)) = queue.pop_front() {
132            if depth > self.config.max_depth { continue; }
133            if self.config.auto_invalidate {
134                if let Some(tile) = self.tiles.get_mut(&current_id) {
135                    if tile.valid {
136                        tile.valid = false;
137                        effects.push(CascadeEffect {
138                            tile_id: current_id.clone(),
139                            event: CascadeEvent::Invalidated { tile_id: current_id.clone() },
140                            depth,
141                            reason: format!("upstream '{}' changed", source_id),
142                        });
143                        // Continue propagation
144                        for dep in self.direct_dependents(&current_id) {
145                            queue.push_back((dep, depth + 1));
146                        }
147                    }
148                }
149            }
150        }
151        effects
152    }
153
154    /// Get cascade log.
155    pub fn cascade_log(&self) -> &[CascadeEffect] { &self.cascade_log }
156
157    /// Count invalid tiles.
158    pub fn invalid_count(&self) -> usize {
159        self.tiles.values().filter(|t| !t.valid).count()
160    }
161
162    /// Get all invalid tile IDs.
163    pub fn invalid_tiles(&self) -> Vec<String> {
164        self.tiles.values().filter(|t| !t.valid).map(|t| t.id.clone()).collect()
165    }
166
167    /// Re-validate a specific tile.
168    pub fn revalidate(&mut self, tile_id: &str) -> bool {
169        if let Some(tile) = self.tiles.get_mut(tile_id) {
170            if !tile.valid {
171                tile.valid = true;
172                self.cascade_log.push(CascadeEffect {
173                    tile_id: tile_id.to_string(),
174                    event: CascadeEvent::Revalidated { tile_id: tile_id.to_string() },
175                    depth: 0,
176                    reason: "manual revalidation".to_string(),
177                });
178                return true;
179            }
180        }
181        false
182    }
183
184    /// Re-validate all invalid tiles.
185    pub fn revalidate_all(&mut self) -> usize {
186        let invalid: Vec<String> = self.invalid_tiles();
187        let count = invalid.len();
188        for id in invalid {
189            self.revalidate(&id);
190        }
191        count
192    }
193
194    /// Tile count.
195    pub fn tile_count(&self) -> usize { self.tiles.len() }
196
197    /// Check if a tile exists.
198    pub fn has_tile(&self, id: &str) -> bool { self.tiles.contains_key(id) }
199
200    /// Get impact radius for a tile.
201    pub fn impact_radius(&self, tile_id: &str) -> usize {
202        let mut visited = HashSet::new();
203        let mut queue = VecDeque::new();
204        queue.push_back(tile_id.to_string());
205        visited.insert(tile_id.to_string());
206        while let Some(current) = queue.pop_front() {
207            for dep in self.direct_dependents(&current) {
208                if visited.insert(dep.clone()) {
209                    queue.push_back(dep);
210                }
211            }
212        }
213        visited.len()
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn make_engine() -> CascadeEngine {
222        let mut e = CascadeEngine::with_defaults();
223        e.register(CascadableTile { id: "a".into(), content: "root".into(), dependencies: vec![], valid: true, version: 1 });
224        e.register(CascadableTile { id: "b".into(), content: "dep on a".into(), dependencies: vec!["a".into()], valid: true, version: 1 });
225        e.register(CascadableTile { id: "c".into(), content: "dep on b".into(), dependencies: vec!["b".into()], valid: true, version: 1 });
226        e.register(CascadableTile { id: "d".into(), content: "also dep on a".into(), dependencies: vec!["a".into()], valid: true, version: 1 });
227        e
228    }
229
230    #[test]
231    fn test_update_triggers_cascade() {
232        let mut e = make_engine();
233        let effects = e.update_tile("a", "new root content");
234        assert!(effects.iter().any(|e| e.tile_id == "a" && e.depth == 0));
235        // b and d depend on a, c depends on b
236        assert!(effects.iter().any(|e| e.tile_id == "b"));
237        assert!(effects.iter().any(|e| e.tile_id == "d"));
238        assert!(effects.iter().any(|e| e.tile_id == "c"));
239    }
240
241    #[test]
242    fn test_invalidate_propagates() {
243        let mut e = make_engine();
244        let effects = e.invalidate_tile("b");
245        // b invalidated, c (depends on b) also invalidated
246        assert!(effects.iter().any(|e| e.tile_id == "b" && matches!(e.event, CascadeEvent::Invalidated { .. })));
247        assert!(effects.iter().any(|e| e.tile_id == "c"));
248    }
249
250    #[test]
251    fn test_revalidate_single() {
252        let mut e = make_engine();
253        e.invalidate_tile("b");
254        assert_eq!(e.invalid_count(), 2); // b and c
255        assert!(e.revalidate("b"));
256        assert_eq!(e.invalid_count(), 1); // c still invalid
257    }
258
259    #[test]
260    fn test_revalidate_all() {
261        let mut e = make_engine();
262        e.invalidate_tile("a");
263        let count = e.revalidate_all();
264        assert_eq!(count, 4); // all invalidated
265        assert_eq!(e.invalid_count(), 0);
266    }
267
268    #[test]
269    fn test_impact_radius() {
270        let e = make_engine();
271        assert_eq!(e.impact_radius("a"), 4);
272        assert_eq!(e.impact_radius("b"), 2);
273        assert_eq!(e.impact_radius("c"), 1);
274    }
275
276    #[test]
277    fn test_max_depth_limits_propagation() {
278        let mut config = CascadeConfig::default();
279        config.max_depth = 1;
280        let mut e = CascadeEngine::new(config);
281        e.register(CascadableTile { id: "a".into(), content: "".into(), dependencies: vec![], valid: true, version: 1 });
282        e.register(CascadableTile { id: "b".into(), content: "".into(), dependencies: vec!["a".into()], valid: true, version: 1 });
283        e.register(CascadableTile { id: "c".into(), content: "".into(), dependencies: vec!["b".into()], valid: true, version: 1 });
284        let effects = e.update_tile("a", "new");
285        // b gets invalidated (depth 1), c should NOT (depth 2 > max_depth 1)
286        assert!(effects.iter().any(|e| e.tile_id == "b"));
287        assert!(!effects.iter().any(|e| e.tile_id == "c"));
288    }
289
290    #[test]
291    fn test_cascade_log() {
292        let mut e = make_engine();
293        e.invalidate_tile("a");
294        assert!(!e.cascade_log().is_empty());
295    }
296
297    #[test]
298    fn test_no_auto_invalidate() {
299        let mut config = CascadeConfig::default();
300        config.auto_invalidate = false;
301        let mut e = CascadeEngine::new(config);
302        e.register(CascadableTile { id: "a".into(), content: "".into(), dependencies: vec![], valid: true, version: 1 });
303        e.register(CascadableTile { id: "b".into(), content: "".into(), dependencies: vec!["a".into()], valid: true, version: 1 });
304        let effects = e.update_tile("a", "new");
305        // Only the direct update, no invalidation propagation
306        assert_eq!(effects.len(), 1);
307        assert_eq!(e.invalid_count(), 0);
308    }
309
310    #[test]
311    fn test_leaf_tile_no_downstream() {
312        let mut e = CascadeEngine::with_defaults();
313        e.register(CascadableTile { id: "leaf".into(), content: "".into(), dependencies: vec![], valid: true, version: 1 });
314        let effects = e.update_tile("leaf", "updated");
315        assert_eq!(effects.len(), 1);
316    }
317}