From 4febabfd2efca5c3fcc0fd39ba8803694b4bf802 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Wed, 16 Sep 2026 14:22:48 -0400 Subject: [PATCH] Wrap rather than saturate: nothing draws two million pixels out A saturating add is five instructions where a wrapping one is one, and it has no i32 vector form. Measured on the fixed-shape fixture, seed 1, depth 8, 500 frames of `many`: 2,098M instructions and ~826M cycles down to 1,918M and ~771M, with `random`, `tabs` and `text` byte-identical at 1920x1200 and the 100-seed oracle passing. What saturating bought was ordering past the end of the range, where a layout is already a defect; wrapping makes that defect obvious instead of plausible. `from_f32` still clamps, since a float has the range to come from anywhere, and `narrow` stays for `Holds`, whose range past i32 really does mean unbounded. MIN and MAX remain unbounded ends only where they are compared and never added to, which is every use. Co-Authored-By: Claude Fable 5.1 --- core/src/fixed.rs | 58 +++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/core/src/fixed.rs b/core/src/fixed.rs index 625b77c..72380d6 100644 --- a/core/src/fixed.rs +++ b/core/src/fixed.rs @@ -18,6 +18,13 @@ use std::{ /// `SHIFT` is the number of fractional bits, which is what makes the steps /// divide a whole number: a power of two also converts to `f32` without /// rounding while the value fits in its mantissa. +/// +/// Arithmetic wraps at the ends of the range, the way the `i32` underneath +/// does. Saturating instead was measured at a twelfth of layout's +/// instructions -- five per add against one -- to keep the ordering of +/// coordinates two million pixels out, where nothing draws anyway. A value +/// off the end is a defect either way; wrapping makes it an obvious one. +/// Only [`Self::from_f32`] clamps, since a float has further to come from. #[repr(transparent)] #[derive( Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, bytemuck::Pod, bytemuck::Zeroable, @@ -56,8 +63,8 @@ impl Fixed { /// The gap between neighbouring values, which is also how far apart two /// numbers can be and still mean the same place. pub const STEP: Self = Self(1); - /// Also what stands in for an unbounded end, since arithmetic saturates - /// here rather than wrapping past it. + /// Also what stands in for an unbounded end: compared against, never + /// added to, since arithmetic wraps past it. pub const MIN: Self = Self(i32::MIN); pub const MAX: Self = Self(i32::MAX); @@ -77,12 +84,13 @@ impl Fixed { } pub const fn from_int(v: i32) -> Self { - Self(v.saturating_mul(Self::one().0)) + Self(v.wrapping_mul(Self::one().0)) } - /// Rounds to the nearest step, and saturates rather than wrapping. A NaN - /// has no nearest step and becomes zero, which is a caller's mistake - /// rather than a value worth carrying. + /// Rounds to the nearest step, and clamps to the ends of the grid rather + /// than wrapping: this is where a number from outside arrives, and a float + /// has the range to be anywhere. A NaN has no nearest step and becomes + /// zero, which is a caller's mistake rather than a value worth carrying. /// /// Half-away is written out rather than called through `f32::round`, /// which is not `const`: a layout constant has to stay a constant. @@ -114,28 +122,28 @@ impl Fixed { /// The same value on another grid, rounded where the new one is coarser. pub const fn to_scale(self) -> Fixed { Fixed(match TO >= SHIFT { - true => narrow((self.0 as i64) << (TO - SHIFT)), - false => narrow(shift_round(self.0 as i64, SHIFT - TO)), + true => self.0 << (TO - SHIFT), + false => shift_round(self.0 as i64, SHIFT - TO) as i32, }) } pub const fn add(self, rhs: Self) -> Self { - Self(self.0.saturating_add(rhs.0)) + Self(self.0.wrapping_add(rhs.0)) } pub const fn sub(self, rhs: Self) -> Self { - Self(self.0.saturating_sub(rhs.0)) + Self(self.0.wrapping_sub(rhs.0)) } pub const fn neg(self) -> Self { - Self(self.0.saturating_neg()) + Self(self.0.wrapping_neg()) } /// Scaled by a number on any grid, which is how a length takes a fraction /// of itself and keeps being a length: the product is measured in the /// receiver's steps. pub const fn mul(self, by: Fixed) -> Self { - Self(narrow(shift_round(self.0 as i64 * by.0 as i64, BY))) + Self(shift_round(self.0 as i64 * by.0 as i64, BY) as i32) } /// A part of a span that is often nothing: no part of nothing is @@ -150,7 +158,7 @@ impl Fixed { /// Repeated a whole number of times, which no grid rounds. pub const fn mul_int(self, by: i32) -> Self { - Self(narrow(self.0 as i64 * by as i64)) + Self(self.0.wrapping_mul(by)) } /// Divided into a whole number of parts, rounded to the nearest step. @@ -159,12 +167,13 @@ impl Fixed { if by == 0 { return Self::ZERO; } - Self(narrow(div_round(self.0 as i64, by as i64))) + Self(div_round(self.0 as i64, by as i64) as i32) } /// Divided by a number on any grid. A zero divisor is a caller bug -- a - /// box of no length has no fraction of itself -- and saturates so that a - /// release build lays out something absurd rather than dying. + /// box of no length has no fraction of itself -- and answers with the end + /// of the range so that a release build lays out something absurd rather + /// than dying. pub const fn div(self, by: Fixed) -> Self { debug_assert!(by.0 != 0, "dividing by a length of zero"); if by.0 == 0 { @@ -173,7 +182,7 @@ impl Fixed { false => Self::MAX, }; } - Self(narrow(div_round((self.0 as i64) << BY, by.0 as i64))) + Self(div_round((self.0 as i64) << BY, by.0 as i64) as i32) } /// `num / den` on *this* grid rather than on theirs, for weights coarser @@ -183,7 +192,7 @@ impl Fixed { if den.0 == 0 { return Self::ZERO; } - Self(narrow(div_round((num.0 as i64) << SHIFT, den.0 as i64))) + Self(div_round((num.0 as i64) << SHIFT, den.0 as i64) as i32) } /// `from` and `to` a fraction of the way apart, the fraction being the @@ -207,7 +216,7 @@ impl Fixed { } pub const fn abs(self) -> Self { - Self(self.0.saturating_abs()) + Self(self.0.wrapping_abs()) } pub const fn clamp(self, lo: Self, hi: Self) -> Self { @@ -219,11 +228,11 @@ impl Fixed { /// boundary. The step is the whole gap, so there is nothing to exclude /// between this and the boundary itself. pub const fn next_up(self) -> Self { - Self(self.0.saturating_add(1)) + Self(self.0.wrapping_add(1)) } pub const fn next_down(self) -> Self { - Self(self.0.saturating_sub(1)) + Self(self.0.wrapping_sub(1)) } } @@ -260,6 +269,8 @@ pub(crate) const fn div_toward(num: i64, den: i64, up: bool) -> i64 { } } +/// Clamped to the ends, unlike a [`Fixed`]'s own arithmetic: a range of box +/// lengths that runs past `i32` really is unbounded. pub(crate) const fn narrow(v: i64) -> i32 { if v > i32::MAX as i64 { return i32::MAX; @@ -471,12 +482,9 @@ mod tests { } #[test] - fn arithmetic_saturates_rather_than_wrapping() { - assert_eq!(Px::MAX + Px::ONE, Px::MAX); - assert_eq!(Px::MIN - Px::ONE, Px::MIN); + fn a_number_from_outside_is_clamped_to_the_grid() { assert_eq!(Px::from_f32(1e12), Px::MAX); assert_eq!(Px::from_f32(-1e12), Px::MIN); - assert_eq!(Px::from_int(i32::MAX), Px::MAX); } #[test]