1use crate::chart_canonicalization::CanonicalChartTopology;
61use gam_math::probability::normal_two_sided_probability;
62use gam_solve::gaussian_reml::{
63 gaussian_reml_closed_form_with_nullspace_dim, gaussian_reml_stationary_set,
64};
65use gam_terms::basis::{
66 BasisOptions, Dense, KnotSource, PeriodicBSplineBasisSpec, bspline_derivative_penalty_matrix,
67 build_periodic_bspline_basis_1d, create_basis, cyclic_bspline_derivative_penalty_matrix,
68 periodic_bspline_first_derivative_nd,
69};
70use ndarray::{Array1, Array2, ArrayView1, Axis};
71use std::f64::consts::{PI, TAU};
72
73const TRANSPORT_SPLINE_DEGREE: usize = 3;
75const TRANSPORT_PENALTY_ORDER: usize = 2;
79const MIN_TRANSPORT_OBS: usize = 16;
81const OBS_PER_BASIS: usize = 8;
83const MIN_PERIODIC_BASIS: usize = 8;
85const MAX_PERIODIC_BASIS: usize = 20;
86const MIN_OPEN_INTERNAL_KNOTS: usize = 4;
88const MAX_OPEN_INTERNAL_KNOTS: usize = 12;
89const DEGREE_CANDIDATES: [i32; 5] = [-2, -1, 0, 1, 2];
91const FOLD_CHECK_GRID: usize = 512;
93pub const DEFAULT_COMPOSITION_GRID: usize = 256;
95
96#[derive(Debug, Clone, Copy, PartialEq)]
98pub enum ChartTopology {
99 Circle,
101 Interval { lo: f64, hi: f64 },
103}
104
105impl ChartTopology {
106 pub fn name(&self) -> &'static str {
108 match self {
109 ChartTopology::Circle => "circle",
110 ChartTopology::Interval { .. } => "interval",
111 }
112 }
113
114 fn validate(&self) -> Result<(), String> {
115 match *self {
116 ChartTopology::Circle => Ok(()),
117 ChartTopology::Interval { lo, hi } => {
118 if !(lo.is_finite() && hi.is_finite()) || hi <= lo {
119 Err(format!(
120 "interval chart bounds must be finite and ordered; got [{lo}, {hi}]"
121 ))
122 } else {
123 Ok(())
124 }
125 }
126 }
127 }
128}
129
130impl From<&CanonicalChartTopology> for ChartTopology {
141 fn from(src: &CanonicalChartTopology) -> Self {
142 match src {
143 CanonicalChartTopology::Circle { .. } => ChartTopology::Circle,
144 CanonicalChartTopology::Interval => ChartTopology::Interval { lo: 0.0, hi: 1.0 },
145 }
146 }
147}
148
149impl From<CanonicalChartTopology> for ChartTopology {
150 fn from(src: CanonicalChartTopology) -> Self {
151 ChartTopology::from(&src)
152 }
153}
154
155fn wrap_tau(x: f64) -> f64 {
157 x.rem_euclid(TAU)
158}
159
160fn wrap_pi(x: f64) -> f64 {
162 let w = (x + PI).rem_euclid(TAU) - PI;
163 if w <= -PI { w + TAU } else { w }
164}
165
166fn circular_mean(angles: &[f64]) -> f64 {
168 let mut s = 0.0_f64;
169 let mut c = 0.0_f64;
170 for &a in angles {
171 s += a.sin();
172 c += a.cos();
173 }
174 if s.hypot(c) <= f64::EPSILON * angles.len().max(1) as f64 {
175 0.0
176 } else {
177 s.atan2(c)
178 }
179}
180
181fn resultant_length(angles: &[f64]) -> f64 {
183 if angles.is_empty() {
184 return 0.0;
185 }
186 let mut s = 0.0_f64;
187 let mut c = 0.0_f64;
188 for &a in angles {
189 s += a.sin();
190 c += a.cos();
191 }
192 s.hypot(c) / angles.len() as f64
193}
194
195#[derive(Debug, Clone)]
199enum DomainBasis {
200 Periodic(PeriodicBSplineBasisSpec),
201 Open { knots: Array1<f64>, degree: usize },
202}
203
204impl DomainBasis {
205 fn build(topology: ChartTopology, coords: ArrayView1<'_, f64>) -> Result<Self, String> {
206 let n = coords.len();
207 match topology {
208 ChartTopology::Circle => {
209 let num_basis = (n / OBS_PER_BASIS).clamp(MIN_PERIODIC_BASIS, MAX_PERIODIC_BASIS);
210 Ok(DomainBasis::Periodic(PeriodicBSplineBasisSpec {
211 degree: TRANSPORT_SPLINE_DEGREE,
212 num_basis,
213 period: TAU,
214 origin: 0.0,
215 penalty_order: TRANSPORT_PENALTY_ORDER,
216 }))
217 }
218 ChartTopology::Interval { lo, hi } => {
219 let num_internal =
220 (n / OBS_PER_BASIS).clamp(MIN_OPEN_INTERNAL_KNOTS, MAX_OPEN_INTERNAL_KNOTS);
221 let (seed, knots) = create_basis::<Dense>(
222 coords.mapv(|v| v.clamp(lo, hi)).view(),
223 KnotSource::Generate {
224 data_range: (lo, hi),
225 num_internal_knots: num_internal,
226 },
227 TRANSPORT_SPLINE_DEGREE,
228 BasisOptions::value(),
229 )
230 .map_err(|e| format!("layer transport open basis construction failed: {e}"))?;
231 if seed.nrows() != n {
232 return Err(format!(
233 "layer transport open basis returned {} rows for {n} inputs",
234 seed.nrows()
235 ));
236 }
237 Ok(DomainBasis::Open {
238 knots,
239 degree: TRANSPORT_SPLINE_DEGREE,
240 })
241 }
242 }
243 }
244
245 fn num_basis(&self) -> usize {
246 match self {
247 DomainBasis::Periodic(spec) => spec.num_basis,
248 DomainBasis::Open { knots, degree } => knots.len() - degree - 1,
249 }
250 }
251
252 fn penalty_rank(&self) -> usize {
256 match self {
257 DomainBasis::Periodic(spec) => spec.num_basis - 1,
258 DomainBasis::Open { .. } => self.num_basis() - TRANSPORT_PENALTY_ORDER,
259 }
260 }
261
262 fn penalty(&self) -> Result<Array2<f64>, String> {
263 match self {
264 DomainBasis::Periodic(spec) => cyclic_bspline_derivative_penalty_matrix(
265 spec.degree,
266 spec.num_basis,
267 spec.period,
268 TRANSPORT_PENALTY_ORDER,
269 )
270 .map_err(|e| format!("cyclic transport roughness failed: {e}")),
271 DomainBasis::Open { knots, degree } => {
272 bspline_derivative_penalty_matrix(knots.view(), *degree, TRANSPORT_PENALTY_ORDER)
273 .map_err(|e| format!("open transport roughness failed: {e}"))
274 }
275 }
276 }
277
278 fn project(&self, t: f64) -> f64 {
280 match self {
281 DomainBasis::Periodic(_) => wrap_tau(t),
282 DomainBasis::Open { knots, degree } => {
283 let lo = knots[*degree];
284 let hi = knots[knots.len() - 1 - degree];
285 t.clamp(lo, hi)
286 }
287 }
288 }
289
290 fn value_rows(&self, t: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
291 let projected = t.mapv(|v| self.project(v));
292 match self {
293 DomainBasis::Periodic(spec) => build_periodic_bspline_basis_1d(projected.view(), spec)
294 .map_err(|e| format!("periodic transport basis evaluation failed: {e}")),
295 DomainBasis::Open { knots, degree } => {
296 let (rows, used_knots) = create_basis::<Dense>(
297 projected.view(),
298 KnotSource::Provided(knots.view()),
299 *degree,
300 BasisOptions::value(),
301 )
302 .map_err(|e| format!("open transport basis evaluation failed: {e}"))?;
303 if used_knots.len() != knots.len() {
304 return Err("open transport basis knot vector drifted".to_string());
305 }
306 Ok(rows.as_ref().to_owned())
307 }
308 }
309 }
310
311 fn derivative_poly_degree(&self) -> usize {
314 let degree = match self {
315 DomainBasis::Periodic(spec) => spec.degree,
316 DomainBasis::Open { degree, .. } => *degree,
317 };
318 degree.saturating_sub(1)
319 }
320
321 fn derivative_breakpoints(&self) -> Vec<f64> {
329 match self {
330 DomainBasis::Periodic(spec) => {
331 let n_seg = spec.num_basis.max(1);
335 (0..=n_seg)
336 .map(|k| spec.origin + spec.period * k as f64 / n_seg as f64)
337 .collect()
338 }
339 DomainBasis::Open { knots, degree } => {
340 let lo = knots[*degree];
341 let hi = knots[knots.len() - 1 - degree];
342 let mut breaks: Vec<f64> = Vec::with_capacity(knots.len());
343 for &k in knots.iter() {
344 if k > lo + 0.0 && k < hi {
345 breaks.push(k);
346 }
347 }
348 breaks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
349 breaks.dedup_by(|a, b| (*a - *b).abs() <= f64::EPSILON * hi.abs().max(1.0));
350 let mut out = Vec::with_capacity(breaks.len() + 2);
351 out.push(lo);
352 out.extend(breaks.into_iter().filter(|&k| k > lo && k < hi));
353 out.push(hi);
354 out
355 }
356 }
357 }
358
359 fn derivative_rows(&self, t: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
360 let projected = t.mapv(|v| self.project(v));
361 match self {
362 DomainBasis::Periodic(spec) => {
363 let n = projected.len();
364 let mut col = Array2::<f64>::zeros((n, 1));
365 for (i, &v) in projected.iter().enumerate() {
366 col[[i, 0]] = v;
367 }
368 let jet = periodic_bspline_first_derivative_nd(
369 col.view(),
370 (0.0, TAU),
371 spec.degree,
372 spec.num_basis,
373 )
374 .map_err(|e| format!("periodic transport derivative failed: {e}"))?;
375 Ok(jet.index_axis(Axis(2), 0).to_owned())
376 }
377 DomainBasis::Open { knots, degree } => {
378 let (rows, used_knots) = create_basis::<Dense>(
379 projected.view(),
380 KnotSource::Provided(knots.view()),
381 *degree,
382 BasisOptions::first_derivative(),
383 )
384 .map_err(|e| format!("open transport derivative failed: {e}"))?;
385 if used_knots.len() != knots.len() {
386 return Err("open transport derivative knot vector drifted".to_string());
387 }
388 Ok(rows.as_ref().to_owned())
389 }
390 }
391 }
392}
393
394struct Penalized1dFit {
396 beta: Array1<f64>,
397 covariance: Array2<f64>,
399 lambda: f64,
400 edf: f64,
401 sigma2: f64,
402 residual_rms: f64,
403 coefficient_score_influence: Array2<f64>,
407}
408
409fn fit_penalized_1d(
418 design: &Array2<f64>,
419 penalty: &Array2<f64>,
420 response: ArrayView1<'_, f64>,
421 weights: Option<ArrayView1<'_, f64>>,
422 penalty_rank: usize,
423) -> Result<Penalized1dFit, String> {
424 let n = design.nrows();
425 let m = design.ncols();
426 if response.len() != n || penalty.nrows() != m || penalty.ncols() != m {
427 return Err(format!(
428 "penalized 1-D fit shape mismatch: X is {n}×{m}, y has {}, S is {}×{}",
429 response.len(),
430 penalty.nrows(),
431 penalty.ncols()
432 ));
433 }
434 if penalty_rank > m {
435 return Err(format!(
436 "penalized 1-D fit penalty rank {penalty_rank} exceeds coefficient dimension {m}"
437 ));
438 }
439 if let Some(w) = weights.as_ref() {
440 if w.len() != n {
441 return Err(format!(
442 "penalized 1-D fit weight length {} does not match n = {n}",
443 w.len()
444 ));
445 }
446 if w.iter().any(|&v| !v.is_finite() || v <= 0.0) {
447 return Err("penalized 1-D fit weights must be finite and positive".to_string());
448 }
449 }
450
451 let nullspace_dim = m - penalty_rank;
452 let weight_view = || weights.as_ref().map(|w| w.view());
453 let stationary = gaussian_reml_stationary_set(
454 design.view(),
455 response.view(),
456 penalty.view(),
457 Some(nullspace_dim),
458 weight_view(),
459 None,
460 )
461 .map_err(|error| format!("penalized 1-D REML stationary enumeration failed: {error}"))?;
462 let reml = gaussian_reml_closed_form_with_nullspace_dim(
463 design.view(),
464 response.view(),
465 penalty.view(),
466 Some(nullspace_dim),
467 weight_view(),
468 None,
469 )
470 .map_err(|error| format!("penalized 1-D Gaussian REML failed: {error}"))?;
471 if reml.rho.to_bits() != stationary.selected_rho.to_bits() {
472 return Err(format!(
473 "penalized 1-D REML selection drifted between its certificate ({}) and fit ({})",
474 stationary.selected_rho, reml.rho,
475 ));
476 }
477
478 let lambda = reml.lambda;
484 let coefficient_basis = &reml.cache.coefficient_basis;
485 let penalty_eigenvalues = &reml.cache.penalty_eigenvalues;
486 if coefficient_basis.dim() != (m, m) || penalty_eigenvalues.len() != m {
487 return Err(format!(
488 "penalized 1-D REML cache shape drift: basis is {}x{}, spectrum has {}, expected {m}",
489 coefficient_basis.nrows(),
490 coefficient_basis.ncols(),
491 penalty_eigenvalues.len(),
492 ));
493 }
494 let mut a_inv = Array2::<f64>::zeros((m, m));
495 for i in 0..m {
496 let delta = penalty_eigenvalues[i];
497 let denominator = 1.0 + lambda * delta;
498 if !(delta.is_finite() && delta >= 0.0 && denominator.is_finite() && denominator > 0.0) {
499 return Err(format!(
500 "penalized 1-D REML cache has invalid mode {i}: delta={delta}, denominator={denominator}"
501 ));
502 }
503 let inverse_denominator = denominator.recip();
504 for j in 0..m {
505 let scaled_basis = coefficient_basis[[j, i]] * inverse_denominator;
506 for k in 0..m {
507 a_inv[[j, k]] += scaled_basis * coefficient_basis[[k, i]];
508 }
509 }
510 }
511
512 let beta = reml.coefficients;
513 let fitted = reml.fitted;
514 let edf = reml.edf;
515 let sigma2 = reml.sigma2;
516 let mut rss = 0.0_f64;
517 for r in 0..n {
518 let w = weights.as_ref().map_or(1.0, |wv| wv[r]);
519 let e = response[r] - fitted[r];
520 rss += w * e * e;
521 }
522 let covariance = a_inv.mapv(|v| v * sigma2);
523 let sum_w = weights
524 .as_ref()
525 .map_or(n as f64, |wv| wv.iter().copied().sum());
526 let residual_rms = (rss / sum_w.max(f64::MIN_POSITIVE)).sqrt();
527 let mut coefficient_score_influence = Array2::<f64>::zeros((m, n));
528 for row in 0..n {
529 let w = weights.as_ref().map_or(1.0, |wv| wv[row]);
530 let residual = response[row] - fitted[row];
531 for j in 0..m {
532 let mut sensitivity = 0.0_f64;
533 for k in 0..m {
534 sensitivity += a_inv[[j, k]] * design[[row, k]];
535 }
536 coefficient_score_influence[[j, row]] = sensitivity * w * residual;
537 }
538 }
539
540 if beta.iter().chain(a_inv.iter()).any(|v| !v.is_finite())
541 || !(sigma2.is_finite() && sigma2 > 0.0)
542 {
543 return Err("penalized 1-D REML produced non-finite posterior moments".to_string());
544 }
545 Ok(Penalized1dFit {
546 beta,
547 covariance,
548 lambda,
549 edf,
550 sigma2,
551 residual_rms,
552 coefficient_score_influence,
553 })
554}
555
556#[derive(Debug, Clone)]
566pub struct FittedTransport {
567 pub topology_from: ChartTopology,
568 pub topology_to: ChartTopology,
569 pub degree: Option<i32>,
571 pub degree_concentration: Option<f64>,
574 pub rotation_offset: f64,
579 pub beta: Array1<f64>,
581 pub covariance: Array2<f64>,
583 pub smoothing_lambda: f64,
584 pub edf: f64,
586 pub noise_variance: f64,
588 pub n_obs: usize,
589 pub isometry_defect: f64,
591 pub isometry_defect_se: f64,
593 pub topology_preserved: bool,
596 pub min_directional_derivative: f64,
598 pub residual_rms: f64,
600 basis: DomainBasis,
601 coefficient_score_influence: Array2<f64>,
602}
603
604impl FittedTransport {
605 fn linear_slope(&self) -> f64 {
606 self.degree.map_or(0.0, f64::from)
607 }
608
609 pub fn eval(&self, t: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
611 let rows = self.basis.value_rows(t)?;
612 let smooth = rows.dot(&self.beta);
613 let slope = self.linear_slope();
614 let mut out = Array1::<f64>::zeros(t.len());
615 for i in 0..t.len() {
616 let raw = slope * t[i] + self.rotation_offset + smooth[i];
617 out[i] = match self.topology_to {
618 ChartTopology::Circle => wrap_tau(raw),
619 ChartTopology::Interval { .. } => raw,
620 };
621 }
622 Ok(out)
623 }
624
625 pub fn eval_with_variance(
627 &self,
628 t: ArrayView1<'_, f64>,
629 ) -> Result<(Array1<f64>, Array1<f64>), String> {
630 let rows = self.basis.value_rows(t)?;
631 let values = self.eval(t)?;
632 let mut variances = Array1::<f64>::zeros(t.len());
633 for i in 0..t.len() {
634 let row = rows.row(i);
635 variances[i] = row.dot(&self.covariance.dot(&row)).max(0.0);
636 }
637 Ok((values, variances))
638 }
639
640 pub fn derivative(&self, t: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
642 let rows = self.basis.derivative_rows(t)?;
643 let slope = self.linear_slope();
644 Ok(rows.dot(&self.beta).mapv(|v| v + slope))
645 }
646
647 fn eval_score_influence(&self, t: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
650 Ok(self
651 .basis
652 .value_rows(t)?
653 .dot(&self.coefficient_score_influence))
654 }
655
656 fn raw_at(&self, t: f64) -> Result<f64, String> {
660 let arr = Array1::from_elem(1, t);
661 let smooth = self.basis.value_rows(arr.view())?.dot(&self.beta)[0];
662 Ok(self.linear_slope() * t + self.rotation_offset + smooth)
663 }
664
665 fn oriented_derivative_at(&self, t: &[f64], orientation: f64) -> Result<Vec<f64>, String> {
667 let arr = Array1::from_vec(t.to_vec());
668 let rows = self.basis.derivative_rows(arr.view())?;
669 let slope = self.linear_slope();
670 Ok((0..t.len())
671 .map(|i| orientation * (rows.row(i).dot(&self.beta) + slope))
672 .collect())
673 }
674
675 fn certify_strict_monotonicity(&self) -> Result<f64, String> {
693 let (lo, hi) = match self.topology_from {
694 ChartTopology::Circle => (0.0, TAU),
695 ChartTopology::Interval { lo, hi } => (lo, hi),
696 };
697 let raw_lo = self.raw_at(lo)?;
700 let raw_hi = self.raw_at(hi)?;
701 let orientation = if raw_hi >= raw_lo { 1.0 } else { -1.0 };
702
703 let deg = self.basis.derivative_poly_degree().max(1);
704 let breaks = self.basis.derivative_breakpoints();
705 for window in breaks.windows(2) {
708 let (a, b) = (window[0], window[1]);
709 if !(b > a) {
710 continue;
711 }
712 let span = b - a;
713 let pad = span * 1.0e-9;
717 let n_nodes = deg + 1;
718 let nodes: Vec<f64> = (0..n_nodes)
719 .map(|i| {
720 let s = if n_nodes == 1 {
721 0.5
722 } else {
723 i as f64 / (n_nodes - 1) as f64
724 };
725 (a + pad) + (span - 2.0 * pad) * s
726 })
727 .collect();
728 let values = self.oriented_derivative_at(&nodes, orientation)?;
729
730 let step = if n_nodes > 1 {
736 nodes[1] - nodes[0]
737 } else {
738 span
739 };
740 let coeffs = monomial_interpolant_at_integer_nodes(&values);
741
742 let probe_t = a + 0.37 * span;
748 let probe_u = (probe_t - nodes[0]) / step;
749 let probe_recon = eval_monomial(&coeffs, probe_u);
750 let probe_actual = self.oriented_derivative_at(&[probe_t], orientation)?[0];
751 let scale = probe_actual.abs().max(1.0);
752 if (probe_recon - probe_actual).abs() > 1.0e-6 * scale {
753 return Err(format!(
754 "transport monotonicity certificate could not reconstruct h′ on the \
755 span [{a}, {b}] (reconstruction {probe_recon} vs actual {probe_actual}); \
756 refusing to certify"
757 ));
758 }
759
760 for &edge in &[a, b] {
762 let u = (edge - nodes[0]) / step;
763 let v = eval_monomial(&coeffs, u);
764 if !(v > 0.0) {
765 return Err(format!(
766 "transport map is not strictly monotone: orientation·h′ = {v} ≤ 0 at \
767 t = {edge}"
768 ));
769 }
770 }
771 for u_crit in monomial_critical_points(&coeffs) {
774 let t_crit = nodes[0] + u_crit * step;
775 if t_crit > a && t_crit < b {
776 let v = eval_monomial(&coeffs, u_crit);
777 if !(v > 0.0) {
778 return Err(format!(
779 "transport map folds: orientation·h′ = {v} ≤ 0 at interior \
780 extremum t = {t_crit}"
781 ));
782 }
783 }
784 }
785 }
786 Ok(orientation)
787 }
788
789 pub fn invert(&self, y: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
809 if y.iter().any(|v| !v.is_finite()) {
810 return Err("transport inverse targets must be finite".to_string());
811 }
812 self.certify_strict_monotonicity()?;
815 let (lo, hi) = match self.topology_from {
816 ChartTopology::Circle => (0.0, TAU),
817 ChartTopology::Interval { lo, hi } => (lo, hi),
818 };
819 let raw_lo = self.raw_at(lo)?;
822 let raw_hi = self.raw_at(hi)?;
823 let increasing = raw_hi > raw_lo;
824 let (raw_min, raw_max) = if increasing {
825 (raw_lo, raw_hi)
826 } else {
827 (raw_hi, raw_lo)
828 };
829 let scale = raw_min.abs().max(raw_max.abs()).max(1.0);
832 let tol = 32.0 * f64::EPSILON * scale;
833
834 let mut probe = Array1::<f64>::zeros(1);
837 let mut raw_at_into = |t: f64| -> Result<f64, String> {
838 probe[0] = t;
839 let smooth = self.basis.value_rows(probe.view())?.dot(&self.beta)[0];
840 Ok(self.linear_slope() * t + self.rotation_offset + smooth)
841 };
842
843 let mut out = Array1::<f64>::zeros(y.len());
844 for (idx, &yi) in y.iter().enumerate() {
845 let target = match self.topology_to {
847 ChartTopology::Interval { .. } => {
848 if yi < raw_min - tol || yi > raw_max + tol {
849 return Err(format!(
850 "transport inverse target {yi} is outside the fitted image \
851 [{raw_min}, {raw_max}]"
852 ));
853 }
854 yi.clamp(raw_min, raw_max)
855 }
856 ChartTopology::Circle => {
857 let ywrapped = wrap_tau(yi);
860 let m = ((raw_min - ywrapped) / TAU).ceil();
861 ywrapped + TAU * m
862 }
863 };
864 let (mut a, mut b) = (lo, hi);
868 let width_floor = f64::EPSILON * hi.abs().max(lo.abs()).max(1.0);
869 for _ in 0..100 {
870 if (b - a) <= width_floor {
871 break;
872 }
873 let mid = 0.5 * (a + b);
874 let rm = raw_at_into(mid)?;
875 let go_right = if increasing { rm < target } else { rm > target };
876 if go_right {
877 a = mid;
878 } else {
879 b = mid;
880 }
881 }
882 out[idx] = 0.5 * (a + b);
883 }
884 Ok(out)
885 }
886
887 pub fn report(&self, layer_from: usize, layer_to: usize) -> LayerTransportReport {
890 LayerTransportReport {
891 layer_from,
892 layer_to,
893 topology_from: self.topology_from,
894 topology_to: self.topology_to,
895 topology_preserved: self.topology_preserved,
896 degree: self.degree,
897 degree_concentration: self.degree_concentration,
898 rotation_offset: self.rotation_offset,
899 isometry_defect: self.isometry_defect,
900 isometry_defect_se: self.isometry_defect_se,
901 min_directional_derivative: self.min_directional_derivative,
902 transport_edf: self.edf,
903 smoothing_lambda: self.smoothing_lambda,
904 noise_variance: self.noise_variance,
905 residual_rms: self.residual_rms,
906 n_obs: self.n_obs,
907 composition_defect: None,
908 composition_max_studentized: None,
909 composition_p_value: None,
910 composition_gauge_reflected: None,
911 }
912 }
913}
914
915#[derive(Debug, Clone)]
917pub struct LayerTransportReport {
918 pub layer_from: usize,
919 pub layer_to: usize,
920 pub topology_from: ChartTopology,
921 pub topology_to: ChartTopology,
922 pub topology_preserved: bool,
924 pub degree: Option<i32>,
926 pub degree_concentration: Option<f64>,
928 pub rotation_offset: f64,
930 pub isometry_defect: f64,
932 pub isometry_defect_se: f64,
934 pub min_directional_derivative: f64,
936 pub transport_edf: f64,
938 pub smoothing_lambda: f64,
939 pub noise_variance: f64,
940 pub residual_rms: f64,
941 pub n_obs: usize,
942 pub composition_defect: Option<f64>,
945 pub composition_max_studentized: Option<f64>,
947 pub composition_p_value: Option<f64>,
950 pub composition_gauge_reflected: Option<bool>,
953}
954
955impl LayerTransportReport {
956 pub fn with_composition(mut self, composition: &CompositionDefectReport) -> Self {
958 self.composition_defect = Some(composition.rms_defect);
959 self.composition_max_studentized = Some(composition.max_studentized_defect);
960 self.composition_p_value = composition
961 .p_value
962 .is_finite()
963 .then_some(composition.p_value);
964 self.composition_gauge_reflected = Some(composition.gauge_reflected);
965 self
966 }
967}
968
969pub fn fit_transport_map(
976 coords_from: ArrayView1<'_, f64>,
977 coords_to: ArrayView1<'_, f64>,
978 topology_from: ChartTopology,
979 topology_to: ChartTopology,
980) -> Result<FittedTransport, String> {
981 let n = coords_from.len();
982 if coords_to.len() != n {
983 return Err(format!(
984 "layer transport coordinate lengths disagree: {} vs {}",
985 n,
986 coords_to.len()
987 ));
988 }
989 if n < MIN_TRANSPORT_OBS {
990 return Err(format!(
991 "layer transport needs at least {MIN_TRANSPORT_OBS} paired observations, got {n}"
992 ));
993 }
994 if coords_from
995 .iter()
996 .chain(coords_to.iter())
997 .any(|v| !v.is_finite())
998 {
999 return Err("layer transport coordinates must all be finite".to_string());
1000 }
1001 topology_from.validate()?;
1002 topology_to.validate()?;
1003
1004 let (degree, degree_concentration, rotation_offset, response): (
1006 Option<i32>,
1007 Option<f64>,
1008 f64,
1009 Array1<f64>,
1010 ) = match (topology_from, topology_to) {
1011 (ChartTopology::Circle, ChartTopology::Circle) => {
1012 let mut best_degree = DEGREE_CANDIDATES[0];
1019 let mut best_r = f64::NEG_INFINITY;
1020 for &d in DEGREE_CANDIDATES.iter() {
1021 let residual: Vec<f64> = (0..n)
1022 .map(|i| coords_to[i] - f64::from(d) * coords_from[i])
1023 .collect();
1024 let r = resultant_length(&residual);
1025 if r > best_r {
1026 best_r = r;
1027 best_degree = d;
1028 }
1029 }
1030 let residual: Vec<f64> = (0..n)
1031 .map(|i| coords_to[i] - f64::from(best_degree) * coords_from[i])
1032 .collect();
1033 let mu = circular_mean(&residual);
1034 let response = Array1::from_iter(residual.iter().map(|&r| wrap_pi(r - mu)));
1035 (Some(best_degree), Some(best_r), mu, response)
1036 }
1037 (_, ChartTopology::Circle) => {
1038 let angles: Vec<f64> = coords_to.iter().copied().collect();
1042 let mu = circular_mean(&angles);
1043 let response = Array1::from_iter(angles.iter().map(|&a| wrap_pi(a - mu)));
1044 (None, None, mu, response)
1045 }
1046 (_, ChartTopology::Interval { .. }) => (None, None, 0.0, coords_to.to_owned()),
1047 };
1048
1049 let basis = DomainBasis::build(topology_from, coords_from)?;
1051 let design = basis.value_rows(coords_from)?;
1052 let penalty = basis.penalty()?;
1053 let fit = fit_penalized_1d(
1054 &design,
1055 &penalty,
1056 response.view(),
1057 None,
1058 basis.penalty_rank(),
1059 )?;
1060
1061 let slope = degree.map_or(0.0, f64::from);
1063 let deriv_rows = basis.derivative_rows(coords_from)?;
1064 let deriv = deriv_rows.dot(&fit.beta).mapv(|v| v + slope);
1065 let m = basis.num_basis();
1066 let mut defect = 0.0_f64;
1067 let mut grad = Array1::<f64>::zeros(m);
1068 for i in 0..n {
1069 let speed = deriv[i].abs();
1070 let gap = speed - 1.0;
1071 defect += gap * gap;
1072 let sgn = if deriv[i] >= 0.0 { 1.0 } else { -1.0 };
1073 for j in 0..m {
1074 grad[j] += 2.0 * gap * sgn * deriv_rows[[i, j]];
1075 }
1076 }
1077 defect /= n as f64;
1078 grad.mapv_inplace(|v| v / n as f64);
1079 let isometry_defect_se = grad.dot(&fit.covariance.dot(&grad)).max(0.0).sqrt();
1080
1081 let grid = domain_grid(topology_from, FOLD_CHECK_GRID);
1083 let grid_deriv = basis
1084 .derivative_rows(grid.view())?
1085 .dot(&fit.beta)
1086 .mapv(|v| v + slope);
1087 let orientation = if slope != 0.0 {
1088 slope.signum()
1089 } else {
1090 let mean = grid_deriv.iter().sum::<f64>() / grid_deriv.len() as f64;
1091 if mean < 0.0 { -1.0 } else { 1.0 }
1092 };
1093 let min_directional_derivative = grid_deriv
1094 .iter()
1095 .map(|&v| orientation * v)
1096 .fold(f64::INFINITY, f64::min);
1097 let topology_preserved = match (topology_from, topology_to) {
1098 (ChartTopology::Circle, ChartTopology::Circle) => {
1099 matches!(degree, Some(1) | Some(-1)) && min_directional_derivative > 0.0
1100 }
1101 (ChartTopology::Interval { .. }, ChartTopology::Interval { .. }) => {
1102 min_directional_derivative > 0.0
1103 }
1104 _ => false,
1105 };
1106
1107 Ok(FittedTransport {
1108 topology_from,
1109 topology_to,
1110 degree,
1111 degree_concentration,
1112 rotation_offset,
1113 beta: fit.beta,
1114 covariance: fit.covariance,
1115 smoothing_lambda: fit.lambda,
1116 edf: fit.edf,
1117 noise_variance: fit.sigma2,
1118 n_obs: n,
1119 isometry_defect: defect,
1120 isometry_defect_se,
1121 topology_preserved,
1122 min_directional_derivative,
1123 residual_rms: fit.residual_rms,
1124 basis,
1125 coefficient_score_influence: fit.coefficient_score_influence,
1126 })
1127}
1128
1129pub fn fit_layer_transport(
1131 layer_from: usize,
1132 layer_to: usize,
1133 coords_from: ArrayView1<'_, f64>,
1134 coords_to: ArrayView1<'_, f64>,
1135 topology_from: ChartTopology,
1136 topology_to: ChartTopology,
1137) -> Result<LayerTransportReport, String> {
1138 Ok(
1139 fit_transport_map(coords_from, coords_to, topology_from, topology_to)?
1140 .report(layer_from, layer_to),
1141 )
1142}
1143
1144#[derive(Debug, Clone)]
1146pub struct CompositionDefectReport {
1147 pub n_grid: usize,
1148 pub gauge_rotation: f64,
1150 pub gauge_reflected: bool,
1152 pub mean_abs_defect: f64,
1153 pub rms_defect: f64,
1154 pub max_abs_defect: f64,
1155 pub max_studentized_defect: f64,
1157 pub max_studentized_p_value: f64,
1160 pub p_value: f64,
1163}
1164
1165fn monomial_interpolant_at_integer_nodes(values: &[f64]) -> Vec<f64> {
1170 let n = values.len();
1171 if n == 0 {
1172 return Vec::new();
1173 }
1174 let mut coeffs = vec![0.0_f64; n];
1175 for (i, &value) in values.iter().enumerate() {
1176 let mut basis = vec![1.0_f64];
1177 let mut denominator = 1.0_f64;
1178 for j in 0..n {
1179 if j == i {
1180 continue;
1181 }
1182 denominator *= i as f64 - j as f64;
1183 let mut expanded = vec![0.0_f64; basis.len() + 1];
1184 for (degree, &coefficient) in basis.iter().enumerate() {
1185 expanded[degree] -= j as f64 * coefficient;
1186 expanded[degree + 1] += coefficient;
1187 }
1188 basis = expanded;
1189 }
1190 let scale = value / denominator;
1191 for (degree, coefficient) in basis.into_iter().enumerate() {
1192 coeffs[degree] += scale * coefficient;
1193 }
1194 }
1195 coeffs
1196}
1197
1198fn eval_monomial(coeffs: &[f64], u: f64) -> f64 {
1200 coeffs.iter().rev().fold(0.0_f64, |acc, &c| acc * u + c)
1201}
1202
1203fn monomial_critical_points(coeffs: &[f64]) -> Vec<f64> {
1212 let n = coeffs.len();
1214 if n <= 1 {
1215 return Vec::new();
1216 }
1217 let deriv: Vec<f64> = (1..n).map(|k| k as f64 * coeffs[k]).collect();
1218 match deriv.len() {
1220 0 => Vec::new(),
1221 1 => Vec::new(), 2 => {
1223 let (b, a) = (deriv[0], deriv[1]);
1225 if a.abs() <= f64::MIN_POSITIVE {
1226 Vec::new()
1227 } else {
1228 vec![-b / a]
1229 }
1230 }
1231 3 => {
1232 let (c, b, a) = (deriv[0], deriv[1], deriv[2]);
1234 if a.abs() <= f64::MIN_POSITIVE {
1235 if b.abs() <= f64::MIN_POSITIVE {
1236 Vec::new()
1237 } else {
1238 vec![-c / b]
1239 }
1240 } else {
1241 let disc = b * b - 4.0 * a * c;
1242 if disc < 0.0 {
1243 Vec::new()
1244 } else {
1245 let s = disc.sqrt();
1246 vec![(-b + s) / (2.0 * a), (-b - s) / (2.0 * a)]
1247 }
1248 }
1249 }
1250 _ => {
1251 let lo = 0.0;
1254 let hi = (coeffs.len() - 1) as f64;
1255 let steps = 256;
1256 let mut roots = Vec::new();
1257 let f = |u: f64| eval_monomial(&deriv, u);
1258 let mut prev_u = lo;
1259 let mut prev_v = f(lo);
1260 for i in 1..=steps {
1261 let u = lo + (hi - lo) * i as f64 / steps as f64;
1262 let v = f(u);
1263 if prev_v == 0.0 {
1264 roots.push(prev_u);
1265 } else if prev_v * v < 0.0 {
1266 let (mut a, mut b) = (prev_u, u);
1267 for _ in 0..60 {
1268 let m = 0.5 * (a + b);
1269 if f(a) * f(m) <= 0.0 {
1270 b = m;
1271 } else {
1272 a = m;
1273 }
1274 }
1275 roots.push(0.5 * (a + b));
1276 }
1277 prev_u = u;
1278 prev_v = v;
1279 }
1280 roots
1281 }
1282 }
1283}
1284
1285fn domain_grid(topology: ChartTopology, n: usize) -> Array1<f64> {
1287 match topology {
1288 ChartTopology::Circle => Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64)),
1289 ChartTopology::Interval { lo, hi } => {
1290 Array1::from_iter((0..n).map(|i| lo + (hi - lo) * i as f64 / (n - 1).max(1) as f64))
1291 }
1292 }
1293}
1294
1295pub fn composition_defect(
1305 h_ab: &FittedTransport,
1306 h_bc: &FittedTransport,
1307 h_ac: &FittedTransport,
1308 n_grid: usize,
1309) -> Result<CompositionDefectReport, String> {
1310 if h_ab.topology_from != h_ac.topology_from
1311 || h_ab.topology_to != h_bc.topology_from
1312 || h_bc.topology_to != h_ac.topology_to
1313 {
1314 return Err("composition defect requires chart-compatible transports: \
1315 h_ab: A→B, h_bc: B→C, h_ac: A→C"
1316 .to_string());
1317 }
1318 if n_grid < MIN_TRANSPORT_OBS {
1319 return Err(format!(
1320 "composition defect grid must have at least {MIN_TRANSPORT_OBS} points, got {n_grid}"
1321 ));
1322 }
1323 if h_ab.n_obs != h_bc.n_obs || h_ab.n_obs != h_ac.n_obs {
1324 return Err(format!(
1325 "composition defect requires maps fitted on the same rows; got n_ab={}, n_bc={}, n_ac={}",
1326 h_ab.n_obs, h_bc.n_obs, h_ac.n_obs
1327 ));
1328 }
1329
1330 let grid = domain_grid(h_ab.topology_from, n_grid);
1331 let direct = h_ac.eval(grid.view())?;
1332 let mid = h_ab.eval(grid.view())?;
1333 let composed = h_bc.eval(mid.view())?;
1334 let mid_slope = h_bc.derivative(mid.view())?;
1335
1336 let influence_direct = h_ac.eval_score_influence(grid.view())?;
1342 let influence_ab = h_ab.eval_score_influence(grid.view())?;
1343 let influence_bc = h_bc.eval_score_influence(mid.view())?;
1344 let mut variance = Array1::<f64>::zeros(n_grid);
1345 for i in 0..n_grid {
1346 let mut value = 0.0_f64;
1347 for row in 0..h_ab.n_obs {
1348 let influence = influence_direct[[i, row]]
1349 - influence_bc[[i, row]]
1350 - mid_slope[i] * influence_ab[[i, row]];
1351 value += influence * influence;
1352 }
1353 variance[i] = value;
1354 }
1355
1356 let circle_target = matches!(h_ac.topology_to, ChartTopology::Circle);
1361 let defect = Array1::from_iter((0..n_grid).map(|i| {
1362 if circle_target {
1363 wrap_pi(direct[i] - composed[i])
1364 } else {
1365 direct[i] - composed[i]
1366 }
1367 }));
1368
1369 let mut calibrated_variance = variance;
1387 for i in 0..n_grid {
1388 let approximation_sd =
1389 h_ac.residual_rms + h_bc.residual_rms + mid_slope[i].abs() * h_ab.residual_rms;
1390 calibrated_variance[i] = calibrated_variance[i].max(approximation_sd * approximation_sd);
1391 }
1392 let max_var = calibrated_variance.iter().copied().fold(0.0_f64, f64::max);
1393 let var_floor = (max_var * 1e-12).max(f64::MIN_POSITIVE);
1394 let mut max_abs = 0.0_f64;
1395 let mut sum_abs = 0.0_f64;
1396 let mut sum_sq = 0.0_f64;
1397 let mut max_z = 0.0_f64;
1398 for i in 0..n_grid {
1399 let d = defect[i];
1400 let a = d.abs();
1401 max_abs = max_abs.max(a);
1402 sum_abs += a;
1403 sum_sq += d * d;
1404 if max_var > 0.0 {
1405 let z = a / calibrated_variance[i].max(var_floor).sqrt();
1406 max_z = max_z.max(z);
1407 }
1408 }
1409 let mean_abs_defect = sum_abs / n_grid as f64;
1410 let rms_defect = (sum_sq / n_grid as f64).sqrt();
1411
1412 let max_studentized_p_value = if max_var > 0.0 {
1418 let pointwise = normal_two_sided_probability(max_z);
1419 (n_grid as f64 * pointwise).min(1.0)
1420 } else {
1421 f64::NAN
1422 };
1423
1424 Ok(CompositionDefectReport {
1425 n_grid,
1426 gauge_rotation: 0.0,
1427 gauge_reflected: false,
1428 mean_abs_defect,
1429 rms_defect,
1430 max_abs_defect: max_abs,
1431 max_studentized_defect: if max_var > 0.0 { max_z } else { f64::NAN },
1432 max_studentized_p_value,
1433 p_value: max_studentized_p_value,
1434 })
1435}
1436
1437#[derive(Debug, Clone)]
1440pub struct TransportLadderReport {
1441 pub adjacent: Vec<LayerTransportReport>,
1443 pub two_hop: Vec<LayerTransportReport>,
1446 pub circle_transports: Vec<crate::inference::transport_class::CircleTransportReport>,
1451}
1452
1453pub fn transport_ladder(
1459 layers: &[usize],
1460 coords: &[Array1<f64>],
1461 topologies: &[ChartTopology],
1462) -> Result<TransportLadderReport, String> {
1463 let depth = layers.len();
1464 if coords.len() != depth || topologies.len() != depth {
1465 return Err(format!(
1466 "transport ladder inputs disagree: {depth} layers, {} coordinate vectors, {} topologies",
1467 coords.len(),
1468 topologies.len()
1469 ));
1470 }
1471 if depth < 2 {
1472 return Err("transport ladder needs at least two layers".to_string());
1473 }
1474
1475 let mut adjacent_fits: Vec<FittedTransport> = Vec::with_capacity(depth - 1);
1476 let mut adjacent: Vec<LayerTransportReport> = Vec::with_capacity(depth - 1);
1477 for k in 0..depth - 1 {
1478 let fit = fit_transport_map(
1479 coords[k].view(),
1480 coords[k + 1].view(),
1481 topologies[k],
1482 topologies[k + 1],
1483 )
1484 .map_err(|e| {
1485 format!(
1486 "adjacent transport {}→{} failed: {e}",
1487 layers[k],
1488 layers[k + 1]
1489 )
1490 })?;
1491 adjacent.push(fit.report(layers[k], layers[k + 1]));
1492 adjacent_fits.push(fit);
1493 }
1494
1495 let mut two_hop: Vec<LayerTransportReport> = Vec::with_capacity(depth.saturating_sub(2));
1496 for k in 0..depth.saturating_sub(2) {
1497 let direct = fit_transport_map(
1498 coords[k].view(),
1499 coords[k + 2].view(),
1500 topologies[k],
1501 topologies[k + 2],
1502 )
1503 .map_err(|e| {
1504 format!(
1505 "two-hop transport {}→{} failed: {e}",
1506 layers[k],
1507 layers[k + 2]
1508 )
1509 })?;
1510 let composition = composition_defect(
1511 &adjacent_fits[k],
1512 &adjacent_fits[k + 1],
1513 &direct,
1514 DEFAULT_COMPOSITION_GRID,
1515 )
1516 .map_err(|e| {
1517 format!(
1518 "composition test {}→{}→{} failed: {e}",
1519 layers[k],
1520 layers[k + 1],
1521 layers[k + 2]
1522 )
1523 })?;
1524 two_hop.push(
1525 direct
1526 .report(layers[k], layers[k + 2])
1527 .with_composition(&composition),
1528 );
1529 }
1530
1531 let mut circle_transports = Vec::new();
1535 for k in 0..depth - 1 {
1536 if let Some(report) = crate::inference::transport_class::classify_circle_transport_fit(
1537 &adjacent_fits[k],
1538 topologies[k],
1539 topologies[k + 1],
1540 layers[k],
1541 layers[k + 1],
1542 DEFAULT_COMPOSITION_GRID,
1543 ) {
1544 circle_transports.push(report);
1545 }
1546 }
1547
1548 Ok(TransportLadderReport {
1549 adjacent,
1550 two_hop,
1551 circle_transports,
1552 })
1553}
1554
1555#[cfg(test)]
1556mod invert_tests {
1557 use super::*;
1558 use faer::Side;
1559 use gam_linalg::faer_ndarray::FaerEigh;
1560 use ndarray::Array1;
1561
1562 fn interval(lo: f64, hi: f64) -> ChartTopology {
1563 ChartTopology::Interval { lo, hi }
1564 }
1565
1566 #[test]
1570 fn penalized_1d_covariance_and_scale_match_reml_system() {
1571 let n = 32;
1572 let design = Array2::from_shape_fn((n, 3), |(row, col)| {
1573 let x = row as f64 / (n - 1) as f64;
1574 match col {
1575 0 => 1.0,
1576 1 => x,
1577 2 => x * x,
1578 _ => unreachable!(),
1579 }
1580 });
1581 let response = Array1::from_shape_fn(n, |row| {
1582 let x = row as f64 / (n - 1) as f64;
1583 0.3 + 0.8 * x + 0.2 * (TAU * x).sin()
1584 });
1585 let mut penalty = Array2::<f64>::zeros((3, 3));
1586 penalty[[2, 2]] = 1.0;
1587 let fit = fit_penalized_1d(&design, &penalty, response.view(), None, 1)
1588 .expect("certified REML fit");
1589
1590 let mut penalized_gram = design.t().dot(&design);
1591 penalized_gram[[2, 2]] += fit.lambda;
1592 let inverse = fit.covariance.mapv(|value| value / fit.sigma2);
1593 let identity = penalized_gram.dot(&inverse);
1594 for row in 0..3 {
1595 for col in 0..3 {
1596 let expected = f64::from(u8::from(row == col));
1597 assert!(
1598 (identity[[row, col]] - expected).abs() < 1.0e-9,
1599 "penalized inverse mismatch at ({row}, {col}): {}",
1600 identity[[row, col]],
1601 );
1602 }
1603 }
1604
1605 let xtwy = design.t().dot(&response);
1606 let prss = response.dot(&response) - fit.beta.dot(&xtwy);
1607 let expected_sigma2 = prss / (n - 2) as f64;
1608 assert!(
1609 (fit.sigma2 - expected_sigma2).abs() <= 1.0e-11 * expected_sigma2.abs().max(1.0),
1610 "REML scale mismatch: fitted {}, expected {expected_sigma2}",
1611 fit.sigma2,
1612 );
1613 }
1614
1615 #[test]
1616 fn invert_round_trips_interval_transport() {
1617 let n = 64;
1621 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1622 let to: Array1<f64> = from.mapv(|t| t + 0.25 * (TAU * t).sin() / TAU);
1623 let ft = fit_transport_map(
1624 from.view(),
1625 to.view(),
1626 interval(0.0, 1.0),
1627 interval(0.0, 1.0),
1628 )
1629 .expect("fit");
1630 assert!(
1631 ft.topology_preserved,
1632 "monotone warp should preserve topology"
1633 );
1634
1635 let probe = Array1::from_iter((1..10).map(|i| i as f64 / 10.0));
1636 let fwd = ft.eval(probe.view()).expect("eval");
1638 let back = ft.invert(fwd.view()).expect("invert");
1639 for i in 0..probe.len() {
1640 assert!(
1641 (back[i] - probe[i]).abs() < 1e-6,
1642 "round-trip failed: t={} back={}",
1643 probe[i],
1644 back[i]
1645 );
1646 }
1647 let re_eval = ft.eval(back.view()).expect("eval");
1648 for i in 0..fwd.len() {
1649 assert!((re_eval[i] - fwd[i]).abs() < 1e-9);
1650 }
1651 }
1652
1653 #[test]
1654 fn invert_round_trips_decreasing_interval_transport() {
1655 let n = 64;
1658 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1659 let to: Array1<f64> = from.mapv(|t| 1.0 - 0.5 * t - 0.5 * t * t);
1660 let ft = fit_transport_map(
1661 from.view(),
1662 to.view(),
1663 interval(0.0, 1.0),
1664 interval(0.0, 1.0),
1665 )
1666 .expect("fit");
1667 assert!(ft.topology_preserved);
1668 let probe = Array1::from_iter((1..10).map(|i| i as f64 / 10.0));
1669 let fwd = ft.eval(probe.view()).expect("eval");
1670 let back = ft.invert(fwd.view()).expect("invert");
1671 for i in 0..probe.len() {
1672 assert!(
1673 (back[i] - probe[i]).abs() < 1e-6,
1674 "t={} back={}",
1675 probe[i],
1676 back[i]
1677 );
1678 }
1679 }
1680
1681 #[test]
1682 fn invert_round_trips_circle_transport() {
1683 let n = 128;
1685 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1686 let to: Array1<f64> = from.mapv(|t| wrap_tau(t + 0.3 + 0.2 * t.sin()));
1687 let ft = fit_transport_map(
1688 from.view(),
1689 to.view(),
1690 ChartTopology::Circle,
1691 ChartTopology::Circle,
1692 )
1693 .expect("fit");
1694 assert!(ft.topology_preserved, "degree {:?}", ft.degree);
1695
1696 let probe = Array1::from_iter((0..7).map(|i| TAU * (i as f64 + 0.5) / 7.0));
1697 let fwd = ft.eval(probe.view()).expect("eval");
1698 let back = ft.invert(fwd.view()).expect("invert");
1699 for i in 0..probe.len() {
1700 let d = wrap_pi(back[i] - probe[i]).abs();
1702 assert!(d < 1e-5, "probe={} back={} d={}", probe[i], back[i], d);
1703 }
1704 }
1705
1706 #[test]
1707 fn invert_rejects_target_outside_interval_image() {
1708 let n = 32;
1710 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1711 let to: Array1<f64> = from.mapv(|t| 0.5 * t);
1712 let ft = fit_transport_map(
1713 from.view(),
1714 to.view(),
1715 interval(0.0, 1.0),
1716 interval(0.0, 1.0),
1717 )
1718 .expect("fit");
1719 assert!(ft.invert(Array1::from_elem(1, 0.9).view()).is_err());
1720 }
1721
1722 fn fitted_from_target(
1728 from: ArrayView1<'_, f64>,
1729 target: ArrayView1<'_, f64>,
1730 lo: f64,
1731 hi: f64,
1732 ) -> FittedTransport {
1733 let basis = DomainBasis::build(interval(lo, hi), from).expect("basis");
1734 let design = basis.value_rows(from).expect("design");
1735 let m = design.ncols();
1736 let mut xtx = design.t().dot(&design);
1738 let xty = design.t().dot(&target);
1739 let diag = (0..m).map(|i| xtx[[i, i]].abs()).fold(1.0_f64, f64::max);
1740 for i in 0..m {
1741 xtx[[i, i]] += 1e-10 * diag;
1742 }
1743 let (evals, evecs) = xtx.eigh(Side::Lower).expect("eigh");
1744 let rotated = evecs.t().dot(&xty);
1745 let mut beta = Array1::<f64>::zeros(m);
1746 for i in 0..m {
1747 let d = evals[i].max(f64::MIN_POSITIVE);
1748 let c = rotated[i] / d;
1749 for j in 0..m {
1750 beta[j] += evecs[[j, i]] * c;
1751 }
1752 }
1753 FittedTransport {
1754 topology_from: interval(lo, hi),
1755 topology_to: interval(lo, hi),
1756 degree: None,
1757 degree_concentration: None,
1758 rotation_offset: 0.0,
1759 beta,
1760 covariance: Array2::<f64>::zeros((m, m)),
1761 smoothing_lambda: 0.0,
1762 edf: 0.0,
1763 noise_variance: 1.0,
1764 n_obs: from.len(),
1765 isometry_defect: 0.0,
1766 isometry_defect_se: 0.0,
1767 topology_preserved: true,
1768 min_directional_derivative: 1.0,
1769 residual_rms: 0.0,
1770 coefficient_score_influence: Array2::<f64>::zeros((m, from.len())),
1771 basis,
1772 }
1773 }
1774
1775 #[test]
1781 fn invert_rejects_between_grid_fold() {
1782 let n = 256;
1783 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1784 let eps = 0.4 / 511.0;
1785 let target: Array1<f64> = from.mapv(|t| (t - 0.5).powi(3) / 3.0 - eps * eps * t);
1786 let mut ft = fitted_from_target(from.view(), target.view(), 0.0, 1.0);
1787
1788 let grid = domain_grid(interval(0.0, 1.0), FOLD_CHECK_GRID);
1791 let grid_d = ft.derivative(grid.view()).expect("grid deriv");
1792 let mean = grid_d.iter().sum::<f64>() / grid_d.len() as f64;
1793 let orientation = if mean < 0.0 { -1.0 } else { 1.0 };
1794 let min_grid = grid_d
1795 .iter()
1796 .map(|&v| orientation * v)
1797 .fold(f64::INFINITY, f64::min);
1798 let dense = Array1::from_iter((0..5120).map(|i| i as f64 / 5119.0));
1800 let dense_d = ft.derivative(dense.view()).expect("dense deriv");
1801 let min_dense = dense_d
1802 .iter()
1803 .map(|&v| orientation * v)
1804 .fold(f64::INFINITY, f64::min);
1805 ft.topology_preserved = min_grid > 0.0;
1806 ft.min_directional_derivative = min_grid;
1807 assert!(
1808 min_grid > 0.0 && min_dense < 0.0,
1809 "fixture must hide a between-grid fold: min on 512-grid={min_grid}, \
1810 min on dense grid={min_dense}"
1811 );
1812
1813 let res = ft.invert(Array1::from_elem(1, 0.0).view());
1816 assert!(
1817 res.is_err(),
1818 "between-grid fold must be rejected by the span-exact certificate \
1819 (topology_preserved={}, min_grid={min_grid}, min_dense={min_dense})",
1820 ft.topology_preserved
1821 );
1822 }
1823
1824 #[test]
1825 fn invert_rejects_non_finite_targets() {
1826 let n = 64;
1827 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1828 let to: Array1<f64> = from.mapv(|t| 0.5 * t);
1829 let ft = fit_transport_map(
1830 from.view(),
1831 to.view(),
1832 interval(0.0, 1.0),
1833 interval(0.0, 1.0),
1834 )
1835 .expect("fit");
1836 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1837 assert!(
1838 ft.invert(Array1::from_elem(1, bad).view()).is_err(),
1839 "non-finite target {bad} must be rejected"
1840 );
1841 }
1842 }
1843
1844 #[test]
1845 fn invert_image_tolerance_is_scale_aware() {
1846 let n = 64;
1850 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1851 let scale = 1.0e-8;
1852 let to: Array1<f64> = from.mapv(|t| scale * t);
1853 let ft = fit_transport_map(
1854 from.view(),
1855 to.view(),
1856 interval(0.0, 1.0),
1857 interval(0.0, 1.0),
1858 )
1859 .expect("fit");
1860 let outside = 1.05e-8;
1861 assert!(
1862 ft.invert(Array1::from_elem(1, outside).view()).is_err(),
1863 "target {outside} is 5% outside the [0, {scale}] image and must be rejected"
1864 );
1865 let inside = 0.5e-8;
1867 let t = ft
1868 .invert(Array1::from_elem(1, inside).view())
1869 .expect("invert inside");
1870 let re = ft.eval(t.view()).expect("eval");
1871 assert!((re[0] - inside).abs() < 1e-3 * scale);
1872 }
1873
1874 #[test]
1875 fn invert_round_trips_degree_minus_one_circle() {
1876 let n = 128;
1879 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1880 let to: Array1<f64> = from.mapv(|t| wrap_tau(-t + 0.4 + 0.15 * t.sin()));
1881 let ft = fit_transport_map(
1882 from.view(),
1883 to.view(),
1884 ChartTopology::Circle,
1885 ChartTopology::Circle,
1886 )
1887 .expect("fit");
1888 assert_eq!(ft.degree, Some(-1), "expected a degree −1 cover");
1889 assert!(ft.topology_preserved, "degree {:?}", ft.degree);
1890 let probe = Array1::from_iter((0..7).map(|i| TAU * (i as f64 + 0.5) / 7.0));
1891 let fwd = ft.eval(probe.view()).expect("eval");
1892 let back = ft.invert(fwd.view()).expect("invert");
1893 for i in 0..probe.len() {
1894 let d = wrap_pi(back[i] - probe[i]).abs();
1895 assert!(d < 1e-5, "probe={} back={} d={}", probe[i], back[i], d);
1896 }
1897 }
1898
1899 #[test]
1900 fn invert_round_trips_circle_seam_and_interval_endpoints() {
1901 let n = 128;
1903 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1904 let to: Array1<f64> = from.mapv(|t| wrap_tau(t + 0.3 + 0.2 * t.sin()));
1905 let ft = fit_transport_map(
1906 from.view(),
1907 to.view(),
1908 ChartTopology::Circle,
1909 ChartTopology::Circle,
1910 )
1911 .expect("fit");
1912 assert!(ft.topology_preserved);
1913 for seam in [1e-9, TAU - 1e-9, 0.0] {
1914 let t = ft
1915 .invert(Array1::from_elem(1, seam).view())
1916 .expect("invert seam");
1917 let re = ft.eval(t.view()).expect("eval");
1918 let d = wrap_pi(re[0] - wrap_tau(seam)).abs();
1919 assert!(d < 1e-6, "seam={seam} re={} d={d}", re[0]);
1920 }
1921
1922 let m = 64;
1924 let ifrom: Array1<f64> = Array1::from_iter((0..m).map(|i| i as f64 / (m as f64 - 1.0)));
1925 let ito: Array1<f64> = ifrom.mapv(|t| t + 0.25 * (TAU * t).sin() / TAU);
1926 let ift = fit_transport_map(
1927 ifrom.view(),
1928 ito.view(),
1929 interval(0.0, 1.0),
1930 interval(0.0, 1.0),
1931 )
1932 .expect("fit");
1933 let raw_lo = ift.raw_at(0.0).expect("raw lo");
1934 let raw_hi = ift.raw_at(1.0).expect("raw hi");
1935 for &edge in &[raw_lo, raw_hi] {
1936 let t = ift
1937 .invert(Array1::from_elem(1, edge).view())
1938 .expect("invert endpoint");
1939 assert!(t[0] >= -1e-9 && t[0] <= 1.0 + 1e-9, "endpoint t={}", t[0]);
1940 let re = ift.eval(t.view()).expect("eval");
1941 assert!((re[0] - edge).abs() < 1e-6, "edge={edge} re={}", re[0]);
1942 }
1943 }
1944
1945 #[test]
1946 fn monomial_reconstruction_is_exact_for_quadratic() {
1947 let coeffs_true = [0.7_f64, -1.3, 2.1]; let values: Vec<f64> = (0..3)
1951 .map(|i| eval_monomial(&coeffs_true, i as f64))
1952 .collect();
1953 let recon = monomial_interpolant_at_integer_nodes(&values);
1954 for (a, b) in recon.iter().zip(coeffs_true.iter()) {
1955 assert!((a - b).abs() < 1e-12, "recon {a} vs {b}");
1956 }
1957 let crit = monomial_critical_points(&recon);
1959 assert_eq!(crit.len(), 1);
1960 assert!((crit[0] - 1.3 / 4.2).abs() < 1e-12);
1961 }
1962}