From ec25acf20fcb1d42dc7041478435be4cc25e4f23 Mon Sep 17 00:00:00 2001 From: Khai Date: Tue, 1 Sep 2026 22:35:54 +0100 Subject: [PATCH] feat(cost): add route cost models --- .../benches/graph_traversal.rs | 4 +- crates/roadrunner-core/src/cost/context.rs | 18 ++ crates/roadrunner-core/src/cost/error.rs | 34 ++++ crates/roadrunner-core/src/cost/mod.rs | 11 + crates/roadrunner-core/src/cost/model.rs | 93 +++++++++ crates/roadrunner-core/src/cost/route_cost.rs | 192 ++++++++++++++++++ crates/roadrunner-core/src/graph/edge.rs | 39 +++- crates/roadrunner-core/src/graph/network.rs | 34 ++-- crates/roadrunner-core/src/lib.rs | 1 + 9 files changed, 405 insertions(+), 21 deletions(-) create mode 100644 crates/roadrunner-core/src/cost/context.rs create mode 100644 crates/roadrunner-core/src/cost/error.rs create mode 100644 crates/roadrunner-core/src/cost/mod.rs create mode 100644 crates/roadrunner-core/src/cost/model.rs create mode 100644 crates/roadrunner-core/src/cost/route_cost.rs diff --git a/crates/roadrunner-core/benches/graph_traversal.rs b/crates/roadrunner-core/benches/graph_traversal.rs index 5934c41..4f33c40 100644 --- a/crates/roadrunner-core/benches/graph_traversal.rs +++ b/crates/roadrunner-core/benches/graph_traversal.rs @@ -4,7 +4,7 @@ use std::collections::{HashSet, VecDeque}; use std::hint::black_box; use criterion::{BenchmarkId, Criterion, Throughput}; -use roadrunner_core::geo::Coordinate; +use roadrunner_core::geo::{Coordinate, Meters, Seconds}; use roadrunner_core::graph::{Edge, EdgeId, Graph, Node, NodeId}; const FAN_OUT: u32 = 3; @@ -29,6 +29,8 @@ fn generated_graph(node_count: u32) -> Graph { EdgeId::new(edge_value), NodeId::new(source), NodeId::new((source + offset) % node_count), + Meters::ZERO, + Seconds::ZERO, ); let result = graph.add_edge(edge); assert!( diff --git a/crates/roadrunner-core/src/cost/context.rs b/crates/roadrunner-core/src/cost/context.rs new file mode 100644 index 0000000..74cd26e --- /dev/null +++ b/crates/roadrunner-core/src/cost/context.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +/// Immutable information available while evaluating an edge cost. +/// +/// Phase 4 cost models are static, so the context is intentionally empty. Later +/// phases can add departure time and traffic state without coupling them to graph +/// topology. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct RoutingContext {} + +impl RoutingContext { + /// Creates the deterministic Phase 4 routing context. + #[must_use] + pub const fn new() -> Self { + Self {} + } +} diff --git a/crates/roadrunner-core/src/cost/error.rs b/crates/roadrunner-core/src/cost/error.rs new file mode 100644 index 0000000..e6f1d1f --- /dev/null +++ b/crates/roadrunner-core/src/cost/error.rs @@ -0,0 +1,34 @@ +use thiserror::Error; + +use super::CostKind; + +/// Errors produced while constructing or combining route costs. +#[derive(Debug, Clone, Copy, PartialEq, Error)] +pub enum CostError { + /// A cost is NaN or infinite. + #[error("{kind} cost must be finite, got {value}")] + NotFinite { + /// The semantic kind of cost. + kind: CostKind, + /// The rejected numeric value. + value: f64, + }, + + /// A cost is less than zero. + #[error("{kind} cost must be non-negative, got {value}")] + Negative { + /// The semantic kind of cost. + kind: CostKind, + /// The rejected numeric value. + value: f64, + }, + + /// An operation attempted to mix costs with different units. + #[error("cannot combine {left} cost with {right} cost")] + MismatchedKinds { + /// The left-hand cost kind. + left: CostKind, + /// The right-hand cost kind. + right: CostKind, + }, +} diff --git a/crates/roadrunner-core/src/cost/mod.rs b/crates/roadrunner-core/src/cost/mod.rs new file mode 100644 index 0000000..f27941c --- /dev/null +++ b/crates/roadrunner-core/src/cost/mod.rs @@ -0,0 +1,11 @@ +//! Route cost values, evaluation context, and edge cost models. + +mod context; +mod error; +mod model; +mod route_cost; + +pub use context::RoutingContext; +pub use error::CostError; +pub use model::{CostModel, DistanceCost, TravelTimeCost}; +pub use route_cost::{CostKind, RouteCost}; diff --git a/crates/roadrunner-core/src/cost/model.rs b/crates/roadrunner-core/src/cost/model.rs new file mode 100644 index 0000000..59efe25 --- /dev/null +++ b/crates/roadrunner-core/src/cost/model.rs @@ -0,0 +1,93 @@ +use crate::graph::Edge; + +use super::{CostError, CostKind, RouteCost, RoutingContext}; + +/// Evaluates a directed edge under a selected routing objective. +pub trait CostModel: Send + Sync { + /// Returns the semantic kind produced by this model. + fn kind(&self) -> CostKind; + + /// Evaluates an edge in an immutable routing context. + /// + /// # Errors + /// + /// Returns [`CostError`] when the model cannot produce a valid non-negative, + /// finite cost. The Phase 4 models operate on validated edge attributes and + /// therefore always succeed. + fn edge_cost(&self, edge: &Edge, context: &RoutingContext) -> Result; +} + +/// Selects edge distance in meters as route cost. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct DistanceCost; + +impl CostModel for DistanceCost { + fn kind(&self) -> CostKind { + CostKind::Distance + } + + fn edge_cost(&self, edge: &Edge, _context: &RoutingContext) -> Result { + Ok(RouteCost::from_distance(edge.distance())) + } +} + +/// Selects edge base travel time in seconds as route cost. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TravelTimeCost; + +impl CostModel for TravelTimeCost { + fn kind(&self) -> CostKind { + CostKind::TravelTime + } + + fn edge_cost(&self, edge: &Edge, _context: &RoutingContext) -> Result { + Ok(RouteCost::from_travel_time(edge.base_travel_time())) + } +} + +#[cfg(test)] +mod tests { + use crate::geo::{Meters, Seconds}; + use crate::graph::{EdgeId, NodeId}; + + use super::*; + + fn edge() -> Edge { + let distance = Meters::new(1_250.0); + let travel_time = Seconds::new(180.0); + let (Ok(distance), Ok(travel_time)) = (distance, travel_time) else { + panic!("expected valid edge measurements"); + }; + Edge::new( + EdgeId::new(1), + NodeId::new(10), + NodeId::new(11), + distance, + travel_time, + ) + } + + #[test] + fn distance_model_selects_only_edge_distance() { + let model = DistanceCost; + let edge = edge(); + + assert_eq!(model.kind(), CostKind::Distance); + assert_eq!( + model.edge_cost(&edge, &RoutingContext::new()), + Ok(RouteCost::from_distance(edge.distance())) + ); + } + + #[test] + fn travel_time_model_selects_only_base_travel_time() { + let model = TravelTimeCost; + let edge = edge(); + + assert_eq!(model.kind(), CostKind::TravelTime); + assert_eq!( + model.edge_cost(&edge, &RoutingContext::new()), + Ok(RouteCost::from_travel_time(edge.base_travel_time())) + ); + } +} diff --git a/crates/roadrunner-core/src/cost/route_cost.rs b/crates/roadrunner-core/src/cost/route_cost.rs new file mode 100644 index 0000000..647cb2d --- /dev/null +++ b/crates/roadrunner-core/src/cost/route_cost.rs @@ -0,0 +1,192 @@ +use std::cmp::Ordering; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::geo::{Meters, Seconds}; + +use super::CostError; + +/// The semantic unit of a route cost. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CostKind { + /// Distance measured in meters. + Distance, + /// Travel time measured in seconds. + TravelTime, +} + +impl fmt::Display for CostKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Distance => formatter.write_str("distance"), + Self::TravelTime => formatter.write_str("travel-time"), + } + } +} + +/// A validated, non-negative scalar tagged with its semantic cost kind. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "RouteCostRepr", into = "RouteCostRepr")] +pub struct RouteCost { + kind: CostKind, + value: f64, +} + +impl RouteCost { + /// Creates a validated route cost. + /// + /// # Errors + /// + /// Returns [`CostError`] when `value` is negative or not finite. + pub fn new(kind: CostKind, value: f64) -> Result { + if !value.is_finite() { + return Err(CostError::NotFinite { kind, value }); + } + if value < 0.0 { + return Err(CostError::Negative { kind, value }); + } + Ok(Self { kind, value }) + } + + /// Creates a zero cost of the selected kind. + #[must_use] + pub const fn zero(kind: CostKind) -> Self { + Self { kind, value: 0.0 } + } + + /// Creates a distance cost from a validated meter value. + #[must_use] + pub const fn from_distance(distance: Meters) -> Self { + Self { + kind: CostKind::Distance, + value: distance.value(), + } + } + + /// Creates a travel-time cost from a validated second value. + #[must_use] + pub const fn from_travel_time(travel_time: Seconds) -> Self { + Self { + kind: CostKind::TravelTime, + value: travel_time.value(), + } + } + + /// Returns the cost kind. + #[must_use] + pub const fn kind(self) -> CostKind { + self.kind + } + + /// Returns the scalar value in the unit identified by [`Self::kind`]. + #[must_use] + pub const fn value(self) -> f64 { + self.value + } + + /// Adds another cost with the same semantic kind. + /// + /// # Errors + /// + /// Returns [`CostError::MismatchedKinds`] when the units differ, or + /// [`CostError::NotFinite`] when finite inputs overflow during addition. + pub fn checked_add(self, other: Self) -> Result { + if self.kind != other.kind { + return Err(CostError::MismatchedKinds { + left: self.kind, + right: other.kind, + }); + } + Self::new(self.kind, self.value + other.value) + } +} + +impl PartialOrd for RouteCost { + fn partial_cmp(&self, other: &Self) -> Option { + (self.kind == other.kind).then(|| self.value.total_cmp(&other.value)) + } +} + +#[derive(Serialize, Deserialize)] +struct RouteCostRepr { + kind: CostKind, + value: f64, +} + +impl TryFrom for RouteCost { + type Error = CostError; + + fn try_from(value: RouteCostRepr) -> Result { + Self::new(value.kind, value.value) + } +} + +impl From for RouteCostRepr { + fn from(value: RouteCost) -> Self { + Self { + kind: value.kind(), + value: value.value(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_negative_and_non_finite_costs() { + assert_eq!( + RouteCost::new(CostKind::Distance, -1.0), + Err(CostError::Negative { + kind: CostKind::Distance, + value: -1.0, + }) + ); + assert!(matches!( + RouteCost::new(CostKind::TravelTime, f64::NAN), + Err(CostError::NotFinite { + kind: CostKind::TravelTime, + .. + }) + )); + } + + #[test] + fn adds_costs_of_the_same_kind() { + let first = RouteCost::new(CostKind::Distance, 10.0); + let second = RouteCost::new(CostKind::Distance, 2.5); + let (Ok(first), Ok(second)) = (first, second) else { + panic!("expected valid test costs"); + }; + + assert_eq!( + first.checked_add(second), + RouteCost::new(CostKind::Distance, 12.5) + ); + } + + #[test] + fn rejects_mixed_kind_arithmetic_and_comparison() { + let distance = RouteCost::zero(CostKind::Distance); + let travel_time = RouteCost::zero(CostKind::TravelTime); + + assert_eq!( + distance.checked_add(travel_time), + Err(CostError::MismatchedKinds { + left: CostKind::Distance, + right: CostKind::TravelTime, + }) + ); + assert_eq!(distance.partial_cmp(&travel_time), None); + } + + #[test] + fn deserialization_preserves_validation() { + let serialized = r#"{"kind":"distance","value":-1}"#; + + assert!(serde_json::from_str::(serialized).is_err()); + } +} diff --git a/crates/roadrunner-core/src/graph/edge.rs b/crates/roadrunner-core/src/graph/edge.rs index 5008b06..d2d56ee 100644 --- a/crates/roadrunner-core/src/graph/edge.rs +++ b/crates/roadrunner-core/src/graph/edge.rs @@ -1,23 +1,40 @@ use serde::{Deserialize, Serialize}; +use crate::geo::{Meters, Seconds}; + use super::{EdgeId, NodeId}; /// A directed connection from one graph node to another. /// /// A one-way road is one edge. A bidirectional road is represented by two edges -/// with opposite endpoints. Geographic and cost attributes arrive in later phases. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// with opposite endpoints. Distance and base travel time remain independent of +/// the cost model selected by a routing request. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Edge { id: EdgeId, from: NodeId, to: NodeId, + distance: Meters, + base_travel_time: Seconds, } impl Edge { - /// Creates a directed edge between two nodes. + /// Creates a directed edge with validated distance and base travel time. #[must_use] - pub const fn new(id: EdgeId, from: NodeId, to: NodeId) -> Self { - Self { id, from, to } + pub const fn new( + id: EdgeId, + from: NodeId, + to: NodeId, + distance: Meters, + base_travel_time: Seconds, + ) -> Self { + Self { + id, + from, + to, + distance, + base_travel_time, + } } /// Returns the edge's identity. @@ -37,4 +54,16 @@ impl Edge { pub const fn to(self) -> NodeId { self.to } + + /// Returns the edge distance. + #[must_use] + pub const fn distance(self) -> Meters { + self.distance + } + + /// Returns the edge's travel time before dynamic adjustments. + #[must_use] + pub const fn base_travel_time(self) -> Seconds { + self.base_travel_time + } } diff --git a/crates/roadrunner-core/src/graph/network.rs b/crates/roadrunner-core/src/graph/network.rs index 8a27c03..fce4fb5 100644 --- a/crates/roadrunner-core/src/graph/network.rs +++ b/crates/roadrunner-core/src/graph/network.rs @@ -154,7 +154,7 @@ impl Graph { #[cfg(test)] mod tests { use super::*; - use crate::geo::Coordinate; + use crate::geo::{Coordinate, Meters, Seconds}; const A: NodeId = NodeId::new(1); const B: NodeId = NodeId::new(2); @@ -165,6 +165,10 @@ mod tests { Node::new(id, Coordinate::ORIGIN) } + fn edge(id: EdgeId, from: NodeId, to: NodeId) -> Edge { + Edge::new(id, from, to, Meters::ZERO, Seconds::ZERO) + } + fn add_nodes(graph: &mut Graph, ids: &[NodeId]) { for id in ids { assert_eq!(graph.add_node(node(*id)), Ok(())); @@ -206,7 +210,7 @@ mod tests { fn directed_edge_is_visible_only_from_its_source() { let mut graph = Graph::new(); add_nodes(&mut graph, &[A, B]); - let edge = Edge::new(EdgeId::new(10), A, B); + let edge = edge(EdgeId::new(10), A, B); assert_eq!(graph.add_edge(edge), Ok(())); @@ -220,8 +224,8 @@ mod tests { fn reciprocal_edges_model_a_bidirectional_road() { let mut graph = Graph::new(); add_nodes(&mut graph, &[A, B]); - let forward = Edge::new(EdgeId::new(10), A, B); - let reverse = Edge::new(EdgeId::new(11), B, A); + let forward = edge(EdgeId::new(10), A, B); + let reverse = edge(EdgeId::new(11), B, A); assert_eq!(graph.add_edge(forward), Ok(())); assert_eq!(graph.add_edge(reverse), Ok(())); @@ -234,9 +238,9 @@ mod tests { fn cycles_preserve_each_outgoing_adjacency() { let mut graph = Graph::new(); add_nodes(&mut graph, &[A, B, C]); - let ab = Edge::new(EdgeId::new(10), A, B); - let bc = Edge::new(EdgeId::new(11), B, C); - let ca = Edge::new(EdgeId::new(12), C, A); + let ab = edge(EdgeId::new(10), A, B); + let bc = edge(EdgeId::new(11), B, C); + let ca = edge(EdgeId::new(12), C, A); assert_eq!(graph.add_edge(ab), Ok(())); assert_eq!(graph.add_edge(bc), Ok(())); @@ -251,8 +255,8 @@ mod tests { fn disconnected_components_remain_independent() { let mut graph = Graph::new(); add_nodes(&mut graph, &[A, B, C, D]); - let ab = Edge::new(EdgeId::new(10), A, B); - let cd = Edge::new(EdgeId::new(11), C, D); + let ab = edge(EdgeId::new(10), A, B); + let cd = edge(EdgeId::new(11), C, D); assert_eq!(graph.add_edge(ab), Ok(())); assert_eq!(graph.add_edge(cd), Ok(())); @@ -267,8 +271,8 @@ mod tests { fn edge_insertion_rejects_missing_endpoints_without_mutation() { let mut graph = Graph::new(); assert_eq!(graph.add_node(node(A)), Ok(())); - let missing_source = Edge::new(EdgeId::new(10), B, A); - let missing_destination = Edge::new(EdgeId::new(11), A, B); + let missing_source = edge(EdgeId::new(10), B, A); + let missing_destination = edge(EdgeId::new(11), A, B); assert_eq!( graph.add_edge(missing_source), @@ -292,8 +296,8 @@ mod tests { fn duplicate_identities_are_rejected_but_parallel_edges_are_allowed() { let mut graph = Graph::new(); add_nodes(&mut graph, &[A, B]); - let first = Edge::new(EdgeId::new(10), A, B); - let parallel = Edge::new(EdgeId::new(11), A, B); + let first = edge(EdgeId::new(10), A, B); + let parallel = edge(EdgeId::new(11), A, B); assert_eq!( graph.add_node(node(A)), @@ -301,7 +305,7 @@ mod tests { ); assert_eq!(graph.add_edge(first), Ok(())); assert_eq!( - graph.add_edge(Edge::new(first.id(), B, A)), + graph.add_edge(edge(first.id(), B, A)), Err(GraphError::DuplicateEdge { id: first.id() }) ); assert_eq!(graph.add_edge(parallel), Ok(())); @@ -313,7 +317,7 @@ mod tests { fn removing_an_edge_updates_lookup_adjacency_and_count() { let mut graph = Graph::new(); add_nodes(&mut graph, &[A, B]); - let edge = Edge::new(EdgeId::new(10), A, B); + let edge = edge(EdgeId::new(10), A, B); assert_eq!(graph.add_edge(edge), Ok(())); assert_eq!(graph.remove_edge(edge.id()), Ok(edge)); diff --git a/crates/roadrunner-core/src/lib.rs b/crates/roadrunner-core/src/lib.rs index 19f095b..6ee5cae 100644 --- a/crates/roadrunner-core/src/lib.rs +++ b/crates/roadrunner-core/src/lib.rs @@ -4,6 +4,7 @@ //! dedicated implementation phases. This crate currently establishes the stable //! library boundary shared by Roadrunner's adapters. +pub mod cost; pub mod geo; pub mod graph;