diff --git a/benchmarks/README.md b/benchmarks/README.md index ee689e6..5201ffe 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -26,3 +26,26 @@ versions, and Criterion configuration. Memory is not reported until Roadrunner adopts a controlled allocator or profiler configuration. An unavailable measurement is recorded explicitly rather than estimated. + +## A* comparison + +Run the Phase 6 comparison with: + +```bash +cargo bench -p roadrunner-core --bench astar --locked +``` + +The benchmark builds deterministic geographic graphs with a directed eastbound backbone and +dead-end northern branches. Both algorithms return the same optimal backbone route. Dijkstra +finalizes the lower-cost dead ends before reaching the destination, while the scaled Haversine +heuristic lets A* exclude them from its search. + +Cases cover 1K, 10K, and 100K nodes. Benchmark identifiers include graph size and observed +finalized-node count. Criterion measures the complete routing call, including A* heuristic +preparation; graph construction and correctness comparisons happen outside the timed closure. + +See [`../docs/benchmarks.md`](../docs/benchmarks.md) for findings and the linked structured +result snapshot. + +The initial result is +[`2026-09-04-apple-m3-astar-comparison.json`](results/2026-09-04-apple-m3-astar-comparison.json). diff --git a/benchmarks/results/2026-09-04-apple-m3-astar-comparison.json b/benchmarks/results/2026-09-04-apple-m3-astar-comparison.json new file mode 100644 index 0000000..b9ecd90 --- /dev/null +++ b/benchmarks/results/2026-09-04-apple-m3-astar-comparison.json @@ -0,0 +1,147 @@ +{ + "schema_version": 1, + "benchmark": "astar_comparison", + "recorded_at": "2026-09-04T07:09:59+01:00", + "hardware": { + "model": "MacBook Pro Mac15,3", + "cpu": "Apple M3", + "architecture": "arm64", + "cpu_cores": 8, + "performance_cores": 4, + "efficiency_cores": 4, + "memory_bytes": 17179869184 + }, + "software": { + "operating_system": "macOS 26.5 (25F71)", + "rustc": "1.96.0 (ac68faa20 2026-05-25)", + "cargo": "1.96.0 (30a34c682 2026-05-25)", + "criterion": "0.7.0", + "build_profile": "bench" + }, + "dataset": { + "name": "geographic_backbone_with_northern_dead_ends", + "provenance": "deterministically generated by crates/roadrunner-core/benches/astar.rs", + "source_node": 0, + "destination": "last_backbone_node", + "backbone_node_fraction": 0.1, + "dead_end_node_fraction": 0.9, + "graph_construction_timed": false, + "correctness_check_timed": false + }, + "algorithms": [ + { + "name": "dijkstra", + "cost_model": "distance", + "heuristic": null, + "priority_queue": "std::collections::BinaryHeap", + "best_cost_storage": "std::collections::HashMap", + "predecessor_storage": "std::collections::HashMap" + }, + { + "name": "astar", + "cost_model": "distance", + "heuristic": "Haversine scaled by the graph minimum cost per geodesic meter", + "heuristic_preparation_timed": true, + "priority_queue": "std::collections::BinaryHeap", + "best_cost_storage": "std::collections::HashMap", + "predecessor_storage": "std::collections::HashMap" + } + ], + "configuration": { + "benchmark_invocations": 1, + "samples_per_case": 20, + "warm_up_seconds": 3.0, + "measurement_seconds": 5.0, + "percentile_method": "R type 7 linear interpolation over per-iteration sample times" + }, + "results": [ + { + "nodes": 1000, + "edges": 999, + "optimal_path_nodes": 100, + "same_optimal_path_and_cost": true, + "search_space_reduction_percent": 90.0, + "dijkstra": { + "visited_nodes": 1000, + "measured_route_iterations": 41790, + "latency_nanoseconds": { + "median": 120527.243, + "p95": 122810.305, + "p99": 139814.875 + }, + "memory_bytes": null + }, + "astar": { + "visited_nodes": 100, + "measured_route_iterations": 28560, + "median_latency_overhead_percent": 44.3, + "latency_nanoseconds": { + "median": 173959.656, + "p95": 176030.169, + "p99": 176698.292 + }, + "memory_bytes": null + } + }, + { + "nodes": 10000, + "edges": 9999, + "optimal_path_nodes": 1000, + "same_optimal_path_and_cost": true, + "search_space_reduction_percent": 90.0, + "dijkstra": { + "visited_nodes": 10000, + "measured_route_iterations": 4200, + "latency_nanoseconds": { + "median": 1242228.313, + "p95": 1261505.533, + "p99": 1296747.607 + }, + "memory_bytes": null + }, + "astar": { + "visited_nodes": 1000, + "measured_route_iterations": 2940, + "median_latency_overhead_percent": 40.8, + "latency_nanoseconds": { + "median": 1748876.286, + "p95": 1774971.69, + "p99": 1784335.605 + }, + "memory_bytes": null + } + }, + { + "nodes": 100000, + "edges": 99999, + "optimal_path_nodes": 10000, + "same_optimal_path_and_cost": true, + "search_space_reduction_percent": 90.0, + "dijkstra": { + "visited_nodes": 100000, + "measured_route_iterations": 420, + "latency_nanoseconds": { + "median": 17776201.563, + "p95": 18465051.255, + "p99": 18970599.537 + }, + "memory_bytes": null + }, + "astar": { + "visited_nodes": 10000, + "measured_route_iterations": 420, + "median_latency_overhead_percent": 23.7, + "latency_nanoseconds": { + "median": 21987747.846, + "p95": 23876468.586, + "p99": 26653078.59 + }, + "memory_bytes": null + } + } + ], + "memory_measurement": { + "status": "not_measured", + "reason": "No controlled allocator or profiler configuration is available yet." + } +} diff --git a/crates/roadrunner-core/Cargo.toml b/crates/roadrunner-core/Cargo.toml index 26940bf..b9d2b75 100644 --- a/crates/roadrunner-core/Cargo.toml +++ b/crates/roadrunner-core/Cargo.toml @@ -25,3 +25,7 @@ harness = false [[bench]] name = "dijkstra" harness = false + +[[bench]] +name = "astar" +harness = false diff --git a/crates/roadrunner-core/benches/astar.rs b/crates/roadrunner-core/benches/astar.rs new file mode 100644 index 0000000..0e52898 --- /dev/null +++ b/crates/roadrunner-core/benches/astar.rs @@ -0,0 +1,167 @@ +//! A* and Dijkstra comparison over deterministic geographic graphs. +//! +//! Criterion records end-to-end search time, including A* heuristic preparation. +//! Graph construction and correctness checks happen outside timed closures. + +use std::hint::black_box; + +use criterion::{BenchmarkId, Criterion, Throughput}; +use roadrunner_core::cost::{DistanceCost, RoutingContext}; +use roadrunner_core::geo::{Coordinate, Seconds, haversine_distance}; +use roadrunner_core::graph::{Edge, EdgeId, Graph, Node, NodeId}; +use roadrunner_core::routing::{RouteResult, astar, dijkstra}; + +const BACKBONE_DIVISOR: u32 = 10; +const SAMPLE_SIZE: usize = 20; + +fn coordinate(latitude: f64, longitude: f64) -> Coordinate { + match Coordinate::new(latitude, longitude) { + Ok(coordinate) => coordinate, + Err(error) => panic!("generated coordinate is invalid: {error}"), + } +} + +fn seconds(value: f64) -> Seconds { + match Seconds::new(value) { + Ok(seconds) => seconds, + Err(error) => panic!("generated duration is invalid: {error}"), + } +} + +fn add_node(graph: &mut Graph, id: u32, coordinate: Coordinate) { + let result = graph.add_node(Node::new(NodeId::new(id), coordinate)); + assert!( + result.is_ok(), + "generated node insertion failed: {result:?}" + ); +} + +fn add_edge(graph: &mut Graph, id: u64, from: NodeId, to: NodeId) { + let from_coordinate = match graph.node(from) { + Some(node) => node.coordinate(), + None => panic!("generated source node {from} is missing"), + }; + let to_coordinate = match graph.node(to) { + Some(node) => node.coordinate(), + None => panic!("generated destination node {to} is missing"), + }; + let distance = haversine_distance(from_coordinate, to_coordinate); + let edge = Edge::new( + EdgeId::new(id), + from, + to, + distance, + seconds(distance.value()), + ); + let result = graph.add_edge(edge); + assert!( + result.is_ok(), + "generated edge insertion failed: {result:?}" + ); +} + +fn generated_graph(node_count: u32) -> (Graph, NodeId) { + assert!(node_count >= BACKBONE_DIVISOR * 2); + let backbone_count = node_count / BACKBONE_DIVISOR; + let destination = NodeId::new(backbone_count - 1); + let mut graph = Graph::new(); + + for id in 0..backbone_count { + let longitude = f64::from(id) / f64::from(backbone_count - 1); + add_node(&mut graph, id, coordinate(0.0, longitude)); + } + for id in backbone_count..node_count { + add_node(&mut graph, id, coordinate(0.25, 0.0)); + } + + let mut edge_id = 0_u64; + for from in 0..(backbone_count - 1) { + add_edge( + &mut graph, + edge_id, + NodeId::new(from), + NodeId::new(from + 1), + ); + edge_id += 1; + } + for decoy in backbone_count..node_count { + add_edge(&mut graph, edge_id, NodeId::new(0), NodeId::new(decoy)); + edge_id += 1; + } + + (graph, destination) +} + +fn dijkstra_route(graph: &Graph, destination: NodeId) -> RouteResult { + let result = dijkstra( + graph, + NodeId::new(0), + destination, + &DistanceCost, + &RoutingContext::new(), + ); + match result { + Ok(route) => route, + Err(error) => panic!("generated graph Dijkstra search failed: {error}"), + } +} + +fn astar_route(graph: &Graph, destination: NodeId) -> RouteResult { + let result = astar( + graph, + NodeId::new(0), + destination, + &DistanceCost, + &RoutingContext::new(), + ); + match result { + Ok(route) => route, + Err(error) => panic!("generated graph A* search failed: {error}"), + } +} + +fn astar_comparison(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("astar_comparison"); + group.sample_size(SAMPLE_SIZE); + + for node_count in [1_000_u32, 10_000, 100_000] { + let (graph, destination) = generated_graph(node_count); + let dijkstra_result = dijkstra_route(&graph, destination); + let astar_result = astar_route(&graph, destination); + assert_eq!(astar_result.total_cost(), dijkstra_result.total_cost()); + assert_eq!(astar_result.path(), dijkstra_result.path()); + + group.throughput(Throughput::Elements(u64::from(node_count))); + let dijkstra_parameter = format!( + "{node_count}_nodes_{}_visited", + dijkstra_result.visited_nodes() + ); + group.bench_with_input( + BenchmarkId::new("dijkstra_distance", dijkstra_parameter), + &graph, + |bencher, graph| { + bencher.iter(|| black_box(dijkstra_route(black_box(graph), destination))); + }, + ); + + let astar_parameter = format!( + "{node_count}_nodes_{}_visited", + astar_result.visited_nodes() + ); + group.bench_with_input( + BenchmarkId::new("astar_distance", astar_parameter), + &graph, + |bencher, graph| { + bencher.iter(|| black_box(astar_route(black_box(graph), destination))); + }, + ); + } + + group.finish(); +} + +fn main() { + let mut criterion = Criterion::default().configure_from_args(); + astar_comparison(&mut criterion); + criterion.final_summary(); +} diff --git a/crates/roadrunner-core/src/graph/network.rs b/crates/roadrunner-core/src/graph/network.rs index fce4fb5..d14e791 100644 --- a/crates/roadrunner-core/src/graph/network.rs +++ b/crates/roadrunner-core/src/graph/network.rs @@ -115,6 +115,14 @@ impl Graph { .find(|edge| edge.id() == id) } + /// Iterates over every directed edge in unspecified order. + /// + /// Algorithms that expose deterministic results must apply their own stable + /// ordering rather than depend on this iterator's hash-map traversal order. + pub fn edges(&self) -> impl Iterator { + self.outgoing.values().flat_map(|edges| edges.iter()) + } + /// Returns the outgoing edges for a node in insertion order. /// /// # Errors diff --git a/crates/roadrunner-core/src/routing/astar.rs b/crates/roadrunner-core/src/routing/astar.rs new file mode 100644 index 0000000..241d962 --- /dev/null +++ b/crates/roadrunner-core/src/routing/astar.rs @@ -0,0 +1,424 @@ +use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashMap}; + +use crate::cost::{CostKind, CostModel, RouteCost, RoutingContext}; +use crate::geo::{Coordinate, Meters, haversine_distance}; +use crate::graph::{Graph, NodeId}; + +use super::search::{evaluate_edge_cost, reconstruct_route, sorted_outgoing, validate_endpoints}; +use super::{RouteResult, RoutingAlgorithm, RoutingError}; + +#[derive(Debug, Clone, Copy)] +struct QueueEntry { + node_id: NodeId, + route_cost: f64, + estimated_total: f64, +} + +impl PartialEq for QueueEntry { + fn eq(&self, other: &Self) -> bool { + self.node_id == other.node_id + && self.route_cost.total_cmp(&other.route_cost).is_eq() + && self + .estimated_total + .total_cmp(&other.estimated_total) + .is_eq() + } +} + +impl Eq for QueueEntry {} + +impl PartialOrd for QueueEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for QueueEntry { + fn cmp(&self, other: &Self) -> Ordering { + other + .estimated_total + .total_cmp(&self.estimated_total) + .then_with(|| other.route_cost.total_cmp(&self.route_cost)) + .then_with(|| other.node_id.cmp(&self.node_id)) + } +} + +#[derive(Debug, Clone, Copy)] +struct ScaledHaversine { + destination: Coordinate, + cost_kind: CostKind, + cost_per_meter: f64, +} + +impl ScaledHaversine { + fn prepare( + graph: &Graph, + destination: NodeId, + cost_model: &dyn CostModel, + context: RoutingContext, + ) -> Result { + let destination = graph + .node(destination) + .ok_or(RoutingError::MissingHeuristicNode { + node_id: destination, + })? + .coordinate(); + let cost_kind = cost_model.kind(); + let mut cost_per_meter: Option = None; + for edge in graph.edges() { + let from = graph + .node(edge.from()) + .ok_or(RoutingError::MissingEdgeNode { + edge_id: edge.id(), + node_id: edge.from(), + })? + .coordinate(); + let to = graph + .node(edge.to()) + .ok_or(RoutingError::MissingEdgeNode { + edge_id: edge.id(), + node_id: edge.to(), + })? + .coordinate(); + let geometric_distance = haversine_distance(from, to).value(); + if geometric_distance == 0.0 { + continue; + } + + let edge_cost = evaluate_edge_cost(cost_model, edge, context, cost_kind)?; + let ratio = edge_cost.value() / geometric_distance; + if !ratio.is_finite() { + continue; + } + cost_per_meter = Some(cost_per_meter.map_or(ratio, |current| current.min(ratio))); + } + + Ok(Self { + destination, + cost_kind, + cost_per_meter: cost_per_meter.unwrap_or(0.0), + }) + } + + fn estimate(self, graph: &Graph, node_id: NodeId) -> Result { + let coordinate = graph + .node(node_id) + .ok_or(RoutingError::MissingHeuristicNode { node_id })? + .coordinate(); + let estimate = + haversine_distance(coordinate, self.destination).value() * self.cost_per_meter; + RouteCost::new(self.cost_kind, estimate) + .map_err(|source| RoutingError::HeuristicEvaluation { node_id, source }) + } +} + +/// Calculates the lowest-cost directed route using A* search. +/// +/// Haversine distance is scaled by the graph's minimum actual edge cost per +/// geodesic meter under the selected model and context. That lower bound makes the +/// heuristic admissible and consistent even when edge cost is travel time or a +/// custom non-negative additive value. If no positive geographic edge exists, the +/// scale is zero and A* safely degenerates to Dijkstra's search order. +/// +/// # Errors +/// +/// Returns [`RoutingError`] for absent endpoints, unreachable destinations, cost +/// failures, or graph/predecessor invariant violations. +pub fn astar( + graph: &Graph, + source: NodeId, + destination: NodeId, + cost_model: &dyn CostModel, + context: &RoutingContext, +) -> Result { + validate_endpoints(graph, source, destination)?; + + let cost_kind = cost_model.kind(); + let zero_cost = RouteCost::zero(cost_kind); + if source == destination { + return Ok(RouteResult::new( + RoutingAlgorithm::AStar, + vec![source], + Vec::new(), + Meters::ZERO, + zero_cost, + 1, + )); + } + + let heuristic = ScaledHaversine::prepare(graph, destination, cost_model, *context)?; + let initial_estimate = heuristic.estimate(graph, source)?; + let mut best_costs = HashMap::new(); + let mut predecessors = HashMap::new(); + let mut queue = BinaryHeap::new(); + let mut visited_nodes = 0; + best_costs.insert(source, zero_cost); + queue.push(QueueEntry { + node_id: source, + route_cost: zero_cost.value(), + estimated_total: initial_estimate.value(), + }); + + while let Some(entry) = queue.pop() { + let Some(current_cost) = best_costs.get(&entry.node_id).copied() else { + continue; + }; + if entry.route_cost.total_cmp(¤t_cost.value()).is_gt() { + continue; + } + + visited_nodes += 1; + if entry.node_id == destination { + return reconstruct_route( + graph, + RoutingAlgorithm::AStar, + source, + destination, + &predecessors, + current_cost, + visited_nodes, + ); + } + + for edge in sorted_outgoing(graph, entry.node_id)? { + let edge_cost = evaluate_edge_cost(cost_model, edge, *context, cost_kind)?; + let candidate_cost = current_cost.checked_add(edge_cost).map_err(|source| { + RoutingError::CostAccumulation { + edge_id: edge.id(), + source, + } + })?; + let improves = best_costs + .get(&edge.to()) + .is_none_or(|known_cost| candidate_cost < *known_cost); + if !improves { + continue; + } + + let estimate = heuristic.estimate(graph, edge.to())?; + let estimated_total = candidate_cost.checked_add(estimate).map_err(|source| { + RoutingError::EstimatedTotal { + node_id: edge.to(), + source, + } + })?; + best_costs.insert(edge.to(), candidate_cost); + predecessors.insert(edge.to(), edge.id()); + queue.push(QueueEntry { + node_id: edge.to(), + route_cost: candidate_cost.value(), + estimated_total: estimated_total.value(), + }); + } + } + + Err(RoutingError::NoRoute { + source_node: source, + destination, + }) +} + +#[cfg(test)] +mod tests { + use crate::cost::{DistanceCost, TravelTimeCost}; + use crate::geo::Seconds; + use crate::graph::{Edge, EdgeId, Node}; + use crate::routing::{RouteEndpoint, dijkstra}; + + use super::*; + + const A: NodeId = NodeId::new(1); + const B: NodeId = NodeId::new(2); + const C: NodeId = NodeId::new(3); + const D: NodeId = NodeId::new(4); + + fn coordinate(latitude: f64, longitude: f64) -> Coordinate { + match Coordinate::new(latitude, longitude) { + Ok(coordinate) => coordinate, + Err(error) => panic!("test coordinate is invalid: {error}"), + } + } + + fn seconds(value: f64) -> Seconds { + match Seconds::new(value) { + Ok(seconds) => seconds, + Err(error) => panic!("test duration is invalid: {error}"), + } + } + + fn graph_with_nodes(nodes: &[(NodeId, Coordinate)]) -> Graph { + let mut graph = Graph::new(); + for (node_id, coordinate) in nodes { + let result = graph.add_node(Node::new(*node_id, *coordinate)); + assert!(result.is_ok(), "test node insertion failed: {result:?}"); + } + graph + } + + fn coordinate_for(graph: &Graph, node_id: NodeId) -> Coordinate { + match graph.node(node_id) { + Some(node) => node.coordinate(), + None => panic!("test node {node_id} is missing"), + } + } + + fn add_geographic_edge(graph: &mut Graph, id: u64, from: NodeId, to: NodeId) { + let distance = haversine_distance(coordinate_for(graph, from), coordinate_for(graph, to)); + add_geographic_edge_with_time(graph, id, from, to, distance.value()); + } + + fn add_geographic_edge_with_time( + graph: &mut Graph, + id: u64, + from: NodeId, + to: NodeId, + travel_time_seconds: f64, + ) { + let distance = haversine_distance(coordinate_for(graph, from), coordinate_for(graph, to)); + let edge = Edge::new( + EdgeId::new(id), + from, + to, + distance, + seconds(travel_time_seconds), + ); + let result = graph.add_edge(edge); + assert!(result.is_ok(), "test edge insertion failed: {result:?}"); + } + + fn guided_graph() -> Graph { + let mut graph = graph_with_nodes(&[ + (A, coordinate(0.0, 0.0)), + (B, coordinate(0.0, 0.01)), + (C, coordinate(0.01, 0.0)), + (D, coordinate(0.0, 0.02)), + ]); + add_geographic_edge(&mut graph, 10, A, B); + add_geographic_edge(&mut graph, 11, B, D); + add_geographic_edge(&mut graph, 12, A, C); + graph + } + + #[test] + fn matches_dijkstra_optimal_cost_and_path() { + let graph = guided_graph(); + let context = RoutingContext::new(); + let dijkstra = dijkstra(&graph, A, D, &DistanceCost, &context); + let astar = astar(&graph, A, D, &DistanceCost, &context); + let (Ok(dijkstra), Ok(astar)) = (dijkstra, astar) else { + panic!("expected both algorithms to find a route"); + }; + + assert_eq!(astar.algorithm(), RoutingAlgorithm::AStar); + assert_eq!(astar.total_cost(), dijkstra.total_cost()); + assert_eq!(astar.path(), dijkstra.path()); + assert_eq!(astar.path(), &[A, B, D]); + } + + #[test] + fn explores_fewer_nodes_when_geography_guides_the_search() { + let graph = guided_graph(); + let context = RoutingContext::new(); + let dijkstra = dijkstra(&graph, A, D, &DistanceCost, &context); + let astar = astar(&graph, A, D, &DistanceCost, &context); + let (Ok(dijkstra), Ok(astar)) = (dijkstra, astar) else { + panic!("expected both algorithms to find a route"); + }; + + assert_eq!(dijkstra.visited_nodes(), 4); + assert_eq!(astar.visited_nodes(), 3); + } + + #[test] + fn remains_admissible_for_travel_time_cost() { + let mut graph = graph_with_nodes(&[ + (A, coordinate(0.0, 0.0)), + (B, coordinate(0.0, 0.01)), + (C, coordinate(0.01, 0.0)), + (D, coordinate(0.0, 0.02)), + ]); + add_geographic_edge_with_time(&mut graph, 10, A, B, 100.0); + add_geographic_edge_with_time(&mut graph, 11, B, D, 100.0); + add_geographic_edge_with_time(&mut graph, 12, A, C, 1.0); + add_geographic_edge_with_time(&mut graph, 13, C, D, 1.0); + let context = RoutingContext::new(); + let dijkstra = dijkstra(&graph, A, D, &TravelTimeCost, &context); + let astar = astar(&graph, A, D, &TravelTimeCost, &context); + let (Ok(dijkstra), Ok(astar)) = (dijkstra, astar) else { + panic!("expected both algorithms to find a route"); + }; + + assert_eq!(astar.total_cost(), dijkstra.total_cost()); + assert_eq!(astar.path(), dijkstra.path()); + assert_eq!(astar.path(), &[A, C, D]); + } + + #[test] + fn zero_geographic_span_safely_degenerates_to_dijkstra() { + let mut graph = graph_with_nodes(&[ + (A, Coordinate::ORIGIN), + (B, Coordinate::ORIGIN), + (D, Coordinate::ORIGIN), + ]); + add_geographic_edge(&mut graph, 10, A, B); + add_geographic_edge(&mut graph, 11, B, D); + let context = RoutingContext::new(); + let dijkstra = dijkstra(&graph, A, D, &DistanceCost, &context); + let astar = astar(&graph, A, D, &DistanceCost, &context); + let (Ok(dijkstra), Ok(astar)) = (dijkstra, astar) else { + panic!("expected both algorithms to find a route"); + }; + + assert_eq!(astar.total_cost(), dijkstra.total_cost()); + assert_eq!(astar.visited_nodes(), dijkstra.visited_nodes()); + } + + #[test] + fn source_equal_to_destination_returns_a_zero_cost_route() { + let graph = graph_with_nodes(&[(A, Coordinate::ORIGIN)]); + + let result = astar(&graph, A, A, &DistanceCost, &RoutingContext::new()); + let Ok(result) = result else { + panic!("expected a trivial route: {result:?}"); + }; + + assert_eq!(result.algorithm(), RoutingAlgorithm::AStar); + assert_eq!(result.path(), &[A]); + assert_eq!(result.total_cost(), RouteCost::zero(CostKind::Distance)); + assert_eq!(result.visited_nodes(), 1); + } + + #[test] + fn reports_unreachable_destinations_in_disconnected_graphs() { + let graph = graph_with_nodes(&[(A, coordinate(0.0, 0.0)), (B, coordinate(0.0, 0.01))]); + + assert_eq!( + astar(&graph, A, B, &DistanceCost, &RoutingContext::new()), + Err(RoutingError::NoRoute { + source_node: A, + destination: B, + }) + ); + } + + #[test] + fn rejects_unknown_endpoints() { + let graph = graph_with_nodes(&[(A, Coordinate::ORIGIN)]); + + assert_eq!( + astar(&graph, B, A, &DistanceCost, &RoutingContext::new()), + Err(RoutingError::NodeNotFound { + endpoint: RouteEndpoint::Source, + node_id: B, + }) + ); + assert_eq!( + astar(&graph, A, B, &DistanceCost, &RoutingContext::new()), + Err(RoutingError::NodeNotFound { + endpoint: RouteEndpoint::Destination, + node_id: B, + }) + ); + } +} diff --git a/crates/roadrunner-core/src/routing/dijkstra.rs b/crates/roadrunner-core/src/routing/dijkstra.rs index d8303ec..98d5105 100644 --- a/crates/roadrunner-core/src/routing/dijkstra.rs +++ b/crates/roadrunner-core/src/routing/dijkstra.rs @@ -3,9 +3,10 @@ use std::collections::{BinaryHeap, HashMap}; use crate::cost::{CostModel, RouteCost, RoutingContext}; use crate::geo::Meters; -use crate::graph::{Edge, EdgeId, Graph, NodeId}; +use crate::graph::{Graph, NodeId}; -use super::{RouteEndpoint, RouteResult, RoutingError}; +use super::search::{evaluate_edge_cost, reconstruct_route, sorted_outgoing, validate_endpoints}; +use super::{RouteResult, RoutingAlgorithm, RoutingError}; #[derive(Debug, Clone, Copy)] struct QueueEntry { @@ -59,6 +60,7 @@ pub fn dijkstra( let zero_cost = RouteCost::zero(cost_kind); if source == destination { return Ok(RouteResult::new( + RoutingAlgorithm::Dijkstra, vec![source], Vec::new(), Meters::ZERO, @@ -89,6 +91,7 @@ pub fn dijkstra( if entry.node_id == destination { return reconstruct_route( graph, + RoutingAlgorithm::Dijkstra, source, destination, &predecessors, @@ -97,31 +100,8 @@ pub fn dijkstra( ); } - let mut outgoing: Vec<&Edge> = graph - .neighbors(entry.node_id) - .map_err(|source| RoutingError::Graph { source })? - .iter() - .collect(); - outgoing.sort_unstable_by(|left, right| { - left.to() - .cmp(&right.to()) - .then_with(|| left.id().cmp(&right.id())) - }); - - for edge in outgoing { - let edge_cost = cost_model.edge_cost(edge, context).map_err(|source| { - RoutingError::CostEvaluation { - edge_id: edge.id(), - source, - } - })?; - if edge_cost.kind() != cost_kind { - return Err(RoutingError::CostKindMismatch { - edge_id: edge.id(), - expected: cost_kind, - actual: edge_cost.kind(), - }); - } + for edge in sorted_outgoing(graph, entry.node_id)? { + let edge_cost = evaluate_edge_cost(cost_model, edge, *context, cost_kind)?; let candidate_cost = current_cost.checked_add(edge_cost).map_err(|source| { RoutingError::CostAccumulation { edge_id: edge.id(), @@ -150,83 +130,12 @@ pub fn dijkstra( }) } -fn validate_endpoints( - graph: &Graph, - source: NodeId, - destination: NodeId, -) -> Result<(), RoutingError> { - if !graph.contains_node(source) { - return Err(RoutingError::NodeNotFound { - endpoint: RouteEndpoint::Source, - node_id: source, - }); - } - if !graph.contains_node(destination) { - return Err(RoutingError::NodeNotFound { - endpoint: RouteEndpoint::Destination, - node_id: destination, - }); - } - Ok(()) -} - -fn reconstruct_route( - graph: &Graph, - source: NodeId, - destination: NodeId, - predecessors: &HashMap, - total_cost: RouteCost, - visited_nodes: usize, -) -> Result { - let mut path = vec![destination]; - let mut edges = Vec::new(); - let mut total_distance = Meters::ZERO; - let mut cursor = destination; - - while cursor != source { - if path.len() > graph.node_count() { - return Err(RoutingError::PredecessorCycle); - } - let edge_id = predecessors - .get(&cursor) - .copied() - .ok_or(RoutingError::MissingPredecessor { node_id: cursor })?; - let edge = graph - .edge(edge_id) - .ok_or(RoutingError::MissingRouteEdge { edge_id })?; - if edge.to() != cursor { - return Err(RoutingError::InvalidPredecessorEdge { - edge_id, - expected_to: cursor, - actual_to: edge.to(), - }); - } - - total_distance = total_distance - .checked_add(edge.distance()) - .map_err(|source| RoutingError::DistanceAccumulation { edge_id, source })?; - edges.push(edge_id); - cursor = edge.from(); - path.push(cursor); - } - - path.reverse(); - edges.reverse(); - Ok(RouteResult::new( - path, - edges, - total_distance, - total_cost, - visited_nodes, - )) -} - #[cfg(test)] mod tests { use crate::cost::{CostError, CostKind, DistanceCost, TravelTimeCost}; use crate::geo::{Coordinate, Seconds}; - use crate::graph::Node; - use crate::routing::RoutingAlgorithm; + use crate::graph::{Edge, EdgeId, Node}; + use crate::routing::{RouteEndpoint, RoutingAlgorithm}; use super::*; diff --git a/crates/roadrunner-core/src/routing/error.rs b/crates/roadrunner-core/src/routing/error.rs index e566ede..471cf05 100644 --- a/crates/roadrunner-core/src/routing/error.rs +++ b/crates/roadrunner-core/src/routing/error.rs @@ -74,6 +74,42 @@ pub enum RoutingError { actual: CostKind, }, + /// An edge references a node that is missing during heuristic preparation. + #[error("edge {edge_id} references missing node {node_id}")] + MissingEdgeNode { + /// The edge with the invalid endpoint. + edge_id: EdgeId, + /// The missing endpoint node. + node_id: NodeId, + }, + + /// A node needed for heuristic evaluation is missing from the graph. + #[error("heuristic node {node_id} is missing")] + MissingHeuristicNode { + /// The missing node identity. + node_id: NodeId, + }, + + /// Constructing a node's heuristic cost failed. + #[error("heuristic evaluation failed for node {node_id}: {source}")] + HeuristicEvaluation { + /// The node being estimated. + node_id: NodeId, + /// The underlying cost validation error. + #[source] + source: CostError, + }, + + /// Combining path and heuristic costs failed. + #[error("estimated-total cost failed for node {node_id}: {source}")] + EstimatedTotal { + /// The node whose estimated total could not be represented. + node_id: NodeId, + /// The underlying cost validation error. + #[source] + source: CostError, + }, + /// Accumulating route cost failed. #[error("cost accumulation failed at edge {edge_id}: {source}")] CostAccumulation { diff --git a/crates/roadrunner-core/src/routing/mod.rs b/crates/roadrunner-core/src/routing/mod.rs index 0c31c33..6cae34f 100644 --- a/crates/roadrunner-core/src/routing/mod.rs +++ b/crates/roadrunner-core/src/routing/mod.rs @@ -1,9 +1,12 @@ //! Shortest-path algorithms and canonical route results. +mod astar; mod dijkstra; mod error; mod result; +mod search; +pub use astar::astar; pub use dijkstra::dijkstra; pub use error::{RouteEndpoint, RoutingError}; pub use result::{RouteResult, RoutingAlgorithm}; diff --git a/crates/roadrunner-core/src/routing/result.rs b/crates/roadrunner-core/src/routing/result.rs index b8d9d91..556e29c 100644 --- a/crates/roadrunner-core/src/routing/result.rs +++ b/crates/roadrunner-core/src/routing/result.rs @@ -10,6 +10,8 @@ use crate::graph::{EdgeId, NodeId}; pub enum RoutingAlgorithm { /// Dijkstra's shortest-path algorithm. Dijkstra, + /// A* shortest-path search with a scaled Haversine heuristic. + AStar, } /// A validated shortest-path result and its search diagnostics. @@ -25,6 +27,7 @@ pub struct RouteResult { impl RouteResult { pub(super) fn new( + algorithm: RoutingAlgorithm, path: Vec, edges: Vec, total_distance: Meters, @@ -32,7 +35,7 @@ impl RouteResult { visited_nodes: usize, ) -> Self { Self { - algorithm: RoutingAlgorithm::Dijkstra, + algorithm, path, edges, total_distance, diff --git a/crates/roadrunner-core/src/routing/search.rs b/crates/roadrunner-core/src/routing/search.rs new file mode 100644 index 0000000..9940a0b --- /dev/null +++ b/crates/roadrunner-core/src/routing/search.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; + +use crate::cost::{CostKind, CostModel, RouteCost, RoutingContext}; +use crate::geo::Meters; +use crate::graph::{Edge, EdgeId, Graph, NodeId}; + +use super::{RouteEndpoint, RouteResult, RoutingAlgorithm, RoutingError}; + +pub(super) fn validate_endpoints( + graph: &Graph, + source: NodeId, + destination: NodeId, +) -> Result<(), RoutingError> { + if !graph.contains_node(source) { + return Err(RoutingError::NodeNotFound { + endpoint: RouteEndpoint::Source, + node_id: source, + }); + } + if !graph.contains_node(destination) { + return Err(RoutingError::NodeNotFound { + endpoint: RouteEndpoint::Destination, + node_id: destination, + }); + } + Ok(()) +} + +pub(super) fn evaluate_edge_cost( + cost_model: &dyn CostModel, + edge: &Edge, + context: RoutingContext, + expected_kind: CostKind, +) -> Result { + let edge_cost = + cost_model + .edge_cost(edge, &context) + .map_err(|source| RoutingError::CostEvaluation { + edge_id: edge.id(), + source, + })?; + if edge_cost.kind() != expected_kind { + return Err(RoutingError::CostKindMismatch { + edge_id: edge.id(), + expected: expected_kind, + actual: edge_cost.kind(), + }); + } + Ok(edge_cost) +} + +pub(super) fn sorted_outgoing(graph: &Graph, node_id: NodeId) -> Result, RoutingError> { + let mut outgoing: Vec<&Edge> = graph + .neighbors(node_id) + .map_err(|source| RoutingError::Graph { source })? + .iter() + .collect(); + outgoing.sort_unstable_by(|left, right| { + left.to() + .cmp(&right.to()) + .then_with(|| left.id().cmp(&right.id())) + }); + Ok(outgoing) +} + +pub(super) fn reconstruct_route( + graph: &Graph, + algorithm: RoutingAlgorithm, + source: NodeId, + destination: NodeId, + predecessors: &HashMap, + total_cost: RouteCost, + visited_nodes: usize, +) -> Result { + let mut path = vec![destination]; + let mut edges = Vec::new(); + let mut total_distance = Meters::ZERO; + let mut cursor = destination; + + while cursor != source { + if path.len() > graph.node_count() { + return Err(RoutingError::PredecessorCycle); + } + let edge_id = predecessors + .get(&cursor) + .copied() + .ok_or(RoutingError::MissingPredecessor { node_id: cursor })?; + let edge = graph + .edge(edge_id) + .ok_or(RoutingError::MissingRouteEdge { edge_id })?; + if edge.to() != cursor { + return Err(RoutingError::InvalidPredecessorEdge { + edge_id, + expected_to: cursor, + actual_to: edge.to(), + }); + } + + total_distance = total_distance + .checked_add(edge.distance()) + .map_err(|source| RoutingError::DistanceAccumulation { edge_id, source })?; + edges.push(edge_id); + cursor = edge.from(); + path.push(cursor); + } + + path.reverse(); + edges.reverse(); + Ok(RouteResult::new( + algorithm, + path, + edges, + total_distance, + total_cost, + visited_nodes, + )) +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..f9624b4 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,60 @@ +# Routing Benchmarks + +Roadrunner records benchmark methodology and machine-readable results so algorithm comparisons +can be reproduced rather than inferred from isolated timing claims. + +## A* versus Dijkstra + +Run the Phase 6 comparison with: + +```bash +cargo bench -p roadrunner-core --bench astar --locked +``` + +The deterministic `geographic_backbone_with_northern_dead_ends` dataset places 10% of its nodes +on the only route from source to destination. The remaining 90% are reachable dead ends whose +distance from the source is less than the complete route cost. Dijkstra therefore finalizes every +node, while A* can use geographic direction to avoid finalizing the dead ends. + +For the selected cost model and routing context, A* prepares the lower bound: + +```text +cost_per_meter = min(edge_cost / Haversine(edge.from, edge.to)) +heuristic(node) = cost_per_meter * Haversine(node, destination) +``` + +Every edge cost is therefore at least the scaled straight-line distance between its endpoints. +Together with the Haversine triangle inequality, this makes the heuristic admissible and +consistent. For travel-time cost, the scale is seconds per meter—the reciprocal of the maximum +observed traversable geographic speed. If the graph has no positive geographic span, the scale is +zero and A* safely uses Dijkstra's search order. + +Both algorithms use `DistanceCost`, `BinaryHeap`, `HashMap` best-cost storage, and predecessor +tracking. Graph construction is outside the timed region. A* timing includes the full heuristic +preparation scan. Before measurement, the benchmark asserts that both algorithms return the same +path and optimal cost. + +The initial measurement was collected on an Apple M3 MacBook Pro with 16 GB RAM using the +Criterion bench profile and 20 samples per case: + +| Nodes | Algorithm | Finalized nodes | Median | p95 | p99 | +| ---: | --- | ---: | ---: | ---: | ---: | +| 1,000 | Dijkstra | 1,000 | 0.121 ms | 0.123 ms | 0.140 ms | +| 1,000 | A* | 100 | 0.174 ms | 0.176 ms | 0.177 ms | +| 10,000 | Dijkstra | 10,000 | 1.242 ms | 1.262 ms | 1.297 ms | +| 10,000 | A* | 1,000 | 1.749 ms | 1.775 ms | 1.784 ms | +| 100,000 | Dijkstra | 100,000 | 17.776 ms | 18.465 ms | 18.971 ms | +| 100,000 | A* | 10,000 | 21.988 ms | 23.876 ms | 26.653 ms | + +A* finalized 90% fewer nodes in every case, demonstrating the expected search-space reduction +when geography points toward the destination. Its end-to-end median remained 44.3%, 40.8%, and +23.7% slower at 1K, 10K, and 100K nodes respectively. On this dataset, the search savings did not +offset the current graph-wide lower-bound preparation and per-node Haversine evaluation. That is a +measured optimization opportunity, not evidence that the heuristic is ineffective. + +The exact configuration and unrounded percentiles are in +[`../benchmarks/results/2026-09-04-apple-m3-astar-comparison.json`](../benchmarks/results/2026-09-04-apple-m3-astar-comparison.json). + +These synthetic results isolate search behavior; they do not establish performance on real road +networks. Memory remains unreported until a controlled allocator or profiler configuration is +available.