1#![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 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(¤t, 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, ¤t.node) {
619 let Some(candidate) = extend(¤t, 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}