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 if stationary.hit_resolution_floor {
462 return Err(format!(
463 "penalized 1-D REML is underresolved: the stationary-point enclosure reached its \
464 resolution floor ({} isolated roots, selected log-lambda {}, endpoint costs {:?})",
465 stationary.roots.len(),
466 stationary.selected_rho,
467 stationary.endpoint_costs,
468 ));
469 }
470 let reml = gaussian_reml_closed_form_with_nullspace_dim(
471 design.view(),
472 response.view(),
473 penalty.view(),
474 Some(nullspace_dim),
475 weight_view(),
476 None,
477 )
478 .map_err(|error| format!("penalized 1-D Gaussian REML failed: {error}"))?;
479 if reml.rho.to_bits() != stationary.selected_rho.to_bits() {
480 return Err(format!(
481 "penalized 1-D REML selection drifted between its certificate ({}) and fit ({})",
482 stationary.selected_rho, reml.rho,
483 ));
484 }
485
486 let lambda = reml.lambda;
492 let coefficient_basis = &reml.cache.coefficient_basis;
493 let penalty_eigenvalues = &reml.cache.penalty_eigenvalues;
494 if coefficient_basis.dim() != (m, m) || penalty_eigenvalues.len() != m {
495 return Err(format!(
496 "penalized 1-D REML cache shape drift: basis is {}x{}, spectrum has {}, expected {m}",
497 coefficient_basis.nrows(),
498 coefficient_basis.ncols(),
499 penalty_eigenvalues.len(),
500 ));
501 }
502 let mut a_inv = Array2::<f64>::zeros((m, m));
503 for i in 0..m {
504 let delta = penalty_eigenvalues[i];
505 let denominator = 1.0 + lambda * delta;
506 if !(delta.is_finite() && delta >= 0.0 && denominator.is_finite() && denominator > 0.0) {
507 return Err(format!(
508 "penalized 1-D REML cache has invalid mode {i}: delta={delta}, denominator={denominator}"
509 ));
510 }
511 let inverse_denominator = denominator.recip();
512 for j in 0..m {
513 let scaled_basis = coefficient_basis[[j, i]] * inverse_denominator;
514 for k in 0..m {
515 a_inv[[j, k]] += scaled_basis * coefficient_basis[[k, i]];
516 }
517 }
518 }
519
520 let beta = reml.coefficients;
521 let fitted = reml.fitted;
522 let edf = reml.edf;
523 let sigma2 = reml.sigma2;
524 let mut rss = 0.0_f64;
525 for r in 0..n {
526 let w = weights.as_ref().map_or(1.0, |wv| wv[r]);
527 let e = response[r] - fitted[r];
528 rss += w * e * e;
529 }
530 let covariance = a_inv.mapv(|v| v * sigma2);
531 let sum_w = weights
532 .as_ref()
533 .map_or(n as f64, |wv| wv.iter().copied().sum());
534 let residual_rms = (rss / sum_w.max(f64::MIN_POSITIVE)).sqrt();
535 let mut coefficient_score_influence = Array2::<f64>::zeros((m, n));
536 for row in 0..n {
537 let w = weights.as_ref().map_or(1.0, |wv| wv[row]);
538 let residual = response[row] - fitted[row];
539 for j in 0..m {
540 let mut sensitivity = 0.0_f64;
541 for k in 0..m {
542 sensitivity += a_inv[[j, k]] * design[[row, k]];
543 }
544 coefficient_score_influence[[j, row]] = sensitivity * w * residual;
545 }
546 }
547
548 if beta.iter().chain(a_inv.iter()).any(|v| !v.is_finite())
549 || !(sigma2.is_finite() && sigma2 > 0.0)
550 {
551 return Err("penalized 1-D REML produced non-finite posterior moments".to_string());
552 }
553 Ok(Penalized1dFit {
554 beta,
555 covariance,
556 lambda,
557 edf,
558 sigma2,
559 residual_rms,
560 coefficient_score_influence,
561 })
562}
563
564#[derive(Debug, Clone)]
574pub struct FittedTransport {
575 pub topology_from: ChartTopology,
576 pub topology_to: ChartTopology,
577 pub degree: Option<i32>,
579 pub degree_concentration: Option<f64>,
582 pub rotation_offset: f64,
587 pub beta: Array1<f64>,
589 pub covariance: Array2<f64>,
591 pub smoothing_lambda: f64,
592 pub edf: f64,
594 pub noise_variance: f64,
596 pub n_obs: usize,
597 pub isometry_defect: f64,
599 pub isometry_defect_se: f64,
601 pub topology_preserved: bool,
604 pub min_directional_derivative: f64,
606 pub residual_rms: f64,
608 basis: DomainBasis,
609 coefficient_score_influence: Array2<f64>,
610}
611
612impl FittedTransport {
613 fn linear_slope(&self) -> f64 {
614 self.degree.map_or(0.0, f64::from)
615 }
616
617 pub fn eval(&self, t: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
619 let rows = self.basis.value_rows(t)?;
620 let smooth = rows.dot(&self.beta);
621 let slope = self.linear_slope();
622 let mut out = Array1::<f64>::zeros(t.len());
623 for i in 0..t.len() {
624 let raw = slope * t[i] + self.rotation_offset + smooth[i];
625 out[i] = match self.topology_to {
626 ChartTopology::Circle => wrap_tau(raw),
627 ChartTopology::Interval { .. } => raw,
628 };
629 }
630 Ok(out)
631 }
632
633 pub fn eval_with_variance(
635 &self,
636 t: ArrayView1<'_, f64>,
637 ) -> Result<(Array1<f64>, Array1<f64>), String> {
638 let rows = self.basis.value_rows(t)?;
639 let values = self.eval(t)?;
640 let mut variances = Array1::<f64>::zeros(t.len());
641 for i in 0..t.len() {
642 let row = rows.row(i);
643 variances[i] = row.dot(&self.covariance.dot(&row)).max(0.0);
644 }
645 Ok((values, variances))
646 }
647
648 pub fn derivative(&self, t: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
650 let rows = self.basis.derivative_rows(t)?;
651 let slope = self.linear_slope();
652 Ok(rows.dot(&self.beta).mapv(|v| v + slope))
653 }
654
655 fn eval_score_influence(&self, t: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
658 Ok(self
659 .basis
660 .value_rows(t)?
661 .dot(&self.coefficient_score_influence))
662 }
663
664 fn raw_at(&self, t: f64) -> Result<f64, String> {
668 let arr = Array1::from_elem(1, t);
669 let smooth = self.basis.value_rows(arr.view())?.dot(&self.beta)[0];
670 Ok(self.linear_slope() * t + self.rotation_offset + smooth)
671 }
672
673 fn oriented_derivative_at(&self, t: &[f64], orientation: f64) -> Result<Vec<f64>, String> {
675 let arr = Array1::from_vec(t.to_vec());
676 let rows = self.basis.derivative_rows(arr.view())?;
677 let slope = self.linear_slope();
678 Ok((0..t.len())
679 .map(|i| orientation * (rows.row(i).dot(&self.beta) + slope))
680 .collect())
681 }
682
683 fn certify_strict_monotonicity(&self) -> Result<f64, String> {
701 let (lo, hi) = match self.topology_from {
702 ChartTopology::Circle => (0.0, TAU),
703 ChartTopology::Interval { lo, hi } => (lo, hi),
704 };
705 let raw_lo = self.raw_at(lo)?;
708 let raw_hi = self.raw_at(hi)?;
709 let orientation = if raw_hi >= raw_lo { 1.0 } else { -1.0 };
710
711 let deg = self.basis.derivative_poly_degree().max(1);
712 let breaks = self.basis.derivative_breakpoints();
713 for window in breaks.windows(2) {
716 let (a, b) = (window[0], window[1]);
717 if !(b > a) {
718 continue;
719 }
720 let span = b - a;
721 let pad = span * 1.0e-9;
725 let n_nodes = deg + 1;
726 let nodes: Vec<f64> = (0..n_nodes)
727 .map(|i| {
728 let s = if n_nodes == 1 {
729 0.5
730 } else {
731 i as f64 / (n_nodes - 1) as f64
732 };
733 (a + pad) + (span - 2.0 * pad) * s
734 })
735 .collect();
736 let values = self.oriented_derivative_at(&nodes, orientation)?;
737
738 let step = if n_nodes > 1 {
743 nodes[1] - nodes[0]
744 } else {
745 span
746 };
747 let coeffs = monomial_from_equispaced(&values);
748
749 let probe_t = a + 0.37 * span;
755 let probe_u = (probe_t - nodes[0]) / step;
756 let probe_recon = eval_monomial(&coeffs, probe_u);
757 let probe_actual = self.oriented_derivative_at(&[probe_t], orientation)?[0];
758 let scale = probe_actual.abs().max(1.0);
759 if (probe_recon - probe_actual).abs() > 1.0e-6 * scale {
760 return Err(format!(
761 "transport monotonicity certificate could not reconstruct h′ on the \
762 span [{a}, {b}] (reconstruction {probe_recon} vs actual {probe_actual}); \
763 refusing to certify"
764 ));
765 }
766
767 for &edge in &[a, b] {
769 let u = (edge - nodes[0]) / step;
770 let v = eval_monomial(&coeffs, u);
771 if !(v > 0.0) {
772 return Err(format!(
773 "transport map is not strictly monotone: orientation·h′ = {v} ≤ 0 at \
774 t = {edge}"
775 ));
776 }
777 }
778 for u_crit in monomial_critical_points(&coeffs) {
781 let t_crit = nodes[0] + u_crit * step;
782 if t_crit > a && t_crit < b {
783 let v = eval_monomial(&coeffs, u_crit);
784 if !(v > 0.0) {
785 return Err(format!(
786 "transport map folds: orientation·h′ = {v} ≤ 0 at interior \
787 extremum t = {t_crit}"
788 ));
789 }
790 }
791 }
792 }
793 Ok(orientation)
794 }
795
796 pub fn invert(&self, y: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
816 if y.iter().any(|v| !v.is_finite()) {
817 return Err("transport inverse targets must be finite".to_string());
818 }
819 self.certify_strict_monotonicity()?;
822 let (lo, hi) = match self.topology_from {
823 ChartTopology::Circle => (0.0, TAU),
824 ChartTopology::Interval { lo, hi } => (lo, hi),
825 };
826 let raw_lo = self.raw_at(lo)?;
829 let raw_hi = self.raw_at(hi)?;
830 let increasing = raw_hi > raw_lo;
831 let (raw_min, raw_max) = if increasing {
832 (raw_lo, raw_hi)
833 } else {
834 (raw_hi, raw_lo)
835 };
836 let scale = raw_min.abs().max(raw_max.abs()).max(1.0);
839 let tol = 32.0 * f64::EPSILON * scale;
840
841 let mut probe = Array1::<f64>::zeros(1);
844 let mut raw_at_into = |t: f64| -> Result<f64, String> {
845 probe[0] = t;
846 let smooth = self.basis.value_rows(probe.view())?.dot(&self.beta)[0];
847 Ok(self.linear_slope() * t + self.rotation_offset + smooth)
848 };
849
850 let mut out = Array1::<f64>::zeros(y.len());
851 for (idx, &yi) in y.iter().enumerate() {
852 let target = match self.topology_to {
854 ChartTopology::Interval { .. } => {
855 if yi < raw_min - tol || yi > raw_max + tol {
856 return Err(format!(
857 "transport inverse target {yi} is outside the fitted image \
858 [{raw_min}, {raw_max}]"
859 ));
860 }
861 yi.clamp(raw_min, raw_max)
862 }
863 ChartTopology::Circle => {
864 let ywrapped = wrap_tau(yi);
867 let m = ((raw_min - ywrapped) / TAU).ceil();
868 ywrapped + TAU * m
869 }
870 };
871 let (mut a, mut b) = (lo, hi);
875 let width_floor = f64::EPSILON * hi.abs().max(lo.abs()).max(1.0);
876 for _ in 0..100 {
877 if (b - a) <= width_floor {
878 break;
879 }
880 let mid = 0.5 * (a + b);
881 let rm = raw_at_into(mid)?;
882 let go_right = if increasing { rm < target } else { rm > target };
883 if go_right {
884 a = mid;
885 } else {
886 b = mid;
887 }
888 }
889 out[idx] = 0.5 * (a + b);
890 }
891 Ok(out)
892 }
893
894 pub fn report(&self, layer_from: usize, layer_to: usize) -> LayerTransportReport {
897 LayerTransportReport {
898 layer_from,
899 layer_to,
900 topology_from: self.topology_from,
901 topology_to: self.topology_to,
902 topology_preserved: self.topology_preserved,
903 degree: self.degree,
904 degree_concentration: self.degree_concentration,
905 rotation_offset: self.rotation_offset,
906 isometry_defect: self.isometry_defect,
907 isometry_defect_se: self.isometry_defect_se,
908 min_directional_derivative: self.min_directional_derivative,
909 transport_edf: self.edf,
910 smoothing_lambda: self.smoothing_lambda,
911 noise_variance: self.noise_variance,
912 residual_rms: self.residual_rms,
913 n_obs: self.n_obs,
914 composition_defect: None,
915 composition_max_studentized: None,
916 composition_p_value: None,
917 composition_gauge_reflected: None,
918 }
919 }
920}
921
922#[derive(Debug, Clone)]
924pub struct LayerTransportReport {
925 pub layer_from: usize,
926 pub layer_to: usize,
927 pub topology_from: ChartTopology,
928 pub topology_to: ChartTopology,
929 pub topology_preserved: bool,
931 pub degree: Option<i32>,
933 pub degree_concentration: Option<f64>,
935 pub rotation_offset: f64,
937 pub isometry_defect: f64,
939 pub isometry_defect_se: f64,
941 pub min_directional_derivative: f64,
943 pub transport_edf: f64,
945 pub smoothing_lambda: f64,
946 pub noise_variance: f64,
947 pub residual_rms: f64,
948 pub n_obs: usize,
949 pub composition_defect: Option<f64>,
952 pub composition_max_studentized: Option<f64>,
954 pub composition_p_value: Option<f64>,
957 pub composition_gauge_reflected: Option<bool>,
960}
961
962impl LayerTransportReport {
963 pub fn with_composition(mut self, composition: &CompositionDefectReport) -> Self {
965 self.composition_defect = Some(composition.rms_defect);
966 self.composition_max_studentized = Some(composition.max_studentized_defect);
967 self.composition_p_value = composition
968 .p_value
969 .is_finite()
970 .then_some(composition.p_value);
971 self.composition_gauge_reflected = Some(composition.gauge_reflected);
972 self
973 }
974}
975
976pub fn fit_transport_map(
983 coords_from: ArrayView1<'_, f64>,
984 coords_to: ArrayView1<'_, f64>,
985 topology_from: ChartTopology,
986 topology_to: ChartTopology,
987) -> Result<FittedTransport, String> {
988 let n = coords_from.len();
989 if coords_to.len() != n {
990 return Err(format!(
991 "layer transport coordinate lengths disagree: {} vs {}",
992 n,
993 coords_to.len()
994 ));
995 }
996 if n < MIN_TRANSPORT_OBS {
997 return Err(format!(
998 "layer transport needs at least {MIN_TRANSPORT_OBS} paired observations, got {n}"
999 ));
1000 }
1001 if coords_from
1002 .iter()
1003 .chain(coords_to.iter())
1004 .any(|v| !v.is_finite())
1005 {
1006 return Err("layer transport coordinates must all be finite".to_string());
1007 }
1008 topology_from.validate()?;
1009 topology_to.validate()?;
1010
1011 let (degree, degree_concentration, rotation_offset, response): (
1013 Option<i32>,
1014 Option<f64>,
1015 f64,
1016 Array1<f64>,
1017 ) = match (topology_from, topology_to) {
1018 (ChartTopology::Circle, ChartTopology::Circle) => {
1019 let mut best_degree = DEGREE_CANDIDATES[0];
1026 let mut best_r = f64::NEG_INFINITY;
1027 for &d in DEGREE_CANDIDATES.iter() {
1028 let residual: Vec<f64> = (0..n)
1029 .map(|i| coords_to[i] - f64::from(d) * coords_from[i])
1030 .collect();
1031 let r = resultant_length(&residual);
1032 if r > best_r {
1033 best_r = r;
1034 best_degree = d;
1035 }
1036 }
1037 let residual: Vec<f64> = (0..n)
1038 .map(|i| coords_to[i] - f64::from(best_degree) * coords_from[i])
1039 .collect();
1040 let mu = circular_mean(&residual);
1041 let response = Array1::from_iter(residual.iter().map(|&r| wrap_pi(r - mu)));
1042 (Some(best_degree), Some(best_r), mu, response)
1043 }
1044 (_, ChartTopology::Circle) => {
1045 let angles: Vec<f64> = coords_to.iter().copied().collect();
1049 let mu = circular_mean(&angles);
1050 let response = Array1::from_iter(angles.iter().map(|&a| wrap_pi(a - mu)));
1051 (None, None, mu, response)
1052 }
1053 (_, ChartTopology::Interval { .. }) => (None, None, 0.0, coords_to.to_owned()),
1054 };
1055
1056 let basis = DomainBasis::build(topology_from, coords_from)?;
1058 let design = basis.value_rows(coords_from)?;
1059 let penalty = basis.penalty()?;
1060 let fit = fit_penalized_1d(
1061 &design,
1062 &penalty,
1063 response.view(),
1064 None,
1065 basis.penalty_rank(),
1066 )?;
1067
1068 let slope = degree.map_or(0.0, f64::from);
1070 let deriv_rows = basis.derivative_rows(coords_from)?;
1071 let deriv = deriv_rows.dot(&fit.beta).mapv(|v| v + slope);
1072 let m = basis.num_basis();
1073 let mut defect = 0.0_f64;
1074 let mut grad = Array1::<f64>::zeros(m);
1075 for i in 0..n {
1076 let speed = deriv[i].abs();
1077 let gap = speed - 1.0;
1078 defect += gap * gap;
1079 let sgn = if deriv[i] >= 0.0 { 1.0 } else { -1.0 };
1080 for j in 0..m {
1081 grad[j] += 2.0 * gap * sgn * deriv_rows[[i, j]];
1082 }
1083 }
1084 defect /= n as f64;
1085 grad.mapv_inplace(|v| v / n as f64);
1086 let isometry_defect_se = grad.dot(&fit.covariance.dot(&grad)).max(0.0).sqrt();
1087
1088 let grid = domain_grid(topology_from, FOLD_CHECK_GRID);
1090 let grid_deriv = basis
1091 .derivative_rows(grid.view())?
1092 .dot(&fit.beta)
1093 .mapv(|v| v + slope);
1094 let orientation = if slope != 0.0 {
1095 slope.signum()
1096 } else {
1097 let mean = grid_deriv.iter().sum::<f64>() / grid_deriv.len() as f64;
1098 if mean < 0.0 { -1.0 } else { 1.0 }
1099 };
1100 let min_directional_derivative = grid_deriv
1101 .iter()
1102 .map(|&v| orientation * v)
1103 .fold(f64::INFINITY, f64::min);
1104 let topology_preserved = match (topology_from, topology_to) {
1105 (ChartTopology::Circle, ChartTopology::Circle) => {
1106 matches!(degree, Some(1) | Some(-1)) && min_directional_derivative > 0.0
1107 }
1108 (ChartTopology::Interval { .. }, ChartTopology::Interval { .. }) => {
1109 min_directional_derivative > 0.0
1110 }
1111 _ => false,
1112 };
1113
1114 Ok(FittedTransport {
1115 topology_from,
1116 topology_to,
1117 degree,
1118 degree_concentration,
1119 rotation_offset,
1120 beta: fit.beta,
1121 covariance: fit.covariance,
1122 smoothing_lambda: fit.lambda,
1123 edf: fit.edf,
1124 noise_variance: fit.sigma2,
1125 n_obs: n,
1126 isometry_defect: defect,
1127 isometry_defect_se,
1128 topology_preserved,
1129 min_directional_derivative,
1130 residual_rms: fit.residual_rms,
1131 basis,
1132 coefficient_score_influence: fit.coefficient_score_influence,
1133 })
1134}
1135
1136pub fn fit_layer_transport(
1138 layer_from: usize,
1139 layer_to: usize,
1140 coords_from: ArrayView1<'_, f64>,
1141 coords_to: ArrayView1<'_, f64>,
1142 topology_from: ChartTopology,
1143 topology_to: ChartTopology,
1144) -> Result<LayerTransportReport, String> {
1145 Ok(
1146 fit_transport_map(coords_from, coords_to, topology_from, topology_to)?
1147 .report(layer_from, layer_to),
1148 )
1149}
1150
1151#[derive(Debug, Clone)]
1153pub struct CompositionDefectReport {
1154 pub n_grid: usize,
1155 pub gauge_rotation: f64,
1157 pub gauge_reflected: bool,
1159 pub mean_abs_defect: f64,
1160 pub rms_defect: f64,
1161 pub max_abs_defect: f64,
1162 pub max_studentized_defect: f64,
1164 pub max_studentized_p_value: f64,
1167 pub p_value: f64,
1170}
1171
1172fn monomial_from_equispaced(values: &[f64]) -> Vec<f64> {
1178 let n = values.len();
1179 if n == 0 {
1180 return Vec::new();
1181 }
1182 let mut diffs: Vec<f64> = values.to_vec();
1184 let mut fwd = vec![0.0_f64; n];
1185 fwd[0] = diffs[0];
1186 for k in 1..n {
1187 for i in 0..(n - k) {
1188 diffs[i] = diffs[i + 1] - diffs[i];
1189 }
1190 fwd[k] = diffs[0];
1191 }
1192 let mut coeffs = vec![0.0_f64; n];
1195 let mut poly = vec![0.0_f64; n];
1198 poly[0] = 1.0;
1199 let mut poly_len = 1usize;
1200 let mut factorial = 1.0_f64;
1201 for k in 0..n {
1202 if k > 0 {
1203 factorial *= k as f64;
1204 }
1205 let scale = fwd[k] / factorial;
1206 for (i, &p) in poly.iter().take(poly_len).enumerate() {
1207 coeffs[i] += scale * p;
1208 }
1209 if k + 1 < n {
1211 let mut next = vec![0.0_f64; poly_len + 1];
1212 for i in 0..poly_len {
1213 next[i + 1] += poly[i]; next[i] -= (k as f64) * poly[i]; }
1216 for i in 0..=poly_len {
1217 poly[i] = next[i];
1218 }
1219 poly_len += 1;
1220 }
1221 }
1222 coeffs
1223}
1224
1225fn eval_monomial(coeffs: &[f64], u: f64) -> f64 {
1227 coeffs.iter().rev().fold(0.0_f64, |acc, &c| acc * u + c)
1228}
1229
1230fn monomial_critical_points(coeffs: &[f64]) -> Vec<f64> {
1239 let n = coeffs.len();
1241 if n <= 1 {
1242 return Vec::new();
1243 }
1244 let deriv: Vec<f64> = (1..n).map(|k| k as f64 * coeffs[k]).collect();
1245 match deriv.len() {
1247 0 => Vec::new(),
1248 1 => Vec::new(), 2 => {
1250 let (b, a) = (deriv[0], deriv[1]);
1252 if a.abs() <= f64::MIN_POSITIVE {
1253 Vec::new()
1254 } else {
1255 vec![-b / a]
1256 }
1257 }
1258 3 => {
1259 let (c, b, a) = (deriv[0], deriv[1], deriv[2]);
1261 if a.abs() <= f64::MIN_POSITIVE {
1262 if b.abs() <= f64::MIN_POSITIVE {
1263 Vec::new()
1264 } else {
1265 vec![-c / b]
1266 }
1267 } else {
1268 let disc = b * b - 4.0 * a * c;
1269 if disc < 0.0 {
1270 Vec::new()
1271 } else {
1272 let s = disc.sqrt();
1273 vec![(-b + s) / (2.0 * a), (-b - s) / (2.0 * a)]
1274 }
1275 }
1276 }
1277 _ => {
1278 let lo = 0.0;
1281 let hi = (coeffs.len() - 1) as f64;
1282 let steps = 256;
1283 let mut roots = Vec::new();
1284 let f = |u: f64| eval_monomial(&deriv, u);
1285 let mut prev_u = lo;
1286 let mut prev_v = f(lo);
1287 for i in 1..=steps {
1288 let u = lo + (hi - lo) * i as f64 / steps as f64;
1289 let v = f(u);
1290 if prev_v == 0.0 {
1291 roots.push(prev_u);
1292 } else if prev_v * v < 0.0 {
1293 let (mut a, mut b) = (prev_u, u);
1294 for _ in 0..60 {
1295 let m = 0.5 * (a + b);
1296 if f(a) * f(m) <= 0.0 {
1297 b = m;
1298 } else {
1299 a = m;
1300 }
1301 }
1302 roots.push(0.5 * (a + b));
1303 }
1304 prev_u = u;
1305 prev_v = v;
1306 }
1307 roots
1308 }
1309 }
1310}
1311
1312fn domain_grid(topology: ChartTopology, n: usize) -> Array1<f64> {
1314 match topology {
1315 ChartTopology::Circle => Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64)),
1316 ChartTopology::Interval { lo, hi } => {
1317 Array1::from_iter((0..n).map(|i| lo + (hi - lo) * i as f64 / (n - 1).max(1) as f64))
1318 }
1319 }
1320}
1321
1322pub fn composition_defect(
1330 h_ab: &FittedTransport,
1331 h_bc: &FittedTransport,
1332 h_ac: &FittedTransport,
1333 n_grid: usize,
1334) -> Result<CompositionDefectReport, String> {
1335 if h_ab.topology_from != h_ac.topology_from
1336 || h_ab.topology_to != h_bc.topology_from
1337 || h_bc.topology_to != h_ac.topology_to
1338 {
1339 return Err("composition defect requires chart-compatible transports: \
1340 h_ab: A→B, h_bc: B→C, h_ac: A→C"
1341 .to_string());
1342 }
1343 if n_grid < MIN_TRANSPORT_OBS {
1344 return Err(format!(
1345 "composition defect grid must have at least {MIN_TRANSPORT_OBS} points, got {n_grid}"
1346 ));
1347 }
1348 if h_ab.n_obs != h_bc.n_obs || h_ab.n_obs != h_ac.n_obs {
1349 return Err(format!(
1350 "composition defect requires maps fitted on the same rows; got n_ab={}, n_bc={}, n_ac={}",
1351 h_ab.n_obs, h_bc.n_obs, h_ac.n_obs
1352 ));
1353 }
1354
1355 let grid = domain_grid(h_ab.topology_from, n_grid);
1356 let direct = h_ac.eval(grid.view())?;
1357 let mid = h_ab.eval(grid.view())?;
1358 let composed = h_bc.eval(mid.view())?;
1359 let mid_slope = h_bc.derivative(mid.view())?;
1360
1361 let influence_direct = h_ac.eval_score_influence(grid.view())?;
1367 let influence_ab = h_ab.eval_score_influence(grid.view())?;
1368 let influence_bc = h_bc.eval_score_influence(mid.view())?;
1369 let mut variance = Array1::<f64>::zeros(n_grid);
1370 for i in 0..n_grid {
1371 let mut value = 0.0_f64;
1372 for row in 0..h_ab.n_obs {
1373 let influence = influence_direct[[i, row]]
1374 - influence_bc[[i, row]]
1375 - mid_slope[i] * influence_ab[[i, row]];
1376 value += influence * influence;
1377 }
1378 variance[i] = value;
1379 }
1380
1381 let circle_target = matches!(h_ac.topology_to, ChartTopology::Circle);
1386 let defect = Array1::from_iter((0..n_grid).map(|i| {
1387 if circle_target {
1388 wrap_pi(direct[i] - composed[i])
1389 } else {
1390 direct[i] - composed[i]
1391 }
1392 }));
1393
1394 let max_var = variance.iter().copied().fold(0.0_f64, f64::max);
1406 let var_floor = (max_var * 1e-12).max(f64::MIN_POSITIVE);
1407 let mut max_abs = 0.0_f64;
1408 let mut sum_abs = 0.0_f64;
1409 let mut sum_sq = 0.0_f64;
1410 let mut max_z = 0.0_f64;
1411 for i in 0..n_grid {
1412 let d = defect[i];
1413 let a = d.abs();
1414 max_abs = max_abs.max(a);
1415 sum_abs += a;
1416 sum_sq += d * d;
1417 if max_var > 0.0 {
1418 let z = a / variance[i].max(var_floor).sqrt();
1419 max_z = max_z.max(z);
1420 }
1421 }
1422 let mean_abs_defect = sum_abs / n_grid as f64;
1423 let rms_defect = (sum_sq / n_grid as f64).sqrt();
1424
1425 let max_studentized_p_value = if max_var > 0.0 {
1430 let normal = Normal::new(0.0, 1.0)
1431 .map_err(|e| format!("standard normal construction failed: {e}"))?;
1432 let pointwise: f64 = (2.0 * (1.0 - normal.cdf(max_z))).clamp(0.0, 1.0);
1433 (n_grid as f64 * pointwise).min(1.0)
1434 } else {
1435 f64::NAN
1436 };
1437
1438 Ok(CompositionDefectReport {
1439 n_grid,
1440 gauge_rotation: 0.0,
1441 gauge_reflected: false,
1442 mean_abs_defect,
1443 rms_defect,
1444 max_abs_defect: max_abs,
1445 max_studentized_defect: if max_var > 0.0 { max_z } else { f64::NAN },
1446 max_studentized_p_value,
1447 p_value: max_studentized_p_value,
1448 })
1449}
1450
1451#[derive(Debug, Clone)]
1454pub struct TransportLadderReport {
1455 pub adjacent: Vec<LayerTransportReport>,
1457 pub two_hop: Vec<LayerTransportReport>,
1460 pub circle_transports: Vec<crate::inference::transport_class::CircleTransportReport>,
1465}
1466
1467pub fn transport_ladder(
1473 layers: &[usize],
1474 coords: &[Array1<f64>],
1475 topologies: &[ChartTopology],
1476) -> Result<TransportLadderReport, String> {
1477 let depth = layers.len();
1478 if coords.len() != depth || topologies.len() != depth {
1479 return Err(format!(
1480 "transport ladder inputs disagree: {depth} layers, {} coordinate vectors, {} topologies",
1481 coords.len(),
1482 topologies.len()
1483 ));
1484 }
1485 if depth < 2 {
1486 return Err("transport ladder needs at least two layers".to_string());
1487 }
1488
1489 let mut adjacent_fits: Vec<FittedTransport> = Vec::with_capacity(depth - 1);
1490 let mut adjacent: Vec<LayerTransportReport> = Vec::with_capacity(depth - 1);
1491 for k in 0..depth - 1 {
1492 let fit = fit_transport_map(
1493 coords[k].view(),
1494 coords[k + 1].view(),
1495 topologies[k],
1496 topologies[k + 1],
1497 )
1498 .map_err(|e| {
1499 format!(
1500 "adjacent transport {}→{} failed: {e}",
1501 layers[k],
1502 layers[k + 1]
1503 )
1504 })?;
1505 adjacent.push(fit.report(layers[k], layers[k + 1]));
1506 adjacent_fits.push(fit);
1507 }
1508
1509 let mut two_hop: Vec<LayerTransportReport> = Vec::with_capacity(depth.saturating_sub(2));
1510 for k in 0..depth.saturating_sub(2) {
1511 let direct = fit_transport_map(
1512 coords[k].view(),
1513 coords[k + 2].view(),
1514 topologies[k],
1515 topologies[k + 2],
1516 )
1517 .map_err(|e| {
1518 format!(
1519 "two-hop transport {}→{} failed: {e}",
1520 layers[k],
1521 layers[k + 2]
1522 )
1523 })?;
1524 let composition = composition_defect(
1525 &adjacent_fits[k],
1526 &adjacent_fits[k + 1],
1527 &direct,
1528 DEFAULT_COMPOSITION_GRID,
1529 )
1530 .map_err(|e| {
1531 format!(
1532 "composition test {}→{}→{} failed: {e}",
1533 layers[k],
1534 layers[k + 1],
1535 layers[k + 2]
1536 )
1537 })?;
1538 two_hop.push(
1539 direct
1540 .report(layers[k], layers[k + 2])
1541 .with_composition(&composition),
1542 );
1543 }
1544
1545 let mut circle_transports = Vec::new();
1549 for k in 0..depth - 1 {
1550 if let Some(report) = crate::inference::transport_class::classify_circle_transport_fit(
1551 &adjacent_fits[k],
1552 topologies[k],
1553 topologies[k + 1],
1554 layers[k],
1555 layers[k + 1],
1556 DEFAULT_COMPOSITION_GRID,
1557 ) {
1558 circle_transports.push(report);
1559 }
1560 }
1561
1562 Ok(TransportLadderReport {
1563 adjacent,
1564 two_hop,
1565 circle_transports,
1566 })
1567}
1568
1569#[cfg(test)]
1570mod invert_tests {
1571 use super::*;
1572 use faer::Side;
1573 use gam_linalg::faer_ndarray::FaerEigh;
1574 use ndarray::Array1;
1575
1576 fn interval(lo: f64, hi: f64) -> ChartTopology {
1577 ChartTopology::Interval { lo, hi }
1578 }
1579
1580 #[test]
1584 fn penalized_1d_covariance_and_scale_match_reml_system() {
1585 let n = 32;
1586 let design = Array2::from_shape_fn((n, 3), |(row, col)| {
1587 let x = row as f64 / (n - 1) as f64;
1588 match col {
1589 0 => 1.0,
1590 1 => x,
1591 2 => x * x,
1592 _ => unreachable!(),
1593 }
1594 });
1595 let response = Array1::from_shape_fn(n, |row| {
1596 let x = row as f64 / (n - 1) as f64;
1597 0.3 + 0.8 * x + 0.2 * (TAU * x).sin()
1598 });
1599 let mut penalty = Array2::<f64>::zeros((3, 3));
1600 penalty[[2, 2]] = 1.0;
1601 let fit = fit_penalized_1d(&design, &penalty, response.view(), None, 1)
1602 .expect("certified REML fit");
1603
1604 let mut penalized_gram = design.t().dot(&design);
1605 penalized_gram[[2, 2]] += fit.lambda;
1606 let inverse = fit.covariance.mapv(|value| value / fit.sigma2);
1607 let identity = penalized_gram.dot(&inverse);
1608 for row in 0..3 {
1609 for col in 0..3 {
1610 let expected = f64::from(u8::from(row == col));
1611 assert!(
1612 (identity[[row, col]] - expected).abs() < 1.0e-9,
1613 "penalized inverse mismatch at ({row}, {col}): {}",
1614 identity[[row, col]],
1615 );
1616 }
1617 }
1618
1619 let xtwy = design.t().dot(&response);
1620 let prss = response.dot(&response) - fit.beta.dot(&xtwy);
1621 let expected_sigma2 = prss / (n - 2) as f64;
1622 assert!(
1623 (fit.sigma2 - expected_sigma2).abs() <= 1.0e-11 * expected_sigma2.abs().max(1.0),
1624 "REML scale mismatch: fitted {}, expected {expected_sigma2}",
1625 fit.sigma2,
1626 );
1627 }
1628
1629 #[test]
1630 fn invert_round_trips_interval_transport() {
1631 let n = 64;
1635 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1636 let to: Array1<f64> = from.mapv(|t| t + 0.25 * (TAU * t).sin() / TAU);
1637 let ft = fit_transport_map(
1638 from.view(),
1639 to.view(),
1640 interval(0.0, 1.0),
1641 interval(0.0, 1.0),
1642 )
1643 .expect("fit");
1644 assert!(
1645 ft.topology_preserved,
1646 "monotone warp should preserve topology"
1647 );
1648
1649 let probe = Array1::from_iter((1..10).map(|i| i as f64 / 10.0));
1650 let fwd = ft.eval(probe.view()).expect("eval");
1652 let back = ft.invert(fwd.view()).expect("invert");
1653 for i in 0..probe.len() {
1654 assert!(
1655 (back[i] - probe[i]).abs() < 1e-6,
1656 "round-trip failed: t={} back={}",
1657 probe[i],
1658 back[i]
1659 );
1660 }
1661 let re_eval = ft.eval(back.view()).expect("eval");
1662 for i in 0..fwd.len() {
1663 assert!((re_eval[i] - fwd[i]).abs() < 1e-9);
1664 }
1665 }
1666
1667 #[test]
1668 fn invert_round_trips_decreasing_interval_transport() {
1669 let n = 64;
1672 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1673 let to: Array1<f64> = from.mapv(|t| 1.0 - 0.5 * t - 0.5 * t * t);
1674 let ft = fit_transport_map(
1675 from.view(),
1676 to.view(),
1677 interval(0.0, 1.0),
1678 interval(0.0, 1.0),
1679 )
1680 .expect("fit");
1681 assert!(ft.topology_preserved);
1682 let probe = Array1::from_iter((1..10).map(|i| i as f64 / 10.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 assert!(
1687 (back[i] - probe[i]).abs() < 1e-6,
1688 "t={} back={}",
1689 probe[i],
1690 back[i]
1691 );
1692 }
1693 }
1694
1695 #[test]
1696 fn invert_round_trips_circle_transport() {
1697 let n = 128;
1699 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1700 let to: Array1<f64> = from.mapv(|t| wrap_tau(t + 0.3 + 0.2 * t.sin()));
1701 let ft = fit_transport_map(
1702 from.view(),
1703 to.view(),
1704 ChartTopology::Circle,
1705 ChartTopology::Circle,
1706 )
1707 .expect("fit");
1708 assert!(ft.topology_preserved, "degree {:?}", ft.degree);
1709
1710 let probe = Array1::from_iter((0..7).map(|i| TAU * (i as f64 + 0.5) / 7.0));
1711 let fwd = ft.eval(probe.view()).expect("eval");
1712 let back = ft.invert(fwd.view()).expect("invert");
1713 for i in 0..probe.len() {
1714 let d = wrap_pi(back[i] - probe[i]).abs();
1716 assert!(d < 1e-5, "probe={} back={} d={}", probe[i], back[i], d);
1717 }
1718 }
1719
1720 #[test]
1721 fn invert_rejects_target_outside_interval_image() {
1722 let n = 32;
1724 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1725 let to: Array1<f64> = from.mapv(|t| 0.5 * t);
1726 let ft = fit_transport_map(
1727 from.view(),
1728 to.view(),
1729 interval(0.0, 1.0),
1730 interval(0.0, 1.0),
1731 )
1732 .expect("fit");
1733 assert!(ft.invert(Array1::from_elem(1, 0.9).view()).is_err());
1734 }
1735
1736 fn fitted_from_target(
1742 from: ArrayView1<'_, f64>,
1743 target: ArrayView1<'_, f64>,
1744 lo: f64,
1745 hi: f64,
1746 ) -> FittedTransport {
1747 let basis = DomainBasis::build(interval(lo, hi), from).expect("basis");
1748 let design = basis.value_rows(from).expect("design");
1749 let m = design.ncols();
1750 let mut xtx = design.t().dot(&design);
1752 let xty = design.t().dot(&target);
1753 let diag = (0..m).map(|i| xtx[[i, i]].abs()).fold(1.0_f64, f64::max);
1754 for i in 0..m {
1755 xtx[[i, i]] += 1e-10 * diag;
1756 }
1757 let (evals, evecs) = xtx.eigh(Side::Lower).expect("eigh");
1758 let rotated = evecs.t().dot(&xty);
1759 let mut beta = Array1::<f64>::zeros(m);
1760 for i in 0..m {
1761 let d = evals[i].max(f64::MIN_POSITIVE);
1762 let c = rotated[i] / d;
1763 for j in 0..m {
1764 beta[j] += evecs[[j, i]] * c;
1765 }
1766 }
1767 FittedTransport {
1768 topology_from: interval(lo, hi),
1769 topology_to: interval(lo, hi),
1770 degree: None,
1771 degree_concentration: None,
1772 rotation_offset: 0.0,
1773 beta,
1774 covariance: Array2::<f64>::zeros((m, m)),
1775 smoothing_lambda: 0.0,
1776 edf: 0.0,
1777 noise_variance: 1.0,
1778 n_obs: from.len(),
1779 isometry_defect: 0.0,
1780 isometry_defect_se: 0.0,
1781 topology_preserved: true,
1782 min_directional_derivative: 1.0,
1783 residual_rms: 0.0,
1784 coefficient_score_influence: Array2::<f64>::zeros((m, from.len())),
1785 basis,
1786 }
1787 }
1788
1789 #[test]
1795 fn invert_rejects_between_grid_fold() {
1796 let n = 256;
1797 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1798 let eps = 0.4 / 511.0;
1799 let target: Array1<f64> = from.mapv(|t| (t - 0.5).powi(3) / 3.0 - eps * eps * t);
1800 let mut ft = fitted_from_target(from.view(), target.view(), 0.0, 1.0);
1801
1802 let grid = domain_grid(interval(0.0, 1.0), FOLD_CHECK_GRID);
1805 let grid_d = ft.derivative(grid.view()).expect("grid deriv");
1806 let mean = grid_d.iter().sum::<f64>() / grid_d.len() as f64;
1807 let orientation = if mean < 0.0 { -1.0 } else { 1.0 };
1808 let min_grid = grid_d
1809 .iter()
1810 .map(|&v| orientation * v)
1811 .fold(f64::INFINITY, f64::min);
1812 let dense = Array1::from_iter((0..5120).map(|i| i as f64 / 5119.0));
1814 let dense_d = ft.derivative(dense.view()).expect("dense deriv");
1815 let min_dense = dense_d
1816 .iter()
1817 .map(|&v| orientation * v)
1818 .fold(f64::INFINITY, f64::min);
1819 ft.topology_preserved = min_grid > 0.0;
1820 ft.min_directional_derivative = min_grid;
1821 assert!(
1822 min_grid > 0.0 && min_dense < 0.0,
1823 "fixture must hide a between-grid fold: min on 512-grid={min_grid}, \
1824 min on dense grid={min_dense}"
1825 );
1826
1827 let res = ft.invert(Array1::from_elem(1, 0.0).view());
1830 assert!(
1831 res.is_err(),
1832 "between-grid fold must be rejected by the span-exact certificate \
1833 (topology_preserved={}, min_grid={min_grid}, min_dense={min_dense})",
1834 ft.topology_preserved
1835 );
1836 }
1837
1838 #[test]
1839 fn invert_rejects_non_finite_targets() {
1840 let n = 64;
1841 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1842 let to: Array1<f64> = from.mapv(|t| 0.5 * t);
1843 let ft = fit_transport_map(
1844 from.view(),
1845 to.view(),
1846 interval(0.0, 1.0),
1847 interval(0.0, 1.0),
1848 )
1849 .expect("fit");
1850 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1851 assert!(
1852 ft.invert(Array1::from_elem(1, bad).view()).is_err(),
1853 "non-finite target {bad} must be rejected"
1854 );
1855 }
1856 }
1857
1858 #[test]
1859 fn invert_image_tolerance_is_scale_aware() {
1860 let n = 64;
1864 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
1865 let scale = 1.0e-8;
1866 let to: Array1<f64> = from.mapv(|t| scale * t);
1867 let ft = fit_transport_map(
1868 from.view(),
1869 to.view(),
1870 interval(0.0, 1.0),
1871 interval(0.0, 1.0),
1872 )
1873 .expect("fit");
1874 let outside = 1.05e-8;
1875 assert!(
1876 ft.invert(Array1::from_elem(1, outside).view()).is_err(),
1877 "target {outside} is 5% outside the [0, {scale}] image and must be rejected"
1878 );
1879 let inside = 0.5e-8;
1881 let t = ft
1882 .invert(Array1::from_elem(1, inside).view())
1883 .expect("invert inside");
1884 let re = ft.eval(t.view()).expect("eval");
1885 assert!((re[0] - inside).abs() < 1e-3 * scale);
1886 }
1887
1888 #[test]
1889 fn invert_round_trips_degree_minus_one_circle() {
1890 let n = 128;
1893 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1894 let to: Array1<f64> = from.mapv(|t| wrap_tau(-t + 0.4 + 0.15 * t.sin()));
1895 let ft = fit_transport_map(
1896 from.view(),
1897 to.view(),
1898 ChartTopology::Circle,
1899 ChartTopology::Circle,
1900 )
1901 .expect("fit");
1902 assert_eq!(ft.degree, Some(-1), "expected a degree −1 cover");
1903 assert!(ft.topology_preserved, "degree {:?}", ft.degree);
1904 let probe = Array1::from_iter((0..7).map(|i| TAU * (i as f64 + 0.5) / 7.0));
1905 let fwd = ft.eval(probe.view()).expect("eval");
1906 let back = ft.invert(fwd.view()).expect("invert");
1907 for i in 0..probe.len() {
1908 let d = wrap_pi(back[i] - probe[i]).abs();
1909 assert!(d < 1e-5, "probe={} back={} d={}", probe[i], back[i], d);
1910 }
1911 }
1912
1913 #[test]
1914 fn invert_round_trips_circle_seam_and_interval_endpoints() {
1915 let n = 128;
1917 let from: Array1<f64> = Array1::from_iter((0..n).map(|i| TAU * i as f64 / n as f64));
1918 let to: Array1<f64> = from.mapv(|t| wrap_tau(t + 0.3 + 0.2 * t.sin()));
1919 let ft = fit_transport_map(
1920 from.view(),
1921 to.view(),
1922 ChartTopology::Circle,
1923 ChartTopology::Circle,
1924 )
1925 .expect("fit");
1926 assert!(ft.topology_preserved);
1927 for seam in [1e-9, TAU - 1e-9, 0.0] {
1928 let t = ft
1929 .invert(Array1::from_elem(1, seam).view())
1930 .expect("invert seam");
1931 let re = ft.eval(t.view()).expect("eval");
1932 let d = wrap_pi(re[0] - wrap_tau(seam)).abs();
1933 assert!(d < 1e-6, "seam={seam} re={} d={d}", re[0]);
1934 }
1935
1936 let m = 64;
1938 let ifrom: Array1<f64> = Array1::from_iter((0..m).map(|i| i as f64 / (m as f64 - 1.0)));
1939 let ito: Array1<f64> = ifrom.mapv(|t| t + 0.25 * (TAU * t).sin() / TAU);
1940 let ift = fit_transport_map(
1941 ifrom.view(),
1942 ito.view(),
1943 interval(0.0, 1.0),
1944 interval(0.0, 1.0),
1945 )
1946 .expect("fit");
1947 let raw_lo = ift.raw_at(0.0).expect("raw lo");
1948 let raw_hi = ift.raw_at(1.0).expect("raw hi");
1949 for &edge in &[raw_lo, raw_hi] {
1950 let t = ift
1951 .invert(Array1::from_elem(1, edge).view())
1952 .expect("invert endpoint");
1953 assert!(t[0] >= -1e-9 && t[0] <= 1.0 + 1e-9, "endpoint t={}", t[0]);
1954 let re = ift.eval(t.view()).expect("eval");
1955 assert!((re[0] - edge).abs() < 1e-6, "edge={edge} re={}", re[0]);
1956 }
1957 }
1958
1959 #[test]
1960 fn monomial_reconstruction_is_exact_for_quadratic() {
1961 let coeffs_true = [0.7_f64, -1.3, 2.1]; let values: Vec<f64> = (0..3)
1965 .map(|i| eval_monomial(&coeffs_true, i as f64))
1966 .collect();
1967 let recon = monomial_from_equispaced(&values);
1968 for (a, b) in recon.iter().zip(coeffs_true.iter()) {
1969 assert!((a - b).abs() < 1e-12, "recon {a} vs {b}");
1970 }
1971 let crit = monomial_critical_points(&recon);
1973 assert_eq!(crit.len(), 1);
1974 assert!((crit[0] - 1.3 / 4.2).abs() < 1e-12);
1975 }
1976}