plato-tile-cascade 0.1.0

Dependency cascade engine - propagate tile updates downstream
Documentation
//! # plato-tile-cascade
//! Dependency cascade engine.
//!
//! From OLMo's insight: corrections to knowledge tiles propagate downstream.
//! When a tile changes, all tiles that depend on it may need re-validation,
//! re-scoring, or invalidation.
//!
//! This is the propagation engine that sits on top of plato-tile-graph.

use std::collections::{HashMap, HashSet, VecDeque};

/// Cascade event type.
#[derive(Debug, Clone, PartialEq)]
pub enum CascadeEvent {
    /// A tile was updated.
    Updated { tile_id: String },
    /// A tile was invalidated.
    Invalidated { tile_id: String },
    /// A tile was re-validated after cascade.
    Revalidated { tile_id: String },
}

/// Cascade effect on a downstream tile.
#[derive(Debug, Clone)]
pub struct CascadeEffect {
    pub tile_id: String,
    pub event: CascadeEvent,
    pub depth: usize,
    pub reason: String,
}

/// Cascade configuration.
#[derive(Debug, Clone)]
pub struct CascadeConfig {
    /// Maximum depth to propagate.
    pub max_depth: usize,
    /// Whether to auto-invalidate downstream tiles.
    pub auto_invalidate: bool,
    /// Whether to re-validate after invalidation.
    pub auto_revalidate: bool,
}

impl Default for CascadeConfig {
    fn default() -> Self {
        Self { max_depth: 10, auto_invalidate: true, auto_revalidate: false }
    }
}

/// A tile with dependency metadata.
#[derive(Debug, Clone)]
pub struct CascadableTile {
    pub id: String,
    pub content: String,
    pub dependencies: Vec<String>,
    pub valid: bool,
    pub version: u32,
}

/// Dependency cascade engine.
pub struct CascadeEngine {
    tiles: HashMap<String, CascadableTile>,
    config: CascadeConfig,
    cascade_log: Vec<CascadeEffect>,
}

impl CascadeEngine {
    pub fn new(config: CascadeConfig) -> Self {
        Self { tiles: HashMap::new(), config, cascade_log: Vec::new() }
    }

    pub fn with_defaults() -> Self { Self::new(CascadeConfig::default()) }

    /// Register a tile.
    pub fn register(&mut self, tile: CascadableTile) {
        self.tiles.insert(tile.id.clone(), tile);
    }

    /// Update a tile's content and trigger cascade.
    pub fn update_tile(&mut self, tile_id: &str, new_content: &str) -> Vec<CascadeEffect> {
        let mut effects = Vec::new();
        if let Some(tile) = self.tiles.get_mut(tile_id) {
            tile.content = new_content.to_string();
            tile.version += 1;
            effects.push(CascadeEffect {
                tile_id: tile_id.to_string(),
                event: CascadeEvent::Updated { tile_id: tile_id.to_string() },
                depth: 0,
                reason: "direct update".to_string(),
            });
        }
        // Propagate downstream
        let downstream_effects = self.propagate(tile_id, 1);
        effects.extend(downstream_effects);
        self.cascade_log.extend(effects.clone());
        effects
    }

    /// Invalidate a tile and propagate.
    pub fn invalidate_tile(&mut self, tile_id: &str) -> Vec<CascadeEffect> {
        let mut effects = Vec::new();
        if let Some(tile) = self.tiles.get_mut(tile_id) {
            tile.valid = false;
            effects.push(CascadeEffect {
                tile_id: tile_id.to_string(),
                event: CascadeEvent::Invalidated { tile_id: tile_id.to_string() },
                depth: 0,
                reason: "direct invalidation".to_string(),
            });
        }
        let downstream = self.propagate(tile_id, 1);
        effects.extend(downstream);
        self.cascade_log.extend(effects.clone());
        effects
    }

    /// Get all tiles that depend on `tile_id` (direct).
    fn direct_dependents(&self, tile_id: &str) -> Vec<String> {
        self.tiles.values()
            .filter(|t| t.dependencies.iter().any(|d| d == tile_id))
            .map(|t| t.id.clone())
            .collect()
    }

    /// BFS propagation downstream.
    fn propagate(&mut self, source_id: &str, start_depth: usize) -> Vec<CascadeEffect> {
        let mut effects = Vec::new();
        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
        for dep in self.direct_dependents(source_id) {
            queue.push_back((dep, start_depth));
        }
        while let Some((current_id, depth)) = queue.pop_front() {
            if depth > self.config.max_depth { continue; }
            if self.config.auto_invalidate {
                if let Some(tile) = self.tiles.get_mut(&current_id) {
                    if tile.valid {
                        tile.valid = false;
                        effects.push(CascadeEffect {
                            tile_id: current_id.clone(),
                            event: CascadeEvent::Invalidated { tile_id: current_id.clone() },
                            depth,
                            reason: format!("upstream '{}' changed", source_id),
                        });
                        // Continue propagation
                        for dep in self.direct_dependents(&current_id) {
                            queue.push_back((dep, depth + 1));
                        }
                    }
                }
            }
        }
        effects
    }

    /// Get cascade log.
    pub fn cascade_log(&self) -> &[CascadeEffect] { &self.cascade_log }

    /// Count invalid tiles.
    pub fn invalid_count(&self) -> usize {
        self.tiles.values().filter(|t| !t.valid).count()
    }

    /// Get all invalid tile IDs.
    pub fn invalid_tiles(&self) -> Vec<String> {
        self.tiles.values().filter(|t| !t.valid).map(|t| t.id.clone()).collect()
    }

