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