Skip to main content

la_stack/
angle.rs

1#![forbid(unsafe_code)]
2
3//! Scale-safe unsigned angles from compensated exterior products and dot products.
4
5use crate::rounding::two_sum_error;
6use crate::{
7    ArithmeticOperation, LaError, NonFiniteLocation, NonFiniteOrigin, Vector, VectorOperand,
8};
9
10/// Unsigned angles for borrowed vector coordinates.
11///
12/// Implemented for `[f64]`. Import this trait directly or through
13/// [`crate::prelude`] to call `left.angle(right)` on coordinate slices.
14/// For finite fixed-size storage, use [`crate::Vector::angle`], which shares the
15/// numerical implementation without repeating coordinate validation.
16pub trait VectorAngle {
17    /// Unsigned angle to another finite, nonzero vector, in radians in `[0, π]`.
18    ///
19    /// Borrows equal-length coordinate slices without allocating. Every positive
20    /// ambient length is supported, including runtime `D + 1` spherical coordinates;
21    /// no matrix dispatch limit applies. Magnitudes may differ arbitrarily and the
22    /// input norms need not be representable. Signed zeros are treated as zero.
23    /// Identical inputs return positive zero, and opposite inputs return `π`.
24    ///
25    /// This is a rounded numerical operation, not an exact parallelism predicate or
26    /// a correctly rounded result. No certified absolute error bound is provided.
27    /// An `atan2` of the exterior-product norm and dot product avoids the endpoint
28    /// sensitivity of `acos`. Power-of-two scaling and compensated products preserve
29    /// small differences without first rounding normalized directions. Work is
30    /// quadratic in the ambient length, with constant auxiliary storage; the intended
31    /// performance scope is small dimensions. An angle below the binary64 output
32    /// range can round to zero. See the
33    /// [derivation and limitations](https://github.com/acgetchell/la-stack/blob/main/docs/mathematical_basis.md#unsigned-vector-angles)
34    /// and `REFERENCES.md` \[18–19\].
35    ///
36    /// # Errors
37    /// Validation proceeds in this order:
38    /// - [`LaError::DimensionMismatch`] for unequal lengths (including one empty).
39    /// - [`LaError::NonFinite`] for the first non-finite coordinate, checking
40    ///   `self` before `other`; its input location includes operand/index.
41    /// - [`LaError::EmptyVector`] for two empty inputs.
42    /// - [`LaError::ZeroVector`] for an all-zero operand, `self` before `other`.
43    ///
44    /// A non-finite computed result is reported as [`LaError::NonFinite`] with
45    /// [`ArithmeticOperation::VectorAngle`] computation provenance.
46    ///
47    /// # Examples
48    /// ```
49    /// use la_stack::prelude::*;
50    ///
51    /// # fn main() -> Result<(), LaError> {
52    /// let tiny = f64::from_bits(1);
53    /// let left = [1.0, 0.0, 0.0];
54    /// let right = [1.0, tiny, 0.0];
55    /// assert_eq!(left.as_slice().angle(right.as_slice())?, tiny);
56    /// # Ok(())
57    /// # }
58    /// ```
59    fn angle(&self, other: &Self) -> Result<f64, LaError>;
60}
61
62impl VectorAngle for [f64] {
63    #[inline]
64    fn angle(&self, other: &Self) -> Result<f64, LaError> {
65        if self.len() != other.len() {
66            return Err(LaError::DimensionMismatch {
67                left: self.len(),
68                right: other.len(),
69            });
70        }
71        let left_scale = input_scale(self, VectorOperand::Left)?;
72        let right_scale = input_scale(other, VectorOperand::Right)?;
73        angle_with_scales(self, other, left_scale, right_scale)
74    }
75}
76
77/// Validate one borrowed operand and return its maximum absolute coordinate.
78///
79/// Reject the first non-finite coordinate in index order, preserving its operand
80/// and index in the input error. Empty and all-zero operands return zero here;
81/// [`angle_with_scales`] rejects them after both operands have been scanned so
82/// [`VectorAngle::angle`] reports non-finite inputs before zero-vector errors.
83fn input_scale(values: &[f64], operand: VectorOperand) -> Result<f64, LaError> {
84    let mut scale = 0.0_f64;
85    for (index, &value) in values.iter().enumerate() {
86        if !value.is_finite() {
87            return Err(LaError::NonFinite {
88                location: NonFiniteLocation::VectorOperandEntry { operand, index },
89                origin: NonFiniteOrigin::Input,
90            });
91        }
92        scale = scale.max(value.abs());
93    }
94    Ok(scale)
95}
96
97/// Finite vectors carry coordinate and equal-length proofs across this boundary.
98#[inline]
99pub(crate) fn angle_finite<const D: usize>(
100    left: &Vector<D>,
101    right: &Vector<D>,
102) -> Result<f64, LaError> {
103    let left = left.as_array();
104    let right = right.as_array();
105    let maximum = |values: &[f64; D]| values.iter().fold(0.0_f64, |s, x| s.max(x.abs()));
106    angle_with_scales(left, right, maximum(left), maximum(right))
107}
108
109/// Scale a direction so its largest magnitude is in `[2^500, 2^501)`.
110///
111/// Direct `value / maximum` can discard a half-subnormal component whose
112/// difference from the other direction contributes a representable full angle.
113/// Two power-of-two multiplications cover maxima down to `2^-1074`, preserving
114/// significands unless a component far below the angular output range underflows.
115/// Underflow here affects only components below roughly `2^-1574` relative to
116/// the maximum, far below the output range even after a slice-length reduction.
117struct DirectionScale {
118    first: f64,
119    second: f64,
120}
121
122impl DirectionScale {
123    /// The caller has proved `maximum` finite and strictly positive.
124    fn new(mut maximum: f64) -> Self {
125        let adjustment = if maximum < f64::MIN_POSITIVE {
126            maximum *= f64::from_bits((1023 + 52) << 52);
127            52
128        } else {
129            0
130        };
131        let bits = maximum.to_bits();
132        let exponent = (bits >> 52).cast_signed() - 1023 - adjustment;
133        let shift = 500 - exponent;
134        let first_shift = shift.min(1023);
135        // first_shift is in [-523, 1023]; the remaining shift is in [0, 551].
136        Self {
137            first: f64::from_bits((first_shift + 1023).cast_unsigned() << 52),
138            second: f64::from_bits((shift - first_shift + 1023).cast_unsigned() << 52),
139        }
140    }
141
142    #[inline]
143    fn apply(&self, value: f64) -> f64 {
144        (value * self.first) * self.second
145    }
146}
147
148/// Compensated `a*b - c*d`, symmetric under exchanging the two products.
149///
150/// Both products are bounded by `2^1002` after direction scaling. FMA captures
151/// their residuals (reference \[18\]); `FastTwoSum` captures subtraction roundoff
152/// (reference \[17\]). Relevant tiny minors remain normal at this enlarged scale.
153/// This is an approximate minor, not an exact determinant predicate.
154#[inline]
155fn difference_of_products(a: f64, b: f64, c: f64, d: f64) -> f64 {
156    let left = a * b;
157    let right = c * d;
158    let left_error = a.mul_add(b, -left);
159    let right_error = c.mul_add(d, -right);
160    let difference = left - right;
161    let difference_error = two_sum_error(left, -right, difference);
162    difference + ((left_error - right_error) + difference_error)
163}
164
165/// Finite equal-length inputs and their maximum magnitudes share one kernel.
166fn angle_with_scales(
167    left: &[f64],
168    right: &[f64],
169    left_scale: f64,
170    right_scale: f64,
171) -> Result<f64, LaError> {
172    const SMALL_RATIO: f64 = f64::from_bits((1023 - 27) << 52);
173
174    if left.is_empty() {
175        return Err(LaError::EmptyVector);
176    }
177    for (scale, operand) in [
178        (left_scale, VectorOperand::Left),
179        (right_scale, VectorOperand::Right),
180    ] {
181        if scale == 0.0 {
182            return Err(LaError::ZeroVector { operand });
183        }
184    }
185
186    let left_scale = DirectionScale::new(left_scale);
187    let right_scale = DirectionScale::new(right_scale);
188    // Select once, after validation, to keep the dense inner loop branch-free.
189    let shared_zeros = left.iter().zip(right).any(|(&x, &y)| x == 0.0 && y == 0.0);
190    let (dot, exterior_norm) = if shared_zeros {
191        angle_reductions::<true>(left, right, &left_scale, &right_scale)
192    } else {
193        angle_reductions::<false>(left, right, &left_scale, &right_scale)
194    };
195
196    // Lagrange's identity: ||left ∧ right|| = ||left|| ||right|| sin(θ).
197    // The dot product has the same norm factor times cos(θ). atan2 cancels it
198    // without forming the norm product or halving a subnormal angle.
199    // Some platform atan2 implementations lose subnormal outputs. For
200    // 0 <= r <= 2^-27, 0 <= r - atan(r) <= r^3/3 <= r*2^-55;
201    // direct division retains tiny outputs without a transcendental underflow.
202    let angle = if dot > 0.0 && exterior_norm <= dot * SMALL_RATIO {
203        exterior_norm / dot
204    } else {
205        exterior_norm.atan2(dot)
206    };
207    if angle.is_finite() {
208        Ok(angle)
209    } else {
210        Err(LaError::non_finite_computation_scalar(
211            ArithmeticOperation::VectorAngle,
212        ))
213    }
214}
215
216/// Preserve one arithmetic implementation while specializing shared-zero checks.
217#[inline]
218fn angle_reductions<const SKIP_SHARED_ZEROS: bool>(
219    left: &[f64],
220    right: &[f64],
221    left_scale: &DirectionScale,
222    right_scale: &DirectionScale,
223) -> (f64, f64) {
224    const REDUCE: f64 = f64::from_bits((1023 - 500) << 52);
225
226    let mut dot = 0.0_f64;
227    let mut dot_error = 0.0_f64;
228    let mut exterior_norm = 0.0_f64;
229    for (i, (&left_i, &right_i)) in left.iter().zip(right).enumerate() {
230        // Validation is complete. A shared-zero axis contributes neither a dot
231        // term nor any minor; skip it without reordering the remaining terms.
232        // Compare raw coordinates so scaling underflow cannot erase an axis.
233        if SKIP_SHARED_ZEROS && left_i == 0.0 && right_i == 0.0 {
234            continue;
235        }
236        let left_i = left_scale.apply(left_i);
237        let right_i = right_scale.apply(right_i);
238        // Reduce products before accumulating: even a usize-sized sum and
239        // exterior norm retain ample overflow headroom on 64-bit targets.
240        let product = (left_i * right_i) * REDUCE;
241        let next_dot = dot + product;
242        dot_error += two_sum_error(dot, product, next_dot);
243        dot = next_dot;
244        for (&left_j, &right_j) in left[i + 1..].iter().zip(&right[i + 1..]) {
245            if SKIP_SHARED_ZEROS && left_j == 0.0 && right_j == 0.0 {
246                continue;
247            }
248            let left_j = left_scale.apply(left_j);
249            let right_j = right_scale.apply(right_j);
250            let minor = difference_of_products(left_i, right_j, left_j, right_i);
251            exterior_norm = exterior_norm.hypot(minor * REDUCE);
252        }
253    }
254
255    (dot + dot_error, exterior_norm)
256}