Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/roadrunner-core/benches/graph_traversal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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!(
Expand Down
18 changes: 18 additions & 0 deletions crates/roadrunner-core/src/cost/context.rs
Original file line number Diff line number Diff line change
@@ -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 {}
}
}
34 changes: 34 additions & 0 deletions crates/roadrunner-core/src/cost/error.rs
Original file line number Diff line number Diff line change
@@ -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,
},
}
11 changes: 11 additions & 0 deletions crates/roadrunner-core/src/cost/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
93 changes: 93 additions & 0 deletions crates/roadrunner-core/src/cost/model.rs
Original file line number Diff line number Diff line change
@@ -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<RouteCost, CostError>;
}

/// 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<RouteCost, CostError> {
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<RouteCost, CostError> {
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()))
);
}
}
192 changes: 192 additions & 0 deletions crates/roadrunner-core/src/cost/route_cost.rs
Original file line number Diff line number Diff line change
@@ -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<Self, CostError> {
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<Self, CostError> {
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<Ordering> {
(self.kind == other.kind).then(|| self.value.total_cmp(&other.value))
}
}

#[derive(Serialize, Deserialize)]
struct RouteCostRepr {
kind: CostKind,
value: f64,
}

impl TryFrom<RouteCostRepr> for RouteCost {
type Error = CostError;

fn try_from(value: RouteCostRepr) -> Result<Self, Self::Error> {
Self::new(value.kind, value.value)
}
}

impl From<RouteCost> 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::<RouteCost>(serialized).is_err());
}
}
Loading
Loading