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