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    let reml = gaussian_reml_closed_form_with_nullspace_dim(
462        design.view(),
463        response.view(),
464        penalty.view(),
465        Some(nullspace_dim),
466        weight_view(),
467        None,
468    )
469    .map_err(|error| format!("penalized 1-D Gaussian REML failed: {error}"))?;
470    if reml.rho.to_bits() != stationary.selected_rho.to_bits() {
471        return Err(format!(
472            "penalized 1-D REML selection drifted between its certificate ({}) and fit ({})",
473            stationary.selected_rho, reml.rho,
474        ));
475    }
476
477    // If C = L⁻ᵀU is the cache's coefficient basis and δᵢ are the
478    // eigenvalues of L⁻¹SL⁻ᵀ, then the exact penalized inverse is
479    //     (XᵀWX + λS)⁻¹ = C diag((1 + λδᵢ)⁻¹) Cᵀ.
480    // Reconstruct that same inverse directly: no eigenvalue flooring and no
481    // representative-selecting ridge that would change the REML objective.
482    let lambda = reml.lambda;
483    let coefficient_basis = &reml.cache.coefficient_basis;
484    let penalty_eigenvalues = &reml.cache.penalty_eigenvalues;
485    if coefficient_basis.dim() != (m, m) || penalty_eigenvalues.len() != m {
486        return Err(format!(
487            "penalized 1-D REML cache shape drift: basis is {}x{}, spectrum has {}, expected {m}",
488            coefficient_basis.nrows(),
489            coefficient_basis.ncols(),
490            penalty_eigenvalues.len(),
491        ));
492    }
493    let mut a_inv = Array2::<f64>::zeros((m, m));
494    for i in 0..m {
495        let delta = penalty_eigenvalues[i];
496        let denominator = 1.0 + lambda * delta;
497        if !(delta.is_finite() && delta >= 0.0 && denominator.is_finite() && denominator > 0.0) {
498            return Err(format!(
499                "penalized 1-D REML cache has invalid mode {i}: delta={delta}, denominator={denominator}"
500            ));
501        }
502        let inverse_denominator = denominator.recip();
503        for j in 0..m {
504            let scaled_basis = coefficient_basis[[j, i]] * inverse_denominator;
505            for k in 0..m {
506                a_inv[[j, k]] += scaled_basis * coefficient_basis[[k, i]];
507            }
508        }
509    }
510
511    let beta = reml.coefficients;
512    let fitted = reml.fitted;
513    let edf = reml.edf;
514    let sigma2 = reml.sigma2;
515    let mut rss = 0.0_f64;
516    for r in 0..n {
517        let w = weights.as_ref().map_or(1.0, |wv| wv[r]);
518        let e = response[r] - fitted[r];
519        rss += w * e * e;
520    }
521    let covariance = a_inv.mapv(|v| v * sigma2);
522    let sum_w = weights
523        .as_ref()
524        .map_or(n as f64, |wv| wv.iter().copied().sum());
525    let residual_rms = (rss / sum_w.max(f64::MIN_POSITIVE)).sqrt();
526    let mut coefficient_score_influence = Array2::<f64>::zeros((m, n));
527    for row in 0..n {
528        let w = weights.as_ref().map_or(1.0, |wv| wv[row]);
529        let residual = response[row] - fitted[row];
530        for j in 0..m {
531            let mut sensitivity = 0.0_f64;
532            for k in 0..m {
533                sensitivity += a_inv[[j, k]] * design[[row, k]];
534            }
535            coefficient_score_influence[[j, row]] = sensitivity * w * residual;
536        }
537    }
538
539    if beta.iter().chain(a_inv.iter()).any(|v| !v.is_finite())
540        || !(sigma2.is_finite() && sigma2 > 0.0)
541    {
542        return Err("penalized 1-D REML produced non-finite posterior moments".to_string());
543    }
544    Ok(Penalized1dFit {
545        beta,
546        covariance,
547        lambda,
548        edf,
549        sigma2,
550        residual_rms,
551        coefficient_score_influence,
552    })
553}
554
555/// A fitted inter-layer transport map with full posterior bookkeeping, ready
556/// for evaluation, banding, and composition testing.
557///
558/// Representation: `h(t) = degree·t + rotation_offset + g(t)` on circle
559/// targets (`g` the REML periodic/open spline; the result is read mod 2π) and
560/// `h(t) = g(t)` on interval targets. The discrete winding `degree` and the
561/// wrap-branch offset are treated as fixed (a discrete selection and a gauge
562/// representative respectively); pointwise variances propagate the spline
563/// coefficient covariance only.
564#[derive(Debug, Clone)]
565pub struct FittedTransport {
566    pub topology_from: ChartTopology,
567    pub topology_to: ChartTopology,
568    /// Winding degree of the map (circle→circle charts only).
569    pub degree: Option<i32>,
570    /// Mean resultant length of the de-wound residual at the selected degree
571    /// (circle→circle only): the concentration evidence behind `degree`.
572    pub degree_concentration: Option<f64>,
573    /// Rotation gauge representative used to pick the wrap branch of the
574    /// angular response (circle targets; `0` for interval targets). The
575    /// estimand is the double coset, so this constant carries no information
576    /// on its own.
577    pub rotation_offset: f64,
578    /// Spline coefficients of the residual smooth `g`.
579    pub beta: Array1<f64>,
580    /// Scale-included posterior covariance of `beta` (mgcv `Vb` analogue).
581    pub covariance: Array2<f64>,
582    pub smoothing_lambda: f64,
583    /// Effective degrees of freedom of the transport smooth.
584    pub edf: f64,
585    /// REML-profiled residual variance σ̂² of the (unwrapped) response.
586    pub noise_variance: f64,
587    pub n_obs: usize,
588    /// Empirical-density-weighted isometry defect `mean((|h′(tᵢ)| − 1)²)`.
589    pub isometry_defect: f64,
590    /// Delta-method standard error of the isometry defect.
591    pub isometry_defect_se: f64,
592    /// Whether `h` is compatible with both chart topologies: a degree-±1
593    /// circle cover without folds, or a fold-free interval homeomorphism.
594    pub topology_preserved: bool,
595    /// `min over a dense grid of orientation·h′(t)`; positive ⇔ no folds.
596    pub min_directional_derivative: f64,
597    /// RMS of the response residuals at the fitted map.
598    pub residual_rms: f64,
599    basis: DomainBasis,
600    coefficient_score_influence: Array2<f64>,
601}
602
603impl FittedTransport {
604    fn linear_slope(&self) -> f64 {
605        self.degree.map_or(0.0, f64::from)
606    }
607
608    /// Evaluate `h` at `t` (wrapped to `[0, 2π)` on circle targets).
609    pub fn eval(&self, t: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
610        let rows = self.basis.value_rows(t)?;
611        let smooth = rows.dot(&self.beta);
612        let slope = self.linear_slope();
613        let mut out = Array1::<f64>::zeros(t.len());
614        for i in 0..t.len() {
615            let raw = slope * t[i] + self.rotation_offset + smooth[i];
616            out[i] = match self.topology_to {
617                ChartTopology::Circle => wrap_tau(raw),
618                ChartTopology::Interval { .. } => raw,
619            };
620        }
621        Ok(out)
622    }
623
624    /// Evaluate `h` and its pointwise delta-method variance.
625    pub fn eval_with_variance(
626        &self,
627        t: ArrayView1<'_, f64>,
628    ) -> Result<(Array1<f64>, Array1<f64>), String> {
629        let rows = self.basis.value_rows(t)?;
630        let values = self.eval(t)?;
631        let mut variances = Array1::<f64>::zeros(t.len());
632        for i in 0..t.len() {
633            let row = rows.row(i);
634            variances[i] = row.dot(&self.covariance.dot(&row)).max(0.0);
635        }
636        Ok((values, variances))
637    }
638
639    /// Evaluate `h′(t)` (chart-coordinate derivative).
640    pub fn derivative(&self, t: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
641        let rows = self.basis.derivative_rows(t)?;
642        let slope = self.linear_slope();
643        Ok(rows.dot(&self.beta).mapv(|v| v + slope))
644    }
645
646    /// Point-evaluation influence by original observation row. The returned
647    /// matrix has shape `(t.len(), n_obs)` and retains cross-map row identity.
648    fn eval_score_influence(&self, t: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
649        Ok(self
650            .basis
651            .value_rows(t)?
652            .dot(&self.coefficient_score_influence))
653    }
654
655    /// Pre-wrap map value `slope·t + offset + g(t)` at a single point — the
656    /// strictly monotone (when fold-free) handle that [`Self::eval`] wraps for
657    /// circle targets and [`Self::invert`] bisects on.
658    fn raw_at(&self, t: f64) -> Result<f64, String> {
659        let arr = Array1::from_elem(1, t);
660        let smooth = self.basis.value_rows(arr.view())?.dot(&self.beta)[0];
661        Ok(self.linear_slope() * t + self.rotation_offset + smooth)
662    }
663
664    /// `orientation·h′` at the supplied source-chart coordinates.
665    fn oriented_derivative_at(&self, t: &[f64], orientation: f64) -> Result<Vec<f64>, String> {
666        let arr = Array1::from_vec(t.to_vec());
667        let rows = self.basis.derivative_rows(arr.view())?;
668        let slope = self.linear_slope();
669        Ok((0..t.len())
670            .map(|i| orientation * (rows.row(i).dot(&self.beta) + slope))
671            .collect())
672    }
673
674    /// Exactly certify that `h` is strictly monotone over the whole source
675    /// domain, returning the certified orientation (+1 increasing, −1
676    /// decreasing) or an `Err` describing where monotonicity fails.
677    ///
678    /// Unlike [`Self::topology_preserved`], which only samples `h′` on a fixed
679    /// 512-point grid and so can miss a fold *between* grid samples, this is a
680    /// span-exact certificate. On each knot span `h′` is a single polynomial of
681    /// degree `d = `[`DomainBasis::derivative_poly_degree`]` (cubic spline ⇒
682    /// quadratic). A degree-`d` polynomial is determined by `d + 1` samples, so
683    /// per span we evaluate `h′` at `d + 1` equally-spaced abscissae, reconstruct
684    /// the known-degree polynomial in the Lagrange basis, locate its interior critical
685    /// points in closed form, and require `orientation·h′ > 0` at the span
686    /// endpoints **and** every interior critical point. To stay sound even if a
687    /// basis is not an exact polynomial of the assumed degree on a span (e.g. a
688    /// row-normalized periodic basis whose row-sum is not a partition of unity),
689    /// the reconstruction is verified against an independent interior sample;
690    /// any mismatch falls back to refusing the span.
691    fn certify_strict_monotonicity(&self) -> Result<f64, String> {
692        let (lo, hi) = match self.topology_from {
693            ChartTopology::Circle => (0.0, TAU),
694            ChartTopology::Interval { lo, hi } => (lo, hi),
695        };
696        // Orientation from the endpoint span of the pre-wrap map, matching the
697        // sign convention `invert` bisects with.
698        let raw_lo = self.raw_at(lo)?;
699        let raw_hi = self.raw_at(hi)?;
700        let orientation = if raw_hi >= raw_lo { 1.0 } else { -1.0 };
701
702        let deg = self.basis.derivative_poly_degree().max(1);
703        let breaks = self.basis.derivative_breakpoints();
704        // Restrict the breakpoints to the active domain (the periodic segment
705        // grid already coincides with `[lo, hi]`).
706        for window in breaks.windows(2) {
707            let (a, b) = (window[0], window[1]);
708            if !(b > a) {
709                continue;
710            }
711            let span = b - a;
712            // Reconstruction abscissae: `deg + 1` equally spaced nodes on the
713            // closed span (sampling strictly inside avoids the knot where two
714            // pieces meet and the open-basis derivative can be one-sided).
715            let pad = span * 1.0e-9;
716            let n_nodes = deg + 1;
717            let nodes: Vec<f64> = (0..n_nodes)
718                .map(|i| {
719                    let s = if n_nodes == 1 {
720                        0.5
721                    } else {
722                        i as f64 / (n_nodes - 1) as f64
723                    };
724                    (a + pad) + (span - 2.0 * pad) * s
725                })
726                .collect();
727            let values = self.oriented_derivative_at(&nodes, orientation)?;
728
729            // Polynomial in the local coordinate u = (t - nodes[0]) / step.
730            // Expand the exact Lagrange interpolant into monomial coefficients
731            // for the closed-form critical-point search. This is algebraic
732            // reconstruction of a known-degree spline piece, not a numerical
733            // derivative approximation.
734            let step = if n_nodes > 1 {
735                nodes[1] - nodes[0]
736            } else {
737                span
738            };
739            let coeffs = monomial_interpolant_at_integer_nodes(&values);
740
741            // Sound guard: verify the reconstruction reproduces an independent
742            // interior sample (deliberately off the reconstruction nodes — the
743            // equispaced nodes never land on a 0.37 fraction). If the basis is
744            // not exactly polynomial of the assumed degree on this span, refuse
745            // rather than trust the fit.
746            let probe_t = a + 0.37 * span;
747            let probe_u = (probe_t - nodes[0]) / step;
748            let probe_recon = eval_monomial(&coeffs, probe_u);
749            let probe_actual = self.oriented_derivative_at(&[probe_t], orientation)?[0];
750            let scale = probe_actual.abs().max(1.0);
751            if (probe_recon - probe_actual).abs() > 1.0e-6 * scale {
752                return Err(format!(
753                    "transport monotonicity certificate could not reconstruct h′ on the \
754                     span [{a}, {b}] (reconstruction {probe_recon} vs actual {probe_actual}); \
755                     refusing to certify"
756                ));
757            }
758
759            // Require positivity at the closed-span endpoints.
760            for &edge in &[a, b] {
761                let u = (edge - nodes[0]) / step;
762                let v = eval_monomial(&coeffs, u);
763                if !(v > 0.0) {
764                    return Err(format!(
765                        "transport map is not strictly monotone: orientation·h′ = {v} ≤ 0 at \
766                         t = {edge}"
767                    ));
768                }
769            }
770            // Require positivity at every interior critical point of the
771            // polynomial within the span.
772            for u_crit in monomial_critical_points(&coeffs) {
773                let t_crit = nodes[0] + u_crit * step;
774                if t_crit > a && t_crit < b {
775                    let v = eval_monomial(&coeffs, u_crit);
776                    if !(v > 0.0) {
777                        return Err(format!(
778                            "transport map folds: orientation·h′ = {v} ≤ 0 at interior \
779                             extremum t = {t_crit}"
780                        ));
781                    }
782                }
783            }
784        }
785        Ok(orientation)
786    }
787
788    /// Invert the transport: for each target-chart coordinate `y`, return the
789    /// source-chart coordinate `t` with `eval([t]) == y`.
790    ///
791    /// Requires a strictly monotone, fold-free map (a degree-±1 cover for
792    /// circle charts, a homeomorphism for intervals), so the inverse is
793    /// single-valued; otherwise this errors rather than picking an arbitrary
794    /// branch. Monotonicity is established with [`Self::certify_strict_monotonicity`]
795    /// — a span-exact polynomial certificate, **not** the sampled
796    /// `topology_preserved` diagnostic, which can miss a narrow fold between its
797    /// grid samples. Non-finite targets are rejected. Interval targets reject a
798    /// `y` outside the fitted image (scale-aware tolerance); circle targets
799    /// accept any `y` (the pre-wrap map covers a full `2π`). The root is found
800    /// by monotone bisection on the pre-wrap map `raw_at`, which converges to
801    /// f64 precision (~53 significand bits) in the source coordinate after on
802    /// the order of 50 iterations.
803    ///
804    /// This is the exact inverse of [`Self::eval`] and the missing half of the
805    /// transport algebra alongside [`composition_defect`]: it is what lets a
806    /// caller form `g_B ∘ g_A⁻¹` from two fitted transports.
807    pub fn invert(&self, y: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
808        if y.iter().any(|v| !v.is_finite()) {
809            return Err("transport inverse targets must be finite".to_string());
810        }
811        // Span-exact strict-monotonicity certificate; supersedes the sampled
812        // `topology_preserved` flag, which can pass over a between-sample fold.
813        self.certify_strict_monotonicity()?;
814        let (lo, hi) = match self.topology_from {
815            ChartTopology::Circle => (0.0, TAU),
816            ChartTopology::Interval { lo, hi } => (lo, hi),
817        };
818        // The pre-wrap map is strictly monotone over [lo, hi]; the endpoints
819        // anchor its orientation and image span.
820        let raw_lo = self.raw_at(lo)?;
821        let raw_hi = self.raw_at(hi)?;
822        let increasing = raw_hi > raw_lo;
823        let (raw_min, raw_max) = if increasing {
824            (raw_lo, raw_hi)
825        } else {
826            (raw_hi, raw_lo)
827        };
828        // Scale-aware image tolerance: an absolute 1e-9 would wrongly accept a
829        // target well outside a tiny image (e.g. [0, 1e-8]).
830        let scale = raw_min.abs().max(raw_max.abs()).max(1.0);
831        let tol = 32.0 * f64::EPSILON * scale;
832
833        // One reusable single-element buffer for the bisection probes (rebuilt
834        // basis rows on every probe otherwise allocated a fresh `Array1`).
835        let mut probe = Array1::<f64>::zeros(1);
836        let mut raw_at_into = |t: f64| -> Result<f64, String> {
837            probe[0] = t;
838            let smooth = self.basis.value_rows(probe.view())?.dot(&self.beta)[0];
839            Ok(self.linear_slope() * t + self.rotation_offset + smooth)
840        };
841
842        let mut out = Array1::<f64>::zeros(y.len());
843        for (idx, &yi) in y.iter().enumerate() {
844            // Target value in the pre-wrap coordinate.
845            let target = match self.topology_to {
846                ChartTopology::Interval { .. } => {
847                    if yi < raw_min - tol || yi > raw_max + tol {
848                        return Err(format!(
849                            "transport inverse target {yi} is outside the fitted image \
850                             [{raw_min}, {raw_max}]"
851                        ));
852                    }
853                    yi.clamp(raw_min, raw_max)
854                }
855                ChartTopology::Circle => {
856                    // The pre-wrap map covers exactly 2π; shift wrap_tau(y) by
857                    // the unique integer multiple of 2π that lands in the image.
858                    let ywrapped = wrap_tau(yi);
859                    let m = ((raw_min - ywrapped) / TAU).ceil();
860                    ywrapped + TAU * m
861                }
862            };
863            // Monotone bisection on the pre-wrap map over [lo, hi]; stop once
864            // the bracket is below the source-coordinate precision floor (f64
865            // bisection stagnates well before 100 iterations).
866            let (mut a, mut b) = (lo, hi);
867            let width_floor = f64::EPSILON * hi.abs().max(lo.abs()).max(1.0);
868            for _ in 0..100 {
869                if (b - a) <= width_floor {
870                    break;
871                }
872                let mid = 0.5 * (a + b);
873                let rm = raw_at_into(mid)?;
874                let go_right = if increasing { rm < target } else { rm > target };
875                if go_right {
876                    a = mid;
877                } else {
878                    b = mid;
879                }
880            }
881            out[idx] = 0.5 * (a + b);
882        }
883        Ok(out)
884    }
885
886    /// Package the fit as a [`LayerTransportReport`] for the given layer pair
887    /// (composition fields empty; see [`LayerTransportReport::with_composition`]).
888    pub fn report(&self, layer_from: usize, layer_to: usize) -> LayerTransportReport {
889        LayerTransportReport {
890            layer_from,
891            layer_to,
892            topology_from: self.topology_from,
893            topology_to: self.topology_to,
894            topology_preserved: self.topology_preserved,
895            degree: self.degree,
896            degree_concentration: self.degree_concentration,
897            rotation_offset: self.rotation_offset,
898            isometry_defect: self.isometry_defect,
899            isometry_defect_se: self.isometry_defect_se,
900            min_directional_derivative: self.min_directional_derivative,
901            transport_edf: self.edf,
902            smoothing_lambda: self.smoothing_lambda,
903            noise_variance: self.noise_variance,
904            residual_rms: self.residual_rms,
905            n_obs: self.n_obs,
906            composition_defect: None,
907            composition_max_studentized: None,
908            composition_p_value: None,
909            composition_gauge_reflected: None,
910        }
911    }
912}
913
914/// Evidence payload for one estimated inter-layer transport map.
915#[derive(Debug, Clone)]
916pub struct LayerTransportReport {
917    pub layer_from: usize,
918    pub layer_to: usize,
919    pub topology_from: ChartTopology,
920    pub topology_to: ChartTopology,
921    /// Degree-±1 fold-free circle cover (or fold-free interval homeo).
922    pub topology_preserved: bool,
923    /// Estimated winding degree (circle→circle only).
924    pub degree: Option<i32>,
925    /// Circular concentration of the de-wound residual at `degree`.
926    pub degree_concentration: Option<f64>,
927    /// Rotation gauge representative (circle targets).
928    pub rotation_offset: f64,
929    /// `∫(|h′| − 1)² dP̂` under the empirical chart density.
930    pub isometry_defect: f64,
931    /// Delta-method SE of the isometry defect.
932    pub isometry_defect_se: f64,
933    /// Fold diagnostic: min of orientation·h′ over a dense grid.
934    pub min_directional_derivative: f64,
935    /// EDF of the REML transport smooth.
936    pub transport_edf: f64,
937    pub smoothing_lambda: f64,
938    pub noise_variance: f64,
939    pub residual_rms: f64,
940    pub n_obs: usize,
941    /// RMS composition defect of the triple ending at this two-hop map
942    /// (populated by [`transport_ladder`] / [`LayerTransportReport::with_composition`]).
943    pub composition_defect: Option<f64>,
944    /// Max studentized composition defect against the composed bands.
945    pub composition_max_studentized: Option<f64>,
946    /// Bonferroni familywise p-value from the joint shared-row sandwich.
947    /// `None` when the fitted maps carry no empirical score variation.
948    pub composition_p_value: Option<f64>,
949    /// Always `false`: both routes already land in the same target chart, so no
950    /// post-hoc target alignment is fitted.
951    pub composition_gauge_reflected: Option<bool>,
952}
953
954impl LayerTransportReport {
955    /// Merge a composition-law test into this (direct, two-hop) report.
956    pub fn with_composition(mut self, composition: &CompositionDefectReport) -> Self {
957        self.composition_defect = Some(composition.rms_defect);
958        self.composition_max_studentized = Some(composition.max_studentized_defect);
959        self.composition_p_value = composition
960            .p_value
961            .is_finite()
962            .then_some(composition.p_value);
963        self.composition_gauge_reflected = Some(composition.gauge_reflected);
964        self
965    }
966}
967
968/// Estimate the transport map `h: M_from → M_to` between two chart
969/// coordinatizations of the same rows.
970///
971/// `coords_from[i]` and `coords_to[i]` must coordinatize the same observation
972/// in the source and target charts. Circle coordinates are radians (any
973/// branch; wrapped internally). See the module docs for the estimator.
974pub fn fit_transport_map(
975    coords_from: ArrayView1<'_, f64>,
976    coords_to: ArrayView1<'_, f64>,
977    topology_from: ChartTopology,
978    topology_to: ChartTopology,
979) -> Result<FittedTransport, String> {
980    let n = coords_from.len();
981    if coords_to.len() != n {
982        return Err(format!(
983            "layer transport coordinate lengths disagree: {} vs {}",
984            n,
985            coords_to.len()
986        ));
987    }
988    if n < MIN_TRANSPORT_OBS {
989        return Err(format!(
990            "layer transport needs at least {MIN_TRANSPORT_OBS} paired observations, got {n}"
991        ));
992    }
993    if coords_from
994        .iter()
995        .chain(coords_to.iter())
996        .any(|v| !v.is_finite())
997    {
998        return Err("layer transport coordinates must all be finite".to_string());
999    }
1000    topology_from.validate()?;
1001    topology_to.validate()?;
1002
1003    // --- degree + rotation gauge + unwrapped response -----------------------
1004    let (degree, degree_concentration, rotation_offset, response): (
1005        Option<i32>,
1006        Option<f64>,
1007        f64,
1008        Array1<f64>,
1009    ) = match (topology_from, topology_to) {
1010        (ChartTopology::Circle, ChartTopology::Circle) => {
1011            // Winding degree by circular concentration: over candidate
1012            // degrees d, the de-wound residual r_i(d) = θ_to − d·θ_from is
1013            // tightest (largest mean resultant length R_d) at the true
1014            // degree whenever the smooth residual stays inside half a turn.
1015            // This is the circular-correlation-maximizing degree estimate
1016            // the issue specifies, in resultant form.
1017            let mut best_degree = DEGREE_CANDIDATES[0];
1018            let mut best_r = f64::NEG_INFINITY;
1019            for &d in DEGREE_CANDIDATES.iter() {
1020                let residual: Vec<f64> = (0..n)
1021                    .map(|i| coords_to[i] - f64::from(d) * coords_from[i])
1022                    .collect();
1023                let r = resultant_length(&residual);
1024                if r > best_r {
1025                    best_r = r;
1026                    best_degree = d;
1027                }
1028            }
1029            let residual: Vec<f64> = (0..n)
1030                .map(|i| coords_to[i] - f64::from(best_degree) * coords_from[i])
1031                .collect();
1032            let mu = circular_mean(&residual);
1033            let response = Array1::from_iter(residual.iter().map(|&r| wrap_pi(r - mu)));
1034            (Some(best_degree), Some(best_r), mu, response)
1035        }
1036        (_, ChartTopology::Circle) => {
1037            // Interval domain, circular target: the domain is contractible so
1038            // the map is null-homotopic — no winding term. Unwrap the angular
1039            // response about its circular mean.
1040            let angles: Vec<f64> = coords_to.iter().copied().collect();
1041            let mu = circular_mean(&angles);
1042            let response = Array1::from_iter(angles.iter().map(|&a| wrap_pi(a - mu)));
1043            (None, None, mu, response)
1044        }
1045        (_, ChartTopology::Interval { .. }) => (None, None, 0.0, coords_to.to_owned()),
1046    };
1047
1048    // --- REML residual smooth on the source chart ---------------------------
1049    let basis = DomainBasis::build(topology_from, coords_from)?;
1050    let design = basis.value_rows(coords_from)?;
1051    let penalty = basis.penalty()?;
1052    let fit = fit_penalized_1d(
1053        &design,
1054        &penalty,
1055        response.view(),
1056        None,
1057        basis.penalty_rank(),
1058    )?;
1059
1060    // --- isometry defect under the empirical density -------------------------
1061    let slope = degree.map_or(0.0, f64::from);
1062    let deriv_rows = basis.derivative_rows(coords_from)?;
1063    let deriv = deriv_rows.dot(&fit.beta).mapv(|v| v + slope);
1064    let m = basis.num_basis();
1065    let mut defect = 0.0_f64;
1066    let mut grad = Array1::<f64>::zeros(m);
1067    for i in 0..n {
1068        let speed = deriv[i].abs();
1069        let gap = speed - 1.0;
1070        defect += gap * gap;
1071        let sgn = if deriv[i] >= 0.0 { 1.0 } else { -1.0 };
1072        for j in 0..m {
1073            grad[j] += 2.0 * gap * sgn * deriv_rows[[i, j]];
1074        }
1075    }
1076    defect /= n as f64;
1077    grad.mapv_inplace(|v| v / n as f64);
1078    let isometry_defect_se = grad.dot(&fit.covariance.dot(&grad)).max(0.0).sqrt();
1079
1080    // --- fold / orientation check on a dense grid ---------------------------
1081    let grid = domain_grid(topology_from, FOLD_CHECK_GRID);
1082    let grid_deriv = basis
1083        .derivative_rows(grid.view())?
1084        .dot(&fit.beta)
1085        .mapv(|v| v + slope);
1086    let orientation = if slope != 0.0 {
1087        slope.signum()
1088    } else {
1089        let mean = grid_deriv.iter().sum::<f64>() / grid_deriv.len() as f64;
1090        if mean < 0.0 { -1.0 } else { 1.0 }
1091    };
1092    let min_directional_derivative = grid_deriv
1093        .iter()
1094        .map(|&v| orientation * v)
1095        .fold(f64::INFINITY, f64::min);
1096    let topology_preserved = match (topology_from, topology_to) {
1097        (ChartTopology::Circle, ChartTopology::Circle) => {
1098            matches!(degree, Some(1) | Some(-1)) && min_directional_derivative > 0.0
1099        }
1100        (ChartTopology::Interval { .. }, ChartTopology::Interval { .. }) => {
1101            min_directional_derivative > 0.0
1102        }
1103        _ => false,
1104    };
1105
1106    Ok(FittedTransport {
1107        topology_from,
1108        topology_to,
1109        degree,
1110        degree_concentration,
1111        rotation_offset,
1112        beta: fit.beta,
1113        covariance: fit.covariance,
1114        smoothing_lambda: fit.lambda,
1115        edf: fit.edf,
1116        noise_variance: fit.sigma2,
1117        n_obs: n,
1118        isometry_defect: defect,
1119        isometry_defect_se,
1120        topology_preserved,
1121        min_directional_derivative,
1122        residual_rms: fit.residual_rms,
1123        basis,
1124        coefficient_score_influence: fit.coefficient_score_influence,
1125    })
1126}
1127
1128/// Estimate the transport map between two layers and package the evidence.
1129pub fn fit_layer_transport(
1130    layer_from: usize,
1131    layer_to: usize,
1132    coords_from: ArrayView1<'_, f64>,
1133    coords_to: ArrayView1<'_, f64>,
1134    topology_from: ChartTopology,
1135    topology_to: ChartTopology,
1136) -> Result<LayerTransportReport, String> {
1137    Ok(
1138        fit_transport_map(coords_from, coords_to, topology_from, topology_to)?
1139            .report(layer_from, layer_to),
1140    )
1141}
1142
1143/// Composition-law test report for one triple `(h_ab, h_bc, h_ac)`.
1144#[derive(Debug, Clone)]
1145pub struct CompositionDefectReport {
1146    pub n_grid: usize,
1147    /// Always zero: no post-hoc target rotation is fitted.
1148    pub gauge_rotation: f64,
1149    /// Always false: no post-hoc target reflection is fitted.
1150    pub gauge_reflected: bool,
1151    pub mean_abs_defect: f64,
1152    pub rms_defect: f64,
1153    pub max_abs_defect: f64,
1154    /// `max_t |d(t)| / band(t)` against the composed pointwise bands.
1155    pub max_studentized_defect: f64,
1156    /// Bonferroni p-value bound for the max studentized defect over all tested
1157    /// grid points.
1158    pub max_studentized_p_value: f64,
1159    /// Alias of the familywise max-test p-value for report consumers. `NaN`
1160    /// explicitly means that deterministic fits supplied no sampling variation.
1161    pub p_value: f64,
1162}
1163
1164/// Recover ascending monomial coefficients of the unique degree-`(n-1)`
1165/// polynomial through `(i, values[i])`, `i = 0, …, n-1`, by expanding its
1166/// Lagrange basis polynomials. The monotonicity certificate uses this exact
1167/// algebraic reconstruction for each known-degree B-spline derivative piece.
1168fn monomial_interpolant_at_integer_nodes(values: &[f64]) -> Vec<f64> {
1169    let n = values.len();
1170    if n == 0 {
1171        return Vec::new();
1172    }
1173    let mut coeffs = vec![0.0_f64; n];
1174    for (i, &value) in values.iter().enumerate() {
1175        let mut basis = vec![1.0_f64];
1176        let mut denominator = 1.0_f64;
1177        for j in 0..n {
1178            if j == i {
1179                continue;
1180            }
1181            denominator *= i as f64 - j as f64;
1182            let mut expanded = vec![0.0_f64; basis.len() + 1];
1183            for (degree, &coefficient) in basis.iter().enumerate() {
1184                expanded[degree] -= j as f64 * coefficient;
1185                expanded[degree + 1] += coefficient;
1186            }
1187            basis = expanded;
1188        }
1189        let scale = value / denominator;
1190        for (degree, coefficient) in basis.into_iter().enumerate() {
1191            coeffs[degree] += scale * coefficient;
1192        }
1193    }
1194    coeffs
1195}
1196
1197/// Evaluate an ascending monomial polynomial at `u` (Horner).
1198fn eval_monomial(coeffs: &[f64], u: f64) -> f64 {
1199    coeffs.iter().rev().fold(0.0_f64, |acc, &c| acc * u + c)
1200}
1201
1202/// Interior critical points (roots of the derivative) of an ascending monomial
1203/// polynomial, in the local `u` coordinate. Returns the closed-form roots for
1204/// degree ≤ 2 derivatives (i.e. cubic-spline pieces, the production path);
1205/// higher-degree derivatives fall back to a robust bisection root-isolation so
1206/// the certificate stays exact-enough (a missed extremum can only make the
1207/// certificate stricter, never falsely accept a fold, because the endpoints and
1208/// every sign change found are still checked). For the cubic transport splines
1209/// the polynomial is quadratic and this is the single vertex.
1210fn monomial_critical_points(coeffs: &[f64]) -> Vec<f64> {
1211    // Derivative coefficients: d/du Σ c_k u^k = Σ k·c_k u^{k−1}.
1212    let n = coeffs.len();
1213    if n <= 1 {
1214        return Vec::new();
1215    }
1216    let deriv: Vec<f64> = (1..n).map(|k| k as f64 * coeffs[k]).collect();
1217    // deriv is ascending of length n−1 (degree n−2).
1218    match deriv.len() {
1219        0 => Vec::new(),
1220        1 => Vec::new(), // constant derivative: no critical point
1221        2 => {
1222            // Linear b + a·u = 0 (a = deriv[1]).
1223            let (b, a) = (deriv[0], deriv[1]);
1224            if a.abs() <= f64::MIN_POSITIVE {
1225                Vec::new()
1226            } else {
1227                vec![-b / a]
1228            }
1229        }
1230        3 => {
1231            // Quadratic c + b·u + a·u² = 0.
1232            let (c, b, a) = (deriv[0], deriv[1], deriv[2]);
1233            if a.abs() <= f64::MIN_POSITIVE {
1234                if b.abs() <= f64::MIN_POSITIVE {
1235                    Vec::new()
1236                } else {
1237                    vec![-c / b]
1238                }
1239            } else {
1240                let disc = b * b - 4.0 * a * c;
1241                if disc < 0.0 {
1242                    Vec::new()
1243                } else {
1244                    let s = disc.sqrt();
1245                    vec![(-b + s) / (2.0 * a), (-b - s) / (2.0 * a)]
1246                }
1247            }
1248        }
1249        _ => {
1250            // General fallback: scan for sign changes of the derivative on a
1251            // dense [0, deg] grid and bisect each bracket. Conservative.
1252            let lo = 0.0;
1253            let hi = (coeffs.len() - 1) as f64;
1254            let steps = 256;
1255            let mut roots = Vec::new();
1256            let f = |u: f64| eval_monomial(&deriv, u);
1257            let mut prev_u = lo;
1258            let mut prev_v = f(lo);
1259            for i in 1..=steps {
1260                let u = lo + (hi - lo) * i as f64 / steps as f64;
1261                let v = f(u);
1262                if prev_v == 0.0 {
1263                    roots.push(prev_u);
1264                } else if prev_v * v < 0.0 {
1265                    let (mut a, mut b) = (prev_u, u);
1266                    for _ in 0..60 {
1267                        let m = 0.5 * (a + b);
1268                        if f(a) * f(m) <= 0.0 {
1269                            b = m;
1270                        } else {
1271                            a = m;
1272                        }
1273                    }
1274                    roots.push(0.5 * (a + b));
1275                }
1276                prev_u = u;
1277                prev_v = v;
1278            }
1279            roots
1280        }
1281    }
1282}
1283
1284/// Uniform evaluation grid over a chart domain.
1285fn domain_grid(topology: ChartTopology, n: usize) -> Array1<f64> {
1286    match topology {
1287        ChartTopology::Circle => Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64)),
1288        ChartTopology::Interval { lo, hi } => {
1289            Array1::from_iter((0..n).map(|i| lo + (hi - lo) * i as f64 / (n - 1).max(1) as f64))
1290        }
1291    }
1292}
1293
1294/// Test the composition law `h_ac ≟ h_bc ∘ h_ab` on `n_grid` points.
1295///
1296/// The defect `d(t) = h_ac(t) ⊖ (h_bc ∘ h_ab)(t)` (circular difference on
1297/// circle targets) is computed directly in the common target chart: no gauge is
1298/// selected after seeing the defect. Pointwise uncertainty is assembled from
1299/// the combined observation-level influence of all three maps, retaining their
1300/// shared-row covariance, and the grid is tested by a Bonferroni max statistic.
1301pub fn composition_defect(
1302    h_ab: &FittedTransport,
1303    h_bc: &FittedTransport,
1304    h_ac: &FittedTransport,
1305    n_grid: usize,
1306) -> Result<CompositionDefectReport, String> {
1307    if h_ab.topology_from != h_ac.topology_from
1308        || h_ab.topology_to != h_bc.topology_from
1309        || h_bc.topology_to != h_ac.topology_to
1310    {
1311        return Err("composition defect requires chart-compatible transports: \
1312             h_ab: A→B, h_bc: B→C, h_ac: A→C"
1313            .to_string());
1314    }
1315    if n_grid < MIN_TRANSPORT_OBS {
1316        return Err(format!(
1317            "composition defect grid must have at least {MIN_TRANSPORT_OBS} points, got {n_grid}"
1318        ));
1319    }
1320    if h_ab.n_obs != h_bc.n_obs || h_ab.n_obs != h_ac.n_obs {
1321        return Err(format!(
1322            "composition defect requires maps fitted on the same rows; got n_ab={}, n_bc={}, n_ac={}",
1323            h_ab.n_obs, h_bc.n_obs, h_ac.n_obs
1324        ));
1325    }
1326
1327    let grid = domain_grid(h_ab.topology_from, n_grid);
1328    let direct = h_ac.eval(grid.view())?;
1329    let mid = h_ab.eval(grid.view())?;
1330    let composed = h_bc.eval(mid.view())?;
1331    let mid_slope = h_bc.derivative(mid.view())?;
1332
1333    // Joint row-influence sandwich. For original fit row r and evaluation point
1334    // t, the first-order influence of the composition defect is
1335    //   IF_ac(t,r) - IF_bc(h_ab(t),r) - h_bc'(h_ab(t)) IF_ab(t,r).
1336    // Squaring the combined influence before summing retains every shared-fit
1337    // covariance term; adding three marginal variances drops those cross terms.
1338    let influence_direct = h_ac.eval_score_influence(grid.view())?;
1339    let influence_ab = h_ab.eval_score_influence(grid.view())?;
1340    let influence_bc = h_bc.eval_score_influence(mid.view())?;
1341    let mut variance = Array1::<f64>::zeros(n_grid);
1342    for i in 0..n_grid {
1343        let mut value = 0.0_f64;
1344        for row in 0..h_ab.n_obs {
1345            let influence = influence_direct[[i, row]]
1346                - influence_bc[[i, row]]
1347                - mid_slope[i] * influence_ab[[i, row]];
1348            value += influence * influence;
1349        }
1350        variance[i] = value;
1351    }
1352
1353    // Both routes consume the same source chart and land in the same target
1354    // chart. Every source/target gauge transformation therefore acts on both
1355    // routes identically and cancels. Fitting a fresh rotation/reflection here
1356    // would fit away the very composition violation being tested.
1357    let circle_target = matches!(h_ac.topology_to, ChartTopology::Circle);
1358    let defect = Array1::from_iter((0..n_grid).map(|i| {
1359        if circle_target {
1360            wrap_pi(direct[i] - composed[i])
1361        } else {
1362            direct[i] - composed[i]
1363        }
1364    }));
1365
1366    // --- pointwise studentization against the composed bands ----------------
1367    // Floor the band variance with BOTH a relative component (numerical guard
1368    // against exact zeros) AND an absolute, coordinate-scale component. The
1369    // absolute component is the fix for #2143: the delta-method band variance is
1370    // a pure sampling variance that collapses on near-noiseless REML fits, so
1371    // without it the irreducible spline-representation defect (which the sampling
1372    // variance does not model) is studentized into a spurious rejection. The
1373    // absolute floor is the squared representation tolerance relative to the
1374    // target chart's coordinate span, so a machine-level composition defect on a
1375    // clean chain reads as non-significant while a genuine violation (far larger
1376    // defect, or real sampling variance well above the floor) is unaffected.
1377    let max_var = variance.iter().copied().fold(0.0_f64, f64::max);
1378    let var_floor = (max_var * 1e-12).max(f64::MIN_POSITIVE);
1379    let mut max_abs = 0.0_f64;
1380    let mut sum_abs = 0.0_f64;
1381    let mut sum_sq = 0.0_f64;
1382    let mut max_z = 0.0_f64;
1383    for i in 0..n_grid {
1384        let d = defect[i];
1385        let a = d.abs();
1386        max_abs = max_abs.max(a);
1387        sum_abs += a;
1388        sum_sq += d * d;
1389        if max_var > 0.0 {
1390            let z = a / variance[i].max(var_floor).sqrt();
1391            max_z = max_z.max(z);
1392        }
1393    }
1394    let mean_abs_defect = sum_abs / n_grid as f64;
1395    let rms_defect = (sum_sq / n_grid as f64).sqrt();
1396
1397    // Bonferroni bound for the max studentized defect over the actual grid:
1398    // valid for arbitrary dependence among pointwise contrasts. With zero
1399    // empirical score variation there is no sampling law, so no p-value is
1400    // emitted from deterministic fitted-grid values.
1401    let max_studentized_p_value = if max_var > 0.0 {
1402        let normal = Normal::new(0.0, 1.0)
1403            .map_err(|e| format!("standard normal construction failed: {e}"))?;
1404        let pointwise: f64 = (2.0 * (1.0 - normal.cdf(max_z))).clamp(0.0, 1.0);
1405        (n_grid as f64 * pointwise).min(1.0)
1406    } else {
1407        f64::NAN
1408    };
1409
1410    Ok(CompositionDefectReport {
1411        n_grid,
1412        gauge_rotation: 0.0,
1413        gauge_reflected: false,
1414        mean_abs_defect,
1415        rms_defect,
1416        max_abs_defect: max_abs,
1417        max_studentized_defect: if max_var > 0.0 { max_z } else { f64::NAN },
1418        max_studentized_p_value,
1419        p_value: max_studentized_p_value,
1420    })
1421}
1422
1423/// Full transport report for a ladder of layers: every adjacent map plus
1424/// every two-hop map with its composition-law test attached.
1425#[derive(Debug, Clone)]
1426pub struct TransportLadderReport {
1427    /// `h_{l→l+1}` for each consecutive pair.
1428    pub adjacent: Vec<LayerTransportReport>,
1429    /// `h_{l→l+2}` with the composition test against the composed adjacent
1430    /// pair merged in.
1431    pub two_hop: Vec<LayerTransportReport>,
1432    /// O(2) classification (winding/phase/defect) of each adjacent map whose
1433    /// endpoints are both circle charts — the Fourier-rigidity report
1434    /// ([`crate::inference::transport_class::classify_circle_transport_fit`]).
1435    /// Non-circle pairs are omitted; empty when no adjacent pair is circle→circle.
1436    pub circle_transports: Vec<crate::inference::transport_class::CircleTransportReport>,
1437}
1438
1439/// Fit the whole transport ladder: adjacent maps, two-hop maps, and the
1440/// composition law `h_{l→l+2} ≟ h_{l+1→l+2} ∘ h_{l→l+1}` per triple.
1441///
1442/// `layers[k]`, `coords[k]`, `topologies[k]` describe layer `k` of the
1443/// ladder; all coordinate vectors must index the same rows.
1444pub fn transport_ladder(
1445    layers: &[usize],
1446    coords: &[Array1<f64>],
1447    topologies: &[ChartTopology],
1448) -> Result<TransportLadderReport, String> {
1449    let depth = layers.len();
1450    if coords.len() != depth || topologies.len() != depth {
1451        return Err(format!(
1452            "transport ladder inputs disagree: {depth} layers, {} coordinate vectors, {} topologies",
1453            coords.len(),
1454            topologies.len()
1455        ));
1456    }
1457    if depth < 2 {
1458        return Err("transport ladder needs at least two layers".to_string());
1459    }
1460
1461    let mut adjacent_fits: Vec<FittedTransport> = Vec::with_capacity(depth - 1);
1462    let mut adjacent: Vec<LayerTransportReport> = Vec::with_capacity(depth - 1);
1463    for k in 0..depth - 1 {
1464        let fit = fit_transport_map(
1465            coords[k].view(),
1466            coords[k + 1].view(),
1467            topologies[k],
1468            topologies[k + 1],
1469        )
1470        .map_err(|e| {
1471            format!(
1472                "adjacent transport {}→{} failed: {e}",
1473                layers[k],
1474                layers[k + 1]
1475            )
1476        })?;
1477        adjacent.push(fit.report(layers[k], layers[k + 1]));
1478        adjacent_fits.push(fit);
1479    }
1480
1481    let mut two_hop: Vec<LayerTransportReport> = Vec::with_capacity(depth.saturating_sub(2));
1482    for k in 0..depth.saturating_sub(2) {
1483        let direct = fit_transport_map(
1484            coords[k].view(),
1485            coords[k + 2].view(),
1486            topologies[k],
1487            topologies[k + 2],
1488        )
1489        .map_err(|e| {
1490            format!(
1491                "two-hop transport {}→{} failed: {e}",
1492                layers[k],
1493                layers[k + 2]
1494            )
1495        })?;
1496        let composition = composition_defect(
1497            &adjacent_fits[k],
1498            &adjacent_fits[k + 1],
1499            &direct,
1500            DEFAULT_COMPOSITION_GRID,
1501        )
1502        .map_err(|e| {
1503            format!(
1504                "composition test {}→{}→{} failed: {e}",
1505                layers[k],
1506                layers[k + 1],
1507                layers[k + 2]
1508            )
1509        })?;
1510        two_hop.push(
1511            direct
1512                .report(layers[k], layers[k + 2])
1513                .with_composition(&composition),
1514        );
1515    }
1516
1517    // O(2) Fourier-rigidity classification of each adjacent circle→circle map,
1518    // grid-sampled from the fitted angle map. Additive report; no fitting-path
1519    // effect.
1520    let mut circle_transports = Vec::new();
1521    for k in 0..depth - 1 {
1522        if let Some(report) = crate::inference::transport_class::classify_circle_transport_fit(
1523            &adjacent_fits[k],
1524            topologies[k],
1525            topologies[k + 1],
1526            layers[k],
1527            layers[k + 1],
1528            DEFAULT_COMPOSITION_GRID,
1529        ) {
1530            circle_transports.push(report);
1531        }
1532    }
1533
1534    Ok(TransportLadderReport {
1535        adjacent,
1536        two_hop,
1537        circle_transports,
1538    })
1539}
1540
1541#[cfg(test)]
1542mod invert_tests {
1543    use super::*;
1544    use faer::Side;
1545    use gam_linalg::faer_ndarray::FaerEigh;
1546    use ndarray::Array1;
1547
1548    fn interval(lo: f64, hi: f64) -> ChartTopology {
1549        ChartTopology::Interval { lo, hi }
1550    }
1551
1552    /// The transport wrapper must reuse Gaussian REML's profiled scale and its
1553    /// exact spectral inverse. In particular, covariance cannot come from the
1554    /// removed eigenvalue floor/micro-ridge solve or from `RSS / (n - edf)`.
1555    #[test]
1556    fn penalized_1d_covariance_and_scale_match_reml_system() {
1557        let n = 32;
1558        let design = Array2::from_shape_fn((n, 3), |(row, col)| {
1559            let x = row as f64 / (n - 1) as f64;
1560            match col {
1561                0 => 1.0,
1562                1 => x,
1563                2 => x * x,
1564                _ => unreachable!(),
1565            }
1566        });
1567        let response = Array1::from_shape_fn(n, |row| {
1568            let x = row as f64 / (n - 1) as f64;
1569            0.3 + 0.8 * x + 0.2 * (TAU * x).sin()
1570        });
1571        let mut penalty = Array2::<f64>::zeros((3, 3));
1572        penalty[[2, 2]] = 1.0;
1573        let fit = fit_penalized_1d(&design, &penalty, response.view(), None, 1)
1574            .expect("certified REML fit");
1575
1576        let mut penalized_gram = design.t().dot(&design);
1577        penalized_gram[[2, 2]] += fit.lambda;
1578        let inverse = fit.covariance.mapv(|value| value / fit.sigma2);
1579        let identity = penalized_gram.dot(&inverse);
1580        for row in 0..3 {
1581            for col in 0..3 {
1582                let expected = f64::from(u8::from(row == col));
1583                assert!(
1584                    (identity[[row, col]] - expected).abs() < 1.0e-9,
1585                    "penalized inverse mismatch at ({row}, {col}): {}",
1586                    identity[[row, col]],
1587                );
1588            }
1589        }
1590
1591        let xtwy = design.t().dot(&response);
1592        let prss = response.dot(&response) - fit.beta.dot(&xtwy);
1593        let expected_sigma2 = prss / (n - 2) as f64;
1594        assert!(
1595            (fit.sigma2 - expected_sigma2).abs() <= 1.0e-11 * expected_sigma2.abs().max(1.0),
1596            "REML scale mismatch: fitted {}, expected {expected_sigma2}",
1597            fit.sigma2,
1598        );
1599    }
1600
1601    #[test]
1602    fn invert_round_trips_interval_transport() {
1603        // A strictly increasing nonlinear warp on [0,1] → [0,1] with derivative
1604        // bounded away from zero: to = (t + 0.25·sin(2πt)/(2π)) normalized, whose
1605        // h′ = 1 + 0.25·cos(2πt) ∈ [0.75, 1.25] never approaches zero.
1606        let n = 64;
1607        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1608        let to: Array1<f64> = from.mapv(|t| t + 0.25 * (TAU * t).sin() / TAU);
1609        let ft = fit_transport_map(
1610            from.view(),
1611            to.view(),
1612            interval(0.0, 1.0),
1613            interval(0.0, 1.0),
1614        )
1615        .expect("fit");
1616        assert!(
1617            ft.topology_preserved,
1618            "monotone warp should preserve topology"
1619        );
1620
1621        let probe = Array1::from_iter((1..10).map(|i| i as f64 / 10.0));
1622        // eval ∘ invert and invert ∘ eval both return identity.
1623        let fwd = ft.eval(probe.view()).expect("eval");
1624        let back = ft.invert(fwd.view()).expect("invert");
1625        for i in 0..probe.len() {
1626            assert!(
1627                (back[i] - probe[i]).abs() < 1e-6,
1628                "round-trip failed: t={} back={}",
1629                probe[i],
1630                back[i]
1631            );
1632        }
1633        let re_eval = ft.eval(back.view()).expect("eval");
1634        for i in 0..fwd.len() {
1635            assert!((re_eval[i] - fwd[i]).abs() < 1e-9);
1636        }
1637    }
1638
1639    #[test]
1640    fn invert_round_trips_decreasing_interval_transport() {
1641        // Orientation-reversing homeomorphism with derivative bounded away from
1642        // zero: to = 1 - 0.5·from - 0.5·from² on [0,1] (h′ = -0.5 - from ≤ -0.5).
1643        let n = 64;
1644        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1645        let to: Array1<f64> = from.mapv(|t| 1.0 - 0.5 * t - 0.5 * t * t);
1646        let ft = fit_transport_map(
1647            from.view(),
1648            to.view(),
1649            interval(0.0, 1.0),
1650            interval(0.0, 1.0),
1651        )
1652        .expect("fit");
1653        assert!(ft.topology_preserved);
1654        let probe = Array1::from_iter((1..10).map(|i| i as f64 / 10.0));
1655        let fwd = ft.eval(probe.view()).expect("eval");
1656        let back = ft.invert(fwd.view()).expect("invert");
1657        for i in 0..probe.len() {
1658            assert!(
1659                (back[i] - probe[i]).abs() < 1e-6,
1660                "t={} back={}",
1661                probe[i],
1662                back[i]
1663            );
1664        }
1665    }
1666
1667    #[test]
1668    fn invert_round_trips_circle_transport() {
1669        // Degree-1 circle cover: a rotation plus a fold-free wiggle.
1670        let n = 128;
1671        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1672        let to: Array1<f64> = from.mapv(|t| wrap_tau(t + 0.3 + 0.2 * t.sin()));
1673        let ft = fit_transport_map(
1674            from.view(),
1675            to.view(),
1676            ChartTopology::Circle,
1677            ChartTopology::Circle,
1678        )
1679        .expect("fit");
1680        assert!(ft.topology_preserved, "degree {:?}", ft.degree);
1681
1682        let probe = Array1::from_iter((0..7).map(|i| TAU * (i as f64 + 0.5) / 7.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            // Compare modulo 2π.
1687            let d = wrap_pi(back[i] - probe[i]).abs();
1688            assert!(d < 1e-5, "probe={} back={} d={}", probe[i], back[i], d);
1689        }
1690    }
1691
1692    #[test]
1693    fn invert_rejects_target_outside_interval_image() {
1694        // Image of `to = 0.5·from` is ~[0, 0.5]; y = 0.9 is outside it.
1695        let n = 32;
1696        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1697        let to: Array1<f64> = from.mapv(|t| 0.5 * t);
1698        let ft = fit_transport_map(
1699            from.view(),
1700            to.view(),
1701            interval(0.0, 1.0),
1702            interval(0.0, 1.0),
1703        )
1704        .expect("fit");
1705        assert!(ft.invert(Array1::from_elem(1, 0.9).view()).is_err());
1706    }
1707
1708    /// Build a `FittedTransport` on an interval whose pre-wrap map interpolates
1709    /// `h` by an unpenalized least-squares spline fit (so a deliberately narrow
1710    /// fold in `h` survives into the coefficients, unlike a REML fit which would
1711    /// smooth it away). Fields irrelevant to `eval`/`derivative`/`invert` are
1712    /// filled with sound placeholders.
1713    fn fitted_from_target(
1714        from: ArrayView1<'_, f64>,
1715        target: ArrayView1<'_, f64>,
1716        lo: f64,
1717        hi: f64,
1718    ) -> FittedTransport {
1719        let basis = DomainBasis::build(interval(lo, hi), from).expect("basis");
1720        let design = basis.value_rows(from).expect("design");
1721        let m = design.ncols();
1722        // Normal equations XᵀX β = Xᵀy with a tiny ridge for conditioning only.
1723        let mut xtx = design.t().dot(&design);
1724        let xty = design.t().dot(&target);
1725        let diag = (0..m).map(|i| xtx[[i, i]].abs()).fold(1.0_f64, f64::max);
1726        for i in 0..m {
1727            xtx[[i, i]] += 1e-10 * diag;
1728        }
1729        let (evals, evecs) = xtx.eigh(Side::Lower).expect("eigh");
1730        let rotated = evecs.t().dot(&xty);
1731        let mut beta = Array1::<f64>::zeros(m);
1732        for i in 0..m {
1733            let d = evals[i].max(f64::MIN_POSITIVE);
1734            let c = rotated[i] / d;
1735            for j in 0..m {
1736                beta[j] += evecs[[j, i]] * c;
1737            }
1738        }
1739        FittedTransport {
1740            topology_from: interval(lo, hi),
1741            topology_to: interval(lo, hi),
1742            degree: None,
1743            degree_concentration: None,
1744            rotation_offset: 0.0,
1745            beta,
1746            covariance: Array2::<f64>::zeros((m, m)),
1747            smoothing_lambda: 0.0,
1748            edf: 0.0,
1749            noise_variance: 1.0,
1750            n_obs: from.len(),
1751            isometry_defect: 0.0,
1752            isometry_defect_se: 0.0,
1753            topology_preserved: true,
1754            min_directional_derivative: 1.0,
1755            residual_rms: 0.0,
1756            coefficient_score_influence: Array2::<f64>::zeros((m, from.len())),
1757            basis,
1758        }
1759    }
1760
1761    /// Reviewer's between-grid fold reproducer: h(t) = (t−0.5)³/3 − (0.4/511)²·t
1762    /// hides a narrow fold between the 512-point certification-grid samples.
1763    /// `topology_preserved` (the sampled diagnostic) reads true, yet a dense
1764    /// grid finds orientation·h′ < 0 — the span-exact certificate that `invert`
1765    /// now gates on must reject the fit.
1766    #[test]
1767    fn invert_rejects_between_grid_fold() {
1768        let n = 256;
1769        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1770        let eps = 0.4 / 511.0;
1771        let target: Array1<f64> = from.mapv(|t| (t - 0.5).powi(3) / 3.0 - eps * eps * t);
1772        let mut ft = fitted_from_target(from.view(), target.view(), 0.0, 1.0);
1773
1774        // Confirm the fold is genuinely between the 512-pt certification grid:
1775        // recompute the sampled diagnostic the production fit uses.
1776        let grid = domain_grid(interval(0.0, 1.0), FOLD_CHECK_GRID);
1777        let grid_d = ft.derivative(grid.view()).expect("grid deriv");
1778        let mean = grid_d.iter().sum::<f64>() / grid_d.len() as f64;
1779        let orientation = if mean < 0.0 { -1.0 } else { 1.0 };
1780        let min_grid = grid_d
1781            .iter()
1782            .map(|&v| orientation * v)
1783            .fold(f64::INFINITY, f64::min);
1784        // Dense grid (10× finer) to expose the hidden fold.
1785        let dense = Array1::from_iter((0..5120).map(|i| i as f64 / 5119.0));
1786        let dense_d = ft.derivative(dense.view()).expect("dense deriv");
1787        let min_dense = dense_d
1788            .iter()
1789            .map(|&v| orientation * v)
1790            .fold(f64::INFINITY, f64::min);
1791        ft.topology_preserved = min_grid > 0.0;
1792        ft.min_directional_derivative = min_grid;
1793        assert!(
1794            min_grid > 0.0 && min_dense < 0.0,
1795            "fixture must hide a between-grid fold: min on 512-grid={min_grid}, \
1796             min on dense grid={min_dense}"
1797        );
1798
1799        // The span-exact certificate must reject it even though the sampled
1800        // diagnostic passed.
1801        let res = ft.invert(Array1::from_elem(1, 0.0).view());
1802        assert!(
1803            res.is_err(),
1804            "between-grid fold must be rejected by the span-exact certificate \
1805             (topology_preserved={}, min_grid={min_grid}, min_dense={min_dense})",
1806            ft.topology_preserved
1807        );
1808    }
1809
1810    #[test]
1811    fn invert_rejects_non_finite_targets() {
1812        let n = 64;
1813        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1814        let to: Array1<f64> = from.mapv(|t| 0.5 * t);
1815        let ft = fit_transport_map(
1816            from.view(),
1817            to.view(),
1818            interval(0.0, 1.0),
1819            interval(0.0, 1.0),
1820        )
1821        .expect("fit");
1822        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1823            assert!(
1824                ft.invert(Array1::from_elem(1, bad).view()).is_err(),
1825                "non-finite target {bad} must be rejected"
1826            );
1827        }
1828    }
1829
1830    #[test]
1831    fn invert_image_tolerance_is_scale_aware() {
1832        // Image of `to = 1e-8·from` is ~[0, 1e-8]. A target 5% outside it must
1833        // be rejected, not silently clamped, under the scale-aware tolerance
1834        // (the old absolute 1e-9 would have accepted it).
1835        let n = 64;
1836        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1837        let scale = 1.0e-8;
1838        let to: Array1<f64> = from.mapv(|t| scale * t);
1839        let ft = fit_transport_map(
1840            from.view(),
1841            to.view(),
1842            interval(0.0, 1.0),
1843            interval(0.0, 1.0),
1844        )
1845        .expect("fit");
1846        let outside = 1.05e-8;
1847        assert!(
1848            ft.invert(Array1::from_elem(1, outside).view()).is_err(),
1849            "target {outside} is 5% outside the [0, {scale}] image and must be rejected"
1850        );
1851        // A target inside the image still round-trips.
1852        let inside = 0.5e-8;
1853        let t = ft
1854            .invert(Array1::from_elem(1, inside).view())
1855            .expect("invert inside");
1856        let re = ft.eval(t.view()).expect("eval");
1857        assert!((re[0] - inside).abs() < 1e-3 * scale);
1858    }
1859
1860    #[test]
1861    fn invert_round_trips_degree_minus_one_circle() {
1862        // Orientation-reversing degree −1 circle cover: a reflection plus a
1863        // fold-free wiggle.
1864        let n = 128;
1865        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1866        let to: Array1<f64> = from.mapv(|t| wrap_tau(-t + 0.4 + 0.15 * t.sin()));
1867        let ft = fit_transport_map(
1868            from.view(),
1869            to.view(),
1870            ChartTopology::Circle,
1871            ChartTopology::Circle,
1872        )
1873        .expect("fit");
1874        assert_eq!(ft.degree, Some(-1), "expected a degree −1 cover");
1875        assert!(ft.topology_preserved, "degree {:?}", ft.degree);
1876        let probe = Array1::from_iter((0..7).map(|i| TAU * (i as f64 + 0.5) / 7.0));
1877        let fwd = ft.eval(probe.view()).expect("eval");
1878        let back = ft.invert(fwd.view()).expect("invert");
1879        for i in 0..probe.len() {
1880            let d = wrap_pi(back[i] - probe[i]).abs();
1881            assert!(d < 1e-5, "probe={} back={} d={}", probe[i], back[i], d);
1882        }
1883    }
1884
1885    #[test]
1886    fn invert_round_trips_circle_seam_and_interval_endpoints() {
1887        // Circle seam: invert a target near 0/2π.
1888        let n = 128;
1889        let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1890        let to: Array1<f64> = from.mapv(|t| wrap_tau(t + 0.3 + 0.2 * t.sin()));
1891        let ft = fit_transport_map(
1892            from.view(),
1893            to.view(),
1894            ChartTopology::Circle,
1895            ChartTopology::Circle,
1896        )
1897        .expect("fit");
1898        assert!(ft.topology_preserved);
1899        for seam in [1e-9, TAU - 1e-9, 0.0] {
1900            let t = ft
1901                .invert(Array1::from_elem(1, seam).view())
1902                .expect("invert seam");
1903            let re = ft.eval(t.view()).expect("eval");
1904            let d = wrap_pi(re[0] - wrap_tau(seam)).abs();
1905            assert!(d < 1e-6, "seam={seam} re={} d={d}", re[0]);
1906        }
1907
1908        // Interval endpoints: invert the image endpoints exactly.
1909        let m = 64;
1910        let ifrom: Array1<f64> = Array1::from_iter((0..m).map(|i| i as f64 / (m as f64 - 1.0)));
1911        let ito: Array1<f64> = ifrom.mapv(|t| t + 0.25 * (TAU * t).sin() / TAU);
1912        let ift = fit_transport_map(
1913            ifrom.view(),
1914            ito.view(),
1915            interval(0.0, 1.0),
1916            interval(0.0, 1.0),
1917        )
1918        .expect("fit");
1919        let raw_lo = ift.raw_at(0.0).expect("raw lo");
1920        let raw_hi = ift.raw_at(1.0).expect("raw hi");
1921        for &edge in &[raw_lo, raw_hi] {
1922            let t = ift
1923                .invert(Array1::from_elem(1, edge).view())
1924                .expect("invert endpoint");
1925            assert!(t[0] >= -1e-9 && t[0] <= 1.0 + 1e-9, "endpoint t={}", t[0]);
1926            let re = ift.eval(t.view()).expect("eval");
1927            assert!((re[0] - edge).abs() < 1e-6, "edge={edge} re={}", re[0]);
1928        }
1929    }
1930
1931    #[test]
1932    fn monomial_reconstruction_is_exact_for_quadratic() {
1933        // The certificate's polynomial reconstruction must be exact on the
1934        // quadratic pieces of a cubic-spline derivative.
1935        let coeffs_true = [0.7_f64, -1.3, 2.1]; // 0.7 − 1.3u + 2.1u²
1936        let values: Vec<f64> = (0..3)
1937            .map(|i| eval_monomial(&coeffs_true, i as f64))
1938            .collect();
1939        let recon = monomial_interpolant_at_integer_nodes(&values);
1940        for (a, b) in recon.iter().zip(coeffs_true.iter()) {
1941            assert!((a - b).abs() < 1e-12, "recon {a} vs {b}");
1942        }
1943        // Vertex of 2.1u² − 1.3u + 0.7 is at u = 1.3 / (2·2.1).
1944        let crit = monomial_critical_points(&recon);
1945        assert_eq!(crit.len(), 1);
1946        assert!((crit[0] - 1.3 / 4.2).abs() < 1e-12);
1947    }
1948}