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