Skip to main content

gam_sae/inference/
layer_transport.rs

1//! Functorial inter-layer concept transport maps (issue #1013).
2//!
3//! For an atom whose layer-`l` chart assigns coordinates `t_l` to each row and
4//! whose continuation at layer `l+1` assigns `t_{l+1}`, the estimand is the
5//! smooth transport map
6//!
7//! ```text
8//!     t_{l+1} = h_{l→l+1}(t_l)
9//! ```
10//!
11//! fitted as a small penalized GAM with the engine's Gaussian REML machinery
12//! (exact 1-D criterion, no GCV per policy). Three questions are answered with
13//! evidence:
14//!
15//! 1. **Topology compatibility** — does `h` preserve the chart topology
16//!    (circle→circle degree-±1 covering, i.e. a homeomorphism of `S¹`) or
17//!    break it (circle→arcs, folds)? For circle charts the winding **degree**
18//!    is estimated by maximizing the circular concentration (mean resultant
19//!    length) of the de-wound residual `θ_to − d·θ_from` over candidate
20//!    degrees `d ∈ {−2,−1,0,1,2}` — for a transport whose smooth residual
21//!    stays inside half a turn this is the circular-correlation-maximizing
22//!    degree, and it is exact in the noiseless limit. A fold check on a dense
23//!    grid (`sign(d)·h′(t) > 0` everywhere) separates genuine degree-±1
24//!    covers from degree-±1 maps with local back-tracking.
25//! 2. **Isometry defect** — `∫ (|h′| − 1)² dP̂` under the empirical data
26//!    density `P̂` (the integral is evaluated at the observed coordinates, so
27//!    dense regions of the chart dominate, as the issue requires). A
28//!    delta-method standard error is propagated from the coefficient
29//!    covariance. Near-zero defect ⇒ TRANSPORT layer (the concept is carried
30//!    isometrically); large defect ⇒ COMPUTE layer (the chart metric is
31//!    reshaped).
32//! 3. **Composition law** — `h_{l→l+2}` vs `h_{l+1→l+2} ∘ h_{l→l+1}`. The
33//!    defect `d(t) = h_ac(t) ⊖ h_bc(h_ab(t))` (circular difference on circle
34//!    charts) is evaluated on a grid and studentized with a joint shared-row
35//!    influence sandwich. A Bonferroni max test controls the grid family under
36//!    arbitrary pointwise dependence; deterministic fits with no score
37//!    variation emit no p-value.
38//!
39//! # Gauge discipline
40//!
41//! Each chart coordinate is identified only up to the residual isometry gauge
42//! of its chart, so a transport map is identified only up to the **double
43//! coset** `[Isom(M_to)] · h · [Isom(M_from)]`. Two facts are used:
44//!
45//! * All three routes in a composition test consume the *same* source
46//!   coordinates, so any isometry of the source chart acts identically on
47//!   `h_ac` and on `h_bc ∘ h_ab`; the source gauge cancels in the defect and
48//!   needs no explicit alignment.
49//! * Both routes land in the same target chart, so the target gauge also acts
50//!   identically and cancels. No post-hoc rotation or reflection is selected;
51//!   such an alignment would erase genuine composition violations.
52//!
53//! All smooths reuse the engine's existing periodic cardinal-B-spline basis
54//! ([`build_periodic_bspline_basis_1d`]) with the exact periodic derivative
55//! Gram on circular domains, and the open B-spline basis with its exact
56//! derivative Gram on interval domains — constructed directly, not via the
57//! string DSL.
58
59use crate::chart_canonicalization::CanonicalChartTopology;
60use gam_solve::gaussian_reml::{
61    gaussian_reml_closed_form_with_nullspace_dim, gaussian_reml_stationary_set,
62};
63use gam_terms::basis::{
64    BasisOptions, Dense, KnotSource, PeriodicBSplineBasisSpec, bspline_derivative_penalty_matrix,
65    build_periodic_bspline_basis_1d, create_basis, cyclic_bspline_derivative_penalty_matrix,
66    periodic_bspline_first_derivative_nd,
67};
68use ndarray::{Array1, Array2, ArrayView1, Axis};
69use statrs::distribution::{ContinuousCDF, Normal};
70use std::f64::consts::{PI, TAU};
71
72/// Cubic splines for every transport smooth.
73const TRANSPORT_SPLINE_DEGREE: usize = 3;
74/// Second-order function-curvature penalty: the cyclic variant leaves constants
75/// unpenalized on a circle; the open variant leaves affine maps unpenalized on
76/// an interval — exactly the isometry-adjacent null spaces.
77const TRANSPORT_PENALTY_ORDER: usize = 2;
78/// Minimum paired observations for a transport fit.
79const MIN_TRANSPORT_OBS: usize = 16;
80/// Target observations per basis function when auto-sizing the basis.
81const OBS_PER_BASIS: usize = 8;
82/// Periodic basis size bounds (auto-derived from `n`, never a caller knob).
83const MIN_PERIODIC_BASIS: usize = 8;
84const MAX_PERIODIC_BASIS: usize = 20;
85/// Open-interval internal-knot bounds.
86const MIN_OPEN_INTERNAL_KNOTS: usize = 4;
87const MAX_OPEN_INTERNAL_KNOTS: usize = 12;
88/// Candidate winding degrees scanned by the circular-concentration estimator.
89const DEGREE_CANDIDATES: [i32; 5] = [-2, -1, 0, 1, 2];
90/// Dense grid used for the fold / orientation check of `h′`.
91const FOLD_CHECK_GRID: usize = 512;
92/// Default evaluation grid for the composition-law defect.
93pub const DEFAULT_COMPOSITION_GRID: usize = 256;
94
95/// Topology of a one-dimensional concept chart.
96#[derive(Debug, Clone, Copy, PartialEq)]
97pub enum ChartTopology {
98    /// Circular chart; coordinates are angles in radians, identified mod 2π.
99    Circle,
100    /// Interval chart with the Euclidean metric on `[lo, hi]`.
101    Interval { lo: f64, hi: f64 },
102}
103
104impl ChartTopology {
105    /// Short stable name used by FFI payloads.
106    pub fn name(&self) -> &'static str {
107        match self {
108            ChartTopology::Circle => "circle",
109            ChartTopology::Interval { .. } => "interval",
110        }
111    }
112
113    fn validate(&self) -> Result<(), String> {
114        match *self {
115            ChartTopology::Circle => Ok(()),
116            ChartTopology::Interval { lo, hi } => {
117                if !(lo.is_finite() && hi.is_finite()) || hi <= lo {
118                    Err(format!(
119                        "interval chart bounds must be finite and ordered; got [{lo}, {hi}]"
120                    ))
121                } else {
122                    Ok(())
123                }
124            }
125        }
126    }
127}
128
129/// Bridge from the SAE canonicalization topology to the transport topology.
130///
131/// `CanonicalChartTopology::Circle { period }` becomes a `Circle` chart whose
132/// coordinates are interpreted on `[0, period)` — the transport module's period
133/// is fixed to `TAU` (angles in radians), so the conversion rescales by mapping
134/// the period-normalized angle `t / period * TAU` at the call site. The caller
135/// must apply this rescaling before handing coordinates to `fit_transport_map`.
136///
137/// `CanonicalChartTopology::Interval` becomes `Interval { lo: 0.0, hi: 1.0 }`
138/// (the canonical unit-speed interval span set by the canonicalization step).
139impl From<&CanonicalChartTopology> for ChartTopology {
140    fn from(src: &CanonicalChartTopology) -> Self {
141        match src {
142            CanonicalChartTopology::Circle { .. } => ChartTopology::Circle,
143            CanonicalChartTopology::Interval => ChartTopology::Interval { lo: 0.0, hi: 1.0 },
144        }
145    }
146}
147
148impl From<CanonicalChartTopology> for ChartTopology {
149    fn from(src: CanonicalChartTopology) -> Self {
150        ChartTopology::from(&src)
151    }
152}
153
154/// Wrap an angle into `[0, 2π)`.
155fn wrap_tau(x: f64) -> f64 {
156    x.rem_euclid(TAU)
157}
158
159/// Wrap an angle into `(−π, π]`.
160fn wrap_pi(x: f64) -> f64 {
161    let w = (x + PI).rem_euclid(TAU) - PI;
162    if w <= -PI { w + TAU } else { w }
163}
164
165/// Circular mean of a set of angles; `0` when the resultant degenerates.
166fn circular_mean(angles: &[f64]) -> f64 {
167    let mut s = 0.0_f64;
168    let mut c = 0.0_f64;
169    for &a in angles {
170        s += a.sin();
171        c += a.cos();
172    }
173    if s.hypot(c) <= f64::EPSILON * angles.len().max(1) as f64 {
174        0.0
175    } else {
176        s.atan2(c)
177    }
178}
179
180/// Mean resultant length `R ∈ [0, 1]` of a set of angles.
181fn resultant_length(angles: &[f64]) -> f64 {
182    if angles.is_empty() {
183        return 0.0;
184    }
185    let mut s = 0.0_f64;
186    let mut c = 0.0_f64;
187    for &a in angles {
188        s += a.sin();
189        c += a.cos();
190    }
191    s.hypot(c) / angles.len() as f64
192}
193
194/// Domain-side basis carrier: periodic cardinal B-splines on a circle, open
195/// B-splines on an interval. Both reuse the existing basis constructors
196/// directly (no string DSL round-trip).
197#[derive(Debug, Clone)]
198enum DomainBasis {
199    Periodic(PeriodicBSplineBasisSpec),
200    Open { knots: Array1<f64>, degree: usize },
201}
202
203impl DomainBasis {
204    fn build(topology: ChartTopology, coords: ArrayView1<'_, f64>) -> Result<Self, String> {
205        let n = coords.len();
206        match topology {
207            ChartTopology::Circle => {
208                let num_basis = (n / OBS_PER_BASIS).clamp(MIN_PERIODIC_BASIS, MAX_PERIODIC_BASIS);
209                Ok(DomainBasis::Periodic(PeriodicBSplineBasisSpec {
210                    degree: TRANSPORT_SPLINE_DEGREE,
211                    num_basis,
212                    period: TAU,
213                    origin: 0.0,
214                    penalty_order: TRANSPORT_PENALTY_ORDER,
215                }))
216            }
217            ChartTopology::Interval { lo, hi } => {
218                let num_internal =
219                    (n / OBS_PER_BASIS).clamp(MIN_OPEN_INTERNAL_KNOTS, MAX_OPEN_INTERNAL_KNOTS);
220                let (seed, knots) = create_basis::<Dense>(
221                    coords.mapv(|v| v.clamp(lo, hi)).view(),
222                    KnotSource::Generate {
223                        data_range: (lo, hi),
224                        num_internal_knots: num_internal,
225                    },
226                    TRANSPORT_SPLINE_DEGREE,
227                    BasisOptions::value(),
228                )
229                .map_err(|e| format!("layer transport open basis construction failed: {e}"))?;
230                if seed.nrows() != n {
231                    return Err(format!(
232                        "layer transport open basis returned {} rows for {n} inputs",
233                        seed.nrows()
234                    ));
235                }
236                Ok(DomainBasis::Open {
237                    knots,
238                    degree: TRANSPORT_SPLINE_DEGREE,
239                })
240            }
241        }
242    }
243
244    fn num_basis(&self) -> usize {
245        match self {
246            DomainBasis::Periodic(spec) => spec.num_basis,
247            DomainBasis::Open { knots, degree } => knots.len() - degree - 1,
248        }
249    }
250
251    /// Rank of the smoothing penalty: the cyclic second-derivative Gram
252    /// annihilates only constants (a linear map is not periodic), the open
253    /// second-derivative Gram annihilates affine maps.
254    fn penalty_rank(&self) -> usize {
255        match self {
256            DomainBasis::Periodic(spec) => spec.num_basis - 1,
257            DomainBasis::Open { .. } => self.num_basis() - TRANSPORT_PENALTY_ORDER,
258        }
259    }
260
261    fn penalty(&self) -> Result<Array2<f64>, String> {
262        match self {
263            DomainBasis::Periodic(spec) => cyclic_bspline_derivative_penalty_matrix(
264                spec.degree,
265                spec.num_basis,
266                spec.period,
267                TRANSPORT_PENALTY_ORDER,
268            )
269            .map_err(|e| format!("cyclic transport roughness failed: {e}")),
270            DomainBasis::Open { knots, degree } => {
271                bspline_derivative_penalty_matrix(knots.view(), *degree, TRANSPORT_PENALTY_ORDER)
272                    .map_err(|e| format!("open transport roughness failed: {e}"))
273            }
274        }
275    }
276
277    /// Clamp/wrap an evaluation point into the basis domain.
278    fn project(&self, t: f64) -> f64 {
279        match self {
280            DomainBasis::Periodic(_) => wrap_tau(t),
281            DomainBasis::Open { knots, degree } => {
282                let lo = knots[*degree];
283                let hi = knots[knots.len() - 1 - degree];
284                t.clamp(lo, hi)
285            }
286        }
287    }
288
289    fn value_rows(&self, t: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
290        let projected = t.mapv(|v| self.project(v));
291        match self {
292            DomainBasis::Periodic(spec) => build_periodic_bspline_basis_1d(projected.view(), spec)
293                .map_err(|e| format!("periodic transport basis evaluation failed: {e}")),
294            DomainBasis::Open { knots, degree } => {
295                let (rows, used_knots) = create_basis::<Dense>(
296                    projected.view(),
297                    KnotSource::Provided(knots.view()),
298                    *degree,
299                    BasisOptions::value(),
300                )
301                .map_err(|e| format!("open transport basis evaluation failed: {e}"))?;
302                if used_knots.len() != knots.len() {
303                    return Err("open transport basis knot vector drifted".to_string());
304                }
305                Ok(rows.as_ref().to_owned())
306            }
307        }
308    }
309
310    /// Polynomial degree of `h′` on each knot span: the basis degree minus one
311    /// (a cubic spline derivative is piecewise quadratic).
312    fn derivative_poly_degree(&self) -> usize {
313        let degree = match self {
314            DomainBasis::Periodic(spec) => spec.degree,
315            DomainBasis::Open { degree, .. } => *degree,
316        };
317        degree.saturating_sub(1)
318    }
319
320    /// Sorted distinct breakpoints bounding the polynomial pieces of `h′` over
321    /// the active domain `[lo, hi]`. Within each `[breakpoints[k],
322    /// breakpoints[k+1]]` span the derivative is a single polynomial of degree
323    /// [`Self::derivative_poly_degree`], which is what the exact monotonicity
324    /// certificate reconstructs and checks. For the open basis these are the
325    /// distinct interior+boundary knots; for the periodic basis they are the
326    /// uniform cardinal-B-spline segment boundaries over `[0, 2π]`.
327    fn derivative_breakpoints(&self) -> Vec<f64> {
328        match self {
329            DomainBasis::Periodic(spec) => {
330                // Cardinal periodic B-splines on `[origin, origin+period]` have
331                // `num_basis` uniform segments; the derivative is a separate
332                // polynomial on each.
333                let n_seg = spec.num_basis.max(1);
334                (0..=n_seg)
335                    .map(|k| spec.origin + spec.period * k as f64 / n_seg as f64)
336                    .collect()
337            }
338            DomainBasis::Open { knots, degree } => {
339                let lo = knots[*degree];
340                let hi = knots[knots.len() - 1 - degree];
341                let mut breaks: Vec<f64> = Vec::with_capacity(knots.len());
342                for &k in knots.iter() {
343                    if k > lo + 0.0 && k < hi {
344                        breaks.push(k);
345                    }
346                }
347                breaks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
348                breaks.dedup_by(|a, b| (*a - *b).abs() <= f64::EPSILON * hi.abs().max(1.0));
349                let mut out = Vec::with_capacity(breaks.len() + 2);
350                out.push(lo);
351                out.extend(breaks.into_iter().filter(|&k| k > lo && k < hi));
352                out.push(hi);
353                out
354            }
355        }
356    }
357
358    fn derivative_rows(&self, t: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
359        let projected = t.mapv(|v| self.project(v));
360        match self {
361            DomainBasis::Periodic(spec) => {
362                let n = projected.len();
363                let mut col = Array2::<f64>::zeros((n, 1));
364                for (i, &v) in projected.iter().enumerate() {
365                    col[[i, 0]] = v;
366                }
367                let jet = periodic_bspline_first_derivative_nd(
368                    col.view(),
369                    (0.0, TAU),
370                    spec.degree,
371                    spec.num_basis,
372                )
373                .map_err(|e| format!("periodic transport derivative failed: {e}"))?;
374                Ok(jet.index_axis(Axis(2), 0).to_owned())
375            }
376            DomainBasis::Open { knots, degree } => {
377                let (rows, used_knots) = create_basis::<Dense>(
378                    projected.view(),
379                    KnotSource::Provided(knots.view()),
380                    *degree,
381                    BasisOptions::first_derivative(),
382                )
383                .map_err(|e| format!("open transport derivative failed: {e}"))?;
384                if used_knots.len() != knots.len() {
385                    return Err("open transport derivative knot vector drifted".to_string());
386                }
387                Ok(rows.as_ref().to_owned())
388            }
389        }
390    }
391}
392
393/// One penalized 1-D smooth chosen by exact Gaussian REML.
394struct Penalized1dFit {
395    beta: Array1<f64>,
396    /// Scale-included posterior covariance `σ̂²(XᵀWX + λS)⁻¹`.
397    covariance: Array2<f64>,
398    lambda: f64,
399    edf: f64,
400    sigma2: f64,
401    residual_rms: f64,
402    /// Observation-level coefficient influence columns
403    /// `A^{-1} x_i w_i residual_i`. Keeping the row identity lets composition
404    /// contrasts form the joint sandwich across maps fitted on the same rows.
405    coefficient_score_influence: Array2<f64>,
406}
407
408/// Exact 1-D Gaussian REML on a fixed design/penalty pair.
409///
410/// Profile σ² out of Wood's REML,
411/// `V(λ) = (n − M₀)·log PRSS(λ) + log|XᵀWX + λS| − rank(S)·log λ`, with
412/// `M₀ = dim ker S` and `PRSS = yᵀWy − β̂ᵀXᵀWy`. Selection delegates to
413/// the engine's analytic Gaussian-REML stationary-point enumerator, so this
414/// transport path shares the same grid-free objective, derivatives, boundary
415/// comparison, and convergence certificate as every other Gaussian smooth.
416fn fit_penalized_1d(
417    design: &Array2<f64>,
418    penalty: &Array2<f64>,
419    response: ArrayView1<'_, f64>,
420    weights: Option<ArrayView1<'_, f64>>,
421    penalty_rank: usize,
422) -> Result<Penalized1dFit, String> {
423    let n = design.nrows();
424    let m = design.ncols();
425    if response.len() != n || penalty.nrows() != m || penalty.ncols() != m {
426        return Err(format!(
427            "penalized 1-D fit shape mismatch: X is {n}×{m}, y has {}, S is {}×{}",
428            response.len(),
429            penalty.nrows(),
430            penalty.ncols()
431        ));
432    }
433    if penalty_rank > m {
434        return Err(format!(
435            "penalized 1-D fit penalty rank {penalty_rank} exceeds coefficient dimension {m}"
436        ));
437    }
438    if let Some(w) = weights.as_ref() {
439        if w.len() != n {
440            return Err(format!(
441                "penalized 1-D fit weight length {} does not match n = {n}",
442                w.len()
443            ));
444        }
445        if w.iter().any(|&v| !v.is_finite() || v <= 0.0) {
446            return Err("penalized 1-D fit weights must be finite and positive".to_string());
447        }
448    }
449
450    let nullspace_dim = m - penalty_rank;
451    let weight_view = || weights.as_ref().map(|w| w.view());
452    let stationary = gaussian_reml_stationary_set(
453        design.view(),
454        response.view(),
455        penalty.view(),
456        Some(nullspace_dim),
457        weight_view(),
458        None,
459    )
460    .map_err(|error| format!("penalized 1-D REML stationary enumeration failed: {error}"))?;
461    if stationary.hit_resolution_floor {
462        return Err(format!(
463            "penalized 1-D REML is underresolved: the stationary-point enclosure reached its \
464             resolution floor ({} isolated roots, selected log-lambda {}, endpoint costs {:?})",
465            stationary.roots.len(),
466            stationary.selected_rho,
467            stationary.endpoint_costs,
468        ));
469    }
470    let reml = gaussian_reml_closed_form_with_nullspace_dim(
471        design.view(),
472        response.view(),
473        penalty.view(),
474        Some(nullspace_dim),
475        weight_view(),
476        None,
477    )
478    .map_err(|error| format!("penalized 1-D Gaussian REML failed: {error}"))?;
479    if reml.rho.to_bits() != stationary.selected_rho.to_bits() {
480        return Err(format!(
481            "penalized 1-D REML selection drifted between its certificate ({}) and fit ({})",
482            stationary.selected_rho, reml.rho,
483        ));
484    }
485
486    // If C = L⁻ᵀU is the cache's coefficient basis and δᵢ are the
487    // eigenvalues of L⁻¹SL⁻ᵀ, then the exact penalized inverse is
488    //     (XᵀWX + λS)⁻¹ = C diag((1 + λδᵢ)⁻¹) Cᵀ.
489    // Reconstruct that same inverse directly: no eigenvalue flooring and no
490    // representative-selecting ridge that would change the REML objective.
491    let lambda = reml.lambda;
492    let coefficient_basis = &reml.cache.coefficient_basis;
493    let penalty_eigenvalues = &reml.cache.penalty_eigenvalues;
494    if coefficient_basis.dim() != (m, m) || penalty_eigenvalues.len() != m {
495        return Err(format!(
496            "penalized 1-D REML cache shape drift: basis is {}x{}, spectrum has {}, expected {m}",
497            coefficient_basis.nrows(),
498            coefficient_basis.ncols(),
499            penalty_eigenvalues.len(),
500        ));
501    }
502    let mut a_inv = Array2::<f64>::zeros((m, m));
503    for i in 0..m {
504        let delta = penalty_eigenvalues[i];
505        let denominator = 1.0 + lambda * delta;
506        if !(delta.is_finite() && delta >= 0.0 && denominator.is_finite() && denominator > 0.0) {
507            return Err(format!(
508                "penalized 1-D REML cache has invalid mode {i}: delta={delta}, denominator={denominator}"
509            ));
510        }
511        let inverse_denominator = denominator.recip();
512        for j in 0..m {
513            let scaled_basis = coefficient_basis[[j, i]] * inverse_denominator;
514            for k in 0..m {
515                a_inv[[j, k]] += scaled_basis * coefficient_basis[[k, i]];
516            }
517        }
518    }
519
520    let beta = reml.coefficients;
521    let fitted = reml.fitted;
522    let edf = reml.edf;
523    let sigma2 = reml.sigma2;
524    let mut rss = 0.0_f64;
525    for r in 0..n {
526        let w = weights.as_ref().map_or(1.0, |wv| wv[r]);
527        let e = response[r] - fitted[r];
528        rss += w * e * e;
529    }
530    let covariance = a_inv.mapv(|v| v * sigma2);
531    let sum_w = weights
532        .as_ref()
533        .map_or(n as f64, |wv| wv.iter().copied().sum());
534    let residual_rms = (rss / sum_w.max(f64::MIN_POSITIVE)).sqrt();
535    let mut coefficient_score_influence = Array2::<f64>::zeros((m, n));
536    for row in 0..n {
537        let w = weights.as_ref().map_or(1.0, |wv| wv[row]);
538        let residual = response[row] - fitted[row];
539        for j in 0..m {
540            let mut sensitivity = 0.0_f64;
541            for k in 0..m {
542                sensitivity += a_inv[[j, k]] * design[[row, k]];
543            }
544            coefficient_score_influence[[j, row]] = sensitivity * w * residual;
545        }
546    }
547
548    if beta.iter().chain(a_inv.iter()).any(|v| !v.is_finite())
549        || !(sigma2.is_finite() && sigma2 > 0.0)
550    {
551        return Err("penalized 1-D REML produced non-finite posterior moments".to_string());
552    }
553    Ok(Penalized1dFit {
554        beta,
555        covariance,
556        lambda,
557        edf,
558        sigma2,
559        residual_rms,
560        coefficient_score_influence,
561    })
562}
563
564/// A fitted inter-layer transport map with full posterior bookkeeping, ready
565/// for evaluation, banding, and composition testing.
566///
567/// Representation: `h(t) = degree·t + rotation_offset + g(t)` on circle
568/// targets (`g` the REML periodic/open spline; the result is read mod 2π) and
569/// `h(t) = g(t)` on interval targets. The discrete winding `degree` and the
570/// wrap-branch offset are treated as fixed (a discrete selection and a gauge
571/// representative respectively); pointwise variances propagate the spline
572/// coefficient covariance only.
573#[derive(Debug, Clone)]
574pub struct FittedTransport {
575    pub topology_from: ChartTopology,
576    pub topology_to: ChartTopology,
577    /// Winding degree of the map (circle→circle charts only).
578    pub degree: Option<i32>,
579    /// Mean resultant length of the de-wound residual at the selected degree
580    /// (circle→circle only): the concentration evidence behind `degree`.
581    pub degree_concentration: Option<f64>,
582    /// Rotation gauge representative used to pick the wrap branch of the
583    /// angular response (circle targets; `0` for interval targets). The
584    /// estimand is the double coset, so this constant carries no information
585    /// on its own.
586    pub rotation_offset: f64,
587    /// Spline coefficients of the residual smooth `g`.
588    pub beta: Array1<f64>,
589    /// Scale-included posterior covariance of `beta` (mgcv `Vb` analogue).
590    pub covariance: Array2<f64>,
591    pub smoothing_lambda: f64,
592    /// Effective degrees of freedom of the transport smooth.
593    pub edf: f64,
594    /// REML-profiled residual variance σ̂² of the (unwrapped) response.
595    pub noise_variance: f64,
596    pub n_obs: usize,
597    /// Empirical-density-weighted isometry defect `mean((|h′(tᵢ)| − 1)²)`.
598    pub isometry_defect: f64,
599    /// Delta-method standard error of the isometry defect.
600    pub isometry_defect_se: f64,
601    /// Whether `h` is compatible with both chart topologies: a degree-±1
602    /// circle cover without folds, or a fold-free interval homeomorphism.
603    pub topology_preserved: bool,
604    /// `min over a dense grid of orientation·h′(t)`; positive ⇔ no folds.
605    pub min_directional_derivative: f64,
606    /// RMS of the response residuals at the fitted map.
607    pub residual_rms: f64,
608    basis: DomainBasis,
609    coefficient_score_influence: Array2<f64>,
610}
611
612impl FittedTransport {
613    fn linear_slope(&self) -> f64 {
614        self.degree.map_or(0.0, f64::from)
615    }
616
617    /// Evaluate `h` at `t` (wrapped to `[0, 2π)` on circle targets).
618    pub fn eval(&self, t: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
619        let rows = self.basis.value_rows(t)?;
620        let smooth = rows.dot(&self.beta);
621        let slope = self.linear_slope();
622        let mut out = Array1::<f64>::zeros(t.len());
623        for i in 0..t.len() {
624            let raw = slope * t[i] + self.rotation_offset + smooth[i];
625            out[i] = match self.topology_to {
626                ChartTopology::Circle => wrap_tau(raw),
627                ChartTopology::Interval { .. } => raw,
628            };
629        }
630        Ok(out)
631    }
632
633    /// Evaluate `h` and its pointwise delta-method variance.
634    pub fn eval_with_variance(
635        &self,
636        t: ArrayView1<'_, f64>,
637    ) -> Result<(Array1<f64>, Array1<f64>), String> {
638        let rows = self.basis.value_rows(t)?;
639        let values = self.eval(t)?;
640        let mut variances = Array1::<f64>::zeros(t.len());
641        for i in 0..t.len() {
642            let row = rows.row(i);
643            variances[i] = row.dot(&self.covariance.dot(&row)).max(0.0);
644        }
645        Ok((values, variances))
646    }
647
648    /// Evaluate `h′(t)` (chart-coordinate derivative).
649    pub fn derivative(&self, t: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
650        let rows = self.basis.derivative_rows(t)?;
651        let slope = self.linear_slope();
652        Ok(rows.dot(&self.beta).mapv(|v| v + slope))
653    }
654
655    /// Point-evaluation influence by original observation row. The returned
656    /// matrix has shape `(t.len(), n_obs)` and retains cross-map row identity.
657    fn eval_score_influence(&self, t: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
658        Ok(self
659            .basis
660            .value_rows(t)?
661            .dot(&self.coefficient_score_influence))
662    }
663
664    /// Pre-wrap map value `slope·t + offset + g(t)` at a single point — the
665    /// strictly monotone (when fold-free) handle that [`Self::eval`] wraps for
666    /// circle targets and [`Self::invert`] bisects on.
667    fn raw_at(&self, t: f64) -> Result<f64, String> {
668        let arr = Array1::from_elem(1, t);
669        let smooth = self.basis.value_rows(arr.view())?.dot(&self.beta)[0];
670        Ok(self.linear_slope() * t + self.rotation_offset + smooth)
671    }
672
673    /// `orientation·h′` at the supplied source-chart coordinates.
674    fn oriented_derivative_at(&self, t: &[f64], orientation: f64) -> Result<Vec<f64>, String> {
675        let arr = Array1::from_vec(t.to_vec());
676        let rows = self.basis.derivative_rows(arr.view())?;
677        let slope = self.linear_slope();
678        Ok((0..t.len())
679            .map(|i| orientation * (rows.row(i).dot(&self.beta) + slope))
680            .collect())
681    }
682
683    /// Exactly certify that `h` is strictly monotone over the whole source
684    /// domain, returning the certified orientation (+1 increasing, −1
685    /// decreasing) or an `Err` describing where monotonicity fails.
686    ///
687    /// Unlike [`Self::topology_preserved`], which only samples `h′` on a fixed
688    /// 512-point grid and so can miss a fold *between* grid samples, this is a
689    /// span-exact certificate. On each knot span `h′` is a single polynomial of
690    /// degree `d = `[`DomainBasis::derivative_poly_degree`]` (cubic spline ⇒
691    /// quadratic). A degree-`d` polynomial is determined by `d + 1` samples, so
692    /// per span we sample `h′` at `d + 1` equally-spaced abscissae, reconstruct
693    /// the polynomial by finite differences, locate its interior critical
694    /// points in closed form, and require `orientation·h′ > 0` at the span
695    /// endpoints **and** every interior critical point. To stay sound even if a
696    /// basis is not an exact polynomial of the assumed degree on a span (e.g. a
697    /// row-normalized periodic basis whose row-sum is not a partition of unity),
698    /// the reconstruction is verified against an independent interior sample;
699    /// any mismatch falls back to refusing the span.
700    fn certify_strict_monotonicity(&self) -> Result<f64, String> {
701        let (lo, hi) = match self.topology_from {
702            ChartTopology::Circle => (0.0, TAU),
703            ChartTopology::Interval { lo, hi } => (lo, hi),
704        };
705        // Orientation from the endpoint span of the pre-wrap map, matching the
706        // sign convention `invert` bisects with.
707        let raw_lo = self.raw_at(lo)?;
708        let raw_hi = self.raw_at(hi)?;
709        let orientation = if raw_hi >= raw_lo { 1.0 } else { -1.0 };
710
711        let deg = self.basis.derivative_poly_degree().max(1);
712        let breaks = self.basis.derivative_breakpoints();
713        // Restrict the breakpoints to the active domain (the periodic segment
714        // grid already coincides with `[lo, hi]`).
715        for window in breaks.windows(2) {
716            let (a, b) = (window[0], window[1]);
717            if !(b > a) {
718                continue;
719            }
720            let span = b - a;
721            // Reconstruction abscissae: `deg + 1` equally spaced nodes on the
722            // closed span (sampling strictly inside avoids the knot where two
723            // pieces meet and the open-basis derivative can be one-sided).
724            let pad = span * 1.0e-9;
725            let n_nodes = deg + 1;
726            let nodes: Vec<f64> = (0..n_nodes)
727                .map(|i| {
728                    let s = if n_nodes == 1 {
729                        0.5
730                    } else {
731                        i as f64 / (n_nodes - 1) as f64
732                    };
733                    (a + pad) + (span - 2.0 * pad) * s
734                })
735                .collect();
736            let values = self.oriented_derivative_at(&nodes, orientation)?;
737
738            // Polynomial in the local coordinate u = (t - nodes[0]) / step,
739            // reconstructed by Newton forward differences on the equally-spaced
740            // nodes. Coefficients in the monomial basis of u are recovered for
741            // the closed-form critical-point search.
742            let step = if n_nodes > 1 {
743                nodes[1] - nodes[0]
744            } else {
745                span
746            };
747            let coeffs = monomial_from_equispaced(&values);
748
749            // Sound guard: verify the reconstruction reproduces an independent
750            // interior sample (deliberately off the reconstruction nodes — the
751            // equispaced nodes never land on a 0.37 fraction). If the basis is
752            // not exactly polynomial of the assumed degree on this span, refuse
753            // rather than trust the fit.
754            let probe_t = a + 0.37 * span;
755            let probe_u = (probe_t - nodes[0]) / step;
756            let probe_recon = eval_monomial(&coeffs, probe_u);
757            let probe_actual = self.oriented_derivative_at(&[probe_t], orientation)?[0];
758            let scale = probe_actual.abs().max(1.0);
759            if (probe_recon - probe_actual).abs() > 1.0e-6 * scale {
760                return Err(format!(
761                    "transport monotonicity certificate could not reconstruct h′ on the \
762                     span [{a}, {b}] (reconstruction {probe_recon} vs actual {probe_actual}); \
763                     refusing to certify"
764                ));
765            }
766
767            // Require positivity at the closed-span endpoints.
768            for &edge in &[a, b] {
769                let u = (edge - nodes[0]) / step;
770                let v = eval_monomial(&coeffs, u);
771                if !(v > 0.0) {
772                    return Err(format!(
773                        "transport map is not strictly monotone: orientation·h′ = {v} ≤ 0 at \
774                         t = {edge}"
775                    ));
776                }
777            }
778            // Require positivity at every interior critical point of the
779            // polynomial within the span.
780            for u_crit in monomial_critical_points(&coeffs) {
781                let t_crit = nodes[0] + u_crit * step;
782                if t_crit > a && t_crit < b {
783                    let v = eval_monomial(&coeffs, u_crit);
784                    if !(v > 0.0) {
785                        return Err(format!(
786                            "transport map folds: orientation·h′ = {v} ≤ 0 at interior \
787                             extremum t = {t_crit}"
788                        ));
789                    }
790                }
791            }
792        }
793        Ok(orientation)
794    }
795
796    /// Invert the transport: for each target-chart coordinate `y`, return the
797    /// source-chart coordinate `t` with `eval([t]) == y`.
798    ///
799    /// Requires a strictly monotone, fold-free map (a degree-±1 cover for
800    /// circle charts, a homeomorphism for intervals), so the inverse is
801    /// single-valued; otherwise this errors rather than picking an arbitrary
802    /// branch. Monotonicity is established with [`Self::certify_strict_monotonicity`]
803    /// — a span-exact polynomial certificate, **not** the sampled
804    /// `topology_preserved` diagnostic, which can miss a narrow fold between its
805    /// grid samples. Non-finite targets are rejected. Interval targets reject a
806    /// `y` outside the fitted image (scale-aware tolerance); circle targets
807    /// accept any `y` (the pre-wrap map covers a full `2π`). The root is found
808    /// by monotone bisection on the pre-wrap map `raw_at`, which converges to
809    /// f64 precision (~53 significand bits) in the source coordinate after on
810    /// the order of 50 iterations.
811    ///
812    /// This is the exact inverse of [`Self::eval`] and the missing half of the
813    /// transport algebra alongside [`composition_defect`]: it is what lets a
814    /// caller form `g_B ∘ g_A⁻¹` from two fitted transports.
815    pub fn invert(&self, y: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
816        if y.iter().any(|v| !v.is_finite()) {
817            return Err("transport inverse targets must be finite".to_string());
818        }
819        // Span-exact strict-monotonicity certificate; supersedes the sampled
820        // `topology_preserved` flag, which can pass over a between-sample fold.
821        self.certify_strict_monotonicity()?;
822        let (lo, hi) = match self.topology_from {
823            ChartTopology::Circle => (0.0, TAU),
824            ChartTopology::Interval { lo, hi } => (lo, hi),
825        };
826        // The pre-wrap map is strictly monotone over [lo, hi]; the endpoints
827        // anchor its orientation and image span.
828        let raw_lo = self.raw_at(lo)?;
829        let raw_hi = self.raw_at(hi)?;
830        let increasing = raw_hi > raw_lo;
831        let (raw_min, raw_max) = if increasing {
832            (raw_lo, raw_hi)
833        } else {
834            (raw_hi, raw_lo)
835        };
836        // Scale-aware image tolerance: an absolute 1e-9 would wrongly accept a
837        // target well outside a tiny image (e.g. [0, 1e-8]).
838        let scale = raw_min.abs().max(raw_max.abs()).max(1.0);
839        let tol = 32.0 * f64::EPSILON * scale;
840
841        // One reusable single-element buffer for the bisection probes (rebuilt
842        // basis rows on every probe otherwise allocated a fresh `Array1`).
843        let mut probe = Array1::<f64>::zeros(1);
844        let mut raw_at_into = |t: f64| -> Result<f64, String> {
845            probe[0] = t;
846            let smooth = self.basis.value_rows(probe.view())?.dot(&self.beta)[0];
847            Ok(self.linear_slope() * t + self.rotation_offset + smooth)
848        };
849
850        let mut out = Array1::<f64>::zeros(y.len());
851        for (idx, &yi) in y.iter().enumerate() {
852            // Target value in the pre-wrap coordinate.
853            let target = match self.topology_to {
854                ChartTopology::Interval { .. } => {
855                    if yi < raw_min - tol || yi > raw_max + tol {
856                        return Err(format!(
857                            "transport inverse target {yi} is outside the fitted image \
858                             [{raw_min}, {raw_max}]"
859                        ));
860                    }
861                    yi.clamp(raw_min, raw_max)
862                }
863                ChartTopology::Circle => {
864                    // The pre-wrap map covers exactly 2π; shift wrap_tau(y) by
865                    // the unique integer multiple of 2π that lands in the image.
866                    let ywrapped = wrap_tau(yi);
867                    let m = ((raw_min - ywrapped) / TAU).ceil();
868                    ywrapped + TAU * m
869                }
870            };
871            // Monotone bisection on the pre-wrap map over [lo, hi]; stop once
872            // the bracket is below the source-coordinate precision floor (f64
873            // bisection stagnates well before 100 iterations).
874            let (mut a, mut b) = (lo, hi);
875            let width_floor = f64::EPSILON * hi.abs().max(lo.abs()).max(1.0);
876            for _ in 0..100 {
877                if (b - a) <= width_floor {
878                    break;
879                }
880                let mid = 0.5 * (a + b);
881                let rm = raw_at_into(mid)?;
882                let go_right = if increasing { rm < target } else { rm > target };
883                if go_right {
884                    a = mid;
885                } else {
886                    b = mid;
887                }
888            }
889            out[idx] = 0.5 * (a + b);
890        }
891        Ok(out)
892    }
893
894    /// Package the fit as a [`LayerTransportReport`] for the given layer pair
895    /// (composition fields empty; see [`LayerTransportReport::with_composition`]).
896    pub fn report(&self, layer_from: usize, layer_to: usize) -> LayerTransportReport {
897        LayerTransportReport {
898            layer_from,
899            layer_to,
900            topology_from: self.topology_from,
901            topology_to: self.topology_to,
902            topology_preserved: self.topology_preserved,
903            degree: self.degree,
904            degree_concentration: self.degree_concentration,
905            rotation_offset: self.rotation_offset,
906            isometry_defect: self.isometry_defect,
907            isometry_defect_se: self.isometry_defect_se,
908            min_directional_derivative: self.min_directional_derivative,
909            transport_edf: self.edf,
910            smoothing_lambda: self.smoothing_lambda,
911            noise_variance: self.noise_variance,
912            residual_rms: self.residual_rms,
913            n_obs: self.n_obs,
914            composition_defect: None,
915            composition_max_studentized: None,
916            composition_p_value: None,
917            composition_gauge_reflected: None,
918        }
919    }
920}
921
922/// Evidence payload for one estimated inter-layer transport map.
923#[derive(Debug, Clone)]
924pub struct LayerTransportReport {
925    pub layer_from: usize,
926    pub layer_to: usize,
927    pub topology_from: ChartTopology,
928    pub topology_to: ChartTopology,
929    /// Degree-±1 fold-free circle cover (or fold-free interval homeo).
930    pub topology_preserved: bool,
931    /// Estimated winding degree (circle→circle only).
932    pub degree: Option<i32>,
933    /// Circular concentration of the de-wound residual at `degree`.
934    pub degree_concentration: Option<f64>,
935    /// Rotation gauge representative (circle targets).
936    pub rotation_offset: f64,
937    /// `∫(|h′| − 1)² dP̂` under the empirical chart density.
938    pub isometry_defect: f64,
939    /// Delta-method SE of the isometry defect.
940    pub isometry_defect_se: f64,
941    /// Fold diagnostic: min of orientation·h′ over a dense grid.
942    pub min_directional_derivative: f64,
943    /// EDF of the REML transport smooth.
944    pub transport_edf: f64,
945    pub smoothing_lambda: f64,
946    pub noise_variance: f64,
947    pub residual_rms: f64,
948    pub n_obs: usize,
949    /// RMS composition defect of the triple ending at this two-hop map
950    /// (populated by [`transport_ladder`] / [`LayerTransportReport::with_composition`]).
951    pub composition_defect: Option<f64>,
952    /// Max studentized composition defect against the composed bands.
953    pub composition_max_studentized: Option<f64>,
954    /// Bonferroni familywise p-value from the joint shared-row sandwich.
955    /// `None` when the fitted maps carry no empirical score variation.
956    pub composition_p_value: Option<f64>,
957    /// Always `false`: both routes already land in the same target chart, so no
958    /// post-hoc target alignment is fitted.
959    pub composition_gauge_reflected: Option<bool>,
960}
961
962impl LayerTransportReport {
963    /// Merge a composition-law test into this (direct, two-hop) report.
964    pub fn with_composition(mut self, composition: &CompositionDefectReport) -> Self {
965        self.composition_defect = Some(composition.rms_defect);
966        self.composition_max_studentized = Some(composition.max_studentized_defect);
967        self.composition_p_value = composition
968            .p_value
969            .is_finite()
970            .then_some(composition.p_value);
971        self.composition_gauge_reflected = Some(composition.gauge_reflected);
972        self
973    }
974}
975
976/// Estimate the transport map `h: M_from → M_to` between two chart
977/// coordinatizations of the same rows.
978///
979/// `coords_from[i]` and `coords_to[i]` must coordinatize the same observation
980/// in the source and target charts. Circle coordinates are radians (any
981/// branch; wrapped internally). See the module docs for the estimator.
982pub fn fit_transport_map(
983    coords_from: ArrayView1<'_, f64>,
984    coords_to: ArrayView1<'_, f64>,
985    topology_from: ChartTopology,
986    topology_to: ChartTopology,
987) -> Result<FittedTransport, String> {
988    let n = coords_from.len();
989    if coords_to.len() != n {
990        return Err(format!(
991            "layer transport coordinate lengths disagree: {} vs {}",
992            n,
993            coords_to.len()
994        ));
995    }
996    if n < MIN_TRANSPORT_OBS {
997        return Err(format!(
998            "layer transport needs at least {MIN_TRANSPORT_OBS} paired observations, got {n}"
999        ));
1000    }
1001    if coords_from
1002        .iter()
1003        .chain(coords_to.iter())
1004        .any(|v| !v.is_finite())
1005    {
1006        return Err("layer transport coordinates must all be finite".to_string());
1007    }
1008    topology_from.validate()?;
1009    topology_to.validate()?;
1010
1011    // --- degree + rotation gauge + unwrapped response -----------------------
1012    let (degree, degree_concentration, rotation_offset, response): (
1013        Option<i32>,
1014        Option<f64>,
1015        f64,
1016        Array1<f64>,
1017    ) = match (topology_from, topology_to) {
1018        (ChartTopology::Circle, ChartTopology::Circle) => {
1019            // Winding degree by circular concentration: over candidate
1020            // degrees d, the de-wound residual r_i(d) = θ_to − d·θ_from is
1021            // tightest (largest mean resultant length R_d) at the true
1022            // degree whenever the smooth residual stays inside half a turn.
1023            // This is the circular-correlation-maximizing degree estimate
1024            // the issue specifies, in resultant form.
1025            let mut best_degree = DEGREE_CANDIDATES[0];
1026            let mut best_r = f64::NEG_INFINITY;
1027            for &d in DEGREE_CANDIDATES.iter() {
1028                let residual: Vec<f64> = (0..n)
1029                    .map(|i| coords_to[i] - f64::from(d) * coords_from[i])
1030                    .collect();
1031                let r = resultant_length(&residual);
1032                if r > best_r {
1033                    best_r = r;
1034                    best_degree = d;
1035                }
1036            }
1037            let residual: Vec<f64> = (0..n)
1038                .map(|i| coords_to[i] - f64::from(best_degree) * coords_from[i])
1039                .collect();
1040            let mu = circular_mean(&residual);
1041            let response = Array1::from_iter(residual.iter().map(|&r| wrap_pi(r - mu)));
1042            (Some(best_degree), Some(best_r), mu, response)
1043        }
1044        (_, ChartTopology::Circle) => {
1045            // Interval domain, circular target: the domain is contractible so
1046            // the map is null-homotopic — no winding term. Unwrap the angular
1047            // response about its circular mean.
1048            let angles: Vec<f64> = coords_to.iter().copied().collect();
1049            let mu = circular_mean(&angles);
1050            let response = Array1::from_iter(angles.iter().map(|&a| wrap_pi(a - mu)));
1051            (None, None, mu, response)
1052        }
1053        (_, ChartTopology::Interval { .. }) => (None, None, 0.0, coords_to.to_owned()),
1054    };
1055
1056    // --- REML residual smooth on the source chart ---------------------------
1057    let basis = DomainBasis::build(topology_from, coords_from)?;
1058    let design = basis.value_rows(coords_from)?;
1059    let penalty = basis.penalty()?;
1060    let fit = fit_penalized_1d(
1061        &design,
1062        &penalty,
1063        response.view(),
1064        None,
1065        basis.penalty_rank(),
1066    )?;
1067
1068    // --- isometry defect under the empirical density -------------------------
1069    let slope = degree.map_or(0.0, f64::from);
1070    let deriv_rows = basis.derivative_rows(coords_from)?;
1071    let deriv = deriv_rows.dot(&fit.beta).mapv(|v| v + slope);
1072    let m = basis.num_basis();
1073    let mut defect = 0.0_f64;
1074    let mut grad = Array1::<f64>::zeros(m);
1075    for i in 0..n {
1076        let speed = deriv[i].abs();
1077        let gap = speed - 1.0;
1078        defect += gap * gap;
1079        let sgn = if deriv[i] >= 0.0 { 1.0 } else { -1.0 };
1080        for j in 0..m {
1081            grad[j] += 2.0 * gap * sgn * deriv_rows[[i, j]];
1082        }
1083    }
1084    defect /= n as f64;
1085    grad.mapv_inplace(|v| v / n as f64);
1086    let isometry_defect_se = grad.dot(&fit.covariance.dot(&grad)).max(0.0).sqrt();
1087
1088    // --- fold / orientation check on a dense grid ---------------------------
1089    let grid = domain_grid(topology_from, FOLD_CHECK_GRID);
1090    let grid_deriv = basis
1091        .derivative_rows(grid.view())?
1092        .dot(&fit.beta)
1093        .mapv(|v| v + slope);
1094    let orientation = if slope != 0.0 {
1095        slope.signum()
1096    } else {
1097        let mean = grid_deriv.iter().sum::<f64>() / grid_deriv.len() as f64;
1098        if mean < 0.0 { -1.0 } else { 1.0 }
1099    };
1100    let min_directional_derivative = grid_deriv
1101        .iter()
1102        .map(|&v| orientation * v)
1103        .fold(f64::INFINITY, f64::min);
1104    let topology_preserved = match (topology_from, topology_to) {
1105        (ChartTopology::Circle, ChartTopology::Circle) => {
1106            matches!(degree, Some(1) | Some(-1)) && min_directional_derivative > 0.0
1107        }
1108        (ChartTopology::Interval { .. }, ChartTopology::Interval { .. }) => {
1109            min_directional_derivative > 0.0
1110        }
1111        _ => false,
1112    };
1113
1114    Ok(FittedTransport {
1115        topology_from,
1116        topology_to,
1117        degree,
1118        degree_concentration,
1119        rotation_offset,
1120        beta: fit.beta,
1121        covariance: fit.covariance,
1122        smoothing_lambda: fit.lambda,
1123        edf: fit.edf,
1124        noise_variance: fit.sigma2,
1125        n_obs: n,
1126        isometry_defect: defect,
1127        isometry_defect_se,
1128        topology_preserved,
1129        min_directional_derivative,
1130        residual_rms: fit.residual_rms,
1131        basis,
1132        coefficient_score_influence: fit.coefficient_score_influence,
1133    })
1134}
1135
1136/// Estimate the transport map between two layers and package the evidence.
1137pub fn fit_layer_transport(
1138    layer_from: usize,
1139    layer_to: usize,
1140    coords_from: ArrayView1<'_, f64>,
1141    coords_to: ArrayView1<'_, f64>,
1142    topology_from: ChartTopology,
1143    topology_to: ChartTopology,
1144) -> Result<LayerTransportReport, String> {
1145    Ok(
1146        fit_transport_map(coords_from, coords_to, topology_from, topology_to)?
1147            .report(layer_from, layer_to),
1148    )
1149}
1150
1151/// Composition-law test report for one triple `(h_ab, h_bc, h_ac)`.
1152#[derive(Debug, Clone)]
1153pub struct CompositionDefectReport {
1154    pub n_grid: usize,
1155    /// Always zero: no post-hoc target rotation is fitted.
1156    pub gauge_rotation: f64,
1157    /// Always false: no post-hoc target reflection is fitted.
1158    pub gauge_reflected: bool,
1159    pub mean_abs_defect: f64,
1160    pub rms_defect: f64,
1161    pub max_abs_defect: f64,
1162    /// `max_t |d(t)| / band(t)` against the composed pointwise bands.
1163    pub max_studentized_defect: f64,
1164    /// Bonferroni p-value bound for the max studentized defect over all tested
1165    /// grid points.
1166    pub max_studentized_p_value: f64,
1167    /// Alias of the familywise max-test p-value for report consumers. `NaN`
1168    /// explicitly means that deterministic fits supplied no sampling variation.
1169    pub p_value: f64,
1170}
1171
1172/// Recover the monomial coefficients (ascending: `c[0] + c[1]·u + …`) of the
1173/// degree-`(values.len()−1)` polynomial that interpolates `values` at the
1174/// integer abscissae `u = 0, 1, …, values.len()−1`. Used by the strict
1175/// monotonicity certificate to reconstruct `h′` on a knot span from equally
1176/// spaced samples. Exact for the polynomial pieces of a B-spline derivative.
1177fn monomial_from_equispaced(values: &[f64]) -> Vec<f64> {
1178    let n = values.len();
1179    if n == 0 {
1180        return Vec::new();
1181    }
1182    // Newton forward differences Δ^k f[0] over the equally spaced nodes.
1183    let mut diffs: Vec<f64> = values.to_vec();
1184    let mut fwd = vec![0.0_f64; n];
1185    fwd[0] = diffs[0];
1186    for k in 1..n {
1187        for i in 0..(n - k) {
1188            diffs[i] = diffs[i + 1] - diffs[i];
1189        }
1190        fwd[k] = diffs[0];
1191    }
1192    // Newton form p(u) = Σ_k Δ^k f[0] · C(u, k), with the falling-factorial
1193    // binomial C(u, k) = u(u−1)…(u−k+1)/k!. Accumulate into monomial coeffs.
1194    let mut coeffs = vec![0.0_f64; n];
1195    // poly tracks the expanded C(u,k)·k!  = Π_{j<k}(u − j); divide by k! via the
1196    // running factorial.
1197    let mut poly = vec![0.0_f64; n];
1198    poly[0] = 1.0;
1199    let mut poly_len = 1usize;
1200    let mut factorial = 1.0_f64;
1201    for k in 0..n {
1202        if k > 0 {
1203            factorial *= k as f64;
1204        }
1205        let scale = fwd[k] / factorial;
1206        for (i, &p) in poly.iter().take(poly_len).enumerate() {
1207            coeffs[i] += scale * p;
1208        }
1209        // Multiply running product by (u − k): poly ← poly·(u − k).
1210        if k + 1 < n {
1211            let mut next = vec![0.0_f64; poly_len + 1];
1212            for i in 0..poly_len {
1213                next[i + 1] += poly[i]; // u · poly
1214                next[i] -= (k as f64) * poly[i]; // −k · poly
1215            }
1216            for i in 0..=poly_len {
1217                poly[i] = next[i];
1218            }
1219            poly_len += 1;
1220        }
1221    }
1222    coeffs
1223}
1224
1225/// Evaluate an ascending monomial polynomial at `u` (Horner).
1226fn eval_monomial(coeffs: &[f64], u: f64) -> f64 {
1227    coeffs.iter().rev().fold(0.0_f64, |acc, &c| acc * u + c)
1228}
1229
1230/// Interior critical points (roots of the derivative) of an ascending monomial
1231/// polynomial, in the local `u` coordinate. Returns the closed-form roots for
1232/// degree ≤ 2 derivatives (i.e. cubic-spline pieces, the production path);
1233/// higher-degree derivatives fall back to a robust bisection root-isolation so
1234/// the certificate stays exact-enough (a missed extremum can only make the
1235/// certificate stricter, never falsely accept a fold, because the endpoints and
1236/// every sign change found are still checked). For the cubic transport splines
1237/// the polynomial is quadratic and this is the single vertex.
1238fn monomial_critical_points(coeffs: &[f64]) -> Vec<f64> {
1239    // Derivative coefficients: d/du Σ c_k u^k = Σ k·c_k u^{k−1}.
1240    let n = coeffs.len();
1241    if n <= 1 {
1242        return Vec::new();
1243    }
1244    let deriv: Vec<f64> = (1..n).map(|k| k as f64 * coeffs[k]).collect();
1245    // deriv is ascending of length n−1 (degree n−2).
1246    match deriv.len() {
1247        0 => Vec::new(),
1248        1 => Vec::new(), // constant derivative: no critical point
1249        2 => {
1250            // Linear b + a·u = 0 (a = deriv[1]).
1251            let (b, a) = (deriv[0], deriv[1]);
1252            if a.abs() <= f64::MIN_POSITIVE {
1253                Vec::new()
1254            } else {
1255                vec![-b / a]
1256            }
1257        }
1258        3 => {
1259            // Quadratic c + b·u + a·u² = 0.
1260            let (c, b, a) = (deriv[0], deriv[1], deriv[2]);
1261            if a.abs() <= f64::MIN_POSITIVE {
1262                if b.abs() <= f64::MIN_POSITIVE {
1263                    Vec::new()
1264                } else {
1265                    vec![-c / b]
1266                }
1267            } else {
1268                let disc = b * b - 4.0 * a * c;
1269                if disc < 0.0 {
1270                    Vec::new()
1271                } else {
1272                    let s = disc.sqrt();
1273                    vec![(-b + s) / (2.0 * a), (-b - s) / (2.0 * a)]
1274                }
1275            }
1276        }
1277        _ => {
1278            // General fallback: scan for sign changes of the derivative on a
1279            // dense [0, deg] grid and bisect each bracket. Conservative.
1280            let lo = 0.0;
1281            let hi = (coeffs.len() - 1) as f64;
1282            let steps = 256;
1283            let mut roots = Vec::new();
1284            let f = |u: f64| eval_monomial(&deriv, u);
1285            let mut prev_u = lo;
1286            let mut prev_v = f(lo);
1287            for i in 1..=steps {
1288                let u = lo + (hi - lo) * i as f64 / steps as f64;
1289                let v = f(u);
1290                if prev_v == 0.0 {
1291                    roots.push(prev_u);
1292                } else if prev_v * v < 0.0 {
1293                    let (mut a, mut b) = (prev_u, u);
1294                    for _ in 0..60 {
1295                        let m = 0.5 * (a + b);
1296                        if f(a) * f(m) <= 0.0 {
1297                            b = m;
1298                        } else {
1299                            a = m;
1300                        }
1301                    }
1302                    roots.push(0.5 * (a + b));
1303                }
1304                prev_u = u;
1305                prev_v = v;
1306            }
1307            roots
1308        }
1309    }
1310}
1311
1312/// Uniform evaluation grid over a chart domain.
1313fn domain_grid(topology: ChartTopology, n: usize) -> Array1<f64> {
1314    match topology {
1315        ChartTopology::Circle => Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64)),
1316        ChartTopology::Interval { lo, hi } => {
1317            Array1::from_iter((0..n).map(|i| lo + (hi - lo) * i as f64 / (n - 1).max(1) as f64))
1318        }
1319    }
1320}
1321
1322/// Test the composition law `h_ac ≟ h_bc ∘ h_ab` on `n_grid` points.
1323///
1324/// The defect `d(t) = h_ac(t) ⊖ (h_bc ∘ h_ab)(t)` (circular difference on
1325/// circle targets) is computed directly in the common target chart: no gauge is
1326/// selected after seeing the defect. Pointwise uncertainty is assembled from
1327/// the combined observation-level influence of all three maps, retaining their
1328/// shared-row covariance, and the grid is tested by a Bonferroni max statistic.
1329pub fn composition_defect(
1330    h_ab: &FittedTransport,
1331    h_bc: &FittedTransport,
1332    h_ac: &FittedTransport,
1333    n_grid: usize,
1334) -> Result<CompositionDefectReport, String> {
1335    if h_ab.topology_from != h_ac.topology_from
1336        || h_ab.topology_to != h_bc.topology_from
1337        || h_bc.topology_to != h_ac.topology_to
1338    {
1339        return Err("composition defect requires chart-compatible transports: \
1340             h_ab: A→B, h_bc: B→C, h_ac: A→C"
1341            .to_string());
1342    }
1343    if n_grid < MIN_TRANSPORT_OBS {
1344        return Err(format!(
1345            "composition defect grid must have at least {MIN_TRANSPORT_OBS} points, got {n_grid}"
1346        ));
1347    }
1348    if h_ab.n_obs != h_bc.n_obs || h_ab.n_obs != h_ac.n_obs {
1349        return Err(format!(
1350            "composition defect requires maps fitted on the same rows; got n_ab={}, n_bc={}, n_ac={}",
1351            h_ab.n_obs, h_bc.n_obs, h_ac.n_obs
1352        ));
1353    }
1354
1355    let grid = domain_grid(h_ab.topology_from, n_grid);
1356    let direct = h_ac.eval(grid.view())?;
1357    let mid = h_ab.eval(grid.view())?;
1358    let composed = h_bc.eval(mid.view())?;
1359    let mid_slope = h_bc.derivative(mid.view())?;
1360
1361    // Joint row-influence sandwich. For original fit row r and evaluation point
1362    // t, the first-order influence of the composition defect is
1363    //   IF_ac(t,r) - IF_bc(h_ab(t),r) - h_bc'(h_ab(t)) IF_ab(t,r).
1364    // Squaring the combined influence before summing retains every shared-fit
1365    // covariance term; adding three marginal variances drops those cross terms.
1366    let influence_direct = h_ac.eval_score_influence(grid.view())?;
1367    let influence_ab = h_ab.eval_score_influence(grid.view())?;
1368    let influence_bc = h_bc.eval_score_influence(mid.view())?;
1369    let mut variance = Array1::<f64>::zeros(n_grid);
1370    for i in 0..n_grid {
1371        let mut value = 0.0_f64;
1372        for row in 0..h_ab.n_obs {
1373            let influence = influence_direct[[i, row]]
1374                - influence_bc[[i, row]]
1375                - mid_slope[i] * influence_ab[[i, row]];
1376            value += influence * influence;
1377        }
1378        variance[i] = value;
1379    }
1380
1381    // Both routes consume the same source chart and land in the same target
1382    // chart. Every source/target gauge transformation therefore acts on both
1383    // routes identically and cancels. Fitting a fresh rotation/reflection here
1384    // would fit away the very composition violation being tested.
1385    let circle_target = matches!(h_ac.topology_to, ChartTopology::Circle);
1386    let defect = Array1::from_iter((0..n_grid).map(|i| {
1387        if circle_target {
1388            wrap_pi(direct[i] - composed[i])
1389        } else {
1390            direct[i] - composed[i]
1391        }
1392    }));
1393
1394    // --- pointwise studentization against the composed bands ----------------
1395    // Floor the band variance with BOTH a relative component (numerical guard
1396    // against exact zeros) AND an absolute, coordinate-scale component. The
1397    // absolute component is the fix for #2143: the delta-method band variance is
1398    // a pure sampling variance that collapses on near-noiseless REML fits, so
1399    // without it the irreducible spline-representation defect (which the sampling
1400    // variance does not model) is studentized into a spurious rejection. The
1401    // absolute floor is the squared representation tolerance relative to the
1402    // target chart's coordinate span, so a machine-level composition defect on a
1403    // clean chain reads as non-significant while a genuine violation (far larger
1404    // defect, or real sampling variance well above the floor) is unaffected.
1405    let max_var = variance.iter().copied().fold(0.0_f64, f64::max);
1406    let var_floor = (max_var * 1e-12).max(f64::MIN_POSITIVE);
1407    let mut max_abs = 0.0_f64;
1408    let mut sum_abs = 0.0_f64;
1409    let mut sum_sq = 0.0_f64;
1410    let mut max_z = 0.0_f64;
1411    for i in 0..n_grid {
1412        let d = defect[i];
1413        let a = d.abs();
1414        max_abs = max_abs.max(a);
1415        sum_abs += a;
1416        sum_sq += d * d;
1417        if max_var > 0.0 {
1418            let z = a / variance[i].max(var_floor).sqrt();
1419            max_z = max_z.max(z);
1420        }
1421    }
1422    let mean_abs_defect = sum_abs / n_grid as f64;
1423    let rms_defect = (sum_sq / n_grid as f64).sqrt();
1424
1425    // Bonferroni bound for the max studentized defect over the actual grid:
1426    // valid for arbitrary dependence among pointwise contrasts. With zero
1427    // empirical score variation there is no sampling law, so no p-value is
1428    // emitted from deterministic fitted-grid values.
1429    let max_studentized_p_value = if max_var > 0.0 {
1430        let normal = Normal::new(0.0, 1.0)
1431            .map_err(|e| format!("standard normal construction failed: {e}"))?;
1432        let pointwise: f64 = (2.0 * (1.0 - normal.cdf(max_z))).clamp(0.0, 1.0);
1433        (n_grid as f64 * pointwise).min(1.0)
1434    } else {
1435        f64::NAN
1436    };
1437
1438    Ok(CompositionDefectReport {
1439        n_grid,
1440        gauge_rotation: 0.0,
1441        gauge_reflected: false,
1442        mean_abs_defect,
1443        rms_defect,
1444        max_abs_defect: max_abs,
1445        max_studentized_defect: if max_var > 0.0 { max_z } else { f64::NAN },
1446        max_studentized_p_value,
1447        p_value: max_studentized_p_value,
1448    })
1449}
1450
1451/// Full transport report for a ladder of layers: every adjacent map plus
1452/// every two-hop map with its composition-law test attached.
1453#[derive(Debug, Clone)]
1454pub struct TransportLadderReport {
1455    /// `h_{l→l+1}` for each consecutive pair.
1456    pub adjacent: Vec<LayerTransportReport>,
1457    /// `h_{l→l+2}` with the composition test against the composed adjacent
1458    /// pair merged in.
1459    pub two_hop: Vec<LayerTransportReport>,
1460    /// O(2) classification (winding/phase/defect) of each adjacent map whose
1461    /// endpoints are both circle charts — the Fourier-rigidity report
1462    /// ([`crate::inference::transport_class::classify_circle_transport_fit`]).
1463    /// Non-circle pairs are omitted; empty when no adjacent pair is circle→circle.
1464    pub circle_transports: Vec<crate::inference::transport_class::CircleTransportReport>,
1465}
1466
1467/// Fit the whole transport ladder: adjacent maps, two-hop maps, and the
1468/// composition law `h_{l→l+2} ≟ h_{l+1→l+2} ∘ h_{l→l+1}` per triple.
1469///
1470/// `layers[k]`, `coords[k]`, `topologies[k]` describe layer `k` of the
1471/// ladder; all coordinate vectors must index the same rows.
1472pub fn transport_ladder(
1473    layers: &[usize],
1474    coords: &[Array1<f64>],
1475    topologies: &[ChartTopology],
1476) -> Result<TransportLadderReport, String> {
1477    let depth = layers.len();
1478    if coords.len() != depth || topologies.len() != depth {
1479        return Err(format!(
1480            "transport ladder inputs disagree: {depth} layers, {} coordinate vectors, {} topologies",
1481            coords.len(),
1482            topologies.len()
1483        ));
1484    }
1485    if depth < 2 {
1486        return Err("transport ladder needs at least two layers".to_string());
1487    }
1488
1489    let mut adjacent_fits: Vec<FittedTransport> = Vec::with_capacity(depth - 1);
1490    let mut adjacent: Vec<LayerTransportReport> = Vec::with_capacity(depth - 1);
1491    for k in 0..depth - 1 {
1492        let fit = fit_transport_map(
1493            coords[k].view(),
1494            coords[k + 1].view(),
1495            topologies[k],
1496            topologies[k + 1],
1497        )
1498        .map_err(|e| {
1499            format!(
1500                "adjacent transport {}→{} failed: {e}",
1501                layers[k],
1502                layers[k + 1]
1503            )
1504        })?;
1505        adjacent.push(fit.report(layers[k], layers[k + 1]));
1506        adjacent_fits.push(fit);
1507    }
1508
1509    let mut two_hop: Vec<LayerTransportReport> = Vec::with_capacity(depth.saturating_sub(2));
1510    for k in 0..depth.saturating_sub(2) {
1511        let direct = fit_transport_map(
1512            coords[k].view(),
1513            coords[k + 2].view(),
1514            topologies[k],
1515            topologies[k + 2],
1516        )
1517        .map_err(|e| {
1518            format!(
1519                "two-hop transport {}→{} failed: {e}",
1520                layers[k],
1521                layers[k + 2]
1522            )
1523        })?;
1524        let composition = composition_defect(
1525            &adjacent_fits[k],
1526            &adjacent_fits[k + 1],
1527            &direct,
1528            DEFAULT_COMPOSITION_GRID,
1529        )
1530        .map_err(|e| {
1531            format!(
1532                "composition test {}→{}→{} failed: {e}",
1533                layers[k],
1534                layers[k + 1],
1535                layers[k + 2]
1536            )
1537        })?;
1538        two_hop.push(
1539            direct
1540                .report(layers[k], layers[k + 2])
1541                .with_composition(&composition),
1542        );
1543    }
1544
1545    // O(2) Fourier-rigidity classification of each adjacent circle→circle map,
1546    // grid-sampled from the fitted angle map. Additive report; no fitting-path
1547    // effect.
1548    let mut circle_transports = Vec::new();
1549    for k in 0..depth - 1 {
1550        if let Some(report) = crate::inference::transport_class::classify_circle_transport_fit(
1551            &adjacent_fits[k],
1552            topologies[k],
1553            topologies[k + 1],
1554            layers[k],
1555            layers[k + 1],
1556            DEFAULT_COMPOSITION_GRID,
1557        ) {
1558            circle_transports.push(report);
1559        }
1560    }
1561
1562    Ok(TransportLadderReport {
1563        adjacent,
1564        two_hop,
1565        circle_transports,
1566    })
1567}
1568
1569#[cfg(test)]
1570mod invert_tests {
1571    use super::*;
1572    use faer::Side;
1573    use gam_linalg::faer_ndarray::FaerEigh;
1574    use ndarray::Array1;
1575
1576    fn interval(lo: f64, hi: f64) -> ChartTopology {
1577        ChartTopology::Interval { lo, hi }
1578    }
1579
1580    /// The transport wrapper must reuse Gaussian REML's profiled scale and its
1581    /// exact spectral inverse. In particular, covariance cannot come from the
1582    /// removed eigenvalue floor/micro-ridge solve or from `RSS / (n - edf)`.
1583    #[test]
1584    fn penalized_1d_covariance_and_scale_match_reml_system() {
1585        let n = 32;
1586        let design = Array2::from_shape_fn((n, 3), |(row, col)| {
1587            let x = row as f64 / (n - 1) as f64;
1588            match col {
1589                0 => 1.0,
1590                1 => x,
1591                2 => x * x,
1592                _ => unreachable!(),
1593            }
1594        });
1595        let response = Array1::from_shape_fn(n, |row| {
1596            let x = row as f64 / (n - 1) as f64;
1597            0.3 + 0.8 * x + 0.2 * (TAU * x).sin()
1598        });
1599        let mut penalty = Array2::<f64>::zeros((3, 3));
1600        penalty[[2, 2]] = 1.0;
1601        let fit = fit_penalized_1d(&design, &penalty, response.view(), None, 1)
1602            .expect("certified REML fit");
1603
1604        let mut penalized_gram = design.t().dot(&design);
1605        penalized_gram[[2, 2]] += fit.lambda;
1606        let inverse = fit.covariance.mapv(|value| value / fit.sigma2);
1607        let identity = penalized_gram.dot(&inverse);
1608        for row in 0..3 {
1609            for col in 0..3 {
1610                let expected = f64::from(u8::from(row == col));
1611                assert!(
1612                    (identity[[row, col]] - expected).abs() < 1.0e-9,
1613                    "penalized inverse mismatch at ({row}, {col}): {}",
1614                    identity[[row, col]],
1615                );
1616            }
1617        }
1618
1619        let xtwy = design.t().dot(&response);
1620        let prss = response.dot(&response) - fit.beta.dot(&xtwy);
1621        let expected_sigma2 = prss / (n - 2) as f64;
1622        assert!(
1623            (fit.sigma2 - expected_sigma2).abs() <= 1.0e-11 * expected_sigma2.abs().max(1.0),
1624            "REML scale mismatch: fitted {}, expected {expected_sigma2}",
1625            fit.sigma2,
1626        );
1627    }
1628
1629    #[test]
1630    fn invert_round_trips_interval_transport() {
1631        // A strictly increasing nonlinear warp on [0,1] → [0,1] with derivative
1632        // bounded away from zero: to = (t + 0.25·sin(2πt)/(2π)) normalized, whose
1633        // h′ = 1 + 0.25·cos(2πt) ∈ [0.75, 1.25] never approaches zero.
1634        let n = 64;
1635        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1636        let to: Array1<f64> = from.mapv(|t| t + 0.25 * (TAU * t).sin() / TAU);
1637        let ft = fit_transport_map(
1638            from.view(),
1639            to.view(),
1640            interval(0.0, 1.0),
1641            interval(0.0, 1.0),
1642        )
1643        .expect("fit");
1644        assert!(
1645            ft.topology_preserved,
1646            "monotone warp should preserve topology"
1647        );
1648
1649        let probe = Array1::from_iter((1..10).map(|i| i as f64 / 10.0));
1650        // eval ∘ invert and invert ∘ eval both return identity.
1651        let fwd = ft.eval(probe.view()).expect("eval");
1652        let back = ft.invert(fwd.view()).expect("invert");
1653        for i in 0..probe.len() {
1654            assert!(
1655                (back[i] - probe[i]).abs() < 1e-6,
1656                "round-trip failed: t={} back={}",
1657                probe[i],
1658                back[i]
1659            );
1660        }
1661        let re_eval = ft.eval(back.view()).expect("eval");
1662        for i in 0..fwd.len() {
1663            assert!((re_eval[i] - fwd[i]).abs() < 1e-9);
1664        }
1665    }
1666
1667    #[test]
1668    fn invert_round_trips_decreasing_interval_transport() {
1669        // Orientation-reversing homeomorphism with derivative bounded away from
1670        // zero: to = 1 - 0.5·from - 0.5·from² on [0,1] (h′ = -0.5 - from ≤ -0.5).
1671        let n = 64;
1672        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1673        let to: Array1<f64> = from.mapv(|t| 1.0 - 0.5 * t - 0.5 * t * t);
1674        let ft = fit_transport_map(
1675            from.view(),
1676            to.view(),
1677            interval(0.0, 1.0),
1678            interval(0.0, 1.0),
1679        )
1680        .expect("fit");
1681        assert!(ft.topology_preserved);
1682        let probe = Array1::from_iter((1..10).map(|i| i as f64 / 10.0));
1683        let fwd = ft.eval(probe.view()).expect("eval");
1684        let back = ft.invert(fwd.view()).expect("invert");
1685        for i in 0..probe.len() {
1686            assert!(
1687                (back[i] - probe[i]).abs() < 1e-6,
1688                "t={} back={}",
1689                probe[i],
1690                back[i]
1691            );
1692        }
1693    }
1694
1695    #[test]
1696    fn invert_round_trips_circle_transport() {
1697        // Degree-1 circle cover: a rotation plus a fold-free wiggle.
1698        let n = 128;
1699        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1700        let to: Array1<f64> = from.mapv(|t| wrap_tau(t + 0.3 + 0.2 * t.sin()));
1701        let ft = fit_transport_map(
1702            from.view(),
1703            to.view(),
1704            ChartTopology::Circle,
1705            ChartTopology::Circle,
1706        )
1707        .expect("fit");
1708        assert!(ft.topology_preserved, "degree {:?}", ft.degree);
1709
1710        let probe = Array1::from_iter((0..7).map(|i| TAU * (i as f64 + 0.5) / 7.0));
1711        let fwd = ft.eval(probe.view()).expect("eval");
1712        let back = ft.invert(fwd.view()).expect("invert");
1713        for i in 0..probe.len() {
1714            // Compare modulo 2π.
1715            let d = wrap_pi(back[i] - probe[i]).abs();
1716            assert!(d < 1e-5, "probe={} back={} d={}", probe[i], back[i], d);
1717        }
1718    }
1719
1720    #[test]
1721    fn invert_rejects_target_outside_interval_image() {
1722        // Image of `to = 0.5·from` is ~[0, 0.5]; y = 0.9 is outside it.
1723        let n = 32;
1724        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1725        let to: Array1<f64> = from.mapv(|t| 0.5 * t);
1726        let ft = fit_transport_map(
1727            from.view(),
1728            to.view(),
1729            interval(0.0, 1.0),
1730            interval(0.0, 1.0),
1731        )
1732        .expect("fit");
1733        assert!(ft.invert(Array1::from_elem(1, 0.9).view()).is_err());
1734    }
1735
1736    /// Build a `FittedTransport` on an interval whose pre-wrap map interpolates
1737    /// `h` by an unpenalized least-squares spline fit (so a deliberately narrow
1738    /// fold in `h` survives into the coefficients, unlike a REML fit which would
1739    /// smooth it away). Fields irrelevant to `eval`/`derivative`/`invert` are
1740    /// filled with sound placeholders.
1741    fn fitted_from_target(
1742        from: ArrayView1<'_, f64>,
1743        target: ArrayView1<'_, f64>,
1744        lo: f64,
1745        hi: f64,
1746    ) -> FittedTransport {
1747        let basis = DomainBasis::build(interval(lo, hi), from).expect("basis");
1748        let design = basis.value_rows(from).expect("design");
1749        let m = design.ncols();
1750        // Normal equations XᵀX β = Xᵀy with a tiny ridge for conditioning only.
1751        let mut xtx = design.t().dot(&design);
1752        let xty = design.t().dot(&target);
1753        let diag = (0..m).map(|i| xtx[[i, i]].abs()).fold(1.0_f64, f64::max);
1754        for i in 0..m {
1755            xtx[[i, i]] += 1e-10 * diag;
1756        }
1757        let (evals, evecs) = xtx.eigh(Side::Lower).expect("eigh");
1758        let rotated = evecs.t().dot(&xty);
1759        let mut beta = Array1::<f64>::zeros(m);
1760        for i in 0..m {
1761            let d = evals[i].max(f64::MIN_POSITIVE);
1762            let c = rotated[i] / d;
1763            for j in 0..m {
1764                beta[j] += evecs[[j, i]] * c;
1765            }
1766        }
1767        FittedTransport {
1768            topology_from: interval(lo, hi),
1769            topology_to: interval(lo, hi),
1770            degree: None,
1771            degree_concentration: None,
1772            rotation_offset: 0.0,
1773            beta,
1774            covariance: Array2::<f64>::zeros((m, m)),
1775            smoothing_lambda: 0.0,
1776            edf: 0.0,
1777            noise_variance: 1.0,
1778            n_obs: from.len(),
1779            isometry_defect: 0.0,
1780            isometry_defect_se: 0.0,
1781            topology_preserved: true,
1782            min_directional_derivative: 1.0,
1783            residual_rms: 0.0,
1784            coefficient_score_influence: Array2::<f64>::zeros((m, from.len())),
1785            basis,
1786        }
1787    }
1788
1789    /// Reviewer's between-grid fold reproducer: h(t) = (t−0.5)³/3 − (0.4/511)²·t
1790    /// hides a narrow fold between the 512-point certification-grid samples.
1791    /// `topology_preserved` (the sampled diagnostic) reads true, yet a dense
1792    /// grid finds orientation·h′ < 0 — the span-exact certificate that `invert`
1793    /// now gates on must reject the fit.
1794    #[test]
1795    fn invert_rejects_between_grid_fold() {
1796        let n = 256;
1797        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1798        let eps = 0.4 / 511.0;
1799        let target: Array1<f64> = from.mapv(|t| (t - 0.5).powi(3) / 3.0 - eps * eps * t);
1800        let mut ft = fitted_from_target(from.view(), target.view(), 0.0, 1.0);
1801
1802        // Confirm the fold is genuinely between the 512-pt certification grid:
1803        // recompute the sampled diagnostic the production fit uses.
1804        let grid = domain_grid(interval(0.0, 1.0), FOLD_CHECK_GRID);
1805        let grid_d = ft.derivative(grid.view()).expect("grid deriv");
1806        let mean = grid_d.iter().sum::<f64>() / grid_d.len() as f64;
1807        let orientation = if mean < 0.0 { -1.0 } else { 1.0 };
1808        let min_grid = grid_d
1809            .iter()
1810            .map(|&v| orientation * v)
1811            .fold(f64::INFINITY, f64::min);
1812        // Dense grid (10× finer) to expose the hidden fold.
1813        let dense = Array1::from_iter((0..5120).map(|i| i as f64 / 5119.0));
1814        let dense_d = ft.derivative(dense.view()).expect("dense deriv");
1815        let min_dense = dense_d
1816            .iter()
1817            .map(|&v| orientation * v)
1818            .fold(f64::INFINITY, f64::min);
1819        ft.topology_preserved = min_grid > 0.0;
1820        ft.min_directional_derivative = min_grid;
1821        assert!(
1822            min_grid > 0.0 && min_dense < 0.0,
1823            "fixture must hide a between-grid fold: min on 512-grid={min_grid}, \
1824             min on dense grid={min_dense}"
1825        );
1826
1827        // The span-exact certificate must reject it even though the sampled
1828        // diagnostic passed.
1829        let res = ft.invert(Array1::from_elem(1, 0.0).view());
1830        assert!(
1831            res.is_err(),
1832            "between-grid fold must be rejected by the span-exact certificate \
1833             (topology_preserved={}, min_grid={min_grid}, min_dense={min_dense})",
1834            ft.topology_preserved
1835        );
1836    }
1837
1838    #[test]
1839    fn invert_rejects_non_finite_targets() {
1840        let n = 64;
1841        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1842        let to: Array1<f64> = from.mapv(|t| 0.5 * t);
1843        let ft = fit_transport_map(
1844            from.view(),
1845            to.view(),
1846            interval(0.0, 1.0),
1847            interval(0.0, 1.0),
1848        )
1849        .expect("fit");
1850        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1851            assert!(
1852                ft.invert(Array1::from_elem(1, bad).view()).is_err(),
1853                "non-finite target {bad} must be rejected"
1854            );
1855        }
1856    }
1857
1858    #[test]
1859    fn invert_image_tolerance_is_scale_aware() {
1860        // Image of `to = 1e-8·from` is ~[0, 1e-8]. A target 5% outside it must
1861        // be rejected, not silently clamped, under the scale-aware tolerance
1862        // (the old absolute 1e-9 would have accepted it).
1863        let n = 64;
1864        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1865        let scale = 1.0e-8;
1866        let to: Array1<f64> = from.mapv(|t| scale * t);
1867        let ft = fit_transport_map(
1868            from.view(),
1869            to.view(),
1870            interval(0.0, 1.0),
1871            interval(0.0, 1.0),
1872        )
1873        .expect("fit");
1874        let outside = 1.05e-8;
1875        assert!(
1876            ft.invert(Array1::from_elem(1, outside).view()).is_err(),
1877            "target {outside} is 5% outside the [0, {scale}] image and must be rejected"
1878        );
1879        // A target inside the image still round-trips.
1880        let inside = 0.5e-8;
1881        let t = ft
1882            .invert(Array1::from_elem(1, inside).view())
1883            .expect("invert inside");
1884        let re = ft.eval(t.view()).expect("eval");
1885        assert!((re[0] - inside).abs() < 1e-3 * scale);
1886    }
1887
1888    #[test]
1889    fn invert_round_trips_degree_minus_one_circle() {
1890        // Orientation-reversing degree −1 circle cover: a reflection plus a
1891        // fold-free wiggle.
1892        let n = 128;
1893        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1894        let to: Array1<f64> = from.mapv(|t| wrap_tau(-t + 0.4 + 0.15 * t.sin()));
1895        let ft = fit_transport_map(
1896            from.view(),
1897            to.view(),
1898            ChartTopology::Circle,
1899            ChartTopology::Circle,
1900        )
1901        .expect("fit");
1902        assert_eq!(ft.degree, Some(-1), "expected a degree −1 cover");
1903        assert!(ft.topology_preserved, "degree {:?}", ft.degree);
1904        let probe = Array1::from_iter((0..7).map(|i| TAU * (i as f64 + 0.5) / 7.0));
1905        let fwd = ft.eval(probe.view()).expect("eval");
1906        let back = ft.invert(fwd.view()).expect("invert");
1907        for i in 0..probe.len() {
1908            let d = wrap_pi(back[i] - probe[i]).abs();
1909            assert!(d < 1e-5, "probe={} back={} d={}", probe[i], back[i], d);
1910        }
1911    }
1912
1913    #[test]
1914    fn invert_round_trips_circle_seam_and_interval_endpoints() {
1915        // Circle seam: invert a target near 0/2π.
1916        let n = 128;
1917        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1918        let to: Array1<f64> = from.mapv(|t| wrap_tau(t + 0.3 + 0.2 * t.sin()));
1919        let ft = fit_transport_map(
1920            from.view(),
1921            to.view(),
1922            ChartTopology::Circle,
1923            ChartTopology::Circle,
1924        )
1925        .expect("fit");
1926        assert!(ft.topology_preserved);
1927        for seam in [1e-9, TAU - 1e-9, 0.0] {
1928            let t = ft
1929                .invert(Array1::from_elem(1, seam).view())
1930                .expect("invert seam");
1931            let re = ft.eval(t.view()).expect("eval");
1932            let d = wrap_pi(re[0] - wrap_tau(seam)).abs();
1933            assert!(d < 1e-6, "seam={seam} re={} d={d}", re[0]);
1934        }
1935
1936        // Interval endpoints: invert the image endpoints exactly.
1937        let m = 64;
1938        let ifrom: Array1<f64> = Array1::from_iter((0..m).map(|i| i as f64 / (m as f64 - 1.0)));
1939        let ito: Array1<f64> = ifrom.mapv(|t| t + 0.25 * (TAU * t).sin() / TAU);
1940        let ift = fit_transport_map(
1941            ifrom.view(),
1942            ito.view(),
1943            interval(0.0, 1.0),
1944            interval(0.0, 1.0),
1945        )
1946        .expect("fit");
1947        let raw_lo = ift.raw_at(0.0).expect("raw lo");
1948        let raw_hi = ift.raw_at(1.0).expect("raw hi");
1949        for &edge in &[raw_lo, raw_hi] {
1950            let t = ift
1951                .invert(Array1::from_elem(1, edge).view())
1952                .expect("invert endpoint");
1953            assert!(t[0] >= -1e-9 && t[0] <= 1.0 + 1e-9, "endpoint t={}", t[0]);
1954            let re = ift.eval(t.view()).expect("eval");
1955            assert!((re[0] - edge).abs() < 1e-6, "edge={edge} re={}", re[0]);
1956        }
1957    }
1958
1959    #[test]
1960    fn monomial_reconstruction_is_exact_for_quadratic() {
1961        // The certificate's polynomial reconstruction must be exact on the
1962        // quadratic pieces of a cubic-spline derivative.
1963        let coeffs_true = [0.7_f64, -1.3, 2.1]; // 0.7 − 1.3u + 2.1u²
1964        let values: Vec<f64> = (0..3)
1965            .map(|i| eval_monomial(&coeffs_true, i as f64))
1966            .collect();
1967        let recon = monomial_from_equispaced(&values);
1968        for (a, b) in recon.iter().zip(coeffs_true.iter()) {
1969            assert!((a - b).abs() < 1e-12, "recon {a} vs {b}");
1970        }
1971        // Vertex of 2.1u² − 1.3u + 0.7 is at u = 1.3 / (2·2.1).
1972        let crit = monomial_critical_points(&recon);
1973        assert_eq!(crit.len(), 1);
1974        assert!((crit[0] - 1.3 / 4.2).abs() < 1e-12);
1975    }
1976}