diff --git a/libs/@local/graph/atlas/benches/math_kernels.rs b/libs/@local/graph/atlas/benches/math_kernels.rs index 128fe122608..cdef6508ae2 100644 --- a/libs/@local/graph/atlas/benches/math_kernels.rs +++ b/libs/@local/graph/atlas/benches/math_kernels.rs @@ -48,7 +48,7 @@ clippy::integer_division_remainder_used, clippy::significant_drop_tightening, reason = "benchmark fixtures compute deterministic floating-point inputs, and Criterion owns \ - group drops; the crate-level expectations in lib.rs do not extend to bench targets" + group drops. The crate-level expectations in lib.rs do not extend to bench targets" )] use core::{hint::black_box, time::Duration}; @@ -327,7 +327,8 @@ fn hardware_counter(event: &str) -> Criterion = drawable(skeleton).into_iter().collect(); let interior: Vec<(f64, f64)> = drawable(interior).into_iter().collect(); - let [horizontal, vertical] = map_bounds(&placement.positions, inner); let canvas = Canvas::default() .block(block) @@ -96,20 +101,22 @@ fn drawable(positions: &[Vec2]) -> impl IntoIterator { /// The map's viewport, which is the placement's own extent squared against the pane's dot grid. /// /// A braille cell is [`DOTS_ACROSS`] dots wide and [`DOTS_DOWN`] tall over a terminal cell about -/// twice as tall as it is wide, so a dot is approximately square. Equal data units per dot on both -/// axes is what keeps the atlas its own shape instead of a version stretched to fill the frame. The -/// extent grows to the grid's aspect first and then by [`MAP_MARGIN`]. A placement with no extent -/// of its own grows to [`MINIMUM_EXTENT`] instead, so its rows sit in the middle of a frame rather -/// than dividing by zero. -pub(super) fn map_bounds(positions: &[Vec2], inner: Rect) -> [[f64; 2]; 2] { +/// twice as tall as it is wide, which leaves each dot approximately square. Growing to the grid's +/// aspect ratio before applying [`MAP_MARGIN`] gives both axes equal data units per dot in exact +/// arithmetic. +/// A collapsed placement first grows to [`MINIMUM_EXTENT`] to provide a positive extent. +/// +/// Returns [`None`] when growth requires a corner beyond the finite `f32` range. With no finite +/// position, returns a unit viewport around the origin. Corner rounding follows +/// [`Bounds2::with_aspect_ratio`] and can change the achieved aspect ratio. +pub(super) fn map_bounds(positions: &[Vec2], inner: Rect) -> Option<[[f64; 2]; 2]> { let Some(bounds) = Bounds2::from_points( positions .iter() .copied() .filter(|position| position.is_finite()), ) else { - // Nothing placeable to draw yet. - return [[-1.0, 1.0], [-1.0, 1.0]]; + return Some([[-1.0, 1.0], [-1.0, 1.0]]); }; let across = (f32::from(inner.width) * f32::from(DOTS_ACROSS)).max(1.0); @@ -117,14 +124,14 @@ pub(super) fn map_bounds(positions: &[Vec2], inner: Rect) -> [[f64; 2]; 2] { let aspect = Positive::new(across / down).unwrap_or(Positive::ONE); let viewport = bounds - .with_minimum_extent(MINIMUM_EXTENT) - .with_aspect_ratio(aspect) - .scaled_about_centre(MAP_MARGIN); + .with_minimum_extent(MINIMUM_EXTENT)? + .with_aspect_ratio(aspect)? + .scaled_about_centre(MAP_MARGIN)?; - [ + Some([ [f64::from(viewport.min().x()), f64::from(viewport.max().x())], [f64::from(viewport.min().y()), f64::from(viewport.max().y())], - ] + ]) } /// The map's footer, showing how many rows it is drawing and how many of them are the skeleton. diff --git a/libs/@local/graph/atlas/src/cli/tui/render/tests.rs b/libs/@local/graph/atlas/src/cli/tui/render/tests.rs index f7360b853c6..e7b5a2b13f6 100644 --- a/libs/@local/graph/atlas/src/cli/tui/render/tests.rs +++ b/libs/@local/graph/atlas/src/cli/tui/render/tests.rs @@ -498,7 +498,8 @@ fn the_map_keeps_the_placement_square() { [Vec2::new(-8.0, -1.0), Vec2::new(8.0, 1.0)], [Vec2::new(-0.5, -12.0), Vec2::new(0.5, 12.0)], ] { - let [horizontal, vertical] = map_bounds(&placement, inner); + let [horizontal, vertical] = + map_bounds(&placement, inner).expect("should represent an ordinary placement viewport"); let across = (horizontal[1] - horizontal[0]) / (f64::from(inner.width) * 2.0); let down = (vertical[1] - vertical[0]) / (f64::from(inner.height) * 4.0); @@ -546,11 +547,13 @@ fn a_placement_with_no_extent_still_has_a_viewport() { // A collapsed placement and a frame with nothing finite in it // both draw a box rather than a degenerate one. - let [horizontal, vertical] = map_bounds(&[Vec2::new(3.0, 3.0); 4], inner); + let [horizontal, vertical] = + map_bounds(&[Vec2::new(3.0, 3.0); 4], inner).expect("should widen the collapsed placement"); assert!(horizontal[0] < horizontal[1], "{horizontal:?}"); assert!(vertical[0] < vertical[1], "{vertical:?}"); - let [horizontal, vertical] = map_bounds(&[Vec2::new(f32::NAN, 0.0)], inner); + let [horizontal, vertical] = + map_bounds(&[Vec2::new(f32::NAN, 0.0)], inner).expect("should preserve the empty viewport"); assert!(horizontal[0] < horizontal[1], "{horizontal:?}"); assert!(vertical[0] < vertical[1], "{vertical:?}"); } diff --git a/libs/@local/graph/atlas/src/file/generation/tests.rs b/libs/@local/graph/atlas/src/file/generation/tests.rs index 79d0464a333..83ac720e582 100644 --- a/libs/@local/graph/atlas/src/file/generation/tests.rs +++ b/libs/@local/graph/atlas/src/file/generation/tests.rs @@ -27,8 +27,8 @@ use crate::{ }, integrity::{Sha256, Sha256Digest, Update as _}, math::{ - AffinityCurve, Bounds2, Vec2, d_non_negative, d_positive, non_negative, open_unit_fraction, - unit_fraction, + AffinityCurve, Bounds2, Vec2, d_non_negative, d_positive, non_negative, nz, + open_unit_fraction, positive, unit_fraction, }, morton::Depth, salt::{ @@ -84,8 +84,7 @@ fn config(seed: u64) -> FitConfig { maximum_count: NonZero::new(2).expect("the fixture capacity is nonzero"), .. }, - curve: AffinityCurve::new(1.577, 0.895) - .expect("the fixture parameters are finite and strictly positive"), + curve: AffinityCurve::new(positive!(1.577), positive!(0.895)), .. } } diff --git a/libs/@local/graph/atlas/src/file/salt/tests.rs b/libs/@local/graph/atlas/src/file/salt/tests.rs index b891d58e4a3..52697364da0 100644 --- a/libs/@local/graph/atlas/src/file/salt/tests.rs +++ b/libs/@local/graph/atlas/src/file/salt/tests.rs @@ -97,8 +97,7 @@ fn config() -> FitConfig { maximum_count: NonZero::new(4_096).expect("the fixture capacity is nonzero"), .. }, - curve: AffinityCurve::new(1.577, 0.895) - .expect("the fixture parameters are finite and strictly positive"), + curve: AffinityCurve::new(positive!(1.577), positive!(0.895)), placement: placement(), policy: PolicyOptions { overrides: vec![PolicyOverride { diff --git a/libs/@local/graph/atlas/src/lib.rs b/libs/@local/graph/atlas/src/lib.rs index 40e9554adeb..ec4b49a5daf 100644 --- a/libs/@local/graph/atlas/src/lib.rs +++ b/libs/@local/graph/atlas/src/lib.rs @@ -142,6 +142,7 @@ #![allow( unused_crate_dependencies, unused_features, + unused_macros, dead_code, unreachable_pub, unused_imports, diff --git a/libs/@local/graph/atlas/src/math/affinity/fit.rs b/libs/@local/graph/atlas/src/math/affinity/fit.rs index f9560f59360..0ba2b99664c 100644 --- a/libs/@local/graph/atlas/src/math/affinity/fit.rs +++ b/libs/@local/graph/atlas/src/math/affinity/fit.rs @@ -13,7 +13,9 @@ use core::num::NonZero; use super::AffinityCurve; -use crate::math::{DNonNegative, DPositive, Positive, positive, scalar::narrow_f32}; +use crate::math::{ + DFinite, DNonNegative, DPositive, Derivation, Positive, d_finite, d_positive, positive, +}; /// Sample count and distance range for the least-squares target. /// @@ -140,7 +142,7 @@ impl AffinityCurve { } })?; - Self::new(narrow_f32(a.get())?, narrow_f32(b.get())?) + Some(Self::new(a.narrow()?, b.narrow()?)) } } @@ -193,38 +195,50 @@ impl SampleGrid { #[derive(Debug, Copy, Clone)] struct NormalEquations { /// Sum of squared residuals, the objective the fit minimizes. - residual_sum_of_squares: f64, + residual_sum_of_squares: DFinite, /// The `a`-`a` entry of the normal matrix. - j_aa: f64, + j_aa: DFinite, /// The symmetric off-diagonal entry of the normal matrix. - j_ab: f64, + j_ab: DFinite, /// The `b`-`b` entry of the normal matrix. - j_bb: f64, - /// The `a` component of the gradient. - g_a: f64, - /// The `b` component of the gradient. - g_b: f64, + j_bb: DFinite, + /// The `a` component of Jᵀr. + g_a: DFinite, + /// The `b` component of Jᵀr. + g_b: DFinite, } -impl NormalEquations { - /// The additive identity every accumulation pass starts from. +/// Unvalidated sums for one objective and Jacobian evaluation. +struct NormalEquationsDerivation { + residual_sum_of_squares: Derivation, + j_aa: Derivation, + j_ab: Derivation, + j_bb: Derivation, + g_a: Derivation, + g_b: Derivation, +} + +impl NormalEquationsDerivation { + /// Empty sums before the first sample. const ZERO: Self = Self { - residual_sum_of_squares: 0.0, - j_aa: 0.0, - j_ab: 0.0, - j_bb: 0.0, - g_a: 0.0, - g_b: 0.0, + residual_sum_of_squares: Derivation::ZERO, + j_aa: Derivation::ZERO, + j_ab: Derivation::ZERO, + j_bb: Derivation::ZERO, + g_a: Derivation::ZERO, + g_b: Derivation::ZERO, }; - /// Returns whether every accumulated sum is finite. - const fn is_finite(self) -> bool { - self.residual_sum_of_squares.is_finite() - && self.j_aa.is_finite() - && self.j_ab.is_finite() - && self.j_bb.is_finite() - && self.g_a.is_finite() - && self.g_b.is_finite() + /// Validates the accumulated sums, returning [`None`] if any is non-finite. + fn finish(self) -> Option { + Some(NormalEquations { + residual_sum_of_squares: self.residual_sum_of_squares.finish().ok()?, + j_aa: self.j_aa.finish().ok()?, + j_ab: self.j_ab.finish().ok()?, + j_bb: self.j_bb.finish().ok()?, + g_a: self.g_a.finish().ok()?, + g_b: self.g_b.finish().ok()?, + }) } } @@ -334,13 +348,16 @@ fn evaluate( a: DPositive, b: DPositive, ) -> Option { - let mut sums = NormalEquations::ZERO; + let mut sums = NormalEquationsDerivation::ZERO; + let exponent = (d_positive!(2.0) * b).finish().ok()?; for index in 0..grid.samples { let distance = grid.distance(index); - let power = distance.powf(2.0 * b).get(); - let denominator = a.get().mul_add(power, 1.0); - let residual = 1.0 / denominator - target(distance); + let power = distance.powf(exponent.into()); + + let denominator = Derivation::from(DNonNegative::from(a)).mul_add(power, DPositive::ONE); + let residual = Derivation::from(DFinite::ONE) / denominator - target(distance); + sums.residual_sum_of_squares = residual.mul_add(residual, sums.residual_sum_of_squares); // the zero-distance sample contributes residual error with zero parameter partials @@ -349,8 +366,9 @@ fn evaluate( }; let denominator_squared = denominator * denominator; - let partial_a = -power / denominator_squared; - let partial_b = -(2.0 * a * power * distance.ln()) / denominator_squared; + let partial_a = (Derivation::from(-DFinite::ONE) * power) / denominator_squared; + let partial_b = (d_finite!(-2.0) * a * power * distance.ln()) / denominator_squared; + sums.j_aa = partial_a.mul_add(partial_a, sums.j_aa); sums.j_ab = partial_a.mul_add(partial_b, sums.j_ab); sums.j_bb = partial_b.mul_add(partial_b, sums.j_bb); @@ -358,7 +376,7 @@ fn evaluate( sums.g_b = partial_b.mul_add(residual, sums.g_b); } - sums.is_finite().then_some(sums) + sums.finish() } /// Solves the multiplicatively damped 2x2 normal system. @@ -372,23 +390,56 @@ fn evaluate( /// is [`f64::EPSILON`], or when a computed step is non-finite. This numerical floor rejects /// near-cancellation, without certifying exact conditioning. fn solve_damped(equations: NormalEquations, damping: f64) -> Option<(f64, f64)> { - let damped_aa = equations.j_aa * (1.0 + damping); - let damped_bb = equations.j_bb * (1.0 + damping); - let determinant = damped_aa.mul_add(damped_bb, -(equations.j_ab * equations.j_ab)); - - // The damped matrix is positive definite in exact arithmetic; at or - // below the floor the closed form divides cancellation noise. - if !determinant.is_finite() || determinant <= f64::EPSILON * damped_aa * damped_bb { + let damped_aa = Derivation::from(equations.j_aa) * (1.0 + damping); + let damped_bb = Derivation::from(equations.j_bb) * (1.0 + damping); + let determinant = damped_aa + .mul_add(damped_bb, -(equations.j_ab * equations.j_ab)) + .finish() + .ok()? + .positive()?; + + // compare determinant cancellation against the product scale of both damped diagonals + let determinant_floor = (damped_aa * d_positive!(f64::EPSILON) * damped_bb) + .finish() + .ok()?; + if DFinite::from(determinant) <= determinant_floor { return None; } - let step_a = equations - .j_ab + let step_a = Derivation::from(equations.j_ab) .mul_add(equations.g_b, -(damped_bb * equations.g_a)) / determinant; - let step_b = equations - .j_ab + let step_b = Derivation::from(equations.j_ab) .mul_add(equations.g_a, -(damped_aa * equations.g_b)) / determinant; - (step_a.is_finite() && step_b.is_finite()).then_some((step_a, step_b)) + Some((step_a.finish().ok()?.get(), step_b.finish().ok()?.get())) +} + +#[cfg(test)] +mod tests { + use super::{SampleGrid, evaluate}; + use crate::math::{DPositive, d_positive}; + + #[test] + fn evaluation_power_overflow() { + // The affinity rounds to zero, but the parameter partials contain ∞/∞. + let equations = evaluate( + SampleGrid::new(2, d_positive!(1e200)), + &|_| 0.0, + DPositive::ONE, + DPositive::ONE, + ); + assert!(equations.is_none()); + } + + #[test] + fn evaluation_exponent_overflow() { + let equations = evaluate( + SampleGrid::new(2, DPositive::ONE), + &|_| 0.0, + DPositive::ONE, + d_positive!(f64::MAX), + ); + assert!(equations.is_none()); + } } diff --git a/libs/@local/graph/atlas/src/math/affinity/mod.rs b/libs/@local/graph/atlas/src/math/affinity/mod.rs index a927f90994c..493a0cdf9a8 100644 --- a/libs/@local/graph/atlas/src/math/affinity/mod.rs +++ b/libs/@local/graph/atlas/src/math/affinity/mod.rs @@ -32,18 +32,18 @@ use core::simd::{Select as _, Simd, cmp::SimdPartialOrd as _, num::SimdFloat as _}; use super::{ - NonNegative, + Derivation, Finite, NonNegative, Positive, kernel::{mul_add_f32x4, pow_f32x4}, - non_negative, + non_negative, positive, vec2::{Vec2, Vec2x4T}, }; mod fit; #[cfg(test)] -pub(crate) use self::fit::AffinityFitConfig; +mod tests; #[cfg(test)] -mod tests; +pub(crate) use self::fit::AffinityFitConfig; /// A positive-parameter affinity curve for layout distances. /// @@ -69,10 +69,10 @@ mod tests; /// assert!(gradient.x() < 0.0); /// assert_eq!(gradient.y(), 0.0); /// ``` -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct AffinityCurve { - a: f32, - b: f32, + a: Positive, + b: Positive, } impl AffinityCurve { @@ -90,21 +90,21 @@ impl AffinityCurve { /// Creates a curve from its fitted parameters. #[must_use] - pub(crate) fn new(a: f32, b: f32) -> Option { - (a.is_finite() && a > 0.0 && b.is_finite() && b > 0.0).then_some(Self { a, b }) + pub(crate) const fn new(a: Positive, b: Positive) -> Self { + Self { a, b } } /// Returns the coefficient controlling the affinity's distance scale. #[inline] #[must_use] - pub(crate) const fn a(self) -> f32 { + pub(crate) const fn a(self) -> Positive { self.a } /// Returns the exponent shaping the affinity's decay. #[inline] #[must_use] - pub(crate) const fn b(self) -> f32 { + pub(crate) const fn b(self) -> Positive { self.b } @@ -115,12 +115,14 @@ impl AffinityCurve { /// strict monotonicity or ULP guarantee is made for the approximation. Overflow in the positive /// denominator can produce a zero affinity. #[must_use] - pub(crate) fn affinity(self, distance_squared: f32) -> f32 { + pub(crate) fn affinity(self, distance_squared: NonNegative) -> f32 { if distance_squared <= 0.0 { return 1.0; } - self.a.mul_add(distance_squared.powf(self.b), 1.0).recip() + let raised = distance_squared.powf(self.b.into()); + let denominator = Derivation::from(self.a).mul_add(raised, Positive::ONE); + (Derivation::from(NonNegative::ONE) / denominator).into_raw() } /// Computes the clipped attraction gradients of four point pairs. @@ -136,11 +138,15 @@ impl AffinityCurve { pub(crate) fn attraction_x4(self, from: Vec2x4T, to: Vec2x4T) -> Vec2x4T { let distance_squared = from.distance_squared(to); - // Shared power: d^(2b - 2), with d^(2b) recovered by one multiply. - let power = pow_f32x4(distance_squared, Simd::splat(self.b - 1.0)); - let coefficient = (Simd::splat(-2.0 * self.a * self.b) * power) + // share ρ^(b−1) between the numerator and denominator, with ρᵇ recovered by multiplication + let power = pow_f32x4( + distance_squared, + Simd::splat((self.b - Positive::ONE).get()), + ); + let scale = (-Finite::from(positive!(2.0)) * self.a) * self.b; + let coefficient = (Simd::splat(scale.into_raw()) * power) / mul_add_f32x4( - Simd::splat(self.a) * power, + Simd::splat(self.a.get()) * power, distance_squared, Simd::splat(1.0), ); @@ -167,14 +173,15 @@ impl AffinityCurve { self, from: Vec2x4T, to: Vec2x4T, - repulsion_strength: f32, + repulsion_strength: NonNegative, ) -> Vec2x4T { let distance_squared = from.distance_squared(to); - let power = pow_f32x4(distance_squared, Simd::splat(self.b)); + let power = pow_f32x4(distance_squared, Simd::splat(self.b.get())); let denominator = (Simd::splat(Self::REPULSION_GUARD.get()) + distance_squared) - * mul_add_f32x4(Simd::splat(self.a), power, Simd::splat(1.0)); - let coefficient = Simd::splat(2.0 * repulsion_strength * self.b) / denominator; + * mul_add_f32x4(Simd::splat(self.a.get()), power, Simd::splat(1.0)); + let scale = (positive!(2.0) * repulsion_strength) * self.b; + let coefficient = Simd::splat(scale.into_raw()) / denominator; // Coincident pairs: no direction to push along. let coefficient = distance_squared @@ -197,11 +204,13 @@ impl AffinityCurve { return Vec2::ZERO; } - let power = distance_squared.powf(self.b - 1.0); - let coefficient = (-2.0 * self.a * self.b * power) - / (self.a * power).mul_add(distance_squared.get(), 1.0); + let power = distance_squared.powf(self.b - Positive::ONE); + let numerator = (-Finite::from(positive!(2.0)) * self.a) * self.b * power; + let denominator = + (Derivation::from(self.a) * power).mul_add(distance_squared, Positive::ONE); + let coefficient = numerator / denominator; - clip_vec2((from - to) * coefficient) + clip_vec2((from - to) * coefficient.into_raw()) } /// Computes the clipped repulsion gradient of a single point pair. @@ -211,17 +220,18 @@ impl AffinityCurve { /// squared distance returns zero. The coefficient and scaled differences must avoid NaNs for a /// finite clipped result. Scalar and SIMD values can differ. #[must_use] - pub(crate) fn repulsion(self, from: Vec2, to: Vec2, repulsion_strength: f32) -> Vec2 { + pub(crate) fn repulsion(self, from: Vec2, to: Vec2, repulsion_strength: NonNegative) -> Vec2 { let distance_squared = from.distance_squared(to); if distance_squared <= 0.0 { return Vec2::ZERO; } - let denominator = (Self::REPULSION_GUARD + distance_squared) - * self.a.mul_add(distance_squared.powf(self.b).get(), 1.0); - let coefficient = 2.0 * repulsion_strength * self.b / denominator; + let power = distance_squared.powf(self.b.into()); + let denominator = (Derivation::from(Self::REPULSION_GUARD) + distance_squared) + * Derivation::from(self.a).mul_add(power, Positive::ONE); + let coefficient = (positive!(2.0) * repulsion_strength) * self.b / denominator; - clip_vec2((from - to) * coefficient) + clip_vec2((from - to) * coefficient.into_raw()) } } diff --git a/libs/@local/graph/atlas/src/math/affinity/tests.rs b/libs/@local/graph/atlas/src/math/affinity/tests.rs index 101f737e5bd..7a0d3608a98 100644 --- a/libs/@local/graph/atlas/src/math/affinity/tests.rs +++ b/libs/@local/graph/atlas/src/math/affinity/tests.rs @@ -15,13 +15,17 @@ use super::{ AffinityFitConfig, fit::{SampleGrid, fit_curve}, }; -use crate::math::{AffinityCurve, Positive, Vec2, Vec2x4T, d_positive, positive, tests::POINTS}; +use crate::math::{ + AffinityCurve, NonNegative, Positive, Vec2, Vec2x4T, d_positive, non_negative, positive, + tests::POINTS, +}; -/// The reference parameters for spread 1.0, minimum distance 0.1. -// Provenance: umap-learn's `find_ab_params(spread=1.0, min_dist=0.1)` -// yields a ≈ 1.5769, b ≈ 0.8951 over the same 300-sample grid. -const CURVE_A: f32 = 1.577; -const CURVE_B: f32 = 0.895; +/// The rounded reference coefficient for spread 1.0 and minimum distance 0.1. +// umap-learn's find_ab_params uses the same 300-sample target, with recorded a ≈ 1.5769 and b ≈ +// 0.8951 +const CURVE_A: Positive = positive!(1.577); +/// The rounded reference exponent paired with [`CURVE_A`]. +const CURVE_B: Positive = positive!(0.895); /// Computes the target residual sum of squares with a separate `f64` loop. /// @@ -45,7 +49,7 @@ fn reference_rss(spread: f64, minimum_distance: f64, curve_a: f64, curve_b: f64) /// Creates the curve with parameters [`CURVE_A`] and [`CURVE_B`]. fn curve() -> AffinityCurve { - AffinityCurve::new(CURVE_A, CURVE_B).expect("reference parameters are positive and finite") + AffinityCurve::new(CURVE_A, CURVE_B) } /// Computes the attraction update using separate `f64` powers. @@ -174,7 +178,7 @@ fn fit_recovers_the_parameters_of_an_exact_affinity_target() { let grid = SampleGrid::new(300, d_positive!(3.0 / 299.0)); let (fitted_a, fitted_b) = fit_curve(grid, |distance| { - 1.0 / (1.0 + known_a * distance.powf(2.0 * known_b)) + 1.0 / (1.0 + known_a * f64::from(distance).powf(2.0 * known_b)) }) .expect("an exact affinity target is well-conditioned"); @@ -233,15 +237,18 @@ fn fitted_curve_tracks_its_target_falloff() { .expect("the inputs are well-conditioned"); // Inside the minimum distance the target membership is 1. - assert_eq!(fitted.affinity(0.0), 1.0); - assert!(fitted.affinity(0.25 * 0.25) > 0.9); + assert_eq!(fitted.affinity(NonNegative::ZERO), 1.0); + assert!(fitted.affinity(non_negative!(0.25 * 0.25)) > 0.9); // Beyond it the curve tracks the exponential falloff to within // 0.05: the fit trades pointwise accuracy for least-squares // balance. for distance in [0.75_f32, 1.5, 3.0, 4.5] { let target = (-(distance - minimum_distance) / spread).exp(); - let affinity = fitted.affinity(distance * distance); + let affinity = fitted.affinity( + NonNegative::new(distance * distance) + .expect("the fixture distances have finite squares"), + ); assert!( (affinity - target).abs() < 0.05, "at distance {distance}: expected roughly {target}, got {affinity}", @@ -360,7 +367,7 @@ fn solver_refuses_a_target_that_poisons_only_the_objective() { if distance == 0.0 { f64::NAN } else { - 1.0 / (1.0 + 1.5 * distance.powf(1.8)) + 1.0 / (1.0 + 1.5 * f64::from(distance).powf(1.8)) } }); @@ -391,7 +398,7 @@ fn solver_solves_a_well_conditioned_system_of_tiny_magnitudes() { // of both damped diagonals tests relative cancellation without imposing an absolute // matrix-magnitude floor. let (fitted_a, fitted_b) = fit_curve(SampleGrid::new(4, d_positive!(1e-5)), |distance| { - 1.0 / (1.0 + 2.0 * distance.powf(2.0)) + 1.0 / (1.0 + 2.0 * f64::from(distance).powf(2.0)) }) .expect("a tiny well-conditioned grid still fits"); @@ -411,7 +418,7 @@ fn fit_recovers_parameters_orders_of_magnitude_from_the_start() { for known_a in [100.0, 1e8] { let (fitted_a, fitted_b) = fit_curve(SampleGrid::new(300, d_positive!(3.0 / 299.0)), |distance| { - 1.0 / (1.0 + known_a * distance.powf(2.0)) + 1.0 / (1.0 + known_a * f64::from(distance).powf(2.0)) }) .expect("an exact affinity target is well-conditioned"); @@ -434,7 +441,7 @@ fn solver_terminates_a_creep_along_the_domain_boundary() { // distances, decreasing a moves the curve toward one. This puts the fit near its // positive-parameter boundary, where damping and stopping thresholds matter. let result = fit_curve(SampleGrid::new(300, d_positive!(3.0 / 299.0)), |distance| { - 30.0 / (1.0 + 1.5 * distance.powf(1.8)) + 30.0 / (1.0 + 1.5 * f64::from(distance).powf(1.8)) }); assert!( @@ -451,7 +458,7 @@ fn tiny_step_convergence_requires_both_parameters() { const TUNED_A: f64 = 0.931_322_701_934_099; let (fitted_a, fitted_b) = fit_curve(SampleGrid::new(12, d_positive!(0.25)), |distance| { - 1.0 / (1.0 + TUNED_A * distance.powf(6.0)) + 1.0 / (1.0 + TUNED_A * f64::from(distance).powf(6.0)) }) .expect("an exact affinity target is well-conditioned"); @@ -470,7 +477,7 @@ fn solver_rescues_a_walk_whose_steps_worsen_the_objective() { // the target a = b = 30 has a steep falloff over a grid extending to distance 30 let (fitted_a, fitted_b) = fit_curve(SampleGrid::new(50, d_positive!(30.0 / 49.0)), |distance| { - 1.0 / (1.0 + 30.0 * distance.powf(60.0)) + 1.0 / (1.0 + 30.0 * f64::from(distance).powf(60.0)) }) .expect("a steep exact target still fits"); @@ -485,24 +492,41 @@ fn solver_rescues_a_walk_whose_steps_worsen_the_objective() { } #[test] -fn new_rejects_degenerate_parameters() { - assert!(AffinityCurve::new(1.0, 1.0).is_some()); - assert!(AffinityCurve::new(0.0, 1.0).is_none()); - assert!(AffinityCurve::new(1.0, 0.0).is_none()); - assert!(AffinityCurve::new(-1.0, 1.0).is_none()); - assert!(AffinityCurve::new(f32::NAN, 1.0).is_none()); - assert!(AffinityCurve::new(1.0, f32::INFINITY).is_none()); +fn affinity_overflowed_power() { + let curve = AffinityCurve::new(Positive::ONE, positive!(2.0)); + + assert_eq!(curve.affinity(non_negative!(f32::MAX)), 0.0); +} + +#[test] +fn affinity_overflowed_denominator() { + let curve = AffinityCurve::new(Positive::MAX, Positive::ONE); + + assert_eq!(curve.affinity(non_negative!(2.0)), 0.0); +} + +#[test] +fn attraction_indeterminate_coefficient() { + let curve = AffinityCurve::new(Positive::ONE, positive!(3.0)); + // ρ = 10²⁰ is finite, but ρ² overflows. The coefficient is then ∞/∞. + let gradient = curve.attraction(Vec2::new(1e10, 0.0), Vec2::ZERO); + + assert!(gradient.x().is_nan()); + assert!(gradient.y().is_nan()); } #[test] fn affinity_is_one_at_zero_and_decreases() { let curve = curve(); - assert_eq!(curve.affinity(0.0), 1.0); + assert_eq!(curve.affinity(NonNegative::ZERO), 1.0); let mut previous = 1.0; for step in 1..=8_u8 { - let affinity = curve.affinity(f32::from(step) * 2.0); + let affinity = curve.affinity( + NonNegative::new(f32::from(step) * 2.0) + .expect("the eight steps have finite positive distances"), + ); assert!(affinity < previous, "affinity must fall monotonically"); assert!(affinity > 0.0); previous = affinity; @@ -534,7 +558,7 @@ fn attraction_matches_f64_reference() { #[test] fn repulsion_matches_f64_reference() { let curve = curve(); - let strength = 1.0; + let strength = NonNegative::ONE; for (from, to) in POINTS.into_iter().zip(ANCHORS) { assert_close( @@ -565,7 +589,7 @@ fn gradients_point_in_the_right_direction() { assert!(attraction.dot(from - to) < 0.0); // Repulsion pushes `from` away from `to`: along the difference. - let repulsion = curve.repulsion(from, to, 1.0); + let repulsion = curve.repulsion(from, to, NonNegative::ONE); assert!(repulsion.dot(from - to) > 0.0); } @@ -575,7 +599,7 @@ fn coincident_pairs_receive_no_gradient() { let point = Vec2::new(2.5, -1.5); assert_eq!(curve.attraction(point, point), Vec2::ZERO); - assert_eq!(curve.repulsion(point, point, 1.0), Vec2::ZERO); + assert_eq!(curve.repulsion(point, point, NonNegative::ONE), Vec2::ZERO); // A batch with one coincident lane zeroes only that lane. let mut anchors = ANCHORS; @@ -593,11 +617,15 @@ fn near_coincident_repulsion_saturates_the_clip() { let from = Vec2::new(0.01, 0.0); let to = Vec2::ZERO; - let gradient = curve.repulsion(from, to, 1.0); + let gradient = curve.repulsion(from, to, NonNegative::ONE); assert_eq!(gradient.x(), AffinityCurve::GRADIENT_CLIP); assert_eq!(gradient.y(), 0.0); - let batch = curve.repulsion_x4(Vec2x4T::from([from; 4]), Vec2x4T::from([to; 4]), 1.0); + let batch = curve.repulsion_x4( + Vec2x4T::from([from; 4]), + Vec2x4T::from([to; 4]), + NonNegative::ONE, + ); assert_eq!(batch.get(0), gradient); } @@ -607,8 +635,12 @@ fn gradients_stay_finite_at_extreme_distances() { let far = Vec2::new(1e18, -1e18); assert!(curve.attraction(far, Vec2::ZERO).is_finite()); - assert!(curve.repulsion(far, Vec2::ZERO, 1.0).is_finite()); - assert!(curve.affinity(f32::MAX).is_finite()); + assert!( + curve + .repulsion(far, Vec2::ZERO, NonNegative::ONE) + .is_finite() + ); + assert!(curve.affinity(non_negative!(f32::MAX)).is_finite()); let batch = curve.attraction_x4(Vec2x4T::from([far; 4]), Vec2x4T::from([Vec2::ZERO; 4])); assert!(batch.get(0).is_finite()); @@ -627,7 +659,10 @@ fn point_array_strategy() -> impl Strategy { /// Generates curves with a in `1e-3..1e3` and b in `0.1..5`. fn curve_strategy() -> impl Strategy { (1e-3_f32..1e3, 0.1_f32..5.0).prop_map(|(curve_a, curve_b)| { - AffinityCurve::new(curve_a, curve_b).expect("the strategy's ranges are positive and finite") + AffinityCurve::new( + Positive::new(curve_a).expect("the strategy's coefficient is positive and finite"), + Positive::new(curve_b).expect("the strategy's exponent is positive and finite"), + ) }) } @@ -661,6 +696,10 @@ fn affinity_is_a_monotone_probability( #[strategy = 0.0_f32..1e6] first: f32, #[strategy = 0.0_f32..1e6] second: f32, ) { + let first = + NonNegative::new(first).expect("the strategy's distances are non-negative and finite"); + let second = + NonNegative::new(second).expect("the strategy's distances are non-negative and finite"); let (near, far) = if first <= second { (first, second) } else { @@ -699,7 +738,7 @@ fn gradients_align_with_the_difference_vector( let difference = from - to; prop_assert!(curve.attraction(from, to).dot(difference) < 0.0); - prop_assert!(curve.repulsion(from, to, 1.0).dot(difference) > 0.0); + prop_assert!(curve.repulsion(from, to, NonNegative::ONE).dot(difference) > 0.0); } #[property_test] @@ -726,6 +765,8 @@ fn repulsion_x4_matches_scalar_repulsion_per_lane( #[strategy = 1e-2_f32..1e2] strength: f32, ) { let curve = curve(); + let strength = + NonNegative::new(strength).expect("the strategy's strength is positive and finite"); let batch = curve.repulsion_x4(Vec2x4T::from(from), Vec2x4T::from(to), strength); for (index, (from, to)) in from.into_iter().zip(to).enumerate() { diff --git a/libs/@local/graph/atlas/src/math/bench.rs b/libs/@local/graph/atlas/src/math/bench.rs index 3f180f07307..ea36a40ff31 100644 --- a/libs/@local/graph/atlas/src/math/bench.rs +++ b/libs/@local/graph/atlas/src/math/bench.rs @@ -16,8 +16,8 @@ use rayon::{ }; use super::{ - AffinityCurve, Bounds2, DVecN, FinitePointField, Positive, Similarity, Vec2, Vec2x4T, VecN, - field::POINT_CHUNK, transform::Transform, vec2::Vec2x4, + AffinityCurve, Bounds2, DVecN, FinitePointField, NonNegative, Positive, Similarity, Vec2, + Vec2x4T, VecN, field::POINT_CHUNK, transform::Transform, vec2::Vec2x4, }; /// Fixed-size operands for vector-kernel benchmarks. @@ -112,8 +112,10 @@ pub fn affinity_state( to: [[f32; 2]; 4], ) -> AffinityState { AffinityState { - curve: AffinityCurve::new(curve_a, curve_b) - .expect("curve parameters should be positive and finite"), + curve: AffinityCurve::new( + Positive::new(curve_a).expect("curve parameters should be positive and finite"), + Positive::new(curve_b).expect("curve parameters should be positive and finite"), + ), from: vec2_batch(from), to: vec2_batch(to), } @@ -164,6 +166,9 @@ pub fn affinity_attraction_scalar_reference(state: &AffinityState) { )] #[inline(always)] pub fn affinity_repulsion_x4(state: &AffinityState, repulsion_strength: f32) { + let repulsion_strength = NonNegative::new(repulsion_strength) + .expect("the benchmark passes a non-negative repulsion strength"); + black_box(black_box(state.curve).repulsion_x4( black_box(state.from), black_box(state.to), @@ -265,8 +270,12 @@ impl Points { reason = "the modulus is the fixture's deterministic spread rule, as the benchmark target \ wrote it" )] +#[expect( + clippy::missing_panics_doc, + reason = "the remainder modulo 40000 is below u16::MAX, making its checked conversion \ + infallible" +)] #[must_use] -#[expect(clippy::missing_panics_doc)] pub fn scattered_points(count: usize) -> Points { Points( (0..count) @@ -331,7 +340,8 @@ pub struct SimilarityFixture { #[must_use] pub fn similarity_fixture(count: usize, reference: [f32; 5]) -> SimilarityFixture { let Points(source) = scattered_points(count); - let reference = Similarity::from_array(reference).expect("scale should be normal and positive"); + let reference = + Similarity::from_array(reference).expect("reference coefficients must be valid"); let target = source.iter().map(|&point| reference.apply(point)).collect(); let weights = vec![1.0_f32; source.len()]; diff --git a/libs/@local/graph/atlas/src/math/bounds/mod.rs b/libs/@local/graph/atlas/src/math/bounds/mod.rs index 83d571d1c31..022d06cd2f2 100644 --- a/libs/@local/graph/atlas/src/math/bounds/mod.rs +++ b/libs/@local/graph/atlas/src/math/bounds/mod.rs @@ -1,6 +1,7 @@ //! Axis-aligned bounding boxes over 2D point sets. use core::{ + mem::{offset_of, size_of}, num::NonZero, simd::{Simd, num::SimdFloat as _}, }; @@ -9,10 +10,14 @@ use rayon::{ iter::{IndexedParallelIterator as _, ParallelIterator as _}, slice::{ParallelSlice as _, ParallelSliceMut as _}, }; +use serde::de::Error as _; +use zerocopy::FromBytes as _; use super::{ + dvec2::DVec2, kernel::mul_add_f64x4, - scalar::Positive, + nz, + scalar::{Positive, narrow_f32_down, narrow_f32_up}, transform::Transform, translation::Translation, vec2::{Vec2, Vec2x4, Vec2x4T}, @@ -21,6 +26,14 @@ use super::{ #[cfg(test)] mod tests; +/// Corner coordinates awaiting finiteness and ordering validation. +#[derive(serde::Deserialize)] +#[serde(rename = "Bounds2")] +struct UnvalidatedBounds2 { + min: Vec2, + max: Vec2, +} + /// An axis-aligned bounding box with finite, ordered corners. /// /// Every value has finite minimum and maximum corners with `min ≤ max` per component. Constructors @@ -55,10 +68,10 @@ mod tests; Clone, PartialEq, zerocopy::ByteHash, - zerocopy::FromZeros, zerocopy::IntoBytes, zerocopy::Immutable, zerocopy::KnownLayout, + serde::Serialize, )] pub(crate) struct Bounds2 { min: Vec2, @@ -68,9 +81,9 @@ pub(crate) struct Bounds2 { impl Bounds2 { /// Points per rayon work item in [`from_slice_par`](Self::from_slice_par). /// - /// 4096 points are 32 KiB, comfortably inside L1 while large enough that per-task overhead - /// disappears against the fold. - pub(crate) const PARALLEL_CHUNK: NonZero = NonZero::new(4096).expect("4096 is not zero"); + /// The default chunk is 4096 points, or 32 KiB of coordinates. Use + /// [`from_slice_par_with`](Self::from_slice_par_with) to tune the work size. + pub(crate) const PARALLEL_CHUNK: NonZero = nz!(4096); /// Creates a bounding box from its corners. /// @@ -202,6 +215,15 @@ impl Bounds2 { self.max - self.min } + /// Returns the per-axis extent widened to `f64`. + /// + /// Exact where an axis's corners differ in exponent by at most 28, and within one `f64` + /// rounding otherwise. + #[inline] + const fn extent_wide(self) -> DVec2 { + DVec2::from(self.max) - DVec2::from(self.min) + } + /// Returns the centre of the box. /// /// Each component is the exact midpoint of its axis rounded once to the nearest `f32`. The @@ -210,7 +232,12 @@ impl Bounds2 { #[inline] #[must_use] pub(crate) const fn centre(self) -> Vec2 { - (self.min + self.max) * 0.5 + // A finite `f32` widens to `f64` exactly, and the sum of two rounds in `f64` only when + // their exponents differ by more than 28, where the smaller is below 2⁻²⁸ of the larger + // and the half-sum rounds to half the larger at `f32` with or without that rounding. + // Halving in `f64` is exact. Therefore the narrowing is the one rounding, and it stays + // finite because the midpoint is bounded by the corners. + ((DVec2::from(self.min) + DVec2::from(self.max)) * 0.5).narrow_lossy() } /// Returns whether the point lies inside the box, boundary included. @@ -235,7 +262,25 @@ impl Bounds2 { } } - /// Widens any axis narrower than `minimum` to exactly `minimum`. + /// Folds one more point into an extent accumulated so far. + /// + /// The incremental form of [`from_points`](Self::from_points) for callers that visit their + /// points one at a time. `None` seeds the extent with the point. A non-finite point yields + /// [`None`], as [`from_points`](Self::from_points) does, and a later finite point re-seeds. + #[inline] + #[must_use] + pub(crate) const fn extend(extent: Option, point: Vec2) -> Option { + match extent { + Some(bounds) if point.is_finite() => Some(Self { + min: bounds.min.min(point), + max: bounds.max.max(point), + }), + Some(_) => None, + None => Self::new(point, point), + } + } + + /// Widens axes narrower than `minimum`. /// /// Each narrow axis grows symmetrically in exact arithmetic. Rounding follows /// [`with_aspect_ratio`](Self::with_aspect_ratio). An axis already at least `minimum` wide @@ -247,16 +292,14 @@ impl Bounds2 { /// Returns [`None`] when a widened corner would lie beyond the finite `f32` range. #[inline] #[must_use] - pub(crate) fn with_minimum_extent(self, minimum: f32) -> Self { - let size = self.size(); - let centre = self.centre(); + pub(crate) fn with_minimum_extent(self, minimum: Positive) -> Option { + let minimum = f64::from(minimum); + let extent = self.extent_wide(); - let half = Vec2::new((size.x().max(minimum)) * 0.5, (size.y().max(minimum)) * 0.5); + let [min_x, max_x] = resize_axis(self.min.x(), self.max.x(), extent.x().max(minimum))?; + let [min_y, max_y] = resize_axis(self.min.y(), self.max.y(), extent.y().max(minimum))?; - Self { - min: centre - half, - max: centre + half, - } + Self::new(Vec2::new(min_x, min_y), Vec2::new(max_x, max_y)) } /// Grows the shorter axis toward the given width-to-height ratio. @@ -309,20 +352,30 @@ impl Bounds2 { /// ``` #[inline] #[must_use] - pub(crate) const fn with_aspect_ratio(self, ratio: Positive) -> Self { - let size = self.size(); - let centre = self.centre(); - - // Both components read the original size, so exactly one axis - // grows: whichever is short for the ratio. - let half = Vec2::new( - size.x().max(size.y() * ratio), - size.y().max(size.x() / ratio), - ) * 0.5; - - Self { - min: centre - half, - max: centre + half, + pub(crate) fn with_aspect_ratio(self, ratio: Positive) -> Option { + let ratio = f64::from(ratio); + let extent = self.extent_wide(); + + // One comparison picks the axis that is short for the ratio. Only that axis is resized: + // the other keeps its corners bit for bit. + if extent.x() < extent.y() * ratio { + let [min_x, max_x] = resize_axis(self.min.x(), self.max.x(), extent.y() * ratio)?; + + Self::new( + Vec2::new(min_x, self.min.y()), + Vec2::new(max_x, self.max.y()), + ) + } else { + // The quotient can round a hair below the height when the box already has the + // ratio. The floor at the current extent makes that case a no-op rather than an + // inward move. + let target = (extent.x() / ratio).max(extent.y()); + let [min_y, max_y] = resize_axis(self.min.y(), self.max.y(), target)?; + + Self::new( + Vec2::new(self.min.x(), min_y), + Vec2::new(self.max.x(), max_y), + ) } } @@ -354,14 +407,14 @@ impl Bounds2 { /// ``` #[inline] #[must_use] - pub(crate) const fn scaled_about_centre(self, factor: Positive) -> Self { - let centre = self.centre(); - let half = self.size() * (factor * 0.5); + pub(crate) fn scaled_about_centre(self, factor: Positive) -> Option { + let factor = f64::from(factor); + let extent = self.extent_wide(); - Self { - min: centre - half, - max: centre + half, - } + let [min_x, max_x] = resize_axis(self.min.x(), self.max.x(), extent.x() * factor)?; + let [min_y, max_y] = resize_axis(self.min.y(), self.max.y(), extent.y() * factor)?; + + Self::new(Vec2::new(min_x, min_y), Vec2::new(max_x, max_y)) } /// Fits an axis-aligned transform from this box to `target`. @@ -468,9 +521,42 @@ impl Bounds2 { mapped } -} -impl Bounds2 { + /// Returns the bounds of this box's computed image in `target`. + /// + /// Applies [`normalize_into`](Self::normalize_into)'s map to the corners, including its + /// rounding. A zero-extent axis maps to the target midpoint. For a tight box of finite + /// points, monotonicity makes these mapped corners the tight bounds of the mapped points. + /// The result need not equal `target` when its extent loses precision in `f64`. + /// + /// # Example + /// + /// This in-crate example is ignored because the module is private. + /// + /// ```ignore + /// use crate::math::{Bounds2, Vec2}; + /// + /// let world = Bounds2::new(Vec2::new(-4.0, -2.0), Vec2::new(8.0, 6.0)) + /// .expect("corners are finite and ordered"); + /// let frame = Bounds2::new(Vec2::splat(-1.0), Vec2::splat(1.0)).expect("the frame is valid"); + /// + /// assert_eq!(world.image_in(frame), frame); + /// ``` + #[must_use] + pub(crate) fn image_in(self, target: Self) -> Self { + let x = AxisMap::new(self.min.x(), self.max.x(), target.min.x(), target.max.x()); + let y = AxisMap::new(self.min.y(), self.max.y(), target.min.y(), target.max.y()); + + // Nondegenerate endpoints produce unit coordinates zero and one, and a degenerate axis + // produces the target midpoint. The non-negative target extent keeps these results ordered. + // At an extreme target endpoint, f64 rounding is smaller than the distance from f32::MAX + // to the overflow threshold. Therefore the narrowed corners are finite and ordered. + Self { + min: Vec2::new(x.apply(self.min.x()), y.apply(self.min.y())), + max: Vec2::new(x.apply(self.max.x()), y.apply(self.max.y())), + } + } + /// Quantizes a point onto the bounds' 32-bit-per-axis grid. /// /// Divides the coordinate's offset from the minimum by the extent returned by [`Self::size`], @@ -505,6 +591,125 @@ impl Bounds2 { } } +impl<'de> serde::Deserialize<'de> for Bounds2 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let corners = UnvalidatedBounds2::deserialize(deserializer)?; + Self::new(corners.min, corners.max).ok_or_else(|| { + D::Error::custom("bounds corners must be finite and ordered per component") + }) + } +} + +// zerocopy's hidden validation APIs have no compatibility guarantee. Recheck this implementation +// when updating the dependency. +// SAFETY: TryFromBytes requires an accepted candidate to contain a valid Self. `offset_of!` locates +// each Vec2 in this exact type, without assuming a repr(Rust) field order. `Maybe` uses a +// transparent ReadOnly wrapper, and `as_bytes` preserves its initialized referent. The checked +// reads copy those field bytes, and `new` accepts only finite, ordered corners without changing +// their representation. Therefore every accepted candidate satisfies the Bounds2 invariant. +unsafe impl zerocopy::TryFromBytes for Bounds2 { + fn only_derive_is_allowed_to_implement_this_trait() {} + + fn is_bit_valid(candidate: zerocopy::Maybe<'_, Self, A>) -> bool + where + A: zerocopy::invariant::Alignment, + { + let bytes = candidate.as_bytes::().as_ref(); + + let min_offset = offset_of!(Self, min); + let max_offset = offset_of!(Self, max); + + let Ok(min) = Vec2::read_from_bytes(&bytes[min_offset..min_offset + size_of::()]) + else { + return false; + }; + + let Ok(max) = Vec2::read_from_bytes(&bytes[max_offset..max_offset + size_of::()]) + else { + return false; + }; + + Self::new(min, max).is_some() + } +} + +// SAFETY: FromZeros requires the all-zero representation to be valid. Each Vec2 is transparent +// over [f32; 2], whose zero bytes represent positive zero. Both corners are then finite and equal. +// Therefore the all-zero Bounds2 satisfies its invariant. +unsafe impl zerocopy::FromZeros for Bounds2 { + fn only_derive_is_allowed_to_implement_this_trait() {} +} + +/// Resizes one axis to the extent `target` about its midpoint. +/// +/// A `target` equal to the extent returns the corners bit for bit. Otherwise each corner shifts +/// by half the change in extent, with outward narrowing as in [`Bounds2::with_aspect_ratio`]. +/// +/// Returns [`None`] when a shifted corner lies beyond the finite `f32` range. +#[expect( + clippy::float_cmp, + reason = "an unchanged target repeats the extent calculation and compares equal bit for bit" +)] +fn resize_axis(low: f32, high: f32, target: f64) -> Option<[f32; 2]> { + let extent = f64::from(high) - f64::from(low); + if target == extent { + return Some([low, high]); + } + + let shift = (target - extent) * 0.5; + + Some([ + shift_corner_down(low, shift)?, + shift_corner_up(high, shift)?, + ]) +} + +/// Subtracts `shift` from a low corner, with outward narrowing. +/// +/// A negative `shift` moves the corner inward. Returns [`None`] when the downward rounding or +/// outward step leaves the finite `f32` range. +#[expect( + clippy::float_cmp, + reason = "the comparison detects a rounding that left the corner exactly in place" +)] +fn shift_corner_down(corner: f32, shift: f64) -> Option { + let shifted = narrow_f32_down(f64::from(corner) - shift)?; + + // A positive shift no larger than half an `f64` ulp of the corner can round away in the + // subtraction and leave the corner in place, although the exact result lies strictly below + // it. The next `f32` down is then the largest at or below that result. + let shifted = if shift > 0.0 && shifted == corner { + corner.next_down() + } else { + shifted + }; + + shifted.is_finite().then_some(shifted) +} + +/// Adds `shift` to a high corner, with outward narrowing. +/// +/// A negative `shift` moves the corner inward. Returns [`None`] when the upward rounding or +/// outward step leaves the finite `f32` range. +#[expect( + clippy::float_cmp, + reason = "the comparison detects a rounding that left the corner exactly in place" +)] +fn shift_corner_up(corner: f32, shift: f64) -> Option { + let shifted = narrow_f32_up(f64::from(corner) + shift)?; + + let shifted = if shift > 0.0 && shifted == corner { + corner.next_up() + } else { + shifted + }; + + shifted.is_finite().then_some(shifted) +} + /// The number of grid positions per axis of [`Bounds2::quantize`]. #[expect( clippy::cast_precision_loss, @@ -560,7 +765,7 @@ impl AxisMap { /// Maps one coordinate onto its target axis. #[expect( clippy::cast_possible_truncation, - reason = "the single f64-to-f32 rounding is the mapping's error bound" + reason = "the f64 result is deliberately narrowed to the f32 output" )] fn apply(self, value: f32) -> f32 { if self.extent == 0.0 { @@ -581,7 +786,7 @@ impl AxisMap { if self.extent == 0.0 { #[expect( clippy::cast_possible_truncation, - reason = "the single f64-to-f32 rounding is the mapping's error bound" + reason = "the f64 midpoint is deliberately narrowed to the f32 lane value" )] return Simd::splat(self.target_centre as f32); } diff --git a/libs/@local/graph/atlas/src/math/bounds/tests.rs b/libs/@local/graph/atlas/src/math/bounds/tests.rs index 570c76017f6..0f1b9031038 100644 --- a/libs/@local/graph/atlas/src/math/bounds/tests.rs +++ b/libs/@local/graph/atlas/src/math/bounds/tests.rs @@ -8,13 +8,91 @@ reason = "test data generation folds indices into range by modulus" )] +use core::{ + assert_matches, + mem::{offset_of, size_of}, +}; + use proptest::{prop_assert, prop_assert_eq, prop_assume, property_test, strategy::Strategy}; +use zerocopy::{FromZeros as _, IntoBytes as _, TryFromBytes as _}; use crate::math::{ Bounds2, Positive, Vec2, Vec2x4T, positive, tests::{POINTS, assert_vec2_close}, }; +#[test] +fn bounds_bytes_validate_both_corners() { + for (min, max) in [ + (Vec2::new(2.0, 0.0), Vec2::splat(1.0)), + (Vec2::new(0.0, 2.0), Vec2::splat(1.0)), + (Vec2::new(f32::NAN, 0.0), Vec2::splat(1.0)), + (Vec2::new(0.0, f32::NEG_INFINITY), Vec2::splat(1.0)), + (Vec2::ZERO, Vec2::new(f32::INFINITY, 1.0)), + (Vec2::ZERO, Vec2::new(1.0, f32::NAN)), + ] { + let mut storage = [0_u32; size_of::().div_euclid(size_of::())]; + let bytes = storage.as_mut_bytes(); + for (offset, corner) in [ + (offset_of!(Bounds2, min), min), + (offset_of!(Bounds2, max), max), + ] { + bytes[offset..offset + size_of::()].copy_from_slice(corner.as_bytes()); + } + assert_matches!( + Bounds2::try_read_from_bytes(bytes), + Err(zerocopy::ConvertError::Validity(_)) + ); + assert_matches!( + Bounds2::try_ref_from_bytes(bytes), + Err(zerocopy::ConvertError::Validity(_)) + ); + assert_matches!( + Bounds2::try_mut_from_bytes(bytes), + Err(zerocopy::ConvertError::Validity(_)) + ); + } + + for (min, max) in [ + (Vec2::ZERO, Vec2::ZERO), + (Vec2::splat(-0.0), Vec2::ZERO), + (Vec2::new(-2.0, 3.0), Vec2::new(4.0, 3.0)), + (Vec2::splat(-f32::MAX), Vec2::splat(f32::MAX)), + ] { + let bounds = Bounds2::new(min, max).expect("finite ordered corners"); + let restored = Bounds2::try_ref_from_bytes(bounds.as_bytes()).expect("valid stored bounds"); + assert_eq!(restored.as_bytes(), bounds.as_bytes()); + let copied = Bounds2::try_read_from_bytes(bounds.as_bytes()).expect("valid stored bounds"); + assert_eq!(copied.as_bytes(), bounds.as_bytes()); + } + + let zero = Bounds2::new_zeroed(); + assert_eq!(zero.min(), Vec2::ZERO); + assert_eq!(zero.max(), Vec2::ZERO); +} + +#[test] +fn bounds_serde_validates_corners() { + for json in [ + r#"{"min":[1,1],"max":[0,0]}"#, + r#"{"min":[0,2],"max":[1,1]}"#, + r#"{"min":[-1e40,0],"max":[1,1]}"#, + r#"{"min":[0,0],"max":[1,1e40]}"#, + ] { + serde_json::from_str::(json).expect_err("invalid corners should not deserialize"); + } + let json = r#"{"min":[-2.0,3.0],"max":[4.0,3.0]}"#; + let bounds: Bounds2 = serde_json::from_str(json).expect("finite ordered corners"); + assert_eq!(bounds.min(), Vec2::new(-2.0, 3.0)); + assert_eq!(bounds.max(), Vec2::new(4.0, 3.0)); + assert_eq!( + serde_json::to_string(&bounds).expect("finite coefficients"), + json + ); +} + +/// `Bounds2::new` accepts ordered and degenerate corners and refuses swapped corners on either +/// axis and non-finite corners. #[test] fn new_validates_corners() { assert!(Bounds2::new(Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0)).is_some()); @@ -52,6 +130,29 @@ fn from_points_rejects_empty_and_non_finite() { ); } +/// Folding `extend` over the fixture equals `from_points`, extending `None` yields a point box, and +/// a non-finite point poisons the extent whether or not one exists. +#[test] +fn extend_matches_from_points() { + let folded = POINTS + .iter() + .fold(None, |extent, &point| Bounds2::extend(extent, point)); + assert_eq!(folded, Bounds2::from_points(POINTS)); + + assert_eq!( + Bounds2::extend(None, Vec2::splat(1.0)), + Bounds2::new(Vec2::splat(1.0), Vec2::splat(1.0)) + ); + assert!(Bounds2::extend(None, Vec2::new(f32::NAN, 0.0)).is_none()); + assert!( + Bounds2::extend( + Bounds2::new(Vec2::ZERO, Vec2::ZERO), + Vec2::new(1.0, f32::NAN) + ) + .is_none() + ); +} + #[test] fn contains_is_boundary_inclusive() { let bounds = @@ -86,7 +187,9 @@ fn minimum_extent_widens_degenerate_axes_only() { let bounds = Bounds2::from_points([Vec2::new(3.0, 0.0), Vec2::new(3.0, 4.0)]) .expect("points are finite and non-empty"); - let widened = bounds.with_minimum_extent(2.0); + let widened = bounds + .with_minimum_extent(positive!(2.0)) + .expect("the widened corners are far inside the `f32` range"); assert_eq!(widened.size(), Vec2::new(2.0, 4.0)); // Widening is symmetric around the centre. assert_eq!(widened.centre(), bounds.centre()); @@ -103,16 +206,28 @@ fn aspect_ratio_grows_the_axis_that_is_short_for_it() { let ratio = Positive::new(4.0).expect("4 is positive"); // 16 by 2 is wider than 4:1, so the height grows and the width stays. - let grown = wide.with_aspect_ratio(ratio); + let grown = wide + .with_aspect_ratio(ratio) + .expect("the grown corners are far inside the `f32` range"); assert_eq!(grown.size(), Vec2::new(16.0, 4.0)); assert_eq!(grown.centre(), wide.centre()); + assert_eq!( + (grown.min().x(), grown.max().x()), + (wide.min().x(), wide.max().x()) + ); // 1 by 12 is narrower, so the width grows instead. let tall = Bounds2::new(Vec2::new(-0.5, -6.0), Vec2::new(0.5, 6.0)) .expect("corners are finite and ordered"); - let grown = tall.with_aspect_ratio(ratio); + let grown = tall + .with_aspect_ratio(ratio) + .expect("the grown corners are far inside the `f32` range"); assert_eq!(grown.size(), Vec2::new(48.0, 12.0)); assert_eq!(grown.centre(), tall.centre()); + assert_eq!( + (grown.min().y(), grown.max().y()), + (tall.min().y(), tall.max().y()) + ); } /// A zero-extent axis grows out of the other under `with_aspect_ratio`, and a single point stays @@ -124,13 +239,15 @@ fn aspect_ratio_takes_a_degenerate_axis_out_of_the_other() { // All points on a horizontal line: the zero-extent axis grows out of the other. let line = Bounds2::from_points([Vec2::new(1.0, 5.0), Vec2::new(9.0, 5.0)]) .expect("points are finite and non-empty"); - let grown = line.with_aspect_ratio(ratio); + let grown = line + .with_aspect_ratio(ratio) + .expect("the grown corners are far inside the `f32` range"); assert_eq!(grown.size(), Vec2::new(8.0, 4.0)); assert_eq!(grown.centre(), line.centre()); // A single point has no extent to take a ratio of, and comes back bit for bit. let point = Bounds2::new(Vec2::splat(3.0), Vec2::splat(3.0)).expect("a point is a valid box"); - assert_eq!(point.with_aspect_ratio(ratio).size(), Vec2::ZERO); + assert_eq!(point.with_aspect_ratio(ratio), Some(point)); } /// `scaled_about_centre` multiplies the size by the factor while keeping the centre, for factors @@ -140,15 +257,205 @@ fn scaling_about_the_centre_moves_both_corners() { let bounds = Bounds2::new(Vec2::new(0.0, 2.0), Vec2::new(4.0, 6.0)) .expect("corners are finite and ordered"); - let widened = bounds.scaled_about_centre(Positive::new(1.5).expect("1.5 is positive")); + let widened = bounds + .scaled_about_centre(positive!(1.5)) + .expect("the scaled corners are far inside the `f32` range"); assert_eq!(widened.size(), Vec2::splat(6.0)); assert_eq!(widened.centre(), bounds.centre()); assert_eq!(widened.min(), Vec2::new(-1.0, 1.0)); assert_eq!(widened.max(), Vec2::new(5.0, 7.0)); - let narrowed = bounds.scaled_about_centre(Positive::new(0.5).expect("0.5 is positive")); + let narrowed = bounds + .scaled_about_centre(positive!(0.5)) + .expect("the scaled corners are far inside the `f32` range"); assert_eq!(narrowed.size(), Vec2::splat(2.0)); assert_eq!(narrowed.centre(), bounds.centre()); + assert_eq!(narrowed.min(), Vec2::new(1.0, 3.0)); + assert_eq!(narrowed.max(), Vec2::new(3.0, 5.0)); +} + +#[test] +fn growth_identity() { + let bounds = Bounds2::new(Vec2::new(-8.0, -1.0), Vec2::new(8.0, 1.0)) + .expect("corners are finite and ordered"); + + assert_eq!(bounds.with_aspect_ratio(positive!(8.0)), Some(bounds)); + assert_eq!(bounds.scaled_about_centre(Positive::ONE), Some(bounds)); + assert_eq!(bounds.with_minimum_extent(positive!(2.0)), Some(bounds)); + assert_eq!(bounds.with_minimum_extent(positive!(1.0)), Some(bounds)); +} + +#[test] +fn growth_translated_sliver() { + let narrow = Bounds2::new(Vec2::new(16_777_216.0, 0.0), Vec2::new(16_777_218.0, 2.0)) + .expect("corners are finite and ordered"); + + // Already square: nothing to grow. + assert_eq!(narrow.with_aspect_ratio(Positive::ONE), Some(narrow)); + + // Ratio 2 wants x = [2²⁴ - 1, 2²⁴ + 3]. The high corner has no `f32`, and the enclosure + // steps to 2²⁴ + 4: five wide against a requested four. + let wider = narrow + .with_aspect_ratio(positive!(2.0)) + .expect("the grown corners are far inside the `f32` range"); + assert_eq!(wider.min(), Vec2::new(16_777_215.0, 0.0)); + assert_eq!(wider.max(), Vec2::new(16_777_220.0, 2.0)); + assert!(wider.contains(narrow.min()) && wider.contains(narrow.max())); + + // Ratio 0.5 grows y instead, where the corners are small and the intent is exact. + let taller = narrow + .with_aspect_ratio(positive!(0.5)) + .expect("the grown corners are far inside the `f32` range"); + assert_eq!(taller.min(), Vec2::new(16_777_216.0, -1.0)); + assert_eq!(taller.max(), Vec2::new(16_777_218.0, 3.0)); +} + +#[test] +fn centre_range_edge() { + let point = Bounds2::new(Vec2::splat(f32::MAX), Vec2::splat(f32::MAX)) + .expect("a point at the range edge is a valid box"); + assert_eq!(point.centre(), Vec2::splat(f32::MAX)); + + let wide = Bounds2::new(Vec2::new(1e38, -f32::MAX), Vec2::new(3e38, f32::MAX)) + .expect("corners are finite and ordered"); + assert_eq!(wide.centre(), Vec2::new(2e38, 0.0)); +} + +#[test] +fn growth_range_edge_point() { + let point = Bounds2::new(Vec2::splat(f32::MAX), Vec2::splat(f32::MAX)) + .expect("a point at the range edge is a valid box"); + + assert_eq!(point.with_aspect_ratio(Positive::ONE), Some(point)); + assert_eq!(point.scaled_about_centre(positive!(1.5)), Some(point)); + assert_eq!(point.with_minimum_extent(Positive::ONE), None); + + // The mirror image at the low end refuses for the low corner. + let low = Bounds2::new(Vec2::new(-f32::MAX, 0.0), Vec2::new(-f32::MAX, 0.0)) + .expect("a point at the range edge is a valid box"); + assert_eq!(low.with_minimum_extent(Positive::ONE), None); +} + +#[test] +fn growth_representability() { + let flat = + Bounds2::new(Vec2::ZERO, Vec2::new(f32::MAX, 0.0)).expect("corners are finite and ordered"); + let half_max = f32::MAX / 2.0; + + // Ratio 1 wants y = [-MAX/2, MAX/2], both representable. x keeps its corners. + let square = flat + .with_aspect_ratio(Positive::ONE) + .expect("the grown corners lie inside the `f32` range"); + assert_eq!(square.min(), Vec2::new(0.0, -half_max)); + assert_eq!(square.max(), Vec2::new(f32::MAX, half_max)); + + // Spanning the whole range, ratio 1 wants y = [-MAX, MAX] exactly, and ratio 1/2 wants twice + // that. + let full = Bounds2::new(Vec2::new(-f32::MAX, 0.0), Vec2::new(f32::MAX, 0.0)) + .expect("corners are finite and ordered"); + assert_eq!( + full.with_aspect_ratio(Positive::ONE), + Bounds2::new(Vec2::splat(-f32::MAX), Vec2::splat(f32::MAX)) + ); + assert_eq!(full.with_aspect_ratio(positive!(0.5)), None); + + // a four percent margin exceeds the range for a box reaching MAX, but fits at MAX/2. + let margin = positive!(1.04); + let reaching_max = + Bounds2::new(Vec2::ZERO, Vec2::new(f32::MAX, 1.0)).expect("corners are finite and ordered"); + assert_eq!(reaching_max.scaled_about_centre(margin), None); + + let reaching_half = + Bounds2::new(Vec2::ZERO, Vec2::new(half_max, 1.0)).expect("corners are finite and ordered"); + let widened = reaching_half + .scaled_about_centre(margin) + .expect("the scaled corners lie inside the `f32` range"); + assert!(widened.contains(reaching_half.min()) && widened.contains(reaching_half.max())); + // The intent as the rounding contract states it: the extent scaled, and each corner shifted + // by half the change. + let extent = f64::from(half_max); + let shift = (extent * f64::from(margin) - extent) * 0.5; + assert_axis_encloses_tightly(widened.min().x(), widened.max().x(), -shift, extent + shift); +} + +#[test] +fn minimum_extent_absorbed_shift() { + let half_max = f32::MAX / 2.0; + let point = + Bounds2::new(Vec2::splat(half_max), Vec2::splat(half_max)).expect("a point is a valid box"); + + // The `f64` ulp at MAX/2 is 2⁷⁴. A shift of 0.5 rounds away, and each corner steps one `f32`. + let widened = point + .with_minimum_extent(Positive::ONE) + .expect("one step in each direction stays inside the `f32` range"); + assert_eq!(widened.min(), Vec2::splat(half_max.next_down())); + assert_eq!(widened.max(), Vec2::splat(half_max.next_up())); +} + +#[test] +fn scaling_adjacent_corners() { + let sliver = Bounds2::new(Vec2::new(1.0, 0.0), Vec2::new(1.0_f32.next_up(), 4.0)) + .expect("corners are finite and ordered"); + + let halved = sliver + .scaled_about_centre(positive!(0.5)) + .expect("shrinking stays inside the `f32` range"); + assert_eq!(halved.min(), Vec2::new(1.0, 1.0)); + assert_eq!(halved.max(), Vec2::new(1.0_f32.next_up(), 3.0)); +} + +#[test] +fn minimum_extent_inexact_corners() { + let point = Bounds2::new(Vec2::splat(0.1), Vec2::splat(0.1)).expect("a point is a valid box"); + + let widened = point + .with_minimum_extent(Positive::ONE) + .expect("the widened corners are far inside the `f32` range"); + let (low, high) = (f64::from(0.1_f32) - 0.5, f64::from(0.1_f32) + 0.5); + assert_axis_encloses_tightly(widened.min().x(), widened.max().x(), low, high); + assert!(f64::from(widened.max().x()) - f64::from(widened.min().x()) >= 1.0); +} + +#[test] +fn aspect_ratio_rounded_shift() { + let corner = 2.0_f32.powi(60); + // height 2³⁸ + 64 requires an x shift of 2³⁷ + 32. The f64 spacing at 2⁶⁰ is 128 below + // and 256 above. Both shifted corners round onto the f32 values 2⁶⁰ ∓ 2³⁷. + let bounds = Bounds2::new( + Vec2::new(corner, -64.0), + Vec2::new(corner, 2.0_f32.powi(38)), + ) + .expect("corners are finite and ordered"); + + let grown = bounds + .with_aspect_ratio(Positive::ONE) + .expect("the grown corners are far inside the `f32` range"); + assert_eq!(grown.min().x(), corner - 2.0_f32.powi(37)); + assert_eq!(grown.max().x(), corner + 2.0_f32.powi(37)); + assert!(grown.contains(bounds.min()) && grown.contains(bounds.max())); + + let target = f64::from(2.0_f32.powi(38)) + 64.0; + let reached = f64::from(grown.max().x()) - f64::from(grown.min().x()); + assert_eq!(target - reached, 64.0); + assert!(target - reached < 2.0_f64.powi(-50) * f64::from(corner)); +} + +/// Asserts an axis is the tightest `f32` enclosure of `[low, high]`: each corner lies on the +/// outward side of its bound and its inward neighbour does not. +/// +/// # Panics +/// +/// Panics when either corner fails the enclosure or nearest-neighbour condition. +#[track_caller] +fn assert_axis_encloses_tightly(min: f32, max: f32, low: f64, high: f64) { + assert!( + f64::from(min) <= low && f64::from(min.next_up()) > low, + "{min} is not the largest f32 at or below {low}", + ); + assert!( + f64::from(max) >= high && f64::from(max.next_down()) < high, + "{max} is not the smallest f32 at or above {high}", + ); } #[test] @@ -248,7 +555,13 @@ fn fit_rejects_degenerate_extents_until_widened() { .expect("points are finite and non-empty"); assert!(collinear.fit(target).is_none()); - assert!(collinear.with_minimum_extent(1.0).fit(target).is_some()); + assert!( + collinear + .with_minimum_extent(Positive::ONE) + .expect("the widened corners are far inside the `f32` range") + .fit(target) + .is_some() + ); } /// `normalize_into` lands the corners and centre exactly on the target's, since it computes the @@ -298,6 +611,31 @@ fn normalize_into_stays_exact_far_from_the_origin() { assert_eq!(mapped, [Vec2::splat(-0.5)]); } +/// The image of a proper box in a frame is the frame itself. +#[test] +fn image_in_proper_box() { + let world = Bounds2::new(Vec2::new(-4.0, -2.0), Vec2::new(8.0, 6.0)) + .expect("corners are finite and ordered"); + let frame = + Bounds2::new(Vec2::splat(-1.0), Vec2::splat(1.0)).expect("corners are finite and ordered"); + + assert_eq!(world.image_in(frame), frame); +} + +/// The image of a zero-extent axis in a viewport is that viewport's centre line on that axis. +#[test] +fn image_in_degenerate_axis() { + let collinear = Bounds2::new(Vec2::new(3.0, -2.0), Vec2::new(3.0, 6.0)) + .expect("a zero-extent axis is a valid box"); + let viewport = + Bounds2::new(Vec2::ZERO, Vec2::new(10.0, 4.0)).expect("corners are finite and ordered"); + + assert_eq!( + collinear.image_in(viewport), + Bounds2::new(Vec2::new(5.0, 0.0), Vec2::new(5.0, 4.0)).expect("the image is ordered") + ); +} + /// A point with coordinates bounded to the well-conditioned `-1e3..1e3` range. /// /// The bounding-box laws are about corner algebra, not overflow. @@ -383,6 +721,22 @@ fn normalize_into_agrees_between_batched_body_and_remainder( } } +/// The image of a point set's tight box is the tight box of the normalized set. +#[property_test] +fn image_in_normalized_extent( + #[strategy = points_strategy()] points: Vec, + #[strategy = bounds_strategy()] frame: Bounds2, +) { + prop_assume!(!points.is_empty()); + + let world = Bounds2::from_points(points.iter().copied()) + .expect("in-range points are finite and non-empty"); + let normalized = Bounds2::from_points(world.normalize_into(frame, &points)) + .expect("the mapped points are finite"); + + prop_assert_eq!(normalized, world.image_in(frame)); +} + /// The fitted transform maps source corners onto target corners. /// /// Rounding scales with the target box's magnitude. The strategy bounds extents to `1..1e3` @@ -423,83 +777,178 @@ fn factor_strategy() -> impl Strategy { (1e-2_f32..1e2).prop_map(|value| Positive::new(value).expect("the range is positive")) } -/// Growing to a ratio yields a box at that ratio which contains the original and shares its centre. +/// Returns the spacing just above a corner's magnitude in the `f32` grid. +/// +/// The outward rounding of a corner moves it by less than one grid step at the result, and the +/// step just above a magnitude is never smaller than the step just below it. This bounds the move +/// for a corner of either sign. +fn ulp(corner: f32) -> f64 { + f64::from(corner.abs().next_up()) - f64::from(corner.abs()) +} + +/// Computes the extent after widening an axis's corners to `f64`. +fn extent_of(low: f32, high: f32) -> f64 { + f64::from(high) - f64::from(low) +} + +/// Asserts an axis reached `target` as the rounding contract promises: its extent lies between the +/// target less the intent's `f64` residual and the target plus one ulp of each corner. +/// +/// # Panics +/// +/// Panics when the represented extent falls outside the stated bounds. +#[track_caller] +fn assert_axis_reaches(low: f32, high: f32, target: f64) { + let reached = extent_of(low, high); + let residual = 2.0_f64.powi(-50) * f64::from(low.abs().max(high.abs())); + + assert!( + reached >= target - residual, + "[{low}, {high}] measures {reached}, short of {target} by more than the residual \ + {residual}", + ); + assert!( + reached <= target + ulp(low) + ulp(high), + "[{low}, {high}] measures {reached}, over {target} by more than an ulp per corner", + ); +} + +/// Asserts the represented midpoint moved from the original's by less than one ulp of the larger +/// corner, the bound the outward rounding of two corners allows. +/// +/// # Panics /// -/// Corners come back through `centre ± size / 2`, so every assertion carries a tolerance scaled by -/// the grown box's magnitude: the rounding of a corner is a rounding of the extent it was rebuilt -/// from, which the ratio makes much larger than the centre it surrounds. +/// Panics when either midpoint moves by at least the stated bound. +#[track_caller] +fn assert_midpoint_within_an_ulp(original: Bounds2, resized: Bounds2) { + for (before, after, bound) in [ + ( + f64::midpoint(f64::from(original.min().x()), f64::from(original.max().x())), + f64::midpoint(f64::from(resized.min().x()), f64::from(resized.max().x())), + ulp(resized.min().x()).max(ulp(resized.max().x())), + ), + ( + f64::midpoint(f64::from(original.min().y()), f64::from(original.max().y())), + f64::midpoint(f64::from(resized.min().y()), f64::from(resized.max().y())), + ulp(resized.min().y()).max(ulp(resized.max().y())), + ), + ] { + assert!( + (after - before).abs() < bound, + "midpoint moved from {before} to {after}, at least an ulp {bound}", + ); + } +} + #[property_test] -fn aspect_ratio_contains_the_box_and_holds_its_ratio( +fn aspect_ratio_growth_laws( #[strategy = bounds_strategy()] bounds: Bounds2, #[strategy = factor_strategy()] ratio: Positive, ) { - let grown = bounds.with_aspect_ratio(ratio); - let tolerance = 1e-4 * (grown.min().length() + grown.size().length()).at_least(positive!(1.0)); + let grown = bounds + .with_aspect_ratio(ratio) + .expect("corners in -1e3..1e3 grown by a ratio in 1e-2..1e2 stay inside the `f32` range"); prop_assert!( - grown.min().x() <= bounds.min().x() + tolerance - && grown.min().y() <= bounds.min().y() + tolerance - && grown.max().x() >= bounds.max().x() - tolerance - && grown.max().y() >= bounds.max().y() - tolerance, - "{:?} does not contain {:?}", - grown, - bounds, - ); - prop_assert!( - (grown.centre().x() - bounds.centre().x()).abs() <= tolerance - && (grown.centre().y() - bounds.centre().y()).abs() <= tolerance, - "{:?} is not centred on {:?}", - grown, - bounds, - ); - - let reached = grown.size().x() / grown.size().y(); - prop_assert!( - (reached - ratio.get()).abs() <= 1e-4 * ratio.get(), - "reached {reached}, wanted {}", - ratio.get(), + grown.contains(bounds.min()) && grown.contains(bounds.max()), + "{grown:?} does not contain {bounds:?}", ); + assert_midpoint_within_an_ulp(bounds, grown); + + let width = extent_of(bounds.min().x(), bounds.max().x()); + let height = extent_of(bounds.min().y(), bounds.max().y()); + let ratio = f64::from(ratio); + if width < height * ratio { + prop_assert_eq!( + (grown.min().y(), grown.max().y()), + (bounds.min().y(), bounds.max().y()), + ); + assert_axis_reaches(grown.min().x(), grown.max().x(), height * ratio); + } else { + prop_assert_eq!( + (grown.min().x(), grown.max().x()), + (bounds.min().x(), bounds.max().x()), + ); + assert_axis_reaches( + grown.min().y(), + grown.max().y(), + (width / ratio).max(height), + ); + } } #[property_test] -fn scaling_about_the_centre_scales_both_extents( +fn scaling_containment_laws( #[strategy = bounds_strategy()] bounds: Bounds2, #[strategy = factor_strategy()] factor: Positive, ) { - let scaled = bounds.scaled_about_centre(factor); - let expected = bounds.size() * factor.get(); - let tolerance = - 1e-4 * (scaled.min().length() + scaled.size().length()).at_least(positive!(1.0)); - - prop_assert!( - (scaled.size().x() - expected.x()).abs() <= tolerance - && (scaled.size().y() - expected.y()).abs() <= tolerance, - "expected extent {:?}, got {:?}", - expected, - scaled.size(), + let scaled = bounds + .scaled_about_centre(factor) + .expect("corners in -1e3..1e3 scaled by a factor in 1e-2..1e2 stay inside the `f32` range"); + + let scale = f64::from(factor); + assert_axis_reaches( + scaled.min().x(), + scaled.max().x(), + extent_of(bounds.min().x(), bounds.max().x()) * scale, ); - prop_assert!( - (scaled.centre().x() - bounds.centre().x()).abs() <= tolerance - && (scaled.centre().y() - bounds.centre().y()).abs() <= tolerance, - "{:?} is not centred on {:?}", - scaled, - bounds, + assert_axis_reaches( + scaled.min().y(), + scaled.max().y(), + extent_of(bounds.min().y(), bounds.max().y()) * scale, ); + assert_midpoint_within_an_ulp(bounds, scaled); - let (outer, inner) = if factor.get() >= 1.0 { + let (outer, inner) = if factor >= Positive::ONE { (scaled, bounds) } else { (bounds, scaled) }; prop_assert!( - outer.min().x() <= inner.min().x() + tolerance - && outer.min().y() <= inner.min().y() + tolerance - && outer.max().x() >= inner.max().x() - tolerance - && outer.max().y() >= inner.max().y() - tolerance, + outer.contains(inner.min()) && outer.contains(inner.max()), "{outer:?} does not contain {inner:?}", ); } +/// Generates a box with its `x` axis collapsed onto a line. +/// +/// The `y` axis keeps the well-conditioned extent of [`bounds_strategy`]. +fn collinear_strategy() -> impl Strategy { + bounds_strategy().prop_map(|bounds| { + Bounds2::new(bounds.min(), Vec2::new(bounds.min().x(), bounds.max().y())) + .expect("collapsing an axis keeps the corners ordered") + }) +} + +#[property_test] +fn minimum_extent_growth_laws( + #[strategy = collinear_strategy()] bounds: Bounds2, + #[strategy = factor_strategy()] minimum: Positive, +) { + let widened = bounds + .with_minimum_extent(minimum) + .expect("corners in -1e3..1e3 widened by at most 1e2 stay inside the `f32` range"); + + prop_assert!( + widened.contains(bounds.min()) && widened.contains(bounds.max()), + "{widened:?} does not contain {bounds:?}", + ); + assert_midpoint_within_an_ulp(bounds, widened); + + let minimum = f64::from(minimum); + assert_axis_reaches(widened.min().x(), widened.max().x(), minimum); + + let height = extent_of(bounds.min().y(), bounds.max().y()); + if height >= minimum { + prop_assert_eq!( + (widened.min().y(), widened.max().y()), + (bounds.min().y(), bounds.max().y()), + ); + } else { + assert_axis_reaches(widened.min().y(), widened.max().y(), minimum); + } +} + /// The tests the `miri` nextest profile selects. /// /// Each test here reduces point slices through the batched kernel at every alignment offset and diff --git a/libs/@local/graph/atlas/src/math/derivation.rs b/libs/@local/graph/atlas/src/math/derivation.rs deleted file mode 100644 index e0da178474c..00000000000 --- a/libs/@local/graph/atlas/src/math/derivation.rs +++ /dev/null @@ -1,543 +0,0 @@ -//! Raw derivations that make their one claim at the end. -//! -//! A [`Derivation`] is an in-flight computation's raw value with no claim attached. The -//! arithmetic runs on the raw carrier exactly as it would on bare floats, and poison propagates -//! through IEEE semantics instead of being asserted away mid-fold. [`finish`] makes the one -//! claim at the end, where the result validates into its target [`Domain`] or comes back as -//! [`Diverged`] with the raw evidence. A raw-on-purpose destination exits through [`into_raw`] -//! instead, and a destination that owns a membership theorem exits through [`finish_unchecked`] -//! with the theorem stated beside the call. -//! -//! # Which op form a fold takes -//! -//! A fold bounded by a totality theorem keeps the plain escape op: the theorem is the claim and -//! the debug assertion is its net. A fold that refuses at its own site keeps the `checked_*` -//! form. An unbounded, data-dependent fold takes a derivation, because no theorem covers the -//! claim and the refusal belongs at the consumer's own finish rather than at every intermediate. -//! Count the consumers per site before choosing. A site with one consumer and one refusal does -//! not need three forms. -//! -//! # Reading a refusal -//! -//! [`Diverged`] holds the raw value, and the refusing site with the raw value names the -//! severity. A non-finite raw is an overflow or an indeterminate form, the expected numerical -//! refusal, and the consumer refuses the reading by name. A finite raw refused by a finish, -//! such as a negative value finishing into [`DNonNegative`], is a wrong claim - the derivation -//! was typed at the wrong domain, and the defect is the finish's type rather than the data. A -//! finite raw refused by a checked narrowing is lawful data past working precision - the claim -//! held at double width, and the refusal is the expected one where the overflow window is -//! lawful input. -//! -//! # Domain transitions -//! -//! A finish followed by a re-entry is deliberate. An accumulated total that finishes -//! positive and re-enters a mean's derivation marks exactly where the codomain switches, and -//! collapsing the two derivations into one would erase the intermediate claim the second one -//! builds on. -//! -//! # Laundering folds -//! -//! Where sums and products propagate poison, maxima and minima launder it. IEEE `maxNum` skips -//! NaN, and the sysroot sources confirm both float forms do the same: `Simd::simd_max` lowers -//! to the `maximum_number` intrinsic, while `Simd::reduce_max` folds `f64::max` from a NaN -//! seed, so one poisoned lane among finite lanes vanishes. A max or min op on a derivation must -//! therefore preserve poison by construction when a consumer demands one, and none does yet. The -//! SIMD twin of this type is a lanes-plus-validity-mask pair for the same reason, and the -//! uniform fit's `FitSums` accumulator hand-rolls that shape today. -//! -//! # The lifted grid -//! -//! The scalar rows are the type-level function: a lifted op on a derivation reads its output -//! domain off the operand domains' own row, so `Derivation ⊗ U` claims exactly what `D ⊗ U` -//! claims, computed on the raw carriers. Sums and differences read the plain-domain escape -//! rows, products and quotients read the derivation-entering fat-exit rows, and a raw `f64` -//! operand keeps the fold's target unchanged, because a raw operand claims nothing and the -//! derivation defers every claim anyway. -//! -//! # The one downward escape -//! -//! Division by an in-flight derivation is the one lift where poison can leave silently: a -//! clean finite numerator over a denominator that overflowed to `±∞` gives `±0.0`, and the -//! escape vanishes before any finish can refuse it. The rounding is defensible - the true -//! quotient's magnitude is at most `|numerator| / f64::MAX`, and a positive-domain destination -//! still refuses the zero - but it rounds where every upward escape refuses. The divisor rows -//! taking a domain scalar are immune: a validated divisor is finite, and a zero divisor sends -//! the quotient up to `±∞` or NaN, which the finish catches. -//! -//! [`finish`]: Derivation::finish -//! [`finish_unchecked`]: Derivation::finish_unchecked -//! [`into_raw`]: Derivation::into_raw - -use core::{fmt, ops}; - -use super::scalar::{DFinite, DNonNegative, DPositive, NonNegative}; - -/// Reads the raw carrier out of an operand. -/// -/// The one vocabulary for every operand kind a fused op takes: a domain scalar reads its -/// validated value, and an in-flight derivation reads its unclaimed raw. A bound on this trait -/// lets one signature accept a mix of the two. -pub(crate) impl(self) const trait IntoCarrier: Sized { - /// The raw carrier the operand computes in. - type Carrier: Copy; - - /// Returns the raw carrier value. - fn into_carrier(self) -> Self::Carrier; -} - -const impl IntoCarrier for Derivation { - type Carrier = D::Carrier; - - #[inline] - fn into_carrier(self) -> Self::Carrier { - self.0 - } -} - -/// A validated domain a derivation can finish into. -/// -/// Implementations are one-liners over the domain's validating constructor, so the domain's -/// membership is stated exactly once. -pub(crate) impl(self) const trait Domain: [const] IntoCarrier { - /// Validates a raw carrier value into the domain. - fn validate(raw: Self::Carrier) -> Option; - - /// Claims a raw carrier value as a domain member without validating. - /// - /// The caller owns the membership argument. - fn unchecked(raw: Self::Carrier) -> Self; -} - -const impl IntoCarrier for DFinite { - type Carrier = f64; - - #[inline] - fn into_carrier(self) -> Self::Carrier { - self.get() - } -} - -const impl Domain for DFinite { - #[inline] - fn validate(raw: f64) -> Option { - Self::new(raw) - } - - #[inline] - fn unchecked(raw: f64) -> Self { - Self::new_unchecked(raw) - } -} - -const impl IntoCarrier for DNonNegative { - type Carrier = f64; - - #[inline] - fn into_carrier(self) -> Self::Carrier { - self.get() - } -} - -const impl Domain for DNonNegative { - #[inline] - fn validate(raw: f64) -> Option { - Self::new(raw) - } - - #[inline] - fn unchecked(raw: f64) -> Self { - Self::new_unchecked(raw) - } -} - -const impl IntoCarrier for DPositive { - type Carrier = f64; - - #[inline] - fn into_carrier(self) -> Self::Carrier { - self.get() - } -} - -const impl Domain for DPositive { - #[inline] - fn validate(raw: f64) -> Option { - Self::new(raw) - } - - #[inline] - fn unchecked(raw: f64) -> Self { - Self::new_unchecked(raw) - } -} - -const impl IntoCarrier for NonNegative { - type Carrier = f32; - - #[inline] - fn into_carrier(self) -> Self::Carrier { - self.get() - } -} - -const impl Domain for NonNegative { - #[inline] - fn validate(raw: f32) -> Option { - Self::new(raw) - } - - #[inline] - fn unchecked(raw: f32) -> Self { - Self::new_unchecked(raw) - } -} - -/// An unclaimed value in flight toward its domain. -/// -/// The wrapper is the target domain's name attached to a raw value, and it asserts nothing -/// about the bits it holds. Every op computes on the raw carrier, and [`finish`](Self::finish) is -/// where the domain's claim is made. -#[must_use = "a derivation claims nothing until it finishes"] -#[repr(transparent)] -pub(crate) struct Derivation(D::Carrier); - -/// A refused derivation reading: the raw value that missed its claim. -/// -/// The raw value is the evidence, and the refusing site fixes its meaning: at a finish, -/// non-finite is the expected numerical refusal and a finite miss is a wrong claim on the -/// wrong domain; at a checked narrowing, a finite raw is lawful data whose reading overflows -/// working precision. -#[derive(Debug, Copy, Clone, PartialEq)] -pub(crate) struct Diverged { - /// The raw value the validation refused. - pub raw: C, -} - -impl Derivation { - /// Enters a raw value into the derivation. - /// - /// No claim is made or checked. The value is whatever it is until the finish. - #[inline] - pub(crate) const fn raw(value: D::Carrier) -> Self { - Self(value) - } - - /// Exits with the raw value, claiming nothing. - /// - /// For destinations that are raw on purpose, such as the solver objective, where a - /// non-finite value must reach the later refusal that names it rather than refuse here. - /// Where the destination wants the domain, [`finish`](Self::finish) claims it instead. - #[inline] - #[must_use] - pub(crate) const fn into_raw(self) -> D::Carrier { - self.0 - } - - /// Validates the raw value into the domain. - /// - /// # Errors - /// - /// Returns [`Diverged`] carrying the raw value when it lies outside the domain. - #[inline] - pub(crate) fn finish(self) -> Result> { - D::validate(self.0).ok_or(Diverged { raw: self.0 }) - } - - /// Finishes the derivation without validating the raw value. - /// - /// For a destination that owns a membership theorem, stated beside the call. Where no - /// theorem covers the raw value, [`finish`](Self::finish) validates instead. - #[inline] - pub(crate) fn finish_unchecked(self) -> D { - D::unchecked(self.0) - } -} - -const impl From for Derivation -where - D: [const] Domain, -{ - /// Enters a validated value into the derivation on its carrier: the typed twin of - /// [`raw`](Derivation::raw), seeded by the domain's own proof instead of no claim at all. - #[inline] - fn from(value: D) -> Self { - Self(value.into_carrier()) - } -} - -impl> Derivation { - /// The zero seed of an accumulating derivation. - pub(crate) const ZERO: Self = Self(0.0); - - /// Fuses `self · factor + addend` with one rounding, on the raw carrier. - #[inline] - pub(crate) const fn mul_add(self, factor: F, addend: A) -> Derivation - where - F: [const] IntoCarrier, - A: [const] IntoCarrier, - O: Domain, - U: Domain, - Self: ops::Mul>, - Derivation: ops::Add>>, - { - Derivation(self.0.mul_add(factor.into_carrier(), addend.into_carrier())) - } - - /// Returns the square root of `self`, with one rounding, on the raw carrier. - #[inline] - pub(crate) fn sqrt(self) -> Self { - Self(self.0.sqrt()) - } -} - -impl Clone for Derivation { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for Derivation {} - -impl> fmt::Debug for Derivation { - fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Debug::fmt(&self.0, fmt) - } -} - -impl> ops::Neg for Derivation { - type Output = Self; - - #[inline] - fn neg(self) -> Self { - Self(-self.0) - } -} - -impl> ops::Sub for Derivation { - type Output = Self; - - /// Subtracts a raw offset, keeping the fold's target. - /// - /// A raw operand claims nothing, and the derivation already defers every claim, so the - /// target domain rides through unchanged and the finish still decides. - #[inline] - fn sub(self, rhs: f64) -> Self { - Self(self.0 - rhs) - } -} - -impl> ops::Mul for Derivation { - type Output = Self; - - /// Scales by a raw factor, keeping the fold's target. - /// - /// A raw operand claims nothing, and the derivation already defers every claim, so the - /// target domain rides through unchanged and the finish still decides. - #[inline] - fn mul(self, rhs: f64) -> Self { - Self(self.0 * rhs) - } -} - -const impl< - D: Domain> + ops::Add, - U: Domain + [const] IntoCarrier, - O: Domain, -> ops::Add for Derivation -{ - type Output = Derivation; - - #[inline] - fn add(self, rhs: U) -> Derivation { - Derivation(self.0 + rhs.into_carrier()) - } -} - -const impl< - D: Domain> + ops::Add, - U: Domain, - O: Domain, -> ops::Add> for Derivation -{ - type Output = Derivation; - - #[inline] - fn add(self, rhs: Derivation) -> Self::Output { - Derivation(self.0 + rhs.0) - } -} - -const impl< - D: Domain + ops::Add, - U: Domain + [const] IntoCarrier, -> ops::AddAssign for Derivation -{ - /// Accumulates in place within the fold's own domain. - /// - /// The scalar row must land back in `D`: an accumulation cannot retarget the derivation it - /// grows. - #[inline] - fn add_assign(&mut self, rhs: U) { - self.0 += rhs.into_carrier(); - } -} - -const impl< - D: Domain + ops::Add, - U: Domain, -> ops::AddAssign> for Derivation -{ - /// Accumulates an in-flight derivation in place, within the fold's own domain. - #[inline] - fn add_assign(&mut self, rhs: Derivation) { - self.0 += rhs.0; - } -} - -const impl< - D: Domain> + ops::Sub, - U: Domain + [const] IntoCarrier, - O: Domain, -> ops::Sub for Derivation -{ - type Output = Derivation; - - #[inline] - fn sub(self, rhs: U) -> Derivation { - Derivation(self.0 - rhs.into_carrier()) - } -} - -const impl< - D: Domain> + ops::Sub, - U: Domain, - O: Domain, -> ops::Sub> for Derivation -{ - type Output = Derivation; - - #[inline] - fn sub(self, rhs: Derivation) -> Self::Output { - Derivation(self.0 - rhs.0) - } -} - -const impl< - D: Domain> + ops::Mul>, - U: Domain + [const] IntoCarrier, - O: Domain, -> ops::Mul for Derivation -{ - type Output = Derivation; - - #[inline] - fn mul(self, rhs: U) -> Derivation { - Derivation(self.0 * rhs.into_carrier()) - } -} - -const impl< - D: Domain> + ops::Mul>, - U: Domain, - O: Domain, -> ops::Mul> for Derivation -{ - type Output = Derivation; - - #[inline] - fn mul(self, rhs: Derivation) -> Self::Output { - Derivation(self.0 * rhs.0) - } -} - -const impl< - D: Domain> + ops::Div>, - U: Domain + [const] IntoCarrier, - O: Domain, -> ops::Div for Derivation -{ - type Output = Derivation; - - #[inline] - fn div(self, rhs: U) -> Derivation { - Derivation(self.0 / rhs.into_carrier()) - } -} - -const impl< - D: Domain> + ops::Div>, - U: Domain, - O: Domain, -> ops::Div> for Derivation -{ - type Output = Derivation; - - #[inline] - fn div(self, rhs: Derivation) -> Self::Output { - Derivation(self.0 / rhs.0) - } -} - -#[cfg(test)] -mod tests { - #![expect( - clippy::float_cmp, - reason = "exactness assertions on raw carriers are bit-precise contracts" - )] - - use super::{Derivation, Diverged, Domain as _}; - use crate::math::{DFinite, DNonNegative, d_non_negative, d_positive}; - - #[test] - fn poison_propagation() { - let mut sum = Derivation::::ZERO; - sum += Derivation::raw(1.0); - sum += Derivation::raw(f64::NAN); - sum += Derivation::raw(2.0); - - let carried = (-sum).mul_add(d_non_negative!(2.0), Derivation::raw(1.0)) / d_positive!(4.0); - let diverged = carried.finish().expect_err("NaN survives every op"); - assert!(diverged.raw.is_nan()); - - let overflowed = - Derivation::::raw(f64::MAX).mul_add(d_non_negative!(2.0), Derivation::ZERO); - assert_eq!(overflowed.finish(), Err(Diverged { raw: f64::INFINITY })); - } - - #[test] - fn finite_domain_miss() { - // The raw is finite, so the data is fine and the claim itself is wrong. - let negative = Derivation::::ZERO - 3.0; - - assert_eq!(negative.finish(), Err(Diverged { raw: -3.0 })); - } - - #[test] - fn raw_carrier_ops() { - let value = Derivation::::raw(2.0); - - assert_eq!((value - 5.0).into_raw(), -3.0); - assert_eq!((value - Derivation::raw(0.5)).into_raw(), 1.5); - assert_eq!((-value).into_raw(), -2.0); - assert_eq!( - value - .mul_add(d_non_negative!(3.0), Derivation::raw(0.125)) - .into_raw(), - 2.0_f64.mul_add(3.0, 0.125) - ); - assert_eq!((value / d_positive!(8.0)).into_raw(), 0.25); - } - - #[test] - fn finish_matches_constructor() { - assert_eq!( - Derivation::::raw(-1.5).finish(), - Ok(DFinite::new(-1.5).expect("-1.5 is finite")) - ); - assert_eq!(DFinite::validate(f64::INFINITY), None); - assert_eq!( - DNonNegative::validate(0.0), - Some(DNonNegative::new(0.0).expect("zero is non-negative")) - ); - } -} diff --git a/libs/@local/graph/atlas/src/math/derivation/domain.rs b/libs/@local/graph/atlas/src/math/derivation/domain.rs new file mode 100644 index 00000000000..66b15c3c805 --- /dev/null +++ b/libs/@local/graph/atlas/src/math/derivation/domain.rs @@ -0,0 +1,184 @@ +//! Scalar validation and carrier operations for deferred arithmetic. + +use super::Derivation; +use crate::math::scalar::{DFinite, DNonNegative, DPositive, Finite, NonNegative, Positive}; + +/// An operand's conversion to its raw arithmetic carrier. +/// +/// Scalars supply validated values, while derivations supply unvalidated intermediate values. +/// Arithmetic accepting this trait can combine both forms. +pub(crate) impl(self) const trait IntoCarrier: Sized { + /// The raw carrier the operand computes in. + type Carrier: Copy; + + /// Returns the raw carrier value. + fn into_carrier(self) -> Self::Carrier; +} + +/// A validated domain a derivation can finish into. +/// +/// Validation follows the scalar type's constructor, including any normalization it performs. +pub(crate) impl(self) const trait Domain: [const] IntoCarrier { + /// Validates a raw carrier value into the domain. + fn validate(raw: Self::Carrier) -> Option; + + /// Claims a raw carrier value as a domain member without validating. + /// + /// The caller must establish that `raw` satisfies the domain's membership requirements. + fn unchecked(raw: Self::Carrier) -> Self; +} + +/// Fused multiplication and addition at the carrier's precision. +pub(crate) impl(self) const trait MulAdd: Sized { + /// Computes `self · factor + addend` with one rounding. + fn mul_add(self, factor: Self, addend: Self) -> Self; +} + +const impl MulAdd for f32 { + #[inline] + fn mul_add(self, factor: Self, addend: Self) -> Self { + self.mul_add(factor, addend) + } +} + +const impl MulAdd for f64 { + #[inline] + fn mul_add(self, factor: Self, addend: Self) -> Self { + self.mul_add(factor, addend) + } +} + +const impl IntoCarrier for Derivation { + type Carrier = D::Carrier; + + #[inline] + fn into_carrier(self) -> Self::Carrier { + self.into_raw() + } +} + +const impl IntoCarrier for Finite { + type Carrier = f32; + + #[inline] + fn into_carrier(self) -> Self::Carrier { + self.get() + } +} + +const impl Domain for Finite { + #[inline] + fn validate(raw: f32) -> Option { + Self::new(raw) + } + + #[inline] + fn unchecked(raw: f32) -> Self { + Self::new_unchecked(raw) + } +} + +const impl IntoCarrier for Positive { + type Carrier = f32; + + #[inline] + fn into_carrier(self) -> Self::Carrier { + self.get() + } +} + +const impl Domain for Positive { + #[inline] + fn validate(raw: f32) -> Option { + Self::new(raw) + } + + #[inline] + fn unchecked(raw: f32) -> Self { + Self::new_unchecked(raw) + } +} + +const impl IntoCarrier for NonNegative { + type Carrier = f32; + + #[inline] + fn into_carrier(self) -> Self::Carrier { + self.get() + } +} + +const impl Domain for NonNegative { + #[inline] + fn validate(raw: f32) -> Option { + Self::new(raw) + } + + #[inline] + fn unchecked(raw: f32) -> Self { + Self::new_unchecked(raw) + } +} + +const impl IntoCarrier for DFinite { + type Carrier = f64; + + #[inline] + fn into_carrier(self) -> Self::Carrier { + self.get() + } +} + +const impl Domain for DFinite { + #[inline] + fn validate(raw: f64) -> Option { + Self::new(raw) + } + + #[inline] + fn unchecked(raw: f64) -> Self { + Self::new_unchecked(raw) + } +} + +const impl IntoCarrier for DNonNegative { + type Carrier = f64; + + #[inline] + fn into_carrier(self) -> Self::Carrier { + self.get() + } +} + +const impl Domain for DNonNegative { + #[inline] + fn validate(raw: f64) -> Option { + Self::new(raw) + } + + #[inline] + fn unchecked(raw: f64) -> Self { + Self::new_unchecked(raw) + } +} + +const impl IntoCarrier for DPositive { + type Carrier = f64; + + #[inline] + fn into_carrier(self) -> Self::Carrier { + self.get() + } +} + +const impl Domain for DPositive { + #[inline] + fn validate(raw: f64) -> Option { + Self::new(raw) + } + + #[inline] + fn unchecked(raw: f64) -> Self { + Self::new_unchecked(raw) + } +} diff --git a/libs/@local/graph/atlas/src/math/derivation/mod.rs b/libs/@local/graph/atlas/src/math/derivation/mod.rs new file mode 100644 index 00000000000..62f737f998f --- /dev/null +++ b/libs/@local/graph/atlas/src/math/derivation/mod.rs @@ -0,0 +1,463 @@ +//! Floating-point computations validated at a chosen domain boundary. +//! +//! A [`Derivation`] carries a raw value and a target [`Domain`], without asserting membership +//! during arithmetic. [`finish`](Derivation::finish) validates the current value and returns +//! [`Diverged`] with that value on rejection. Use [`into_raw`](Derivation::into_raw) when the +//! destination needs the raw result, or [`finish_unchecked`](Derivation::finish_unchecked) when +//! you have established membership yourself. +//! +//! Delaying validation permits a reduction to check its final result once. A `checked_*` scalar +//! operation instead reports rejection at that operation. When an intermediate result must +//! satisfy a domain before further arithmetic, finish it there and begin another derivation. +//! For example, validating an accumulated denominator as positive establishes the condition +//! needed by a subsequent mean. +//! +//! # Result domains +//! +//! Typed operators select the result domain from the corresponding scalar operator's output. +//! If `D + U` returns `O`, adding `U` or `Derivation` to `Derivation` returns +//! `Derivation`. Multiplication and division use the scalar operators that already return +//! derivations. Raw operands and negation keep the target domain unchanged. In-place addition +//! requires the corresponding scalar addition to return the existing target domain. In every case +//! the arithmetic uses the raw carriers, and membership remains deferred. +//! +//! # Exceptional intermediate values +//! +//! Validation observes the final value, not an error flag recording earlier operations. IEEE +//! arithmetic can turn an infinite intermediate into a finite result: a finite numerator divided +//! by an infinite denominator is signed zero. A finite or non-negative destination accepts that +//! zero, while a positive destination rejects it. If every intermediate must be finite, validate +//! at the required intermediate boundaries instead. +//! +//! A reduction using [`f64::min`] or [`f64::max`] can also discard a NaN: when exactly one operand +//! is NaN, these operations return the other operand. Final-value validation can then accept the +//! reduced value. Validate each input separately when the reduction must reject every non-finite +//! input. +//! +//! A refusal alone does not identify its cause or severity. [`Diverged`] preserves a non-finite +//! result or an out-of-domain finite value, such as a negative result destined for +//! [`DNonNegative`](super::DNonNegative), for the surrounding operation to interpret. + +use core::{fmt, marker::Destruct, ops}; + +use self::domain::MulAdd; + +mod domain; + +pub(crate) use self::domain::{Domain, IntoCarrier}; + +/// A raw computation with a domain to validate at its finish. +/// +/// Arithmetic makes no membership claim. [`finish`](Self::finish) checks the current value +/// against the target domain. +#[must_use = "a derivation claims nothing until it finishes"] +#[repr(transparent)] +pub(crate) struct Derivation(D::Carrier); + +/// A raw value rejected by domain validation or checked narrowing. +/// +/// The value identifies what was rejected, without assigning a cause or severity. +#[derive(Debug, Copy, Clone, PartialEq)] +pub(crate) struct Diverged { + /// The raw value the validation refused. + pub raw: C, +} + +impl Derivation { + /// Begins a derivation from an unvalidated raw value. + #[inline] + pub(crate) const fn raw(value: D::Carrier) -> Self { + Self(value) + } + + /// Returns the raw value without domain validation. + /// + /// Use [`Self::finish`] when the destination requires a validated domain member. + #[inline] + #[must_use] + pub(crate) const fn into_raw(self) -> D::Carrier { + self.0 + } + + /// Validates the raw value into the domain. + /// + /// # Errors + /// + /// Returns [`Diverged`] carrying the raw value when it lies outside the domain. + #[inline] + pub(crate) const fn finish(self) -> Result> + where + D: [const] Domain + [const] Destruct, + { + match D::validate(self.0) { + Some(value) => Ok(value), + None => Err(Diverged { raw: self.0 }), + } + } + + /// Finishes the derivation without validating the raw value. + /// + /// You must establish that the current raw value satisfies the target domain's requirements. + /// Use [`finish`](Self::finish) to validate other results. + #[inline] + pub(crate) fn finish_unchecked(self) -> D { + D::unchecked(self.0) + } + + /// Fuses `self · factor + addend` with one rounding at the carrier's precision. + #[inline] + pub(crate) const fn mul_add(self, factor: F, addend: A) -> Derivation + where + D::Carrier: [const] MulAdd, + F: [const] IntoCarrier, + A: [const] IntoCarrier, + O: Domain, + U: Domain, + Self: ops::Mul>, + Derivation: ops::Add>>, + { + Derivation(self.0.mul_add(factor.into_carrier(), addend.into_carrier())) + } +} + +const impl From for Derivation +where + D: [const] Domain, +{ + #[inline] + fn from(value: D) -> Self { + Self(value.into_carrier()) + } +} + +impl> Derivation { + /// The zero seed of an accumulating derivation. + pub(crate) const ZERO: Self = Self(0.0); + + /// Returns the square root of `self`, with one rounding, on the raw carrier. + #[inline] + pub(crate) fn sqrt(self) -> Self { + Self(self.0.sqrt()) + } +} + +impl Clone for Derivation { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for Derivation {} + +impl> fmt::Debug for Derivation { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self.0, fmt) + } +} + +impl>> ops::Neg for Derivation { + type Output = Self; + + #[inline] + fn neg(self) -> Self { + Self(-self.0) + } +} + +impl> ops::Sub for Derivation { + type Output = Self; + + #[inline] + fn sub(self, rhs: f64) -> Self { + Self(self.0 - rhs) + } +} + +impl> ops::Mul for Derivation { + type Output = Self; + + #[inline] + fn mul(self, rhs: f32) -> Self { + Self(self.0 * rhs) + } +} + +impl> ops::Mul for Derivation { + type Output = Self; + + #[inline] + fn mul(self, rhs: f64) -> Self { + Self(self.0 * rhs) + } +} + +const impl< + D: Domain> + ops::Add, + U: Domain + [const] IntoCarrier, + O: Domain, +> ops::Add for Derivation +{ + type Output = Derivation; + + #[inline] + fn add(self, rhs: U) -> Derivation { + Derivation(self.0 + rhs.into_carrier()) + } +} + +const impl< + D: Domain> + ops::Add, + U: Domain, + O: Domain, +> ops::Add> for Derivation +{ + type Output = Derivation; + + #[inline] + fn add(self, rhs: Derivation) -> Self::Output { + Derivation(self.0 + rhs.0) + } +} + +const impl< + D: Domain + ops::Add, + U: Domain + [const] IntoCarrier, +> ops::AddAssign for Derivation +{ + #[inline] + fn add_assign(&mut self, rhs: U) { + self.0 += rhs.into_carrier(); + } +} + +const impl< + D: Domain + ops::Add, + U: Domain, +> ops::AddAssign> for Derivation +{ + #[inline] + fn add_assign(&mut self, rhs: Derivation) { + self.0 += rhs.0; + } +} + +const impl< + D: Domain> + ops::Sub, + U: Domain + [const] IntoCarrier, + O: Domain, +> ops::Sub for Derivation +{ + type Output = Derivation; + + #[inline] + fn sub(self, rhs: U) -> Derivation { + Derivation(self.0 - rhs.into_carrier()) + } +} + +const impl< + D: Domain> + ops::Sub, + U: Domain, + O: Domain, +> ops::Sub> for Derivation +{ + type Output = Derivation; + + #[inline] + fn sub(self, rhs: Derivation) -> Self::Output { + Derivation(self.0 - rhs.0) + } +} + +const impl< + D: Domain> + ops::Mul>, + U: Domain + [const] IntoCarrier, + O: Domain, +> ops::Mul for Derivation +{ + type Output = Derivation; + + #[inline] + fn mul(self, rhs: U) -> Derivation { + Derivation(self.0 * rhs.into_carrier()) + } +} + +const impl< + D: Domain> + ops::Mul>, + U: Domain, + O: Domain, +> ops::Mul> for Derivation +{ + type Output = Derivation; + + #[inline] + fn mul(self, rhs: Derivation) -> Self::Output { + Derivation(self.0 * rhs.0) + } +} + +const impl< + D: Domain> + ops::Div>, + U: Domain + [const] IntoCarrier, + O: Domain, +> ops::Div for Derivation +{ + type Output = Derivation; + + #[inline] + fn div(self, rhs: U) -> Derivation { + Derivation(self.0 / rhs.into_carrier()) + } +} + +const impl< + D: Domain> + ops::Div>, + U: Domain, + O: Domain, +> ops::Div> for Derivation +{ + type Output = Derivation; + + #[inline] + fn div(self, rhs: Derivation) -> Self::Output { + Derivation(self.0 / rhs.0) + } +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::float_cmp, + reason = "exactness assertions on raw carriers are bit-precise contracts" + )] + + use super::{Derivation, Diverged, Domain as _}; + use crate::math::{ + DFinite, DNonNegative, Finite, NonNegative, Positive, d_non_negative, d_positive, finite, + non_negative, positive, + }; + + #[test] + fn poison_propagation() { + let mut sum = Derivation::::ZERO; + sum += Derivation::raw(1.0); + sum += Derivation::raw(f64::NAN); + sum += Derivation::raw(2.0); + + let carried = (-sum).mul_add(d_non_negative!(2.0), Derivation::raw(1.0)) / d_positive!(4.0); + let diverged = carried.finish().expect_err("NaN survives every op"); + assert!(diverged.raw.is_nan()); + + let overflowed = + Derivation::::raw(f64::MAX).mul_add(d_non_negative!(2.0), Derivation::ZERO); + assert_eq!(overflowed.finish(), Err(Diverged { raw: f64::INFINITY })); + } + + #[test] + fn finite_domain_miss() { + let negative = Derivation::::ZERO - 3.0; + + assert_eq!(negative.finish(), Err(Diverged { raw: -3.0 })); + } + + #[test] + fn raw_carrier_ops() { + let value = Derivation::::raw(2.0); + + assert_eq!((value - 5.0).into_raw(), -3.0); + assert_eq!((value - Derivation::raw(0.5)).into_raw(), 1.5); + assert_eq!((-value).into_raw(), -2.0); + assert_eq!( + value + .mul_add(d_non_negative!(3.0), Derivation::raw(0.125)) + .into_raw(), + 2.0_f64.mul_add(3.0, 0.125) + ); + assert_eq!((value / d_positive!(8.0)).into_raw(), 0.25); + } + + #[test] + fn fused_single_precision() { + const VALUE: Derivation = + Derivation::from(positive!(2.0)).mul_add(non_negative!(3.0), positive!(0.125)); + + assert_eq!(VALUE.finish(), Ok(positive!(6.125))); + let overflowed = Derivation::from(Positive::MAX).mul_add(non_negative!(2.0), Positive::ONE); + assert_eq!(overflowed.finish(), Err(Diverged { raw: f32::INFINITY })); + } + + #[test] + fn finite_signed_expression() { + let product = finite!(-2.0) * positive!(3.0); + let quotient = product * non_negative!(4.0) / positive!(8.0); + assert_eq!(quotient.finish(), Ok(finite!(-3.0))); + assert_eq!((-quotient).finish(), Ok(finite!(3.0))); + + let overflowed = finite!(-f32::MAX) * positive!(2.0); + assert_eq!( + overflowed.finish(), + Err(Diverged { + raw: f32::NEG_INFINITY + }) + ); + let indeterminate = overflowed / (Positive::MAX * positive!(2.0)); + assert!( + indeterminate + .finish() + .expect_err("infinity divided by infinity is NaN") + .raw + .is_nan() + ); + } + + #[test] + fn positive_underflow() { + assert_eq!( + (Positive::MIN * Positive::MIN).finish(), + Err(Diverged { raw: 0.0 }) + ); + assert_eq!( + (Positive::MIN / Positive::MAX).finish(), + Err(Diverged { raw: 0.0 }) + ); + } + + #[test] + fn finite_division_overflow() { + assert_eq!( + (finite!(f32::MAX) / Positive::MIN).finish(), + Err(Diverged { raw: f32::INFINITY }) + ); + assert_eq!((finite!(-6.0) / positive!(2.0)).finish(), Ok(finite!(-3.0))); + } + + #[test] + fn finish_single_precision_zero() { + assert_eq!( + Derivation::::raw(0.0).finish(), + Err(Diverged { raw: 0.0 }) + ); + let nonnegative = Derivation::::raw(-0.0) + .finish() + .expect("zero is nonnegative"); + assert_eq!(nonnegative.get().to_bits(), 0.0_f32.to_bits()); + let finite = Derivation::::raw(-0.0) + .finish() + .expect("zero is finite"); + assert_eq!(finite.get().to_bits(), (-0.0_f32).to_bits()); + } + + #[test] + fn finish_matches_constructor() { + assert_eq!( + Derivation::::raw(-1.5).finish(), + Ok(DFinite::new(-1.5).expect("-1.5 is finite")) + ); + assert_eq!(DFinite::validate(f64::INFINITY), None); + assert_eq!( + DNonNegative::validate(0.0), + Some(DNonNegative::new(0.0).expect("zero is non-negative")) + ); + } +} diff --git a/libs/@local/graph/atlas/src/math/dvec2/mod.rs b/libs/@local/graph/atlas/src/math/dvec2/mod.rs index ae39b798d28..573c1088254 100644 --- a/libs/@local/graph/atlas/src/math/dvec2/mod.rs +++ b/libs/@local/graph/atlas/src/math/dvec2/mod.rs @@ -153,14 +153,16 @@ impl DVec2 { /// Narrows both components with round-to-nearest, allowing non-finite results. /// - /// A component beyond the finite `f32` range overflows to `±∞` rather than refusing. A - /// caller that must reject an out-of-range component calls [`narrow`](Self::narrow) instead, - /// which returns [`None`] on exactly that case. - #[inline] + /// # Warning + /// + /// Precision is lost when a component is not exactly representable in `f32`. Finite components + /// may round to infinity, and NaN remains NaN. Use [`Self::narrow`] to reject non-finite + /// outputs. #[expect( clippy::cast_possible_truncation, reason = "the rounding cast is the operation itself" )] + #[inline] #[must_use] pub const fn narrow_lossy(self) -> Vec2 { Vec2::new(self.x() as f32, self.y() as f32) @@ -304,13 +306,13 @@ impl DVec2x4T { /// Returns the four `x` components as SIMD lanes. /// /// Lane `i` holds the `x` component of vector `i`. - #[inline] - #[must_use] #[expect( clippy::cast_ptr_alignment, reason = "the pointer derives from `&Self` with 64-byte alignment, which satisfies \ `Simd`'s alignment at offset 0" )] + #[inline] + #[must_use] pub const fn xs(&self) -> &Simd { let this = &raw const *self; let this = this.cast::(); @@ -326,13 +328,13 @@ impl DVec2x4T { /// Returns the four `y` components as SIMD lanes. /// /// Lane `i` holds the `y` component of vector `i`. - #[inline] - #[must_use] #[expect( clippy::cast_ptr_alignment, reason = "the pointer derives from `&Self` with 64-byte alignment, which satisfies \ `Simd`'s alignment at the 32-byte `y` group offset" )] + #[inline] + #[must_use] pub const fn ys(&self) -> &Simd { let this = &raw const *self; let this = this.cast::(); @@ -350,12 +352,12 @@ impl DVec2x4T { /// The first group holds the `x` components, the second the `y` components. Lane `i` of each /// corresponds to vector `i`. This is the inverse of [`from_lanes`](Self::from_lanes) and the /// by-value counterpart of [`xs`](Self::xs) and [`ys`](Self::ys). - #[inline] - #[must_use] #[expect( clippy::tuple_array_conversions, reason = "the suggested `From` conversion is not const-callable" )] + #[inline] + #[must_use] pub const fn into_lanes(self) -> (Simd, Simd) { // SAFETY: This transmute relies on each SIMD vector having its array's element layout // without padding. Self contains initialized x then y groups in repr(C) storage, and @@ -442,15 +444,15 @@ impl DVec2x4T { /// /// With lane `i` of `factor` scaling both components of vector `i`. /// - /// This is the weighted-moment accumulation step. On targets with native FMA each component - /// fuses, and for components widened from `f32` scaled by a widened weight the products are - /// exact, so the fused and separate forms agree bit for bit. - #[inline] - #[must_use] + /// Each component uses a fused multiply-add with one rounding. When the component and factor + /// are widened finite `f32` values, their product is exact in `f64`, leaving only the addition + /// to the accumulator to round. #[expect( clippy::similar_names, reason = "the lane groups pair by axis: each `xs` binding has its `ys` sibling" )] + #[inline] + #[must_use] pub fn mul_add(self, factor: Simd, accumulator: Self) -> Self { let (xs, ys) = self.into_lanes(); let (acc_xs, acc_ys) = accumulator.into_lanes(); diff --git a/libs/@local/graph/atlas/src/math/dvec2/tests.rs b/libs/@local/graph/atlas/src/math/dvec2/tests.rs index 03151d13407..41fcc5b8a72 100644 --- a/libs/@local/graph/atlas/src/math/dvec2/tests.rs +++ b/libs/@local/graph/atlas/src/math/dvec2/tests.rs @@ -221,8 +221,8 @@ mod miri { #[test] #[expect( clippy::suboptimal_flops, - reason = "the potency guard contrasts the fused and separate forms, so the separate form \ - must stay unfused" + reason = "products and sum must round separately to distinguish the unfused and fused \ + results" )] fn dvec2x4t_distance_squared_matches_the_scalar_twin_per_lane() { let (sources, targets) = distance_pairs(); diff --git a/libs/@local/graph/atlas/src/math/field.rs b/libs/@local/graph/atlas/src/math/field.rs index 378025e630a..6a0e8acdae5 100644 --- a/libs/@local/graph/atlas/src/math/field.rs +++ b/libs/@local/graph/atlas/src/math/field.rs @@ -17,6 +17,7 @@ use core::{ ops::{Deref, Index}, simd::{Simd, num::SimdFloat as _}, }; +use std::alloc::Global; use hashql_core::id::{Id, IdSlice, IdVec}; use rayon::iter::ParallelIterator as _; @@ -116,15 +117,49 @@ impl FinitePointField where I: Id, { - /// Validates every point finite and wraps the slice. + /// Allocates `count` points at the origin in the global allocator. /// - /// The scan runs serially, four points at a time on SIMD lanes. The `math_kernels` - /// bench's `finite_scan` group holds the choice to wall-time measurement on an arm64 - /// Apple-silicon host: rayon's per-point search trails the serial scan at every measured - /// count from 2¹² through 2²⁰ (above 100× at 2¹⁴, above 4× at 2²⁰), and a chunked rayon - /// distribution of the serial scan's own batch predicate reads near parity at 2¹² and - /// decisively behind from 2¹⁴ through 2²⁰, because fork-join overhead dominates a - /// memory-bound predicate. + /// # Panics + /// + /// Panics if the point-slice layout cannot be represented. See [`Self::zeroed_in`]. + #[inline] + #[must_use] + pub(crate) fn zeroed(count: usize) -> Box { + Self::zeroed_in(count, Global) + } + + /// Allocates `count` points at the origin in `alloc`. + /// + /// The origin is finite. Zero-initialization establishes the field's invariant without a + /// coordinate scan. Allocation failure follows [`Box::new_zeroed_slice_in`]. + /// + /// # Panics + /// + /// Panics if the point-slice layout cannot be represented. + #[inline] + pub(crate) fn zeroed_in(count: usize, alloc: A) -> Box { + const { + /// Requires `T` to admit the all-zero bit pattern at compile time. + const fn assert_is_zeroed() {} + assert_is_zeroed::(); + + let bytes = [0_u8; size_of::()]; + let vec: Vec2 = zerocopy::transmute!(bytes); + assert!(vec == Vec2::ZERO); + assert!(vec.is_finite()); + } + + // SAFETY: assume_init requires every point to be initialized as Vec2. The boxed constructor + // supplies zeroed storage, and the compile-time FromZeros check and value assertions + // establish that an all-zero Vec2 is valid and finite. Therefore all elements may be + // assumed initialized, including the vacuous empty slice. + let boxed = unsafe { Box::new_zeroed_slice_in(count, alloc).assume_init() }; + let boxed = IdSlice::from_boxed_slice(boxed); + + Self::new_boxed_unchecked(boxed) + } + + /// Validates every point as finite and borrows the slice in place. /// /// # Errors /// @@ -254,6 +289,31 @@ where unsafe { Box::from_raw_in(ptr as *mut Self, alloc) } } + /// Copies finite points into the rows starting at `offset`. + /// + /// # Errors + /// + /// Returns the first non-finite point's destination id and leaves the field unchanged. + /// + /// # Panics + /// + /// Panics if the destination range extends beyond the field. If validation finds a non-finite + /// point, it also panics under [`Self::new`]'s ID-range condition or when the translated error + /// ID is outside `I`'s range. + pub(crate) fn copy_from( + &mut self, + offset: I, + points: &IdSlice, + ) -> Result<(), NonFinitePoint> { + let target = &mut self.0.as_raw_mut()[offset.as_usize()..][..points.len()]; + let finite = Self::new(points).map_err(|error| NonFinitePoint { + id: offset.plus(error.id.as_usize()), + })?; + target.copy_from_slice(finite.as_slice().as_raw()); + + Ok(()) + } + /// Returns the underlying point slice. #[inline] #[must_use] @@ -335,11 +395,11 @@ where /// # Panics /// /// This panics when the field is empty, because an empty set has no centroid. - #[must_use] #[expect( clippy::cast_precision_loss, - reason = "point counts sit far below 2^53, so the count converts exactly" + reason = "the point count is converted to f64 for double-precision centroid arithmetic" )] + #[must_use] pub(crate) fn centroid(&self) -> DVec2 { assert!(!self.0.is_empty(), "a centroid needs at least one point"); @@ -376,7 +436,7 @@ where /// about. #[expect( clippy::cast_precision_loss, - reason = "point counts sit far below 2^53, so the count converts exactly" + reason = "double-precision RMS normalization uses the point count as f64" )] #[must_use] pub(crate) fn rms_spread(&self) -> f64 { @@ -463,7 +523,76 @@ mod tests { } #[test] - fn the_scan_admits_a_finite_set_and_names_the_smallest_offender() { + fn zeroed_lengths() { + for count in [0, 1, 7] { + let field = FinitePointField::::zeroed(count); + assert_eq!(field.len(), count); + assert!(field.iter().all(|&point| point == Vec2::ZERO)); + } + } + + #[test] + fn copy_range() { + let mut field = FinitePointField::::zeroed(5); + let replacement = [Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)]; + field + .copy_from(RowId::new(2), IdSlice::from_raw(&replacement)) + .expect("should copy finite points"); + assert_eq!( + field.as_slice().as_raw(), + [ + Vec2::ZERO, + Vec2::ZERO, + replacement[0], + replacement[1], + Vec2::ZERO + ] + ); + } + + #[test] + fn copy_empty() { + for count in [0, 4] { + let mut field = FinitePointField::::zeroed(count); + field + .copy_from(RowId::from_usize(count), IdSlice::from_raw(&[])) + .expect("should accept an empty copy at the end"); + assert_eq!(field.len(), count); + assert!(field.iter().all(|&point| point == Vec2::ZERO)); + } + } + + #[test] + fn copy_non_finite() { + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + for point in [Vec2::new(value, 0.0), Vec2::new(0.0, value)] { + let mut field = FinitePointField::::zeroed(5); + let replacement = [Vec2::new(1.0, 2.0), point, point]; + let error = field + .copy_from(RowId::new(2), IdSlice::from_raw(&replacement)) + .expect_err("should refuse non-finite points"); + assert_eq!(error.id, RowId::new(3)); + assert_eq!(field.as_slice().as_raw(), [Vec2::ZERO; 5]); + } + } + } + + #[test] + #[should_panic(expected = "out of range")] + fn copy_overrun() { + let mut field = FinitePointField::::zeroed(2); + let _result = field.copy_from(RowId::new(1), IdSlice::from_raw(&[Vec2::ZERO; 2])); + } + + #[test] + #[should_panic(expected = "out of range")] + fn copy_offset_past_end() { + let mut field = FinitePointField::::zeroed(2); + let _result = field.copy_from(RowId::new(3), IdSlice::from_raw(&[])); + } + + #[test] + fn scan_first_non_finite() { let finite = points(); let field = FinitePointField::new(IdSlice::::from_raw(&finite)) .expect("every point is finite"); @@ -492,7 +621,7 @@ mod tests { } #[test] - fn gather_carries_the_points_in_draw_order() { + fn gather_draw_order() { let points = points(); let field = FinitePointField::new(IdSlice::::from_raw(&points)) .expect("every point is finite"); @@ -513,7 +642,7 @@ mod tests { #[test] #[should_panic(expected = "index out of bounds")] - fn gather_panics_outside_the_row_domain() { + fn gather_out_of_domain() { let points = points(); let field = FinitePointField::new(IdSlice::::from_raw(&points)) .expect("every point is finite"); @@ -525,8 +654,8 @@ mod tests { // The dyadic rectangle has centroid (2, −1) and four squared deviations of 5. The sum is // exactly 20, and its RMS spread is the floating-point square root of 5. #[test] - fn the_statistics_read_exact_dyadic_values() { - // Centroid (2, -1), deviations (∓2, ±1): the sums are exact dyadics. + fn statistics_dyadic_values() { + // centroid (2, −1), deviations (±2, ±1) let square = [ Vec2::new(0.0, 0.0), Vec2::new(4.0, -2.0), diff --git a/libs/@local/graph/atlas/src/math/kdtree/mod.rs b/libs/@local/graph/atlas/src/math/kdtree/mod.rs index 9424fede2bb..c5db0d205ad 100644 --- a/libs/@local/graph/atlas/src/math/kdtree/mod.rs +++ b/libs/@local/graph/atlas/src/math/kdtree/mod.rs @@ -196,8 +196,7 @@ where |index, _| Leaf::new(I::from_usize(index)), ) .expect( - "the leaf strategy is soft-bucketed and items arrive from the closure, so no \ - construction error is reachable", + "soft buckets and closure-supplied items make engine construction errors unreachable", ); Self { points, engine } diff --git a/libs/@local/graph/atlas/src/math/kdtree/tests.rs b/libs/@local/graph/atlas/src/math/kdtree/tests.rs index d6b596bae61..eec7a6def2e 100644 --- a/libs/@local/graph/atlas/src/math/kdtree/tests.rs +++ b/libs/@local/graph/atlas/src/math/kdtree/tests.rs @@ -14,7 +14,7 @@ use rand::{RngExt as _, SeedableRng as _}; use rand_xoshiro::Xoshiro256PlusPlus; use super::{BUCKET_ROWS, KdNeighbour, KdTree}; -use crate::math::{DNonNegative, FinitePointField, Vec2}; +use crate::math::{DNonNegative, FinitePointField, Vec2, nz}; hashql_core::id::newtype! { /// A row identity in a test frame. @@ -152,7 +152,7 @@ fn a_fully_co_located_frame_orders_by_row_alone() { let points = vec![Vec2::new(2.5, -3.5); BUCKET_ROWS + 8]; let tree = KdTree::build(frame(&points)); - let neighbours = tree.nearest(RowId::new(11), NonZero::new(7).expect("seven is nonzero")); + let neighbours = tree.nearest(RowId::new(11), nz!(7)); let expected: Vec> = (0..7) .map(|row| KdNeighbour { @@ -189,10 +189,7 @@ fn k_at_least_the_frame_returns_every_other_row() { let points = scattered(11, 33); let tree = KdTree::build(frame(&points)); - let neighbours = tree.nearest( - RowId::new(0), - NonZero::new(100).expect("a hundred is nonzero"), - ); + let neighbours = tree.nearest(RowId::new(0), nz!(100)); assert_eq!(neighbours.len(), 32); let mut rows: Vec = neighbours.iter().map(|neighbour| neighbour.row).collect(); @@ -206,10 +203,7 @@ fn the_query_row_is_never_a_readout_while_its_co_located_rows_are() { points[17] = points[3]; let tree = KdTree::build(frame(&points)); - let neighbours = tree.nearest( - RowId::new(3), - NonZero::new(19).expect("nineteen is nonzero"), - ); + let neighbours = tree.nearest(RowId::new(3), nz!(19)); assert!( neighbours @@ -229,7 +223,7 @@ fn the_query_row_is_never_a_readout_while_its_co_located_rows_are() { fn a_scratch_arena_serves_readouts_across_resets() { let points = scattered(17, 120); let tree = KdTree::build(frame(&points)); - let k = NonZero::new(9).expect("nine is nonzero"); + let k = nz!(9); let mut scratch = Scratch::new(); let first = tree.nearest_in(RowId::new(0), k, &scratch); @@ -247,7 +241,7 @@ fn a_query_for_a_row_outside_the_frame_panics() { let points = scattered(23, 5); let tree = KdTree::build(frame(&points)); - let _readout = tree.nearest(RowId::new(5), NonZero::new(1).expect("one is nonzero")); + let _readout = tree.nearest(RowId::new(5), nz!(1)); } #[test] @@ -285,7 +279,7 @@ fn a_point_query_excludes_no_row() { ]; let frame = frame(&points); let tree = KdTree::build(frame); - let k = NonZero::new(2).expect("two is nonzero"); + let k = nz!(2); let neighbours = tree.nearest(RowId::new(1), k); assert!( @@ -308,10 +302,7 @@ fn a_point_query_excludes_no_row() { fn an_empty_frame_builds_and_a_point_readout_returns_nothing() { let tree = KdTree::build(frame(&[])); - let readout = tree.nearest_point( - Vec2::new(0.0, 0.0), - NonZero::new(3).expect("three is nonzero"), - ); + let readout = tree.nearest_point(Vec2::new(0.0, 0.0), nz!(3)); assert!(readout.is_empty()); } @@ -321,8 +312,5 @@ fn a_non_finite_query_point_panics() { let points = scattered(37, 8); let tree = KdTree::build(frame(&points)); - let _readout = tree.nearest_point( - Vec2::new(f32::NAN, 0.0), - NonZero::new(1).expect("one is nonzero"), - ); + let _readout = tree.nearest_point(Vec2::new(f32::NAN, 0.0), nz!(1)); } diff --git a/libs/@local/graph/atlas/src/math/kernel/bench.rs b/libs/@local/graph/atlas/src/math/kernel/bench.rs index 3c3a1760bea..a32af0042d5 100644 --- a/libs/@local/graph/atlas/src/math/kernel/bench.rs +++ b/libs/@local/graph/atlas/src/math/kernel/bench.rs @@ -9,8 +9,7 @@ use core::simd::{f32x4, f32x8, f64x4}; /// Approximates each lane's exponential through [`super::exp_f64x4`]. #[expect( clippy::inline_always, - reason = "the seam must measure the wrapper as production calls it: transparently inlined, \ - with only the vendored kernel's call remaining" + reason = "request inlining to expose the benchmark expression to call-site optimization" )] #[inline(always)] #[must_use] @@ -21,8 +20,7 @@ pub fn exp_f64x4(values: f64x4) -> f64x4 { /// Approximates each lane's exponential through [`super::exp_f32x8`]. #[expect( clippy::inline_always, - reason = "the seam must measure the wrapper as production calls it: transparently inlined, \ - with only the vendored kernel's call remaining" + reason = "request inlining to expose the benchmark expression to call-site optimization" )] #[inline(always)] #[must_use] @@ -35,8 +33,7 @@ pub fn exp_f32x8(values: f32x8) -> f32x8 { /// This exposes the 16-entry split-table candidate for comparison with [`exp_f32x8`]. #[expect( clippy::inline_always, - reason = "the seam must measure the candidate as a production wrapper would call it: \ - transparently inlined, with only the kernel's call remaining" + reason = "request inlining to expose the benchmark expression to call-site optimization" )] #[inline(always)] #[must_use] @@ -51,8 +48,7 @@ pub fn exp_f32x8_table_gather(values: f32x8) -> f32x8 { #[cfg(all(target_arch = "aarch64", target_endian = "little"))] #[expect( clippy::inline_always, - reason = "the seam must measure the candidate as a production wrapper would call it: \ - transparently inlined, with only the kernel's call remaining" + reason = "request inlining to expose the benchmark expression to call-site optimization" )] #[inline(always)] #[must_use] @@ -66,8 +62,7 @@ pub fn exp_f32x8_table_tbl4(values: f32x8) -> f32x8 { /// apply. #[expect( clippy::inline_always, - reason = "the seam must measure the wrapper as production calls it: transparently inlined, \ - with only the vendored kernels' calls remaining" + reason = "request inlining to expose the benchmark expression to call-site optimization" )] #[inline(always)] #[must_use] diff --git a/libs/@local/graph/atlas/src/math/kernel/exp_table.rs b/libs/@local/graph/atlas/src/math/kernel/exp_table.rs index 56c240b663e..816a5d1164b 100644 --- a/libs/@local/graph/atlas/src/math/kernel/exp_table.rs +++ b/libs/@local/graph/atlas/src/math/kernel/exp_table.rs @@ -23,15 +23,21 @@ //! //! # Lookup portability //! -//! The arithmetic is target-independent. Only the 16-entry lookup is not. -//! [`Simd::gather_or_default`] is the portable form. On AVX2/AVX-512 it lowers to `vgatherdps` -//! (fine), on NEON it scalarizes (poor). The `aarch64` path below instead uses `vqtbl4q_u8` - a -//! single-instruction 64-byte table lookup, which is exactly a 16-entry f32 table for four lanes. -//! On x86 without fast gathers, the analogous trick is two `u8x32` `swizzle_dyn` calls per table -//! with the second index offset by 32 and the results OR-ed (out-of-range indices yield zero), -//! which lowers to `vpshufb` pairs on AVX2 and `vpermb` on AVX-512VBMI. - -use core::simd::prelude::*; +//! Little-endian `aarch64` builds with NEON enabled use `vqtbl4q_u8`. Its 64-byte table holds 16 +//! f32 entries, and byte indices select four float values per call. Other configurations use +//! [`Simd::gather_or_default`]. + +#[cfg(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" +))] +use core::simd::simd_swizzle; +use core::simd::{ + Select as _, Simd, + cmp::SimdPartialOrd as _, + num::{SimdFloat as _, SimdInt as _}, +}; use std::simd::StdFloat as _; use super::sleef::scale_by_pow2_f32; @@ -107,10 +113,14 @@ const EXP16_LO: [f32; 16] = [ /// Looks four lanes of a 16-entry `f32` table up in a single `TBL4`. /// -/// Lane `i` with index `j` reads bytes `4j..4j+4`. The lookup builds the byte indices in the -/// `u32` domain (`4j` replicated to all four bytes, plus `0,1,2,3`) and reinterprets them, which -/// assumes little-endian lane layout. -#[cfg(all(target_arch = "aarch64", target_endian = "little"))] +/// Each lane's index j must satisfy 0 ≤ j < 16. Lane `i` with index `j` reads bytes `4j..4j+4`. The +/// lookup builds the byte indices in the `u32` domain (`4j` replicated to all four bytes, plus +/// `0,1,2,3`) and reinterprets them, which assumes little-endian lane layout. +#[cfg(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" +))] #[inline] fn tbl4_lookup(table: &[f32; 16], index: Simd) -> Simd { use core::arch::aarch64::{ @@ -176,8 +186,12 @@ pub(crate) fn exp_f32(values: Simd) -> Simd { finish(values, reduced, quotient, table_hi, table_lo) } -/// aarch64 form: both table lookups are one `TBL4` each. -#[cfg(all(target_arch = "aarch64", target_endian = "little"))] +/// Evaluates four lanes with NEON table lookups. +#[cfg(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" +))] #[inline] pub(crate) fn exp_f32x4(values: Simd) -> Simd { let nearest = (values * Simd::splat(INVLN2_16)).round_ties_even(); @@ -193,14 +207,23 @@ pub(crate) fn exp_f32x4(values: Simd) -> Simd { finish(values, reduced, quotient, table_hi, table_lo) } -#[cfg(not(all(target_arch = "aarch64", target_endian = "little")))] +/// Evaluates four lanes with portable gather lookups. +#[cfg(not(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" +)))] #[inline] pub(crate) fn exp_f32x4(values: Simd) -> Simd { exp_f32(values) } /// Evaluates eight lanes as two four-lane `TBL4` halves. -#[cfg(all(target_arch = "aarch64", target_endian = "little"))] +#[cfg(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" +))] #[inline] pub(crate) fn exp_f32x8(values: Simd) -> Simd { let low = exp_f32x4(simd_swizzle!(values, [0, 1, 2, 3])); @@ -208,7 +231,12 @@ pub(crate) fn exp_f32x8(values: Simd) -> Simd { simd_swizzle!(low, high, [0, 1, 2, 3, 4, 5, 6, 7]) } -#[cfg(not(all(target_arch = "aarch64", target_endian = "little")))] +/// Evaluates eight lanes with portable gather lookups. +#[cfg(not(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" +)))] #[inline] pub(crate) fn exp_f32x8(values: Simd) -> Simd { exp_f32(values) @@ -216,10 +244,27 @@ pub(crate) fn exp_f32x8(values: Simd) -> Simd { #[cfg(test)] mod tests { - use super::*; + #[cfg(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" + ))] + use core::simd::num::SimdUint as _; + use core::simd::{Simd, num::SimdFloat as _, simd_swizzle}; + + #[cfg(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" + ))] + use super::{EXP16_HI, EXP16_LO, tbl4_lookup}; + use super::{exp_f32, exp_f32x4, exp_f32x8}; - /// The NEON lookup must agree with the portable gather for every index. - #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + #[cfg(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" + ))] #[test] fn tbl4_matches_gather() { for base in 0..16_u32 { @@ -233,8 +278,11 @@ mod tests { } } - /// Both entry points agree bit-for-bit. - #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + #[cfg(all( + target_arch = "aarch64", + target_endian = "little", + target_feature = "neon" + ))] #[test] #[expect( clippy::cast_precision_loss, @@ -287,7 +335,7 @@ mod tests { clippy::cast_possible_truncation, reason = "narrowing the wider-precision libm result is how the sweep builds its reference" )] - fn tracks_libm_across_the_full_bit_range() { + fn exp_f32_libm_samples() { let mut lanes = [0.0_f32; 8]; let mut filled = 0; for bits in (0..=u32::MAX).step_by(F32_STRIDE) { @@ -319,7 +367,7 @@ mod tests { } #[test] - fn edge_cases_are_exact() { + fn exp_f32_specials() { let output = exp_f32::<4>(Simd::from_array([ 0.0, f32::NEG_INFINITY, @@ -337,7 +385,7 @@ mod tests { /// Little-endian aarch64 with NEON enabled uses TBL4. Other configurations use portable gather /// lookups. #[test] - fn entry_points_agree_with_the_generic_kernel() { + fn entry_point_agreement() { let mut lanes = [0.0_f32; 8]; let mut filled = 0; for bits in (0..=u32::MAX).step_by(F32_STRIDE) { diff --git a/libs/@local/graph/atlas/src/math/kernel/sleef.rs b/libs/@local/graph/atlas/src/math/kernel/sleef.rs index 54d2427833c..c8738b61fe3 100644 --- a/libs/@local/graph/atlas/src/math/kernel/sleef.rs +++ b/libs/@local/graph/atlas/src/math/kernel/sleef.rs @@ -37,13 +37,19 @@ //! //! # Verification //! -//! The tests at the bottom of this file sweep strided samples of the full input bit range (every -//! exponent, both signs, zeros, infinities, subnormals, and NaN payloads) and bound each kernel's -//! distance from a scalar libm reference evaluated in wider precision. Each bound is the kernel's -//! accuracy tier plus the reference's own rounding step. [`math::kernel`](super)'s tests assert the -//! special points exactly. - -use core::{f32, f64, f128, simd::prelude::*}; +//! The tests compare strided samples of input bit patterns against scalar libm. The f32 reference +//! uses f64 precision before narrowing. The f64 reference uses f64 precision, and the sampling +//! tests do not establish a worst-case error bound over all f64 inputs. [`math::kernel`](super)'s +//! tests assert special points separately. + +use core::{ + f32, f64, f128, + simd::{ + Select as _, Simd, + cmp::{SimdPartialEq as _, SimdPartialOrd as _}, + num::{SimdFloat as _, SimdInt as _, SimdUint as _}, + }, +}; use std::simd::StdFloat as _; // A product is exact when its significand fits the destination precision and its exponent is in @@ -348,7 +354,7 @@ pub(crate) fn exp_f64(values: Simd) -> Simd { reason = "narrowing the wider-precision libm result is how each sweep builds its reference" )] mod tests { - use core::simd::prelude::*; + use core::simd::Simd; use super::{exp_f32, exp_f64, exp2_f32, log2_f32}; @@ -421,7 +427,7 @@ mod tests { } #[test] - fn exp_f32_tracks_libm_across_the_full_bit_range() { + fn exp_f32_libm_samples() { let mut lanes = [0.0_f32; 8]; let mut filled = 0; for bits in (0..=u32::MAX).step_by(F32_STRIDE) { @@ -446,7 +452,7 @@ mod tests { } #[test] - fn exp2_f32_tracks_libm_across_the_full_bit_range() { + fn exp2_f32_libm_samples() { let mut lanes = [0.0_f32; 8]; let mut filled = 0; for bits in (0..=u32::MAX).step_by(F32_STRIDE) { @@ -471,7 +477,7 @@ mod tests { } #[test] - fn log2_f32_tracks_libm_across_the_full_bit_range() { + fn log2_f32_libm_samples() { let mut lanes = [0.0_f32; 8]; let mut filled = 0; for bits in (0..=u32::MAX).step_by(F32_STRIDE) { @@ -501,7 +507,7 @@ mod tests { /// compares the class over every representable input around `ln(f32::MAX)`, independently of /// that distance tolerance. #[test] - fn exp_f32_overflow_boundary_is_class_exact() { + fn exp_f32_overflow_class() { let mut bits = 88.5_f32.to_bits(); let end = 89.0_f32.to_bits(); while bits <= end { @@ -530,7 +536,7 @@ mod tests { /// permits infinity beside `MAX`. This scan checks classification and distance at every /// representable input in `[709.782711, 709.782713]`, which straddles that transition. #[test] - fn exp_f64_overflow_boundary_is_class_exact() { + fn exp_f64_overflow_class() { let mut bits = 709.782_711_f64.to_bits(); let end = 709.782_713_f64.to_bits(); while bits <= end { @@ -560,7 +566,7 @@ mod tests { } #[test] - fn exp_f64_tracks_libm_across_the_full_bit_range() { + fn exp_f64_libm_samples() { let mut lanes = [0.0_f64; 4]; let mut filled = 0; for bits in (0..=u64::MAX).step_by(F64_STRIDE) { diff --git a/libs/@local/graph/atlas/src/math/mod.rs b/libs/@local/graph/atlas/src/math/mod.rs index 21170bbe119..2a5bcc831c5 100644 --- a/libs/@local/graph/atlas/src/math/mod.rs +++ b/libs/@local/graph/atlas/src/math/mod.rs @@ -119,7 +119,7 @@ mod test_alloc; mod tests; #[cfg(test)] -pub(crate) use self::scalar::{d_non_negative, finite, greater_than_one}; +pub(crate) use self::scalar::finite; #[cfg(test)] pub(crate) use self::translation::Translation; pub(crate) use self::{ @@ -136,9 +136,9 @@ pub(crate) use self::{ rotation::Rotation, scalar::{ DFinite, DNonNegative, DPositive, Finite, GreaterThanOne, Log2, Negative, NonNegative, - OpenUnitFraction, Positive, PositiveUnitFraction, UnitFraction, d_finite, d_positive, - narrow_f32, non_negative, nz, open_unit_fraction, positive, positive_unit_fraction, - softplus, unit_fraction, + OpenUnitFraction, Positive, PositiveUnitFraction, UnitFraction, d_finite, d_non_negative, + d_positive, greater_than_one, narrow_f32, non_negative, nz, open_unit_fraction, positive, + positive_unit_fraction, softplus, unit_fraction, unsafe_impl_try_from_bytes, }, similarity::Similarity, transform::Transform, diff --git a/libs/@local/graph/atlas/src/math/rotation/mod.rs b/libs/@local/graph/atlas/src/math/rotation/mod.rs index 10b52de7484..bee41de2df5 100644 --- a/libs/@local/graph/atlas/src/math/rotation/mod.rs +++ b/libs/@local/graph/atlas/src/math/rotation/mod.rs @@ -54,8 +54,11 @@ mod tests; zerocopy::IntoBytes, zerocopy::Immutable, zerocopy::KnownLayout, + serde::Serialize, + serde::Deserialize, )] #[repr(transparent)] +#[serde(transparent)] pub(crate) struct Rotation(Vec2); impl Rotation { diff --git a/libs/@local/graph/atlas/src/math/scalar/d_non_negative.rs b/libs/@local/graph/atlas/src/math/scalar/d_non_negative/mod.rs similarity index 96% rename from libs/@local/graph/atlas/src/math/scalar/d_non_negative.rs rename to libs/@local/graph/atlas/src/math/scalar/d_non_negative/mod.rs index 07d025d18d1..e582e47d398 100644 --- a/libs/@local/graph/atlas/src/math/scalar/d_non_negative.rs +++ b/libs/@local/graph/atlas/src/math/scalar/d_non_negative/mod.rs @@ -6,17 +6,22 @@ use core::{ hash::{Hash, Hasher}, }; +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, num, strategy::Strategy as _}; + use super::{ DFinite, DPositive, NonNegative, OpenUnitFraction, Positive, PositiveUnitFraction, UnitFraction, raw_interop, unsafe_impl_try_from_bytes, }; use crate::math::derivation::Derivation; +#[cfg(test)] +mod tests; + /// Validates a non-negative double-precision literal at compile time. /// -/// The expansion is a `const` block over [`DNonNegative::new`], so a literal outside the domain -/// fails the build instead of a test run. Runtime values keep the checked constructor. -#[cfg(test)] +/// A `const` block validates the literal with [`DNonNegative::new`] during compilation. A literal +/// outside the domain fails the build. Runtime values use the checked constructor. macro_rules! d_non_negative { ($value:expr) => { const { @@ -25,7 +30,6 @@ macro_rules! d_non_negative { } }; } -#[cfg(test)] pub(crate) use d_non_negative; /// A finite, non-negative `f64`, valid by construction. @@ -241,9 +245,8 @@ impl DNonNegative { /// Overflow and zero raised to a negative exponent produce infinity in the [`Derivation`]. Zero /// raised to zero is one. Underflow to zero remains nonnegative. #[inline] - #[must_use] - pub(crate) fn powf(self, exponent: f64) -> Self { - Self::new_unchecked(self.0.powf(exponent)) + pub(crate) fn powf(self, exponent: DFinite) -> Derivation { + Derivation::raw(self.0.powf(exponent.get())) } } @@ -518,20 +521,6 @@ const impl PartialOrd for DNonNegative { } } -#[cfg(test)] -impl proptest::arbitrary::Arbitrary for DNonNegative { - type Parameters = (); - type Strategy = proptest::strategy::BoxedStrategy; - - fn arbitrary_with((): Self::Parameters) -> Self::Strategy { - use proptest::strategy::Strategy as _; - - (0.0..=f64::MAX) - .prop_map(|value| Self::new(value).expect("the range covers exactly the domain")) - .boxed() - } -} - impl serde::Serialize for DNonNegative { fn serialize(&self, serializer: S) -> Result { serializer.serialize_f64(self.0) @@ -613,3 +602,15 @@ const impl core::ops::Mul for OpenUnitFraction { raw_interop!(DNonNegative[f64]); unsafe_impl_try_from_bytes!(DNonNegative[f64]); + +#[cfg(test)] +impl Arbitrary for DNonNegative { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (num::f64::POSITIVE | num::f64::NORMAL | num::f64::SUBNORMAL | num::f64::ZERO) + .prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/d_non_negative/tests.rs b/libs/@local/graph/atlas/src/math/scalar/d_non_negative/tests.rs new file mode 100644 index 00000000000..d2dc1942298 --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/d_non_negative/tests.rs @@ -0,0 +1,129 @@ +#![expect( + clippy::float_cmp, + reason = "bit-exact assertions are the point: single-element identities, asymptotes over \ + exactly-representable values, and round-trip narrowing are exact contracts" +)] + +use proptest::{prop_assert_eq, property_test}; + +use crate::math::{ + DNonNegative, Derivation, d_finite, d_non_negative, derivation::Diverged, + scalar::tests::hash_of, +}; + +/// The double-precision non-negative domain admits zero and rejects every sign and escape. +#[test] +fn new_domain() { + assert_eq!(DNonNegative::new(0.0).expect("zero is admitted").get(), 0.0); + assert_eq!( + DNonNegative::new(1.0e-10) + .expect("a tolerance constructs") + .get(), + 1.0e-10, + ); + + assert_eq!(DNonNegative::new(-1.0e-300), None); + assert_eq!(DNonNegative::new(f64::INFINITY), None); + assert_eq!(DNonNegative::new(f64::NAN), None); +} + +#[test] +fn constructors_negative_zero() { + let plus_zero = 0.0_f64.to_bits(); + + assert_eq!( + DNonNegative::new(-0.0) + .expect("-0.0 is non-negative") + .get() + .to_bits(), + plus_zero + ); + assert_eq!(DNonNegative::new_unchecked(-0.0).get().to_bits(), plus_zero); + assert_eq!(DNonNegative::new(-0.0), Some(DNonNegative::ZERO)); +} + +/// The total order agrees with the raw float order. +#[property_test] +fn cmp_numeric( + #[strategy = 0.0_f64..=f64::MAX] left: f64, + #[strategy = 0.0_f64..=f64::MAX] right: f64, +) { + let left = DNonNegative::new(left).expect("the strategy stays inside the domain"); + let right = DNonNegative::new(right).expect("the strategy stays inside the domain"); + + prop_assert_eq!( + left.cmp(&right), + left.get() + .partial_cmp(&right.get()) + .expect("non-negative values are never NaN") + ); +} + +/// Equal values hash equally across the two encodings of zero, and distinct values hash apart. +#[test] +fn hash_numeric() { + assert_eq!( + hash_of(DNonNegative::new(-0.0).expect("-0.0 is non-negative")), + hash_of(DNonNegative::ZERO) + ); + assert_ne!( + hash_of(DNonNegative::ZERO), + hash_of(DNonNegative::new(1.0).expect("one is non-negative")) + ); +} + +#[test] +fn power_deferred_overflow() { + let power = d_non_negative!(f64::MAX).powf(d_finite!(2.0)); + assert_eq!(power.finish(), Err(Diverged { raw: f64::INFINITY })); + let reciprocal = Derivation::from(DNonNegative::ONE) / power; + assert_eq!(reciprocal.finish(), Ok(DNonNegative::ZERO)); +} + +#[test] +fn power_signed_exponent() { + assert_eq!( + d_non_negative!(4.0).powf(d_finite!(-0.5)).finish(), + Ok(d_non_negative!(0.5)) + ); + assert_eq!( + DNonNegative::ZERO.powf(d_finite!(-1.0)).finish(), + Err(Diverged { raw: f64::INFINITY }) + ); + assert_eq!( + DNonNegative::ZERO.powf(d_finite!(0.0)).finish(), + Ok(DNonNegative::ONE) + ); + assert_eq!( + d_non_negative!(f64::from_bits(1)) + .powf(d_finite!(2.0)) + .finish(), + Ok(DNonNegative::ZERO) + ); +} + +mod miri { + #![expect( + clippy::host_endian_bytes, + reason = "fixture bytes must match the native-endian representation that is_bit_valid \ + reads" + )] + + use zerocopy::TryFromBytes as _; + + use crate::math::DNonNegative; + + /// `DNonNegative` reads canonical positive bytes and refuses `-0.0` and NaN. + #[test] + fn try_from_bytes_canonical() { + assert_eq!( + DNonNegative::try_read_from_bytes(&1.5_f64.to_ne_bytes()) + .expect("1.5 is canonical") + .get(), + 1.5, + ); + DNonNegative::try_read_from_bytes(&(-0.0_f64).to_ne_bytes()) + .expect_err("-0.0 is non-negative but not the canonical +0.0 bit pattern"); + DNonNegative::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/d_positive.rs b/libs/@local/graph/atlas/src/math/scalar/d_positive/mod.rs similarity index 89% rename from libs/@local/graph/atlas/src/math/scalar/d_positive.rs rename to libs/@local/graph/atlas/src/math/scalar/d_positive/mod.rs index 4b1386046cd..0f684567983 100644 --- a/libs/@local/graph/atlas/src/math/scalar/d_positive.rs +++ b/libs/@local/graph/atlas/src/math/scalar/d_positive/mod.rs @@ -8,12 +8,18 @@ use core::{ num::NonZero, }; +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, num, strategy::Strategy as _}; + use super::{ DFinite, DNonNegative, OpenUnitFraction, Positive, PositiveUnitFraction, raw_interop, unsafe_impl_try_from_bytes, }; use crate::math::Derivation; +#[cfg(test)] +mod tests; + /// Validates a positive double-precision literal at compile time. /// /// A `const` block validates the literal with [`DPositive::new`] during compilation. A literal @@ -181,6 +187,17 @@ impl DPositive { DFinite::new_unchecked(self.0.ln()) } + /// Narrows to single precision, rejecting overflow and underflow to zero. + #[expect( + clippy::cast_possible_truncation, + reason = "the checked narrowing is the operation" + )] + #[inline] + #[must_use] + pub(crate) const fn narrow(self) -> Option { + Positive::new(self.0 as f32) + } + /// Divides, refusing the escape. /// /// The quotient of positives is never NaN and never negative. Returns [`None`] exactly when @@ -298,28 +315,28 @@ const impl core::ops::Div for DPositive { } const impl core::ops::Mul for DPositive { - type Output = Self; + type Output = Derivation; - /// Scales by a positive fraction. + /// Scales by a positive fraction with deferred validation of underflow. /// - /// The rounded product must remain positive. For in-domain operands it cannot exceed the - /// positive value or become NaN, but underflow can round it to zero. + /// The product is finite and non-negative. Underflow can round it to zero, which + /// [`Derivation::finish`] rejects for the positive domain. #[inline] - fn mul(self, rhs: PositiveUnitFraction) -> Self { - Self::new_unchecked(self.0 * rhs.get()) + fn mul(self, rhs: PositiveUnitFraction) -> Self::Output { + Derivation::raw(self.0 * rhs.get()) } } const impl core::ops::Mul for OpenUnitFraction { - type Output = DPositive; + type Output = Derivation; - /// Scales a positive value toward zero. + /// Scales a positive value toward zero with deferred validation of underflow. /// - /// The rounded product must remain positive. For in-domain operands it cannot exceed the - /// positive value or become NaN. Rounding can leave the value unchanged or underflow to zero. + /// The product is finite and non-negative. Rounding can leave the value unchanged or underflow + /// to zero, which [`Derivation::finish`] rejects for the positive domain. #[inline] - fn mul(self, rhs: DPositive) -> DPositive { - DPositive::new_unchecked(self.get() * rhs.0) + fn mul(self, rhs: DPositive) -> Self::Output { + Derivation::raw(self.get() * rhs.0) } } @@ -394,20 +411,6 @@ const impl From for DPositive { } } -#[cfg(test)] -impl proptest::arbitrary::Arbitrary for DPositive { - type Parameters = (); - type Strategy = proptest::strategy::BoxedStrategy; - - fn arbitrary_with((): Self::Parameters) -> Self::Strategy { - use proptest::strategy::Strategy as _; - - (f64::from_bits(1)..=f64::MAX) - .prop_map(|value| Self::new(value).expect("the range covers exactly the domain")) - .boxed() - } -} - impl serde::Serialize for DPositive { fn serialize(&self, serializer: S) -> Result { serializer.serialize_f64(self.0) @@ -428,3 +431,14 @@ impl<'de> serde::Deserialize<'de> for DPositive { raw_interop!(DPositive[f64]); unsafe_impl_try_from_bytes!(DPositive[f64]); + +#[cfg(test)] +impl Arbitrary for DPositive { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (num::f64::POSITIVE | num::f64::NORMAL | num::f64::SUBNORMAL).prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/d_positive/tests.rs b/libs/@local/graph/atlas/src/math/scalar/d_positive/tests.rs new file mode 100644 index 00000000000..264b980b30e --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/d_positive/tests.rs @@ -0,0 +1,131 @@ +#![expect( + clippy::float_cmp, + reason = "bit-exact assertions are the point: single-element identities, asymptotes over \ + exactly-representable values, and round-trip narrowing are exact contracts" +)] + +use proptest::{prop_assert_eq, property_test}; + +use crate::math::{ + DPositive, Diverged, OpenUnitFraction, Positive, PositiveUnitFraction, d_positive, positive, +}; + +/// The double-precision positive domain is exactly the finite values strictly above zero. +#[test] +fn new_domain() { + assert_eq!( + DPositive::new(1.0e-308) + .expect("a tiny positive constructs") + .get(), + 1.0e-308, + ); + assert_eq!( + DPositive::new(f64::MAX) + .expect("the maximum is finite") + .get(), + f64::MAX + ); + + assert_eq!(DPositive::new(0.0), None); + assert_eq!(DPositive::new(-0.0), None); + assert_eq!(DPositive::new(-1.0), None); + assert_eq!(DPositive::new(f64::INFINITY), None); + assert_eq!(DPositive::new(f64::NAN), None); +} + +#[test] +fn narrow_subnormal_bounds() { + let smallest = Positive::MIN.widen(); + assert_eq!(smallest.narrow(), Some(Positive::MIN)); + assert_eq!( + (smallest / d_positive!(2.0)) + .finish() + .expect("the half-subnormal is representable in f64") + .narrow(), + None + ); + assert_eq!( + DPositive::new(smallest.get() * 0.75) + .expect("the value is positive") + .narrow(), + Some(Positive::MIN) + ); +} + +#[test] +fn narrow_overflow() { + assert_eq!(Positive::MAX.widen().narrow(), Some(Positive::MAX)); + assert_eq!( + (Positive::MAX.widen() * d_positive!(2.0)) + .finish() + .expect("twice the f32 maximum is finite in f64") + .narrow(), + None + ); + assert_eq!(d_positive!(1.5).narrow(), Some(positive!(1.5))); +} + +#[property_test] +fn narrow_widen_round_trip(value: Positive) { + prop_assert_eq!(value.widen().narrow(), Some(value)); +} + +#[property_test] +fn mul_positive_fraction(value: DPositive, fraction: PositiveUnitFraction) { + let expected = DPositive::new(value.get() * fraction.get()).ok_or(Diverged { raw: 0.0 }); + prop_assert_eq!((value * fraction).finish(), expected); +} + +#[property_test] +fn mul_open_fraction(value: DPositive, fraction: OpenUnitFraction) { + let expected = DPositive::new(fraction.get() * value.get()).ok_or(Diverged { raw: 0.0 }); + prop_assert_eq!((fraction * value).finish(), expected); +} + +#[property_test] +fn mul_positive_fraction_identity(value: DPositive) { + prop_assert_eq!((value * PositiveUnitFraction::ONE).finish(), Ok(value)); +} + +#[test] +fn mul_positive_fraction_underflow() { + for (value, fraction) in [(1e-200, 1e-200), (f64::from_bits(1), 0.5)] { + let value = DPositive::new(value).expect("should be positive and finite"); + let fraction = PositiveUnitFraction::new(fraction).expect("should be in (0, 1]"); + assert_eq!((value * fraction).finish(), Err(Diverged { raw: 0.0 })); + } +} + +#[test] +fn mul_open_fraction_underflow() { + for (value, fraction) in [(1e-200, 1e-200), (f64::from_bits(1), 0.5)] { + let value = DPositive::new(value).expect("should be positive and finite"); + let fraction = OpenUnitFraction::new(fraction).expect("should be in (0, 1)"); + assert_eq!((fraction * value).finish(), Err(Diverged { raw: 0.0 })); + } +} + +mod miri { + #![expect( + clippy::host_endian_bytes, + reason = "fixture bytes must match the native-endian representation that is_bit_valid \ + reads" + )] + + use zerocopy::TryFromBytes as _; + + use crate::math::DPositive; + + /// `DPositive` reads canonical positive bytes and refuses zero and NaN. + #[test] + fn try_from_bytes_domain() { + assert_eq!( + DPositive::try_read_from_bytes(&1.5_f64.to_ne_bytes()) + .expect("1.5 is canonical") + .get(), + 1.5, + ); + DPositive::try_read_from_bytes(&0.0_f64.to_ne_bytes()).expect_err("zero is refused"); + DPositive::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/finite.rs b/libs/@local/graph/atlas/src/math/scalar/finite/mod.rs similarity index 93% rename from libs/@local/graph/atlas/src/math/scalar/finite.rs rename to libs/@local/graph/atlas/src/math/scalar/finite/mod.rs index 4ebda25cc74..e63d9812098 100644 --- a/libs/@local/graph/atlas/src/math/scalar/finite.rs +++ b/libs/@local/graph/atlas/src/math/scalar/finite/mod.rs @@ -22,12 +22,18 @@ use core::{ hash::{Hash, Hasher}, }; +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, num, strategy::Strategy as _}; + use super::{ DNonNegative, DPositive, NonNegative, OpenUnitFraction, Positive, narrow_f32, raw_interop, unsafe_impl_try_from_bytes, }; use crate::math::Derivation; +#[cfg(test)] +mod tests; + /// Validates a finite literal at compile time. /// /// A `const` block validates the literal with [`Finite::new`] during compilation. A literal outside @@ -183,27 +189,34 @@ const impl From for f64 { } } -const impl core::ops::Div for Finite { - type Output = f32; +const impl core::ops::Neg for Finite { + type Output = Self; - /// Divides by a nonzero divisor with deferred validation of overflow. #[inline] - fn div(self, rhs: Positive) -> f32 { - self.0 / rhs.get() + fn neg(self) -> Self { + Self(-self.0) } } -#[cfg(test)] -impl proptest::arbitrary::Arbitrary for Finite { - type Parameters = (); - type Strategy = proptest::strategy::BoxedStrategy; +const impl core::ops::Mul for Finite +where + T: [const] Into, +{ + type Output = Derivation; - fn arbitrary_with((): Self::Parameters) -> Self::Strategy { - use proptest::strategy::Strategy as _; + #[inline] + fn mul(self, rhs: T) -> Self::Output { + Derivation::raw(self.0 * rhs.into().0) + } +} - (-f32::MAX..=f32::MAX) - .prop_map(|value| Self::new(value).expect("the range covers exactly the domain")) - .boxed() +const impl core::ops::Div for Finite { + type Output = Derivation; + + /// Divides by a nonzero divisor with deferred validation of overflow. + #[inline] + fn div(self, rhs: Positive) -> Self::Output { + Derivation::raw(self.0 / rhs.get()) } } @@ -543,20 +556,6 @@ const impl PartialOrd for DFinite { } } -#[cfg(test)] -impl proptest::arbitrary::Arbitrary for DFinite { - type Parameters = (); - type Strategy = proptest::strategy::BoxedStrategy; - - fn arbitrary_with((): Self::Parameters) -> Self::Strategy { - use proptest::strategy::Strategy as _; - - (-f64::MAX..=f64::MAX) - .prop_map(|value| Self::new(value).expect("the range covers exactly the domain")) - .boxed() - } -} - impl serde::Serialize for DFinite { fn serialize(&self, serializer: S) -> Result { serializer.serialize_f64(self.0) @@ -574,3 +573,35 @@ impl<'de> serde::Deserialize<'de> for DFinite { raw_interop!(Finite[f32], DFinite[f64]); unsafe_impl_try_from_bytes!(Finite[f32], DFinite[f64]); + +#[cfg(test)] +impl Arbitrary for Finite { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (num::f32::POSITIVE + | num::f32::NEGATIVE + | num::f32::NORMAL + | num::f32::SUBNORMAL + | num::f32::ZERO) + .prop_map(Self) + } +} + +#[cfg(test)] +impl Arbitrary for DFinite { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (num::f64::POSITIVE + | num::f64::NEGATIVE + | num::f64::NORMAL + | num::f64::SUBNORMAL + | num::f64::ZERO) + .prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/finite/tests.rs b/libs/@local/graph/atlas/src/math/scalar/finite/tests.rs new file mode 100644 index 00000000000..453413332df --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/finite/tests.rs @@ -0,0 +1,192 @@ +#![expect( + clippy::float_cmp, + reason = "bit-exact assertions are the point: single-element identities, asymptotes over \ + exactly-representable values, and round-trip narrowing are exact contracts" +)] + +use proptest::{prop_assert_eq, property_test}; + +use crate::math::{ + DFinite, DNonNegative, DPositive, Finite, NonNegative, Positive, d_finite, finite, +}; + +/// The finite `f32` domain is every value except NaN and the two infinities. +#[test] +fn new_domain_f32() { + assert_eq!(Finite::new(0.0).expect("zero is finite"), Finite::ZERO); + assert_eq!(Finite::new(1.0).expect("one is finite"), Finite::ONE); + assert_eq!( + Finite::new(-2.5).expect("a negative value is finite").get(), + -2.5 + ); + assert_eq!( + Finite::new(f32::MIN).expect("the minimum is finite").get(), + f32::MIN + ); + assert_eq!( + Finite::new(f32::MAX).expect("the maximum is finite").get(), + f32::MAX + ); + + assert_eq!(Finite::new(f32::NAN), None); + assert_eq!(Finite::new(f32::INFINITY), None); + assert_eq!(Finite::new(f32::NEG_INFINITY), None); +} + +/// `Finite::new` admits negative zero and keeps its sign bit. +#[test] +fn new_negative_zero() { + assert_eq!( + Finite::new(-0.0) + .expect("negative zero is finite") + .get() + .to_bits(), + (-0.0_f32).to_bits() + ); + assert_eq!( + DFinite::new(-0.0) + .expect("negative zero is finite") + .get() + .to_bits(), + (-0.0_f64).to_bits() + ); +} + +/// The finite `f64` domain is every value except NaN and the two infinities. +#[test] +fn new_domain_f64() { + assert_eq!(DFinite::new(0.0).expect("zero is finite"), DFinite::ZERO); + assert_eq!(DFinite::new(1.0).expect("one is finite"), DFinite::ONE); + assert_eq!( + DFinite::new(-1.0e-300) + .expect("a tiny negative is finite") + .get(), + -1.0e-300 + ); + assert_eq!( + DFinite::new(f64::MIN).expect("the minimum is finite").get(), + f64::MIN + ); + assert_eq!( + DFinite::new(f64::MAX).expect("the maximum is finite").get(), + f64::MAX + ); + + assert_eq!(DFinite::new(f64::NAN), None); + assert_eq!(DFinite::new(f64::INFINITY), None); + assert_eq!(DFinite::new(f64::NEG_INFINITY), None); +} + +/// The compile-time literal macros construct through the checked constructors. +#[test] +fn literals_const() { + assert_eq!(finite!(-2.5).get(), -2.5); + assert_eq!(d_finite!(1.0e-300).get(), 1.0e-300); +} + +/// The sign-bounded types widen into the finiteness-only domain. +#[test] +fn from_sign_bounded() { + assert_eq!(Finite::from(Positive::ONE), Finite::ONE); + assert_eq!(Finite::from(NonNegative::ZERO), Finite::ZERO); + assert_eq!(DFinite::from(DPositive::ONE), DFinite::ONE); + assert_eq!(DFinite::from(DNonNegative::ZERO), DFinite::ZERO); +} + +/// Finite values serialize as plain numbers and deserialization re-validates the domain. +#[test] +fn serde_domain() { + let single = Finite::new(-2.5).expect("a negative value is finite"); + let value = serde_json::to_value(single).expect("a number serializes"); + assert_eq!(value, serde_json::json!(-2.5)); + assert_eq!( + serde_json::from_value::(value).expect("-2.5 is finite"), + single + ); + + let double = DFinite::new(0.125).expect("an eighth is finite"); + let value = serde_json::to_value(double).expect("a number serializes"); + assert_eq!(value, serde_json::json!(0.125)); + assert_eq!( + serde_json::from_value::(value).expect("0.125 is finite"), + double + ); + + // A NaN written into JSON arrives as `null`, and an overflowing exponent + // arrives as an infinity or as a parse failure. Both refuse. + serde_json::from_str::("null").expect_err("null is not a number"); + serde_json::from_str::("null").expect_err("null is not a number"); + serde_json::from_str::("1e40").expect_err("1e40 overflows the f32 range"); + serde_json::from_str::("1e400").expect_err("1e400 overflows the f64 range"); +} + +#[property_test] +fn new_finite_f32(#[strategy = -f32::MAX..=f32::MAX] value: f32) { + prop_assert_eq!(Finite::new(value).map(Finite::get), Some(value)); +} + +#[property_test] +fn new_finite_f64(#[strategy = -f64::MAX..=f64::MAX] value: f64) { + prop_assert_eq!(DFinite::new(value).map(DFinite::get), Some(value)); +} + +#[property_test] +fn cmp_total_f64(left: DFinite, right: DFinite) { + prop_assert_eq!(left.cmp(&right), left.get().total_cmp(&right.get())); + prop_assert_eq!(left == right, left.get().to_bits() == right.get().to_bits()); +} + +#[property_test] +fn narrow_round_trip_bits(value: Finite) { + let widened = DFinite::new(f64::from(value)).expect("widening should preserve finiteness"); + prop_assert_eq!( + widened.narrow().map(|narrowed| narrowed.get().to_bits()), + Some(value.get().to_bits()) + ); +} + +#[test] +fn cmp_signed_zero_f64() { + let negative = DFinite::new(-0.0).expect("negative zero should be finite"); + assert!(negative < DFinite::ZERO); + assert_ne!(negative, DFinite::ZERO); +} + +mod miri { + #![expect( + clippy::host_endian_bytes, + reason = "fixture bytes must match the native-endian representation that is_bit_valid \ + reads" + )] + + use zerocopy::TryFromBytes as _; + + use crate::math::{DFinite, Finite}; + + /// `Finite` reads finite bytes of either sign and refuses NaN and infinity. + #[test] + fn try_from_bytes_domain_f32() { + assert_eq!( + Finite::try_read_from_bytes(&(-2.5_f32).to_ne_bytes()) + .expect("a finite value is canonical") + .get(), + -2.5, + ); + Finite::try_read_from_bytes(&f32::NAN.to_ne_bytes()).expect_err("NaN is refused"); + Finite::try_read_from_bytes(&f32::INFINITY.to_ne_bytes()).expect_err("infinity is refused"); + } + + /// `DFinite` reads finite bytes of either sign and refuses NaN and infinity. + #[test] + fn try_from_bytes_domain_f64() { + assert_eq!( + DFinite::try_read_from_bytes(&(-2.5_f64).to_ne_bytes()) + .expect("a finite value is canonical") + .get(), + -2.5, + ); + DFinite::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); + DFinite::try_read_from_bytes(&f64::NEG_INFINITY.to_ne_bytes()) + .expect_err("infinity is refused"); + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/greater_than_one.rs b/libs/@local/graph/atlas/src/math/scalar/greater_than_one/mod.rs similarity index 71% rename from libs/@local/graph/atlas/src/math/scalar/greater_than_one.rs rename to libs/@local/graph/atlas/src/math/scalar/greater_than_one/mod.rs index 48ff4e2860d..79251b31da0 100644 --- a/libs/@local/graph/atlas/src/math/scalar/greater_than_one.rs +++ b/libs/@local/graph/atlas/src/math/scalar/greater_than_one/mod.rs @@ -1,12 +1,17 @@ //! The finite `f64` scalar strictly greater than one. +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, strategy::Strategy as _}; + use super::unsafe_impl_try_from_bytes; +#[cfg(test)] +mod tests; + /// Validates a greater-than-one literal at compile time. /// /// The expansion is a `const` block over [`GreaterThanOne::new`], so a literal outside the domain /// fails the build instead of a test run. Runtime values keep the checked constructor. -#[cfg(test)] macro_rules! greater_than_one { ($value:expr) => { const { @@ -15,7 +20,6 @@ macro_rules! greater_than_one { } }; } -#[cfg(test)] pub(crate) use greater_than_one; /// A finite `f64` strictly greater than one, valid by construction. @@ -76,3 +80,32 @@ impl GreaterThanOne { } unsafe_impl_try_from_bytes!(GreaterThanOne[f64]); + +impl serde::Serialize for GreaterThanOne { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_f64(self.0) + } +} + +impl<'de> serde::Deserialize<'de> for GreaterThanOne { + fn deserialize>(deserializer: D) -> Result { + let value = f64::deserialize(deserializer)?; + Self::new(value).ok_or_else(|| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Float(value), + &"a finite positive number greater than one", + ) + }) + } +} + +#[cfg(test)] +impl Arbitrary for GreaterThanOne { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (1.0_f64.next_up()..=f64::MAX).prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/greater_than_one/tests.rs b/libs/@local/graph/atlas/src/math/scalar/greater_than_one/tests.rs new file mode 100644 index 00000000000..f86b436caed --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/greater_than_one/tests.rs @@ -0,0 +1,67 @@ +#![expect( + clippy::float_cmp, + reason = "bit-exact assertions are the point: single-element identities, asymptotes over \ + exactly-representable values, and round-trip narrowing are exact contracts" +)] + +use proptest::{prop_assert_eq, property_test}; +use zerocopy::TryFromBytes as _; + +use crate::math::GreaterThanOne; + +/// The greater-than-one domain rejects one itself, infinities, and everything below. +#[test] +fn new_domain() { + assert_eq!(GreaterThanOne::new(2.0).expect("doubling grows").get(), 2.0); + let barely = 1.0 + f64::EPSILON; + assert_eq!( + GreaterThanOne::new(barely) + .expect("one ulp above grows") + .get(), + barely + ); + + assert_eq!(GreaterThanOne::new(1.0), None); + assert_eq!(GreaterThanOne::new(0.5), None); + assert_eq!(GreaterThanOne::new(f64::INFINITY), None); + assert_eq!(GreaterThanOne::new(f64::NAN), None); +} + +#[property_test] +#[expect( + clippy::host_endian_bytes, + reason = "the scalar representation uses native byte order" +)] +fn try_from_bytes_validation(bits: u64) { + let value = f64::from_bits(bits); + prop_assert_eq!( + GreaterThanOne::try_read_from_bytes(&bits.to_ne_bytes()).ok(), + GreaterThanOne::new(value) + ); +} + +mod miri { + #![expect( + clippy::host_endian_bytes, + reason = "fixture bytes must match the native-endian representation that is_bit_valid \ + reads" + )] + + use zerocopy::TryFromBytes as _; + + use crate::math::GreaterThanOne; + + /// `GreaterThanOne` reads `2.0` and refuses exactly one and NaN. + #[test] + fn try_from_bytes_domain() { + assert_eq!( + GreaterThanOne::try_read_from_bytes(&2.0_f64.to_ne_bytes()) + .expect("2.0 is canonical") + .get(), + 2.0, + ); + GreaterThanOne::try_read_from_bytes(&1.0_f64.to_ne_bytes()) + .expect_err("one itself is refused"); + GreaterThanOne::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/log2.rs b/libs/@local/graph/atlas/src/math/scalar/log2.rs deleted file mode 100644 index 871605d7471..00000000000 --- a/libs/@local/graph/atlas/src/math/scalar/log2.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! The power-of-two shift exponent. - -use super::unsafe_impl_try_from_bytes; - -/// A power-of-two exponent below the `u64` shift width, valid by construction. -/// -/// Values lie in `0..64`, the valid shift counts for a `u64`. A power of two computed as -/// `1_u64 << exponent.get()` therefore fits in that type. A narrower integer needs its own -/// shift-width bound. -/// -/// # Example -/// -/// This in-crate example is ignored because the module is private. -/// -/// ```ignore -/// use crate::math::{Log2}; -/// -/// let span = Log2::new(6).expect("6 lies below the shift width"); -/// assert_eq!(span.get(), 6); -/// assert_eq!(1_u64 << span.get(), 64); -/// -/// // A hostile document's 200 refuses construction instead of panicking a later shift. -/// assert_eq!(Log2::new(200), None); -/// ``` -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, zerocopy::Immutable)] -#[repr(transparent)] -pub(crate) struct Log2(u8); - -impl Log2 { - /// Validates a shift exponent. - /// - /// Returns [`None`] unless the value is below 64, the `u64` shift width. - #[inline] - #[must_use] - pub(crate) const fn new(value: u8) -> Option { - if u32::from(value) >= u64::BITS { - return None; - } - - Some(Self(value)) - } - - /// Returns whether `value`'s exact bits are a stored exponent. - /// - /// The constructor [`new`](Self::new) preserves integer bits. Persisted values are valid - /// exactly when the exponent lies below the `u64` shift width. - #[inline] - #[must_use] - pub(crate) const fn is_canonical(value: u8) -> bool { - match Self::new(value) { - // compare with the constructed value to account for normalization - Some(accepted) => accepted.0 == value, - None => false, - } - } - - /// Returns the exponent. - #[inline] - #[must_use] - pub(crate) const fn get(self) -> u8 { - self.0 - } -} - -unsafe_impl_try_from_bytes!(Log2[u8]); diff --git a/libs/@local/graph/atlas/src/math/scalar/log2/mod.rs b/libs/@local/graph/atlas/src/math/scalar/log2/mod.rs new file mode 100644 index 00000000000..588fbc865f9 --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/log2/mod.rs @@ -0,0 +1,146 @@ +//! The power-of-two shift exponent. + +use core::fmt; + +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, strategy::Strategy as _}; + +use super::unsafe_impl_try_from_bytes; + +#[cfg(test)] +mod tests; + +/// A power-of-two exponent below the `u64` shift width, valid by construction. +/// +/// Values lie in `0..64`, the valid shift counts for a `u64`. A power of two computed as +/// `1_u64 << exponent.get()` therefore fits in that type. A narrower integer needs its own +/// shift-width bound. +/// +/// # Example +/// +/// This in-crate example is ignored because the module is private. +/// +/// ```ignore +/// use crate::math::{Log2}; +/// +/// let span = Log2::new(6).expect("6 lies below the shift width"); +/// assert_eq!(span.get(), 6); +/// assert_eq!(1_u64 << span.get(), 64); +/// +/// // A hostile document's 200 refuses construction instead of panicking a later shift. +/// assert_eq!(Log2::new(200), None); +/// ``` +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, zerocopy::Immutable)] +#[repr(transparent)] +pub struct Log2(u8); + +impl Log2 { + /// One doubling. + pub(crate) const ONE: Self = Self(1); + /// No doublings: the exponent of one. + pub(crate) const ZERO: Self = Self(0); + + /// Validates a shift exponent. + /// + /// Returns [`None`] unless the value is below 64, the `u64` shift width. + #[inline] + #[must_use] + pub(crate) const fn new(value: u8) -> Option { + if u32::from(value) >= u64::BITS { + return None; + } + + Some(Self(value)) + } + + /// Creates an exponent whose value is already known to be below the `u64` shift width. + /// + /// The caller must establish `value < 64`. Use [`new`](Self::new) to check other inputs. + // this bound concerns numeric correctness rather than memory safety. + pub(crate) const fn new_unchecked(value: u8) -> Self { + debug_assert!(u32::from(value) < u64::BITS); + Self(value) + } + + /// Returns whether `value`'s exact bits are a stored exponent. + /// + /// The constructor [`new`](Self::new) preserves integer bits. Persisted values are valid + /// exactly when the exponent lies below the `u64` shift width. + #[inline] + #[must_use] + pub(crate) const fn is_canonical(value: u8) -> bool { + match Self::new(value) { + // compare with the constructed value to account for normalization + Some(accepted) => accepted.0 == value, + None => false, + } + } + + /// Adds another exponent, refusing a sum at or above the `u64` shift width. + #[inline] + #[must_use] + pub(crate) const fn checked_add(self, other: Self) -> Option { + // The sum of two exponents below 64 is at most 126 and fits within u8's range. + Self::new(self.0 + other.0) + } + + /// Returns the exponent. + #[inline] + #[must_use] + pub(crate) const fn get(self) -> u8 { + self.0 + } +} + +impl fmt::Display for Log2 { + /// Formats as a power of two, e.g. `2⁶`. + #[expect( + clippy::non_ascii_literal, + clippy::integer_division_remainder_used, + clippy::integer_division + )] + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + const SUPERSCRIPTS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹']; + + write!(fmt, "2")?; + if self.0 >= 10 { + write!(fmt, "{}", SUPERSCRIPTS[usize::from(self.0 / 10)])?; + } + write!(fmt, "{}", SUPERSCRIPTS[usize::from(self.0 % 10)]) + } +} + +impl serde::Serialize for Log2 { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(self.0) + } +} + +impl<'de> serde::Deserialize<'de> for Log2 { + fn deserialize>(deserializer: D) -> Result { + let value = u8::deserialize(deserializer)?; + Self::new(value).ok_or_else(|| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Unsigned(u64::from(value)), + &"an exponent below the u64 shift width", + ) + }) + } +} + +unsafe_impl_try_from_bytes!(Log2[u8]); + +#[cfg(test)] +#[expect( + exported_private_dependencies, + reason = "the impl is absent from downstream builds" +)] +impl Arbitrary for Log2 { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (0_u8..64).prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/log2/tests.rs b/libs/@local/graph/atlas/src/math/scalar/log2/tests.rs new file mode 100644 index 00000000000..162b2c0b30d --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/log2/tests.rs @@ -0,0 +1,60 @@ +use proptest::{prop_assert_eq, property_test}; + +use crate::math::Log2; + +/// The whole `u8` domain, exhaustively: exactly the shiftable exponents construct. +#[test] +fn new_shift_domain() { + for value in 0_u8..64 { + let exponent = Log2::new(value).expect("values below the shift width construct"); + assert_eq!(exponent.get(), value); + // The type guarantees that a shift by an exponent that exists cannot panic. + let _power = 1_u64 << exponent.get(); + } + for value in 64_u8..=u8::MAX { + assert_eq!(Log2::new(value), None); + } +} + +#[property_test] +fn checked_add_powers(left: Log2, right: Log2) { + let product = (1_u64 << left.get()).checked_mul(1_u64 << right.get()); + let summed = left.checked_add(right).map(|sum| 1_u64 << sum.get()); + prop_assert_eq!(summed, product); +} + +#[test] +fn checked_add_shift_boundary() { + let largest = Log2::new(63).expect("should be below the shift width"); + assert_eq!(largest.checked_add(Log2::ZERO), Some(largest)); + assert_eq!(largest.checked_add(Log2::ONE), None); + assert_eq!(largest.checked_add(largest), None); +} + +#[test] +#[expect(clippy::non_ascii_literal, reason = "Display uses superscript digits")] +fn display_superscript_digits() { + for (exponent, expected) in [(0, "2⁰"), (9, "2⁹"), (10, "2¹⁰"), (63, "2⁶³")] { + let exponent = Log2::new(exponent).expect("should be below the shift width"); + assert_eq!(exponent.to_string(), expected); + } +} + +mod miri { + use zerocopy::TryFromBytes as _; + + use crate::math::Log2; + + /// `Log2` reads a byte below the shift width and refuses `64` and `255`. + #[test] + fn try_from_bytes_domain() { + assert_eq!( + Log2::try_read_from_bytes(&[10_u8]) + .expect("10 lies below the shift width") + .get(), + 10, + ); + Log2::try_read_from_bytes(&[64_u8]).expect_err("64 is the shift width itself, refused"); + Log2::try_read_from_bytes(&[255_u8]).expect_err("255 is far past the shift width"); + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/mod.rs b/libs/@local/graph/atlas/src/math/scalar/mod.rs index c9a31efe26b..2e05c3006fb 100644 --- a/libs/@local/graph/atlas/src/math/scalar/mod.rs +++ b/libs/@local/graph/atlas/src/math/scalar/mod.rs @@ -60,16 +60,12 @@ mod positive_unit_fraction; mod tests; mod unit_fraction; -pub(crate) use d_non_negative::DNonNegative; -#[cfg(test)] -pub(crate) use d_non_negative::d_non_negative; +pub(crate) use d_non_negative::{DNonNegative, d_non_negative}; pub(crate) use d_positive::{DPositive, d_positive}; #[cfg(test)] pub(crate) use finite::finite; pub(crate) use finite::{DFinite, Finite, d_finite}; -pub(crate) use greater_than_one::GreaterThanOne; -#[cfg(test)] -pub(crate) use greater_than_one::greater_than_one; +pub(crate) use greater_than_one::{GreaterThanOne, greater_than_one}; pub(crate) use log2::Log2; pub(crate) use negative::Negative; pub(crate) use non_negative::{NonNegative, non_negative}; @@ -303,3 +299,92 @@ pub(crate) const fn narrow_f32(value: f64) -> Option { None } } + +/// Narrows an `f64` to the largest `f32` at or below it. +/// +/// The directed twin of [`narrow_f32`]. The result never exceeds `value`: a lower bound +/// computed in `f64` stays a lower bound at working precision. An `f32` value narrows to itself, +/// `-0.0` included. Returns [`None`] for NaN, for both infinities, and where no finite `f32` lies +/// at or below `value`, which is every value below `-f32::MAX`. +/// +/// # Example +/// +/// This in-crate example is ignored because the module is private. +/// +/// ```ignore +/// use crate::math::scalar::{narrow_f32_down}; +/// +/// // 0.1 has no exact `f32`. The nearest lies above it, and the narrowing steps down. +/// let below = narrow_f32_down(0.1).expect("0.1 is finite"); +/// assert!(f64::from(below) <= 0.1 && f64::from(below.next_up()) > 0.1); +/// assert_eq!(narrow_f32_down(0.25), Some(0.25_f32)); +/// assert_eq!(narrow_f32_down(1e300), Some(f32::MAX)); +/// assert_eq!(narrow_f32_down(-1e300), None); +/// ``` +#[inline] +#[must_use] +pub(crate) const fn narrow_f32_down(value: f64) -> Option { + #[expect( + clippy::cast_possible_truncation, + reason = "the rounding cast supplies the nearest `f32`, and the step below corrects its \ + direction" + )] + let nearest = value as f32; + + // one step corrects upward rounding, including positive overflow to infinity. + // positive infinity itself compares equal and remains infinite for the check below. + let narrowed = if (nearest as f64) > value { + nearest.next_down() + } else { + nearest + }; + + if narrowed.is_finite() { + Some(narrowed) + } else { + None + } +} + +/// Narrows an `f64` to the smallest `f32` at or above it. +/// +/// The mirror of [`narrow_f32_down`]. The result never falls below `value`: an upper bound +/// computed in `f64` stays an upper bound at working precision. An `f32` value narrows to itself, +/// `-0.0` included. Returns [`None`] for NaN, for both infinities, and where no finite `f32` lies +/// at or above `value`, which is every value above `f32::MAX`. +/// +/// # Example +/// +/// This in-crate example is ignored because the module is private. +/// +/// ```ignore +/// use crate::math::scalar::{narrow_f32_up}; +/// +/// let above = narrow_f32_up(0.1).expect("0.1 is finite"); +/// assert!(f64::from(above) >= 0.1 && f64::from(above.next_down()) < 0.1); +/// assert_eq!(narrow_f32_up(0.25), Some(0.25_f32)); +/// assert_eq!(narrow_f32_up(-1e300), Some(-f32::MAX)); +/// assert_eq!(narrow_f32_up(1e300), None); +/// ``` +#[inline] +#[must_use] +pub(crate) const fn narrow_f32_up(value: f64) -> Option { + #[expect( + clippy::cast_possible_truncation, + reason = "the rounding cast supplies the nearest `f32`, and the step below corrects its \ + direction" + )] + let nearest = value as f32; + + let narrowed = if (nearest as f64) < value { + nearest.next_up() + } else { + nearest + }; + + if narrowed.is_finite() { + Some(narrowed) + } else { + None + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/negative.rs b/libs/@local/graph/atlas/src/math/scalar/negative/mod.rs similarity index 88% rename from libs/@local/graph/atlas/src/math/scalar/negative.rs rename to libs/@local/graph/atlas/src/math/scalar/negative/mod.rs index baaa5c80dbd..1616d1a9cec 100644 --- a/libs/@local/graph/atlas/src/math/scalar/negative.rs +++ b/libs/@local/graph/atlas/src/math/scalar/negative/mod.rs @@ -6,8 +6,14 @@ use core::{ hash::{Hash, Hasher}, }; +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, num, strategy::Strategy as _}; + use super::raw_interop; +#[cfg(test)] +mod tests; + /// A finite, strictly negative `f32`, valid by construction. /// /// Use [`Positive`](super::Positive) for the positive domain. @@ -116,3 +122,14 @@ const impl From for f64 { } raw_interop!(Negative[f32]); + +#[cfg(test)] +impl Arbitrary for Negative { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (num::f32::NEGATIVE | num::f32::NORMAL | num::f32::SUBNORMAL).prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/negative/tests.rs b/libs/@local/graph/atlas/src/math/scalar/negative/tests.rs new file mode 100644 index 00000000000..d41f1ac6ec5 --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/negative/tests.rs @@ -0,0 +1,21 @@ +use proptest::{prop_assert_eq, property_test}; + +use crate::math::Negative; + +#[test] +fn new_domain() { + for value in [f32::MIN, -1.0, -f32::from_bits(1)] { + assert_eq!(Negative::new(value).map(f64::from), Some(f64::from(value))); + } + for value in [0.0, -0.0, 1.0, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] { + assert_eq!(Negative::new(value), None); + } +} + +#[property_test] +fn cmp_numeric(left: Negative, right: Negative) { + prop_assert_eq!( + left.cmp(&right), + f64::from(left).total_cmp(&f64::from(right)) + ); +} diff --git a/libs/@local/graph/atlas/src/math/scalar/non_negative.rs b/libs/@local/graph/atlas/src/math/scalar/non_negative/mod.rs similarity index 90% rename from libs/@local/graph/atlas/src/math/scalar/non_negative.rs rename to libs/@local/graph/atlas/src/math/scalar/non_negative/mod.rs index aa5bc4d83ce..8c9d0efe342 100644 --- a/libs/@local/graph/atlas/src/math/scalar/non_negative.rs +++ b/libs/@local/graph/atlas/src/math/scalar/non_negative/mod.rs @@ -6,7 +6,16 @@ use core::{ hash::{Hash, Hasher}, }; -use super::{DNonNegative, Finite, Positive, raw_interop, unsafe_impl_try_from_bytes}; +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, num, strategy::Strategy as _}; + +use super::{ + DNonNegative, Finite, Positive, UnitFraction, raw_interop, unsafe_impl_try_from_bytes, +}; +use crate::math::Derivation; + +#[cfg(test)] +mod tests; /// Validates a non-negative literal at compile time. /// @@ -324,12 +333,8 @@ impl NonNegative { /// Overflow and zero raised to a negative exponent produce infinity in the [`Derivation`]. Zero /// raised to zero is one. Underflow to zero remains nonnegative. #[inline] - #[must_use] - pub(crate) fn powf(self, exponent: f32) -> Self { - let raised = self.0.powf(exponent); - debug_assert!(raised.is_finite(), "the power left the domain"); - - Self(raised) + pub(crate) fn powf(self, exponent: Finite) -> Derivation { + Derivation::raw(self.0.powf(exponent.get())) } /// Returns the reciprocal. @@ -484,6 +489,57 @@ const impl core::ops::Sub for NonNegative { } } +const impl core::ops::Mul for NonNegative { + type Output = Derivation; + + /// Multiplies with deferred validation of overflow. + #[inline] + fn mul(self, rhs: Self) -> Self::Output { + Derivation::raw(self.0 * rhs.0) + } +} + +const impl core::ops::Mul for NonNegative { + type Output = Derivation; + + #[inline] + fn mul(self, rhs: Positive) -> Self::Output { + Derivation::raw(self.0 * rhs.get()) + } +} + +const impl core::ops::Mul for NonNegative { + type Output = Self; + + #[inline] + fn mul(self, rhs: UnitFraction) -> Self::Output { + // A fraction in [0, 1] cannot increase the magnitude of a nonnegative finite value. The + // product remains finite and nonnegative without a check, with canonical +0.0 for a zero + // product. + Self(self.0 * rhs.as_f32()) + } +} + +const impl core::ops::Div for NonNegative { + type Output = Derivation; + + /// Divides by a nonzero divisor with deferred validation of overflow. + #[inline] + fn div(self, rhs: Positive) -> Self::Output { + Derivation::raw(self.0 / rhs.get()) + } +} + +const impl core::ops::Div for NonNegative { + type Output = Derivation; + + /// Divides with deferred validation of zero division and overflow. + #[inline] + fn div(self, rhs: Self) -> Self::Output { + Derivation::raw(self.0 / rhs.0) + } +} + const impl core::ops::Mul for NonNegative { type Output = f32; @@ -514,20 +570,6 @@ const impl core::ops::Add for f32 { } } -#[cfg(test)] -impl proptest::arbitrary::Arbitrary for NonNegative { - type Parameters = (); - type Strategy = proptest::strategy::BoxedStrategy; - - fn arbitrary_with((): Self::Parameters) -> Self::Strategy { - use proptest::strategy::Strategy as _; - - (0.0..=f32::MAX) - .prop_map(|value| Self::new(value).expect("the range covers exactly the domain")) - .boxed() - } -} - impl serde::Serialize for NonNegative { fn serialize(&self, serializer: S) -> Result { serializer.serialize_f32(self.0) @@ -548,3 +590,15 @@ impl<'de> serde::Deserialize<'de> for NonNegative { raw_interop!(NonNegative[f32]); unsafe_impl_try_from_bytes!(NonNegative[f32]); + +#[cfg(test)] +impl Arbitrary for NonNegative { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (num::f32::POSITIVE | num::f32::NORMAL | num::f32::SUBNORMAL | num::f32::ZERO) + .prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/non_negative/tests.rs b/libs/@local/graph/atlas/src/math/scalar/non_negative/tests.rs new file mode 100644 index 00000000000..be54e36dfa1 --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/non_negative/tests.rs @@ -0,0 +1,253 @@ +#![expect( + clippy::float_cmp, + reason = "bit-exact assertions are the point: single-element identities, asymptotes over \ + exactly-representable values, and round-trip narrowing are exact contracts" +)] + +use proptest::{prop_assert, prop_assert_eq, property_test}; + +use crate::math::{ + Derivation, NonNegative, Positive, derivation::Diverged, finite, non_negative, positive, + scalar::tests::hash_of, +}; + +/// `sigmoid(0)` is exactly `0.5`, and at `±200` the asymptotes `1` and `0` are exact. +#[test] +fn sigmoid_asymptotes() { + // At zero the two branches agree exactly: 1 / (1 + 1). + assert_eq!(NonNegative::sigmoid(0.0), 0.5); + // exp(-200) rounds to zero in f32. + assert_eq!(NonNegative::sigmoid(200.0), 1.0); + assert_eq!(NonNegative::sigmoid(-200.0), 0.0); +} + +/// `sigmoid(-20)` stays positive and within a relative `1e-6` of `exp(-20)`, where the complement +/// form would round to zero. +#[test] +fn sigmoid_negative_tail() { + // The complement form `1 - 1/(1 + exp(-|x|))` rounds to zero once exp(-|x|) drops below f32 ε. + // The direct ratio keeps the tail. + let tail = NonNegative::sigmoid(-20.0).get(); + let expected = (-20.0_f32).exp(); + assert!(tail > 0.0); + assert!((tail - expected).abs() <= 1e-6 * expected, "tail {tail}"); +} + +/// `huber` is `0.5 · v²` below the threshold, `0.5 · t²` at it and `t · (v - 0.5 t)` above it, on +/// exactly representable inputs. +#[test] +fn huber_regimes() { + // Quadratic regime: 0.5 · value^2, over exactly-representable inputs. + assert_eq!( + non_negative!(0.5).huber(positive!(1.0)), + non_negative!(0.125) + ); + assert_eq!(NonNegative::ZERO.huber(positive!(1.0)), NonNegative::ZERO); + // At the threshold both formulas give 0.5 · threshold^2. + assert_eq!(non_negative!(1.0).huber(positive!(1.0)), non_negative!(0.5)); + // Linear regime: threshold · (value - 0.5 · threshold). + assert_eq!(non_negative!(3.0).huber(positive!(1.0)), non_negative!(2.5)); + assert_eq!( + non_negative!(2.0).huber(positive!(0.5)), + non_negative!(0.875) + ); +} + +#[test] +fn huber_threshold_continuity() { + let threshold = 1.0_f32; + let step = 1e-4_f32; + + let below = NonNegative::new(threshold - step) + .expect("a step below the threshold is non-negative") + .huber(positive!(1.0)); + let above = NonNegative::new(threshold + step) + .expect("a step above the threshold is non-negative") + .huber(positive!(1.0)); + + // with a unit threshold, the expected difference across this interval is about 2 · step. + assert!(below < above); + assert!((above.get() - below.get()) < 1e-3); +} + +/// `huber` at `1e20` against a `1e20` threshold clamps to `f32::MAX` instead of overflowing to +/// infinity. +#[test] +fn huber_overflow_saturation() { + // In the quadratic regime the square of 10²⁰ overflows the `f32` range. The reading clamps + // to the domain's maximum instead of leaving it. + assert_eq!( + non_negative!(1.0e20).huber(positive!(1.0e20)), + non_negative!(f32::MAX) + ); +} + +/// The sigmoid is monotone non-decreasing and satisfies its complement identity. +/// +/// Values lie in `[0, 1]`, and `sigmoid(-x) == 1 - sigmoid(x)` up to rounding. The strategy bounds +/// inputs to `-1e4..1e4`. The tests above pin the asymptotes. +#[property_test] +fn sigmoid_range_order_complement( + #[strategy = -1e4_f32..1e4] first: f32, + #[strategy = -1e4_f32..1e4] second: f32, +) { + let (lower, upper) = if first <= second { + (first, second) + } else { + (second, first) + }; + + prop_assert!((0.0..=1.0).contains(&NonNegative::sigmoid(lower).get())); + prop_assert!( + NonNegative::sigmoid(lower) <= NonNegative::sigmoid(upper), + "sigmoid({}) = {} above sigmoid({}) = {}", + lower, + NonNegative::sigmoid(lower), + upper, + NonNegative::sigmoid(upper), + ); + + let complement = 1.0 - NonNegative::sigmoid(first).get(); + prop_assert!( + (NonNegative::sigmoid(-first).get() - complement).abs() <= 1e-6, + "sigmoid(-{0}) = {1} against 1 - sigmoid({0}) = {2}", + first, + NonNegative::sigmoid(-first), + complement, + ); +} + +/// The Huber penalty is monotone non-decreasing in the magnitude. +#[property_test] +fn huber_monotonicity(first: NonNegative, second: NonNegative, threshold: Positive) { + let (lower, upper) = if first <= second { + (first, second) + } else { + (second, first) + }; + + prop_assert!( + lower.huber(threshold) <= upper.huber(threshold), + "huber({}, {}) = {} above huber({}, {}) = {}", + lower, + threshold, + lower.huber(threshold), + upper, + threshold, + upper.huber(threshold), + ); +} + +/// The non-negative domain admits zero and rejects every sign and escape. +#[test] +fn new_domain() { + assert_eq!(NonNegative::new(0.0).expect("zero is admitted").get(), 0.0); + assert_eq!( + NonNegative::new(1.0e-10) + .expect("a tolerance constructs") + .get(), + 1.0e-10, + ); + + assert_eq!(NonNegative::new(-1.0e-10), None); + assert_eq!(NonNegative::new(f32::INFINITY), None); + assert_eq!(NonNegative::new(f32::NAN), None); +} + +#[test] +fn constructors_negative_zero() { + let plus_zero = 0.0_f32.to_bits(); + + assert_eq!( + NonNegative::new(-0.0) + .expect("-0.0 is non-negative") + .to_bits(), + plus_zero + ); + assert_eq!(NonNegative::new_unchecked(-0.0).to_bits(), plus_zero); + assert!(NonNegative::new_unchecked(-0.0).is_zero()); + assert_eq!(NonNegative::new(-0.0), Some(NonNegative::ZERO)); +} + +/// The total order agrees with the raw float order. +#[property_test] +fn cmp_numeric( + #[strategy = 0.0_f32..=f32::MAX] left: f32, + #[strategy = 0.0_f32..=f32::MAX] right: f32, +) { + let left = NonNegative::new(left).expect("the strategy stays inside the domain"); + let right = NonNegative::new(right).expect("the strategy stays inside the domain"); + + prop_assert_eq!( + left.cmp(&right), + left.get() + .partial_cmp(&right.get()) + .expect("non-negative values are never NaN") + ); +} + +/// Equal values hash equally across the two encodings of zero, and distinct values hash apart. +#[test] +fn hash_numeric() { + assert_eq!( + hash_of(NonNegative::new(-0.0).expect("-0.0 is non-negative")), + hash_of(NonNegative::ZERO) + ); + assert_ne!(hash_of(NonNegative::ZERO), hash_of(NonNegative::ONE)); +} + +#[test] +fn power_deferred_overflow() { + let power = non_negative!(f32::MAX).powf(finite!(2.0)); + assert_eq!(power.finish(), Err(Diverged { raw: f32::INFINITY })); + let reciprocal = Derivation::from(NonNegative::ONE) / power; + assert_eq!(reciprocal.finish(), Ok(NonNegative::ZERO)); +} + +#[test] +fn power_signed_exponent() { + assert_eq!( + non_negative!(4.0).powf(finite!(-0.5)).finish(), + Ok(non_negative!(0.5)) + ); + assert_eq!( + NonNegative::ZERO.powf(finite!(-1.0)).finish(), + Err(Diverged { raw: f32::INFINITY }) + ); + assert_eq!( + NonNegative::ZERO.powf(finite!(0.0)).finish(), + Ok(NonNegative::ONE) + ); + assert_eq!( + non_negative!(f32::from_bits(1)).powf(finite!(2.0)).finish(), + Ok(NonNegative::ZERO) + ); +} + +mod miri { + #![expect( + clippy::host_endian_bytes, + reason = "fixture bytes must match the native-endian representation that is_bit_valid \ + reads" + )] + + use zerocopy::TryFromBytes as _; + + use crate::math::NonNegative; + + /// `NonNegative` reads canonical positive bytes and refuses `-0.0`, negatives and NaN. + #[test] + fn try_from_bytes_canonical() { + assert_eq!( + NonNegative::try_read_from_bytes(&1.5_f32.to_ne_bytes()) + .expect("1.5 is canonical") + .get(), + 1.5, + ); + NonNegative::try_read_from_bytes(&(-0.0_f32).to_ne_bytes()) + .expect_err("-0.0 is non-negative but not the canonical +0.0 bit pattern"); + NonNegative::try_read_from_bytes(&(-1.0_f32).to_ne_bytes()) + .expect_err("negative is refused"); + NonNegative::try_read_from_bytes(&f32::NAN.to_ne_bytes()).expect_err("NaN is refused"); + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/open_unit_fraction.rs b/libs/@local/graph/atlas/src/math/scalar/open_unit_fraction/mod.rs similarity index 96% rename from libs/@local/graph/atlas/src/math/scalar/open_unit_fraction.rs rename to libs/@local/graph/atlas/src/math/scalar/open_unit_fraction/mod.rs index 3d6ea22252a..da17be5d3d8 100644 --- a/libs/@local/graph/atlas/src/math/scalar/open_unit_fraction.rs +++ b/libs/@local/graph/atlas/src/math/scalar/open_unit_fraction/mod.rs @@ -7,8 +7,14 @@ use core::{ hash::{Hash, Hasher}, }; +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, strategy::Strategy as _}; + use super::{DPositive, UnitFraction, raw_interop, unsafe_impl_try_from_bytes}; +#[cfg(test)] +mod tests; + /// Validates an open-unit-fraction literal at compile time. /// /// A literal outside the domain fails the build. Use [`OpenUnitFraction::new`] to check runtime @@ -270,24 +276,6 @@ const impl core::ops::Div for f64 { } } -#[cfg(test)] -#[expect( - exported_private_dependencies, - reason = "the impl exists only in test builds, which no downstream consumer compiles" -)] -impl proptest::arbitrary::Arbitrary for OpenUnitFraction { - type Parameters = (); - type Strategy = proptest::strategy::BoxedStrategy; - - fn arbitrary_with((): Self::Parameters) -> Self::Strategy { - use proptest::strategy::Strategy as _; - - (f64::from_bits(1)..1.0) - .prop_map(|value| Self::new(value).expect("the range covers exactly the domain")) - .boxed() - } -} - impl serde::Serialize for OpenUnitFraction { fn serialize(&self, serializer: S) -> Result { serializer.serialize_f64(self.0) @@ -308,3 +296,18 @@ impl<'de> serde::Deserialize<'de> for OpenUnitFraction { raw_interop!(OpenUnitFraction[f64]); unsafe_impl_try_from_bytes!(OpenUnitFraction[f64]); + +#[cfg(test)] +#[expect( + exported_private_dependencies, + reason = "the impl is absent from downstream builds" +)] +impl Arbitrary for OpenUnitFraction { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (f64::from_bits(1)..1.0).prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/open_unit_fraction/tests.rs b/libs/@local/graph/atlas/src/math/scalar/open_unit_fraction/tests.rs new file mode 100644 index 00000000000..2ee955cf2b4 --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/open_unit_fraction/tests.rs @@ -0,0 +1,75 @@ +#![expect( + clippy::float_cmp, + reason = "bit-exact assertions are the point: single-element identities, asymptotes over \ + exactly-representable values, and round-trip narrowing are exact contracts" +)] + +use crate::math::{OpenUnitFraction, UnitFraction}; + +/// The open complement widens to the closed type at both ends of its range. +#[test] +fn complement_rounding() { + // Ties-to-even: `1 − 2⁻⁵⁴` is halfway between the largest float below one and one itself, + // and rounds to the even mantissa, which is one. + let tie = OpenUnitFraction::new(2.0_f64.powi(-54)).expect("2^-54 lies inside (0, 1)"); + assert_eq!(tie.complement(), UnitFraction::ONE); + + // One spacing further from one, the subtraction is representable again. + let above = OpenUnitFraction::new(2.0_f64.powi(-53)).expect("2^-53 lies inside (0, 1)"); + assert_eq!(above.complement().get(), 1.0 - 2.0_f64.powi(-53)); + + // Sterbenz: the complement of the largest fraction below one is exactly `2⁻⁵³`. + let largest = OpenUnitFraction::new(1.0 - 2.0_f64.powi(-53)).expect("below one"); + assert_eq!(largest.complement().get(), 2.0_f64.powi(-53)); +} + +/// The open unit interval excludes both endpoints, unlike its closed sibling. +#[test] +fn new_domain() { + assert_eq!( + OpenUnitFraction::new(0.25) + .expect("a quarter is interior") + .get(), + 0.25 + ); + let almost_one = 1.0 - f64::EPSILON; + assert_eq!( + OpenUnitFraction::new(almost_one) + .expect("below one is interior") + .get(), + almost_one, + ); + + assert_eq!(OpenUnitFraction::new(0.0), None); + assert_eq!(OpenUnitFraction::new(1.0), None); + assert_eq!(OpenUnitFraction::new(-0.5), None); + assert_eq!(OpenUnitFraction::new(1.5), None); + assert_eq!(OpenUnitFraction::new(f64::NAN), None); +} + +mod miri { + #![expect( + clippy::host_endian_bytes, + reason = "fixture bytes must match the native-endian representation that is_bit_valid \ + reads" + )] + + use zerocopy::TryFromBytes as _; + + use crate::math::OpenUnitFraction; + + #[test] + fn try_from_bytes_domain() { + assert_eq!( + OpenUnitFraction::try_read_from_bytes(&0.5_f64.to_ne_bytes()) + .expect("0.5 is canonical, strictly interior") + .get(), + 0.5, + ); + OpenUnitFraction::try_read_from_bytes(&0.0_f64.to_ne_bytes()) + .expect_err("zero is excluded, an endpoint"); + OpenUnitFraction::try_read_from_bytes(&1.0_f64.to_ne_bytes()) + .expect_err("one is excluded, an endpoint"); + OpenUnitFraction::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/positive.rs b/libs/@local/graph/atlas/src/math/scalar/positive/mod.rs similarity index 92% rename from libs/@local/graph/atlas/src/math/scalar/positive.rs rename to libs/@local/graph/atlas/src/math/scalar/positive/mod.rs index 402d9e0234c..f0222de2861 100644 --- a/libs/@local/graph/atlas/src/math/scalar/positive.rs +++ b/libs/@local/graph/atlas/src/math/scalar/positive/mod.rs @@ -6,7 +6,14 @@ use core::{ hash::{Hash, Hasher}, }; -use super::{DPositive, Finite, Negative, raw_interop, unsafe_impl_try_from_bytes}; +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, num, strategy::Strategy as _}; + +use super::{DPositive, Finite, Negative, NonNegative, raw_interop, unsafe_impl_try_from_bytes}; +use crate::math::Derivation; + +#[cfg(test)] +mod tests; /// Validates a positive literal at compile time. /// @@ -307,6 +314,33 @@ const impl core::ops::Sub for Positive { } } +const impl core::ops::Mul for Positive { + type Output = Derivation; + + #[inline] + fn mul(self, rhs: Self) -> Self::Output { + Derivation::raw(self.0 * rhs.0) + } +} + +const impl core::ops::Mul for Positive { + type Output = Derivation; + + #[inline] + fn mul(self, rhs: NonNegative) -> Self::Output { + Derivation::raw(self.0 * rhs.get()) + } +} + +const impl core::ops::Div for Positive { + type Output = Derivation; + + #[inline] + fn div(self, rhs: Self) -> Self::Output { + Derivation::raw(self.0 / rhs.0) + } +} + const impl core::ops::Div for f32 { type Output = f32; @@ -320,20 +354,6 @@ const impl core::ops::Div for f32 { } } -#[cfg(test)] -impl proptest::arbitrary::Arbitrary for Positive { - type Parameters = (); - type Strategy = proptest::strategy::BoxedStrategy; - - fn arbitrary_with((): Self::Parameters) -> Self::Strategy { - use proptest::strategy::Strategy as _; - - (f32::from_bits(1)..=f32::MAX) - .prop_map(|value| Self::new(value).expect("the range covers exactly the domain")) - .boxed() - } -} - impl serde::Serialize for Positive { fn serialize(&self, serializer: S) -> Result { serializer.serialize_f32(self.0) @@ -354,3 +374,14 @@ impl<'de> serde::Deserialize<'de> for Positive { raw_interop!(Positive[f32]); unsafe_impl_try_from_bytes!(Positive[f32]); + +#[cfg(test)] +impl Arbitrary for Positive { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (num::f32::POSITIVE | num::f32::NORMAL | num::f32::SUBNORMAL).prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/positive/tests.rs b/libs/@local/graph/atlas/src/math/scalar/positive/tests.rs new file mode 100644 index 00000000000..ab37b003523 --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/positive/tests.rs @@ -0,0 +1,84 @@ +#![expect( + clippy::float_cmp, + reason = "bit-exact assertions are the point: single-element identities, asymptotes over \ + exactly-representable values, and round-trip narrowing are exact contracts" +)] + +use proptest::{prop_assert, prop_assert_eq, property_test}; + +use crate::math::{Positive, positive}; + +/// The positive domain is exactly the finite `f32` values strictly above zero. +#[test] +fn new_domain() { + assert_eq!( + Positive::new(f32::MIN_POSITIVE) + .expect("a tiny positive constructs") + .get(), + f32::MIN_POSITIVE, + ); + assert_eq!( + Positive::new(f32::MAX) + .expect("the maximum is finite") + .get(), + f32::MAX + ); + + assert_eq!(Positive::new(0.0), None); + assert_eq!(Positive::new(-0.0), None); + assert_eq!(Positive::new(-1.0), None); + assert_eq!(Positive::new(f32::INFINITY), None); + assert_eq!(Positive::new(f32::NAN), None); +} + +#[property_test] +fn mul_wide_exact(left: Positive, right: Positive) { + let product = left.mul_wide(right); + prop_assert_eq!((product / left.widen()).finish(), Ok(right.widen())); + prop_assert_eq!((product / right.widen()).finish(), Ok(left.widen())); +} + +#[property_test] +fn geometric_mean_bounds(left: Positive, right: Positive) { + let mean = left.geometric_mean(right); + prop_assert!(left.min(right) <= mean && mean <= left.max(right)); + prop_assert_eq!(mean, right.geometric_mean(left)); +} + +#[property_test] +fn geometric_mean_identity(value: Positive) { + prop_assert_eq!(value.geometric_mean(value), value); +} + +#[test] +fn mul_wide_range_edges() { + assert!(Positive::MIN.mul_wide(Positive::MIN).get() > 0.0); + assert!(Positive::MAX.mul_wide(Positive::MAX).get().is_finite()); + assert_eq!(Positive::MIN.checked_mul(positive!(0.5)), None); + assert_eq!(Positive::MAX.checked_mul(positive!(2.0)), None); +} + +mod miri { + #![expect( + clippy::host_endian_bytes, + reason = "fixture bytes must match the native-endian representation that is_bit_valid \ + reads" + )] + + use zerocopy::TryFromBytes as _; + + use crate::math::Positive; + + /// `Positive` reads canonical positive bytes and refuses zero and NaN. + #[test] + fn try_from_bytes_domain() { + assert_eq!( + Positive::try_read_from_bytes(&1.5_f32.to_ne_bytes()) + .expect("1.5 is canonical") + .get(), + 1.5, + ); + Positive::try_read_from_bytes(&0.0_f32.to_ne_bytes()).expect_err("zero is refused"); + Positive::try_read_from_bytes(&f32::NAN.to_ne_bytes()).expect_err("NaN is refused"); + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/positive_unit_fraction.rs b/libs/@local/graph/atlas/src/math/scalar/positive_unit_fraction/mod.rs similarity index 92% rename from libs/@local/graph/atlas/src/math/scalar/positive_unit_fraction.rs rename to libs/@local/graph/atlas/src/math/scalar/positive_unit_fraction/mod.rs index 35f303de895..5b34900106d 100644 --- a/libs/@local/graph/atlas/src/math/scalar/positive_unit_fraction.rs +++ b/libs/@local/graph/atlas/src/math/scalar/positive_unit_fraction/mod.rs @@ -7,8 +7,14 @@ use core::{ ops::Mul, }; +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, strategy::Strategy as _}; + use super::{UnitFraction, raw_interop, unsafe_impl_try_from_bytes}; +#[cfg(test)] +mod tests; + /// Validates a positive-unit-fraction literal at compile time. /// /// A literal outside the domain fails the build. Use [`PositiveUnitFraction::new`] to check runtime @@ -198,3 +204,18 @@ const impl Mul for PositiveUnitFraction { raw_interop!(PositiveUnitFraction[f64]); unsafe_impl_try_from_bytes!(PositiveUnitFraction[f64]); + +#[cfg(test)] +#[expect( + exported_private_dependencies, + reason = "the impl is absent from downstream builds" +)] +impl Arbitrary for PositiveUnitFraction { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (f64::from_bits(1)..=1.0).prop_map(Self) + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/positive_unit_fraction/tests.rs b/libs/@local/graph/atlas/src/math/scalar/positive_unit_fraction/tests.rs new file mode 100644 index 00000000000..f720e0eabe6 --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/positive_unit_fraction/tests.rs @@ -0,0 +1,59 @@ +#![expect( + clippy::float_cmp, + reason = "bit-exact assertions are the point: single-element identities, asymptotes over \ + exactly-representable values, and round-trip narrowing are exact contracts" +)] + +use crate::math::{PositiveUnitFraction, UnitFraction}; + +/// Deserialising refuses exactly what the constructors refuse (`0` for the positive unit fraction, +/// negatives, values above one) and admits the closed endpoints, and serializing writes the plain +/// number. +#[test] +fn serde_domain() { + let admitted: PositiveUnitFraction = + serde_json::from_str("1.0e-3").expect("1.0e-3 lies inside (0, 1]"); + assert_eq!(admitted.get(), 1.0e-3); + + serde_json::from_str::("0.0") + .expect_err("zero is excluded, the domain's open endpoint"); + serde_json::from_str::("-0.5").expect_err("negatives are refused"); + serde_json::from_str::("1.5").expect_err("1.5 is out of range"); + + let closed: UnitFraction = serde_json::from_str("0.0").expect("zero lies inside [0, 1]"); + assert_eq!(closed.get(), 0.0); + serde_json::from_str::("1.5").expect_err("1.5 is out of range"); + + let wire = serde_json::to_string(&PositiveUnitFraction::ONE).expect("a fraction serializes"); + assert_eq!(wire, "1.0"); +} + +mod miri { + #![expect( + clippy::host_endian_bytes, + reason = "fixture bytes must match the native-endian representation that is_bit_valid \ + reads" + )] + + use zerocopy::TryFromBytes as _; + + use crate::math::PositiveUnitFraction; + + /// `PositiveUnitFraction` reads the closed endpoint `1.0` and refuses zero, values above one + /// and NaN. + #[test] + fn try_from_bytes_domain() { + assert_eq!( + PositiveUnitFraction::try_read_from_bytes(&1.0_f64.to_ne_bytes()) + .expect("1.0 is canonical, the domain's closed endpoint") + .get(), + 1.0, + ); + PositiveUnitFraction::try_read_from_bytes(&0.0_f64.to_ne_bytes()) + .expect_err("zero is excluded, the domain's open endpoint"); + PositiveUnitFraction::try_read_from_bytes(&1.5_f64.to_ne_bytes()) + .expect_err("1.5 is out of range"); + PositiveUnitFraction::try_read_from_bytes(&f64::NAN.to_ne_bytes()) + .expect_err("NaN is refused"); + } +} diff --git a/libs/@local/graph/atlas/src/math/scalar/tests.rs b/libs/@local/graph/atlas/src/math/scalar/tests.rs index 37c47f0d369..8facad81bd0 100644 --- a/libs/@local/graph/atlas/src/math/scalar/tests.rs +++ b/libs/@local/graph/atlas/src/math/scalar/tests.rs @@ -7,268 +7,21 @@ use core::hash::{Hash, Hasher as _}; use std::hash::DefaultHasher; -use proptest::{prop_assert, prop_assert_eq, prop_assert_ne, property_test}; +use proptest::{prop_assert, prop_assert_eq, property_test}; -use crate::math::{ - d_finite, finite, non_negative, positive, - scalar::{ - DFinite, DNonNegative, DPositive, Finite, GreaterThanOne, Log2, NonNegative, - OpenUnitFraction, Positive, PositiveUnitFraction, UnitFraction, narrow_f32, softplus, - }, -}; - -/// `UnitFraction::new` accepts `0`, `1` and interior values and refuses negatives, values above -/// one, NaN and both infinities. -#[test] -fn unit_fraction_accepts_exactly_the_closed_interval() { - assert_eq!(UnitFraction::new(0.0), Some(UnitFraction::ZERO)); - assert_eq!(UnitFraction::new(1.0), Some(UnitFraction::ONE)); - assert_eq!(UnitFraction::new(0.25).map(UnitFraction::get), Some(0.25),); - - assert_eq!(UnitFraction::new(-0.1), None); - assert_eq!(UnitFraction::new(1.5), None); - assert_eq!(UnitFraction::new(f64::NAN), None); - assert_eq!(UnitFraction::new(f64::INFINITY), None); - assert_eq!(UnitFraction::new(f64::NEG_INFINITY), None); -} - -/// Every constructor canonicalizes the sign of zero. -/// -/// `-0.0` enters through `new`, `new_clamped`, and `new_unchecked`; one bit pattern per value is -/// the ground for bitwise `Eq`, `Ord`, and `Hash`, so all three must store `+0.0`. -#[test] -fn unit_fraction_constructors_canonicalize_negative_zero() { - let plus_zero = 0.0_f64.to_bits(); - - assert_eq!( - UnitFraction::new(-0.0) - .expect("-0.0 lies inside [0, 1]") - .get() - .to_bits(), - plus_zero - ); - assert_eq!( - UnitFraction::new_clamped(-0.0) - .expect("-0.0 is not NaN") - .get() - .to_bits(), - plus_zero - ); - assert_eq!(UnitFraction::new_unchecked(-0.0).get().to_bits(), plus_zero); -} - -/// `new_unchecked` passes an in-domain value through unchanged. -#[test] -fn unit_fraction_new_unchecked_passes_the_promised_value_through() { - assert_eq!(UnitFraction::new_unchecked(0.625).get(), 0.625); - assert_eq!(UnitFraction::new_unchecked(0.0), UnitFraction::ZERO); - assert_eq!(UnitFraction::new_unchecked(1.0), UnitFraction::ONE); -} - -/// Counts that divide exactly yield the exact quotient, not an approximation. -#[test] -fn unit_fraction_ratio_divides_small_counts_exactly() { - assert_eq!(UnitFraction::ratio(3, 4).map(UnitFraction::get), Some(0.75)); - assert_eq!( - UnitFraction::ratio(1, 8).map(UnitFraction::get), - Some(0.125) - ); - assert_eq!(UnitFraction::ratio(1, 1), Some(UnitFraction::ONE)); -} - -/// Clamping saturates at the nearer endpoint and refuses only NaN. -#[test] -fn unit_fraction_clamp_saturates_and_refuses_only_nan() { - assert_eq!(UnitFraction::new_clamped(1.5), Some(UnitFraction::ONE)); - assert_eq!( - UnitFraction::new_clamped(f64::INFINITY), - Some(UnitFraction::ONE) - ); - assert_eq!(UnitFraction::new_clamped(-0.25), Some(UnitFraction::ZERO)); - assert_eq!( - UnitFraction::new_clamped(f64::NEG_INFINITY), - Some(UnitFraction::ZERO) - ); - assert_eq!(UnitFraction::new_clamped(f64::NAN), None); -} - -/// Inside the domain, clamping is validation: both constructors yield the same value. -#[property_test] -fn unit_fraction_clamp_agrees_with_new_inside_the_domain(#[strategy = 0.0_f64..=1.0] value: f64) { - prop_assert_eq!(UnitFraction::new_clamped(value), UnitFraction::new(value)); -} - -/// The complement stays in `[0, 1]` and stays canonical. -#[property_test] -fn unit_fraction_complement_is_closed_and_canonical(#[strategy = 0.0_f64..=1.0] value: f64) { - let fraction = UnitFraction::new(value).expect("the strategy stays inside [0, 1]"); - let complement = fraction.complement(); - - prop_assert!(complement.get() >= 0.0 && complement.get() <= 1.0); - prop_assert_ne!(complement.get().to_bits(), (-0.0_f64).to_bits()); -} - -/// The endpoints complement to each other exactly, and one half is its own complement. -#[test] -fn unit_fraction_endpoints_complement_exactly() { - assert_eq!(UnitFraction::ONE.complement(), UnitFraction::ZERO); - assert_eq!(UnitFraction::ZERO.complement(), UnitFraction::ONE); - assert_eq!(UnitFraction::HALF.complement(), UnitFraction::HALF); -} - -/// Fraction products stay in the interval, keep a positive sign, and match the iterator fold. -#[property_test] -fn unit_fraction_products_are_closed( - #[strategy = 0.0_f64..=1.0] left: f64, - #[strategy = 0.0_f64..=1.0] right: f64, -) { - let left = UnitFraction::new(left).expect("the strategy stays inside [0, 1]"); - let right = UnitFraction::new(right).expect("the strategy stays inside [0, 1]"); - - let product = left * right; - prop_assert!(product.get() >= 0.0 && product.get() <= 1.0); - prop_assert_ne!(product.get().to_bits(), (-0.0_f64).to_bits()); - prop_assert_eq!([left, right].into_iter().product::(), product); -} - -/// The empty product is the multiplicative identity. -#[test] -fn unit_fraction_empty_product_is_one() { - assert_eq!( - core::iter::empty::().product::(), - UnitFraction::ONE - ); -} - -/// `ratio` admits exactly a part within a non-zero total, and the quotient lies in `[0, 1]`. -#[property_test] -fn unit_fraction_ratio_lies_in_the_interval(part: u64, total: u64) { - match UnitFraction::ratio(part, total) { - Some(fraction) => { - prop_assert!(total != 0 && part <= total); - prop_assert!(fraction.get() >= 0.0 && fraction.get() <= 1.0); - } - None => prop_assert!(total == 0 || part > total), - } -} - -/// The total order agrees with the raw float order. -#[property_test] -fn unit_fraction_order_agrees_with_the_raw_floats( - #[strategy = 0.0_f64..=1.0] left: f64, - #[strategy = 0.0_f64..=1.0] right: f64, -) { - let left = UnitFraction::new(left).expect("the strategy stays inside [0, 1]"); - let right = UnitFraction::new(right).expect("the strategy stays inside [0, 1]"); - - prop_assert_eq!( - left.cmp(&right), - left.get() - .partial_cmp(&right.get()) - .expect("fractions are never NaN") - ); -} - -/// The open complement widens to the closed type at both ends of its range. -#[test] -fn open_unit_fraction_complement_hits_the_closed_endpoints() { - // Ties-to-even: `1 − 2⁻⁵⁴` is halfway between the largest float below one and one itself, - // and rounds to the even mantissa, which is one. - let tie = OpenUnitFraction::new(2.0_f64.powi(-54)).expect("2^-54 lies inside (0, 1)"); - assert_eq!(tie.complement(), UnitFraction::ONE); - - // One spacing further from one, the subtraction is representable again. - let above = OpenUnitFraction::new(2.0_f64.powi(-53)).expect("2^-53 lies inside (0, 1)"); - assert_eq!(above.complement().get(), 1.0 - 2.0_f64.powi(-53)); - - // Sterbenz: the complement of the largest fraction below one is exactly `2⁻⁵³`. - let largest = OpenUnitFraction::new(1.0 - 2.0_f64.powi(-53)).expect("below one"); - assert_eq!(largest.complement().get(), 2.0_f64.powi(-53)); -} - -/// Fractions serialize as plain numbers and deserialization re-validates the domain. -#[test] -fn unit_fractions_round_trip_serde_and_refuse_out_of_domain() { - let value = serde_json::to_value(UnitFraction::HALF).expect("a number serializes"); - assert_eq!(value, serde_json::json!(0.5)); - assert_eq!( - serde_json::from_value::(value).expect("0.5 lies inside [0, 1]"), - UnitFraction::HALF - ); - serde_json::from_str::("1.5").expect_err("1.5 is out of range"); - - let open = OpenUnitFraction::new(0.25).expect("0.25 lies inside (0, 1)"); - let value = serde_json::to_value(open).expect("a number serializes"); - assert_eq!( - serde_json::from_value::(value).expect("0.25 lies inside (0, 1)"), - open - ); - serde_json::from_str::("0.0").expect_err("0.0 is out of range"); - serde_json::from_str::("1.0").expect_err("1.0 is out of range"); -} - -/// The endpoint predicates detect exactly their endpoint. -#[test] -fn unit_fraction_predicates_detect_exactly_their_endpoint() { - assert!(UnitFraction::ZERO.is_zero()); - assert!(UnitFraction::ONE.is_one()); - assert!(!UnitFraction::HALF.is_zero()); - assert!(!UnitFraction::HALF.is_one()); - - // Exactness: the nearest representable neighbours do not qualify. - let below_one = UnitFraction::new(1.0 - f64::EPSILON / 2.0).expect("below one"); - assert!(!below_one.is_one()); - let above_zero = UnitFraction::new(f64::MIN_POSITIVE).expect("above zero"); - assert!(!above_zero.is_zero()); -} - -/// Fractions display as the raw number, and conversion errors name the value and its interval. -#[test] -fn unit_fractions_display_the_number_and_errors_the_interval() { - assert_eq!(UnitFraction::HALF.to_string(), "0.5"); - let open = OpenUnitFraction::new(0.25).expect("0.25 lies inside (0, 1)"); - assert_eq!(open.to_string(), "0.25"); - - assert_eq!( - UnitFraction::try_from(1.5) - .expect_err("1.5 lies outside [0, 1]") - .to_string(), - "1.5 is not a fraction in [0, 1]" - ); - assert_eq!( - OpenUnitFraction::try_from(1.5) - .expect_err("1.5 lies outside (0, 1)") - .to_string(), - "1.5 is not a fraction in (0, 1)" - ); -} +use crate::math::scalar::{narrow_f32, narrow_f32_down, narrow_f32_up, softplus}; /// Hashes one value with the std default hasher. -fn hash_of(value: impl Hash) -> u64 { +pub(super) fn hash_of(value: impl Hash) -> u64 { let mut hasher = DefaultHasher::new(); value.hash(&mut hasher); hasher.finish() } -/// `Hash` agrees with `Eq` and separates distinct fractions. -#[test] -fn unit_fraction_hashes_follow_numeric_value() { - // A fixed-key DefaultHasher makes distinctness deterministic for fixed inputs. - assert_eq!( - hash_of(UnitFraction::new(-0.0).expect("-0.0 lies inside [0, 1]")), - hash_of(UnitFraction::ZERO) - ); - assert_ne!(hash_of(UnitFraction::ZERO), hash_of(UnitFraction::ONE)); - - let quarter = OpenUnitFraction::new(0.25).expect("0.25 lies inside (0, 1)"); - let half = OpenUnitFraction::new(0.5).expect("0.5 lies inside (0, 1)"); - assert_ne!(hash_of(quarter), hash_of(half)); -} - /// `softplus(50)` is exactly `50`, `softplus(-50)` is positive but below `1e-20`, and /// `softplus(-200)` is exactly zero once `exp` underflows `f32`. #[test] -fn softplus_approaches_asymptotes() { +fn softplus_asymptotes() { // ln_1p(exp(-50)) is far below f32 ε at 50, so the positive // asymptote is exact. assert_eq!(softplus(50.0), 50.0); @@ -281,7 +34,7 @@ fn softplus_approaches_asymptotes() { /// `softplus(x) - softplus(-x) = x` within `1e-5` across negative, zero and positive inputs. #[test] -fn softplus_satisfies_shift_identity() { +fn softplus_shift_identity() { // softplus(x) - softplus(-x) == x: the ln_1p terms share |x| and cancel. for value in [-3.0_f32, -0.5, 0.0, 1.25, 4.0] { let difference = softplus(value) - softplus(-value); @@ -297,95 +50,23 @@ fn softplus_satisfies_shift_identity() { reason = "the textbook `ln(1 + exp(x))` form is the reference the stable evaluation is \ checked against" )] -fn softplus_matches_naive_on_small_values() { +fn softplus_naive_reference() { for value in [-4.0_f32, -1.0, 0.0, 0.5, 3.0] { let naive = (1.0 + value.exp()).ln(); assert!((softplus(value) - naive).abs() < 1e-5, "value {value}"); } } -/// `sigmoid(0)` is exactly `0.5`, and at `±200` the asymptotes `1` and `0` are exact. -#[test] -fn sigmoid_matches_hand_computed_values() { - // At zero the two branches agree exactly: 1 / (1 + 1). - assert_eq!(NonNegative::sigmoid(0.0), 0.5); - // Saturation: exp(-200) underflows f32, so the asymptotes are exact. - assert_eq!(NonNegative::sigmoid(200.0), 1.0); - assert_eq!(NonNegative::sigmoid(-200.0), 0.0); -} - -/// `sigmoid(-20)` stays positive and within a relative `1e-6` of `exp(-20)`, where the complement -/// form would round to zero. -#[test] -fn sigmoid_keeps_relative_precision_on_the_negative_tail() { - // The complement form `1 - 1/(1 + exp(-|x|))` rounds to zero once exp(-|x|) drops below f32 ε. - // The direct ratio keeps the tail. - let tail = NonNegative::sigmoid(-20.0).get(); - let expected = (-20.0_f32).exp(); - assert!(tail > 0.0); - assert!((tail - expected).abs() <= 1e-6 * expected, "tail {tail}"); -} - -/// `huber` is `0.5 · v²` below the threshold, `0.5 · t²` at it and `t · (v - 0.5 t)` above it, on -/// exactly representable inputs. -#[test] -fn huber_matches_hand_computed_regimes() { - // Quadratic regime: 0.5 · value^2, over exactly-representable inputs. - assert_eq!( - non_negative!(0.5).huber(positive!(1.0)), - non_negative!(0.125) - ); - assert_eq!(NonNegative::ZERO.huber(positive!(1.0)), NonNegative::ZERO); - // At the threshold both formulas give 0.5 · threshold^2. - assert_eq!(non_negative!(1.0).huber(positive!(1.0)), non_negative!(0.5)); - // Linear regime: threshold · (value - 0.5 · threshold). - assert_eq!(non_negative!(3.0).huber(positive!(1.0)), non_negative!(2.5)); - assert_eq!( - non_negative!(2.0).huber(positive!(0.5)), - non_negative!(0.875) - ); -} - -#[test] -fn huber_is_continuous_at_the_threshold() { - let threshold = 1.0_f32; - let step = 1e-4_f32; - - let below = NonNegative::new(threshold - step) - .expect("a step below the threshold is non-negative") - .huber(positive!(1.0)); - let above = NonNegative::new(threshold + step) - .expect("a step above the threshold is non-negative") - .huber(positive!(1.0)); - - // The derivative at the threshold is the threshold itself, so values a step apart on either - // side differ by about 2 · step. - assert!(below < above); - assert!((above.get() - below.get()) < 1e-3); -} - -/// `huber` at `1e20` against a `1e20` threshold clamps to `f32::MAX` instead of overflowing to -/// infinity. -#[test] -fn huber_saturates_instead_of_overflowing() { - // In the quadratic regime the square of 10²⁰ overflows the `f32` range. The reading clamps - // to the domain's maximum instead of leaving it. - assert_eq!( - non_negative!(1.0e20).huber(positive!(1.0e20)), - non_negative!(f32::MAX) - ); -} - /// `narrow_f32` returns powers of two unchanged. #[test] -fn narrowing_round_trips_powers_of_two() { +fn narrow_f32_powers_of_two() { assert_eq!(narrow_f32(0.25), Some(0.25_f32)); assert_eq!(narrow_f32(-1024.0), Some(-1024.0_f32)); } /// `narrow_f32(0.1)` rounds to the nearest `f32`, the value the `0.1_f32` literal denotes. #[test] -fn narrow_f32_rounds_where_exact_rejects() { +fn narrow_f32_inexact() { // 0.1 has no exact binary representation at either width; narrowing // rounds to the nearest f32, which is what the 0.1_f32 literal denotes. assert_eq!(narrow_f32(0.1), Some(0.1_f32)); @@ -393,7 +74,7 @@ fn narrow_f32_rounds_where_exact_rejects() { /// `narrow_f32` returns `None` for values beyond the `f32` range, infinity and NaN. #[test] -fn narrowing_rejects_overflow_and_nan() { +fn narrow_f32_invalid() { assert_eq!(narrow_f32(1e300), None); assert_eq!(narrow_f32(f64::INFINITY), None); assert!(narrow_f32(f64::NAN).is_none()); @@ -401,647 +82,126 @@ fn narrowing_rejects_overflow_and_nan() { /// `narrow_f32(-0.0)` keeps the sign bit. #[test] -fn narrowing_preserves_negative_zero() { +fn narrow_f32_negative_zero() { let rounded = narrow_f32(-0.0).expect("negative zero is finite"); assert_eq!(rounded.to_bits(), (-0.0_f32).to_bits()); } -/// Softplus is non-negative and satisfies the shift identity. -/// -/// `softplus(x) - softplus(-x) == x` up to rounding scaled by `|x|`. The strategy bounds inputs to -/// `-1e4..1e4`, where the stable form is well-conditioned. The tests above pin the asymptotes. -#[property_test] -fn softplus_is_non_negative_and_satisfies_the_shift_identity( - #[strategy = -1e4_f32..1e4] value: f32, -) { - prop_assert!(softplus(value) >= 0.0); - prop_assert!(softplus(-value) >= 0.0); - - let difference = softplus(value) - softplus(-value); - prop_assert!( - (difference - value).abs() <= 1e-5 * value.abs().max(1.0), - "softplus({0}) - softplus(-{0}) = {1}", - value, - difference, - ); -} - -/// The sigmoid is monotone non-decreasing and satisfies its complement identity. -/// -/// Values lie in `[0, 1]`, and `sigmoid(-x) == 1 - sigmoid(x)` up to rounding. The strategy bounds -/// inputs to `-1e4..1e4`. The tests above pin the asymptotes. -#[property_test] -fn sigmoid_is_bounded_monotone_and_complementary( - #[strategy = -1e4_f32..1e4] first: f32, - #[strategy = -1e4_f32..1e4] second: f32, -) { - let (lower, upper) = if first <= second { - (first, second) - } else { - (second, first) - }; - - prop_assert!((0.0..=1.0).contains(&NonNegative::sigmoid(lower).get())); - prop_assert!( - NonNegative::sigmoid(lower) <= NonNegative::sigmoid(upper), - "sigmoid({}) = {} above sigmoid({}) = {}", - lower, - NonNegative::sigmoid(lower), - upper, - NonNegative::sigmoid(upper), - ); - - let complement = 1.0 - NonNegative::sigmoid(first).get(); - prop_assert!( - (NonNegative::sigmoid(-first).get() - complement).abs() <= 1e-6, - "sigmoid(-{0}) = {1} against 1 - sigmoid({0}) = {2}", - first, - NonNegative::sigmoid(-first), - complement, - ); -} - -/// The Huber penalty is monotone non-decreasing in the magnitude. -/// -/// For a fixed positive threshold, the quadratic and linear pieces are each monotone and meet -/// continuously at the threshold. The saturation at [`f32::MAX`] is itself monotone, so the -/// property holds over the whole domain. -#[property_test] -fn huber_is_monotone_in_the_magnitude( - first: NonNegative, - second: NonNegative, - threshold: Positive, -) { - let (lower, upper) = if first <= second { - (first, second) - } else { - (second, first) - }; - - prop_assert!( - lower.huber(threshold) <= upper.huber(threshold), - "huber({}, {}) = {} above huber({}, {}) = {}", - lower, - threshold, - lower.huber(threshold), - upper, - threshold, - upper.huber(threshold), - ); -} - -/// Widening an `f32` to `f64` and narrowing it back is the identity. -/// -/// Every finite `f32` is exactly representable in `f64`, and round-to-nearest returns it unchanged. -#[property_test] -fn narrow_f32_round_trips_every_finite_f32(#[strategy = -f32::MAX..=f32::MAX] value: f32) { - prop_assert_eq!(narrow_f32(f64::from(value)), Some(value)); -} - -/// The whole `u8` domain, exhaustively: exactly the shiftable exponents construct. +/// The directed narrowings bracket a value the nearest rounding would cross: `0.1_f32` lies above +/// `0.1`: `narrow_f32_down` steps below it and `narrow_f32_up` returns it. #[test] -fn log2_admits_exactly_the_shift_domain() { - for value in 0_u8..64 { - let exponent = Log2::new(value).expect("values below the shift width construct"); - assert_eq!(exponent.get(), value); - // The type guarantees that a shift by an exponent that exists cannot panic. - let _power = 1_u64 << exponent.get(); - } - for value in 64_u8..=u8::MAX { - assert_eq!(Log2::new(value), None); - } -} +fn directed_narrowing_inexact() { + // `0.1_f32` is the nearest `f32` to 0.1 and sits above it. + assert!(f64::from(0.1_f32) > 0.1); -/// The positive domain is exactly the finite `f32` values strictly above zero. -#[test] -fn positive_admits_exactly_the_positive_finite_domain() { - assert_eq!( - Positive::new(f32::MIN_POSITIVE) - .expect("a tiny positive constructs") - .get(), - f32::MIN_POSITIVE, - ); - assert_eq!( - Positive::new(f32::MAX) - .expect("the maximum is finite") - .get(), - f32::MAX - ); + assert_eq!(narrow_f32_down(0.1), Some(0.1_f32.next_down())); + assert_eq!(narrow_f32_up(0.1), Some(0.1_f32)); - assert_eq!(Positive::new(0.0), None); - assert_eq!(Positive::new(-0.0), None); - assert_eq!(Positive::new(-1.0), None); - assert_eq!(Positive::new(f32::INFINITY), None); - assert_eq!(Positive::new(f32::NAN), None); + // A value whose nearest `f32` lies below it: the mirror image. + let below = f64::from(0.1_f32.next_down()); + let between = f64::midpoint(below, f64::from(0.1_f32)) - 1e-12; + assert_eq!(narrow_f32_down(between), Some(0.1_f32.next_down())); + assert_eq!(narrow_f32_up(between), Some(0.1_f32)); } -/// The non-negative domain admits zero and rejects every sign and escape. +/// A value that is already an `f32` narrows to itself in both directions, `-0.0` with its sign bit. #[test] -fn non_negative_admits_exactly_the_non_negative_finite_domain() { - assert_eq!(NonNegative::new(0.0).expect("zero is admitted").get(), 0.0); - assert_eq!( - NonNegative::new(1.0e-10) - .expect("a tolerance constructs") - .get(), - 1.0e-10, - ); +fn directed_narrowing_exact() { + assert_eq!(narrow_f32_down(0.25), Some(0.25_f32)); + assert_eq!(narrow_f32_up(0.25), Some(0.25_f32)); + assert_eq!(narrow_f32_down(f64::from(f32::MAX)), Some(f32::MAX)); + assert_eq!(narrow_f32_up(f64::from(-f32::MAX)), Some(-f32::MAX)); - assert_eq!(NonNegative::new(-1.0e-10), None); - assert_eq!(NonNegative::new(f32::INFINITY), None); - assert_eq!(NonNegative::new(f32::NAN), None); + let down = narrow_f32_down(-0.0).expect("negative zero is finite"); + let up = narrow_f32_up(-0.0).expect("negative zero is finite"); + assert_eq!(down.to_bits(), (-0.0_f32).to_bits()); + assert_eq!(up.to_bits(), (-0.0_f32).to_bits()); } -/// `-0.0` enters through `new` and `new_unchecked`; one bit pattern per value is the ground for -/// bitwise `Eq`, `Ord`, and `Hash`, so both must store `+0.0`. +/// Beyond `f32::MAX` only the downward narrowing has an answer, beyond `-f32::MAX` only the upward +/// one, and neither has one for NaN or the infinities. #[test] -fn non_negative_constructors_canonicalize_negative_zero() { - let plus_zero = 0.0_f32.to_bits(); +fn directed_narrowing_range_edges() { + // Just past the range, where the nearest rounding still returns `f32::MAX`. + let past_max = f64::from(f32::MAX) + 1e30; + assert_eq!(narrow_f32_down(past_max), Some(f32::MAX)); + assert_eq!(narrow_f32_up(past_max), None); - assert_eq!( - NonNegative::new(-0.0) - .expect("-0.0 is non-negative") - .to_bits(), - plus_zero - ); - assert_eq!(NonNegative::new_unchecked(-0.0).to_bits(), plus_zero); - assert!(NonNegative::new_unchecked(-0.0).is_zero()); - assert_eq!(NonNegative::new(-0.0), Some(NonNegative::ZERO)); -} + // Far past the range, where the nearest rounding is infinite. + assert_eq!(narrow_f32_down(1e300), Some(f32::MAX)); + assert_eq!(narrow_f32_up(1e300), None); + assert_eq!(narrow_f32_down(-1e300), None); + assert_eq!(narrow_f32_up(-1e300), Some(-f32::MAX)); -/// The total order agrees with the raw float order. -#[property_test] -fn non_negative_order_agrees_with_the_raw_floats( - #[strategy = 0.0_f32..=f32::MAX] left: f32, - #[strategy = 0.0_f32..=f32::MAX] right: f32, -) { - let left = NonNegative::new(left).expect("the strategy stays inside the domain"); - let right = NonNegative::new(right).expect("the strategy stays inside the domain"); - - prop_assert_eq!( - left.cmp(&right), - left.get() - .partial_cmp(&right.get()) - .expect("non-negative values are never NaN") - ); -} - -/// Equal values hash equally across the two encodings of zero, and distinct values hash apart. -#[test] -fn non_negative_hashes_follow_numeric_value() { - assert_eq!( - hash_of(NonNegative::new(-0.0).expect("-0.0 is non-negative")), - hash_of(NonNegative::ZERO) - ); - assert_ne!(hash_of(NonNegative::ZERO), hash_of(NonNegative::ONE)); -} - -/// The double-precision positive domain is exactly the finite values strictly above zero. -#[test] -fn d_positive_admits_exactly_the_positive_finite_domain() { - assert_eq!( - DPositive::new(1.0e-308) - .expect("a tiny positive constructs") - .get(), - 1.0e-308, - ); - assert_eq!( - DPositive::new(f64::MAX) - .expect("the maximum is finite") - .get(), - f64::MAX - ); - - assert_eq!(DPositive::new(0.0), None); - assert_eq!(DPositive::new(-0.0), None); - assert_eq!(DPositive::new(-1.0), None); - assert_eq!(DPositive::new(f64::INFINITY), None); - assert_eq!(DPositive::new(f64::NAN), None); + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(narrow_f32_down(value), None, "down({value})"); + assert_eq!(narrow_f32_up(value), None, "up({value})"); + } } -/// The double-precision non-negative domain admits zero and rejects every sign and escape. +/// Below the smallest subnormal magnitude the directed narrowings step onto the neighbouring +/// subnormal or zero, on the side the direction names. #[test] -fn d_non_negative_admits_exactly_the_non_negative_finite_domain() { - assert_eq!(DNonNegative::new(0.0).expect("zero is admitted").get(), 0.0); - assert_eq!( - DNonNegative::new(1.0e-10) - .expect("a tolerance constructs") - .get(), - 1.0e-10, - ); - - assert_eq!(DNonNegative::new(-1.0e-300), None); - assert_eq!(DNonNegative::new(f64::INFINITY), None); - assert_eq!(DNonNegative::new(f64::NAN), None); -} +fn directed_narrowing_subnormal() { + let tiny = f32::from_bits(1); -/// `-0.0` enters through `new` and `new_unchecked`; one bit pattern per value is the ground for -/// bitwise `Eq`, `Ord`, and `Hash`, so both must store `+0.0`. -#[test] -fn d_non_negative_constructors_canonicalize_negative_zero() { - let plus_zero = 0.0_f64.to_bits(); + assert_eq!(narrow_f32_down(1e-50), Some(0.0)); + assert_eq!(narrow_f32_up(1e-50), Some(tiny)); + assert_eq!(narrow_f32_down(-1e-50), Some(-tiny)); - assert_eq!( - DNonNegative::new(-0.0) - .expect("-0.0 is non-negative") - .get() - .to_bits(), - plus_zero - ); - assert_eq!(DNonNegative::new_unchecked(-0.0).get().to_bits(), plus_zero); - assert_eq!(DNonNegative::new(-0.0), Some(DNonNegative::ZERO)); + let up = narrow_f32_up(-1e-50).expect("the value is finite"); + assert_eq!(up.to_bits(), (-0.0_f32).to_bits()); } -/// The total order agrees with the raw float order. +/// Softplus is non-negative and satisfies the shift identity. +/// +/// `softplus(x) - softplus(-x) == x` up to rounding scaled by `|x|`. The strategy bounds inputs to +/// `-1e4..1e4`, where the stable form is well-conditioned. The tests above pin the asymptotes. #[property_test] -fn d_non_negative_order_agrees_with_the_raw_floats( - #[strategy = 0.0_f64..=f64::MAX] left: f64, - #[strategy = 0.0_f64..=f64::MAX] right: f64, -) { - let left = DNonNegative::new(left).expect("the strategy stays inside the domain"); - let right = DNonNegative::new(right).expect("the strategy stays inside the domain"); - - prop_assert_eq!( - left.cmp(&right), - left.get() - .partial_cmp(&right.get()) - .expect("non-negative values are never NaN") - ); -} - -/// Equal values hash equally across the two encodings of zero, and distinct values hash apart. -#[test] -fn d_non_negative_hashes_follow_numeric_value() { - assert_eq!( - hash_of(DNonNegative::new(-0.0).expect("-0.0 is non-negative")), - hash_of(DNonNegative::ZERO) - ); - assert_ne!( - hash_of(DNonNegative::ZERO), - hash_of(DNonNegative::new(1.0).expect("one is non-negative")) - ); -} - -/// The open unit interval excludes both endpoints, unlike its closed sibling. -#[test] -fn open_unit_fraction_excludes_the_endpoints() { - assert_eq!( - OpenUnitFraction::new(0.25) - .expect("a quarter is interior") - .get(), - 0.25 - ); - let almost_one = 1.0 - f64::EPSILON; - assert_eq!( - OpenUnitFraction::new(almost_one) - .expect("below one is interior") - .get(), - almost_one, - ); - - assert_eq!(OpenUnitFraction::new(0.0), None); - assert_eq!(OpenUnitFraction::new(1.0), None); - assert_eq!(OpenUnitFraction::new(-0.5), None); - assert_eq!(OpenUnitFraction::new(1.5), None); - assert_eq!(OpenUnitFraction::new(f64::NAN), None); -} - -/// The finite `f32` domain is every value except NaN and the two infinities. -#[test] -fn finite_admits_exactly_the_finite_f32_domain() { - assert_eq!(Finite::new(0.0).expect("zero is finite"), Finite::ZERO); - assert_eq!(Finite::new(1.0).expect("one is finite"), Finite::ONE); - assert_eq!( - Finite::new(-2.5).expect("a negative value is finite").get(), - -2.5 - ); - assert_eq!( - Finite::new(f32::MIN).expect("the minimum is finite").get(), - f32::MIN - ); - assert_eq!( - Finite::new(f32::MAX).expect("the maximum is finite").get(), - f32::MAX - ); - - assert_eq!(Finite::new(f32::NAN), None); - assert_eq!(Finite::new(f32::INFINITY), None); - assert_eq!(Finite::new(f32::NEG_INFINITY), None); -} - -/// `Finite::new` admits negative zero and keeps its sign bit. -#[test] -fn finite_preserves_negative_zero() { - assert_eq!( - Finite::new(-0.0) - .expect("negative zero is finite") - .get() - .to_bits(), - (-0.0_f32).to_bits() - ); - assert_eq!( - DFinite::new(-0.0) - .expect("negative zero is finite") - .get() - .to_bits(), - (-0.0_f64).to_bits() - ); -} - -/// The finite `f64` domain is every value except NaN and the two infinities. -#[test] -fn d_finite_admits_exactly_the_finite_f64_domain() { - assert_eq!(DFinite::new(0.0).expect("zero is finite"), DFinite::ZERO); - assert_eq!(DFinite::new(1.0).expect("one is finite"), DFinite::ONE); - assert_eq!( - DFinite::new(-1.0e-300) - .expect("a tiny negative is finite") - .get(), - -1.0e-300 - ); - assert_eq!( - DFinite::new(f64::MIN).expect("the minimum is finite").get(), - f64::MIN - ); - assert_eq!( - DFinite::new(f64::MAX).expect("the maximum is finite").get(), - f64::MAX - ); - - assert_eq!(DFinite::new(f64::NAN), None); - assert_eq!(DFinite::new(f64::INFINITY), None); - assert_eq!(DFinite::new(f64::NEG_INFINITY), None); -} - -/// The compile-time literal macros construct through the checked constructors. -#[test] -fn finite_literals_validate_in_const_position() { - assert_eq!(finite!(-2.5).get(), -2.5); - assert_eq!(d_finite!(1.0e-300).get(), 1.0e-300); -} - -/// The sign-bounded types widen into the finiteness-only domain. -#[test] -fn finite_widens_from_the_sign_bounded_types() { - assert_eq!(Finite::from(Positive::ONE), Finite::ONE); - assert_eq!(Finite::from(NonNegative::ZERO), Finite::ZERO); - assert_eq!(DFinite::from(DPositive::ONE), DFinite::ONE); - assert_eq!(DFinite::from(DNonNegative::ZERO), DFinite::ZERO); -} - -/// Finite values serialize as plain numbers and deserialization re-validates the domain. -#[test] -fn finite_round_trips_serde_and_refuses_the_escapes() { - let single = Finite::new(-2.5).expect("a negative value is finite"); - let value = serde_json::to_value(single).expect("a number serializes"); - assert_eq!(value, serde_json::json!(-2.5)); - assert_eq!( - serde_json::from_value::(value).expect("-2.5 is finite"), - single - ); +fn softplus_range_and_shift_identity(#[strategy = -1e4_f32..1e4] value: f32) { + prop_assert!(softplus(value) >= 0.0); + prop_assert!(softplus(-value) >= 0.0); - let double = DFinite::new(0.125).expect("an eighth is finite"); - let value = serde_json::to_value(double).expect("a number serializes"); - assert_eq!(value, serde_json::json!(0.125)); - assert_eq!( - serde_json::from_value::(value).expect("0.125 is finite"), - double + let difference = softplus(value) - softplus(-value); + prop_assert!( + (difference - value).abs() <= 1e-5 * value.abs().max(1.0), + "softplus({0}) - softplus(-{0}) = {1}", + value, + difference, ); - - // A NaN written into JSON arrives as `null`, and an overflowing exponent - // arrives as an infinity or as a parse failure. Both refuse. - serde_json::from_str::("null").expect_err("null is not a number"); - serde_json::from_str::("null").expect_err("null is not a number"); - serde_json::from_str::("1e40").expect_err("1e40 overflows the f32 range"); - serde_json::from_str::("1e400").expect_err("1e400 overflows the f64 range"); } +/// Widening an `f32` to `f64` and narrowing it back is the identity. +/// +/// Every finite `f32` is exactly representable in `f64`, and round-to-nearest returns it unchanged. #[property_test] -fn finite_admits_every_finite_f32(#[strategy = -f32::MAX..=f32::MAX] value: f32) { - prop_assert_eq!(Finite::new(value).map(Finite::get), Some(value)); +fn narrow_f32_round_trip_identity(#[strategy = -f32::MAX..=f32::MAX] value: f32) { + prop_assert_eq!(narrow_f32(f64::from(value)), Some(value)); } +/// The directed narrowings are the floor and ceiling onto the `f32` grid. +/// +/// For every finite `f64` inside the `f32` range, `narrow_f32_down` returns a value at or below it +/// whose successor lies above it, and `narrow_f32_up` a value at or above it whose predecessor lies +/// below it. The strategy spans the whole range in both signs, subnormals included. #[property_test] -fn d_finite_admits_every_finite_f64(#[strategy = -f64::MAX..=f64::MAX] value: f64) { - prop_assert_eq!(DFinite::new(value).map(DFinite::get), Some(value)); -} - -/// The greater-than-one domain rejects one itself, infinities, and everything below. -#[test] -fn greater_than_one_requires_actual_growth() { - assert_eq!(GreaterThanOne::new(2.0).expect("doubling grows").get(), 2.0); - let barely = 1.0 + f64::EPSILON; - assert_eq!( - GreaterThanOne::new(barely) - .expect("one ulp above grows") - .get(), - barely +fn directed_narrowing_order_laws( + #[strategy = -f64::from(f32::MAX)..=f64::from(f32::MAX)] value: f64, +) { + let down = narrow_f32_down(value).expect("the value is inside the range"); + prop_assert!( + f64::from(down) <= value, + "down({value}) = {down} lies above" + ); + prop_assert!( + f64::from(down.next_up()) > value, + "down({value}) = {down} has a successor at or below", ); - assert_eq!(GreaterThanOne::new(1.0), None); - assert_eq!(GreaterThanOne::new(0.5), None); - assert_eq!(GreaterThanOne::new(f64::INFINITY), None); - assert_eq!(GreaterThanOne::new(f64::NAN), None); -} - -/// Deserialising refuses exactly what the constructors refuse (`0` for the positive unit fraction, -/// negatives, values above one) and admits the closed endpoints, and serializing writes the plain -/// number. -#[test] -fn serde_doors_validate_the_domain() { - // A published record's wire form reads through `Deserialize`, so the door refuses - // exactly the values the constructors refuse. - let admitted: PositiveUnitFraction = - serde_json::from_str("1.0e-3").expect("1.0e-3 lies inside (0, 1]"); - assert_eq!(admitted.get(), 1.0e-3); - - serde_json::from_str::("0.0") - .expect_err("zero is excluded, the domain's open endpoint"); - serde_json::from_str::("-0.5").expect_err("negatives are refused"); - serde_json::from_str::("1.5").expect_err("1.5 is out of range"); - - let closed: UnitFraction = serde_json::from_str("0.0").expect("zero lies inside [0, 1]"); - assert_eq!(closed.get(), 0.0); - serde_json::from_str::("1.5").expect_err("1.5 is out of range"); - - let wire = serde_json::to_string(&PositiveUnitFraction::ONE).expect("a fraction serializes"); - assert_eq!(wire, "1.0"); -} - -/// The tests the `miri` nextest profile selects. -/// -/// Every `TryFromBytes` impl generated by `unsafe_impl_try_from_bytes!` accepts exactly its -/// type's canonical bit pattern and refuses everything else, read straight through -/// `try_read_from_bytes`, the path a mapped byte region validates through. One test per type. -/// The profile selects by module path, so moving a test in or out of this module is the whole edit. -mod miri { - #![expect( - clippy::host_endian_bytes, - reason = "is_bit_valid reads the candidate as a native-endian primitive with no \ - byte-order conversion, so the fixture bytes must be the host's native order too" - )] - - use zerocopy::TryFromBytes as _; - - use crate::math::scalar::{ - DFinite, DNonNegative, DPositive, Finite, GreaterThanOne, Log2, NonNegative, - OpenUnitFraction, Positive, PositiveUnitFraction, UnitFraction, - }; - - /// `NonNegative` reads canonical positive bytes and refuses `-0.0`, negatives and NaN. - #[test] - fn non_negative_try_from_bytes() { - assert_eq!( - NonNegative::try_read_from_bytes(&1.5_f32.to_ne_bytes()) - .expect("1.5 is canonical") - .get(), - 1.5, - ); - NonNegative::try_read_from_bytes(&(-0.0_f32).to_ne_bytes()) - .expect_err("-0.0 is non-negative but not the canonical +0.0 bit pattern"); - NonNegative::try_read_from_bytes(&(-1.0_f32).to_ne_bytes()) - .expect_err("negative is refused"); - NonNegative::try_read_from_bytes(&f32::NAN.to_ne_bytes()).expect_err("NaN is refused"); - } - - /// `DNonNegative` reads canonical positive bytes and refuses `-0.0` and NaN. - #[test] - fn d_non_negative_try_from_bytes() { - assert_eq!( - DNonNegative::try_read_from_bytes(&1.5_f64.to_ne_bytes()) - .expect("1.5 is canonical") - .get(), - 1.5, - ); - DNonNegative::try_read_from_bytes(&(-0.0_f64).to_ne_bytes()) - .expect_err("-0.0 is non-negative but not the canonical +0.0 bit pattern"); - DNonNegative::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); - } - - /// `Positive` reads canonical positive bytes and refuses zero and NaN. - #[test] - fn positive_try_from_bytes() { - assert_eq!( - Positive::try_read_from_bytes(&1.5_f32.to_ne_bytes()) - .expect("1.5 is canonical") - .get(), - 1.5, - ); - Positive::try_read_from_bytes(&0.0_f32.to_ne_bytes()).expect_err("zero is refused"); - Positive::try_read_from_bytes(&f32::NAN.to_ne_bytes()).expect_err("NaN is refused"); - } - - /// `DPositive` reads canonical positive bytes and refuses zero and NaN. - #[test] - fn d_positive_try_from_bytes() { - assert_eq!( - DPositive::try_read_from_bytes(&1.5_f64.to_ne_bytes()) - .expect("1.5 is canonical") - .get(), - 1.5, - ); - DPositive::try_read_from_bytes(&0.0_f64.to_ne_bytes()).expect_err("zero is refused"); - DPositive::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); - } - - /// `Finite` reads finite bytes of either sign and refuses NaN and infinity. - #[test] - fn finite_try_from_bytes() { - assert_eq!( - Finite::try_read_from_bytes(&(-2.5_f32).to_ne_bytes()) - .expect("a finite value is canonical") - .get(), - -2.5, - ); - Finite::try_read_from_bytes(&f32::NAN.to_ne_bytes()).expect_err("NaN is refused"); - Finite::try_read_from_bytes(&f32::INFINITY.to_ne_bytes()).expect_err("infinity is refused"); - } - - /// `DFinite` reads finite bytes of either sign and refuses NaN and infinity. - #[test] - fn d_finite_try_from_bytes() { - assert_eq!( - DFinite::try_read_from_bytes(&(-2.5_f64).to_ne_bytes()) - .expect("a finite value is canonical") - .get(), - -2.5, - ); - DFinite::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); - DFinite::try_read_from_bytes(&f64::NEG_INFINITY.to_ne_bytes()) - .expect_err("infinity is refused"); - } - - /// `GreaterThanOne` reads `2.0` and refuses exactly one and NaN. - #[test] - fn greater_than_one_try_from_bytes() { - assert_eq!( - GreaterThanOne::try_read_from_bytes(&2.0_f64.to_ne_bytes()) - .expect("2.0 is canonical") - .get(), - 2.0, - ); - GreaterThanOne::try_read_from_bytes(&1.0_f64.to_ne_bytes()) - .expect_err("one itself is refused"); - GreaterThanOne::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); - } - - /// `Log2` reads a byte below the shift width and refuses `64` and `255`. - #[test] - fn log2_try_from_bytes() { - assert_eq!( - Log2::try_read_from_bytes(&[10_u8]) - .expect("10 lies below the shift width") - .get(), - 10, - ); - Log2::try_read_from_bytes(&[64_u8]).expect_err("64 is the shift width itself, refused"); - Log2::try_read_from_bytes(&[255_u8]).expect_err("255 is far past the shift width"); - } - - /// `UnitFraction` reads canonical interior bytes and refuses `-0.0`, values above one and NaN. - #[test] - fn unit_fraction_try_from_bytes() { - assert_eq!( - UnitFraction::try_read_from_bytes(&0.5_f64.to_ne_bytes()) - .expect("0.5 is canonical") - .get(), - 0.5, - ); - UnitFraction::try_read_from_bytes(&(-0.0_f64).to_ne_bytes()) - .expect_err("-0.0 lies in [0, 1] but is not the canonical +0.0 bit pattern"); - UnitFraction::try_read_from_bytes(&1.5_f64.to_ne_bytes()).expect_err("1.5 is out of range"); - UnitFraction::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); - } - - /// `PositiveUnitFraction` reads the closed endpoint `1.0` and refuses zero, values above one - /// and NaN. - #[test] - fn positive_unit_fraction_try_from_bytes() { - assert_eq!( - PositiveUnitFraction::try_read_from_bytes(&1.0_f64.to_ne_bytes()) - .expect("1.0 is canonical, the domain's closed endpoint") - .get(), - 1.0, - ); - PositiveUnitFraction::try_read_from_bytes(&0.0_f64.to_ne_bytes()) - .expect_err("zero is excluded, the domain's open endpoint"); - PositiveUnitFraction::try_read_from_bytes(&1.5_f64.to_ne_bytes()) - .expect_err("1.5 is out of range"); - PositiveUnitFraction::try_read_from_bytes(&f64::NAN.to_ne_bytes()) - .expect_err("NaN is refused"); - } - - #[test] - fn open_unit_fraction_try_from_bytes() { - assert_eq!( - OpenUnitFraction::try_read_from_bytes(&0.5_f64.to_ne_bytes()) - .expect("0.5 is canonical, strictly interior") - .get(), - 0.5, - ); - OpenUnitFraction::try_read_from_bytes(&0.0_f64.to_ne_bytes()) - .expect_err("zero is excluded, an endpoint"); - OpenUnitFraction::try_read_from_bytes(&1.0_f64.to_ne_bytes()) - .expect_err("one is excluded, an endpoint"); - OpenUnitFraction::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); - } + let up = narrow_f32_up(value).expect("the value is inside the range"); + prop_assert!(f64::from(up) >= value, "up({value}) = {up} lies below"); + prop_assert!( + f64::from(up.next_down()) < value, + "up({value}) = {up} has a predecessor at or above", + ); } diff --git a/libs/@local/graph/atlas/src/math/scalar/unit_fraction.rs b/libs/@local/graph/atlas/src/math/scalar/unit_fraction/mod.rs similarity index 94% rename from libs/@local/graph/atlas/src/math/scalar/unit_fraction.rs rename to libs/@local/graph/atlas/src/math/scalar/unit_fraction/mod.rs index f8310ee7563..0c36219db70 100644 --- a/libs/@local/graph/atlas/src/math/scalar/unit_fraction.rs +++ b/libs/@local/graph/atlas/src/math/scalar/unit_fraction/mod.rs @@ -5,13 +5,20 @@ use core::{ error::Error, fmt, hash::{Hash, Hasher}, - ops::{Mul, MulAssign, Sub}, + ops::{Add, Mul, MulAssign, Sub}, }; +#[cfg(test)] +use proptest::{arbitrary::Arbitrary, strategy::Strategy as _}; + use super::{ - DFinite, OpenUnitFraction, PositiveUnitFraction, raw_interop, unsafe_impl_try_from_bytes, + DFinite, DNonNegative, OpenUnitFraction, PositiveUnitFraction, raw_interop, + unsafe_impl_try_from_bytes, }; +#[cfg(test)] +mod tests; + /// Validates a unit-fraction literal at compile time. /// /// A literal outside the domain fails the build. Use [`UnitFraction::new`] to check runtime values. @@ -252,7 +259,8 @@ impl UnitFraction { /// Returns `true` when the fraction is exactly one. #[expect( clippy::float_cmp, - reason = "one is exactly representable and stored canonically, so equality is exact" + reason = "the endpoint test needs exact equality with the exactly representable, \ + canonical one" )] #[inline] #[must_use] @@ -312,6 +320,20 @@ impl UnitFraction { // a non-negative operand, and sqrt(+0.0) is +0.0. Self(self.0.sqrt()) } + + /// Returns the fraction rounded to `f32`. + /// + /// The result remains finite and in `[0, 1]`. Both endpoints are exact. + /// + /// # Warning + /// + /// Rounding can map a positive fraction to `0.0` or a fraction below one to `1.0`. + #[inline] + #[must_use] + #[expect(clippy::cast_possible_truncation)] + pub(crate) const fn as_f32(self) -> f32 { + self.0 as f32 + } } const impl PartialEq for UnitFraction { @@ -431,20 +453,6 @@ const impl TryFrom for UnitFraction { } } -#[cfg(test)] -impl proptest::arbitrary::Arbitrary for UnitFraction { - type Parameters = (); - type Strategy = proptest::strategy::BoxedStrategy; - - fn arbitrary_with((): Self::Parameters) -> Self::Strategy { - use proptest::strategy::Strategy as _; - - (0.0..=1.0) - .prop_map(|value| Self::new(value).expect("the range covers exactly the domain")) - .boxed() - } -} - impl serde::Serialize for UnitFraction { fn serialize(&self, serializer: S) -> Result { serializer.serialize_f64(self.0) @@ -534,9 +542,29 @@ const impl Mul for UnitFraction { } } +const impl Add for UnitFraction { + type Output = DNonNegative; + + #[inline] + fn add(self, rhs: UnitFraction) -> DNonNegative { + DNonNegative::new_unchecked(self.get() + rhs.get()) + } +} + raw_interop!(UnitFraction[f64]); unsafe_impl_try_from_bytes!(UnitFraction[f64]); +#[cfg(test)] +impl Arbitrary for UnitFraction { + type Parameters = (); + + type Strategy = impl proptest::strategy::Strategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (0.0..=1.0).prop_map(Self) + } +} + // SAFETY: repr(transparent) preserves the native f64 layout, and this type has no interior // mutability. unsafe impl rkyv::Portable for UnitFraction {} diff --git a/libs/@local/graph/atlas/src/math/scalar/unit_fraction/tests.rs b/libs/@local/graph/atlas/src/math/scalar/unit_fraction/tests.rs new file mode 100644 index 00000000000..0bb7f4e3148 --- /dev/null +++ b/libs/@local/graph/atlas/src/math/scalar/unit_fraction/tests.rs @@ -0,0 +1,250 @@ +#![expect( + clippy::float_cmp, + reason = "bit-exact assertions are the point: single-element identities, asymptotes over \ + exactly-representable values, and round-trip narrowing are exact contracts" +)] + +use proptest::{prop_assert, prop_assert_eq, prop_assert_ne, property_test}; + +use crate::math::{OpenUnitFraction, UnitFraction, scalar::tests::hash_of}; + +/// `UnitFraction::new` accepts `0`, `1` and interior values and refuses negatives, values above +/// one, NaN and both infinities. +#[test] +fn new_domain() { + assert_eq!(UnitFraction::new(0.0), Some(UnitFraction::ZERO)); + assert_eq!(UnitFraction::new(1.0), Some(UnitFraction::ONE)); + assert_eq!(UnitFraction::new(0.25).map(UnitFraction::get), Some(0.25),); + + assert_eq!(UnitFraction::new(-0.1), None); + assert_eq!(UnitFraction::new(1.5), None); + assert_eq!(UnitFraction::new(f64::NAN), None); + assert_eq!(UnitFraction::new(f64::INFINITY), None); + assert_eq!(UnitFraction::new(f64::NEG_INFINITY), None); +} + +#[test] +fn constructors_negative_zero() { + let plus_zero = 0.0_f64.to_bits(); + + assert_eq!( + UnitFraction::new(-0.0) + .expect("-0.0 lies inside [0, 1]") + .get() + .to_bits(), + plus_zero + ); + assert_eq!( + UnitFraction::new_clamped(-0.0) + .expect("-0.0 is not NaN") + .get() + .to_bits(), + plus_zero + ); + assert_eq!(UnitFraction::new_unchecked(-0.0).get().to_bits(), plus_zero); +} + +/// `new_unchecked` passes an in-domain value through unchanged. +#[test] +fn new_unchecked_valid() { + assert_eq!(UnitFraction::new_unchecked(0.625).get(), 0.625); + assert_eq!(UnitFraction::new_unchecked(0.0), UnitFraction::ZERO); + assert_eq!(UnitFraction::new_unchecked(1.0), UnitFraction::ONE); +} + +/// Counts that divide exactly yield the exact quotient, not an approximation. +#[test] +fn ratio_exact() { + assert_eq!(UnitFraction::ratio(3, 4).map(UnitFraction::get), Some(0.75)); + assert_eq!( + UnitFraction::ratio(1, 8).map(UnitFraction::get), + Some(0.125) + ); + assert_eq!(UnitFraction::ratio(1, 1), Some(UnitFraction::ONE)); +} + +/// Clamping saturates at the nearer endpoint and refuses only NaN. +#[test] +fn new_clamped_domain() { + assert_eq!(UnitFraction::new_clamped(1.5), Some(UnitFraction::ONE)); + assert_eq!( + UnitFraction::new_clamped(f64::INFINITY), + Some(UnitFraction::ONE) + ); + assert_eq!(UnitFraction::new_clamped(-0.25), Some(UnitFraction::ZERO)); + assert_eq!( + UnitFraction::new_clamped(f64::NEG_INFINITY), + Some(UnitFraction::ZERO) + ); + assert_eq!(UnitFraction::new_clamped(f64::NAN), None); +} + +/// Inside the domain, clamping is validation: both constructors yield the same value. +#[property_test] +fn new_clamped_valid(#[strategy = 0.0_f64..=1.0] value: f64) { + prop_assert_eq!(UnitFraction::new_clamped(value), UnitFraction::new(value)); +} + +/// The complement stays in `[0, 1]` and stays canonical. +#[property_test] +fn complement_domain(#[strategy = 0.0_f64..=1.0] value: f64) { + let fraction = UnitFraction::new(value).expect("the strategy stays inside [0, 1]"); + let complement = fraction.complement(); + + prop_assert!(complement.get() >= 0.0 && complement.get() <= 1.0); + prop_assert_ne!(complement.get().to_bits(), (-0.0_f64).to_bits()); +} + +/// The endpoints complement to each other exactly, and one half is its own complement. +#[test] +fn complement_endpoints() { + assert_eq!(UnitFraction::ONE.complement(), UnitFraction::ZERO); + assert_eq!(UnitFraction::ZERO.complement(), UnitFraction::ONE); + assert_eq!(UnitFraction::HALF.complement(), UnitFraction::HALF); +} + +/// Fraction products stay in the interval, keep a positive sign, and match the iterator fold. +#[property_test] +fn product_domain(#[strategy = 0.0_f64..=1.0] left: f64, #[strategy = 0.0_f64..=1.0] right: f64) { + let left = UnitFraction::new(left).expect("the strategy stays inside [0, 1]"); + let right = UnitFraction::new(right).expect("the strategy stays inside [0, 1]"); + + let product = left * right; + prop_assert!(product.get() >= 0.0 && product.get() <= 1.0); + prop_assert_ne!(product.get().to_bits(), (-0.0_f64).to_bits()); + prop_assert_eq!([left, right].into_iter().product::(), product); +} + +/// The empty product is the multiplicative identity. +#[test] +fn product_empty() { + assert_eq!( + core::iter::empty::().product::(), + UnitFraction::ONE + ); +} + +/// `ratio` admits exactly a part within a non-zero total, and the quotient lies in `[0, 1]`. +#[property_test] +fn ratio_domain(part: u64, total: u64) { + match UnitFraction::ratio(part, total) { + Some(fraction) => { + prop_assert!(total != 0 && part <= total); + prop_assert!(fraction.get() >= 0.0 && fraction.get() <= 1.0); + } + None => prop_assert!(total == 0 || part > total), + } +} + +/// The total order agrees with the raw float order. +#[property_test] +fn cmp_numeric(#[strategy = 0.0_f64..=1.0] left: f64, #[strategy = 0.0_f64..=1.0] right: f64) { + let left = UnitFraction::new(left).expect("the strategy stays inside [0, 1]"); + let right = UnitFraction::new(right).expect("the strategy stays inside [0, 1]"); + + prop_assert_eq!( + left.cmp(&right), + left.get() + .partial_cmp(&right.get()) + .expect("fractions are never NaN") + ); +} + +/// Fractions serialize as plain numbers and deserialization re-validates the domain. +#[test] +fn serde_domain() { + let value = serde_json::to_value(UnitFraction::HALF).expect("a number serializes"); + assert_eq!(value, serde_json::json!(0.5)); + assert_eq!( + serde_json::from_value::(value).expect("0.5 lies inside [0, 1]"), + UnitFraction::HALF + ); + serde_json::from_str::("1.5").expect_err("1.5 is out of range"); + + let open = OpenUnitFraction::new(0.25).expect("0.25 lies inside (0, 1)"); + let value = serde_json::to_value(open).expect("a number serializes"); + assert_eq!( + serde_json::from_value::(value).expect("0.25 lies inside (0, 1)"), + open + ); + serde_json::from_str::("0.0").expect_err("0.0 is out of range"); + serde_json::from_str::("1.0").expect_err("1.0 is out of range"); +} + +/// The endpoint predicates detect exactly their endpoint. +#[test] +fn predicates_endpoints() { + assert!(UnitFraction::ZERO.is_zero()); + assert!(UnitFraction::ONE.is_one()); + assert!(!UnitFraction::HALF.is_zero()); + assert!(!UnitFraction::HALF.is_one()); + + // Exactness: the nearest representable neighbours do not qualify. + let below_one = UnitFraction::new(1.0 - f64::EPSILON / 2.0).expect("below one"); + assert!(!below_one.is_one()); + let above_zero = UnitFraction::new(f64::MIN_POSITIVE).expect("above zero"); + assert!(!above_zero.is_zero()); +} + +/// Fractions display as the raw number, and conversion errors name the value and its interval. +#[test] +fn display_values_and_errors() { + assert_eq!(UnitFraction::HALF.to_string(), "0.5"); + let open = OpenUnitFraction::new(0.25).expect("0.25 lies inside (0, 1)"); + assert_eq!(open.to_string(), "0.25"); + + assert_eq!( + UnitFraction::try_from(1.5) + .expect_err("1.5 lies outside [0, 1]") + .to_string(), + "1.5 is not a fraction in [0, 1]" + ); + assert_eq!( + OpenUnitFraction::try_from(1.5) + .expect_err("1.5 lies outside (0, 1)") + .to_string(), + "1.5 is not a fraction in (0, 1)" + ); +} + +/// `Hash` agrees with `Eq` and separates distinct fractions. +#[test] +fn hash_numeric() { + // A fixed-key DefaultHasher makes distinctness deterministic for fixed inputs. + assert_eq!( + hash_of(UnitFraction::new(-0.0).expect("-0.0 lies inside [0, 1]")), + hash_of(UnitFraction::ZERO) + ); + assert_ne!(hash_of(UnitFraction::ZERO), hash_of(UnitFraction::ONE)); + + let quarter = OpenUnitFraction::new(0.25).expect("0.25 lies inside (0, 1)"); + let half = OpenUnitFraction::new(0.5).expect("0.5 lies inside (0, 1)"); + assert_ne!(hash_of(quarter), hash_of(half)); +} + +mod miri { + #![expect( + clippy::host_endian_bytes, + reason = "fixture bytes must match the native-endian representation that is_bit_valid \ + reads" + )] + + use zerocopy::TryFromBytes as _; + + use crate::math::UnitFraction; + + /// `UnitFraction` reads canonical interior bytes and refuses `-0.0`, values above one and NaN. + #[test] + fn try_from_bytes_canonical() { + assert_eq!( + UnitFraction::try_read_from_bytes(&0.5_f64.to_ne_bytes()) + .expect("0.5 is canonical") + .get(), + 0.5, + ); + UnitFraction::try_read_from_bytes(&(-0.0_f64).to_ne_bytes()) + .expect_err("-0.0 lies in [0, 1] but is not the canonical +0.0 bit pattern"); + UnitFraction::try_read_from_bytes(&1.5_f64.to_ne_bytes()).expect_err("1.5 is out of range"); + UnitFraction::try_read_from_bytes(&f64::NAN.to_ne_bytes()).expect_err("NaN is refused"); + } +} diff --git a/libs/@local/graph/atlas/src/math/similarity/fit.rs b/libs/@local/graph/atlas/src/math/similarity/fit.rs index 6ba1f4e3720..e1a328d2c79 100644 --- a/libs/@local/graph/atlas/src/math/similarity/fit.rs +++ b/libs/@local/graph/atlas/src/math/similarity/fit.rs @@ -31,6 +31,7 @@ use super::Similarity; use crate::math::{ FinitePointField, Positive, dvec2::{DVec2, DVec2x4T}, + nz, rotation::Rotation, scalar::narrow_f32, vec2::{Vec2, Vec2x4, Vec2x4T}, @@ -39,9 +40,9 @@ use crate::math::{ impl Similarity { /// Pairs per rayon work item in [`fit_par`](Self::fit_par). /// - /// 4096 pairs read 80 KiB across the three input slices, small enough to stay cache-warm while - /// large enough that per-task overhead disappears against the fold. - pub(crate) const PARALLEL_CHUNK: NonZero = NonZero::new(4096).expect("4096 is not zero"); + /// A full chunk reads 80 KiB of point and weight data: 4096 pairs at two 8-byte points and one + /// 4-byte weight per pair. [`fit_par_with`](Self::fit_par_with) accepts a different chunk size. + pub(crate) const PARALLEL_CHUNK: NonZero = nz!(4096); /// Estimates the weighted Procrustes alignment of paired points. /// @@ -340,7 +341,7 @@ impl FitSums { /// round above 2⁵³. #[expect( clippy::cast_precision_loss, - reason = "pair counts remain exactly representable in f64 far beyond any corpus" + reason = "deliberately convert the pair count for double-precision arithmetic" )] fn from_slices_uniform(source: &[Vec2], target: &[Vec2]) -> Self { debug_assert!( diff --git a/libs/@local/graph/atlas/src/math/similarity/mod.rs b/libs/@local/graph/atlas/src/math/similarity/mod.rs index 3131d784798..7490d25be73 100644 --- a/libs/@local/graph/atlas/src/math/similarity/mod.rs +++ b/libs/@local/graph/atlas/src/math/similarity/mod.rs @@ -12,6 +12,8 @@ use core::simd::Simd; +use serde::de::Error as _; + use super::{ Positive, kernel::mul_add_f32x4, @@ -71,6 +73,7 @@ mod tests; zerocopy::IntoBytes, zerocopy::Immutable, zerocopy::KnownLayout, + serde::Serialize, )] pub(crate) struct Similarity { scale: Positive, @@ -78,6 +81,15 @@ pub(crate) struct Similarity { translation: Vec2, } +/// Similarity coefficients awaiting joint numerical validation. +#[derive(serde::Deserialize)] +#[serde(rename = "Similarity")] +struct UnvalidatedSimilarity { + scale: Positive, + rotation: Rotation, + translation: Vec2, +} + impl Similarity { /// Unit scale, identity rotation and zero translation. pub(crate) const IDENTITY: Self = Self { @@ -104,6 +116,18 @@ impl Similarity { return None; } + let cos = f64::from(rotation.cos()); + let sin = f64::from(rotation.sin()); + + // products of finite widened f32 components are exact in f64. Only their sum rounds. + let norm_squared = cos * cos + sin * sin; + if !norm_squared.is_finite() + || (norm_squared - 1.0).abs() > 1.0e-6 + || !translation.is_finite() + { + return None; + } + Some(Self { scale, rotation, @@ -166,17 +190,17 @@ impl Similarity { /// while rotating or scaling the inverse translation. #[inline] #[must_use] - pub(crate) const fn inverse(self) -> Self { + pub(crate) const fn inverse(self) -> Option { // In domain with no check: `new` admits only scales whose reciprocal is also normal. let inverse_scale = self.scale.recip(); let rotation = self.rotation.inverse(); let moved = rotation.apply(self.translation); - Self { - scale: inverse_scale, + Self::new( + inverse_scale, rotation, - translation: Vec2::new(-(inverse_scale * moved.x()), -(inverse_scale * moved.y())), - } + Vec2::new(-(inverse_scale * moved.x()), -(inverse_scale * moved.y())), + ) } /// Transforms a single vector. @@ -267,6 +291,26 @@ impl Similarity { } } +impl<'de> serde::Deserialize<'de> for Similarity { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let coefficients = UnvalidatedSimilarity::deserialize(deserializer)?; + Self::new( + coefficients.scale, + coefficients.rotation, + coefficients.translation, + ) + .ok_or_else(|| { + D::Error::custom( + "similarity requires a normal scale and reciprocal, a rotation squared-norm \ + defect at most 1e-6, and finite translation", + ) + }) + } +} + const impl From for Transform { #[inline] fn from(similarity: Similarity) -> Self { diff --git a/libs/@local/graph/atlas/src/math/similarity/residual.rs b/libs/@local/graph/atlas/src/math/similarity/residual.rs index 2d4c9612ffe..b2284e39da0 100644 --- a/libs/@local/graph/atlas/src/math/similarity/residual.rs +++ b/libs/@local/graph/atlas/src/math/similarity/residual.rs @@ -181,7 +181,7 @@ impl Similarity { /// `squared` must be nonnegative and finite, and `pairs` must be nonzero. #[expect( clippy::cast_precision_loss, - reason = "pair counts remain exactly representable in f64 far beyond any corpus" + reason = "deliberately convert the pair count for double-precision arithmetic" )] fn finish_rms(squared: f64, pairs: usize) -> DNonNegative { // Finite f32 coordinates and coefficients have magnitude below 2¹²⁸. Each residual component diff --git a/libs/@local/graph/atlas/src/math/similarity/tests.rs b/libs/@local/graph/atlas/src/math/similarity/tests.rs index 6cb4acc5874..bd0f52f74be 100644 --- a/libs/@local/graph/atlas/src/math/similarity/tests.rs +++ b/libs/@local/graph/atlas/src/math/similarity/tests.rs @@ -3,14 +3,12 @@ reason = "exactness assertions on power-of-two coefficients are bit-precise contracts" )] -use core::num::NonZero; - use hashql_core::id::IdSlice; use proptest::{prop_assert, prop_assume, property_test, strategy::Strategy}; use super::Similarity; use crate::math::{ - DNonNegative, FinitePointField, Positive, Rotation, Vec2, Vec2x4T, positive, + DNonNegative, FinitePointField, Positive, Rotation, Vec2, Vec2x4T, nz, positive, tests::{POINTS, assert_vec2_close}, transform::Transform, }; @@ -226,7 +224,9 @@ fn inverse_round_trips_both_directions() { Vec2::new(10.0, -2.0), ) .expect("scale 4.0 is normal and positive"); - let inverse = similarity.inverse(); + let inverse = similarity + .inverse() + .expect("inverse coefficients are in range"); // A power-of-two scale inverts exactly. assert_eq!(inverse.scale(), 0.25); @@ -249,9 +249,14 @@ fn boundary_scales_and_their_inverses_stay_valid() { ) .expect("2^126 has the reciprocal f32::MIN_POSITIVE, which is normal"); - // Powers of two invert exactly, so each edge maps onto the other. - assert_eq!(bottom.inverse().scale(), 2.0_f32.powi(126)); - assert_eq!(top.inverse().scale(), f32::MIN_POSITIVE); + assert_eq!( + bottom.inverse().expect("zero translation").scale(), + 2.0_f32.powi(126) + ); + assert_eq!( + top.inverse().expect("zero translation").scale(), + f32::MIN_POSITIVE + ); } #[test] @@ -414,7 +419,7 @@ fn fit_is_equivariant_under_target_transformation() { .expect("a similarity image of a well-determined target stays well-determined"); let expected = base .then(post) - .expect("both scales are near one, so the product stays in the accepted range"); + .expect("multiplying the near-one fixture scales stays inside the accepted range"); for (actual, reference) in refitted.to_array().into_iter().zip(expected.to_array()) { assert_scalar_close(actual, reference); @@ -810,10 +815,136 @@ fn fit_par_rejects_an_invalid_chunk_beside_a_valid_one() { // validity propagation through the merge let mut weights = [1.0_f32; 8]; weights[6] = -0.5; - let chunk = NonZero::new(4).expect("four is not zero"); + let chunk = nz!(4); assert!(Similarity::fit_par_with(source, &target, &weights, chunk).is_none()); } +#[test] +fn similarity_construction_validates_rotation_and_translation() { + for [cos, sin, x, y] in [ + [0.0, 0.0, 0.0, 0.0], + [2.0, 0.0, 0.0, 0.0], + [f32::NAN, 0.0, 0.0, 0.0], + [1.0, f32::INFINITY, 0.0, 0.0], + [1.0, 0.0, f32::INFINITY, 0.0], + [1.0, 0.0, 0.0, f32::NAN], + ] { + assert!( + Similarity::new( + Positive::ONE, + Rotation::from_cos_sin(cos, sin), + Vec2::new(x, y) + ) + .is_none() + ); + assert!(Similarity::from_array([1.0, cos, sin, x, y]).is_none()); + } + + let accepted_cos = f32::from_bits(1.0_f32.to_bits() + 4); + let rejected_cos = f32::from_bits(1.0_f32.to_bits() + 5); + let coefficients = [1.0, accepted_cos, 0.0, 3.0, -4.0]; + let similarity = Similarity::from_array(coefficients).expect("squared-norm defect below 1e-6"); + assert_eq!(similarity.to_array(), coefficients); + assert!(Similarity::from_array([1.0, rejected_cos, 0.0, 3.0, -4.0]).is_none()); + assert!(similarity.then(similarity).is_none()); + assert_eq!(similarity.then(Similarity::IDENTITY), Some(similarity)); +} + +#[test] +fn similarity_serde_validates_all_coefficients() { + for json in [ + r#"{"scale":1e-45,"rotation":[1,0],"translation":[0,0]}"#, + r#"{"scale":1e38,"rotation":[1,0],"translation":[0,0]}"#, + r#"{"scale":1,"rotation":[2,0],"translation":[0,0]}"#, + r#"{"scale":1,"rotation":[1,1e40],"translation":[0,0]}"#, + r#"{"scale":1,"rotation":[1,0],"translation":[1e40,0]}"#, + r#"{"scale":1,"rotation":[1,0],"translation":[0,-1e40]}"#, + ] { + serde_json::from_str::(json) + .expect_err("invalid coefficients should not deserialize"); + } + let json = r#"{"scale":2.0,"rotation":[0.0,1.0],"translation":[3.0,-4.0]}"#; + let similarity: Similarity = serde_json::from_str(json).expect("valid coefficients"); + assert_eq!(similarity.to_array(), [2.0, 0.0, 1.0, 3.0, -4.0]); + assert_eq!( + serde_json::to_string(&similarity).expect("finite coefficients"), + json + ); + + let accepted_cos = f32::from_bits(1.0_f32.to_bits() + 4); + let similarity = Similarity::from_array([1.0, accepted_cos, 0.0, 0.0, 0.0]) + .expect("squared-norm defect below 1e-6"); + let encoded = serde_json::to_string(&similarity).expect("finite coefficients"); + let restored: Similarity = + serde_json::from_str(&encoded).expect("admitted rotation stays valid"); + assert_eq!(restored.to_array(), similarity.to_array()); +} + +#[test] +fn similarity_inverse_rejects_translation_overflow() { + let scaled = Similarity::new( + positive!(f32::MIN_POSITIVE), + Rotation::IDENTITY, + Vec2::new(8.0, 0.0), + ) + .expect("finite coefficients with invertible scale"); + assert!(scaled.inverse().is_none()); + + let rotated = Similarity::new( + Positive::ONE, + Rotation::from_radians(core::f32::consts::FRAC_PI_4), + Vec2::splat(f32::MAX), + ) + .expect("finite coefficients with unit rotation"); + assert!(rotated.inverse().is_none()); + + let finite = Similarity::new(Positive::ONE, Rotation::IDENTITY, Vec2::splat(f32::MAX)) + .expect("finite coefficients"); + assert_eq!( + finite + .inverse() + .expect("negation remains finite") + .translation(), + Vec2::splat(-f32::MAX) + ); +} + +#[test] +fn similarity_composition_rejects_translation_overflow() { + let translated = Similarity::new(Positive::ONE, Rotation::IDENTITY, Vec2::new(f32::MAX, 0.0)) + .expect("finite translation"); + assert!(translated.then(translated).is_none()); + let scaled = + Similarity::new(positive!(2.0), Rotation::IDENTITY, Vec2::ZERO).expect("normal scale"); + assert!(translated.then(scaled).is_none()); + assert_eq!(translated.then(Similarity::IDENTITY), Some(translated)); +} + +#[test] +fn similarity_fit_rejects_unrepresentable_translation() { + let source = [Vec2::new(-f32::MAX, 0.0), Vec2::new(-f32::MAX / 2.0, 0.0)]; + let target = [Vec2::new(f32::MAX / 2.0, 0.0), Vec2::new(f32::MAX, 0.0)]; + assert_fit_rejects(&source, &target, &[1.0; 2]); + assert!(Similarity::fit_uniform(field(&source), field(&target)).is_none()); + assert!(Similarity::fit_uniform_par(field(&source), field(&target)).is_none()); + + let source = [Vec2::new(-4.0, 0.0), Vec2::new(-2.0, 0.0)]; + let target = [Vec2::new(2.0, 0.0), Vec2::new(4.0, 0.0)]; + for fitted in [ + Similarity::fit(&source, &target, &[1.0; 2]), + Similarity::fit_par(&source, &target, &[1.0; 2]), + Similarity::fit_uniform(field(&source), field(&target)), + Similarity::fit_uniform_par(field(&source), field(&target)), + ] { + assert_eq!( + fitted + .expect("unit scale and finite translation") + .to_array(), + [1.0, 1.0, 0.0, 6.0, 0.0] + ); + } +} + #[test] fn invalid_scales_are_rejected() { let invalid_scales = [ @@ -952,18 +1083,20 @@ fn inverse_stays_inside_the_constructed_range( let similarity = Similarity::new( Positive::new(scale).expect("a normal positive scale is in domain"), Rotation::from_radians(radians), - Vec2::new(1.0, -2.0), + Vec2::ZERO, ) .expect("a normal scale below 2^126 has a normal reciprocal"); - let inverse = similarity.inverse(); + let inverse = similarity + .inverse() + .expect("zero translation remains finite"); prop_assert!( Similarity::from_array(inverse.to_array()).is_some(), "inverse scale {} left the accepted range", inverse.scale(), ); - let double = inverse.inverse(); + let double = inverse.inverse().expect("zero translation remains finite"); prop_assert!( Similarity::from_array(double.to_array()).is_some(), "double-inverse scale {} left the accepted range", diff --git a/libs/@local/graph/atlas/src/math/transform/fit.rs b/libs/@local/graph/atlas/src/math/transform/fit.rs index 66f04dc2fe9..4858ae50d5d 100644 --- a/libs/@local/graph/atlas/src/math/transform/fit.rs +++ b/libs/@local/graph/atlas/src/math/transform/fit.rs @@ -68,15 +68,15 @@ impl Transform { /// let fitted = Transform::fit_uniform(source, target).expect("the pairs are exact"); /// assert_eq!(fitted.apply(Vec2::new(1.0, 1.0)), expected.apply(Vec2::new(1.0, 1.0))); /// ``` - #[must_use] #[expect( clippy::cast_precision_loss, - reason = "pair counts remain exactly representable in f64 far beyond any corpus" + reason = "deliberately convert the pair count for double-precision arithmetic" )] #[expect( clippy::similar_names, reason = "the raw moments carry their axis-pair names, which the closed form is written in" )] + #[must_use] pub(crate) fn fit_uniform( source: &FinitePointField, target: &FinitePointField, diff --git a/libs/@local/graph/atlas/src/math/transform/tests.rs b/libs/@local/graph/atlas/src/math/transform/tests.rs index bddfb883453..d13655bcbcd 100644 --- a/libs/@local/graph/atlas/src/math/transform/tests.rs +++ b/libs/@local/graph/atlas/src/math/transform/tests.rs @@ -1,7 +1,6 @@ #![expect( clippy::float_cmp, - reason = "the exact fit fixtures produce exactly representable readings, so the asserted \ - constants are exact contracts" + reason = "exact comparisons test the exactly representable results of the exact-fit fixtures" )] use hashql_core::id::IdSlice; diff --git a/libs/@local/graph/atlas/src/math/vec2/mod.rs b/libs/@local/graph/atlas/src/math/vec2/mod.rs index 334e25f5c1c..9a41926b4e5 100644 --- a/libs/@local/graph/atlas/src/math/vec2/mod.rs +++ b/libs/@local/graph/atlas/src/math/vec2/mod.rs @@ -61,14 +61,16 @@ use super::{DVec2x4T, NonNegative, dvec2::DVec2, scalar::DNonNegative}; Debug, Copy, Clone, - PartialEq, zerocopy::ByteHash, zerocopy::FromBytes, zerocopy::IntoBytes, zerocopy::Immutable, zerocopy::KnownLayout, + serde::Serialize, + serde::Deserialize, )] #[repr(transparent)] +#[serde(transparent)] pub struct Vec2([f32; 2]); impl Vec2 { @@ -284,6 +286,13 @@ impl Vec2 { } } +const impl PartialEq for Vec2 { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + const impl Add for Vec2 { type Output = Self; diff --git a/libs/@local/graph/atlas/src/math/vec2/transposed.rs b/libs/@local/graph/atlas/src/math/vec2/transposed.rs index c667fb5de2f..5bf4e979151 100644 --- a/libs/@local/graph/atlas/src/math/vec2/transposed.rs +++ b/libs/@local/graph/atlas/src/math/vec2/transposed.rs @@ -93,13 +93,13 @@ impl Vec2x4T { /// Returns the four `x` components as SIMD lanes. /// /// Lane `i` holds the `x` component of vector `i`. - #[inline] - #[must_use] #[expect( clippy::cast_ptr_alignment, reason = "the pointer derives from `&Self` with 32-byte alignment, which satisfies \ `Simd`'s 16-byte alignment at offset 0" )] + #[inline] + #[must_use] pub const fn xs(&self) -> &Simd { let this = &raw const *self; let this = this.cast::(); @@ -114,13 +114,13 @@ impl Vec2x4T { /// Returns the four `y` components as SIMD lanes. /// /// Lane `i` holds the `y` component of vector `i`. - #[inline] - #[must_use] #[expect( clippy::cast_ptr_alignment, reason = "the pointer derives from `&Self` with 32-byte alignment, which satisfies \ `Simd`'s 16-byte alignment at the 16-byte `y` group offset" )] + #[inline] + #[must_use] pub const fn ys(&self) -> &Simd { let this = &raw const *self; let this = this.cast::(); @@ -138,12 +138,12 @@ impl Vec2x4T { /// The first group holds the `x` components, the second the `y` components. Lane `i` of each /// corresponds to vector `i`. This is the inverse of [`from_lanes`](Self::from_lanes) and the /// by-value counterpart of [`xs`](Self::xs) and [`ys`](Self::ys). - #[inline] - #[must_use] #[expect( clippy::tuple_array_conversions, reason = "the suggested `From` conversion is not const-callable" )] + #[inline] + #[must_use] pub const fn into_lanes(self) -> (Simd, Simd) { // SAFETY: the cast relies on each four-lane Simd having its array element layout. Self // contains the initialized x group followed by the y group, without padding. The transmute diff --git a/libs/@local/graph/atlas/src/math/vecn/tests.rs b/libs/@local/graph/atlas/src/math/vecn/tests.rs index b372afc9702..7020dba6be8 100644 --- a/libs/@local/graph/atlas/src/math/vecn/tests.rs +++ b/libs/@local/graph/atlas/src/math/vecn/tests.rs @@ -390,7 +390,7 @@ mod miri { wrapped_mut[1] = *VecN::from_ref(&[9.0; 8]); assert_eq!( source[1], [9.0; 8], - "a write through the mut wrapper must land in the source" + "a write through the mut wrapper must update the source" ); } diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/projector/error.rs b/libs/@local/graph/atlas/src/salt/fit/compute/projector/error.rs index d686454f814..f5603c9dacf 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/projector/error.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/projector/error.rs @@ -14,7 +14,7 @@ use crate::{ dataset::PROJECTOR_DIMENSIONS, file::attraction::read::OpenAttractionError, identity::NodeRowId, - math::NonFinitePoint, + math::{NonFinitePoint, Positive}, salt::{ ladder::{CanonicalError, LadderError, paired::EncodeError}, projector::{ @@ -31,7 +31,7 @@ use crate::{ #[derive(Debug)] pub(crate) enum ProjectorError { /// The projector objective rejects the fit's low-dimensional kernel. - ObjectiveCurve { exponent: f32 }, + ObjectiveCurve { exponent: Positive }, /// The configured architecture disagrees with the dataset's representation width. RepresentationWidth { configured: usize }, /// Projector training failed. diff --git a/libs/@local/graph/atlas/src/salt/fit/echo.rs b/libs/@local/graph/atlas/src/salt/fit/echo.rs index a60337be31f..ef85e1c8518 100644 --- a/libs/@local/graph/atlas/src/salt/fit/echo.rs +++ b/libs/@local/graph/atlas/src/salt/fit/echo.rs @@ -105,7 +105,7 @@ mod affinity_curve { use serde::{Deserialize as _, Serialize as _, de::Error as _}; - use crate::math::AffinityCurve; + use crate::math::{AffinityCurve, Positive}; /// The curve's wire form. #[derive(serde::Serialize, serde::Deserialize)] @@ -123,8 +123,8 @@ mod affinity_curve { S: serde::Serializer, { Record { - a: curve.a(), - b: curve.b(), + a: curve.a().get(), + b: curve.b().get(), } .serialize(serializer) } @@ -134,12 +134,13 @@ mod affinity_curve { D: serde::Deserializer<'de>, { let Record { a, b } = Record::deserialize(deserializer)?; - AffinityCurve::new(a, b).ok_or_else(|| { - D::Error::custom(format_args!( + match (Positive::new(a), Positive::new(b)) { + (Some(a), Some(b)) => Ok(AffinityCurve::new(a, b)), + _ => Err(D::Error::custom(format_args!( "the parameters a = {a}, b = {b} do not form an affinity curve; both must be \ finite and strictly positive" - )) - }) + ))), + } } } diff --git a/libs/@local/graph/atlas/src/salt/ladder/paired/fixtures.rs b/libs/@local/graph/atlas/src/salt/ladder/paired/fixtures.rs index 017df7eef63..fd441de31aa 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/paired/fixtures.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/paired/fixtures.rs @@ -19,7 +19,8 @@ use crate::{ identity::{EdgeRowId, NodeRowId, OntologyRowId}, integrity::{Sha256, Sha256Digest, Update as _}, math::{ - AffinityCurve, FinitePointField, NonNegative, PositiveUnitFraction, UnitFraction, Vec2, + AffinityCurve, FinitePointField, NonNegative, PositiveUnitFraction, UnitFraction, Vec2, nz, + positive, }, salt::{embedding::EmbedderFingerprint, fit::FitConfig, landmark::select::SelectionOptions}, }; @@ -41,8 +42,7 @@ pub(super) fn config() -> FitConfig { maximum_count: NonZero::new(512).expect("the fixture capacity is nonzero"), .. }, - curve: AffinityCurve::new(1.5, 0.9) - .expect("the fixture parameters are finite and strictly positive"), + curve: AffinityCurve::new(positive!(1.5), positive!(0.9)), .. } } diff --git a/libs/@local/graph/atlas/src/salt/landmark/layout.rs b/libs/@local/graph/atlas/src/salt/landmark/layout.rs index a75396df331..1e151771006 100644 --- a/libs/@local/graph/atlas/src/salt/landmark/layout.rs +++ b/libs/@local/graph/atlas/src/salt/landmark/layout.rs @@ -369,7 +369,7 @@ where let gradients = self.curve - .repulsion_x4(position, targets, self.options.repulsion_strength.get()); + .repulsion_x4(position, targets, self.options.repulsion_strength); let step = Vec2::new(gradients.xs().reduce_sum(), gradients.ys().reduce_sum()) * learning_rate; self.coordinates[anchor] += step; @@ -382,7 +382,7 @@ where let gradient = self.curve.repulsion( self.coordinates[anchor], target, - self.options.repulsion_strength.get(), + self.options.repulsion_strength, ); self.coordinates[anchor] += gradient * learning_rate; diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/solve.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/solve.rs index ac50cbfafee..8332ac93ef1 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/solve.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/solve.rs @@ -402,7 +402,11 @@ pub(super) const fn rejected( return Err(SolverFailure::RadiusUnderflow); } - control.radius = (config.shrink_factor * control.radius).max(config.radius_minimum); + control.radius = match (config.shrink_factor * control.radius).finish() { + Ok(radius) => radius.max(config.radius_minimum), + // a positive fraction cannot overflow the radius. A rejected product rounded to zero. + Err(_) => config.radius_minimum, + }; Ok(()) } @@ -463,3 +467,33 @@ fn curvature_diagnostic( }; CurvatureDiagnostic::Value { along, normalized } } + +#[cfg(test)] +mod tests { + use super::{SolverConfig, SolverControl, SolverFailure, WorkCounters, rejected}; + use crate::math::{d_positive, open_unit_fraction}; + + #[test] + fn rejected_product_underflow() { + let config = SolverConfig { + radius_minimum: d_positive!(1e-300), + radius_initial: d_positive!(1e-200), + shrink_factor: open_unit_fraction!(1e-200), + .. + }; + config + .validate() + .expect("the radii and thresholds are ordered"); + let mut control = SolverControl::new(config.radius_initial, WorkCounters::default()); + + assert_eq!(rejected(&mut control, &config), Ok(())); + assert_eq!(control.radius, config.radius_minimum); + assert_eq!(control.consecutive_rejections, 1); + core::assert_matches!( + rejected(&mut control, &config), + Err(SolverFailure::RadiusUnderflow) + ); + assert_eq!(control.radius, config.radius_minimum); + assert_eq!(control.consecutive_rejections, 2); + } +} diff --git a/libs/@local/graph/atlas/src/salt/projector/bench/live.rs b/libs/@local/graph/atlas/src/salt/projector/bench/live.rs index 073fca8d1d6..97445597062 100644 --- a/libs/@local/graph/atlas/src/salt/projector/bench/live.rs +++ b/libs/@local/graph/atlas/src/salt/projector/bench/live.rs @@ -33,8 +33,8 @@ use crate::{ device::{Inference, PinnedDevice}, identity::{EdgeRowId, NodeRowId, OntologyRowId}, math::{ - AffinityCurve, FinitePointField, MatrixN, NonNegative, Positive, UnitFraction, Vec2, - non_negative, + AffinityCurve, FinitePointField, MatrixN, NonNegative, UnitFraction, Vec2, non_negative, + positive, }, salt::{ policy::ClassProbabilities, @@ -423,21 +423,21 @@ fn objective_options() -> ObjectiveOptions { let ratified = crate::salt::fit::ProjectorOptions::ratified(); ObjectiveOptions { affinity: AffinityEnergy::new( - AffinityCurve::new(1.0, 1.0).expect("the curve constants are valid"), + AffinityCurve::new(positive!(1.0), positive!(1.0)), ratified.affinity_offset, ) .expect("the curve exponent satisfies the objective bound"), relation: Some( RelationEnergy::new( - CoincidentEnergy::new( - NonNegative::new(0.05).expect("the bench radius is non-negative"), - Positive::new(1.0).expect("the bench threshold is positive"), - ), - ProximalEnergy::new( - NonNegative::new(0.1).expect("the bench radius is non-negative"), - Positive::new(0.25).expect("the bench temperature is positive"), - ), - Positive::new(1.0e-3).expect("the bench scale guard is positive"), + CoincidentEnergy { + radius: non_negative!(0.05), + threshold: positive!(1.0), + }, + ProximalEnergy { + radius: non_negative!(0.1), + temperature: positive!(0.25), + }, + positive!(1.0e-3), ) .expect("the radii are ordered"), ), diff --git a/libs/@local/graph/atlas/src/salt/projector/evidence/tests.rs b/libs/@local/graph/atlas/src/salt/projector/evidence/tests.rs index 2d0880d794b..a0a05ef086f 100644 --- a/libs/@local/graph/atlas/src/salt/projector/evidence/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/evidence/tests.rs @@ -35,7 +35,7 @@ fn positive(value: f32) -> Positive { fn bridge(evidence: &EvaluationEvidence) -> Option { evidence .gauge_similarity - .inverse() + .inverse()? .then(evidence.corpus_similarity) } diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/energy.rs b/libs/@local/graph/atlas/src/salt/projector/loss/energy.rs index e6e157a06b9..b9319987f01 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/energy.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/energy.rs @@ -40,14 +40,14 @@ impl AffinityEnergy { #[must_use] pub(crate) fn attraction(self, distance_squared: NonNegative) -> (f32, f32) { let epsilon = self.epsilon.get(); - let affinity = self.curve.affinity(distance_squared.get()); + let affinity = self.curve.affinity(distance_squared); let value = -(affinity + epsilon).ln(); if distance_squared.is_zero() { return (value, 0.0); } - // d/du of -ln(q + ε) = a b u^(b - 1) q^2 / (q + ε). - let derivative = self.mass(distance_squared.get(), affinity) / (affinity + epsilon); + // d/du of -ln(q + ε) = a b u^(b - 1) q² / (q + ε). + let derivative = self.mass(distance_squared, affinity) / (affinity + epsilon); (value, derivative) } @@ -60,28 +60,29 @@ impl AffinityEnergy { #[must_use] pub(crate) fn repulsion(self, distance_squared: NonNegative) -> (f32, f32) { let epsilon = self.epsilon.get(); - let affinity = self.curve.affinity(distance_squared.get()); + let affinity = self.curve.affinity(distance_squared); let value = -(1.0 - affinity + epsilon).ln(); if distance_squared.is_zero() { return (value, 0.0); } - // d/du of -ln(1 - q + ε) = -a b u^(b - 1) q^2 / (1 - q + ε). - let derivative = -self.mass(distance_squared.get(), affinity) / (1.0 - affinity + epsilon); + // d/du of -ln(1 - q + ε) = -a b u^(b - 1) q² / (1 - q + ε). + let derivative = -self.mass(distance_squared, affinity) / (1.0 - affinity + epsilon); (value, derivative) } /// Computes the shared derivative mass `a b u^(b - 1) q²`. /// - /// `-q'(u)` in both derivatives; the callers divide by their respective logarithm arguments and - /// choose the sign. - fn mass(self, distance_squared: f32, affinity: f32) -> f32 { - #[expect( - clippy::min_ident_chars, - reason = "a and b are the affinity curve's literature parameter names" - )] + /// This is `-q'(u)` in both derivatives. The callers divide by their respective logarithm + /// arguments and choose the sign. + #[expect( + clippy::min_ident_chars, + reason = "a and b are the affinity curve's literature parameter names" + )] + fn mass(self, distance_squared: NonNegative, affinity: f32) -> f32 { let (a, b) = (self.curve.a(), self.curve.b()); - a * b * distance_squared.powf(b - 1.0) * affinity * affinity + let power = distance_squared.powf(b - Positive::ONE); + (a * b * power * affinity * affinity).into_raw() } } @@ -99,8 +100,8 @@ impl AffinityEnergy { /// competing terms jointly set a pair's equilibrium distance. #[derive(Debug, Copy, Clone, PartialEq)] pub(crate) struct ProximalEnergy { - radius: NonNegative, - temperature: Positive, + pub radius: NonNegative, + pub temperature: Positive, } impl ProximalEnergy { @@ -129,10 +130,9 @@ impl ProximalEnergy { /// [`f32::MAX`]. #[must_use] pub(crate) fn evaluate(self, normalized: NonNegative) -> (NonNegative, NonNegative) { - // The quotient can leave the finite domain, so the scaled excess and softplus - // compute raw: softplus carries +∞ through and the clamp re-enters the domain. - // sigmoid is total over the extended reals and re-enters on its own. - let argument = (normalized - self.radius) / self.temperature; + // the scaled excess can overflow. softplus preserves +∞, which saturates at the output's + // maximum. sigmoid accepts either infinity. + let argument = ((normalized - self.radius) / self.temperature).into_raw(); ( NonNegative::new_unchecked((self.temperature * softplus(argument)).min(f32::MAX)), @@ -148,8 +148,8 @@ impl ProximalEnergy { /// derivative is continuous everywhere. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(crate) struct CoincidentEnergy { - radius: NonNegative, - threshold: Positive, + pub radius: NonNegative, + pub threshold: Positive, } impl CoincidentEnergy { diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/objective/mod.rs b/libs/@local/graph/atlas/src/salt/projector/loss/objective/mod.rs index 395732da4f5..7bb7965ff0f 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/objective/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/objective/mod.rs @@ -360,11 +360,9 @@ impl TargetEstimator { .evaluate(unit.ruler, canonical_distance, zero_distance); let (value, slope) = self.penalty.evaluate(f64::from(evaluation.violation)); - // The unit's estimator mass w(e)/(W·π(e)), raw in flight. The divisor is a `DPositive` - // product through the unchecked constructor, which rejects a product rounded to zero - // only under debug assertions. A build without them divides by that zero. The - // quotient's claim waits for the folds' finish. - let mass = unit.weight.get() / (denominator * unit.inclusion); + // the estimator mass is w(e)/(W·π(e)). A denominator rounded to zero can produce an + // infinite or NaN mass. Validation belongs to the accumulated readings below. + let mass = (Derivation::from(unit.weight) / (denominator * unit.inclusion)).into_raw(); estimand = Derivation::::raw(mass) .mul_add(Derivation::::raw(value), estimand); diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/tests.rs b/libs/@local/graph/atlas/src/salt/projector/loss/tests.rs index 31016a7b8d5..99f2a21a3af 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/tests.rs @@ -55,7 +55,10 @@ static DEVICE: LazyLock = LazyLock::new(|| Device::Cpu.pin(0).re reason = "`a` and `b` are the affinity curve's literature parameter names" )] fn curve(a: f32, b: f32) -> AffinityCurve { - AffinityCurve::new(a, b).expect("test curve parameters are positive and finite") + AffinityCurve::new( + Positive::new(a).expect("test curve parameters are positive and finite"), + Positive::new(b).expect("test curve parameters are positive and finite"), + ) } /// Builds an affinity energy over `curve(a, b)` with the log guard `epsilon`. diff --git a/libs/@local/graph/atlas/src/salt/projector/report/replay/preflight.rs b/libs/@local/graph/atlas/src/salt/projector/report/replay/preflight.rs index f91498e6ea3..df8f4d62f32 100644 --- a/libs/@local/graph/atlas/src/salt/projector/report/replay/preflight.rs +++ b/libs/@local/graph/atlas/src/salt/projector/report/replay/preflight.rs @@ -183,7 +183,7 @@ mod tests { salt::metadata::{Placement, Reproducibility}, }, integrity::{Sha256, Sha256Digest, Update as _}, - math::AffinityCurve, + math::{AffinityCurve, nz, positive}, salt::{ embedding::EmbedderFingerprint, fit::FitConfig, landmark::select::SelectionOptions, }, @@ -204,8 +204,7 @@ mod tests { maximum_count: NonZero::new(2).expect("the fixture capacity is nonzero"), .. }, - curve: AffinityCurve::new(1.577, 0.895) - .expect("the fixture parameters are finite and strictly positive"), + curve: AffinityCurve::new(positive!(1.577), positive!(0.895)), .. } } diff --git a/libs/@local/graph/atlas/src/salt/projector/train/fit/fixture.rs b/libs/@local/graph/atlas/src/salt/projector/train/fit/fixture.rs index 889d71fe01b..7da3411f7a3 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/fit/fixture.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/fit/fixture.rs @@ -297,7 +297,7 @@ pub(super) fn options(schedule: TrainingSchedule) -> TrainOptions { temporal_anchors: 0, }, affinity: AffinityEnergy::new( - AffinityCurve::new(1.0, 1.0).expect("the fixture curve is valid"), + AffinityCurve::new(positive!(1.0), positive!(1.0)), positive!(0.5), ) .expect("the fixture exponent satisfies the objective bound"), diff --git a/libs/@local/graph/atlas/src/salt/projector/train/tests.rs b/libs/@local/graph/atlas/src/salt/projector/train/tests.rs index 841c0a22b6f..13b9775b644 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/tests.rs @@ -168,7 +168,7 @@ fn relation_indexes( /// (zero value), and the derivative mass is exactly `0.25`. fn affinity() -> AffinityEnergy { AffinityEnergy::new( - AffinityCurve::new(1.0, 1.0).expect("the fixture curve is valid"), + AffinityCurve::new(positive!(1.0), positive!(1.0)), positive!(0.5), ) .expect("the fixture exponent satisfies the objective bound")