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