Skip to main content

canwu_routing/
lib.rs

1//! Deterministic routing over a versioned, time-dependent transfer network.
2//!
3//! This crate is deliberately pure: it does not read simulation state, mutate
4//! capacity, draw randomness, schedule work, or resolve information recipients.
5
6#![allow(clippy::missing_errors_doc)]
7
8use canwu_time::{SimDuration, SimTime};
9use serde::{Deserialize, Serialize};
10use std::cmp::Ordering;
11use std::collections::{BTreeMap, BTreeSet, BinaryHeap, VecDeque};
12use std::fmt;
13
14pub const ROUTING_ALGORITHM_VERSION: &str = "canwu-routing.v1";
15
16#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
17#[serde(transparent)]
18pub struct RoutingNodeRef(String);
19
20impl RoutingNodeRef {
21    #[must_use]
22    pub fn new(value: impl Into<String>) -> Self {
23        Self(value.into())
24    }
25
26    #[must_use]
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
33#[serde(transparent)]
34pub struct RoutingConnectionRef(String);
35
36impl RoutingConnectionRef {
37    #[must_use]
38    pub fn new(value: impl Into<String>) -> Self {
39        Self(value.into())
40    }
41
42    #[must_use]
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46}
47
48#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
49#[serde(rename_all = "snake_case")]
50pub enum RoutingEndpointKind {
51    Settlement,
52    RelayStation,
53    RailwayStation,
54    Port,
55    Airport,
56    TelegraphOffice,
57    DeliveryDistrict,
58    MilitaryPosition,
59}
60
61#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
62#[serde(rename_all = "snake_case")]
63pub enum TransferMode {
64    Foot,
65    Horse,
66    RoadVehicle,
67    RiverBoat,
68    Sea,
69    Rail,
70    Air,
71    Signal,
72}
73
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75pub struct RoutingEndpoint {
76    pub id: RoutingNodeRef,
77    pub kind: RoutingEndpointKind,
78}
79
80#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81pub struct DepartureSlot {
82    pub departure_at: SimTime,
83    pub duration: SimDuration,
84}
85
86#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
87pub struct DurationSample {
88    pub from: SimTime,
89    pub duration: SimDuration,
90}
91
92#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94pub enum TraversalModel {
95    Fixed { duration: SimDuration },
96    Departures { slots: Vec<DepartureSlot> },
97    Piecewise { samples: Vec<DurationSample> },
98}
99
100impl TraversalModel {
101    fn validate(&self) -> Result<(), RoutingError> {
102        match self {
103            Self::Fixed { duration } if duration.is_negative() => Err(
104                RoutingError::InvalidNetwork("negative traversal duration".to_owned()),
105            ),
106            Self::Fixed { .. } => Ok(()),
107            Self::Departures { slots } => {
108                if slots
109                    .windows(2)
110                    .any(|pair| pair[0].departure_at >= pair[1].departure_at)
111                    || slots.iter().any(|slot| slot.duration.is_negative())
112                {
113                    return Err(RoutingError::InvalidNetwork(
114                        "departure slots must be sorted and non-negative".to_owned(),
115                    ));
116                }
117                Ok(())
118            }
119            Self::Piecewise { samples } => {
120                if samples.windows(2).any(|pair| pair[0].from >= pair[1].from)
121                    || samples.iter().any(|sample| sample.duration.is_negative())
122                {
123                    return Err(RoutingError::InvalidNetwork(
124                        "duration samples must be sorted and non-negative".to_owned(),
125                    ));
126                }
127                Ok(())
128            }
129        }
130    }
131
132    fn traverse_after(&self, at: SimTime) -> Option<(SimTime, SimTime)> {
133        match self {
134            Self::Fixed { duration } => Some((at, at.checked_add(*duration)?)),
135            Self::Departures { slots } => slots.iter().find_map(|slot| {
136                (slot.departure_at >= at).then(|| {
137                    Some((
138                        slot.departure_at,
139                        slot.departure_at.checked_add(slot.duration)?,
140                    ))
141                })?
142            }),
143            Self::Piecewise { samples } => samples
144                .iter()
145                .rev()
146                .find(|sample| sample.from <= at)
147                .and_then(|sample| Some((at, at.checked_add(sample.duration)?))),
148        }
149    }
150}
151
152#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
153pub struct RoutingConnection {
154    pub id: RoutingConnectionRef,
155    pub from: RoutingNodeRef,
156    pub to: RoutingNodeRef,
157    pub mode: TransferMode,
158    pub traversal: TraversalModel,
159    pub available_from: Option<SimTime>,
160    pub available_until: Option<SimTime>,
161    pub risk_per_mille: u32,
162    pub resource_cost: u64,
163}
164
165impl RoutingConnection {
166    fn validate(&self, endpoints: &BTreeSet<RoutingNodeRef>) -> Result<(), RoutingError> {
167        if !endpoints.contains(&self.from) || !endpoints.contains(&self.to) || self.from == self.to
168        {
169            return Err(RoutingError::InvalidNetwork(format!(
170                "connection {} has invalid endpoints",
171                self.id.0
172            )));
173        }
174        if self.risk_per_mille > 1_000 {
175            return Err(RoutingError::InvalidNetwork(format!(
176                "connection {} risk exceeds 1000 per mille",
177                self.id.0
178            )));
179        }
180        if let (Some(start), Some(end)) = (self.available_from, self.available_until)
181            && start > end
182        {
183            return Err(RoutingError::InvalidNetwork(format!(
184                "connection {} availability is inverted",
185                self.id.0
186            )));
187        }
188        self.traversal.validate()
189    }
190
191    fn traverse_after(&self, at: SimTime) -> Option<(SimTime, SimTime)> {
192        let at = self.available_from.map_or(at, |start| at.max(start));
193        if self.available_until.is_some_and(|end| at > end) {
194            return None;
195        }
196        let result = self.traversal.traverse_after(at)?;
197        self.available_until
198            .is_none_or(|end| result.1 <= end)
199            .then_some(result)
200    }
201}
202
203#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
204pub struct RoutingNetwork {
205    pub version: String,
206    pub endpoints: Vec<RoutingEndpoint>,
207    pub connections: Vec<RoutingConnection>,
208}
209
210impl RoutingNetwork {
211    pub fn new(
212        version: impl Into<String>,
213        mut endpoints: Vec<RoutingEndpoint>,
214        mut connections: Vec<RoutingConnection>,
215    ) -> Result<Self, RoutingError> {
216        endpoints.sort_by(|left, right| left.id.cmp(&right.id));
217        connections.sort_by(|left, right| left.id.cmp(&right.id));
218        let endpoint_ids = endpoints
219            .iter()
220            .map(|endpoint| endpoint.id.clone())
221            .collect::<Vec<_>>();
222        if endpoint_ids.windows(2).any(|pair| pair[0] == pair[1]) {
223            return Err(RoutingError::InvalidNetwork(
224                "duplicate endpoint".to_owned(),
225            ));
226        }
227        let endpoint_set = endpoint_ids.iter().cloned().collect::<BTreeSet<_>>();
228        if connections.windows(2).any(|pair| pair[0].id == pair[1].id) {
229            return Err(RoutingError::InvalidNetwork(
230                "duplicate connection".to_owned(),
231            ));
232        }
233        for connection in &connections {
234            connection.validate(&endpoint_set)?;
235        }
236        Ok(Self {
237            version: version.into(),
238            endpoints,
239            connections,
240        })
241    }
242}
243
244#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
245pub struct PlanningSnapshot {
246    pub observer: String,
247    pub observed_at: SimTime,
248    pub valid_until: Option<SimTime>,
249    pub knowledge_cut: String,
250    pub topology_version: String,
251    pub timetable_version: Option<String>,
252    pub network: RoutingNetwork,
253}
254
255impl PlanningSnapshot {
256    pub fn validate(&self) -> Result<(), RoutingError> {
257        if self
258            .valid_until
259            .is_some_and(|until| until < self.observed_at)
260        {
261            return Err(RoutingError::InvalidSnapshot(
262                "planning snapshot expires before it is observed".to_owned(),
263            ));
264        }
265        if self.topology_version != self.network.version {
266            return Err(RoutingError::InvalidSnapshot(
267                "topology version does not match network version".to_owned(),
268            ));
269        }
270        Ok(())
271    }
272
273    #[must_use]
274    pub fn digest(&self) -> String {
275        canonical_digest(self)
276    }
277}
278
279#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
280#[serde(rename_all = "snake_case")]
281pub enum RoutingAlgorithm {
282    FifoDijkstraV1,
283    BoundedLabelCorrectingV1,
284}
285
286#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
287pub struct RoutingPolicy {
288    pub version: String,
289    pub algorithm: RoutingAlgorithm,
290    pub allowed_modes: BTreeSet<TransferMode>,
291    pub max_arrival_at: Option<SimTime>,
292    pub max_expanded_nodes: usize,
293    pub max_transfers: usize,
294    pub max_risk_per_mille: u64,
295}
296
297impl Default for RoutingPolicy {
298    fn default() -> Self {
299        Self {
300            version: "canwu-routing.policy.v1".to_owned(),
301            algorithm: RoutingAlgorithm::FifoDijkstraV1,
302            allowed_modes: BTreeSet::new(),
303            max_arrival_at: None,
304            max_expanded_nodes: 10_000,
305            max_transfers: 64,
306            max_risk_per_mille: 1_000,
307        }
308    }
309}
310
311impl RoutingPolicy {
312    fn allows(&self, mode: TransferMode) -> bool {
313        self.allowed_modes.is_empty() || self.allowed_modes.contains(&mode)
314    }
315}
316
317#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
318pub struct RoutingRequest {
319    pub origin: RoutingNodeRef,
320    pub destination: RoutingNodeRef,
321    pub departure_at: SimTime,
322    pub policy: RoutingPolicy,
323}
324
325#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
326pub struct RouteLeg {
327    pub connection: RoutingConnectionRef,
328    pub from: RoutingNodeRef,
329    pub to: RoutingNodeRef,
330    pub mode: TransferMode,
331    pub planned_departure_at: SimTime,
332    pub planned_arrival_at: SimTime,
333}
334
335#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
336pub struct RouteCost {
337    pub estimated_arrival_at: SimTime,
338    pub risk_per_mille: u64,
339    pub resource_cost: u64,
340    pub transfers: usize,
341}
342
343#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
344pub struct RoutePlan {
345    pub algorithm_version: String,
346    pub policy_version: String,
347    pub planning_snapshot_digest: String,
348    pub origin: RoutingNodeRef,
349    pub destination: RoutingNodeRef,
350    pub departure_at: SimTime,
351    pub estimated_arrival_at: SimTime,
352    pub cost: RouteCost,
353    pub legs: Vec<RouteLeg>,
354    pub digest: String,
355}
356
357#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
358pub enum RoutingError {
359    InvalidNetwork(String),
360    InvalidSnapshot(String),
361    UnknownOrigin,
362    UnknownDestination,
363    NoKnownRoute,
364    SearchHorizonExceeded,
365    ExpansionBudgetExceeded,
366    RequirementsUnsatisfied,
367    ArithmeticOverflow,
368}
369
370impl fmt::Display for RoutingError {
371    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
372        write!(formatter, "{self:?}")
373    }
374}
375
376impl std::error::Error for RoutingError {}
377
378#[derive(Clone, Debug, Eq, PartialEq)]
379struct Label {
380    node: RoutingNodeRef,
381    arrival_at: SimTime,
382    risk_per_mille: u64,
383    resource_cost: u64,
384    transfers: usize,
385    legs: Vec<RouteLeg>,
386}
387
388impl Label {
389    fn key(&self) -> (&SimTime, u64, u64, usize, &Vec<RouteLeg>) {
390        (
391            &self.arrival_at,
392            self.risk_per_mille,
393            self.resource_cost,
394            self.transfers,
395            &self.legs,
396        )
397    }
398}
399
400#[derive(Clone, Debug, Eq, PartialEq)]
401struct QueueEntry {
402    node: RoutingNodeRef,
403    arrival_at: SimTime,
404    risk_per_mille: u64,
405    resource_cost: u64,
406    transfers: usize,
407    legs: Vec<RouteLeg>,
408}
409
410impl QueueEntry {
411    fn from_label(label: &Label) -> Self {
412        Self {
413            node: label.node.clone(),
414            arrival_at: label.arrival_at,
415            risk_per_mille: label.risk_per_mille,
416            resource_cost: label.resource_cost,
417            transfers: label.transfers,
418            legs: label.legs.clone(),
419        }
420    }
421
422    fn key(&self) -> (&SimTime, u64, u64, usize, &RoutingNodeRef, &Vec<RouteLeg>) {
423        (
424            &self.arrival_at,
425            self.risk_per_mille,
426            self.resource_cost,
427            self.transfers,
428            &self.node,
429            &self.legs,
430        )
431    }
432}
433
434impl Ord for QueueEntry {
435    fn cmp(&self, other: &Self) -> Ordering {
436        other.key().cmp(&self.key())
437    }
438}
439
440impl PartialOrd for QueueEntry {
441    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
442        Some(self.cmp(other))
443    }
444}
445
446fn better(left: &Label, right: &Label) -> bool {
447    left.key() < right.key()
448}
449
450fn outgoing<'a>(
451    network: &'a RoutingNetwork,
452    node: &RoutingNodeRef,
453) -> impl Iterator<Item = &'a RoutingConnection> {
454    network
455        .connections
456        .iter()
457        .filter(move |connection| &connection.from == node)
458}
459
460fn extend(label: &Label, connection: &RoutingConnection, policy: &RoutingPolicy) -> Option<Label> {
461    if !policy.allows(connection.mode) || label.transfers >= policy.max_transfers {
462        return None;
463    }
464    let (departure_at, arrival_at) = connection.traverse_after(label.arrival_at)?;
465    let risk_per_mille = label
466        .risk_per_mille
467        .checked_add(u64::from(connection.risk_per_mille))?;
468    let resource_cost = label.resource_cost.checked_add(connection.resource_cost)?;
469    let transfers = label.transfers + 1;
470    if risk_per_mille > policy.max_risk_per_mille
471        || policy
472            .max_arrival_at
473            .is_some_and(|limit| arrival_at > limit)
474    {
475        return None;
476    }
477    let mut legs = label.legs.clone();
478    legs.push(RouteLeg {
479        connection: connection.id.clone(),
480        from: connection.from.clone(),
481        to: connection.to.clone(),
482        mode: connection.mode,
483        planned_departure_at: departure_at,
484        planned_arrival_at: arrival_at,
485    });
486    Some(Label {
487        node: connection.to.clone(),
488        arrival_at,
489        risk_per_mille,
490        resource_cost,
491        transfers,
492        legs,
493    })
494}
495
496fn solve_dijkstra(
497    snapshot: &PlanningSnapshot,
498    request: &RoutingRequest,
499) -> Result<Label, RoutingError> {
500    let mut labels = BTreeMap::<RoutingNodeRef, Label>::new();
501    let start = Label {
502        node: request.origin.clone(),
503        arrival_at: request.departure_at,
504        risk_per_mille: 0,
505        resource_cost: 0,
506        transfers: 0,
507        legs: Vec::new(),
508    };
509    labels.insert(request.origin.clone(), start.clone());
510    let mut queue = BinaryHeap::from([QueueEntry::from_label(&start)]);
511    let mut expanded = 0;
512    while let Some(entry) = queue.pop() {
513        expanded += 1;
514        if expanded > request.policy.max_expanded_nodes {
515            return Err(RoutingError::ExpansionBudgetExceeded);
516        }
517        let Some(current) = labels.get(&entry.node).cloned() else {
518            continue;
519        };
520        if current.arrival_at != entry.arrival_at
521            || current.risk_per_mille != entry.risk_per_mille
522            || current.resource_cost != entry.resource_cost
523            || current.transfers != entry.transfers
524            || current.legs != entry.legs
525        {
526            // The label may have been improved since this queue entry was added.
527            continue;
528        }
529        if entry.node == request.destination {
530            return Ok(current);
531        }
532        for connection in outgoing(&snapshot.network, &entry.node) {
533            let Some(candidate) = extend(&current, connection, &request.policy) else {
534                continue;
535            };
536            let replace = labels
537                .get(&candidate.node)
538                .is_none_or(|existing| better(&candidate, existing));
539            if replace {
540                queue.push(QueueEntry::from_label(&candidate));
541                labels.insert(candidate.node.clone(), candidate);
542            }
543        }
544    }
545    Err(RoutingError::NoKnownRoute)
546}
547
548fn solve_label_correcting(
549    snapshot: &PlanningSnapshot,
550    request: &RoutingRequest,
551) -> Result<Label, RoutingError> {
552    let mut labels = BTreeMap::<RoutingNodeRef, Vec<Label>>::new();
553    let start = Label {
554        node: request.origin.clone(),
555        arrival_at: request.departure_at,
556        risk_per_mille: 0,
557        resource_cost: 0,
558        transfers: 0,
559        legs: Vec::new(),
560    };
561    labels.insert(request.origin.clone(), vec![start.clone()]);
562    let mut queue = VecDeque::from([start]);
563    let mut expanded = 0;
564    while let Some(current) = queue.pop_front() {
565        expanded += 1;
566        if expanded > request.policy.max_expanded_nodes {
567            return Err(RoutingError::ExpansionBudgetExceeded);
568        }
569        for connection in outgoing(&snapshot.network, &current.node) {
570            let Some(candidate) = extend(&current, connection, &request.policy) else {
571                continue;
572            };
573            let node_labels = labels.entry(candidate.node.clone()).or_default();
574            if node_labels.iter().any(|existing| existing == &candidate) {
575                continue;
576            }
577            node_labels.push(candidate.clone());
578            queue.push_back(candidate);
579        }
580    }
581    labels
582        .remove(&request.destination)
583        .and_then(|candidates| {
584            candidates
585                .into_iter()
586                .min_by(|left, right| left.key().cmp(&right.key()))
587        })
588        .ok_or(RoutingError::NoKnownRoute)
589}
590
591pub fn plan_route(
592    snapshot: &PlanningSnapshot,
593    request: &RoutingRequest,
594) -> Result<RoutePlan, RoutingError> {
595    snapshot.validate()?;
596    if !snapshot
597        .network
598        .endpoints
599        .iter()
600        .any(|endpoint| endpoint.id == request.origin)
601    {
602        return Err(RoutingError::UnknownOrigin);
603    }
604    if !snapshot
605        .network
606        .endpoints
607        .iter()
608        .any(|endpoint| endpoint.id == request.destination)
609    {
610        return Err(RoutingError::UnknownDestination);
611    }
612    if snapshot
613        .valid_until
614        .is_some_and(|until| request.departure_at > until)
615    {
616        return Err(RoutingError::SearchHorizonExceeded);
617    }
618    if request.origin == request.destination {
619        let mut plan = RoutePlan {
620            algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
621            policy_version: request.policy.version.clone(),
622            planning_snapshot_digest: snapshot.digest(),
623            origin: request.origin.clone(),
624            destination: request.destination.clone(),
625            departure_at: request.departure_at,
626            estimated_arrival_at: request.departure_at,
627            cost: RouteCost {
628                estimated_arrival_at: request.departure_at,
629                ..RouteCost::default()
630            },
631            legs: Vec::new(),
632            digest: String::new(),
633        };
634        plan.digest = canonical_digest(&plan_without_digest(&plan));
635        return Ok(plan);
636    }
637    let label = match request.policy.algorithm {
638        RoutingAlgorithm::FifoDijkstraV1 => solve_dijkstra(snapshot, request)?,
639        RoutingAlgorithm::BoundedLabelCorrectingV1 => solve_label_correcting(snapshot, request)?,
640    };
641    let mut plan = RoutePlan {
642        algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
643        policy_version: request.policy.version.clone(),
644        planning_snapshot_digest: snapshot.digest(),
645        origin: request.origin.clone(),
646        destination: request.destination.clone(),
647        departure_at: request.departure_at,
648        estimated_arrival_at: label.arrival_at,
649        cost: RouteCost {
650            estimated_arrival_at: label.arrival_at,
651            risk_per_mille: label.risk_per_mille,
652            resource_cost: label.resource_cost,
653            transfers: label.transfers,
654        },
655        legs: label.legs,
656        digest: String::new(),
657    };
658    plan.digest = canonical_digest(&plan_without_digest(&plan));
659    Ok(plan)
660}
661
662fn plan_without_digest(
663    plan: &RoutePlan,
664) -> (
665    &str,
666    &str,
667    &str,
668    &RoutingNodeRef,
669    &RoutingNodeRef,
670    SimTime,
671    SimTime,
672    &RouteCost,
673    &Vec<RouteLeg>,
674) {
675    (
676        &plan.algorithm_version,
677        &plan.policy_version,
678        &plan.planning_snapshot_digest,
679        &plan.origin,
680        &plan.destination,
681        plan.departure_at,
682        plan.estimated_arrival_at,
683        &plan.cost,
684        &plan.legs,
685    )
686}
687
688fn canonical_digest<T: Serialize>(value: &T) -> String {
689    let bytes = serde_json::to_vec(value).expect("routing types must be serializable");
690    blake3::hash(&bytes).to_hex().to_string()
691}
692
693#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
694pub struct RoutingCache {
695    entries: BTreeMap<String, RoutePlan>,
696}
697
698impl RoutingCache {
699    #[must_use]
700    pub fn key(snapshot: &PlanningSnapshot, request: &RoutingRequest) -> String {
701        canonical_digest(&(snapshot.digest(), request))
702    }
703
704    #[must_use]
705    pub fn get(&self, key: &str) -> Option<&RoutePlan> {
706        self.entries.get(key)
707    }
708
709    pub fn insert(&mut self, key: String, plan: RoutePlan) {
710        self.entries.insert(key, plan);
711    }
712
713    pub fn clear(&mut self) {
714        self.entries.clear();
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    fn endpoint(id: &str) -> RoutingEndpoint {
723        RoutingEndpoint {
724            id: RoutingNodeRef::new(id),
725            kind: RoutingEndpointKind::Settlement,
726        }
727    }
728
729    fn connection(id: &str, from: &str, to: &str, minutes: i64) -> RoutingConnection {
730        RoutingConnection {
731            id: RoutingConnectionRef::new(id),
732            from: RoutingNodeRef::new(from),
733            to: RoutingNodeRef::new(to),
734            mode: TransferMode::Horse,
735            traversal: TraversalModel::Fixed {
736                duration: SimDuration::minutes(minutes),
737            },
738            available_from: None,
739            available_until: None,
740            risk_per_mille: 0,
741            resource_cost: 0,
742        }
743    }
744
745    fn snapshot(connections: Vec<RoutingConnection>) -> PlanningSnapshot {
746        snapshot_with_endpoints(connections, ["a", "b", "c"])
747    }
748
749    fn snapshot_with_endpoints<const N: usize>(
750        connections: Vec<RoutingConnection>,
751        endpoint_ids: [&str; N],
752    ) -> PlanningSnapshot {
753        let network = RoutingNetwork::new(
754            "roads.v1",
755            endpoint_ids.into_iter().map(endpoint).collect(),
756            connections,
757        )
758        .unwrap();
759        PlanningSnapshot {
760            observer: "courier".to_owned(),
761            observed_at: SimTime::EPOCH,
762            valid_until: None,
763            knowledge_cut: "knowledge:1".to_owned(),
764            topology_version: "roads.v1".to_owned(),
765            timetable_version: None,
766            network,
767        }
768    }
769
770    #[test]
771    fn chooses_deterministic_earliest_arrival_across_multiple_legs() {
772        let snapshot = snapshot(vec![
773            connection("a-c", "a", "c", 90),
774            connection("a-b", "a", "b", 30),
775            connection("b-c", "b", "c", 30),
776        ]);
777        let plan = plan_route(
778            &snapshot,
779            &RoutingRequest {
780                origin: RoutingNodeRef::new("a"),
781                destination: RoutingNodeRef::new("c"),
782                departure_at: SimTime::EPOCH,
783                policy: RoutingPolicy::default(),
784            },
785        )
786        .unwrap();
787        assert_eq!(plan.estimated_arrival_at, SimTime::from_minutes(60));
788        assert_eq!(
789            plan.legs
790                .iter()
791                .map(|leg| leg.connection.clone())
792                .collect::<Vec<_>>(),
793            vec![
794                RoutingConnectionRef::new("a-b"),
795                RoutingConnectionRef::new("b-c")
796            ]
797        );
798    }
799
800    #[test]
801    fn scheduled_connections_wait_for_the_next_departure() {
802        let mut rail = connection("rail", "a", "c", 1);
803        rail.mode = TransferMode::Rail;
804        rail.traversal = TraversalModel::Departures {
805            slots: vec![DepartureSlot {
806                departure_at: SimTime::from_minutes(60),
807                duration: SimDuration::minutes(20),
808            }],
809        };
810        let snapshot = snapshot(vec![rail]);
811        let plan = plan_route(
812            &snapshot,
813            &RoutingRequest {
814                origin: RoutingNodeRef::new("a"),
815                destination: RoutingNodeRef::new("c"),
816                departure_at: SimTime::from_minutes(10),
817                policy: RoutingPolicy::default(),
818            },
819        )
820        .unwrap();
821        assert_eq!(plan.estimated_arrival_at, SimTime::from_minutes(80));
822    }
823
824    #[test]
825    fn label_correcting_handles_piecewise_duration_changes() {
826        let mut edge = connection("edge", "a", "c", 10);
827        edge.traversal = TraversalModel::Piecewise {
828            samples: vec![
829                DurationSample {
830                    from: SimTime::EPOCH,
831                    duration: SimDuration::minutes(100),
832                },
833                DurationSample {
834                    from: SimTime::from_minutes(10),
835                    duration: SimDuration::minutes(5),
836                },
837            ],
838        };
839        let snapshot = snapshot(vec![edge]);
840        let policy = RoutingPolicy {
841            algorithm: RoutingAlgorithm::BoundedLabelCorrectingV1,
842            ..RoutingPolicy::default()
843        };
844        let plan = plan_route(
845            &snapshot,
846            &RoutingRequest {
847                origin: RoutingNodeRef::new("a"),
848                destination: RoutingNodeRef::new("c"),
849                departure_at: SimTime::from_minutes(10),
850                policy,
851            },
852        )
853        .unwrap();
854        assert_eq!(plan.estimated_arrival_at, SimTime::from_minutes(15));
855    }
856
857    #[test]
858    fn label_correcting_keeps_a_later_label_for_a_faster_non_fifo_departure() {
859        let mut final_leg = connection("b-d", "b", "d", 100);
860        final_leg.traversal = TraversalModel::Piecewise {
861            samples: vec![
862                DurationSample {
863                    from: SimTime::EPOCH,
864                    duration: SimDuration::minutes(100),
865                },
866                DurationSample {
867                    from: SimTime::from_minutes(10),
868                    duration: SimDuration::minutes(1),
869                },
870            ],
871        };
872        let snapshot = snapshot_with_endpoints(
873            vec![
874                connection("a-b", "a", "b", 5),
875                connection("a-c", "a", "c", 10),
876                connection("c-b", "c", "b", 0),
877                final_leg,
878            ],
879            ["a", "b", "c", "d"],
880        );
881        let policy = RoutingPolicy {
882            algorithm: RoutingAlgorithm::BoundedLabelCorrectingV1,
883            ..RoutingPolicy::default()
884        };
885        let plan = plan_route(
886            &snapshot,
887            &RoutingRequest {
888                origin: RoutingNodeRef::new("a"),
889                destination: RoutingNodeRef::new("d"),
890                departure_at: SimTime::EPOCH,
891                policy,
892            },
893        )
894        .unwrap();
895        assert_eq!(plan.estimated_arrival_at, SimTime::from_minutes(11));
896        assert_eq!(plan.legs[1].connection, RoutingConnectionRef::new("c-b"));
897    }
898
899    #[test]
900    fn cache_key_changes_with_snapshot_or_policy() {
901        let snapshot = snapshot(vec![connection("a-c", "a", "c", 10)]);
902        let request = RoutingRequest {
903            origin: RoutingNodeRef::new("a"),
904            destination: RoutingNodeRef::new("c"),
905            departure_at: SimTime::EPOCH,
906            policy: RoutingPolicy::default(),
907        };
908        let first = RoutingCache::key(&snapshot, &request);
909        let mut changed = request.clone();
910        changed.policy.version = "policy.v2".to_owned();
911        assert_ne!(first, RoutingCache::key(&snapshot, &changed));
912    }
913}