deke_multipath/lib.rs
1//! Optimal ordering and orientation of *required* robot paths.
2//!
3//! Given a set of paths that must each be traversed exactly once — some of
4//! which may be reversible or have several interchangeable realizations — this
5//! crate chooses one option per required path and orders them to minimise total
6//! motion cost, starting at a configuration and optionally ending at one. It
7//! returns the full stitched motion: connector segments interleaved with the
8//! chosen required paths.
9//!
10//! The ordering is an asymmetric generalized TSP (see [`agtsp`]); cost is any
11//! `Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> f64` scoring a transition between
12//! two configurations. [`weighted_euclidean`], [`planned_path_length`] and
13//! [`planned_trajectory_time`] build the common ones (cheap joint distance,
14//! planner arc length, retimed trajectory time). The planner passed to
15//! [`plan_multipath`] is only used to *generate* connector paths in the output
16//! — [`plan_multipath_straight`] emits validated straight-line connectors
17//! instead.
18//!
19//! If you already hold a precomputed `option × option` cost matrix and want the
20//! chosen option index per cluster (rather than stitched paths), call
21//! [`solve_matrix`] / [`solve_matrix_multi_start`] directly.
22//!
23//! ```no_run
24//! # use deke_multipath::*;
25//! # use deke_types::{SRobotQ, SRobotPath, Planner, Validator};
26//! # fn go<'ctx, const N: usize, P, V, MW>(
27//! # paths: Vec<ReqPath<N>>, planner: &P, cfg: &P::Config, make_wp: MW,
28//! # validator: &V, ctx: &V::Context<'ctx>, start: SRobotQ<N, f64>, weights: SRobotQ<N, f64>,
29//! # ) -> MultipathResult<Vec<SRobotPath<N, f64>>>
30//! # where P: Planner<N, f64> + Sync, P::Config: Sync, V: Validator<N, (), f64>,
31//! # V::Context<'ctx>: Sync,
32//! # MW: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> P::Waypoints + Sync {
33//! let settings = MultiPathSettings::new(start);
34//! let cost = weighted_euclidean(weights);
35//! let transition = TransitionPlanner { planner, config: cfg, make_waypoints: make_wp };
36//! plan_multipath(&paths, &cost, &settings, &transition, validator, ctx)
37//! # }
38//! ```
39//!
40//! # Feature flags
41//!
42//! - `rayon` — use rayon to plan connectors concurrently and to fan out the
43//! heuristic's seed tours. Worth enabling when connector planning is the
44//! bottleneck (real collision-checked RRT, where each plan costs milliseconds)
45//! or for large heuristic instances; for trivially cheap connectors the
46//! thread-pool dispatch can cost more than it saves. The exact Held–Karp
47//! solver stays sequential — its layers depend on one another, so it does not
48//! parallelize without a structural rewrite. Enabling it adds `Sync` bounds on
49//! the planner, its config, the waypoint builder, and the validator context
50//! (all satisfied by the deke planners/validators).
51
52mod agtsp;
53mod cost;
54mod error;
55mod reqpath;
56
57use std::cmp::Ordering;
58use std::collections::HashMap;
59
60use deke_types::{DekeError, Planner, SRobotPath, SRobotQ, Validator};
61
62use agtsp::Problem;
63use cost::build_matrices;
64use reqpath::{DirectedOption, expand};
65
66pub use cost::{
67 planned_path_length, planned_trajectory_time, weighted_distance, weighted_euclidean,
68};
69pub use error::{MultipathError, MultipathResult};
70pub use reqpath::ReqPath;
71
72/// Default Held–Karp cell budget: above this many DP cells the solver switches
73/// from exact to the nearest-neighbour + 2-opt heuristic. `16 * 1024 * 1024`
74/// cells ≈ 256 MB.
75pub const DEFAULT_CELL_BUDGET: usize = agtsp::DEFAULT_CELL_BUDGET;
76
77/// `Sync` exactly when the `rayon` feature is enabled. The public API requires
78/// thread-safety only when work is actually dispatched across the rayon pool;
79/// the sequential default build imposes no such bound. Blanket-implemented for
80/// every type, so callers never name or implement it — it only appears in the
81/// public bounds, hence `#[doc(hidden)] pub`.
82#[doc(hidden)]
83#[cfg(feature = "rayon")]
84pub trait MaybeSync: Sync {}
85#[cfg(feature = "rayon")]
86impl<T: Sync> MaybeSync for T {}
87
88#[doc(hidden)]
89#[cfg(not(feature = "rayon"))]
90pub trait MaybeSync {}
91#[cfg(not(feature = "rayon"))]
92impl<T> MaybeSync for T {}
93
94/// Knobs for a multipath solve. Build with [`MultiPathSettings::new`].
95pub struct MultiPathSettings<const N: usize> {
96 /// Configuration the motion starts from.
97 pub start: SRobotQ<N, f64>,
98 /// Optional configuration the motion must finish at.
99 pub end: Option<SRobotQ<N, f64>>,
100 /// Connectors whose endpoints coincide within this (unweighted) joint
101 /// distance are skipped — no point planning a move that goes nowhere.
102 pub dedup_tol: f64,
103 /// Above this many DP cells the solver switches from exact Held–Karp to the
104 /// nearest-neighbour + 2-opt heuristic.
105 pub exact_cell_budget: usize,
106}
107
108impl<const N: usize> MultiPathSettings<N> {
109 pub fn new(start: SRobotQ<N, f64>) -> Self {
110 Self {
111 start,
112 end: None,
113 dedup_tol: 1e-6,
114 exact_cell_budget: agtsp::DEFAULT_CELL_BUDGET,
115 }
116 }
117
118 pub fn with_end(mut self, end: SRobotQ<N, f64>) -> Self {
119 self.end = Some(end);
120 self
121 }
122}
123
124/// Everything needed to generate real connector paths with a deke planner. The
125/// `make_waypoints` closure bridges the planner's associated `Waypoints` type —
126/// for the RRT planners that is `|s, e| StartEnd { start: s, end: e }`.
127pub struct TransitionPlanner<'a, const N: usize, P, MW>
128where
129 P: Planner<N, f64>,
130{
131 pub planner: &'a P,
132 pub config: &'a P::Config,
133 pub make_waypoints: MW,
134}
135
136/// Solve the ordering and stitch the full motion using `transition` to plan
137/// obstacle-aware connectors between the chosen paths.
138pub fn plan_multipath<'ctx, const N: usize, P, V, MW, C>(
139 req_paths: &[ReqPath<N>],
140 cost: &C,
141 settings: &MultiPathSettings<N>,
142 transition: &TransitionPlanner<'_, N, P, MW>,
143 validator: &V,
144 ctx: &V::Context<'ctx>,
145) -> MultipathResult<Vec<SRobotPath<N, f64>>>
146where
147 P: Planner<N, f64> + MaybeSync,
148 P::Config: MaybeSync,
149 V: Validator<N, (), f64>,
150 V::Context<'ctx>: MaybeSync,
151 MW: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> P::Waypoints + MaybeSync,
152 C: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> f64,
153{
154 run(req_paths, cost, settings, |from, to| {
155 let waypoints = (transition.make_waypoints)(*from, *to);
156 let (path, _diag) =
157 transition
158 .planner
159 .plan::<DekeError, _>(transition.config, &waypoints, validator, ctx);
160 Ok(path?)
161 })
162}
163
164/// Solve the ordering and stitch the full motion using straight joint-space
165/// connectors. Each connector is checked with `validator.validate_motion`; if a
166/// straight line between two required paths is infeasible the solve fails (there
167/// is no planner to route around the obstacle).
168pub fn plan_multipath_straight<'ctx, const N: usize, V, C>(
169 req_paths: &[ReqPath<N>],
170 cost: &C,
171 settings: &MultiPathSettings<N>,
172 validator: &V,
173 ctx: &V::Context<'ctx>,
174) -> MultipathResult<Vec<SRobotPath<N, f64>>>
175where
176 V: Validator<N, (), f64>,
177 V::Context<'ctx>: MaybeSync,
178 C: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> f64,
179{
180 run(req_paths, cost, settings, |from, to| {
181 validator.validate_motion(&[*from, *to], ctx)?;
182 Ok(SRobotPath::from_two(*from, *to))
183 })
184}
185
186/// Solve the asymmetric generalized TSP directly over a precomputed cost matrix,
187/// returning the chosen option index per cluster in visiting order and the total
188/// cost — without expanding paths or stitching motion. This is the bare ordering
189/// core for callers that already have their own cost structure and only want the
190/// selection and order back.
191///
192/// - `cluster_ids[i]` is the cluster option `i` belongs to. Cluster labels may be
193/// any `usize`; they are densified internally, so the returned indices are
194/// positions into `cluster_ids` regardless of how the clusters are numbered.
195/// - `transition` is the `option × option` matrix where `(i, j)` is the cost to
196/// move from option `i` to option `j` (fold option `j`'s own traversal cost
197/// into the entry if you want the solver to prefer cheaper realizations). Must
198/// be square with side `cluster_ids.len()`.
199/// - `start[i]` is the cost to begin the tour at option `i`; `end[i]` the cost to
200/// finish at it. `None` means a zero vector (no start/end bias).
201/// - Non-finite entries mark infeasible edges and are routed around.
202/// - `cell_budget` switches exact Held–Karp → heuristic above that many DP cells;
203/// pass [`DEFAULT_CELL_BUDGET`] for the standard 16M-cell cap.
204///
205/// Returns `None` if no feasible tour visits every cluster exactly once.
206pub fn solve_matrix(
207 cluster_ids: &[usize],
208 transition: &[Vec<f64>],
209 start: Option<&[f64]>,
210 end: Option<&[f64]>,
211 cell_budget: usize,
212) -> Option<(Vec<usize>, f64)> {
213 let prepared = MatrixProblem::new(cluster_ids, transition, start, end);
214 let solution = agtsp::solve(&prepared.problem(&prepared.start), cell_budget)?;
215 Some((solution.order, solution.cost))
216}
217
218/// Multi-start, top-k variant of [`solve_matrix`]: run one solve per starting
219/// cluster (each constrained to begin its tour at that cluster) and return the
220/// `k` cheapest tours found, ascending by cost. Useful when a single optimum is
221/// not enough — e.g. choosing among near-equal orderings by a downstream metric
222/// the cost matrix does not capture.
223///
224/// Arguments are identical to [`solve_matrix`]. The per-start solves are
225/// independent and fan out across the rayon pool when the `rayon` feature is
226/// enabled. At most one tour is returned per starting cluster, so the result has
227/// at most `min(k, n_clusters)` entries.
228pub fn solve_matrix_multi_start(
229 cluster_ids: &[usize],
230 transition: &[Vec<f64>],
231 start: Option<&[f64]>,
232 end: Option<&[f64]>,
233 cell_budget: usize,
234 k: usize,
235) -> Vec<(Vec<usize>, f64)> {
236 if k == 0 {
237 return Vec::new();
238 }
239 let prepared = MatrixProblem::new(cluster_ids, transition, start, end);
240
241 // Force the tour to begin in cluster `c` by leaving only that cluster's
242 // start edges finite. One independent solve per starting cluster.
243 let solve_from = |c: usize| -> Option<(Vec<usize>, f64)> {
244 let start: Vec<f64> = prepared
245 .cluster_ids
246 .iter()
247 .zip(&prepared.start)
248 .map(|(&ci, &s)| if ci == c { s } else { f64::INFINITY })
249 .collect();
250 let solution = agtsp::solve(&prepared.problem(&start), cell_budget)?;
251 Some((solution.order, solution.cost))
252 };
253
254 let mut tours: Vec<(Vec<usize>, f64)> = {
255 #[cfg(feature = "rayon")]
256 {
257 use rayon::prelude::*;
258 (0..prepared.n_clusters)
259 .into_par_iter()
260 .filter_map(solve_from)
261 .collect()
262 }
263 #[cfg(not(feature = "rayon"))]
264 {
265 (0..prepared.n_clusters).filter_map(solve_from).collect()
266 }
267 };
268
269 tours.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
270 tours.truncate(k);
271 tours
272}
273
274/// Owned, validated inputs for the matrix-level AGTSP entry points. Holds the
275/// densified cluster labels and the flattened row-major matrix / start / end
276/// vectors so a borrowing [`Problem`] can be built (once per solve) against them.
277struct MatrixProblem {
278 cluster_ids: Vec<usize>,
279 n_clusters: usize,
280 options: usize,
281 transition: Vec<f64>,
282 start: Vec<f64>,
283 end: Vec<f64>,
284}
285
286impl MatrixProblem {
287 fn new(
288 cluster_ids: &[usize],
289 transition: &[Vec<f64>],
290 start: Option<&[f64]>,
291 end: Option<&[f64]>,
292 ) -> Self {
293 let options = cluster_ids.len();
294 debug_assert_eq!(
295 transition.len(),
296 options,
297 "transition must be square with side cluster_ids.len()"
298 );
299 debug_assert!(
300 transition.iter().all(|row| row.len() == options),
301 "transition rows must each have length cluster_ids.len()"
302 );
303 let (dense, n_clusters) = densify_clusters(cluster_ids);
304 let flat = transition
305 .iter()
306 .flat_map(|row| row.iter().copied())
307 .collect();
308 let resolve = |v: Option<&[f64]>| match v {
309 Some(s) => {
310 debug_assert_eq!(s.len(), options, "start/end length must equal option count");
311 s.to_vec()
312 }
313 None => vec![0.0; options],
314 };
315 MatrixProblem {
316 cluster_ids: dense,
317 n_clusters,
318 options,
319 transition: flat,
320 start: resolve(start),
321 end: resolve(end),
322 }
323 }
324
325 fn problem<'a>(&'a self, start: &'a [f64]) -> Problem<'a> {
326 Problem {
327 cluster_ids: &self.cluster_ids,
328 n_clusters: self.n_clusters,
329 transition: &self.transition,
330 options: self.options,
331 start,
332 end: &self.end,
333 }
334 }
335}
336
337/// Remap arbitrary cluster labels onto a dense `0..k` range (in order of first
338/// appearance), returning the remapped labels and `k`. The Held–Karp bitmask
339/// indexes clusters by bit position, so labels must be dense and zero-based.
340fn densify_clusters(cluster_ids: &[usize]) -> (Vec<usize>, usize) {
341 let mut map: HashMap<usize, usize> = HashMap::new();
342 let dense = cluster_ids
343 .iter()
344 .map(|&c| {
345 let next = map.len();
346 *map.entry(c).or_insert(next)
347 })
348 .collect();
349 (dense, map.len())
350}
351
352fn run<const N: usize, Cost, Conn>(
353 req_paths: &[ReqPath<N>],
354 cost: &Cost,
355 settings: &MultiPathSettings<N>,
356 connect: Conn,
357) -> MultipathResult<Vec<SRobotPath<N, f64>>>
358where
359 Cost: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> f64,
360 Conn: Fn(&SRobotQ<N, f64>, &SRobotQ<N, f64>) -> MultipathResult<SRobotPath<N, f64>> + MaybeSync,
361{
362 let (options, n_clusters) = expand(req_paths)?;
363 let matrices = build_matrices(&options, cost, &settings.start, settings.end.as_ref());
364 let cluster_ids: Vec<usize> = options.iter().map(|o| o.cluster).collect();
365 let problem = Problem {
366 cluster_ids: &cluster_ids,
367 n_clusters,
368 transition: &matrices.transition,
369 options: options.len(),
370 start: &matrices.start,
371 end: &matrices.end,
372 };
373 let solution =
374 agtsp::solve(&problem, settings.exact_cell_budget).ok_or(MultipathError::NoFeasibleTour)?;
375 tracing::debug!(
376 n_required = req_paths.len(),
377 n_options = options.len(),
378 cost = solution.cost,
379 "deke-multipath: ordering solved"
380 );
381 assemble(options, &solution.order, settings, connect)
382}
383
384/// One element of the stitched plan before connectors are realized: either a
385/// chosen required path (already oriented) or a connector that still needs to be
386/// planned between two configurations.
387enum Segment<const N: usize> {
388 Connector(SRobotQ<N, f64>, SRobotQ<N, f64>),
389 Required(SRobotPath<N, f64>),
390}
391
392fn assemble<const N: usize, C>(
393 options: Vec<DirectedOption<N>>,
394 order: &[usize],
395 settings: &MultiPathSettings<N>,
396 connect: C,
397) -> MultipathResult<Vec<SRobotPath<N, f64>>>
398where
399 C: Fn(&SRobotQ<N, f64>, &SRobotQ<N, f64>) -> MultipathResult<SRobotPath<N, f64>> + MaybeSync,
400{
401 // Lay out the plan as connector/required segments. Connectors are
402 // independent point-to-point plans, so they can be realized concurrently
403 // (the costly RRT case); the required paths just pass through. The tour
404 // visits each option at most once, so the chosen paths are moved out rather
405 // than cloned.
406 let mut options: Vec<Option<DirectedOption<N>>> = options.into_iter().map(Some).collect();
407 let mut segments: Vec<Segment<N>> = Vec::new();
408 let mut cursor = settings.start;
409 for &oi in order {
410 let path = options[oi]
411 .take()
412 .expect("each option appears once in the tour")
413 .path;
414 let first = *path.first();
415 if cursor.distance(&first) > settings.dedup_tol {
416 segments.push(Segment::Connector(cursor, first));
417 }
418 cursor = *path.last();
419 segments.push(Segment::Required(path));
420 }
421 if let Some(end) = settings.end
422 && cursor.distance(&end) > settings.dedup_tol
423 {
424 segments.push(Segment::Connector(cursor, end));
425 }
426
427 let realize = |segment: Segment<N>| -> MultipathResult<SRobotPath<N, f64>> {
428 match segment {
429 Segment::Connector(from, to) => connect(&from, &to),
430 Segment::Required(path) => Ok(path),
431 }
432 };
433
434 #[cfg(feature = "rayon")]
435 {
436 use rayon::prelude::*;
437 segments.into_par_iter().map(realize).collect()
438 }
439 #[cfg(not(feature = "rayon"))]
440 {
441 segments.into_iter().map(realize).collect()
442 }
443}