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