Skip to main content

trailgen_core/
optimizer.rs

1use crate::constraints::LoopConstraints;
2use crate::model::{EdgeId, EdgeTravel, VertexId, WalkGraph};
3use crate::route::{Route, rank_routes};
4use crate::trail::RoutingLaw;
5use crate::{Coord, RouteShape};
6use serde::{Deserialize, Serialize};
7use std::cmp::Ordering;
8use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum SearchStage {
12    Preparing,
13    Exploring,
14    Ranking,
15}
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub struct SearchProgress {
19    pub stage: SearchStage,
20    pub explored: usize,
21    pub limit: usize,
22    pub candidates: usize,
23}
24
25pub trait SearchMonitor {
26    fn cancelled(&self) -> bool;
27    fn report(&self, progress: SearchProgress);
28    fn preview(&self, _routes: &[Route]) {}
29}
30
31impl SearchMonitor for () {
32    fn cancelled(&self) -> bool {
33        false
34    }
35
36    fn report(&self, _progress: SearchProgress) {}
37}
38
39#[derive(Clone, Copy)]
40pub struct SearchScope<'a> {
41    graph: &'a WalkGraph,
42    allowed: Option<&'a [bool]>,
43    adjacency: Option<&'a [Vec<EdgeId>]>,
44    edicts: Option<&'a EdgeEdicts>,
45    edge_count: usize,
46}
47
48impl<'a> SearchScope<'a> {
49    #[must_use]
50    pub const fn all(graph: &'a WalkGraph) -> Self {
51        Self {
52            graph,
53            allowed: None,
54            adjacency: None,
55            edicts: None,
56            edge_count: graph.edges.len(),
57        }
58    }
59
60    #[must_use]
61    pub fn restricted(graph: &'a WalkGraph, allowed: &'a [bool]) -> Self {
62        assert_eq!(
63            allowed.len(),
64            graph.edges.len(),
65            "search mask must cover every graph edge"
66        );
67        Self {
68            graph,
69            allowed: Some(allowed),
70            adjacency: None,
71            edicts: None,
72            edge_count: allowed.iter().filter(|allowed| **allowed).count(),
73        }
74    }
75
76    #[must_use]
77    pub fn projected(
78        graph: &'a WalkGraph,
79        allowed: &'a [bool],
80        adjacency: &'a [Vec<EdgeId>],
81    ) -> Self {
82        assert_eq!(allowed.len(), graph.edges.len());
83        assert_eq!(adjacency.len(), graph.vertices.len());
84        Self {
85            graph,
86            allowed: Some(allowed),
87            adjacency: Some(adjacency),
88            edicts: None,
89            edge_count: allowed.iter().filter(|allowed| **allowed).count(),
90        }
91    }
92
93    #[must_use]
94    pub fn obeying(mut self, edicts: &'a EdgeEdicts) -> Self {
95        self.edicts = Some(edicts);
96        self.edge_count = self
97            .graph
98            .edges
99            .iter()
100            .filter(|edge| self.allows(edge.id))
101            .count();
102        self
103    }
104
105    fn fanout(self, vertex: VertexId) -> Vec<EdgeId> {
106        self.adjacency.unwrap_or(&self.graph.adjacency)[vertex.0]
107            .iter()
108            .copied()
109            .filter(|edge| self.allows(*edge))
110            .collect()
111    }
112
113    fn allows(self, edge: EdgeId) -> bool {
114        self.allowed.is_none_or(|allowed| allowed[edge.0])
115            && self
116                .edicts
117                .is_none_or(|edicts| !edicts.forbidden.contains(&edge))
118    }
119}
120
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum EdgeDisposition {
123    Free,
124    Required,
125    Forbidden,
126}
127
128#[derive(Clone, Debug, Default, Eq, PartialEq)]
129pub struct EdgeEdicts {
130    required: BTreeSet<EdgeId>,
131    forbidden: BTreeSet<EdgeId>,
132}
133
134impl EdgeEdicts {
135    #[must_use]
136    pub fn disposition(&self, edge: EdgeId) -> EdgeDisposition {
137        if self.required.contains(&edge) {
138            EdgeDisposition::Required
139        } else if self.forbidden.contains(&edge) {
140            EdgeDisposition::Forbidden
141        } else {
142            EdgeDisposition::Free
143        }
144    }
145
146    pub fn toggle_required(&mut self, edge: EdgeId) {
147        if !self.required.remove(&edge) {
148            self.forbidden.remove(&edge);
149            self.required.insert(edge);
150        }
151    }
152
153    pub fn toggle_forbidden(&mut self, edge: EdgeId) {
154        if !self.forbidden.remove(&edge) {
155            self.required.remove(&edge);
156            self.forbidden.insert(edge);
157        }
158    }
159
160    pub fn clear(&mut self) {
161        self.required.clear();
162        self.forbidden.clear();
163    }
164
165    #[must_use]
166    pub fn is_empty(&self) -> bool {
167        self.required.is_empty() && self.forbidden.is_empty()
168    }
169
170    #[must_use]
171    pub fn required_count(&self) -> usize {
172        self.required.len()
173    }
174
175    #[must_use]
176    pub fn forbidden_count(&self) -> usize {
177        self.forbidden.len()
178    }
179
180    pub fn required(&self) -> impl Iterator<Item = EdgeId> + '_ {
181        self.required.iter().copied()
182    }
183
184    pub fn forbidden(&self) -> impl Iterator<Item = EdgeId> + '_ {
185        self.forbidden.iter().copied()
186    }
187
188    #[must_use]
189    pub fn admits(&self, route: &Route) -> bool {
190        self.required.iter().all(|edge| route.edges.contains(edge))
191            && route
192                .edges
193                .iter()
194                .all(|edge| !self.forbidden.contains(edge))
195    }
196
197    pub fn validate(&self, graph: &WalkGraph) -> crate::Result<()> {
198        if let Some(edge) = self
199            .required
200            .iter()
201            .chain(&self.forbidden)
202            .find(|edge| edge.0 >= graph.edges.len())
203        {
204            return Err(crate::TrailgenError::InvalidData(format!(
205                "segment edict references missing edge {}",
206                edge.0
207            )));
208        }
209        Ok(())
210    }
211}
212
213#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
214pub struct SearchParams {
215    pub max_hops: usize,
216    pub max_frontier: usize,
217    pub keep: usize,
218    #[serde(default = "default_closure_paths")]
219    pub closure_paths: usize,
220    #[serde(default)]
221    pub seed: u64,
222    #[serde(default)]
223    pub routing: RoutingLaw,
224}
225
226const fn default_closure_paths() -> usize {
227    4
228}
229
230impl Default for SearchParams {
231    fn default() -> Self {
232        Self {
233            max_hops: 256,
234            max_frontier: 200_000,
235            keep: 12,
236            closure_paths: default_closure_paths(),
237            seed: 0,
238            routing: RoutingLaw::default(),
239        }
240    }
241}
242
243#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
244#[serde(rename_all = "kebab-case")]
245pub enum SolverKind {
246    #[default]
247    Auto,
248    Heuristic,
249    Exact,
250}
251
252impl SolverKind {
253    const AUTO_EXACT_EDGE_LIMIT: usize = 32;
254
255    #[must_use]
256    pub const fn resolve(self, graph: &WalkGraph) -> Self {
257        self.resolve_edge_count(graph.edges.len())
258    }
259
260    const fn resolve_edge_count(self, edge_count: usize) -> Self {
261        match self {
262            Self::Auto if edge_count <= Self::AUTO_EXACT_EDGE_LIMIT => Self::Exact,
263            Self::Auto => Self::Heuristic,
264            resolved => resolved,
265        }
266    }
267
268    #[must_use]
269    pub const fn label(self) -> &'static str {
270        match self {
271            Self::Auto => "auto",
272            Self::Heuristic => "loop-hunter",
273            Self::Exact => "exact-enumerator",
274        }
275    }
276
277    #[must_use]
278    pub fn solve(
279        self,
280        params: SearchParams,
281        graph: &WalkGraph,
282        start: VertexId,
283        constraints: &LoopConstraints,
284        count: usize,
285    ) -> Vec<Route> {
286        self.solve_monitored(params, graph, start, constraints, count, &())
287    }
288
289    #[must_use]
290    pub fn solve_monitored(
291        self,
292        params: SearchParams,
293        graph: &WalkGraph,
294        start: VertexId,
295        constraints: &LoopConstraints,
296        count: usize,
297        monitor: &dyn SearchMonitor,
298    ) -> Vec<Route> {
299        self.solve_scoped(
300            params,
301            SearchScope::all(graph),
302            start,
303            constraints,
304            count,
305            monitor,
306        )
307    }
308
309    #[must_use]
310    pub fn solve_scoped(
311        self,
312        params: SearchParams,
313        scope: SearchScope<'_>,
314        start: VertexId,
315        constraints: &LoopConstraints,
316        count: usize,
317        monitor: &dyn SearchMonitor,
318    ) -> Vec<Route> {
319        if constraints.allowed_shapes.as_slice() == [RouteShape::OutAndBack] {
320            return support_out_and_backs(params, scope, start, constraints, count, monitor);
321        }
322        match self.resolve_edge_count(scope.edge_count) {
323            Self::Auto => unreachable!("auto solver must resolve to a concrete backend"),
324            Self::Heuristic => {
325                LoopHunter { params }.solve_monitored(scope, start, constraints, count, monitor)
326            }
327            Self::Exact => ExactLoopSolver { params }.solve_monitored(
328                scope,
329                start,
330                constraints,
331                count,
332                monitor,
333            ),
334        }
335    }
336
337    #[must_use]
338    #[allow(clippy::too_many_arguments)]
339    pub fn revise_scoped(
340        self,
341        mut params: SearchParams,
342        scope: SearchScope<'_>,
343        start: VertexId,
344        constraints: &LoopConstraints,
345        count: usize,
346        edicts: &EdgeEdicts,
347        incumbents: &[Route],
348        monitor: &dyn SearchMonitor,
349    ) -> Vec<Route> {
350        if edicts.validate(scope.graph).is_err() {
351            return Vec::new();
352        }
353        let scope = scope.obeying(edicts);
354        let amplification = usize::from(edicts.required_count() == 0 && !incumbents.is_empty()) + 1;
355        let hunt = count
356            .max(1)
357            .saturating_mul(amplification)
358            .min(params.max_frontier.max(count));
359        params.keep = params.keep.max(hunt);
360        let mut routes = incumbents
361            .iter()
362            .filter(|route| {
363                route.start == start
364                    && route.edges.iter().all(|edge| scope.allows(*edge))
365                    && edicts.admits(route)
366                    && scope.graph.walk_edges(start, &route.edges).is_some()
367            })
368            .map(|route| {
369                Route::from_edges(
370                    route.name.clone(),
371                    scope.graph,
372                    start,
373                    route.edges.clone(),
374                    constraints,
375                )
376            })
377            .filter(|route| constraints.allows_shape(route.metrics.shape))
378            .collect::<Vec<_>>();
379        if !monitor.cancelled() {
380            if edicts.required_count() == 0 || !constraints.allows_shape(RouteShape::Loop) {
381                routes.extend(
382                    self.solve_scoped(params, scope, start, constraints, hunt, monitor)
383                        .into_iter()
384                        .filter(|route| edicts.admits(route)),
385                );
386            } else {
387                routes.extend(support_loop_portfolio_obeying(
388                    params,
389                    scope,
390                    start,
391                    constraints,
392                    hunt,
393                    monitor,
394                    edicts,
395                ));
396            }
397        }
398        finish_routes(
399            routes,
400            scope.graph,
401            constraints,
402            count,
403            params.keep,
404            monitor,
405        )
406    }
407}
408
409#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
410pub struct LoopHunter {
411    pub params: SearchParams,
412}
413
414#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
415pub struct ExactLoopSolver {
416    pub params: SearchParams,
417}
418
419#[derive(Clone)]
420struct State {
421    at: VertexId,
422    edges: Vec<EdgeId>,
423    used: BTreeSet<EdgeId>,
424    distance_m: f64,
425}
426
427struct SearchMeter<'a> {
428    monitor: &'a dyn SearchMonitor,
429    explored: usize,
430    limit: usize,
431    reported: usize,
432    previewed: usize,
433}
434
435impl<'a> SearchMeter<'a> {
436    const REPORT_STRIDE: usize = 32;
437
438    fn new(monitor: &'a dyn SearchMonitor, limit: usize) -> Self {
439        Self {
440            monitor,
441            explored: 0,
442            limit,
443            reported: 0,
444            previewed: 0,
445        }
446    }
447
448    fn advance(&mut self, routes: &[Route]) -> bool {
449        if self.explored >= self.limit || self.monitor.cancelled() {
450            return false;
451        }
452        if routes.len() > self.previewed {
453            self.previewed = routes.len();
454            self.monitor.preview(routes);
455        }
456        self.explored += 1;
457        if self.explored == 1 || self.explored - self.reported >= Self::REPORT_STRIDE {
458            self.emit(SearchStage::Exploring, routes.len());
459        }
460        true
461    }
462
463    fn finish(&mut self, routes: &[Route]) {
464        if routes.len() > self.previewed {
465            self.monitor.preview(routes);
466        }
467        self.emit(SearchStage::Ranking, routes.len());
468    }
469
470    fn emit(&mut self, stage: SearchStage, candidates: usize) {
471        self.reported = self.explored;
472        self.monitor.report(SearchProgress {
473            stage,
474            explored: self.explored,
475            limit: self.limit,
476            candidates,
477        });
478    }
479}
480
481pub trait RouteSolver {
482    fn solve(
483        &self,
484        graph: &WalkGraph,
485        start: VertexId,
486        constraints: &LoopConstraints,
487        count: usize,
488    ) -> Vec<Route>;
489}
490
491impl LoopHunter {
492    #[must_use]
493    pub fn hunt(
494        self,
495        graph: &WalkGraph,
496        start: VertexId,
497        constraints: &LoopConstraints,
498        count: usize,
499    ) -> Vec<Route> {
500        self.solve(graph, start, constraints, count)
501    }
502
503    fn solve_monitored(
504        &self,
505        scope: SearchScope<'_>,
506        start: VertexId,
507        constraints: &LoopConstraints,
508        count: usize,
509        monitor: &dyn SearchMonitor,
510    ) -> Vec<Route> {
511        if constraints.allowed_shapes.as_slice() == [RouteShape::Loop] {
512            return support_loop_portfolio(self.params, scope, start, constraints, count, monitor);
513        }
514        let graph = scope.graph;
515        let mut stack = vec![State {
516            at: start,
517            edges: Vec::new(),
518            used: BTreeSet::new(),
519            distance_m: 0.0,
520        }];
521        let mut routes = Vec::<Route>::new();
522        let mut meter = SearchMeter::new(monitor, self.params.max_frontier);
523        let Some(mut closer) = LoopCloser::forge(scope, start, self.params, constraints, monitor)
524        else {
525            return Vec::new();
526        };
527
528        while let Some(state) = stack.pop() {
529            if !meter.advance(&routes) {
530                break;
531            }
532            closer.strike(&state, constraints, &mut routes);
533            if state.edges.len() >= self.params.max_hops {
534                continue;
535            }
536            let mut fanout = scope.fanout(state.at);
537            sort_heuristic_fanout(
538                graph,
539                &mut fanout,
540                self.params.seed,
541                state.edges.len(),
542                state.at,
543                self.params.routing,
544            );
545
546            for edge_id in fanout {
547                if monitor.cancelled() {
548                    return Vec::new();
549                }
550                if state.used.contains(&edge_id) {
551                    continue;
552                }
553                if self.params.routing.edge_cost(graph, edge_id).is_none() {
554                    continue;
555                }
556                if !graph.turn_allowed(state.edges.last().copied(), state.at, edge_id) {
557                    continue;
558                }
559                let edge = &graph.edges[edge_id.0];
560                let Some(next) = edge.traverse(state.at) else {
561                    continue;
562                };
563                let distance_m = state.distance_m + edge.attr.length_m;
564                if distance_m > constraints.max_distance_m * 1.35 {
565                    continue;
566                }
567                let mut edges = state.edges.clone();
568                edges.push(edge_id);
569                if constraints.allows_shape(RouteShape::OutAndBack) {
570                    let out_and_back = mirrored_route(&edges);
571                    if route_distance(graph, &out_and_back) <= constraints.max_distance_m * 1.35 {
572                        push_allowed_route(&mut routes, graph, start, out_and_back, constraints);
573                    }
574                }
575
576                let mut used = state.used.clone();
577                used.insert(edge_id);
578                if next == start && edges.len() >= 2 {
579                    push_allowed_route(&mut routes, graph, start, edges.clone(), constraints);
580                    if constraints.allows_shape(RouteShape::FigureEight) {
581                        stack.push(State {
582                            at: next,
583                            edges,
584                            used,
585                            distance_m,
586                        });
587                    }
588                    continue;
589                }
590
591                stack.push(State {
592                    at: next,
593                    edges,
594                    used,
595                    distance_m,
596                });
597            }
598        }
599
600        if monitor.cancelled() {
601            return Vec::new();
602        }
603        meter.finish(&routes);
604        finish_routes(routes, graph, constraints, count, self.params.keep, monitor)
605    }
606}
607
608impl RouteSolver for LoopHunter {
609    fn solve(
610        &self,
611        graph: &WalkGraph,
612        start: VertexId,
613        constraints: &LoopConstraints,
614        count: usize,
615    ) -> Vec<Route> {
616        self.solve_monitored(SearchScope::all(graph), start, constraints, count, &())
617    }
618}
619
620impl ExactLoopSolver {
621    #[must_use]
622    pub fn enumerate(
623        self,
624        graph: &WalkGraph,
625        start: VertexId,
626        constraints: &LoopConstraints,
627        count: usize,
628    ) -> Vec<Route> {
629        self.solve(graph, start, constraints, count)
630    }
631
632    fn solve_monitored(
633        &self,
634        scope: SearchScope<'_>,
635        start: VertexId,
636        constraints: &LoopConstraints,
637        count: usize,
638        monitor: &dyn SearchMonitor,
639    ) -> Vec<Route> {
640        let graph = scope.graph;
641        let mut stack = vec![State {
642            at: start,
643            edges: Vec::new(),
644            used: BTreeSet::new(),
645            distance_m: 0.0,
646        }];
647        let mut routes = Vec::<Route>::new();
648        let mut meter = SearchMeter::new(monitor, self.params.max_frontier);
649
650        while let Some(state) = stack.pop() {
651            if !meter.advance(&routes) {
652                break;
653            }
654            if state.edges.len() >= self.params.max_hops {
655                continue;
656            }
657
658            let mut fanout = scope.fanout(state.at);
659            fanout.sort();
660            fanout.reverse();
661
662            for edge_id in fanout {
663                if monitor.cancelled() {
664                    return Vec::new();
665                }
666                if state.used.contains(&edge_id) {
667                    continue;
668                }
669                if self.params.routing.edge_cost(graph, edge_id).is_none() {
670                    continue;
671                }
672                if !graph.turn_allowed(state.edges.last().copied(), state.at, edge_id) {
673                    continue;
674                }
675                let edge = &graph.edges[edge_id.0];
676                let Some(next) = edge.traverse(state.at) else {
677                    continue;
678                };
679                let distance_m = state.distance_m + edge.attr.length_m;
680                if distance_m > constraints.max_distance_m * 1.35 {
681                    continue;
682                }
683
684                let mut edges = state.edges.clone();
685                edges.push(edge_id);
686                if constraints.allows_shape(RouteShape::OutAndBack) {
687                    let out_and_back = mirrored_route(&edges);
688                    if route_distance(graph, &out_and_back) <= constraints.max_distance_m * 1.35 {
689                        push_allowed_route(&mut routes, graph, start, out_and_back, constraints);
690                    }
691                }
692
693                let mut used = state.used.clone();
694                used.insert(edge_id);
695                if next == start && edges.len() >= 2 {
696                    push_allowed_route(&mut routes, graph, start, edges.clone(), constraints);
697                    if !constraints.allows_shape(RouteShape::FigureEight) {
698                        continue;
699                    }
700                }
701
702                stack.push(State {
703                    at: next,
704                    edges,
705                    used,
706                    distance_m,
707                });
708            }
709        }
710
711        if monitor.cancelled() {
712            return Vec::new();
713        }
714        meter.finish(&routes);
715        finish_routes(routes, graph, constraints, count, self.params.keep, monitor)
716    }
717}
718
719impl RouteSolver for ExactLoopSolver {
720    fn solve(
721        &self,
722        graph: &WalkGraph,
723        start: VertexId,
724        constraints: &LoopConstraints,
725        count: usize,
726    ) -> Vec<Route> {
727        self.solve_monitored(SearchScope::all(graph), start, constraints, count, &())
728    }
729}
730
731#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
732struct SupportWalk {
733    at: VertexId,
734    previous: Option<EdgeId>,
735}
736
737#[derive(Clone, Copy, Debug, PartialEq)]
738struct SupportFrontier {
739    cost: f64,
740    walk: SupportWalk,
741}
742
743impl Eq for SupportFrontier {}
744
745impl Ord for SupportFrontier {
746    fn cmp(&self, rhs: &Self) -> Ordering {
747        rhs.cost
748            .total_cmp(&self.cost)
749            .then_with(|| rhs.walk.cmp(&self.walk))
750    }
751}
752
753impl PartialOrd for SupportFrontier {
754    fn partial_cmp(&self, rhs: &Self) -> Option<Ordering> {
755        Some(self.cmp(rhs))
756    }
757}
758
759fn support_out_and_backs(
760    params: SearchParams,
761    scope: SearchScope<'_>,
762    start: VertexId,
763    constraints: &LoopConstraints,
764    count: usize,
765    monitor: &dyn SearchMonitor,
766) -> Vec<Route> {
767    let graph = scope.graph;
768    let law = params.routing;
769    let origin = SupportWalk {
770        at: start,
771        previous: None,
772    };
773    let mut frontier = BinaryHeap::from([SupportFrontier {
774        cost: 0.0,
775        walk: origin,
776    }]);
777    let mut distance = BTreeMap::from([(origin, 0.0)]);
778    let mut predecessor = BTreeMap::<SupportWalk, (SupportWalk, EdgeId)>::new();
779    let mut emitted = BTreeSet::new();
780    let mut routes = Vec::new();
781    let mut meter = SearchMeter::new(monitor, params.max_frontier);
782    let maximum_outward_m = constraints.max_distance_m * 0.675;
783    let maximum_cost = maximum_outward_m * (1.0 + law.road_aversion);
784
785    while let Some(SupportFrontier { cost, walk }) = frontier.pop() {
786        if cost > maximum_cost || !meter.advance(&routes) {
787            break;
788        }
789        if distance
790            .get(&walk)
791            .is_some_and(|best| cost > *best + f64::EPSILON)
792        {
793            continue;
794        }
795        let path = support_path(origin, walk, &predecessor);
796        let outward_m = route_distance(graph, &path);
797        if walk.at != start
798            && emitted.insert(walk.at)
799            && !path.is_empty()
800            && path.len() <= params.max_hops
801            && outward_m <= maximum_outward_m
802            && path.iter().copied().collect::<BTreeSet<_>>().len() == path.len()
803        {
804            push_allowed_route(
805                &mut routes,
806                graph,
807                start,
808                mirrored_route(&path),
809                constraints,
810            );
811        }
812        if path.len() >= params.max_hops || outward_m > maximum_outward_m {
813            continue;
814        }
815        for edge in scope.fanout(walk.at) {
816            if monitor.cancelled() {
817                return Vec::new();
818            }
819            if !graph.turn_allowed(walk.previous, walk.at, edge) {
820                continue;
821            }
822            let Some(edge_cost) = law.edge_cost(graph, edge) else {
823                continue;
824            };
825            let next_cost = cost + edge_cost;
826            if next_cost > maximum_cost {
827                continue;
828            }
829            let Some(at) = graph.edges[edge.0].traverse(walk.at) else {
830                continue;
831            };
832            let next = SupportWalk {
833                at,
834                previous: Some(edge),
835            };
836            if distance
837                .get(&next)
838                .is_none_or(|best| next_cost < *best - f64::EPSILON)
839            {
840                distance.insert(next, next_cost);
841                predecessor.insert(next, (walk, edge));
842                frontier.push(SupportFrontier {
843                    cost: next_cost,
844                    walk: next,
845                });
846            }
847        }
848    }
849    if monitor.cancelled() {
850        return Vec::new();
851    }
852    meter.finish(&routes);
853    finish_routes(routes, graph, constraints, count, params.keep, monitor)
854}
855
856const SUPPORT_RINGS: u32 = 12;
857const SUPPORT_SECTORS: u32 = 16;
858
859#[derive(Clone, Debug)]
860struct SupportDesign {
861    lower_bound_m: f64,
862    supports: Vec<VertexId>,
863}
864
865fn support_loop_portfolio(
866    params: SearchParams,
867    scope: SearchScope<'_>,
868    start: VertexId,
869    constraints: &LoopConstraints,
870    count: usize,
871    monitor: &dyn SearchMonitor,
872) -> Vec<Route> {
873    support_loop_portfolio_obeying(
874        params,
875        scope,
876        start,
877        constraints,
878        count,
879        monitor,
880        &EdgeEdicts::default(),
881    )
882}
883
884#[allow(clippy::too_many_arguments)]
885fn support_loop_portfolio_obeying(
886    params: SearchParams,
887    scope: SearchScope<'_>,
888    start: VertexId,
889    constraints: &LoopConstraints,
890    count: usize,
891    monitor: &dyn SearchMonitor,
892    edicts: &EdgeEdicts,
893) -> Vec<Route> {
894    let graph = scope.graph;
895    let skeleton =
896        RoutingSkeleton::forge_preserving(scope, start, params.routing, edicts.required());
897    let compulsory = edicts
898        .required()
899        .map(|edge| {
900            skeleton
901                .arcs
902                .iter()
903                .find(|arc| arc.edges.as_slice() == [edge])
904                .map(|arc| arc.id)
905        })
906        .collect::<Option<Vec<_>>>();
907    let Some(compulsory) = compulsory else {
908        return Vec::new();
909    };
910    let radial = radial_distances(&skeleton, start, constraints.max_distance_m * 0.55, monitor);
911    if monitor.cancelled() {
912        return Vec::new();
913    }
914    let landmarks = support_landmarks(graph, start, &radial, constraints);
915    let designs = support_designs(graph, &radial, &landmarks, constraints, params);
916    let limit = designs.len().min(params.max_frontier);
917    let mut meter = SearchMeter::new(monitor, limit);
918    let mut workspace = ArcWorkspace::new(skeleton.arcs.len());
919    let mut banned = vec![false; graph.vertices.len()];
920    let mut barred = vec![false; skeleton.arcs.len()];
921    let mut routes = Vec::new();
922    let mut forge = SupportForge {
923        skeleton: &skeleton,
924        start,
925        constraints,
926        monitor,
927        outbound: vec![None; graph.vertices.len()],
928    };
929    for (attempt, design) in designs.into_iter().take(limit).enumerate() {
930        if !meter.advance(&routes) {
931            break;
932        }
933        let mut compulsory_order = compulsory.clone();
934        if compulsory_order.len() > 1 {
935            let width = compulsory_order.len();
936            compulsory_order.rotate_left(attempt % width);
937            if attempt / width % 2 == 1 {
938                compulsory_order.reverse();
939            }
940        }
941        if let Some(edges) = forge.loop_through(
942            &compulsory_order,
943            &design.supports,
944            &mut workspace,
945            &mut banned,
946            &mut barred,
947        ) && edicts.required().all(|edge| edges.contains(&edge))
948        {
949            push_allowed_route(&mut routes, graph, start, edges, constraints);
950        }
951    }
952    if monitor.cancelled() {
953        return Vec::new();
954    }
955    meter.finish(&routes);
956    finish_routes(routes, graph, constraints, count, params.keep, monitor)
957}
958
959fn support_designs(
960    graph: &WalkGraph,
961    radial: &[f64],
962    landmarks: &[VertexId],
963    constraints: &LoopConstraints,
964    params: SearchParams,
965) -> Vec<SupportDesign> {
966    let pool = params.keep.max(1).saturating_mul(8);
967    let feasibility_floor_m = constraints.min_distance_m;
968    let mut designs = landmarks
969        .iter()
970        .copied()
971        .map(|pivot| SupportDesign {
972            lower_bound_m: radial[pivot.0] * 2.0,
973            supports: vec![pivot],
974        })
975        .collect::<Vec<_>>();
976
977    let mut pairs = Vec::new();
978    for first in landmarks.iter().copied() {
979        for second in landmarks.iter().copied().filter(|second| *second != first) {
980            pairs.push((
981                radial[first.0]
982                    + graph.vertices[first.0]
983                        .coord
984                        .haversine_m(graph.vertices[second.0].coord)
985                    + radial[second.0],
986                [first, second],
987            ));
988        }
989    }
990    pairs.sort_by(|left, right| {
991        support_rank(left.0, &left.1, feasibility_floor_m, params.seed).cmp(&support_rank(
992            right.0,
993            &right.1,
994            feasibility_floor_m,
995            params.seed,
996        ))
997    });
998    designs.extend(
999        pairs
1000            .into_iter()
1001            .take(pool)
1002            .map(|(lower_bound_m, supports)| SupportDesign {
1003                lower_bound_m,
1004                supports: supports.into(),
1005            }),
1006    );
1007
1008    let mut triples = Vec::new();
1009    for first in landmarks.iter().copied() {
1010        for second in landmarks.iter().copied().filter(|second| *second != first) {
1011            for third in landmarks
1012                .iter()
1013                .copied()
1014                .filter(|third| *third != first && *third != second)
1015            {
1016                triples.push((
1017                    radial[first.0]
1018                        + graph.vertices[first.0]
1019                            .coord
1020                            .haversine_m(graph.vertices[second.0].coord)
1021                        + graph.vertices[second.0]
1022                            .coord
1023                            .haversine_m(graph.vertices[third.0].coord)
1024                        + radial[third.0],
1025                    [first, second, third],
1026                ));
1027            }
1028        }
1029    }
1030    triples.sort_by(|left, right| {
1031        support_rank(left.0, &left.1, feasibility_floor_m, params.seed).cmp(&support_rank(
1032            right.0,
1033            &right.1,
1034            feasibility_floor_m,
1035            params.seed,
1036        ))
1037    });
1038    designs.extend(triples.into_iter().take(pool.saturating_mul(2)).map(
1039        |(lower_bound_m, supports)| SupportDesign {
1040            lower_bound_m,
1041            supports: supports.into(),
1042        },
1043    ));
1044    designs.sort_by(|left, right| {
1045        support_rank(
1046            left.lower_bound_m,
1047            &left.supports,
1048            feasibility_floor_m,
1049            params.seed,
1050        )
1051        .cmp(&support_rank(
1052            right.lower_bound_m,
1053            &right.supports,
1054            feasibility_floor_m,
1055            params.seed,
1056        ))
1057    });
1058    designs
1059}
1060
1061fn support_rank(
1062    lower_bound_m: f64,
1063    supports: &[VertexId],
1064    feasibility_floor_m: f64,
1065    seed: u64,
1066) -> (u64, u64) {
1067    let deviation = (lower_bound_m - feasibility_floor_m).abs().to_bits();
1068    let hash = supports
1069        .iter()
1070        .fold(seed, |hash, vertex| splitmix64(hash ^ vertex.0 as u64));
1071    (deviation, hash)
1072}
1073
1074fn support_landmarks(
1075    graph: &WalkGraph,
1076    start: VertexId,
1077    radial: &[f64],
1078    constraints: &LoopConstraints,
1079) -> Vec<VertexId> {
1080    let ceiling_m = constraints.max_distance_m * 0.5;
1081    if ceiling_m <= 0.0 || !ceiling_m.is_finite() {
1082        return Vec::new();
1083    }
1084    let floor_m = ceiling_m / 16.0;
1085    let origin = graph.vertices[start.0].coord;
1086    let reachable = graph
1087        .vertices
1088        .iter()
1089        .filter(|vertex| vertex.id != start && radial[vertex.id.0].is_finite())
1090        .collect::<Vec<_>>();
1091    let mut landmarks = BTreeSet::new();
1092    for ring in 0..SUPPORT_RINGS {
1093        let target_m =
1094            floor_m + (ceiling_m - floor_m) * (f64::from(ring) + 0.5) / f64::from(SUPPORT_RINGS);
1095        for sector in 0..SUPPORT_SECTORS {
1096            landmarks.extend(
1097                reachable
1098                    .iter()
1099                    .copied()
1100                    .filter(|vertex| bearing_sector(origin, vertex.coord) == sector)
1101                    .min_by(|left, right| {
1102                        (radial[left.id.0] - target_m)
1103                            .abs()
1104                            .total_cmp(&(radial[right.id.0] - target_m).abs())
1105                            .then_with(|| left.id.cmp(&right.id))
1106                    })
1107                    .map(|vertex| vertex.id),
1108            );
1109        }
1110    }
1111    landmarks.into_iter().collect()
1112}
1113
1114fn bearing_sector(origin: Coord, point: Coord) -> u32 {
1115    let x = (point.lon - origin.lon) * origin.lat.to_radians().cos();
1116    let y = point.lat - origin.lat;
1117    let turn = (y.atan2(x) + std::f64::consts::PI) / std::f64::consts::TAU;
1118    (0..SUPPORT_SECTORS - 1)
1119        .find(|sector| turn < f64::from(*sector + 1) / f64::from(SUPPORT_SECTORS))
1120        .unwrap_or(SUPPORT_SECTORS - 1)
1121}
1122
1123#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
1124struct ArcId(usize);
1125
1126struct RoutingArc {
1127    id: ArcId,
1128    a: VertexId,
1129    b: VertexId,
1130    edges: Vec<EdgeId>,
1131    distance_m: f64,
1132    routing_cost_m: f64,
1133    forward: bool,
1134    backward: bool,
1135}
1136
1137impl RoutingArc {
1138    fn traverse(&self, from: VertexId) -> Option<VertexId> {
1139        if from == self.a && self.forward {
1140            Some(self.b)
1141        } else if from == self.b && self.backward {
1142            Some(self.a)
1143        } else {
1144            None
1145        }
1146    }
1147
1148    fn first_edge_from(&self, from: VertexId) -> Option<EdgeId> {
1149        self.traverse(from)?;
1150        if from == self.a {
1151            self.edges.first().copied()
1152        } else {
1153            self.edges.last().copied()
1154        }
1155    }
1156
1157    fn last_edge_at(&self, at: VertexId) -> Option<EdgeId> {
1158        if at == self.b && self.forward {
1159            self.edges.last().copied()
1160        } else if at == self.a && self.backward {
1161            self.edges.first().copied()
1162        } else {
1163            None
1164        }
1165    }
1166
1167    fn append_edges_from(&self, from: VertexId, output: &mut Vec<EdgeId>) -> Option<VertexId> {
1168        let at = self.traverse(from)?;
1169        if from == self.a {
1170            output.extend(self.edges.iter().copied());
1171        } else {
1172            output.extend(self.edges.iter().rev().copied());
1173        }
1174        Some(at)
1175    }
1176}
1177
1178struct RoutingSkeleton<'graph> {
1179    graph: &'graph WalkGraph,
1180    arcs: Vec<RoutingArc>,
1181    adjacency: Vec<Vec<ArcId>>,
1182}
1183
1184impl<'graph> RoutingSkeleton<'graph> {
1185    fn forge_preserving(
1186        scope: SearchScope<'graph>,
1187        start: VertexId,
1188        law: RoutingLaw,
1189        compulsory: impl IntoIterator<Item = EdgeId>,
1190    ) -> Self {
1191        let graph = scope.graph;
1192        let incidence = routing_incidence(scope, law);
1193        let mut preserved = preserved_vertices(graph, &incidence, start);
1194        for edge in compulsory {
1195            preserved[graph.edges[edge.0].a.0] = true;
1196            preserved[graph.edges[edge.0].b.0] = true;
1197        }
1198        let arcs = skeleton_arcs(graph, &incidence, &preserved, law);
1199        let adjacency = arc_adjacency(graph.vertices.len(), &arcs);
1200        Self {
1201            graph,
1202            arcs,
1203            adjacency,
1204        }
1205    }
1206
1207    fn turn_allowed(&self, previous: Option<ArcId>, via: VertexId, next: ArcId) -> bool {
1208        let prior = previous.and_then(|arc| self.arcs[arc.0].last_edge_at(via));
1209        self.arcs[next.0]
1210            .first_edge_from(via)
1211            .is_some_and(|edge| self.graph.turn_allowed(prior, via, edge))
1212    }
1213
1214    fn expand(&self, start: VertexId, arcs: &[ArcId]) -> Option<Vec<EdgeId>> {
1215        let mut at = start;
1216        let mut edges = Vec::new();
1217        for arc in arcs {
1218            at = self.arcs[arc.0].append_edges_from(at, &mut edges)?;
1219        }
1220        Some(edges)
1221    }
1222}
1223
1224fn routing_incidence(scope: SearchScope<'_>, law: RoutingLaw) -> Vec<Vec<EdgeId>> {
1225    let graph = scope.graph;
1226    let mut incidence = vec![Vec::new(); graph.vertices.len()];
1227    for edge in graph
1228        .edges
1229        .iter()
1230        .filter(|edge| scope.allows(edge.id) && law.edge_cost(graph, edge.id).is_some())
1231    {
1232        incidence[edge.a.0].push(edge.id);
1233        incidence[edge.b.0].push(edge.id);
1234    }
1235    incidence
1236}
1237
1238fn preserved_vertices(graph: &WalkGraph, incidence: &[Vec<EdgeId>], start: VertexId) -> Vec<bool> {
1239    let mut barred_turn = vec![false; graph.vertices.len()];
1240    for ban in &graph.turn_bans {
1241        barred_turn[ban.via.0] = true;
1242    }
1243    let mut preserved = graph
1244        .vertices
1245        .iter()
1246        .map(|vertex| {
1247            let edges = &incidence[vertex.id.0];
1248            let distinct_neighbours = edges.len() == 2
1249                && graph.edges[edges[0].0].other(vertex.id)
1250                    != graph.edges[edges[1].0].other(vertex.id);
1251            vertex.id == start
1252                || barred_turn[vertex.id.0]
1253                || !distinct_neighbours
1254                || edges
1255                    .iter()
1256                    .any(|edge| graph.edges[edge.0].attr.travel != EdgeTravel::Both)
1257        })
1258        .collect::<Vec<_>>();
1259    let roots = preserved.clone();
1260    for vertex in graph.vertices.iter().filter(|vertex| roots[vertex.id.0]) {
1261        for edge in &incidence[vertex.id.0] {
1262            let neighbour = graph.edges[edge.0]
1263                .other(vertex.id)
1264                .expect("an incident edge contains its vertex");
1265            preserved[neighbour.0] = true;
1266        }
1267    }
1268    preserved
1269}
1270
1271fn skeleton_arcs(
1272    graph: &WalkGraph,
1273    incidence: &[Vec<EdgeId>],
1274    preserved: &[bool],
1275    law: RoutingLaw,
1276) -> Vec<RoutingArc> {
1277    let mut visited = vec![false; graph.edges.len()];
1278    let mut arcs = Vec::new();
1279    for vertex in graph
1280        .vertices
1281        .iter()
1282        .filter(|vertex| preserved[vertex.id.0])
1283    {
1284        for first in incidence[vertex.id.0].iter().copied() {
1285            if visited[first.0] {
1286                continue;
1287            }
1288            let mut edges = Vec::new();
1289            let mut at = vertex.id;
1290            let mut edge_id = first;
1291            let endpoint = loop {
1292                visited[edge_id.0] = true;
1293                edges.push(edge_id);
1294                let next = graph.edges[edge_id.0]
1295                    .other(at)
1296                    .expect("an incident edge contains its vertex");
1297                if preserved[next.0] {
1298                    break next;
1299                }
1300                edge_id = incidence[next.0]
1301                    .iter()
1302                    .copied()
1303                    .find(|candidate| *candidate != edge_id)
1304                    .expect("an elided vertex has exactly two incident edges");
1305                at = next;
1306            };
1307            let id = ArcId(arcs.len());
1308            let distance_m = edges
1309                .iter()
1310                .map(|edge| graph.edges[edge.0].attr.length_m)
1311                .sum();
1312            let routing_cost_m = edges
1313                .iter()
1314                .map(|edge| {
1315                    law.edge_cost(graph, *edge)
1316                        .expect("a skeleton contains only lawful edges")
1317                })
1318                .sum();
1319            arcs.push(RoutingArc {
1320                id,
1321                a: vertex.id,
1322                b: endpoint,
1323                forward: chain_traversable(graph, vertex.id, edges.iter().copied()),
1324                backward: chain_traversable(graph, endpoint, edges.iter().rev().copied()),
1325                edges,
1326                distance_m,
1327                routing_cost_m,
1328            });
1329        }
1330    }
1331    arcs
1332}
1333
1334fn arc_adjacency(vertex_count: usize, arcs: &[RoutingArc]) -> Vec<Vec<ArcId>> {
1335    let mut adjacency = vec![Vec::new(); vertex_count];
1336    for arc in arcs {
1337        if arc.forward {
1338            adjacency[arc.a.0].push(arc.id);
1339        }
1340        if arc.backward {
1341            adjacency[arc.b.0].push(arc.id);
1342        }
1343    }
1344    adjacency
1345}
1346
1347fn chain_traversable(
1348    graph: &WalkGraph,
1349    mut at: VertexId,
1350    edges: impl IntoIterator<Item = EdgeId>,
1351) -> bool {
1352    for edge in edges {
1353        let Some(next) = graph.edges[edge.0].traverse(at) else {
1354            return false;
1355        };
1356        at = next;
1357    }
1358    true
1359}
1360
1361#[derive(Clone, Copy, Debug, PartialEq)]
1362struct RadialFrontier {
1363    distance_m: f64,
1364    at: VertexId,
1365}
1366
1367impl Eq for RadialFrontier {}
1368
1369impl Ord for RadialFrontier {
1370    fn cmp(&self, other: &Self) -> Ordering {
1371        other
1372            .distance_m
1373            .total_cmp(&self.distance_m)
1374            .then_with(|| other.at.cmp(&self.at))
1375    }
1376}
1377
1378impl PartialOrd for RadialFrontier {
1379    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1380        Some(self.cmp(other))
1381    }
1382}
1383
1384fn radial_distances(
1385    skeleton: &RoutingSkeleton<'_>,
1386    start: VertexId,
1387    maximum_m: f64,
1388    monitor: &dyn SearchMonitor,
1389) -> Vec<f64> {
1390    let graph = skeleton.graph;
1391    let mut distance = vec![f64::INFINITY; graph.vertices.len()];
1392    distance[start.0] = 0.0;
1393    let mut heap = BinaryHeap::from([RadialFrontier {
1394        distance_m: 0.0,
1395        at: start,
1396    }]);
1397    while let Some(frontier) = heap.pop() {
1398        if monitor.cancelled() {
1399            break;
1400        }
1401        if frontier.distance_m > distance[frontier.at.0] {
1402            continue;
1403        }
1404        for arc_id in &skeleton.adjacency[frontier.at.0] {
1405            let arc = &skeleton.arcs[arc_id.0];
1406            let Some(at) = arc.traverse(frontier.at) else {
1407                continue;
1408            };
1409            let next_m = frontier.distance_m + arc.distance_m;
1410            if next_m <= maximum_m && next_m < distance[at.0] {
1411                distance[at.0] = next_m;
1412                heap.push(RadialFrontier {
1413                    distance_m: next_m,
1414                    at,
1415                });
1416            }
1417        }
1418    }
1419    distance
1420}
1421
1422struct SupportForge<'graph, 'constraint, 'monitor> {
1423    skeleton: &'graph RoutingSkeleton<'graph>,
1424    start: VertexId,
1425    constraints: &'constraint LoopConstraints,
1426    monitor: &'monitor dyn SearchMonitor,
1427    outbound: Vec<Option<MeasuredPath>>,
1428}
1429
1430impl SupportForge<'_, '_, '_> {
1431    fn loop_through(
1432        &mut self,
1433        compulsory: &[ArcId],
1434        supports: &[VertexId],
1435        workspace: &mut ArcWorkspace,
1436        banned: &mut [bool],
1437        barred: &mut [bool],
1438    ) -> Option<Vec<EdgeId>> {
1439        banned.fill(false);
1440        barred.fill(false);
1441        for arc in compulsory {
1442            barred[arc.0] = true;
1443        }
1444        banned[self.start.0] = true;
1445        let maximum_m = self.constraints.max_distance_m;
1446        let mut arcs = Vec::new();
1447        let mut at = self.start;
1448        let mut spent_m = 0.0;
1449        for exact in compulsory {
1450            let arc = &self.skeleton.arcs[exact.0];
1451            let approach_budget = maximum_m - spent_m - arc.distance_m;
1452            let mut approaches = [(arc.a, arc.forward), (arc.b, arc.backward)]
1453                .into_iter()
1454                .filter(|(target, traversable)| {
1455                    *traversable && (*target == at || !banned[target.0])
1456                })
1457                .filter_map(|(target, _)| {
1458                    shortest_path_avoiding(
1459                        AvoidanceHunt {
1460                            skeleton: self.skeleton,
1461                            from: at,
1462                            target,
1463                            previous: arcs.last().copied(),
1464                            banned,
1465                            barred,
1466                            max_distance_m: approach_budget,
1467                            monitor: self.monitor,
1468                        },
1469                        workspace,
1470                    )
1471                    .map(|path| (target, path))
1472                })
1473                .collect::<Vec<_>>();
1474            approaches.sort_by(|left, right| left.1.distance_m.total_cmp(&right.1.distance_m));
1475            let (target, path) = approaches.into_iter().next()?;
1476            ban_internal_vertices(self.skeleton, at, &path.arcs, banned);
1477            for connector in &path.arcs {
1478                barred[connector.0] = true;
1479            }
1480            if at != self.start {
1481                banned[at.0] = true;
1482            }
1483            spent_m += path.distance_m;
1484            arcs.extend(path.arcs);
1485            at = target;
1486            if !self.skeleton.turn_allowed(arcs.last().copied(), at, *exact) {
1487                return None;
1488            }
1489            at = arc.traverse(at)?;
1490            arcs.push(*exact);
1491            spent_m += arc.distance_m;
1492            if spent_m > maximum_m {
1493                return None;
1494            }
1495        }
1496        for target in supports.iter().copied().chain(std::iter::once(self.start)) {
1497            let hunt = AvoidanceHunt {
1498                skeleton: self.skeleton,
1499                from: at,
1500                target,
1501                previous: arcs.last().copied(),
1502                banned,
1503                barred,
1504                max_distance_m: maximum_m - spent_m,
1505                monitor: self.monitor,
1506            };
1507            let path = if at == self.start && arcs.is_empty() {
1508                if self.outbound[target.0].is_none() {
1509                    self.outbound[target.0] = shortest_path_avoiding(hunt, workspace);
1510                }
1511                self.outbound[target.0].clone()?
1512            } else {
1513                shortest_path_avoiding(hunt, workspace)?
1514            };
1515            ban_internal_vertices(self.skeleton, at, &path.arcs, banned);
1516            for arc in &path.arcs {
1517                barred[arc.0] = true;
1518            }
1519            if at != self.start {
1520                banned[at.0] = true;
1521            }
1522            spent_m += path.distance_m;
1523            arcs.extend(path.arcs);
1524            at = target;
1525        }
1526        let graph = self.skeleton.graph;
1527        let edges = self.skeleton.expand(self.start, &arcs)?;
1528        (edge_simple(&edges) && graph.walk_edges(self.start, &edges) == Some(self.start))
1529            .then_some(edges)
1530    }
1531}
1532
1533fn ban_internal_vertices(
1534    skeleton: &RoutingSkeleton<'_>,
1535    mut at: VertexId,
1536    arcs: &[ArcId],
1537    banned: &mut [bool],
1538) {
1539    for arc in arcs.iter().copied().take(arcs.len().saturating_sub(1)) {
1540        at = skeleton.arcs[arc.0]
1541            .traverse(at)
1542            .expect("a recovered path is a legal walk");
1543        banned[at.0] = true;
1544    }
1545}
1546
1547#[derive(Clone, Copy)]
1548struct AvoidanceHunt<'a> {
1549    skeleton: &'a RoutingSkeleton<'a>,
1550    from: VertexId,
1551    target: VertexId,
1552    previous: Option<ArcId>,
1553    banned: &'a [bool],
1554    barred: &'a [bool],
1555    max_distance_m: f64,
1556    monitor: &'a dyn SearchMonitor,
1557}
1558
1559#[derive(Clone)]
1560struct MeasuredPath {
1561    arcs: Vec<ArcId>,
1562    distance_m: f64,
1563}
1564
1565fn shortest_path_avoiding(
1566    hunt: AvoidanceHunt<'_>,
1567    workspace: &mut ArcWorkspace,
1568) -> Option<MeasuredPath> {
1569    if hunt.max_distance_m < 0.0 {
1570        return None;
1571    }
1572    let skeleton = hunt.skeleton;
1573    let target = skeleton.graph.vertices[hunt.target.0].coord;
1574    let origin_bound_m = skeleton.graph.vertices[hunt.from.0]
1575        .coord
1576        .haversine_m(target);
1577    if origin_bound_m > hunt.max_distance_m {
1578        return None;
1579    }
1580    workspace.begin();
1581    let origin = ArcWalk {
1582        at: hunt.from,
1583        previous: hunt.previous,
1584    };
1585    let origin_label = workspace.admit(
1586        arc_slot(skeleton, origin),
1587        ArcLabel {
1588            routing_cost_m: 0.0,
1589            distance_m: 0.0,
1590            predecessor: None,
1591            arc: None,
1592            live: true,
1593        },
1594    )?;
1595    workspace.heap.push(ArcFrontier {
1596        rank_m: origin_bound_m,
1597        routing_cost_m: 0.0,
1598        distance_m: 0.0,
1599        walk: origin,
1600        label: origin_label,
1601    });
1602    let mut expanded = 0usize;
1603    let expansion_cap = return_expansion_cap(1, skeleton.graph.edges.len());
1604    while let Some(frontier) = workspace.heap.pop() {
1605        if hunt.monitor.cancelled() || expanded >= expansion_cap {
1606            return None;
1607        }
1608        if !workspace.labels[frontier.label].live {
1609            continue;
1610        }
1611        expanded += 1;
1612        if frontier.walk.at == hunt.target {
1613            let arcs = recover_arc_path(&workspace.labels, frontier.label);
1614            return arc_simple(&arcs).then_some(MeasuredPath {
1615                arcs,
1616                distance_m: frontier.distance_m,
1617            });
1618        }
1619        for arc_id in &skeleton.adjacency[frontier.walk.at.0] {
1620            if hunt.barred[arc_id.0]
1621                || frontier.walk.previous == Some(*arc_id)
1622                || !skeleton.turn_allowed(frontier.walk.previous, frontier.walk.at, *arc_id)
1623            {
1624                continue;
1625            }
1626            let arc = &skeleton.arcs[arc_id.0];
1627            let Some(at) = arc.traverse(frontier.walk.at) else {
1628                continue;
1629            };
1630            if at != hunt.target && hunt.banned[at.0] {
1631                continue;
1632            }
1633            let distance_m = frontier.distance_m + arc.distance_m;
1634            let remaining_bound_m = skeleton.graph.vertices[at.0].coord.haversine_m(target);
1635            if distance_m + remaining_bound_m > hunt.max_distance_m {
1636                continue;
1637            }
1638            let routing_cost_m = frontier.routing_cost_m + arc.routing_cost_m;
1639            let walk = ArcWalk {
1640                at,
1641                previous: Some(*arc_id),
1642            };
1643            let Some(label) = workspace.admit(
1644                arc_slot(skeleton, walk),
1645                ArcLabel {
1646                    routing_cost_m,
1647                    distance_m,
1648                    predecessor: Some(frontier.label),
1649                    arc: Some(*arc_id),
1650                    live: true,
1651                },
1652            ) else {
1653                continue;
1654            };
1655            workspace.heap.push(ArcFrontier {
1656                rank_m: routing_cost_m + remaining_bound_m,
1657                routing_cost_m,
1658                distance_m,
1659                walk,
1660                label,
1661            });
1662        }
1663    }
1664    None
1665}
1666
1667#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
1668struct ArcWalk {
1669    at: VertexId,
1670    previous: Option<ArcId>,
1671}
1672
1673#[derive(Clone, Debug)]
1674struct ArcLabel {
1675    routing_cost_m: f64,
1676    distance_m: f64,
1677    predecessor: Option<usize>,
1678    arc: Option<ArcId>,
1679    live: bool,
1680}
1681
1682impl ArcLabel {
1683    fn dominates(&self, routing_cost_m: f64, distance_m: f64) -> bool {
1684        self.live && self.routing_cost_m <= routing_cost_m && self.distance_m <= distance_m
1685    }
1686
1687    fn is_dominated_by(&self, routing_cost_m: f64, distance_m: f64) -> bool {
1688        routing_cost_m <= self.routing_cost_m && distance_m <= self.distance_m
1689    }
1690}
1691
1692#[derive(Clone, Copy, Debug, PartialEq)]
1693struct ArcFrontier {
1694    rank_m: f64,
1695    routing_cost_m: f64,
1696    distance_m: f64,
1697    walk: ArcWalk,
1698    label: usize,
1699}
1700
1701impl Eq for ArcFrontier {}
1702
1703impl Ord for ArcFrontier {
1704    fn cmp(&self, other: &Self) -> Ordering {
1705        other
1706            .rank_m
1707            .total_cmp(&self.rank_m)
1708            .then_with(|| other.routing_cost_m.total_cmp(&self.routing_cost_m))
1709            .then_with(|| other.distance_m.total_cmp(&self.distance_m))
1710            .then_with(|| other.walk.cmp(&self.walk))
1711            .then_with(|| other.label.cmp(&self.label))
1712    }
1713}
1714
1715impl PartialOrd for ArcFrontier {
1716    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1717        Some(self.cmp(other))
1718    }
1719}
1720
1721struct ArcWorkspace {
1722    skylines: Vec<Vec<usize>>,
1723    touched: Vec<usize>,
1724    labels: Vec<ArcLabel>,
1725    heap: BinaryHeap<ArcFrontier>,
1726}
1727
1728impl ArcWorkspace {
1729    fn new(arc_count: usize) -> Self {
1730        Self {
1731            skylines: vec![Vec::new(); closure_slot_count(arc_count)],
1732            touched: Vec::new(),
1733            labels: Vec::new(),
1734            heap: BinaryHeap::new(),
1735        }
1736    }
1737
1738    fn begin(&mut self) {
1739        for slot in self.touched.drain(..) {
1740            self.skylines[slot].clear();
1741        }
1742        self.labels.clear();
1743        self.heap.clear();
1744    }
1745
1746    fn admit(&mut self, slot: usize, label: ArcLabel) -> Option<usize> {
1747        let peers = &mut self.skylines[slot];
1748        let pristine = peers.is_empty();
1749        if peers
1750            .iter()
1751            .any(|id| self.labels[*id].dominates(label.routing_cost_m, label.distance_m))
1752        {
1753            return None;
1754        }
1755        for id in peers.iter().copied() {
1756            let incumbent = &mut self.labels[id];
1757            if incumbent.is_dominated_by(label.routing_cost_m, label.distance_m) {
1758                incumbent.live = false;
1759            }
1760        }
1761        peers.retain(|id| self.labels[*id].live);
1762        let id = self.labels.len();
1763        self.labels.push(label);
1764        if pristine {
1765            self.touched.push(slot);
1766        }
1767        peers.push(id);
1768        Some(id)
1769    }
1770}
1771
1772fn arc_slot(skeleton: &RoutingSkeleton<'_>, walk: ArcWalk) -> usize {
1773    let Some(arc_id) = walk.previous else {
1774        return skeleton.arcs.len() * 2;
1775    };
1776    let arc = &skeleton.arcs[arc_id.0];
1777    arc_id.0 * 2
1778        + if walk.at == arc.a {
1779            0
1780        } else {
1781            debug_assert_eq!(walk.at, arc.b);
1782            1
1783        }
1784}
1785
1786fn recover_arc_path(labels: &[ArcLabel], mut label: usize) -> Vec<ArcId> {
1787    let mut arcs = Vec::new();
1788    while let Some(predecessor) = labels[label].predecessor {
1789        arcs.push(
1790            labels[label]
1791                .arc
1792                .expect("a non-origin arc label records its arc"),
1793        );
1794        label = predecessor;
1795    }
1796    arcs.reverse();
1797    arcs
1798}
1799
1800fn arc_simple(arcs: &[ArcId]) -> bool {
1801    let mut seen = BTreeSet::new();
1802    arcs.iter().all(|arc| seen.insert(*arc))
1803}
1804
1805fn support_path(
1806    origin: SupportWalk,
1807    mut cursor: SupportWalk,
1808    predecessor: &BTreeMap<SupportWalk, (SupportWalk, EdgeId)>,
1809) -> Vec<EdgeId> {
1810    let mut edges = Vec::new();
1811    while cursor != origin {
1812        let Some((prior, edge)) = predecessor.get(&cursor).copied() else {
1813            return Vec::new();
1814        };
1815        edges.push(edge);
1816        cursor = prior;
1817    }
1818    edges.reverse();
1819    edges
1820}
1821
1822fn closes_allowed(constraints: &LoopConstraints) -> bool {
1823    constraints.allows_shape(RouteShape::Loop) || constraints.allows_shape(RouteShape::FigureEight)
1824}
1825
1826fn push_allowed_route(
1827    routes: &mut Vec<Route>,
1828    graph: &WalkGraph,
1829    start: VertexId,
1830    edges: Vec<EdgeId>,
1831    constraints: &LoopConstraints,
1832) {
1833    if graph.walk_edges(start, &edges).is_none() {
1834        return;
1835    }
1836    let route = Route::from_edges(
1837        format!("candidate-{}", routes.len() + 1),
1838        graph,
1839        start,
1840        edges,
1841        constraints,
1842    );
1843    if constraints.allows_shape(route.metrics.shape) {
1844        routes.push(route);
1845    }
1846}
1847
1848fn finish_routes(
1849    mut routes: Vec<Route>,
1850    graph: &WalkGraph,
1851    constraints: &LoopConstraints,
1852    count: usize,
1853    keep: usize,
1854    monitor: &dyn SearchMonitor,
1855) -> Vec<Route> {
1856    if monitor.cancelled() {
1857        return Vec::new();
1858    }
1859    let mut seen = BTreeSet::new();
1860    routes.retain(|route| seen.insert(route_signature(route)));
1861    rank_routes(&mut routes, constraints);
1862    if monitor.cancelled() {
1863        return Vec::new();
1864    }
1865    routes = diverse_portfolio(routes, graph, graphless_limit(count, keep), monitor);
1866    if monitor.cancelled() {
1867        return Vec::new();
1868    }
1869    for (i, route) in routes.iter_mut().enumerate() {
1870        route.name = format!("candidate-{}", i + 1);
1871    }
1872    routes
1873}
1874
1875const fn graphless_limit(count: usize, keep: usize) -> usize {
1876    if count < 1 {
1877        1
1878    } else if count < keep {
1879        count
1880    } else {
1881        keep
1882    }
1883}
1884
1885fn diverse_portfolio(
1886    routes: Vec<Route>,
1887    graph: &WalkGraph,
1888    limit: usize,
1889    monitor: &dyn SearchMonitor,
1890) -> Vec<Route> {
1891    if routes.len() <= limit {
1892        return routes;
1893    }
1894    let tier = routes
1895        .iter()
1896        .position(|route| !route.verdict.satisfied)
1897        .unwrap_or(routes.len());
1898    let mut misses = routes;
1899    let near = misses.split_off(tier);
1900    let mut chosen = Vec::with_capacity(limit);
1901    admit_diverse_tier(misses, graph, limit, &mut chosen, monitor);
1902    admit_diverse_tier(near, graph, limit, &mut chosen, monitor);
1903    chosen
1904}
1905
1906fn admit_diverse_tier(
1907    routes: Vec<Route>,
1908    graph: &WalkGraph,
1909    limit: usize,
1910    chosen: &mut Vec<Route>,
1911    monitor: &dyn SearchMonitor,
1912) {
1913    let mut pool = routes.into_iter().map(Some).collect::<Vec<_>>();
1914    for exclusion_radius in [0.35, 0.20, 0.08, 0.0] {
1915        for candidate in &mut pool {
1916            if chosen.len() >= limit || monitor.cancelled() {
1917                break;
1918            }
1919            let admit = candidate.as_ref().is_some_and(|route| {
1920                chosen
1921                    .iter()
1922                    .all(|known| route_distance_between(graph, route, known) >= exclusion_radius)
1923            });
1924            if admit {
1925                chosen.push(candidate.take().expect("admitted route exists"));
1926            }
1927        }
1928    }
1929}
1930
1931fn route_distance_between(graph: &WalkGraph, left: &Route, right: &Route) -> f64 {
1932    let counts = |route: &Route| {
1933        let mut counts = BTreeMap::<EdgeId, u32>::new();
1934        for edge in &route.edges {
1935            let count = counts.entry(*edge).or_default();
1936            *count = count
1937                .checked_add(1)
1938                .expect("a route has fewer than 2³² legs");
1939        }
1940        counts
1941    };
1942    let left_counts = counts(left);
1943    let right_counts = counts(right);
1944    let shared_m = left_counts
1945        .iter()
1946        .map(|(edge, count)| {
1947            graph.edges[edge.0].attr.length_m
1948                * f64::from(*count.min(right_counts.get(edge).unwrap_or(&0)))
1949        })
1950        .sum::<f64>();
1951    let basis_m = left
1952        .metrics
1953        .distance_m
1954        .min(right.metrics.distance_m)
1955        .max(1.0);
1956    1.0 - shared_m / basis_m
1957}
1958
1959#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
1960struct RouteSignature {
1961    shape: RouteShape,
1962    edge_counts: Vec<(EdgeId, usize)>,
1963}
1964
1965fn route_signature(route: &Route) -> RouteSignature {
1966    let mut edge_counts = BTreeMap::<EdgeId, usize>::new();
1967    for edge in &route.edges {
1968        *edge_counts.entry(*edge).or_default() += 1;
1969    }
1970    RouteSignature {
1971        shape: route.metrics.shape,
1972        edge_counts: edge_counts.into_iter().collect(),
1973    }
1974}
1975
1976fn route_distance(graph: &WalkGraph, edges: &[EdgeId]) -> f64 {
1977    edges
1978        .iter()
1979        .map(|edge_id| graph.edges[edge_id.0].attr.length_m)
1980        .sum()
1981}
1982
1983#[derive(Clone, Copy)]
1984struct ReturnHunt<'a> {
1985    graph: &'a WalkGraph,
1986    from: VertexId,
1987    target: VertexId,
1988    previous: Option<EdgeId>,
1989    barred: &'a BTreeSet<EdgeId>,
1990    max_distance_m: f64,
1991    keep: usize,
1992    law: RoutingLaw,
1993    monitor: &'a dyn SearchMonitor,
1994    allowed: Option<&'a [bool]>,
1995}
1996
1997struct LoopCloser<'graph, 'monitor> {
1998    graph: &'graph WalkGraph,
1999    target: VertexId,
2000    keep: usize,
2001    law: RoutingLaw,
2002    monitor: &'monitor dyn SearchMonitor,
2003    allowed: Option<&'graph [bool]>,
2004    active: bool,
2005    workspace: ClosureWorkspace,
2006    oracle: Option<ClosureOracle>,
2007}
2008
2009impl<'graph, 'monitor> LoopCloser<'graph, 'monitor> {
2010    fn forge(
2011        scope: SearchScope<'graph>,
2012        target: VertexId,
2013        params: SearchParams,
2014        constraints: &LoopConstraints,
2015        monitor: &'monitor dyn SearchMonitor,
2016    ) -> Option<Self> {
2017        let active = closes_allowed(constraints);
2018        let oracle = if active && params.closure_paths <= 1 {
2019            Some(ClosureOracle::forge(
2020                scope.graph,
2021                target,
2022                scope.allowed,
2023                params.routing,
2024                monitor,
2025            )?)
2026        } else {
2027            None
2028        };
2029        Some(Self {
2030            graph: scope.graph,
2031            target,
2032            keep: params.closure_paths,
2033            law: params.routing,
2034            monitor,
2035            allowed: scope.allowed,
2036            active,
2037            workspace: ClosureWorkspace::new(scope.graph.edges.len()),
2038            oracle,
2039        })
2040    }
2041
2042    fn strike(&mut self, state: &State, constraints: &LoopConstraints, routes: &mut Vec<Route>) {
2043        if !self.active || state.at == self.target || state.edges.is_empty() {
2044            return;
2045        }
2046        let max_distance_m = constraints.max_distance_m.mul_add(1.35, -state.distance_m);
2047        let hunt = ReturnHunt {
2048            graph: self.graph,
2049            from: state.at,
2050            target: self.target,
2051            previous: state.edges.last().copied(),
2052            barred: &state.used,
2053            max_distance_m,
2054            keep: self.keep,
2055            law: self.law,
2056            monitor: self.monitor,
2057            allowed: self.allowed,
2058        };
2059        for return_edges in shortest_return_paths(hunt, &mut self.workspace, self.oracle.as_ref()) {
2060            let mut edges = state.edges.clone();
2061            edges.extend(return_edges);
2062            push_allowed_route(routes, self.graph, self.target, edges, constraints);
2063        }
2064    }
2065}
2066
2067fn shortest_return_paths(
2068    hunt: ReturnHunt<'_>,
2069    workspace: &mut ClosureWorkspace,
2070    oracle: Option<&ClosureOracle>,
2071) -> Vec<Vec<EdgeId>> {
2072    if hunt.max_distance_m < 0.0 {
2073        return Vec::new();
2074    }
2075    if hunt.keep <= 1 {
2076        return shortest_return_path(
2077            hunt,
2078            workspace,
2079            oracle.expect("single-path closure search has an oracle"),
2080        )
2081        .into_iter()
2082        .collect();
2083    }
2084    enumerate_return_paths(hunt)
2085}
2086
2087fn shortest_return_path(
2088    hunt: ReturnHunt<'_>,
2089    workspace: &mut ClosureWorkspace,
2090    oracle: &ClosureOracle,
2091) -> Option<Vec<EdgeId>> {
2092    // A* ranks by the shared reverse potential, while each turn-state keeps
2093    // the (routing cost, physical distance) skyline required by the separate
2094    // hard distance budget.
2095    workspace.begin();
2096    let origin = SupportWalk {
2097        at: hunt.from,
2098        previous: hunt.previous,
2099    };
2100    let origin_label = workspace
2101        .admit(
2102            closure_slot(hunt.graph, origin),
2103            ClosureLabel {
2104                routing_cost_m: 0.0,
2105                distance_m: 0.0,
2106                predecessor: None,
2107                edge: None,
2108                live: true,
2109            },
2110        )
2111        .expect("an empty closure skyline admits its origin");
2112    let rank_m = oracle.cost(hunt.graph, origin);
2113    if !rank_m.is_finite() {
2114        return None;
2115    }
2116    workspace.heap.push(ClosureFrontier {
2117        rank_m,
2118        routing_cost_m: 0.0,
2119        distance_m: 0.0,
2120        walk: origin,
2121        label: origin_label,
2122    });
2123    let expansion_cap = return_expansion_cap(1, hunt.graph.edges.len());
2124    let mut expanded = 0usize;
2125
2126    while let Some(frontier) = workspace.heap.pop() {
2127        if hunt.monitor.cancelled() {
2128            return None;
2129        }
2130        if expanded >= expansion_cap {
2131            break;
2132        }
2133        if !workspace.labels[frontier.label].live {
2134            continue;
2135        }
2136        expanded += 1;
2137        if frontier.walk.at == hunt.target && frontier.label != origin_label {
2138            let path = recover_closure_path(&workspace.labels, frontier.label);
2139            if edge_simple(&path) {
2140                return Some(path);
2141            }
2142            continue;
2143        }
2144        for edge_id in &hunt.graph.adjacency[frontier.walk.at.0] {
2145            if hunt.allowed.is_some_and(|allowed| !allowed[edge_id.0])
2146                || hunt.barred.contains(edge_id)
2147                || frontier.walk.previous == Some(*edge_id)
2148                || !hunt
2149                    .graph
2150                    .turn_allowed(frontier.walk.previous, frontier.walk.at, *edge_id)
2151            {
2152                continue;
2153            }
2154            let Some(edge_cost) = hunt.law.edge_cost(hunt.graph, *edge_id) else {
2155                continue;
2156            };
2157            let edge = &hunt.graph.edges[edge_id.0];
2158            let Some(at) = edge.traverse(frontier.walk.at) else {
2159                continue;
2160            };
2161            let distance_m = frontier.distance_m + edge.attr.length_m;
2162            if distance_m > hunt.max_distance_m {
2163                continue;
2164            }
2165            let routing_cost_m = frontier.routing_cost_m + edge_cost;
2166            let walk = SupportWalk {
2167                at,
2168                previous: Some(*edge_id),
2169            };
2170            let remaining_cost_m = oracle.cost(hunt.graph, walk);
2171            if !remaining_cost_m.is_finite() {
2172                continue;
2173            }
2174            let Some(label) = workspace.admit(
2175                closure_slot(hunt.graph, walk),
2176                ClosureLabel {
2177                    routing_cost_m,
2178                    distance_m,
2179                    predecessor: Some(frontier.label),
2180                    edge: Some(*edge_id),
2181                    live: true,
2182                },
2183            ) else {
2184                continue;
2185            };
2186            workspace.heap.push(ClosureFrontier {
2187                rank_m: routing_cost_m + remaining_cost_m,
2188                routing_cost_m,
2189                distance_m,
2190                walk,
2191                label,
2192            });
2193        }
2194    }
2195    None
2196}
2197
2198fn enumerate_return_paths(hunt: ReturnHunt<'_>) -> Vec<Vec<EdgeId>> {
2199    let keep = hunt.keep.max(1);
2200    let expansion_cap = return_expansion_cap(keep, hunt.graph.edges.len());
2201    let mut heap = BinaryHeap::new();
2202    heap.push(ReturnPathState {
2203        routing_cost_m: 0.0,
2204        distance_m: 0.0,
2205        at: hunt.from,
2206        previous: hunt.previous,
2207        edges: Vec::new(),
2208        used: hunt.barred.clone(),
2209    });
2210    let mut expanded = 0usize;
2211    let mut paths = Vec::new();
2212
2213    while let Some(state) = heap.pop() {
2214        if hunt.monitor.cancelled() {
2215            return Vec::new();
2216        }
2217        if paths.len() >= keep || expanded >= expansion_cap {
2218            break;
2219        }
2220        expanded += 1;
2221        if state.at == hunt.target && !state.edges.is_empty() {
2222            paths.push(state.edges);
2223            continue;
2224        }
2225        for edge_id in &hunt.graph.adjacency[state.at.0] {
2226            if hunt.allowed.is_some_and(|allowed| !allowed[edge_id.0]) {
2227                continue;
2228            }
2229            if state.used.contains(edge_id) {
2230                continue;
2231            }
2232            if !hunt.graph.turn_allowed(state.previous, state.at, *edge_id) {
2233                continue;
2234            }
2235            let Some(edge_cost) = hunt.law.edge_cost(hunt.graph, *edge_id) else {
2236                continue;
2237            };
2238            let edge = &hunt.graph.edges[edge_id.0];
2239            let Some(next) = edge.traverse(state.at) else {
2240                continue;
2241            };
2242            let distance_m = state.distance_m + edge.attr.length_m;
2243            if distance_m > hunt.max_distance_m {
2244                continue;
2245            }
2246            let mut edges = state.edges.clone();
2247            edges.push(*edge_id);
2248            let mut used = state.used.clone();
2249            used.insert(*edge_id);
2250            heap.push(ReturnPathState {
2251                routing_cost_m: state.routing_cost_m + edge_cost,
2252                distance_m,
2253                at: next,
2254                previous: Some(*edge_id),
2255                edges,
2256                used,
2257            });
2258        }
2259    }
2260    paths
2261}
2262
2263fn return_expansion_cap(keep: usize, edge_count: usize) -> usize {
2264    keep.saturating_mul(edge_count.max(1))
2265        .saturating_mul(8)
2266        .max(64)
2267}
2268
2269fn sort_heuristic_fanout(
2270    graph: &WalkGraph,
2271    fanout: &mut [EdgeId],
2272    seed: u64,
2273    depth: usize,
2274    at: VertexId,
2275    law: RoutingLaw,
2276) {
2277    fanout.sort_by(|a, b| {
2278        branch_score(graph, *b, seed, depth, at, law)
2279            .total_cmp(&branch_score(graph, *a, seed, depth, at, law))
2280            .then_with(|| b.cmp(a))
2281    });
2282}
2283
2284fn branch_score(
2285    graph: &WalkGraph,
2286    edge_id: EdgeId,
2287    seed: u64,
2288    depth: usize,
2289    at: VertexId,
2290    law: RoutingLaw,
2291) -> f64 {
2292    let edge = &graph.edges[edge_id.0];
2293    let road_detour_km = law
2294        .edge_cost(graph, edge_id)
2295        .map_or(f64::MAX, |cost| (cost - edge.attr.length_m) / 1_000.0);
2296    (edge.traversal_from(at).lower_limb_load_km + road_detour_km)
2297        .mul_add(1_024.0, seeded_unit(seed, depth, at, edge_id) * 128.0)
2298}
2299
2300fn seeded_unit(seed: u64, depth: usize, at: VertexId, edge_id: EdgeId) -> f64 {
2301    let hash = splitmix64(seed ^ ((depth as u64) << 48) ^ ((at.0 as u64) << 24) ^ edge_id.0 as u64);
2302    let bits = u32::try_from(hash >> 32).expect("shifted splitmix output fits in u32");
2303    f64::from(bits) * (1.0 / f64::from(u32::MAX))
2304}
2305
2306const fn splitmix64(mut x: u64) -> u64 {
2307    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
2308    x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
2309    x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
2310    x ^ (x >> 31)
2311}
2312
2313#[derive(Clone, Debug, PartialEq)]
2314struct ClosureLabel {
2315    routing_cost_m: f64,
2316    distance_m: f64,
2317    predecessor: Option<usize>,
2318    edge: Option<EdgeId>,
2319    live: bool,
2320}
2321
2322impl ClosureLabel {
2323    fn dominates(&self, routing_cost_m: f64, distance_m: f64) -> bool {
2324        self.live && self.routing_cost_m <= routing_cost_m && self.distance_m <= distance_m
2325    }
2326
2327    fn is_dominated_by(&self, routing_cost_m: f64, distance_m: f64) -> bool {
2328        routing_cost_m <= self.routing_cost_m && distance_m <= self.distance_m
2329    }
2330}
2331
2332struct ClosureWorkspace {
2333    skylines: Vec<Vec<usize>>,
2334    touched: Vec<usize>,
2335    labels: Vec<ClosureLabel>,
2336    heap: BinaryHeap<ClosureFrontier>,
2337}
2338
2339impl ClosureWorkspace {
2340    fn new(edge_count: usize) -> Self {
2341        Self {
2342            skylines: vec![Vec::new(); closure_slot_count(edge_count)],
2343            touched: Vec::new(),
2344            labels: Vec::new(),
2345            heap: BinaryHeap::new(),
2346        }
2347    }
2348
2349    fn begin(&mut self) {
2350        for slot in self.touched.drain(..) {
2351            self.skylines[slot].clear();
2352        }
2353        self.labels.clear();
2354        self.heap.clear();
2355    }
2356
2357    fn admit(&mut self, slot: usize, label: ClosureLabel) -> Option<usize> {
2358        let peers = &mut self.skylines[slot];
2359        let pristine = peers.is_empty();
2360        if peers
2361            .iter()
2362            .any(|id| self.labels[*id].dominates(label.routing_cost_m, label.distance_m))
2363        {
2364            return None;
2365        }
2366        for id in peers.iter().copied() {
2367            let incumbent = &mut self.labels[id];
2368            if incumbent.is_dominated_by(label.routing_cost_m, label.distance_m) {
2369                incumbent.live = false;
2370            }
2371        }
2372        peers.retain(|id| self.labels[*id].live);
2373        let id = self.labels.len();
2374        self.labels.push(label);
2375        if pristine {
2376            self.touched.push(slot);
2377        }
2378        peers.push(id);
2379        Some(id)
2380    }
2381}
2382
2383/// Exact shortest-cost potentials on the directed turn-state graph. Per-path
2384/// outbound-edge bans are deliberately relaxed, so these shared values remain
2385/// admissible for every closure attempted by one loop search.
2386struct ClosureOracle {
2387    cost_m: Vec<f64>,
2388}
2389
2390impl ClosureOracle {
2391    fn forge(
2392        graph: &WalkGraph,
2393        target: VertexId,
2394        allowed: Option<&[bool]>,
2395        law: RoutingLaw,
2396        monitor: &dyn SearchMonitor,
2397    ) -> Option<Self> {
2398        let mut cost_m = vec![f64::INFINITY; closure_slot_count(graph.edges.len())];
2399        let mut heap = BinaryHeap::new();
2400        let mut incoming = vec![Vec::new(); graph.vertices.len()];
2401        for edge in &graph.edges {
2402            if allowed.is_some_and(|allowed| !allowed[edge.id.0])
2403                || law.edge_cost(graph, edge.id).is_none()
2404            {
2405                continue;
2406            }
2407            if edge.traverse(edge.a) == Some(edge.b) {
2408                incoming[edge.b.0].push(edge.id);
2409            }
2410            if edge.traverse(edge.b) == Some(edge.a) {
2411                incoming[edge.a.0].push(edge.id);
2412            }
2413        }
2414        for edge_id in &incoming[target.0] {
2415            let walk = SupportWalk {
2416                at: target,
2417                previous: Some(*edge_id),
2418            };
2419            let slot = closure_slot(graph, walk);
2420            cost_m[slot] = 0.0;
2421            heap.push(SupportFrontier { cost: 0.0, walk });
2422        }
2423
2424        while let Some(SupportFrontier { cost, walk }) = heap.pop() {
2425            if monitor.cancelled() {
2426                return None;
2427            }
2428            if cost > cost_m[closure_slot(graph, walk)] {
2429                continue;
2430            }
2431            let edge_id = walk
2432                .previous
2433                .expect("an oracle frontier always follows an edge");
2434            let edge = &graph.edges[edge_id.0];
2435            let Some(via) = edge.other(walk.at) else {
2436                continue;
2437            };
2438            if edge.traverse(via) != Some(walk.at) {
2439                continue;
2440            }
2441            let edge_cost_m = law
2442                .edge_cost(graph, edge_id)
2443                .expect("an oracle frontier only contains lawful edges");
2444            for prior in &incoming[via.0] {
2445                if *prior == edge_id {
2446                    continue;
2447                }
2448                if !graph.turn_allowed(Some(*prior), via, edge_id) {
2449                    continue;
2450                }
2451                let predecessor = SupportWalk {
2452                    at: via,
2453                    previous: Some(*prior),
2454                };
2455                let slot = closure_slot(graph, predecessor);
2456                let candidate_m = cost + edge_cost_m;
2457                if candidate_m < cost_m[slot] {
2458                    cost_m[slot] = candidate_m;
2459                    heap.push(SupportFrontier {
2460                        cost: candidate_m,
2461                        walk: predecessor,
2462                    });
2463                }
2464            }
2465        }
2466        Some(Self { cost_m })
2467    }
2468
2469    fn cost(&self, graph: &WalkGraph, walk: SupportWalk) -> f64 {
2470        self.cost_m[closure_slot(graph, walk)]
2471    }
2472}
2473
2474fn closure_slot(graph: &WalkGraph, walk: SupportWalk) -> usize {
2475    let Some(edge_id) = walk.previous else {
2476        return graph.edges.len() * 2;
2477    };
2478    let edge = &graph.edges[edge_id.0];
2479    edge_id.0 * 2
2480        + if walk.at == edge.a {
2481            0
2482        } else {
2483            debug_assert_eq!(walk.at, edge.b);
2484            1
2485        }
2486}
2487
2488fn closure_slot_count(edge_count: usize) -> usize {
2489    edge_count
2490        .checked_mul(2)
2491        .and_then(|slots| slots.checked_add(1))
2492        .expect("a trail graph has fewer than usize::MAX / 2 edges")
2493}
2494
2495#[derive(Clone, Copy, Debug, PartialEq)]
2496struct ClosureFrontier {
2497    rank_m: f64,
2498    routing_cost_m: f64,
2499    distance_m: f64,
2500    walk: SupportWalk,
2501    label: usize,
2502}
2503
2504impl Eq for ClosureFrontier {}
2505
2506impl Ord for ClosureFrontier {
2507    fn cmp(&self, rhs: &Self) -> Ordering {
2508        rhs.rank_m
2509            .total_cmp(&self.rank_m)
2510            .then_with(|| rhs.routing_cost_m.total_cmp(&self.routing_cost_m))
2511            .then_with(|| rhs.distance_m.total_cmp(&self.distance_m))
2512            .then_with(|| rhs.walk.cmp(&self.walk))
2513            .then_with(|| rhs.label.cmp(&self.label))
2514    }
2515}
2516
2517impl PartialOrd for ClosureFrontier {
2518    fn partial_cmp(&self, rhs: &Self) -> Option<Ordering> {
2519        Some(self.cmp(rhs))
2520    }
2521}
2522
2523fn recover_closure_path(labels: &[ClosureLabel], mut label: usize) -> Vec<EdgeId> {
2524    let mut path = Vec::new();
2525    loop {
2526        let current = &labels[label];
2527        let Some(edge) = current.edge else {
2528            break;
2529        };
2530        path.push(edge);
2531        label = current
2532            .predecessor
2533            .expect("every closure edge has a predecessor");
2534    }
2535    path.reverse();
2536    path
2537}
2538
2539fn edge_simple(path: &[EdgeId]) -> bool {
2540    let mut seen = BTreeSet::new();
2541    path.iter().all(|edge| seen.insert(*edge))
2542}
2543
2544#[derive(Clone, Debug, PartialEq)]
2545struct ReturnPathState {
2546    routing_cost_m: f64,
2547    distance_m: f64,
2548    at: VertexId,
2549    previous: Option<EdgeId>,
2550    edges: Vec<EdgeId>,
2551    used: BTreeSet<EdgeId>,
2552}
2553
2554impl Eq for ReturnPathState {}
2555
2556impl Ord for ReturnPathState {
2557    fn cmp(&self, rhs: &Self) -> Ordering {
2558        rhs.routing_cost_m
2559            .total_cmp(&self.routing_cost_m)
2560            .then_with(|| rhs.distance_m.total_cmp(&self.distance_m))
2561            .then_with(|| rhs.at.cmp(&self.at))
2562            .then_with(|| rhs.edges.cmp(&self.edges))
2563    }
2564}
2565
2566impl PartialOrd for ReturnPathState {
2567    fn partial_cmp(&self, rhs: &Self) -> Option<Ordering> {
2568        Some(self.cmp(rhs))
2569    }
2570}
2571
2572fn mirrored_route(edges: &[EdgeId]) -> Vec<EdgeId> {
2573    edges
2574        .iter()
2575        .copied()
2576        .chain(edges.iter().rev().copied())
2577        .collect()
2578}
2579
2580#[cfg(test)]
2581mod tests {
2582    use super::*;
2583    use crate::{
2584        Access, Coord, CrossingControl, EdgeTravel, GeometryClaim, GraphBuilder, JunctionPolicy,
2585        LineString, Provenance, SegmentDraft, Terrain, TrailStanding, WayKind, WayRealm,
2586    };
2587    use std::cell::{Cell, RefCell};
2588
2589    struct RecordingMonitor {
2590        cancel_after: usize,
2591        checks: Cell<usize>,
2592        progress: RefCell<Vec<SearchProgress>>,
2593        previews: RefCell<Vec<usize>>,
2594    }
2595
2596    impl RecordingMonitor {
2597        fn patient() -> Self {
2598            Self {
2599                cancel_after: usize::MAX,
2600                checks: Cell::new(0),
2601                progress: RefCell::new(Vec::new()),
2602                previews: RefCell::new(Vec::new()),
2603            }
2604        }
2605    }
2606
2607    impl SearchMonitor for RecordingMonitor {
2608        fn cancelled(&self) -> bool {
2609            let checks = self.checks.get().saturating_add(1);
2610            self.checks.set(checks);
2611            checks >= self.cancel_after
2612        }
2613
2614        fn report(&self, progress: SearchProgress) {
2615            self.progress.borrow_mut().push(progress);
2616        }
2617
2618        fn preview(&self, routes: &[Route]) {
2619            self.previews.borrow_mut().push(routes.len());
2620        }
2621    }
2622
2623    fn branch(points: Vec<Coord>, name: &str) -> SegmentDraft {
2624        SegmentDraft {
2625            geometry: LineString::new(points).expect("valid branch"),
2626            junctions: JunctionPolicy::Planar,
2627            turn_ref: None,
2628            junction_keys: None,
2629            turn_restrictions: Vec::new(),
2630            way_kind: WayKind::Path,
2631            realm: WayRealm::default(),
2632            geometry_claim: GeometryClaim::default(),
2633            crossing_control: CrossingControl::default(),
2634            standing: TrailStanding::Established,
2635            marking: crate::TrailMarking::default(),
2636            terrain: Terrain::Trail,
2637            terrain_confidence: Some(1.0),
2638            surface: None,
2639            access: Access::Open,
2640            travel: EdgeTravel::Both,
2641            road_exposure: 0.0,
2642            confidence: 1.0,
2643            provenance: vec![Provenance::fixture(name)],
2644        }
2645    }
2646
2647    fn named_edge(graph: &WalkGraph, name: &str) -> EdgeId {
2648        graph
2649            .edges
2650            .iter()
2651            .find(|edge| {
2652                edge.attr
2653                    .provenance
2654                    .iter()
2655                    .any(|source| source.source_id.as_deref() == Some(name))
2656            })
2657            .map(|edge| edge.id)
2658            .expect("fixture edge exists")
2659    }
2660
2661    #[test]
2662    fn support_lower_bounds_target_the_feasibility_floor() {
2663        let low = Coord::new(0.001, 0.0);
2664        let midpoint = Coord::new(0.002, 0.0);
2665        let graph = GraphBuilder::default()
2666            .build(&[branch(
2667                vec![Coord::new(0.0, 0.0), low, midpoint],
2668                "lower-bound-axis",
2669            )])
2670            .expect("build lower-bound fixture");
2671        let low = graph.nearest_vertex(low).expect("low landmark");
2672        let midpoint = graph.nearest_vertex(midpoint).expect("midpoint landmark");
2673        let mut radial = vec![f64::INFINITY; graph.vertices.len()];
2674        radial[low.0] = 25_000.0;
2675        radial[midpoint.0] = 37_500.0;
2676        let constraints = LoopConstraints {
2677            min_distance_m: 50_000.0,
2678            max_distance_m: 100_000.0,
2679            ..LoopConstraints::default()
2680        };
2681
2682        let designs = support_designs(
2683            &graph,
2684            &radial,
2685            &[low, midpoint],
2686            &constraints,
2687            SearchParams {
2688                keep: 1,
2689                ..SearchParams::default()
2690            },
2691        );
2692
2693        assert_eq!(designs[0].supports, [low]);
2694        assert!((designs[0].lower_bound_m - constraints.min_distance_m).abs() <= f64::EPSILON);
2695    }
2696
2697    #[test]
2698    fn routing_skeleton_crushes_shape_points_without_losing_support() {
2699        let corners = [
2700            Coord::new(0.0, 0.0),
2701            Coord::new(0.01, 0.0),
2702            Coord::new(0.01, 0.01),
2703            Coord::new(0.0, 0.01),
2704            Coord::new(0.0, 0.0),
2705        ];
2706        let mut points = Vec::new();
2707        for side in corners.windows(2) {
2708            for step in 0..40_u32 {
2709                let t = f64::from(step) / 40.0;
2710                points.push(Coord::new(
2711                    (side[1].lon - side[0].lon).mul_add(t, side[0].lon),
2712                    (side[1].lat - side[0].lat).mul_add(t, side[0].lat),
2713                ));
2714            }
2715        }
2716        points.push(corners[4]);
2717        let graph = GraphBuilder::default()
2718            .build(&[branch(points, "fine-ring")])
2719            .expect("build fine ring");
2720        let start = graph.nearest_vertex(corners[0]).expect("ring origin");
2721        let skeleton = RoutingSkeleton::forge_preserving(
2722            SearchScope::all(&graph),
2723            start,
2724            RoutingLaw::default(),
2725            std::iter::empty(),
2726        );
2727
2728        assert_eq!(graph.edges.len(), 160);
2729        assert_eq!(skeleton.arcs.len(), 3);
2730        assert_eq!(
2731            skeleton
2732                .arcs
2733                .iter()
2734                .map(|arc| arc.edges.len())
2735                .sum::<usize>(),
2736            graph.edges.len()
2737        );
2738    }
2739
2740    #[test]
2741    fn warmed_revisions_obey_required_and_forbidden_segments() {
2742        let ring = [
2743            Coord::new(0.0, 0.0),
2744            Coord::new(0.002, 0.0),
2745            Coord::new(0.002, 0.002),
2746            Coord::new(0.0, 0.002),
2747            Coord::new(0.0, 0.0),
2748        ];
2749        let graph = GraphBuilder::default()
2750            .build(&[branch(ring.to_vec(), "ring")])
2751            .expect("build ring");
2752        let start = graph.nearest_vertex(ring[0]).expect("ring origin");
2753        let constraints = LoopConstraints {
2754            min_distance_m: 0.0,
2755            max_distance_m: 10_000.0,
2756            max_lower_limb_load_km: f64::MAX,
2757            max_repeated_edge_fraction: 0.0,
2758            allowed_shapes: vec![RouteShape::Loop],
2759            ..LoopConstraints::default()
2760        };
2761        let params = SearchParams {
2762            max_frontier: 1_000,
2763            keep: 12,
2764            ..SearchParams::default()
2765        };
2766        let incumbents = SolverKind::Auto.solve(params, &graph, start, &constraints, 3);
2767        let incumbent = incumbents.first().expect("ring yields a loop");
2768        let compulsory = incumbent.edges[0];
2769        let mut edicts = EdgeEdicts::default();
2770        edicts.toggle_required(compulsory);
2771        let revised = SolverKind::Auto.revise_scoped(
2772            params,
2773            SearchScope::all(&graph),
2774            start,
2775            &constraints,
2776            3,
2777            &edicts,
2778            &[],
2779            &(),
2780        );
2781        assert!(!revised.is_empty());
2782        assert!(revised.iter().all(|route| edicts.admits(route)));
2783
2784        edicts.toggle_forbidden(compulsory);
2785        assert_eq!(edicts.disposition(compulsory), EdgeDisposition::Forbidden);
2786        let revised = SolverKind::Auto.revise_scoped(
2787            params,
2788            SearchScope::all(&graph),
2789            start,
2790            &constraints,
2791            3,
2792            &edicts,
2793            &incumbents,
2794            &(),
2795        );
2796        assert!(
2797            revised
2798                .iter()
2799                .all(|route| !route.edges.contains(&compulsory))
2800        );
2801        edicts.toggle_forbidden(compulsory);
2802        assert_eq!(edicts.disposition(compulsory), EdgeDisposition::Free);
2803    }
2804
2805    #[test]
2806    fn closure_oracle_preserves_cost_distance_pareto_labels() {
2807        let approach = Coord::new(-0.001, 0.0);
2808        let from = Coord::new(0.0, 0.0);
2809        let bend = Coord::new(0.002, 0.003);
2810        let junction = Coord::new(0.004, 0.0);
2811        let merge = Coord::new(0.005, 0.0);
2812        let target = Coord::new(0.008, 0.0);
2813        let mut road = branch(vec![from, junction], "short-road");
2814        road.way_kind = WayKind::Roadway;
2815        road.terrain = Terrain::Road;
2816        road.road_exposure = 1.0;
2817        let graph = GraphBuilder::default()
2818            .build(&[
2819                branch(vec![approach, from], "approach"),
2820                branch(vec![from, bend, junction], "long-clean"),
2821                road,
2822                branch(vec![junction, merge], "common"),
2823                branch(vec![merge, target], "tail"),
2824            ])
2825            .expect("build cost-distance closure fixture");
2826        let from = graph.nearest_vertex(from).expect("closure origin");
2827        let target = graph.nearest_vertex(target).expect("closure target");
2828        let approach = named_edge(&graph, "approach");
2829        let road = named_edge(&graph, "short-road");
2830        let barred = BTreeSet::from([approach]);
2831        let law = RoutingLaw { road_aversion: 2.0 };
2832        let oracle =
2833            ClosureOracle::forge(&graph, target, None, law, &()).expect("forge closure oracle");
2834        let mut workspace = ClosureWorkspace::new(graph.edges.len());
2835        let paths = shortest_return_paths(
2836            ReturnHunt {
2837                graph: &graph,
2838                from,
2839                target,
2840                previous: Some(approach),
2841                barred: &barred,
2842                max_distance_m: 1_000.0,
2843                keep: 1,
2844                law,
2845                monitor: &(),
2846                allowed: None,
2847            },
2848            &mut workspace,
2849            Some(&oracle),
2850        );
2851
2852        assert_eq!(paths.len(), 1);
2853        let path = &paths[0];
2854        assert_eq!(graph.walk_edges(from, path), Some(target));
2855        assert!(edge_simple(path));
2856        assert!(path.contains(&road));
2857        assert!(route_distance(&graph, path) <= 1_000.0);
2858    }
2859
2860    #[test]
2861    fn out_and_back_portfolio_spends_slots_on_distinct_spines_first() {
2862        let origin = Coord::new(0.0, 0.0);
2863        let graph = GraphBuilder::default()
2864            .build(&[
2865                branch(
2866                    vec![
2867                        origin,
2868                        Coord::new(0.001, 0.0),
2869                        Coord::new(0.002, 0.0),
2870                        Coord::new(0.003, 0.0),
2871                    ],
2872                    "east",
2873                ),
2874                branch(
2875                    vec![
2876                        origin,
2877                        Coord::new(0.0, 0.001),
2878                        Coord::new(0.0, 0.002),
2879                        Coord::new(0.0, 0.003),
2880                    ],
2881                    "north",
2882                ),
2883                branch(
2884                    vec![
2885                        origin,
2886                        Coord::new(-0.001, 0.0),
2887                        Coord::new(-0.002, 0.0),
2888                        Coord::new(-0.003, 0.0),
2889                    ],
2890                    "west",
2891                ),
2892            ])
2893            .expect("build branching graph");
2894        let start = graph.nearest_vertex(origin).expect("origin vertex");
2895        let constraints = LoopConstraints {
2896            min_distance_m: 200.0,
2897            max_distance_m: 800.0,
2898            max_lower_limb_load_km: f64::MAX,
2899            max_repeated_edge_fraction: 1.0,
2900            allowed_shapes: vec![RouteShape::OutAndBack],
2901            ..LoopConstraints::default()
2902        };
2903        let routes = SolverKind::Auto.solve(
2904            SearchParams {
2905                max_hops: 4,
2906                max_frontier: 100,
2907                keep: 12,
2908                closure_paths: 1,
2909                seed: 0,
2910                routing: RoutingLaw::default(),
2911            },
2912            &graph,
2913            start,
2914            &constraints,
2915            3,
2916        );
2917        assert_eq!(routes.len(), 3);
2918        assert_eq!(
2919            routes
2920                .iter()
2921                .map(|route| route.edges[0])
2922                .collect::<BTreeSet<_>>()
2923                .len(),
2924            3
2925        );
2926    }
2927
2928    #[test]
2929    fn restricted_scope_never_leaks_a_forbidden_edge() {
2930        let origin = Coord::new(0.0, 0.0);
2931        let graph = GraphBuilder::default()
2932            .build(&[
2933                branch(
2934                    vec![origin, Coord::new(0.001, 0.0), Coord::new(0.002, 0.0)],
2935                    "allowed",
2936                ),
2937                branch(
2938                    vec![origin, Coord::new(0.0, 0.001), Coord::new(0.0, 0.002)],
2939                    "forbidden",
2940                ),
2941            ])
2942            .expect("build scoped graph");
2943        let allowed = graph
2944            .edges
2945            .iter()
2946            .map(|edge| {
2947                edge.geometry
2948                    .points
2949                    .iter()
2950                    .all(|point| point.lat.abs() < f64::EPSILON)
2951            })
2952            .collect::<Vec<_>>();
2953        let constraints = LoopConstraints {
2954            min_distance_m: 100.0,
2955            max_distance_m: 1_000.0,
2956            max_lower_limb_load_km: f64::MAX,
2957            max_repeated_edge_fraction: 1.0,
2958            allowed_shapes: vec![RouteShape::OutAndBack],
2959            ..LoopConstraints::default()
2960        };
2961        let routes = SolverKind::Auto.solve_scoped(
2962            SearchParams::default(),
2963            SearchScope::restricted(&graph, &allowed),
2964            graph.nearest_vertex(origin).expect("origin vertex"),
2965            &constraints,
2966            3,
2967            &(),
2968        );
2969
2970        assert!(!routes.is_empty());
2971        assert!(
2972            routes
2973                .iter()
2974                .flat_map(|route| &route.edges)
2975                .all(|edge| allowed[edge.0])
2976        );
2977    }
2978
2979    #[test]
2980    fn diversity_never_spends_a_slot_on_a_near_miss_while_matches_remain() {
2981        let origin = Coord::new(0.0, 0.0);
2982        let graph = GraphBuilder::default()
2983            .build(&[
2984                branch(
2985                    vec![
2986                        origin,
2987                        Coord::new(0.001, 0.0),
2988                        Coord::new(0.002, 0.0),
2989                        Coord::new(0.003, 0.0),
2990                    ],
2991                    "exact-spine",
2992                ),
2993                branch(vec![origin, Coord::new(0.0, 0.0004)], "short-near-miss"),
2994            ])
2995            .expect("build tiered graph");
2996        let start = graph.nearest_vertex(origin).expect("origin vertex");
2997        let constraints = LoopConstraints {
2998            min_distance_m: 200.0,
2999            max_distance_m: 800.0,
3000            max_lower_limb_load_km: f64::MAX,
3001            max_repeated_edge_fraction: 1.0,
3002            allowed_shapes: vec![RouteShape::OutAndBack],
3003            ..LoopConstraints::default()
3004        };
3005        let routes = SolverKind::Auto.solve(
3006            SearchParams {
3007                keep: 12,
3008                max_frontier: 100,
3009                ..SearchParams::default()
3010            },
3011            &graph,
3012            start,
3013            &constraints,
3014            3,
3015        );
3016        assert_eq!(routes.len(), 3);
3017        assert!(routes.iter().all(|route| route.verdict.satisfied));
3018    }
3019
3020    #[test]
3021    fn monitored_search_reports_monotone_effort_and_ranking() {
3022        let origin = Coord::new(0.0, 0.0);
3023        let graph = GraphBuilder::default()
3024            .build(&[branch(
3025                vec![
3026                    origin,
3027                    Coord::new(0.001, 0.0),
3028                    Coord::new(0.002, 0.0),
3029                    Coord::new(0.003, 0.0),
3030                ],
3031                "monitored",
3032            )])
3033            .expect("build monitored graph");
3034        let start = graph.nearest_vertex(origin).expect("origin vertex");
3035        let constraints = LoopConstraints {
3036            min_distance_m: 0.0,
3037            max_distance_m: 1_000.0,
3038            max_lower_limb_load_km: f64::MAX,
3039            max_repeated_edge_fraction: 1.0,
3040            allowed_shapes: vec![RouteShape::OutAndBack],
3041            ..LoopConstraints::default()
3042        };
3043        let monitor = RecordingMonitor::patient();
3044
3045        let _routes = SolverKind::Auto.solve_monitored(
3046            SearchParams {
3047                max_frontier: 100,
3048                ..SearchParams::default()
3049            },
3050            &graph,
3051            start,
3052            &constraints,
3053            3,
3054            &monitor,
3055        );
3056
3057        let progress = monitor.progress.borrow();
3058        assert_eq!(
3059            progress.first().map(|progress| progress.stage),
3060            Some(SearchStage::Exploring)
3061        );
3062        assert_eq!(
3063            progress.last().map(|progress| progress.stage),
3064            Some(SearchStage::Ranking)
3065        );
3066        assert!(
3067            progress
3068                .windows(2)
3069                .all(|pair| pair[0].explored <= pair[1].explored)
3070        );
3071        assert!(progress.iter().all(|progress| progress.explored <= 100));
3072        let previews = monitor.previews.borrow();
3073        assert!(previews.first().is_some_and(|count| *count > 0));
3074        assert!(previews.windows(2).all(|pair| pair[0] < pair[1]));
3075        assert_eq!(
3076            previews.last().copied(),
3077            progress.last().map(|progress| progress.candidates)
3078        );
3079    }
3080
3081    #[test]
3082    fn monitored_search_obeys_cooperative_cancellation() {
3083        let origin = Coord::new(0.0, 0.0);
3084        let graph = GraphBuilder::default()
3085            .build(&[branch(
3086                vec![
3087                    origin,
3088                    Coord::new(0.001, 0.0),
3089                    Coord::new(0.002, 0.0),
3090                    Coord::new(0.003, 0.0),
3091                ],
3092                "cancelled",
3093            )])
3094            .expect("build cancellable graph");
3095        let start = graph.nearest_vertex(origin).expect("origin vertex");
3096        let constraints = LoopConstraints {
3097            min_distance_m: 0.0,
3098            max_distance_m: 1_000.0,
3099            max_lower_limb_load_km: f64::MAX,
3100            max_repeated_edge_fraction: 1.0,
3101            allowed_shapes: vec![RouteShape::OutAndBack],
3102            ..LoopConstraints::default()
3103        };
3104        let monitor = RecordingMonitor {
3105            cancel_after: 3,
3106            checks: Cell::new(0),
3107            progress: RefCell::new(Vec::new()),
3108            previews: RefCell::new(Vec::new()),
3109        };
3110
3111        let routes = SolverKind::Auto.solve_monitored(
3112            SearchParams::default(),
3113            &graph,
3114            start,
3115            &constraints,
3116            3,
3117            &monitor,
3118        );
3119
3120        assert!(routes.is_empty());
3121        assert!(
3122            monitor
3123                .progress
3124                .borrow()
3125                .iter()
3126                .all(|progress| progress.stage != SearchStage::Ranking)
3127        );
3128    }
3129}