condor-pathfinding-grid 0.4.0

Grid pathfinding, preprocessing, replanning, and multi-agent algorithms for Condor.
Documentation
//! Private candidate: damage-adaptive incremental replanning portfolio.
//!
//! **Hypothesis:** deterministic dispatch between incremental repair and restart
//! exact search can improve adverse update batches while preserving exactness.
//!
//! **Non-negotiable behavior:** dispatch criteria must be explicit and
//! deterministic. Every route (incremental, restart exact core, cold A*
//! fallback) remains answer-equivalent to the exact baseline.
//!
//! **Dispatch:** if the number of cell/cost edits since the last successful
//! plan is ≤ `INCREMENTAL_DAMAGE_THRESHOLD`, replan via
//! [`super::dstar_lite_exact_integer_core::DStarLiteExactIntegerCore`];
//! otherwise cold-restart that exact core. If the chosen route fails to find
//! a path that A* still finds, fall back to cold A* restart (still exact).
//!
//! **Evidence and promotion:** ordinary `replanning` while developing; remains
//! private beside D* Lite.

use crate::{
    algorithms::{astar::AStar, dstar_lite_exact_integer_core::DStarLiteExactIntegerCore},
    grid::{Cell, Grid, GridEditError},
    point::Point,
    replanning::GridReplanner,
    search::{Pathfinder, SearchRequest, SearchResult},
};

/// Edits at or below this count use incremental repair; above uses cold restart.
const INCREMENTAL_DAMAGE_THRESHOLD: usize = 2;

/// Which dispatch arm produced the last successful replan (for tests).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortfolioRoute {
    /// Low-damage batch repaired via the exact-integer core.
    IncrementalExact,
    /// High-damage batch cold-restarted on the exact-integer core.
    RestartExact,
    /// Core arm failed to match A*; cold A* restart (still exact).
    RestartAStarFallback,
}

/// Damage-adaptive portfolio over the exact-integer D* Lite core.
///
/// Implements [`crate::GridReplanner`] by routing edits through
/// [`super::dstar_lite_exact_integer_core::DStarLiteExactIntegerCore`] or cold A*
/// while preserving answer equivalence. Private candidate beside D* Lite.
pub struct DStarLiteDamageAdaptivePortfolio {
    core: DStarLiteExactIntegerCore,
    grid: Grid,
    request: SearchRequest,
    initialized: bool,
    pending_damage: usize,
    last_route: Option<PortfolioRoute>,
}

impl DStarLiteDamageAdaptivePortfolio {
    /// Stable source-local candidate identity.
    pub const CANDIDATE_ID: &str = "dynamic-grid-replanning/C002-damage-adaptive-portfolio";

    /// Creates an uninitialized portfolio; call [`GridReplanner::initialize`] before replan.
    #[must_use]
    pub fn new() -> Self {
        Self {
            core: DStarLiteExactIntegerCore::new(),
            grid: Grid::new(1, 1).expect("grid dimensions are valid"),
            request: SearchRequest::new(Point::new(0, 0), Point::new(0, 0)),
            initialized: false,
            pending_damage: 0,
            last_route: None,
        }
    }

    /// Last dispatch route taken (tests pin multi-branch coverage).
    #[must_use]
    pub fn last_route(&self) -> Option<PortfolioRoute> {
        self.last_route
    }

    fn cold_astar(&self) -> SearchResult {
        AStar.search(&self.grid, self.request)
    }
}

impl Default for DStarLiteDamageAdaptivePortfolio {
    fn default() -> Self {
        Self::new()
    }
}

impl GridReplanner for DStarLiteDamageAdaptivePortfolio {
    fn name(&self) -> &'static str {
        "dstar-lite-damage-adaptive"
    }

    fn initialize(&mut self, grid: &Grid, request: SearchRequest) -> SearchResult {
        self.grid = grid.clone();
        self.request = request;
        self.initialized = true;
        self.pending_damage = 0;
        let result = self.core.initialize(grid, request);
        self.last_route = Some(PortfolioRoute::RestartExact);
        result
    }

    fn update_cell(&mut self, point: Point, cell: Cell) {
        if self.grid.set_cell(point, cell).is_err() {
            return;
        }
        self.pending_damage = self.pending_damage.saturating_add(1);
        self.core.update_cell(point, cell);
    }

    fn update_cost(&mut self, point: Point, cost: usize) -> Result<(), GridEditError> {
        self.grid.set_traversal_cost(point, cost)?;
        self.pending_damage = self.pending_damage.saturating_add(1);
        self.core.update_cost(point, cost)?;
        Ok(())
    }

    fn replan(&mut self) -> SearchResult {
        if !self.initialized {
            return crate::search::not_found(0);
        }

        let damage = self.pending_damage;
        self.pending_damage = 0;

        let (route, result) = if damage <= INCREMENTAL_DAMAGE_THRESHOLD {
            (PortfolioRoute::IncrementalExact, self.core.replan())
        } else {
            // High damage: cold restart exact core on the current snapshot.
            let grid = self.grid.clone();
            let request = self.request;
            (
                PortfolioRoute::RestartExact,
                self.core.initialize(&grid, request),
            )
        };

        // Answer-equivalence guard: if the chosen route missed a path A* still
        // finds, fall back to cold A* (exact).
        let astar = self.cold_astar();
        let needs_fallback = match (&result, &astar) {
            (Ok(cand), Ok(base)) if cand.is_found() != base.is_found() => true,
            (Ok(cand), Ok(base)) if cand.is_found() && base.is_found() => {
                cand.cost() != base.cost()
            }
            (Err(_), Ok(base)) if base.is_found() => true,
            _ => false,
        };

        if needs_fallback {
            self.last_route = Some(PortfolioRoute::RestartAStarFallback);
            // Resync exact core to the A* world.
            let grid = self.grid.clone();
            let request = self.request;
            let _ = self.core.initialize(&grid, request);
            return astar;
        }

        self.last_route = Some(route);
        result
    }
}

