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}