    /// Re-validate a specific tile.
    pub fn revalidate(&mut self, tile_id: &str) -> bool {
        if let Some(tile) = self.tiles.get_mut(tile_id) {
            if !tile.valid {
                tile.valid = true;
                self.cascade_log.push(CascadeEffect {
                    tile_id: tile_id.to_string(),
                    event: CascadeEvent::Revalidated { tile_id: tile_id.to_string() },
                    depth: 0,
                    reason: "manual revalidation".to_string(),
                });
                return true;
            }
        }
        false
    }

    /// Re-validate all invalid tiles.
    pub fn revalidate_all(&mut self) -> usize {
        let invalid: Vec<String> = self.invalid_tiles();
        let count = invalid.len();
        for id in invalid {
            self.revalidate(&id);
        }
        count
    }

    /// Tile count.
    pub fn tile_count(&self) -> usize { self.tiles.len() }

    /// Check if a tile exists.
    pub fn has_tile(&self, id: &str) -> bool { self.tiles.contains_key(id) }

    /// Get impact radius for a tile.
    pub fn impact_radius(&self, tile_id: &str) -> usize {
        let mut visited = HashSet::new();
        let mut queue = VecDeque::new();
        queue.push_back(tile_id.to_string());
        visited.insert(tile_id.to_string());
        while let Some(current) = queue.pop_front() {
            for dep in self.direct_dependents(&current) {
                if visited.insert(dep.clone()) {
                    queue.push_back(dep);
                }
            }
        }
        visited.len()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_engine() -> CascadeEngine {
        let mut e = CascadeEngine::with_defaults();
        e.register(CascadableTile { id: "a".into(), content: "root".into(), dependencies: vec![], valid: true, version: 1 });
        e.register(CascadableTile { id: "b".into(), content: "dep on a".into(), dependencies: vec!["a".into()], valid: true, version: 1 });
        e.register(CascadableTile { id: "c".into(), content: "dep on b".into(), dependencies: vec!["b".into()], valid: true, version: 1 });
        e.register(CascadableTile { id: "d".into(), content: "also dep on a".into(), dependencies: vec!["a".into()], valid: true, version: 1 });
        e
    }

    #[test]
    fn test_update_triggers_cascade() {
        let mut e = make_engine();
        let effects = e.update_tile("a", "new root content");
        assert!(effects.iter().any(|e| e.tile_id == "a" && e.depth == 0));
        // b and d depend on a, c depends on b
        assert!(effects.iter().any(|e| e.tile_id == "b"));
        assert!(effects.iter().any(|e| e.tile_id == "d"));
        assert!(effects.iter().any(|e| e.tile_id == "c"));
    }

    #[test]
    fn test_invalidate_propagates() {
        let mut e = make_engine();
        let effects = e.invalidate_tile("b");
        // b invalidated, c (depends on b) also invalidated
        assert!(effects.iter().any(|e| e.tile_id == "b" && matches!(e.event, CascadeEvent::Invalidated { .. })));
        assert!(effects.iter().any(|e| e.tile_id == "c"));
    }

    #[test]
    fn test_revalidate_single() {
        let mut e = make_engine();
        e.invalidate_tile("b");
        assert_eq!(e.invalid_count(), 2); // b and c
        assert!(e.revalidate("b"));
        assert_eq!(e.invalid_count(), 1); // c still invalid
    }

    #[test]
    fn test_revalidate_all() {
        let mut e = make_engine();
        e.invalidate_tile("a");
        let count = e.revalidate_all();
        assert_eq!(count, 4); // all invalidated
        assert_eq!(e.invalid_count(), 0);
    }

    #[test]
    fn test_impact_radius() {
        let e = make_engine();
        assert_eq!(e.impact_radius("a"), 4);
        assert_eq!(e.impact_radius("b"), 2);
        assert_eq!(e.impact_radius("c"), 1);
    }

    #[test]
    fn test_max_depth_limits_propagation() {
        let mut config = CascadeConfig::default();
        config.max_depth = 1;
        let mut e = CascadeEngine::new(config);
        e.register(CascadableTile { id: "a".into(), content: "".into(), dependencies: vec![], valid: true, version: 1 });
        e.register(CascadableTile { id: "b".into(), content: "".into(), dependencies: vec!["a".into()], valid: true, version: 1 });
        e.register(CascadableTile { id: "c".into(), content: "".into(), dependencies: vec!["b".into()], valid: true, version: 1 });
        let effects = e.update_tile("a", "new");
        // b gets invalidated (depth 1), c should NOT (depth 2 > max_depth 1)
        assert!(effects.iter().any(|e| e.tile_id == "b"));
        assert!(!effects.iter().any(|e| e.tile_id == "c"));
    }

    #[test]
    fn test_cascade_log() {
        let mut e = make_engine();
        e.invalidate_tile("a");
        assert!(!e.cascade_log().is_empty());
    }

    #[test]
    fn test_no_auto_invalidate() {
        let mut config = CascadeConfig::default();
        config.auto_invalidate = false;
        let mut e = CascadeEngine::new(config);
        e.register(CascadableTile { id: "a".into(), content: "".into(), dependencies: vec![], valid: true, version: 1 });
        e.register(CascadableTile { id: "b".into(), content: "".into(), dependencies: vec!["a".into()], valid: true, version: 1 });
        let effects = e.update_tile("a", "new");
        // Only the direct update, no invalidation propagation
        assert_eq!(effects.len(), 1);
        assert_eq!(e.invalid_count(), 0);
    }

    #[test]
    fn test_leaf_tile_no_downstream() {
        let mut e = CascadeEngine::with_defaults();
        e.register(CascadableTile { id: "leaf".into(), content: "".into(), dependencies: vec![], valid: true, version: 1 });
        let effects = e.update_tile("leaf", "updated");
        assert_eq!(effects.len(), 1);
    }
}