use ndarray::{Array1, ArrayView1};
use crate::chart_canonicalization::{
CanonicalChartTopology, ChartArcLengthReading, SAE_FLOW_DIFFEO_MIN_DET,
UNIT_SPEED_INLOOP_DEFECT_TOL, chart_arclength_coordinates,
};
use super::{SaeManifoldTerm, SupportMeasure};
#[cfg(test)]
#[path = "coordinate_fidelity_recovery_tests.rs"]
mod recovered_collapse_tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AngleFidelityVerdict {
ArcLengthHonest,
RecoverableViaArcLength,
Degenerate,
}
impl AngleFidelityVerdict {
pub fn label(self) -> &'static str {
match self {
AngleFidelityVerdict::ArcLengthHonest => "arclength_honest",
AngleFidelityVerdict::RecoverableViaArcLength => "recoverable_via_arclength",
AngleFidelityVerdict::Degenerate => "degenerate",
}
}
pub fn certified(self) -> bool {
!matches!(self, AngleFidelityVerdict::Degenerate)
}
}
pub fn angle_fidelity_verdict(reading: Option<&ChartArcLengthReading>) -> AngleFidelityVerdict {
match reading {
Some(r) if r.min_speed_over_mean > SAE_FLOW_DIFFEO_MIN_DET => {
if r.speed_cv < UNIT_SPEED_INLOOP_DEFECT_TOL {
AngleFidelityVerdict::ArcLengthHonest
} else {
AngleFidelityVerdict::RecoverableViaArcLength
}
}
_ => AngleFidelityVerdict::Degenerate,
}
}
#[derive(Debug, Clone, Copy)]
pub struct WatsonUniformity {
pub statistic: f64,
pub p_value: Option<f64>,
pub n: usize,
}
fn interval_uniformity(
u: &[f64],
weights: Option<ArrayView1<'_, f64>>,
) -> Option<WatsonUniformity> {
let mut pairs: Vec<(f64, f64)> = u
.iter()
.copied()
.enumerate()
.filter_map(|(i, x)| {
let w = weights.map_or(1.0, |wv| wv[i]);
(x.is_finite() && w.is_finite() && w > 0.0).then_some((x.clamp(0.0, 1.0), w))
})
.collect();
if pairs.len() < 2 {
return None;
}
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mass: f64 = pairs.iter().map(|(_, w)| *w).sum();
if !(mass > 0.0) {
return None;
}
let mut cumulative = 0.0_f64;
let mut d = 0.0_f64;
for (x, w) in pairs.iter().copied() {
let before = cumulative / mass;
cumulative += w;
let after = cumulative / mass;
d = d.max((x - before).abs()).max((after - x).abs());
}
Some(WatsonUniformity {
statistic: d,
p_value: None,
n: pairs.len(),
})
}
pub fn watson_u2_pvalue(u2: f64) -> f64 {
if !(u2 > 0.0) {
return 1.0;
}
let two_pi_sq = 2.0 * std::f64::consts::PI * std::f64::consts::PI;
let mut sum = 0.0_f64;
for j in 1..=100_usize {
let jf = j as f64;
let term = (-two_pi_sq * jf * jf * u2).exp();
sum += if j % 2 == 1 { term } else { -term };
if term < 1.0e-14 {
break;
}
}
(2.0 * sum).clamp(0.0, 1.0)
}
pub fn watson_u2_uniform(u: &[f64]) -> WatsonUniformity {
let n = u.len();
if n < 2 {
return WatsonUniformity {
statistic: 0.0,
p_value: Some(1.0),
n,
};
}
let mut v: Vec<f64> = u
.iter()
.map(|&x| {
let f = x - x.floor();
if f >= 1.0 { 0.0 } else { f }
})
.collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let nf = n as f64;
let mut cvm = 1.0 / (12.0 * nf);
let mut mean = 0.0_f64;
for (i, &ui) in v.iter().enumerate() {
let expected = (2.0 * (i as f64 + 1.0) - 1.0) / (2.0 * nf);
let d = ui - expected;
cvm += d * d;
mean += ui;
}
mean /= nf;
let u2 = cvm - nf * (mean - 0.5) * (mean - 0.5);
let p_value = watson_u2_pvalue(u2);
WatsonUniformity {
statistic: u2,
p_value: Some(p_value),
n,
}
}
pub fn coordinate_uniformity_weighted(
coords: ArrayView1<'_, f64>,
support: &SupportMeasure,
topology: &CanonicalChartTopology,
) -> Option<WatsonUniformity> {
if support.len() != coords.len() {
return None;
}
coordinate_uniformity_impl(coords, Some(support.weights()), topology)
}
fn coordinate_uniformity_impl(
coords: ArrayView1<'_, f64>,
weights: Option<ArrayView1<'_, f64>>,
topology: &CanonicalChartTopology,
) -> Option<WatsonUniformity> {
let n = coords.len();
if n < 2 {
return None;
}
if coords.iter().any(|t| !t.is_finite()) {
return None;
}
let u: Vec<f64> = match topology {
CanonicalChartTopology::Circle { period } => {
if !(period.is_finite() && *period > 0.0) {
return None;
}
coords
.iter()
.map(|&t| t.rem_euclid(*period) / *period)
.collect()
}
CanonicalChartTopology::Interval => {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for &t in coords.iter() {
lo = lo.min(t);
hi = hi.max(t);
}
let span = hi - lo;
let scale = lo.abs().max(hi.abs()).max(1.0);
if !(span > 1.0e-12 * scale) {
return None;
}
coords.iter().map(|&t| (t - lo) / span).collect()
}
};
match topology {
CanonicalChartTopology::Circle { .. } => match weights {
Some(w) => watson_u2_uniform_weighted(&u, w),
None => Some(watson_u2_uniform(&u)),
},
CanonicalChartTopology::Interval => interval_uniformity(&u, weights),
}
}
pub fn watson_u2_uniform_weighted(
u: &[f64],
weights: ArrayView1<'_, f64>,
) -> Option<WatsonUniformity> {
if u.len() != weights.len() {
return None;
}
let mut pairs: Vec<(f64, f64)> = u
.iter()
.copied()
.zip(weights.iter().copied())
.filter_map(|(x, w)| {
if x.is_finite() && w.is_finite() && w > 0.0 {
let f = x - x.floor();
Some((if f >= 1.0 { 0.0 } else { f }, w))
} else {
None
}
})
.collect();
if pairs.len() < 2 {
return None;
}
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mass: f64 = pairs.iter().map(|(_, w)| *w).sum();
let fisher_n: f64 = pairs.iter().map(|(_, w)| *w * *w).sum();
if !(mass > 0.0 && fisher_n > 0.0) {
return None;
}
let ess = (mass * mass) / fisher_n;
let mut cumulative = 0.0_f64;
let mut cvm_core = 0.0_f64;
let mut mean = 0.0_f64;
for (ui, wi_raw) in pairs.iter().copied() {
let wi = wi_raw / mass;
let midpoint = cumulative + 0.5 * wi;
let d = ui - midpoint;
cvm_core += wi * d * d;
mean += wi * ui;
cumulative += wi;
}
let u2 = ess * cvm_core + 1.0 / (12.0 * ess) - ess * (mean - 0.5) * (mean - 0.5);
Some(WatsonUniformity {
statistic: u2,
p_value: Some(watson_u2_pvalue(u2)),
n: pairs.len(),
})
}
pub const OCCUPANCY_ANCHOR_LADDER: &[usize] = &[2, 3, 4, 5, 6, 7, 9, 12];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OccupancyLaw {
Uniform,
Discrete { anchors: usize },
Continuous,
Collapsed,
Indeterminate,
}
impl OccupancyLaw {
pub fn label(self) -> &'static str {
match self {
OccupancyLaw::Uniform => "uniform",
OccupancyLaw::Discrete { .. } => "discrete",
OccupancyLaw::Continuous => "continuous",
OccupancyLaw::Collapsed => "collapsed",
OccupancyLaw::Indeterminate => "indeterminate",
}
}
pub fn anchors(self) -> usize {
match self {
OccupancyLaw::Discrete { anchors } => anchors,
_ => 0,
}
}
pub fn d_eff(self) -> usize {
match self {
OccupancyLaw::Discrete { anchors } => anchors.saturating_sub(1),
_ => 0,
}
}
}
pub fn classify_occupancy_weighted(u: &[f64], weights: ArrayView1<'_, f64>) -> OccupancyLaw {
classify_occupancy_weighted_impl(u, weights, true)
}
pub fn classify_occupancy_interval_weighted(
u: &[f64],
weights: ArrayView1<'_, f64>,
) -> OccupancyLaw {
classify_occupancy_weighted_impl(u, weights, false)
}
fn occupied_extent(pts: &[f64], circular: bool) -> f64 {
match (pts.first(), pts.last()) {
(Some(&first), Some(&last)) if pts.len() >= 2 => {
if !circular {
return last - first;
}
let mut largest_gap = (first + 1.0) - last; for pair in pts.windows(2) {
largest_gap = largest_gap.max(pair[1] - pair[0]);
}
(1.0 - largest_gap).max(0.0)
}
_ => 0.0,
}
}
fn classify_occupancy_weighted_impl(
u: &[f64],
weights: ArrayView1<'_, f64>,
circular: bool,
) -> OccupancyLaw {
if u.len() != weights.len() {
return OccupancyLaw::Indeterminate;
}
let mut pairs: Vec<(f64, f64)> = u
.iter()
.copied()
.zip(weights.iter().copied())
.filter_map(|(x, w)| {
if x.is_finite() && w.is_finite() && w > 0.0 {
let folded = if circular {
let f = x - x.floor();
if f >= 1.0 { 0.0 } else { f }
} else {
x.clamp(0.0, 1.0)
};
Some((folded, w))
} else {
None
}
})
.collect();
if pairs.len() < 4 {
return OccupancyLaw::Indeterminate;
}
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let pts: Vec<f64> = pairs.iter().map(|(x, _)| *x).collect();
let w: Vec<f64> = pairs.iter().map(|(_, weight)| *weight).collect();
let support = match SupportMeasure::from_weights(0, Array1::from_vec(w.clone())) {
Ok(support) => support,
Err(_) => return OccupancyLaw::Indeterminate,
};
let mass = support.mass();
let ess = support.ess();
if !(mass > 0.0 && ess >= 4.0) {
return OccupancyLaw::Indeterminate;
}
let ln_n = ess.ln();
let bic_uniform = 0.0_f64;
let sigma_floor = 1.0 / (2.0 * ess);
if occupied_extent(&pts, circular) < sigma_floor {
return OccupancyLaw::Collapsed;
}
let single =
wrapped_gaussian_mixture_bic_weighted(&pts, &w, 1, sigma_floor, ln_n, circular, mass);
let mut best_law = OccupancyLaw::Uniform;
let mut best_bic = bic_uniform;
if let Some(bic) = single {
if bic < best_bic {
best_bic = bic;
best_law = OccupancyLaw::Continuous;
}
}
for &k in OCCUPANCY_ANCHOR_LADDER {
if k >= pairs.len() {
break;
}
if let Some(bic) =
wrapped_gaussian_mixture_bic_weighted(&pts, &w, k, sigma_floor, ln_n, circular, mass)
{
if bic < best_bic {
best_bic = bic;
best_law = OccupancyLaw::Discrete { anchors: k };
}
}
}
best_law
}
fn wrapped_gaussian_mixture_bic_weighted(
pts: &[f64],
weights_in: &[f64],
k: usize,
sigma_floor: f64,
ln_n: f64,
circular: bool,
total_mass: f64,
) -> Option<f64> {
let n = pts.len();
if k == 0 || k > n || weights_in.len() != n || !(total_mass > 0.0) {
return None;
}
let circ_dist = |a: f64, b: f64| -> f64 {
if circular {
let d = (a - b).rem_euclid(1.0);
d.min(1.0 - d)
} else {
(a - b).abs()
}
};
let mut means = weighted_quantile_initial_means(pts, weights_in, k, total_mass);
let mut assign = vec![0usize; n];
for _ in 0..100 {
let mut changed = false;
for (i, &p) in pts.iter().enumerate() {
let mut best_j = 0usize;
let mut best_d = f64::INFINITY;
for (j, &m) in means.iter().enumerate() {
let d = circ_dist(p, m);
if d < best_d {
best_d = d;
best_j = j;
}
}
if assign[i] != best_j {
assign[i] = best_j;
changed = true;
}
}
for (j, m) in means.iter_mut().enumerate() {
if circular {
let (mut sx, mut sy, mut mass_j) = (0.0_f64, 0.0_f64, 0.0_f64);
for (i, &p) in pts.iter().enumerate() {
if assign[i] == j {
let wi = weights_in[i];
let ang = std::f64::consts::TAU * p;
sx += wi * ang.cos();
sy += wi * ang.sin();
mass_j += wi;
}
}
if mass_j > 0.0 && (sx * sx + sy * sy) > 0.0 {
*m = (sy.atan2(sx) / std::f64::consts::TAU).rem_euclid(1.0);
}
} else {
let (mut sum, mut mass_j) = (0.0_f64, 0.0_f64);
for (i, &p) in pts.iter().enumerate() {
if assign[i] == j {
let wi = weights_in[i];
sum += wi * p;
mass_j += wi;
}
}
if mass_j > 0.0 {
*m = sum / mass_j;
}
}
}
if !changed {
break;
}
}
let mut mixture_weights = vec![0.0_f64; k];
let mut total_ss = 0.0_f64;
for (i, &p) in pts.iter().enumerate() {
let j = assign[i];
let wi = weights_in[i];
mixture_weights[j] += wi;
let d = if circular {
let raw = (p - means[j]).rem_euclid(1.0);
if raw > 0.5 { raw - 1.0 } else { raw }
} else {
p - means[j]
};
total_ss += wi * d * d;
}
for weight in &mut mixture_weights {
*weight /= total_mass;
}
let shared_sigma = (total_ss / total_mass).sqrt().max(sigma_floor);
let sigmas = vec![shared_sigma; k];
let inv_sqrt_2pi = 1.0 / (std::f64::consts::TAU).sqrt();
let mut loglik = 0.0_f64;
for (i, &p) in pts.iter().enumerate() {
let mut dens = 0.0_f64;
for j in 0..k {
if mixture_weights[j] <= 0.0 {
continue;
}
let s = sigmas[j];
let mut g = 0.0_f64;
let (lo_img, hi_img) = if circular { (-1_i32, 1_i32) } else { (0, 0) };
for m in lo_img..=hi_img {
let d = p - means[j] + m as f64;
g += (-0.5 * (d / s) * (d / s)).exp();
}
dens += mixture_weights[j] * inv_sqrt_2pi / s * g;
}
if !(dens > 0.0) {
return None;
}
loglik += weights_in[i] * dens.ln();
}
if !loglik.is_finite() {
return None;
}
let p_free = (2 * k) as f64;
Some(-2.0 * loglik + p_free * ln_n)
}
fn weighted_quantile_initial_means(
pts: &[f64],
weights: &[f64],
k: usize,
total_mass: f64,
) -> Vec<f64> {
let mut out = Vec::with_capacity(k);
for j in 0..k {
let target = (j as f64 / k as f64) * total_mass;
let mut acc = 0.0_f64;
let mut chosen = pts[0];
for (&p, &w) in pts.iter().zip(weights.iter()) {
acc += w;
if acc >= target {
chosen = p;
break;
}
}
out.push(chosen);
}
out
}
#[derive(Debug, Clone)]
pub struct AtomCoordinateFidelity {
pub topology: &'static str,
pub uniformity_statistic: Option<f64>,
pub uniformity_p_value: Option<f64>,
pub arclength_defect: Option<f64>,
pub n_coords: usize,
pub support_mass: f64,
pub effective_n: f64,
pub support_ess: f64,
pub verdict: AngleFidelityVerdict,
pub certified: bool,
pub coords_u_arc: Option<Array1<f64>>,
pub raw_arclength_defect_rms: Option<f64>,
pub raw_arclength_defect_max: Option<f64>,
pub min_speed_over_mean: Option<f64>,
pub max_speed_over_mean: Option<f64>,
pub log_speed_rms: Option<f64>,
pub chart_honest: bool,
pub occupancy: &'static str,
pub occupancy_anchors: usize,
pub occupancy_d_eff: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct CoordinateFidelityCertificate<'a> {
pub atoms: &'a [Option<AtomCoordinateFidelity>],
}
impl<'a> CoordinateFidelityCertificate<'a> {
pub fn new(atoms: &'a [Option<AtomCoordinateFidelity>]) -> Self {
Self { atoms }
}
}
pub fn atom_coordinate_fidelity(
term: &SaeManifoldTerm,
atom_idx: usize,
) -> Result<Option<AtomCoordinateFidelity>, String> {
let Some(topology) = term.d1_unit_speed_topology(atom_idx) else {
return Ok(None);
};
let coords = term.assignment.coords[atom_idx].as_matrix();
if coords.ncols() != 1 {
return Ok(None);
}
let row_coords = coords.column(0);
let support = SupportMeasure::from_assignment(&term.assignment, atom_idx)?;
let uniformity = coordinate_uniformity_weighted(row_coords, &support, &topology);
let occupancy_law = fold_for_occupancy_weighted(row_coords, support.weights(), &topology)
.map(|(folded, folded_weights)| {
if matches!(topology, CanonicalChartTopology::Circle { .. }) {
classify_occupancy_weighted(&folded, folded_weights.view())
} else {
classify_occupancy_interval_weighted(&folded, folded_weights.view())
}
})
.unwrap_or(OccupancyLaw::Indeterminate);
let atom = &term.atoms[atom_idx];
let evaluator = atom.basis_evaluator.as_ref().ok_or_else(|| {
format!("atom_coordinate_fidelity: atom {atom_idx} has no basis evaluator")
})?;
let defect = crate::chart_canonicalization::chart_unit_speed_defect(
evaluator.as_ref(),
atom.decoder_coefficients().view(),
row_coords,
&topology,
)?;
let reading = chart_arclength_coordinates(
evaluator.as_ref(),
atom.decoder_coefficients().view(),
row_coords,
&topology,
)?;
let topology_label = match topology {
CanonicalChartTopology::Circle { .. } => "circle",
CanonicalChartTopology::Interval => "interval",
};
let is_circle = matches!(topology, CanonicalChartTopology::Circle { .. });
let (
verdict,
coords_u_arc,
raw_arclength_defect_rms,
raw_arclength_defect_max,
min_speed_over_mean,
max_speed_over_mean,
log_speed_rms,
) = match reading {
Some(r) if r.min_speed_over_mean > SAE_FLOW_DIFFEO_MIN_DET => {
let verdict = angle_fidelity_verdict(Some(&r));
let (rms, max) = raw_vs_arclength_defect_weighted(
row_coords,
r.coords_u_arc.view(),
support.weights(),
&topology,
is_circle,
);
(
verdict,
Some(r.coords_u_arc),
Some(rms),
Some(max),
Some(r.min_speed_over_mean),
Some(r.max_speed_over_mean),
Some(r.log_speed_rms),
)
}
Some(r) => (
AngleFidelityVerdict::Degenerate,
None,
None,
None,
Some(r.min_speed_over_mean),
Some(r.max_speed_over_mean),
Some(r.log_speed_rms),
),
None => (
AngleFidelityVerdict::Degenerate,
None,
None,
None,
None,
None,
None,
),
};
Ok(Some(AtomCoordinateFidelity {
topology: topology_label,
uniformity_statistic: uniformity.as_ref().map(|u| u.statistic),
uniformity_p_value: uniformity.as_ref().and_then(|u| u.p_value),
arclength_defect: defect,
n_coords: uniformity.as_ref().map(|u| u.n).unwrap_or(row_coords.len()),
support_mass: support.mass(),
effective_n: support.fisher_n(),
support_ess: support.ess(),
verdict,
certified: verdict.certified(),
coords_u_arc,
raw_arclength_defect_rms,
raw_arclength_defect_max,
min_speed_over_mean,
max_speed_over_mean,
log_speed_rms,
chart_honest: verdict.certified(),
occupancy: occupancy_law.label(),
occupancy_anchors: occupancy_law.anchors(),
occupancy_d_eff: occupancy_law.d_eff(),
}))
}
fn fold_for_occupancy_weighted(
coords: ArrayView1<'_, f64>,
weights: ArrayView1<'_, f64>,
topology: &CanonicalChartTopology,
) -> Option<(Vec<f64>, Array1<f64>)> {
if coords.len() != weights.len() {
return None;
}
if coords.len() < 2 || coords.iter().any(|t| !t.is_finite()) {
return None;
}
match topology {
CanonicalChartTopology::Circle { period } => {
if !(period.is_finite() && *period > 0.0) {
return None;
}
let mut folded = Vec::new();
let mut folded_weights = Vec::new();
for (&t, &w) in coords.iter().zip(weights.iter()) {
if w > 0.0 {
folded.push(t.rem_euclid(*period) / *period);
folded_weights.push(w);
}
}
Some((folded, Array1::from_vec(folded_weights)))
}
CanonicalChartTopology::Interval => {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for (&t, &w) in coords.iter().zip(weights.iter()) {
if !(w > 0.0) {
continue;
}
lo = lo.min(t);
hi = hi.max(t);
}
let span = hi - lo;
let scale = lo.abs().max(hi.abs()).max(1.0);
if !(span > 1.0e-12 * scale) {
return None;
}
let mut folded = Vec::new();
let mut folded_weights = Vec::new();
for (&t, &w) in coords.iter().zip(weights.iter()) {
if w > 0.0 {
folded.push((t - lo) / span);
folded_weights.push(w);
}
}
Some((folded, Array1::from_vec(folded_weights)))
}
}
}
fn raw_vs_arclength_defect_weighted(
raw: ArrayView1<'_, f64>,
u_arc: ArrayView1<'_, f64>,
weights: ArrayView1<'_, f64>,
topology: &CanonicalChartTopology,
is_circle: bool,
) -> (f64, f64) {
let n = raw.len();
if n == 0 || u_arc.len() != n || weights.len() != n {
return (f64::NAN, f64::NAN);
}
let r: Vec<f64> = match topology {
CanonicalChartTopology::Circle { period } => {
raw.iter().map(|&t| (t / period).rem_euclid(1.0)).collect()
}
CanonicalChartTopology::Interval => {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for (&t, &w) in raw.iter().zip(weights.iter()) {
if !(w > 0.0) {
continue;
}
lo = lo.min(t);
hi = hi.max(t);
}
let span = hi - lo;
if !(span > 0.0) {
return (f64::NAN, f64::NAN);
}
raw.iter()
.map(|&t| ((t - lo) / span).clamp(0.0, 1.0))
.collect()
}
};
let circ_dist = |a: f64, b: f64| -> f64 {
let d = (a - b).rem_euclid(1.0);
d.min(1.0 - d)
};
let mut best_rms = f64::INFINITY;
let mut best_max = f64::INFINITY;
for &s in &[1.0_f64, -1.0_f64] {
let c = if is_circle {
let (mut sx, mut sy) = (0.0_f64, 0.0_f64);
for ((ui, ri), wi) in u_arc.iter().zip(r.iter()).zip(weights.iter()) {
if !(*wi > 0.0) {
continue;
}
let diff = ui - s * ri;
let ang = std::f64::consts::TAU * diff;
sx += *wi * ang.cos();
sy += *wi * ang.sin();
}
sy.atan2(sx) / std::f64::consts::TAU
} else {
let mut acc = 0.0_f64;
let mut mass = 0.0_f64;
for ((ui, ri), wi) in u_arc.iter().zip(r.iter()).zip(weights.iter()) {
if !(*wi > 0.0) {
continue;
}
acc += *wi * (ui - s * ri);
mass += *wi;
}
if mass > 0.0 { acc / mass } else { 0.0 }
};
let mut sum_sq = 0.0_f64;
let mut max = 0.0_f64;
let mut mass = 0.0_f64;
for ((ui, ri), wi) in u_arc.iter().zip(r.iter()).zip(weights.iter()) {
if !(*wi > 0.0) {
continue;
}
let aligned = s * ri + c;
let d = if is_circle {
circ_dist(*ui, aligned)
} else {
(ui - aligned).abs()
};
sum_sq += *wi * d * d;
mass += *wi;
max = max.max(d);
}
let rms = if mass > 0.0 {
(sum_sq / mass).sqrt()
} else {
f64::NAN
};
if rms < best_rms {
best_rms = rms;
best_max = max;
}
}
(best_rms, best_max)
}
pub fn prefer_candidate_basin(
candidate_ev: f64,
candidate_uniformity: Option<f64>,
incumbent_ev: f64,
incumbent_uniformity: Option<f64>,
ev_tol: f64,
) -> bool {
if !candidate_ev.is_finite() {
return false;
}
if !incumbent_ev.is_finite() {
return true;
}
if candidate_ev > incumbent_ev + ev_tol {
return true; }
if incumbent_ev > candidate_ev + ev_tol {
return false; }
match (candidate_uniformity, incumbent_uniformity) {
(Some(candidate), Some(incumbent)) => candidate < incumbent,
_ => false,
}
}
pub fn prefer_candidate_state(
candidate_objective: f64,
candidate_ev: f64,
candidate_uniformity: Option<f64>,
incumbent_objective: f64,
incumbent_ev: f64,
incumbent_uniformity: Option<f64>,
objective_rel_tol: f64,
ev_tol: f64,
) -> bool {
if !candidate_objective.is_finite() {
return false;
}
if !incumbent_objective.is_finite() {
return true;
}
let scale =
objective_rel_tol * (1.0 + candidate_objective.abs().max(incumbent_objective.abs()));
if candidate_objective < incumbent_objective - scale {
return true; }
if candidate_objective > incumbent_objective + scale {
return false; }
prefer_candidate_basin(
candidate_ev,
candidate_uniformity,
incumbent_ev,
incumbent_uniformity,
ev_tol,
)
}
impl SaeManifoldTerm {
pub(crate) fn coordinate_uniformity_aggregate(&self) -> Option<f64> {
let mut sum = 0.0_f64;
let mut count = 0usize;
for atom_idx in 0..self.atoms.len() {
let Some(topology) = self.d1_unit_speed_topology(atom_idx) else {
continue;
};
let coords = self.assignment.coords[atom_idx].as_matrix();
if coords.ncols() != 1 {
continue;
}
let atom = &self.atoms[atom_idx];
let defect = atom.basis_evaluator.as_ref().and_then(|evaluator| {
crate::chart_canonicalization::chart_unit_speed_defect(
evaluator.as_ref(),
atom.decoder_coefficients().view(),
coords.column(0),
&topology,
)
.ok()
.flatten()
});
if let Some(d) = defect {
if d.is_finite() {
sum += d;
count += 1;
}
}
}
if count == 0 {
None
} else {
Some(sum / count as f64)
}
}
}
#[cfg(test)]
mod coordinate_fidelity_tests {
use super::*;
use crate::manifold::{
SAE_FINAL_EV_DEGRADATION_TOL, SAE_MANIFOLD_INNER_OBJECTIVE_STALL_REL_TOL, SaeBasisEvaluator,
};
use ndarray::{Array1, Array2, Array3, Array4, Array5, ArrayView2};
#[derive(Debug)]
struct CircleHarmonicEvaluator {
harmonics: usize,
}
impl SaeBasisEvaluator for CircleHarmonicEvaluator {
fn evaluate(
&self,
coords: ArrayView2<'_, f64>,
) -> Result<(Array2<f64>, Array3<f64>), String> {
let n = coords.nrows();
let m = 2 * self.harmonics;
let mut phi = Array2::<f64>::zeros((n, m));
let mut jet = Array3::<f64>::zeros((n, m, 1));
let tau = std::f64::consts::TAU;
for i in 0..n {
let t = coords[[i, 0]];
for h in 1..=self.harmonics {
let w = tau * h as f64;
let c = 2 * (h - 1);
let s = c + 1;
phi[[i, c]] = (w * t).cos();
phi[[i, s]] = (w * t).sin();
jet[[i, c, 0]] = -w * (w * t).sin();
jet[[i, s, 0]] = w * (w * t).cos();
}
}
Ok((phi, jet))
}
fn second_jet_dyn(
&self,
coords: ArrayView2<'_, f64>,
) -> Option<Result<Array4<f64>, String>> {
if coords.ncols() != 1 {
return Some(Err(format!(
"CircleHarmonicEvaluator::second_jet_dyn: d = 1 evaluator got {} coords",
coords.ncols()
)));
}
None
}
fn third_jet_dyn(
&self,
coords: ArrayView2<'_, f64>,
) -> Option<Result<Array5<f64>, String>> {
if coords.ncols() != 1 {
return Some(Err(format!(
"CircleHarmonicEvaluator::third_jet_dyn: d = 1 evaluator got {} coords",
coords.ncols()
)));
}
None
}
}
#[derive(Debug)]
struct IntervalLinearEvaluator;
impl SaeBasisEvaluator for IntervalLinearEvaluator {
fn evaluate(
&self,
coords: ArrayView2<'_, f64>,
) -> Result<(Array2<f64>, Array3<f64>), String> {
let n = coords.nrows();
let mut phi = Array2::<f64>::zeros((n, 2));
let mut jet = Array3::<f64>::zeros((n, 2, 1));
for i in 0..n {
phi[[i, 0]] = 1.0;
phi[[i, 1]] = coords[[i, 0]];
jet[[i, 1, 0]] = 1.0;
}
Ok((phi, jet))
}
fn second_jet_dyn(
&self,
coords: ArrayView2<'_, f64>,
) -> Option<Result<Array4<f64>, String>> {
if coords.ncols() != 1 {
return Some(Err(format!(
"IntervalLinearEvaluator::second_jet_dyn: d = 1 evaluator got {} coords",
coords.ncols()
)));
}
None
}
fn third_jet_dyn(
&self,
coords: ArrayView2<'_, f64>,
) -> Option<Result<Array5<f64>, String>> {
if coords.ncols() != 1 {
return Some(Err(format!(
"IntervalLinearEvaluator::third_jet_dyn: d = 1 evaluator got {} coords",
coords.ncols()
)));
}
None
}
}
fn circle() -> CanonicalChartTopology {
CanonicalChartTopology::Circle { period: 1.0 }
}
fn interval() -> CanonicalChartTopology {
CanonicalChartTopology::Interval
}
#[test]
fn watson_pvalue_matches_tabulated_critical_values() {
let p05 = watson_u2_pvalue(0.187);
let p01 = watson_u2_pvalue(0.267);
assert!(
(p05 - 0.05).abs() < 5.0e-3,
"p(U²=0.187) must be ≈0.05, got {p05}"
);
assert!(
(p01 - 0.01).abs() < 5.0e-3,
"p(U²=0.267) must be ≈0.01, got {p01}"
);
assert!(watson_u2_pvalue(0.05) > watson_u2_pvalue(0.15));
assert!(watson_u2_pvalue(0.15) > watson_u2_pvalue(0.30));
}
#[test]
fn support_metrics_are_shared_by_fidelity_occupancy_and_persistence_reads() {
let weights = Array1::from_vec(vec![1.0, 1.0, 0.5, 0.0]);
let support = SupportMeasure::from_weights(0, weights).unwrap();
let coords = Array1::from_vec(vec![0.0, 0.25, 0.5, 0.9]);
let fidelity = coordinate_uniformity_weighted(coords.view(), &support, &circle()).unwrap();
let (occupancy_rows, occupancy_weights) =
fold_for_occupancy_weighted(coords.view(), support.weights(), &circle()).unwrap();
let persistence_rows = support.positive_rows();
assert_eq!(fidelity.n, occupancy_rows.len());
assert_eq!(fidelity.n, occupancy_weights.len());
assert_eq!(fidelity.n, persistence_rows.len());
assert!((support.mass() - 2.5).abs() < 1e-12);
assert!((support.fisher_n() - 2.25).abs() < 1e-12);
assert!((support.ess() - (2.5_f64 * 2.5 / 2.25)).abs() < 1e-12);
}
#[test]
fn uniformity_is_rotation_and_reflection_invariant() {
let base: Vec<f64> = (0..97)
.map(|i| {
let x = (i as f64 * 0.61803398875).fract();
x * x
})
.collect();
let u0 = watson_u2_uniform(&base).statistic;
let rotated: Vec<f64> = base.iter().map(|&x| (x + 0.37).rem_euclid(1.0)).collect();
let reflected: Vec<f64> = base.iter().map(|&x| (1.0 - x).rem_euclid(1.0)).collect();
let ur = watson_u2_uniform(&rotated).statistic;
let uf = watson_u2_uniform(&reflected).statistic;
assert!(
(u0 - ur).abs() < 1e-9,
"rotation must not change U²: {u0} vs {ur}"
);
assert!(
(u0 - uf).abs() < 1e-9,
"reflection must not change U²: {u0} vs {uf}"
);
}
#[test]
fn arclength_defect_flags_non_unit_speed_chart() {
let ev = CircleHarmonicEvaluator { harmonics: 2 };
let mut unit = Array2::<f64>::zeros((4, 2));
unit[[0, 0]] = 1.3; unit[[1, 1]] = 1.3; let row_coords = Array1::linspace(0.0, 1.0, 32);
let d_unit = crate::chart_canonicalization::chart_unit_speed_defect(
&ev,
unit.view(),
row_coords.view(),
&circle(),
)
.unwrap()
.expect("unit-speed circle must produce a defect");
assert!(
d_unit < 1e-6,
"a constant-speed circle must have ~zero arc-length defect, got {d_unit}"
);
let mut wobbly = unit.clone();
wobbly[[2, 0]] = 0.6; wobbly[[3, 1]] = 0.6; let d_wobbly = crate::chart_canonicalization::chart_unit_speed_defect(
&ev,
wobbly.view(),
row_coords.view(),
&circle(),
)
.unwrap()
.expect("wobbly circle must produce a defect");
assert!(
d_wobbly > 1e-2,
"a non-unit-speed chart must have a positive arc-length defect, got {d_wobbly}"
);
}
#[test]
fn declining_higher_jets_enforce_d1_coords_contract() {
let ev = CircleHarmonicEvaluator { harmonics: 3 };
let good = Array2::<f64>::zeros((5, 1));
assert!(
ev.second_jet_dyn(good.view()).is_none(),
"d = 1 coords must decline the second jet with None"
);
assert!(
ev.third_jet_dyn(good.view()).is_none(),
"d = 1 coords must decline the third jet with None"
);
let bad = Array2::<f64>::zeros((5, 2));
let second = ev
.second_jet_dyn(bad.view())
.expect("wrong-dimension coords must not silently decline the second jet");
assert!(
second.is_err(),
"second_jet_dyn must reject d != 1 coords, got {second:?}"
);
let third = ev
.third_jet_dyn(bad.view())
.expect("wrong-dimension coords must not silently decline the third jet");
assert!(
third.is_err(),
"third_jet_dyn must reject d != 1 coords, got {third:?}"
);
}
#[test]
fn prefer_candidate_basin_prices_ev_then_uniformity() {
let tol = SAE_FINAL_EV_DEGRADATION_TOL;
assert!(prefer_candidate_basin(
0.90,
Some(0.5),
0.80,
Some(0.01),
tol
));
assert!(!prefer_candidate_basin(
0.80,
Some(0.01),
0.90,
Some(0.5),
tol
));
assert!(prefer_candidate_basin(
0.90,
Some(0.02),
0.9005,
Some(0.20),
tol
));
assert!(!prefer_candidate_basin(
0.90,
Some(0.20),
0.9005,
Some(0.02),
tol
));
assert!(!prefer_candidate_basin(
0.90,
Some(0.05),
0.90,
Some(0.05),
tol
));
assert!(!prefer_candidate_basin(0.90, None, 0.90, Some(0.05), tol));
assert!(!prefer_candidate_basin(
f64::NAN,
Some(0.0),
0.5,
Some(0.5),
tol
));
}
#[test]
fn prefer_candidate_state_prices_objective_then_ev() {
let objective_tol = SAE_MANIFOLD_INNER_OBJECTIVE_STALL_REL_TOL;
let ev_tol = SAE_FINAL_EV_DEGRADATION_TOL;
assert!(prefer_candidate_state(
100.0,
0.13,
Some(0.5),
200.0,
0.65,
Some(0.01),
objective_tol,
ev_tol,
));
assert!(!prefer_candidate_state(
200.0,
0.65,
Some(0.01),
100.0,
0.13,
Some(0.5),
objective_tol,
ev_tol,
));
assert!(prefer_candidate_state(
100.0,
0.65,
Some(0.5),
100.0 + 0.5 * objective_tol,
0.13,
Some(0.01),
objective_tol,
ev_tol,
));
assert!(prefer_candidate_state(
100.0,
0.65,
Some(0.02),
100.0,
0.6502,
Some(0.20),
objective_tol,
ev_tol,
));
assert!(!prefer_candidate_state(
f64::NAN,
0.9,
Some(0.0),
100.0,
0.1,
Some(0.5),
objective_tol,
ev_tol,
));
assert!(prefer_candidate_state(
100.0,
0.1,
None,
f64::INFINITY,
0.9,
None,
objective_tol,
ev_tol,
));
assert!(prefer_candidate_state(
83_999.0,
0.13,
Some(0.5),
84_000.0,
0.65,
Some(0.01),
objective_tol,
ev_tol,
));
}
#[test]
fn arclength_reading_is_identity_on_a_unit_speed_circle() {
use crate::chart_canonicalization::chart_arclength_coordinates;
let ev = CircleHarmonicEvaluator { harmonics: 2 };
let mut unit = Array2::<f64>::zeros((4, 2));
unit[[0, 0]] = 1.3; unit[[1, 1]] = 1.3; let rows = Array1::linspace(0.0, 0.97, 40);
let reading = chart_arclength_coordinates(&ev, unit.view(), rows.view(), &circle())
.unwrap()
.expect("unit-speed circle yields a reading");
for (i, &t) in rows.iter().enumerate() {
let d = (reading.coords_u_arc[i] - t).rem_euclid(1.0);
let circ = d.min(1.0 - d);
assert!(
circ < 1e-6,
"u_arc must equal raw t on a unit-speed circle: {circ}"
);
}
assert!(
reading.speed_cv < 1e-6,
"flat speed ⇒ ~zero CV, got {}",
reading.speed_cv
);
assert!((reading.min_speed_over_mean - 1.0).abs() < 1e-6);
assert!((reading.max_speed_over_mean - 1.0).abs() < 1e-6);
assert_eq!(
angle_fidelity_verdict(Some(&reading)),
AngleFidelityVerdict::ArcLengthHonest
);
let unit = Array1::<f64>::ones(rows.len());
let (rms, max) = raw_vs_arclength_defect_weighted(
rows.view(),
reading.coords_u_arc.view(),
unit.view(),
&circle(),
true,
);
assert!(
rms < 1e-6 && max < 1e-6,
"honest chart has ~zero raw defect: rms={rms} max={max}"
);
}
#[test]
fn arclength_reading_is_affine_on_a_linear_interval() {
use crate::chart_canonicalization::chart_arclength_coordinates;
let ev = IntervalLinearEvaluator;
let mut decoder = Array2::<f64>::zeros((2, 2));
decoder[[0, 0]] = 0.7;
decoder[[0, 1]] = -0.2;
decoder[[1, 0]] = 1.5;
decoder[[1, 1]] = -0.5;
let rows = Array1::linspace(-0.4, 1.3, 37);
let reading = chart_arclength_coordinates(&ev, decoder.view(), rows.view(), &interval())
.unwrap()
.expect("linear interval yields a reading");
let lo = rows[0];
let span = rows[rows.len() - 1] - lo;
for (i, &t) in rows.iter().enumerate() {
let expected = (t - lo) / span;
assert!(
(reading.coords_u_arc[i] - expected).abs() < 1e-9,
"linear interval u_arc must be affine: got {}, expected {}",
reading.coords_u_arc[i],
expected
);
}
assert!(reading.speed_cv < 1e-9, "linear segment has constant speed");
assert_eq!(
angle_fidelity_verdict(Some(&reading)),
AngleFidelityVerdict::ArcLengthHonest
);
}
#[test]
fn arclength_reading_recovers_and_certifies_a_wobbly_circle() {
use crate::chart_canonicalization::chart_arclength_coordinates;
let ev = CircleHarmonicEvaluator { harmonics: 2 };
let mut wobbly = Array2::<f64>::zeros((4, 2));
wobbly[[0, 0]] = 1.3;
wobbly[[1, 1]] = 1.3;
wobbly[[2, 0]] = 0.2; wobbly[[3, 1]] = 0.2; let rows = Array1::linspace(0.0, 0.98, 64);
let reading = chart_arclength_coordinates(&ev, wobbly.view(), rows.view(), &circle())
.unwrap()
.expect("wobbly circle yields a reading");
assert!(
reading.speed_cv > 1e-2,
"wobbly chart must have a positive speed CV, got {}",
reading.speed_cv
);
assert!(reading.min_speed_over_mean < 1.0 && reading.max_speed_over_mean > 1.0);
assert!(reading.min_speed_over_mean > SAE_FLOW_DIFFEO_MIN_DET);
assert_eq!(
angle_fidelity_verdict(Some(&reading)),
AngleFidelityVerdict::RecoverableViaArcLength
);
let unit = Array1::<f64>::ones(rows.len());
let (rms, _max) = raw_vs_arclength_defect_weighted(
rows.view(),
reading.coords_u_arc.view(),
unit.view(),
&circle(),
true,
);
assert!(
rms > 1e-2,
"u_arc must materially differ from raw t on a squished chart, got rms={rms}"
);
}
#[test]
fn arclength_reading_flags_a_cusped_chart_degenerate() {
use crate::chart_canonicalization::chart_arclength_coordinates;
let ev = CircleHarmonicEvaluator { harmonics: 2 };
let mut cusped = Array2::<f64>::zeros((4, 2));
cusped[[0, 0]] = 1.0; cusped[[1, 1]] = 1.0;
cusped[[2, 0]] = 0.5; cusped[[3, 1]] = 0.5;
let rows = Array1::linspace(0.0, 0.98, 64);
let reading = chart_arclength_coordinates(&ev, cusped.view(), rows.view(), &circle())
.unwrap()
.expect("a cusped-but-finite chart still yields a reading");
assert!(
reading.min_speed_over_mean < SAE_FLOW_DIFFEO_MIN_DET,
"a cusped chart must have a collapsing min speed, got {}",
reading.min_speed_over_mean
);
assert_eq!(
angle_fidelity_verdict(Some(&reading)),
AngleFidelityVerdict::Degenerate
);
}
#[test]
fn angle_fidelity_verdict_uses_derived_thresholds() {
use crate::chart_canonicalization::{ChartArcLengthReading, UNIT_SPEED_INLOOP_DEFECT_TOL};
let mk = |speed_cv: f64, min_over: f64, max_over: f64| ChartArcLengthReading {
coords_u_arc: Array1::zeros(1),
speed_cv,
log_speed_rms: 0.0,
min_speed_over_mean: min_over,
max_speed_over_mean: max_over,
total_arc_length: 1.0,
};
assert_eq!(
angle_fidelity_verdict(Some(&mk(0.1 * UNIT_SPEED_INLOOP_DEFECT_TOL, 1.0, 1.0))),
AngleFidelityVerdict::ArcLengthHonest
);
assert_eq!(
angle_fidelity_verdict(Some(&mk(0.3, 2.0 * SAE_FLOW_DIFFEO_MIN_DET, 1.8))),
AngleFidelityVerdict::RecoverableViaArcLength
);
assert_eq!(
angle_fidelity_verdict(Some(&mk(0.3, 0.5 * SAE_FLOW_DIFFEO_MIN_DET, 3.0))),
AngleFidelityVerdict::Degenerate
);
assert_eq!(
angle_fidelity_verdict(None),
AngleFidelityVerdict::Degenerate
);
assert!(AngleFidelityVerdict::ArcLengthHonest.certified());
assert!(AngleFidelityVerdict::RecoverableViaArcLength.certified());
assert!(!AngleFidelityVerdict::Degenerate.certified());
}
#[test]
fn raw_vs_arclength_defect_is_gauge_invariant() {
let n = 80;
let raw = Array1::linspace(0.0, 1.0 - 1.0 / n as f64, n);
let u_arc = Array1::from_iter(raw.iter().map(|&t| (0.5 * t * t + 0.5 * t).rem_euclid(1.0)));
let unit = Array1::<f64>::ones(raw.len());
let (rms0, _) = raw_vs_arclength_defect_weighted(
raw.view(),
u_arc.view(),
unit.view(),
&circle(),
true,
);
let rotated = Array1::from_iter(raw.iter().map(|&t| (t + 0.31).rem_euclid(1.0)));
let reflected = Array1::from_iter(raw.iter().map(|&t| (1.0 - t).rem_euclid(1.0)));
let (rms_rot, _) = raw_vs_arclength_defect_weighted(
rotated.view(),
u_arc.view(),
unit.view(),
&circle(),
true,
);
let (rms_ref, _) = raw_vs_arclength_defect_weighted(
reflected.view(),
u_arc.view(),
unit.view(),
&circle(),
true,
);
assert!(
(rms0 - rms_rot).abs() < 1e-9,
"rotation must not change the defect: {rms0} vs {rms_rot}"
);
assert!(
(rms0 - rms_ref).abs() < 1e-9,
"reflection must not change the defect: {rms0} vs {rms_ref}"
);
}
}