Skip to main content

condor_grid/
mapf.rs

1//! Collision-aware multi-agent pathfinding model, validator, and starter planner.
2//!
3//! [`MapfProblem`] combines a static 4-connected grid with time-indexed agent paths;
4//! [`MapfProblem::validate_plan`] reports structural, vertex, and edge-swap conflicts.
5//! [`MapfStarterPlanner`] plans agents in fixed order with reservations and returns a
6//! solved or partial failed outcome, not an optimality guarantee. Prefer
7//! [`crate::flow_field`] when agents merely share a goal and do not need collision
8//! resolution. The harness owns corpus and conformance evidence for this lane.
9
10use std::collections::{BTreeMap, BTreeSet, VecDeque};
11
12use serde::Deserialize;
13
14use crate::{Cell, Grid, Point};
15
16/// One MAPF agent with a static start cell and a static goal cell.
17///
18/// Plans must occupy `start` at timestep 0, reach `goal`, then wait in place through the
19/// planner horizon. Identity strings are opaque keys used in conflict reports.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct MapfAgent {
22    /// Stable agent identity used in plans and conflict reports.
23    pub agent_id: String,
24    /// Required walkable start cell at timestep 0.
25    pub start: Point,
26    /// Required walkable goal; plans must reach and then stay on this cell.
27    pub goal: Point,
28}
29
30impl MapfAgent {
31    /// Creates an agent with the given identity and static endpoints.
32    #[must_use]
33    pub fn new(agent_id: impl Into<String>, start: Point, goal: Point) -> Self {
34        Self {
35            agent_id: agent_id.into(),
36            start,
37            goal,
38        }
39    }
40}
41
42/// Objective label used by the MAPF validation foundation.
43///
44/// Metrics are always computed for both labels; the objective only tags the
45/// problem for reporting.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum MapfObjective {
48    /// Minimize the last agent arrival time.
49    Makespan,
50    /// Minimize the sum of individual first-arrival times.
51    SumOfCosts,
52}
53
54impl MapfObjective {
55    /// Returns the stable artifact slug for this objective.
56    #[must_use]
57    pub const fn slug(self) -> &'static str {
58        match self {
59            Self::Makespan => "makespan",
60            Self::SumOfCosts => "sum-of-costs",
61        }
62    }
63}
64
65/// Static-grid MAPF problem: grid, agents, and objective label.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct MapfProblem {
68    /// Stable problem identity for fixtures and capture reports.
69    pub problem_id: String,
70    /// Shared static map; movement is 4-way with wait-in-place.
71    pub grid: Grid,
72    /// Agents in planning order for the starter planner.
73    pub agents: Vec<MapfAgent>,
74    /// Reporting objective label (does not change validation rules).
75    pub objective: MapfObjective,
76}
77
78impl MapfProblem {
79    /// Validates `plan` against this problem's agents, grid, and conflict rules.
80    ///
81    /// Always returns a full [`MapfValidationReport`]; `valid` is true only when
82    /// `conflicts` is empty.
83    #[must_use]
84    pub fn validate_plan(&self, plan: &MapfPlan) -> MapfValidationReport {
85        validate_mapf_plan(self, plan)
86    }
87}
88
89/// Deterministic fixed-order reservation-table starter baseline for MAPF problems.
90///
91/// # Contract
92///
93/// - Agents are planned **in problem order** with a shared reservation table.
94/// - Search is bounded by the inclusive [`Self::max_timesteps`] horizon.
95/// - Successful agent paths are padded to `max_timesteps + 1` positions
96///   (wait-in-place after first arrival).
97/// - Failure at any agent yields [`MapfStarterPlannerOutcome::Failed`] with a
98///   partial plan for already-planned agents only.
99/// - A fully built plan is re-validated before reporting success; validation
100///   failures surface as [`MapfStarterPlannerFailure::UnvalidatedPlan`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct MapfStarterPlanner {
103    max_timesteps: usize,
104}
105
106impl MapfStarterPlanner {
107    /// Hard ceiling applied when constructing the planner (prevents runaway horizons).
108    pub const MAX_TIMESTEPS: usize = 1 << 20;
109
110    /// Creates a bounded starter planner with an inclusive maximum timestep.
111    ///
112    /// Values above [`Self::MAX_TIMESTEPS`] are clamped.
113    #[must_use]
114    pub const fn new(max_timesteps: usize) -> Self {
115        let max_timesteps = if max_timesteps > Self::MAX_TIMESTEPS {
116            Self::MAX_TIMESTEPS
117        } else {
118            max_timesteps
119        };
120        Self { max_timesteps }
121    }
122
123    /// Inclusive maximum timestep used for search and path padding.
124    #[must_use]
125    pub const fn max_timesteps(self) -> usize {
126        self.max_timesteps
127    }
128
129    /// Plans agents in problem order and validates the final plan before reporting success.
130    #[must_use]
131    pub fn plan(&self, problem: &MapfProblem) -> MapfStarterPlannerResult {
132        let mut reservations = ReservationTable::default();
133        let mut planned_paths = Vec::with_capacity(problem.agents.len());
134
135        for agent in &problem.agents {
136            let path = match self.plan_agent_path(problem, agent, &reservations) {
137                Ok(path) => path,
138                Err(reason) => {
139                    return MapfStarterPlannerResult::failed(reason, MapfPlan::new(planned_paths));
140                }
141            };
142
143            reservations.reserve_path(&path, self.max_timesteps);
144            planned_paths.push(MapfAgentPath::new(
145                agent.agent_id.clone(),
146                pad_path_to_horizon(path, self.max_timesteps),
147            ));
148        }
149
150        let plan = MapfPlan::new(planned_paths);
151        let validation = problem.validate_plan(&plan);
152        if validation.valid {
153            MapfStarterPlannerResult::solved(plan, validation)
154        } else {
155            MapfStarterPlannerResult::failed(
156                MapfStarterPlannerFailure::UnvalidatedPlan { validation },
157                plan,
158            )
159        }
160    }
161
162    fn plan_agent_path(
163        &self,
164        problem: &MapfProblem,
165        agent: &MapfAgent,
166        reservations: &ReservationTable,
167    ) -> Result<Vec<Point>, MapfStarterPlannerFailure> {
168        if !problem.grid.is_walkable(agent.start) {
169            return Err(MapfStarterPlannerFailure::InvalidStart {
170                agent_id: agent.agent_id.clone(),
171                point: agent.start,
172            });
173        }
174        if !problem.grid.is_walkable(agent.goal) {
175            return Err(MapfStarterPlannerFailure::InvalidGoal {
176                agent_id: agent.agent_id.clone(),
177                point: agent.goal,
178            });
179        }
180        if reservations.vertex_reserved(0, agent.start) {
181            return Err(MapfStarterPlannerFailure::StartReserved {
182                agent_id: agent.agent_id.clone(),
183                point: agent.start,
184            });
185        }
186
187        let start_state = TimedPoint {
188            timestep: 0,
189            point: agent.start,
190        };
191        let mut frontier = VecDeque::from([start_state]);
192        let mut parents = BTreeMap::from([(start_state, None)]);
193
194        while let Some(state) = frontier.pop_front() {
195            if state.point == agent.goal
196                && reservations.vertex_available_from(
197                    state.timestep,
198                    agent.goal,
199                    self.max_timesteps,
200                )
201            {
202                return Ok(reconstruct_timed_path(state, &parents));
203            }
204
205            if state.timestep >= self.max_timesteps {
206                continue;
207            }
208
209            for next_point in ordered_mapf_moves(&problem.grid, state.point, agent.goal) {
210                let next_state = TimedPoint {
211                    timestep: state.timestep + 1,
212                    point: next_point,
213                };
214                if parents.contains_key(&next_state)
215                    || !reservations.transition_allowed(state.timestep + 1, state.point, next_point)
216                {
217                    continue;
218                }
219
220                parents.insert(next_state, Some(state));
221                frontier.push_back(next_state);
222            }
223        }
224
225        Err(MapfStarterPlannerFailure::NoPathWithinHorizon {
226            agent_id: agent.agent_id.clone(),
227            max_timesteps: self.max_timesteps,
228        })
229    }
230}
231
232impl Default for MapfStarterPlanner {
233    fn default() -> Self {
234        Self::new(64)
235    }
236}
237
238/// Result from the bounded MAPF starter planner.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct MapfStarterPlannerResult {
241    /// Explicit solved or failed payload.
242    pub outcome: MapfStarterPlannerOutcome,
243}
244
245impl MapfStarterPlannerResult {
246    /// Returns true when the outcome is a validated full plan.
247    #[must_use]
248    pub const fn is_solved(&self) -> bool {
249        matches!(self.outcome, MapfStarterPlannerOutcome::Solved { .. })
250    }
251
252    /// Returns the solved plan only when the planner outcome is solved.
253    #[must_use]
254    pub const fn solved_plan(&self) -> Option<&MapfPlan> {
255        match &self.outcome {
256            MapfStarterPlannerOutcome::Solved { plan, .. } => Some(plan),
257            MapfStarterPlannerOutcome::Failed { .. } => None,
258        }
259    }
260
261    /// Returns the partial plan only when the planner outcome is failed.
262    #[must_use]
263    pub const fn partial_plan(&self) -> Option<&MapfPlan> {
264        match &self.outcome {
265            MapfStarterPlannerOutcome::Solved { .. } => None,
266            MapfStarterPlannerOutcome::Failed { partial_plan, .. } => Some(partial_plan),
267        }
268    }
269
270    /// Returns any plan payload carried by the result.
271    ///
272    /// Use [`Self::solved_plan`] or [`Self::partial_plan`] when the outcome
273    /// distinction matters.
274    #[must_use]
275    pub const fn plan(&self) -> Option<&MapfPlan> {
276        match &self.outcome {
277            MapfStarterPlannerOutcome::Solved { plan, .. } => Some(plan),
278            MapfStarterPlannerOutcome::Failed { partial_plan, .. } => Some(partial_plan),
279        }
280    }
281
282    /// Returns the validation report when present (solved plans always include one;
283    /// failed plans include it only for [`MapfStarterPlannerFailure::UnvalidatedPlan`]).
284    #[must_use]
285    pub const fn validation_report(&self) -> Option<&MapfValidationReport> {
286        match &self.outcome {
287            MapfStarterPlannerOutcome::Solved { validation, .. } => Some(validation),
288            MapfStarterPlannerOutcome::Failed {
289                reason: MapfStarterPlannerFailure::UnvalidatedPlan { validation, .. },
290                ..
291            } => Some(validation),
292            MapfStarterPlannerOutcome::Failed { .. } => None,
293        }
294    }
295
296    fn solved(plan: MapfPlan, validation: MapfValidationReport) -> Self {
297        Self {
298            outcome: MapfStarterPlannerOutcome::Solved { plan, validation },
299        }
300    }
301
302    fn failed(reason: MapfStarterPlannerFailure, partial_plan: MapfPlan) -> Self {
303        Self {
304            outcome: MapfStarterPlannerOutcome::Failed {
305                reason,
306                partial_plan,
307            },
308        }
309    }
310}
311
312/// Explicit solved or failed starter-planner outcome.
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub enum MapfStarterPlannerOutcome {
315    /// Fully planned agents with a validation report that reported no conflicts.
316    Solved {
317        plan: MapfPlan,
318        validation: MapfValidationReport,
319    },
320    /// Planning stopped early or post-plan validation failed.
321    Failed {
322        reason: MapfStarterPlannerFailure,
323        /// Agents planned successfully before failure (may be empty).
324        partial_plan: MapfPlan,
325    },
326}
327
328/// Explicit bounded-failure reason for the starter planner.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum MapfStarterPlannerFailure {
331    /// Agent start is blocked or out of bounds.
332    InvalidStart { agent_id: String, point: Point },
333    /// Agent goal is blocked or out of bounds.
334    InvalidGoal { agent_id: String, point: Point },
335    /// Another agent already reserved the start vertex at timestep 0.
336    StartReserved { agent_id: String, point: Point },
337    /// No reservation-safe path within the inclusive horizon.
338    NoPathWithinHorizon {
339        agent_id: String,
340        max_timesteps: usize,
341    },
342    /// All agents planned, but re-validation found conflicts.
343    UnvalidatedPlan { validation: MapfValidationReport },
344}
345
346/// Time-indexed path for one agent (`positions[t]` is the cell at timestep `t`).
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct MapfAgentPath {
349    /// Must match a problem agent id.
350    pub agent_id: String,
351    /// Positions from timestep 0 through the plan horizon.
352    pub positions: Vec<Point>,
353}
354
355impl MapfAgentPath {
356    /// Creates a time-indexed path; does not validate geometry or conflicts.
357    #[must_use]
358    pub fn new(agent_id: impl Into<String>, positions: Vec<Point>) -> Self {
359        Self {
360            agent_id: agent_id.into(),
361            positions,
362        }
363    }
364}
365
366/// Time-stepped multi-agent plan indexed by agent paths.
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct MapfPlan {
369    /// One path per planned agent (order is not significant for validation).
370    pub agent_paths: Vec<MapfAgentPath>,
371}
372
373impl MapfPlan {
374    /// Creates a plan from agent paths without running validation.
375    #[must_use]
376    pub fn new(agent_paths: Vec<MapfAgentPath>) -> Self {
377        Self { agent_paths }
378    }
379}
380
381/// Validation issue or conflict found in a MAPF plan.
382///
383/// Structural issues (missing paths, illegal moves) are reported alongside
384/// multi-agent vertex and edge-swap conflicts. Any non-empty conflict list
385/// makes [`MapfValidationReport::valid`] false.
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub enum MapfConflict {
388    /// Problem start is not walkable.
389    InvalidStart { agent_id: String, point: Point },
390    /// Problem goal is not walkable.
391    InvalidGoal { agent_id: String, point: Point },
392    /// Agent path has zero positions.
393    EmptyAgentPath { agent_id: String },
394    /// Problem agent has no path entry in the plan.
395    MissingAgentPath { agent_id: String },
396    /// Plan path references an unknown agent id.
397    UnknownAgentPath { agent_id: String },
398    /// More than one path for the same agent id.
399    DuplicateAgentPath { agent_id: String },
400    /// Path timestep 0 does not match the problem start.
401    StartMismatch {
402        agent_id: String,
403        expected: Point,
404        actual: Point,
405    },
406    /// Path visits a cell outside the grid.
407    OutOfBoundsCell {
408        agent_id: String,
409        timestep: usize,
410        point: Point,
411    },
412    /// Path visits a blocked cell.
413    BlockedCell {
414        agent_id: String,
415        timestep: usize,
416        point: Point,
417    },
418    /// Consecutive positions are not a legal 4-way step or wait.
419    IllegalTransition {
420        agent_id: String,
421        timestep: usize,
422        from: Point,
423        to: Point,
424    },
425    /// Path never occupies the agent goal.
426    GoalNotReached {
427        agent_id: String,
428        expected: Point,
429        actual: Point,
430    },
431    /// Agent left the goal after first arrival.
432    GoalDeparted {
433        agent_id: String,
434        timestep: usize,
435        goal: Point,
436        actual: Point,
437    },
438    /// Two or more agents occupy the same cell at the same timestep.
439    Vertex {
440        timestep: usize,
441        point: Point,
442        agent_ids: Vec<String>,
443    },
444    /// Two agents swap edges between consecutive timesteps.
445    EdgeSwap {
446        timestep: usize,
447        from: Point,
448        to: Point,
449        agent_a: String,
450        agent_b: String,
451    },
452}
453
454impl MapfConflict {
455    /// Returns the stable kebab-case kind label for capture/export.
456    #[must_use]
457    pub const fn kind(&self) -> &'static str {
458        match self {
459            Self::InvalidStart { .. } => "invalid-start",
460            Self::InvalidGoal { .. } => "invalid-goal",
461            Self::EmptyAgentPath { .. } => "empty-agent-path",
462            Self::MissingAgentPath { .. } => "missing-agent-path",
463            Self::UnknownAgentPath { .. } => "unknown-agent-path",
464            Self::DuplicateAgentPath { .. } => "duplicate-agent-path",
465            Self::StartMismatch { .. } => "start-mismatch",
466            Self::OutOfBoundsCell { .. } => "out-of-bounds-cell",
467            Self::BlockedCell { .. } => "blocked-cell",
468            Self::IllegalTransition { .. } => "illegal-transition",
469            Self::GoalNotReached { .. } => "goal-not-reached",
470            Self::GoalDeparted { .. } => "goal-departed",
471            Self::Vertex { .. } => "vertex",
472            Self::EdgeSwap { .. } => "edge-swap",
473        }
474    }
475}
476
477/// Objective metrics derived from a plan (first-arrival based).
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub struct MapfPlanMetrics {
480    /// Maximum first-arrival timestep across agents with paths.
481    pub makespan: usize,
482    /// Sum of first-arrival timesteps across agents with paths.
483    pub sum_of_costs: usize,
484}
485
486/// Complete MAPF plan validation result.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct MapfValidationReport {
489    /// True only when `conflicts` is empty.
490    pub valid: bool,
491    /// Structural issues and multi-agent conflicts in discovery order.
492    pub conflicts: Vec<MapfConflict>,
493    /// Makespan and sum-of-costs computed from first arrivals.
494    pub metrics: MapfPlanMetrics,
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
498struct TimedPoint {
499    timestep: usize,
500    point: Point,
501}
502
503#[derive(Debug, Default, Clone, PartialEq, Eq)]
504struct ReservationTable {
505    vertices: BTreeSet<(usize, Point)>,
506    edges: BTreeSet<(usize, Point, Point)>,
507}
508
509impl ReservationTable {
510    fn reserve_path(&mut self, path: &[Point], max_timesteps: usize) {
511        let Some(&last) = path.last() else {
512            return;
513        };
514
515        for timestep in 0..=max_timesteps {
516            let point = path.get(timestep).copied().unwrap_or(last);
517            self.vertices.insert((timestep, point));
518
519            if timestep > 0 {
520                let previous = path.get(timestep - 1).copied().unwrap_or(last);
521                self.edges.insert((timestep, previous, point));
522            }
523        }
524    }
525
526    fn vertex_reserved(&self, timestep: usize, point: Point) -> bool {
527        self.vertices.contains(&(timestep, point))
528    }
529
530    fn vertex_available_from(
531        &self,
532        start_timestep: usize,
533        point: Point,
534        max_timestep: usize,
535    ) -> bool {
536        (start_timestep..=max_timestep).all(|timestep| !self.vertex_reserved(timestep, point))
537    }
538
539    fn transition_allowed(&self, timestep: usize, from: Point, to: Point) -> bool {
540        !self.vertex_reserved(timestep, to) && !self.edges.contains(&(timestep, to, from))
541    }
542}
543
544/// Cardinal neighbors plus a wait-in-place move, ordered by Manhattan-to-goal.
545fn ordered_mapf_moves(grid: &Grid, current: Point, goal: Point) -> Vec<Point> {
546    let mut candidates = Vec::with_capacity(5);
547    candidates.push(current);
548    candidates.extend(grid.neighbors4(current));
549    candidates.sort_by_key(|point| (manhattan_distance(*point, goal), *point));
550    candidates
551}
552
553fn reconstruct_timed_path(
554    end_state: TimedPoint,
555    parents: &BTreeMap<TimedPoint, Option<TimedPoint>>,
556) -> Vec<Point> {
557    let mut states = vec![end_state];
558    let mut current = end_state;
559
560    while let Some(parent) = parents
561        .get(&current)
562        .expect("timed path reconstruction requires known states")
563    {
564        states.push(*parent);
565        current = *parent;
566    }
567
568    states.reverse();
569    states.into_iter().map(|state| state.point).collect()
570}
571
572/// Pads to `max_timesteps + 1` positions (timestep 0 through horizon) by holding the goal.
573fn pad_path_to_horizon(mut path: Vec<Point>, max_timesteps: usize) -> Vec<Point> {
574    let target_len = max_timesteps + 1;
575    let last = path
576        .last()
577        .copied()
578        .expect("planned agent paths always include a start point");
579    path.resize(target_len, last);
580    path
581}
582
583fn manhattan_distance(from: Point, to: Point) -> usize {
584    from.x.abs_diff(to.x) + from.y.abs_diff(to.y)
585}
586
587fn validate_mapf_plan(problem: &MapfProblem, plan: &MapfPlan) -> MapfValidationReport {
588    let mut conflicts = validate_problem(problem);
589    let agent_by_id = problem
590        .agents
591        .iter()
592        .map(|agent| (agent.agent_id.as_str(), agent))
593        .collect::<BTreeMap<_, _>>();
594    let mut paths_by_agent: BTreeMap<&str, &MapfAgentPath> = BTreeMap::new();
595
596    for agent_path in &plan.agent_paths {
597        let agent_id = agent_path.agent_id.as_str();
598        if !agent_by_id.contains_key(agent_id) {
599            conflicts.push(MapfConflict::UnknownAgentPath {
600                agent_id: agent_path.agent_id.clone(),
601            });
602            continue;
603        }
604        if paths_by_agent.insert(agent_id, agent_path).is_some() {
605            conflicts.push(MapfConflict::DuplicateAgentPath {
606                agent_id: agent_path.agent_id.clone(),
607            });
608        }
609    }
610
611    for agent in &problem.agents {
612        let Some(path) = paths_by_agent.get(agent.agent_id.as_str()) else {
613            conflicts.push(MapfConflict::MissingAgentPath {
614                agent_id: agent.agent_id.clone(),
615            });
616            continue;
617        };
618
619        validate_agent_path(problem, agent, path, &mut conflicts);
620    }
621
622    conflicts.extend(vertex_conflicts(plan));
623    conflicts.extend(edge_swap_conflicts(plan));
624
625    let metrics = metrics_for(problem, &paths_by_agent);
626    let valid = conflicts.is_empty();
627    MapfValidationReport {
628        valid,
629        conflicts,
630        metrics,
631    }
632}
633
634fn validate_problem(problem: &MapfProblem) -> Vec<MapfConflict> {
635    let mut conflicts = Vec::new();
636    for agent in &problem.agents {
637        if !problem.grid.is_walkable(agent.start) {
638            conflicts.push(MapfConflict::InvalidStart {
639                agent_id: agent.agent_id.clone(),
640                point: agent.start,
641            });
642        }
643        if !problem.grid.is_walkable(agent.goal) {
644            conflicts.push(MapfConflict::InvalidGoal {
645                agent_id: agent.agent_id.clone(),
646                point: agent.goal,
647            });
648        }
649    }
650    conflicts
651}
652
653fn validate_agent_path(
654    problem: &MapfProblem,
655    agent: &MapfAgent,
656    path: &MapfAgentPath,
657    conflicts: &mut Vec<MapfConflict>,
658) {
659    let Some(first) = path.positions.first().copied() else {
660        conflicts.push(MapfConflict::EmptyAgentPath {
661            agent_id: agent.agent_id.clone(),
662        });
663        return;
664    };
665
666    if first != agent.start {
667        conflicts.push(MapfConflict::StartMismatch {
668            agent_id: agent.agent_id.clone(),
669            expected: agent.start,
670            actual: first,
671        });
672    }
673
674    for (timestep, &point) in path.positions.iter().enumerate() {
675        match problem.grid.cell(point) {
676            Some(Cell::Open) => {}
677            Some(Cell::Blocked) => conflicts.push(MapfConflict::BlockedCell {
678                agent_id: agent.agent_id.clone(),
679                timestep,
680                point,
681            }),
682            None => conflicts.push(MapfConflict::OutOfBoundsCell {
683                agent_id: agent.agent_id.clone(),
684                timestep,
685                point,
686            }),
687        }
688    }
689
690    for (transition_index, pair) in path.positions.windows(2).enumerate() {
691        if !problem.grid.segment_is_walkable(pair[0], pair[1]) {
692            conflicts.push(MapfConflict::IllegalTransition {
693                agent_id: agent.agent_id.clone(),
694                timestep: transition_index + 1,
695                from: pair[0],
696                to: pair[1],
697            });
698        }
699    }
700
701    let Some(first_arrival) = path.positions.iter().position(|&point| point == agent.goal) else {
702        conflicts.push(MapfConflict::GoalNotReached {
703            agent_id: agent.agent_id.clone(),
704            expected: agent.goal,
705            actual: *path
706                .positions
707                .last()
708                .expect("non-empty path should still have a last point"),
709        });
710        return;
711    };
712
713    if let Some((offset, &actual)) = path.positions[first_arrival + 1..]
714        .iter()
715        .enumerate()
716        .find(|(_, point)| **point != agent.goal)
717    {
718        conflicts.push(MapfConflict::GoalDeparted {
719            agent_id: agent.agent_id.clone(),
720            timestep: first_arrival + 1 + offset,
721            goal: agent.goal,
722            actual,
723        });
724    }
725}
726
727fn vertex_conflicts(plan: &MapfPlan) -> Vec<MapfConflict> {
728    let mut occupancy: BTreeMap<(usize, Point), Vec<String>> = BTreeMap::new();
729    let horizon = plan
730        .agent_paths
731        .iter()
732        .map(|path| path.positions.len())
733        .max()
734        .unwrap_or(0);
735
736    for path in &plan.agent_paths {
737        for timestep in 0..horizon {
738            let Some(point) = path_position_at_or_after(path, timestep) else {
739                continue;
740            };
741            occupancy
742                .entry((timestep, point))
743                .or_default()
744                .push(path.agent_id.clone());
745        }
746    }
747
748    occupancy
749        .into_iter()
750        .filter_map(|((timestep, point), mut agent_ids)| {
751            if agent_ids.len() < 2 {
752                return None;
753            }
754            agent_ids.sort();
755            Some(MapfConflict::Vertex {
756                timestep,
757                point,
758                agent_ids,
759            })
760        })
761        .collect()
762}
763
764fn edge_swap_conflicts(plan: &MapfPlan) -> Vec<MapfConflict> {
765    let mut conflicts = Vec::new();
766    let horizon = plan
767        .agent_paths
768        .iter()
769        .map(|path| path.positions.len())
770        .max()
771        .unwrap_or(0);
772
773    for (left_index, left) in plan.agent_paths.iter().enumerate() {
774        for right in plan.agent_paths.iter().skip(left_index + 1) {
775            for timestep in 1..horizon {
776                let Some(left_from) = path_position_at_or_after(left, timestep - 1) else {
777                    continue;
778                };
779                let Some(left_to) = path_position_at_or_after(left, timestep) else {
780                    continue;
781                };
782                let Some(right_from) = path_position_at_or_after(right, timestep - 1) else {
783                    continue;
784                };
785                let Some(right_to) = path_position_at_or_after(right, timestep) else {
786                    continue;
787                };
788
789                if left_from == right_to && left_to == right_from && left_from != left_to {
790                    conflicts.push(MapfConflict::EdgeSwap {
791                        timestep,
792                        from: left_from,
793                        to: left_to,
794                        agent_a: left.agent_id.clone(),
795                        agent_b: right.agent_id.clone(),
796                    });
797                }
798            }
799        }
800    }
801    conflicts
802}
803
804fn path_position_at_or_after(path: &MapfAgentPath, timestep: usize) -> Option<Point> {
805    path.positions
806        .get(timestep)
807        .copied()
808        .or_else(|| path.positions.last().copied())
809}
810
811fn metrics_for(
812    problem: &MapfProblem,
813    paths_by_agent: &BTreeMap<&str, &MapfAgentPath>,
814) -> MapfPlanMetrics {
815    let mut makespan = 0usize;
816    let mut sum_of_costs = 0usize;
817
818    for agent in &problem.agents {
819        let Some(path) = paths_by_agent.get(agent.agent_id.as_str()) else {
820            continue;
821        };
822        if path.positions.is_empty() {
823            continue;
824        }
825
826        let first_arrival = path
827            .positions
828            .iter()
829            .position(|&point| point == agent.goal)
830            .unwrap_or_else(|| path.positions.len() - 1);
831        makespan = makespan.max(first_arrival);
832        sum_of_costs += first_arrival;
833    }
834
835    MapfPlanMetrics {
836        makespan,
837        sum_of_costs,
838    }
839}
840
841impl<'de> Deserialize<'de> for MapfObjective {
842    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
843    where
844        D: serde::Deserializer<'de>,
845    {
846        let value = String::deserialize(deserializer)?;
847        match value.as_str() {
848            "makespan" => Ok(Self::Makespan),
849            "sum-of-costs" => Ok(Self::SumOfCosts),
850            _ => Err(serde::de::Error::custom(format!(
851                "unsupported MAPF objective '{value}'"
852            ))),
853        }
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860
861    #[test]
862    fn starter_planner_reports_explicit_bounded_failure() {
863        let problem = MapfProblem {
864            problem_id: "starter-bounded-failure".to_string(),
865            grid: Grid::new(3, 1).expect("grid dimensions are valid"),
866            agents: vec![MapfAgent::new("alpha", Point::new(0, 0), Point::new(2, 0))],
867            objective: MapfObjective::SumOfCosts,
868        };
869
870        let result = MapfStarterPlanner::new(1).plan(&problem);
871        assert!(!result.is_solved());
872        assert!(result.solved_plan().is_none());
873        assert!(
874            result
875                .partial_plan()
876                .expect("failed result carries partial plan")
877                .agent_paths
878                .is_empty()
879        );
880        match result.outcome {
881            MapfStarterPlannerOutcome::Failed {
882                reason:
883                    MapfStarterPlannerFailure::NoPathWithinHorizon {
884                        agent_id,
885                        max_timesteps,
886                    },
887                partial_plan,
888            } => {
889                assert_eq!(agent_id, "alpha");
890                assert_eq!(max_timesteps, 1);
891                assert!(partial_plan.agent_paths.is_empty());
892            }
893            other => panic!("expected bounded no-path failure, got {other:?}"),
894        }
895    }
896
897    #[test]
898    fn starter_planner_reports_invalid_start_for_blocked_start_cell() {
899        let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
900        grid.set_cell(Point::new(0, 0), Cell::Blocked)
901            .expect("grid edit should succeed");
902        let problem = MapfProblem {
903            problem_id: "starter-invalid-start".to_string(),
904            grid,
905            agents: vec![MapfAgent::new("alpha", Point::new(0, 0), Point::new(2, 0))],
906            objective: MapfObjective::SumOfCosts,
907        };
908
909        match MapfStarterPlanner::new(64).plan(&problem).outcome {
910            MapfStarterPlannerOutcome::Failed {
911                reason: MapfStarterPlannerFailure::InvalidStart { agent_id, point },
912                ..
913            } => {
914                assert_eq!(agent_id, "alpha");
915                assert_eq!(point, Point::new(0, 0));
916            }
917            other => panic!("expected invalid-start failure, got {other:?}"),
918        }
919    }
920
921    #[test]
922    fn starter_planner_reports_invalid_goal_for_blocked_goal_cell() {
923        let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
924        grid.set_cell(Point::new(2, 0), Cell::Blocked)
925            .expect("grid edit should succeed");
926        let problem = MapfProblem {
927            problem_id: "starter-invalid-goal".to_string(),
928            grid,
929            agents: vec![MapfAgent::new("alpha", Point::new(0, 0), Point::new(2, 0))],
930            objective: MapfObjective::SumOfCosts,
931        };
932
933        match MapfStarterPlanner::new(64).plan(&problem).outcome {
934            MapfStarterPlannerOutcome::Failed {
935                reason: MapfStarterPlannerFailure::InvalidGoal { agent_id, point },
936                ..
937            } => {
938                assert_eq!(agent_id, "alpha");
939                assert_eq!(point, Point::new(2, 0));
940            }
941            other => panic!("expected invalid-goal failure, got {other:?}"),
942        }
943    }
944
945    #[test]
946    fn starter_planner_saturates_unbounded_horizon() {
947        let planner = MapfStarterPlanner::new(usize::MAX);
948        assert_eq!(planner.max_timesteps(), MapfStarterPlanner::MAX_TIMESTEPS);
949
950        let problem = MapfProblem {
951            problem_id: "starter-unbounded-horizon".to_string(),
952            grid: Grid::new(5, 5).expect("grid dimensions are valid"),
953            agents: vec![MapfAgent::new("alpha", Point::new(0, 0), Point::new(4, 4))],
954            objective: MapfObjective::SumOfCosts,
955        };
956
957        assert!(planner.plan(&problem).is_solved());
958    }
959}