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