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 REDUCE: f64 = f64::from_bits((1023 - 500) << 52);
173    const SMALL_RATIO: f64 = f64::from_bits((1023 - 27) << 52);
174
175    if left.is_empty() {
176        return Err(LaError::EmptyVector);
177    }
178    for (scale, operand) in [
179        (left_scale, VectorOperand::Left),
180        (right_scale, VectorOperand::Right),
181    ] {
182        if scale == 0.0 {
183            return Err(LaError::ZeroVector { operand });
184        }
185    }
186
187    let left_scale = DirectionScale::new(left_scale);
188    let right_scale = DirectionScale::new(right_scale);
189    let mut dot = 0.0_f64;
190    let mut dot_error = 0.0_f64;
191    let mut exterior_norm = 0.0_f64;
192    for (i, (&left_i, &right_i)) in left.iter().zip(right).enumerate() {
193        let left_i = left_scale.apply(left_i);
194        let right_i = right_scale.apply(right_i);
195        // Reduce products before accumulating: even a usize-sized sum and
196        // exterior norm retain ample overflow headroom on 64-bit targets.
197        let product = (left_i * right_i) * REDUCE;
198        let next_dot = dot + product;
199        dot_error += two_sum_error(dot, product, next_dot);
200        dot = next_dot;
201        for (&left_j, &right_j) in left[i + 1..].iter().zip(&right[i + 1..]) {
202            let left_j = left_scale.apply(left_j);
203            let right_j = right_scale.apply(right_j);
204            let minor = difference_of_products(left_i, right_j, left_j, right_i);
205            exterior_norm = exterior_norm.hypot(minor * REDUCE);
206        }
207    }
208
209    // Lagrange's identity: ||left ∧ right|| = ||left|| ||right|| sin(θ).
210    // The dot product has the same norm factor times cos(θ). atan2 cancels it
211    // without forming the norm product or halving a subnormal angle.
212    let dot = dot + dot_error;
213    // Some platform atan2 implementations lose subnormal outputs. For
214    // 0 <= r <= 2^-27, 0 <= r - atan(r) <= r^3/3 <= r*2^-55;
215    // direct division retains tiny outputs without a transcendental underflow.
216    let angle = if dot > 0.0 && exterior_norm <= dot * SMALL_RATIO {
217        exterior_norm / dot
218    } else {
219        exterior_norm.atan2(dot)
220    };
221    if angle.is_finite() {
222        Ok(angle)
223    } else {
224        Err(LaError::non_finite_computation_scalar(
225            ArithmeticOperation::VectorAngle,
226        ))
227    }
228}