Skip to main content

fdars_core/alignment/
mod.rs

1//! Elastic alignment and SRSF (Square-Root Slope Function) transforms.
2//!
3//! This module provides phase-amplitude separation for functional data via
4//! the elastic framework. Key capabilities:
5//!
6//! - [`srsf_transform`] / [`srsf_inverse`] — SRSF representation and reconstruction
7//! - [`elastic_align_pair`] — Pairwise curve alignment via dynamic programming
8//! - [`elastic_distance`] — Elastic (Fisher-Rao) distance between curves
9//! - [`align_to_target`] — Align a set of curves to a common target
10//! - [`karcher_mean`] — Karcher (Fréchet) mean in the elastic metric
11//! - [`elastic_self_distance_matrix`] / [`elastic_cross_distance_matrix`] — Distance matrices
12//! - [`reparameterize_curve`] / [`compose_warps`] — Warping utilities
13
14mod bayesian;
15mod closed;
16mod clustering;
17mod constrained;
18mod diagnostics;
19mod elastic_depth;
20mod fpns;
21mod generative;
22mod geodesic;
23mod karcher;
24mod lambda_cv;
25mod multires;
26mod nd;
27mod outlier;
28mod pairwise;
29mod partial_match;
30mod persistence;
31mod phase_boxplot;
32mod quality;
33mod robust_karcher;
34mod set;
35mod shape;
36mod shape_ci;
37mod srsf;
38mod transfer;
39mod tsrvf;
40mod warp_stats;
41
42#[cfg(test)]
43mod tests;
44
45// Re-export all public items so that `crate::alignment::X` continues to work.
46pub use bayesian::{bayesian_align_pair, BayesianAlignConfig, BayesianAlignmentResult};
47pub use closed::{
48    elastic_align_pair_closed, elastic_distance_closed, karcher_mean_closed, ClosedAlignmentResult,
49    ClosedKarcherMeanResult,
50};
51pub use clustering::{
52    cut_dendrogram, hierarchical_from_distances, kmedoids_from_distances, Dendrogram,
53    KMedoidsConfig, KMedoidsResult, Linkage,
54};
55pub use constrained::{
56    elastic_align_pair_constrained, elastic_align_pair_with_landmarks, ConstrainedAlignmentResult,
57};
58pub use diagnostics::{
59    diagnose_alignment, diagnose_pairwise, AlignmentDiagnostic, AlignmentDiagnosticSummary,
60    DiagnosticConfig,
61};
62pub use elastic_depth::{elastic_depth, ElasticDepthResult};
63pub use fpns::{horiz_fpns, FpnsResult};
64pub use generative::{gauss_model, joint_gauss_model, GenerativeModelResult};
65pub use geodesic::{curve_geodesic, curve_geodesic_nd, GeodesicPath, GeodesicPathNd};
66pub use karcher::{karcher_mean, karcher_mean_banded};
67pub use lambda_cv::{lambda_cv, LambdaCvConfig, LambdaCvResult};
68pub use multires::{elastic_align_pair_multires, MultiresConfig};
69pub use nd::{
70    elastic_align_pair_nd, elastic_distance_nd, srsf_inverse_nd, srsf_transform_nd,
71    AlignmentResultNd,
72};
73pub use nd::{karcher_covariance_nd, karcher_mean_nd, pca_nd, KarcherMeanResultNd, PcaNdResult};
74pub use outlier::{elastic_outlier_detection, ElasticOutlierConfig, ElasticOutlierResult};
75pub use pairwise::{
76    amplitude_distance, amplitude_self_distance_matrix, elastic_align_pair,
77    elastic_align_pair_banded, elastic_align_pair_penalized, elastic_cross_distance_matrix,
78    elastic_cross_distance_matrix_banded, elastic_distance, elastic_distance_banded,
79    elastic_self_distance_matrix, elastic_self_distance_matrix_banded, phase_distance_pair,
80    phase_self_distance_matrix, WarpPenaltyType,
81};
82pub use partial_match::{elastic_partial_match, PartialMatchConfig, PartialMatchResult};
83pub use persistence::{peak_persistence, PersistenceDiagramResult};
84pub use phase_boxplot::{phase_boxplot, PhaseBoxplot};
85pub use quality::{
86    alignment_quality, pairwise_consistency, warp_complexity, warp_smoothness, AlignmentQuality,
87};
88pub use robust_karcher::{
89    karcher_median, robust_karcher_mean, RobustKarcherConfig, RobustKarcherResult,
90};
91pub use set::{align_to_target, elastic_decomposition, DecompositionResult};
92pub use shape::{
93    orbit_representative, shape_distance, shape_mean, shape_self_distance_matrix,
94    OrbitRepresentative, ShapeDistanceResult, ShapeMeanResult, ShapeQuotient,
95};
96pub use shape_ci::{shape_confidence_interval, ShapeCiConfig, ShapeCiResult};
97pub use srsf::{
98    compose_warps, invert_warp, reparameterize_curve, srsf_inverse, srsf_transform,
99    warp_inverse_error,
100};
101pub use transfer::{transfer_alignment, TransferAlignConfig, TransferAlignResult};
102pub use tsrvf::{
103    tsrvf_from_alignment, tsrvf_from_alignment_with_method, tsrvf_inverse, tsrvf_transform,
104    tsrvf_transform_with_method, TransportMethod, TsrvfResult,
105};
106pub use warp_stats::{warp_statistics, WarpStatistics};
107
108// Re-export pub(crate) items so other crate modules can use them.
109pub(crate) use karcher::sqrt_mean_inverse;
110
111use crate::helpers::linear_interp;
112use crate::matrix::FdMatrix;
113use crate::warping::normalize_warp;
114use std::cell::RefCell;
115
116// ─── Types ──────────────────────────────────────────────────────────────────
117
118/// Result of aligning one curve to another.
119#[derive(Debug, Clone, PartialEq)]
120#[non_exhaustive]
121pub struct AlignmentResult {
122    /// Warping function γ mapping the domain to itself.
123    pub gamma: Vec<f64>,
124    /// The aligned (reparameterized) curve.
125    pub f_aligned: Vec<f64>,
126    /// Elastic distance after alignment.
127    pub distance: f64,
128}
129
130/// Result of aligning a set of curves to a common target.
131#[derive(Debug, Clone, PartialEq)]
132#[non_exhaustive]
133pub struct AlignmentSetResult {
134    /// Warping functions (n × m).
135    pub gammas: FdMatrix,
136    /// Aligned curves (n × m).
137    pub aligned_data: FdMatrix,
138    /// Elastic distances for each curve.
139    pub distances: Vec<f64>,
140}
141
142/// Result of the Karcher mean computation.
143#[derive(Debug, Clone, PartialEq)]
144#[non_exhaustive]
145pub struct KarcherMeanResult {
146    /// Karcher mean curve.
147    pub mean: Vec<f64>,
148    /// SRSF of the Karcher mean.
149    pub mean_srsf: Vec<f64>,
150    /// Final warping functions (n × m).
151    pub gammas: FdMatrix,
152    /// Curves aligned to the mean (n × m).
153    pub aligned_data: FdMatrix,
154    /// Number of iterations used.
155    pub n_iter: usize,
156    /// Whether the algorithm converged.
157    pub converged: bool,
158    /// Pre-computed SRSFs of aligned curves (n × m), if available.
159    /// When set, FPCA functions use these instead of recomputing from `aligned_data`.
160    pub aligned_srsfs: Option<FdMatrix>,
161}
162
163impl KarcherMeanResult {
164    /// Create a new `KarcherMeanResult`.
165    pub fn new(
166        mean: Vec<f64>,
167        mean_srsf: Vec<f64>,
168        gammas: FdMatrix,
169        aligned_data: FdMatrix,
170        n_iter: usize,
171        converged: bool,
172        aligned_srsfs: Option<FdMatrix>,
173    ) -> Self {
174        Self {
175            mean,
176            mean_srsf,
177            gammas,
178            aligned_data,
179            n_iter,
180            converged,
181            aligned_srsfs,
182        }
183    }
184}
185
186// ─── Trait: AlignmentOutput ─────────────────────────────────────────────────
187
188/// Common interface for alignment results, enabling interchangeable
189/// alignment methods in downstream analysis (elastic FPCA, regression, etc.).
190pub trait AlignmentOutput {
191    /// The estimated mean/template curve (length m).
192    fn mean(&self) -> &[f64];
193    /// The mean SRSF (length m).
194    fn mean_srsf(&self) -> &[f64];
195    /// The aligned curves (n × m).
196    fn aligned_data(&self) -> &FdMatrix;
197    /// The warping functions (n × m).
198    fn gammas(&self) -> &FdMatrix;
199    /// Whether the algorithm converged.
200    fn converged(&self) -> bool;
201    /// Number of iterations performed.
202    fn n_iter(&self) -> usize;
203}
204
205impl AlignmentOutput for KarcherMeanResult {
206    fn mean(&self) -> &[f64] {
207        &self.mean
208    }
209    fn mean_srsf(&self) -> &[f64] {
210        &self.mean_srsf
211    }
212    fn aligned_data(&self) -> &FdMatrix {
213        &self.aligned_data
214    }
215    fn gammas(&self) -> &FdMatrix {
216        &self.gammas
217    }
218    fn converged(&self) -> bool {
219        self.converged
220    }
221    fn n_iter(&self) -> usize {
222        self.n_iter
223    }
224}
225
226impl AlignmentOutput for RobustKarcherResult {
227    fn mean(&self) -> &[f64] {
228        &self.mean
229    }
230    fn mean_srsf(&self) -> &[f64] {
231        &self.mean_srsf
232    }
233    fn aligned_data(&self) -> &FdMatrix {
234        &self.aligned_data
235    }
236    fn gammas(&self) -> &FdMatrix {
237        &self.gammas
238    }
239    fn converged(&self) -> bool {
240        self.converged
241    }
242    fn n_iter(&self) -> usize {
243        self.n_iter
244    }
245}
246
247// ─── Conversions ───────────────────────────────────────────────────────────
248
249impl From<RobustKarcherResult> for KarcherMeanResult {
250    fn from(r: RobustKarcherResult) -> Self {
251        Self {
252            mean: r.mean,
253            mean_srsf: r.mean_srsf,
254            gammas: r.gammas,
255            aligned_data: r.aligned_data,
256            n_iter: r.n_iter,
257            converged: r.converged,
258            aligned_srsfs: None,
259        }
260    }
261}
262
263// ─── Dynamic Programming Alignment ──────────────────────────────────────────
264// Faithful port of fdasrvf's DP algorithm (dp_grid.cpp / dp_nbhd.cpp).
265
266/// Pre-computed coprime neighborhood for nbhd_dim=7 (fdasrvf default).
267/// All (dr, dc) with 1 ≤ dr, dc ≤ 7 and gcd(dr, dc) = 1.
268/// dr = row delta (q2 direction), dc = column delta (q1 direction).
269#[rustfmt::skip]
270const COPRIME_NBHD_7: [(usize, usize); 35] = [
271    (1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(1,7),
272    (2,1),      (2,3),      (2,5),      (2,7),
273    (3,1),(3,2),      (3,4),(3,5),      (3,7),
274    (4,1),      (4,3),      (4,5),      (4,7),
275    (5,1),(5,2),(5,3),(5,4),      (5,6),(5,7),
276    (6,1),                  (6,5),      (6,7),
277    (7,1),(7,2),(7,3),(7,4),(7,5),(7,6),
278];
279
280/// Compute the edge weight for a move from grid point (sr, sc) to (tr, tc).
281///
282/// Port of fdasrvf's `dp_edge_weight` for 1-D curves on a shared uniform grid.
283/// - Rows = q2 indices, columns = q1 indices (matching fdasrvf convention).
284/// - `slope = (argvals[tr] - argvals[sr]) / (argvals[tc] - argvals[sc])` = γ'
285/// - Walks through sub-intervals synchronized at both curves' breakpoints,
286///   accumulating `(q1[idx1] - √slope · q2[idx2])² · dt`.
287#[inline]
288pub(super) fn dp_edge_weight(
289    q1: &[f64],
290    q2: &[f64],
291    argvals: &[f64],
292    sc: usize,
293    tc: usize,
294    sr: usize,
295    tr: usize,
296) -> f64 {
297    if tc == sc || tr == sr {
298        return f64::INFINITY;
299    }
300    // General (possibly non-uniform grid) path: slope and span come from argvals.
301    let rslope = ((argvals[tr] - argvals[sr]) / (argvals[tc] - argvals[sc])).sqrt();
302    let span = argvals[tc] - argvals[sc];
303    dp_edge_weight_core(q1, q2, sc, tc, sr, tr, rslope, span)
304}
305
306/// Core edge-weight integer walk, shared by the general and uniform-grid paths.
307///
308/// `rslope = √γ'` and `span` (the q1-direction interval length) are supplied by
309/// the caller so the uniform-grid path can look `rslope` up from a precomputed
310/// `√(dr/dc)` table (avoiding a `sqrt` + division on every one of the `m²·35`
311/// edge evaluations) and derive `span` as `dc·h`.
312///
313/// Walks the merged sub-interval breakpoints of both curves in integer units of
314/// `1/(n1·n2)`: q1's breakpoints land on multiples of n2, q2's on multiples of
315/// n1 (both n1, n2 ≤ 7 from the coprime neighborhood). Integer arithmetic keeps
316/// the loop free of per-step float divisions and hoists the single `/(n1·n2)`
317/// divisor out. Control flow (and tie handling) is exact.
318#[inline]
319pub(super) fn dp_edge_weight_core(
320    q1: &[f64],
321    q2: &[f64],
322    sc: usize,
323    tc: usize,
324    sr: usize,
325    tr: usize,
326    rslope: f64,
327    span: f64,
328) -> f64 {
329    let n1 = tc - sc;
330    let n2 = tr - sr;
331    if n1 == 0 || n2 == 0 {
332        return f64::INFINITY;
333    }
334
335    let mut weight_scaled = 0.0;
336    let mut i1 = 0usize; // sub-interval index in q1 direction
337    let mut i2 = 0usize; // sub-interval index in q2 direction
338
339    while i1 < n1 && i2 < n2 {
340        let left = (i1 * n2).max(i2 * n1);
341        let right1 = (i1 + 1) * n2;
342        let right2 = (i2 + 1) * n1;
343        let right = right1.min(right2);
344        let dt = right - left;
345
346        if dt > 0 {
347            let diff = q1[sc + i1] - rslope * q2[sr + i2];
348            weight_scaled += diff * diff * dt as f64;
349        }
350
351        // Advance whichever sub-interval ends first
352        if right1 < right2 {
353            i1 += 1;
354        } else if right2 < right1 {
355            i2 += 1;
356        } else {
357            i1 += 1;
358            i2 += 1;
359        }
360    }
361
362    // Undo the 1/(n1·n2) integer scaling, then scale by the span in q1 direction.
363    weight_scaled / (n1 * n2) as f64 * span
364}
365
366/// Return the uniform grid spacing `h` if `argvals` is (numerically) uniform.
367///
368/// Used to enable the fast edge-weight path in [`dp_alignment_core`]: on a
369/// uniform grid the DP slope is exactly `dr/dc` and the span is `dc·h`, so both
370/// become precomputable and independent of absolute position.
371fn uniform_spacing(argvals: &[f64]) -> Option<f64> {
372    let m = argvals.len();
373    if m < 2 {
374        return None;
375    }
376    let h = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
377    if h.is_nan() || h <= 0.0 {
378        return None;
379    }
380    let tol = h * 1e-9;
381    for k in 1..m {
382        if (argvals[k] - argvals[k - 1] - h).abs() > tol {
383            return None;
384        }
385    }
386    Some(h)
387}
388
389/// Compute the λ·(slope−1)²·dt penalty for a DP edge.
390#[inline]
391pub(super) fn dp_lambda_penalty(
392    argvals: &[f64],
393    sc: usize,
394    tc: usize,
395    sr: usize,
396    tr: usize,
397    lambda: f64,
398) -> f64 {
399    if lambda > 0.0 {
400        let dt = argvals[tc] - argvals[sc];
401        let slope = (argvals[tr] - argvals[sr]) / dt;
402        lambda * (slope - 1.0).powi(2) * dt
403    } else {
404        0.0
405    }
406}
407
408/// Traceback a parent-pointer array from bottom-right to top-left.
409///
410/// Returns the path as `(row, col)` pairs from `(0,0)` to `(nrows-1, ncols-1)`.
411fn dp_traceback(parent: &[u32], nrows: usize, ncols: usize) -> Vec<(usize, usize)> {
412    let mut path = Vec::with_capacity(nrows + ncols);
413    let mut cur = (nrows - 1) * ncols + (ncols - 1);
414    loop {
415        path.push((cur / ncols, cur % ncols));
416        if cur == 0 || parent[cur] == u32::MAX {
417            break;
418        }
419        cur = parent[cur] as usize;
420    }
421    path.reverse();
422    path
423}
424
425/// Try to relax cell `(tr, tc)` from each coprime neighbor, updating cost and parent.
426#[inline]
427fn dp_relax_cell<F>(
428    e: &mut [f64],
429    parent: &mut [u32],
430    ncols: usize,
431    tr: usize,
432    tc: usize,
433    edge_cost: &F,
434) where
435    F: Fn(usize, usize, usize, usize) -> f64,
436{
437    let idx = tr * ncols + tc;
438    for &(dr, dc) in &COPRIME_NBHD_7 {
439        if dr > tr || dc > tc {
440            continue;
441        }
442        let sr = tr - dr;
443        let sc = tc - dc;
444        let src_idx = sr * ncols + sc;
445        if e[src_idx] == f64::INFINITY {
446            continue;
447        }
448        let cost = e[src_idx] + edge_cost(sr, sc, tr, tc);
449        if cost < e[idx] {
450            e[idx] = cost;
451            parent[idx] = src_idx as u32;
452        }
453    }
454}
455
456thread_local! {
457    /// Per-thread reusable DP scratch: `(cost grid, parent pointers)`.
458    ///
459    /// The grid fill in [`dp_grid_solve`] allocates two `nrows·ncols` buffers on
460    /// every alignment; in Karcher-mean / distance-matrix routines that is
461    /// `n·iters` (or `n²`) allocations of buffers that are cleared and rewritten
462    /// anyway. Keeping them in thread-local storage removes the allocator
463    /// round-trip while staying compatible with the `iter_maybe_parallel!` outer
464    /// loops (each rayon worker gets its own scratch).
465    static DP_SCRATCH: RefCell<(Vec<f64>, Vec<u32>)> = const { RefCell::new((Vec::new(), Vec::new())) };
466}
467
468/// Shared DP grid fill + traceback using the coprime neighborhood.
469///
470/// `edge_cost(sr, sc, tr, tc)` returns the combined edge weight + penalty for
471/// a move from local (sr, sc) to local (tr, tc). Returns the raw local-index
472/// path from (0,0) to (nrows-1, ncols-1).
473pub(super) fn dp_grid_solve<F>(nrows: usize, ncols: usize, edge_cost: F) -> Vec<(usize, usize)>
474where
475    F: Fn(usize, usize, usize, usize) -> f64,
476{
477    dp_grid_solve_banded(nrows, ncols, None, edge_cost)
478}
479
480/// Shared DP grid fill + traceback, optionally restricted to a Sakoe–Chiba band.
481///
482/// When `band = Some(r)`, only cells with `|tr − tc| ≤ r` are filled; the rest
483/// stay at `+∞` and are skipped as predecessors, so the optimal warp is confined
484/// to a diagonal corridor of radius `r`. This turns the O(nrows·ncols) grid fill
485/// into O(min(nrows,ncols)·r), the main algorithmic speedup for near-diagonal
486/// warps. `band = None` reproduces the full unbanded search exactly. A feasible
487/// path always exists for any `r ≥ 0` because the `(1,1)` diagonal move keeps
488/// `tr = tc`.
489pub(super) fn dp_grid_solve_banded<F>(
490    nrows: usize,
491    ncols: usize,
492    band: Option<usize>,
493    edge_cost: F,
494) -> Vec<(usize, usize)>
495where
496    F: Fn(usize, usize, usize, usize) -> f64,
497{
498    DP_SCRATCH.with(|cell| {
499        let mut scratch = cell.borrow_mut();
500        let (e, parent) = &mut *scratch;
501        let size = nrows * ncols;
502        e.clear();
503        e.resize(size, f64::INFINITY);
504        parent.clear();
505        parent.resize(size, u32::MAX);
506        e[0] = 0.0;
507
508        for tr in 0..nrows {
509            for tc in 0..ncols {
510                if tr == 0 && tc == 0 {
511                    continue;
512                }
513                if let Some(r) = band {
514                    if tr.abs_diff(tc) > r {
515                        continue;
516                    }
517                }
518                dp_relax_cell(e, parent, ncols, tr, tc, &edge_cost);
519            }
520        }
521
522        dp_traceback(parent, nrows, ncols)
523    })
524}
525
526/// Convert a band width expressed as a fraction of the domain into an index
527/// radius for [`dp_grid_solve_banded`].
528///
529/// `band_frac` is the largest allowed warp displacement `|γ(t) − t|` as a
530/// fraction of the number of grid points. Returns `None` (unbounded search)
531/// unless `0 < band_frac < 1`; a positive fraction yields a radius of at least
532/// 1 so that some warping is always permitted.
533pub(super) fn band_radius(band_frac: f64, m: usize) -> Option<usize> {
534    if band_frac > 0.0 && band_frac < 1.0 {
535        Some(((band_frac * m as f64).ceil() as usize).max(1))
536    } else {
537        None
538    }
539}
540
541/// Convert a DP path (local row,col indices) to an interpolated+normalized gamma warp.
542pub(super) fn dp_path_to_gamma(path: &[(usize, usize)], argvals: &[f64]) -> Vec<f64> {
543    let path_tc: Vec<f64> = path.iter().map(|&(_, c)| argvals[c]).collect();
544    let path_tr: Vec<f64> = path.iter().map(|&(r, _)| argvals[r]).collect();
545    let mut gamma: Vec<f64> = argvals
546        .iter()
547        .map(|&t| linear_interp(&path_tc, &path_tr, t))
548        .collect();
549    normalize_warp(&mut gamma, argvals);
550    gamma
551}
552
553/// Core DP alignment between two SRSFs on a grid.
554///
555/// Finds the optimal warping γ minimizing ‖q₁ - (q₂∘γ)√γ'‖².
556/// Uses fdasrvf's coprime neighborhood (nbhd_dim=7 → 35 move directions).
557/// SRSFs are L2-normalized before alignment (matching fdasrvf's `optimum.reparam`).
558pub(crate) fn dp_alignment_core(q1: &[f64], q2: &[f64], argvals: &[f64], lambda: f64) -> Vec<f64> {
559    dp_alignment_core_banded(q1, q2, argvals, lambda, None)
560}
561
562/// Core DP alignment, optionally restricted to a Sakoe–Chiba band of `band`
563/// grid-index radius (see [`dp_grid_solve_banded`] / [`band_radius`]).
564///
565/// `band = None` is identical to [`dp_alignment_core`]. A finite band confines
566/// the warp to a diagonal corridor, trading unbounded warps for a large speedup.
567pub(crate) fn dp_alignment_core_banded(
568    q1: &[f64],
569    q2: &[f64],
570    argvals: &[f64],
571    lambda: f64,
572    band: Option<usize>,
573) -> Vec<f64> {
574    let m = argvals.len();
575    if m < 2 {
576        return argvals.to_vec();
577    }
578
579    let norm1 = q1.iter().map(|&v| v * v).sum::<f64>().sqrt().max(1e-10);
580    let norm2 = q2.iter().map(|&v| v * v).sum::<f64>().sqrt().max(1e-10);
581    let q1n: Vec<f64> = q1.iter().map(|&v| v / norm1).collect();
582    let q2n: Vec<f64> = q2.iter().map(|&v| v / norm2).collect();
583
584    let path = if let Some(h) = uniform_spacing(argvals) {
585        // Uniform-grid fast path: the DP slope is exactly dr/dc, so precompute
586        // the 35 possible √(dr/dc) values (indices 1..=7) once instead of a
587        // sqrt + division on every one of the m²·35 edge evaluations. The span
588        // is dc·h. Both are independent of absolute position.
589        let mut rslope_tab = [[0.0f64; 8]; 8];
590        for (dr, row) in rslope_tab.iter_mut().enumerate().skip(1) {
591            for (dc, cell) in row.iter_mut().enumerate().skip(1) {
592                *cell = (dr as f64 / dc as f64).sqrt();
593            }
594        }
595        dp_grid_solve_banded(m, m, band, |sr, sc, tr, tc| {
596            let dr = tr - sr;
597            let dc = tc - sc;
598            dp_edge_weight_core(
599                &q1n,
600                &q2n,
601                sc,
602                tc,
603                sr,
604                tr,
605                rslope_tab[dr][dc],
606                dc as f64 * h,
607            ) + dp_lambda_penalty(argvals, sc, tc, sr, tr, lambda)
608        })
609    } else {
610        dp_grid_solve_banded(m, m, band, |sr, sc, tr, tc| {
611            dp_edge_weight(&q1n, &q2n, argvals, sc, tc, sr, tr)
612                + dp_lambda_penalty(argvals, sc, tc, sr, tr, lambda)
613        })
614    };
615
616    dp_path_to_gamma(&path, argvals)
617}
618
619/// Greatest common divisor (Euclidean algorithm). Used only in tests.
620#[cfg(test)]
621pub(super) fn gcd(a: usize, b: usize) -> usize {
622    if b == 0 {
623        a
624    } else {
625        gcd(b, a % b)
626    }
627}
628
629/// Generate coprime neighborhood: all (i,j) with 1 ≤ i,j ≤ nbhd_dim, gcd(i,j) = 1.
630/// With nbhd_dim=7 this produces 35 pairs, matching fdasrvf's default.
631#[cfg(test)]
632pub(super) fn generate_coprime_nbhd(nbhd_dim: usize) -> Vec<(usize, usize)> {
633    let mut pairs = Vec::new();
634    for i in 1..=nbhd_dim {
635        for j in 1..=nbhd_dim {
636            if gcd(i, j) == 1 {
637                pairs.push((i, j));
638            }
639        }
640    }
641    pairs
642}