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