#[cfg(test)]
mod tests {
    use super::{DStarLiteDamageAdaptivePortfolio, PortfolioRoute};
    use crate::{
        algorithms::{astar::AStar, dstar_lite_exact_integer_core::DStarLiteExactIntegerCore},
        grid::{Cell, Grid},
        point::Point,
        replanning::GridReplanner,
        search::{Pathfinder, SearchRequest},
    };

    #[test]
    fn every_dispatch_route_answer_equivalent_to_exact_baseline() {
        let mut grid = Grid::new(20, 20).expect("grid");
        let request = SearchRequest::new(Point::new(1, 10), Point::new(18, 10));

        // --- Incremental route (damage ≤ 2) ---
        let mut portfolio = DStarLiteDamageAdaptivePortfolio::new();
        let mut exact = DStarLiteExactIntegerCore::new();
        let p_init = portfolio.initialize(&grid, request).expect("valid");
        let e_init = exact.initialize(&grid, request).expect("valid");
        assert_eq!(p_init.cost(), e_init.cost());
        assert_eq!(
            p_init.cost(),
            AStar.search(&grid, request).expect("valid").cost()
        );

        portfolio.update_cell(Point::new(10, 10), Cell::Blocked);
        exact.update_cell(Point::new(10, 10), Cell::Blocked);
        grid.set_cell(Point::new(10, 10), Cell::Blocked)
            .expect("valid");

        let p_inc = portfolio.replan().expect("valid");
        let e_inc = exact.replan().expect("valid");
        let a_inc = AStar.search(&grid, request).expect("valid");
        assert_eq!(
            portfolio.last_route(),
            Some(PortfolioRoute::IncrementalExact)
        );
        assert_eq!(p_inc.cost(), e_inc.cost());
        assert_eq!(p_inc.cost(), a_inc.cost());

        // --- Restart exact route (damage > 2) ---
        for k in 0..4 {
            let p = Point::new(12, 8 + k);
            portfolio.update_cell(p, Cell::Blocked);
            exact.update_cell(p, Cell::Blocked);
            grid.set_cell(p, Cell::Blocked).expect("valid");
        }
        let p_restart = portfolio.replan().expect("valid");
        let e_restart = exact.replan().expect("valid");
        let a_restart = AStar.search(&grid, request).expect("valid");
        assert_eq!(portfolio.last_route(), Some(PortfolioRoute::RestartExact));
        assert_eq!(p_restart.cost(), e_restart.cost());
        assert_eq!(p_restart.cost(), a_restart.cost());
    }

    #[test]
    fn damage_threshold_boundary_selects_incremental_then_restart() {
        let grid = Grid::new(12, 12).expect("grid");
        let request = SearchRequest::new(Point::new(1, 6), Point::new(10, 6));
        let mut portfolio = DStarLiteDamageAdaptivePortfolio::new();
        let _ = portfolio.initialize(&grid, request).expect("valid");

        // Exactly INCREMENTAL_DAMAGE_THRESHOLD (2) edits → still incremental.
        portfolio.update_cell(Point::new(5, 6), Cell::Blocked);
        portfolio.update_cell(Point::new(6, 6), Cell::Blocked);
        let _ = portfolio.replan().expect("valid");
        assert_eq!(
            portfolio.last_route(),
            Some(PortfolioRoute::IncrementalExact)
        );

        // Three edits next replan → restart exact.
        portfolio.update_cell(Point::new(4, 5), Cell::Blocked);
        portfolio.update_cell(Point::new(4, 6), Cell::Blocked);
        portfolio.update_cell(Point::new(4, 7), Cell::Blocked);
        let _ = portfolio.replan().expect("valid");
        assert_eq!(portfolio.last_route(), Some(PortfolioRoute::RestartExact));
    }

    #[test]
    fn retains_candidate_id() {
        assert_eq!(
            DStarLiteDamageAdaptivePortfolio::CANDIDATE_ID,
            "dynamic-grid-replanning/C002-damage-adaptive-portfolio"
        );
    }
}