Skip to main content

polydat_nodes/
vector_math.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Element-wise vector math nodes (type_system_alignment.md §8.2).
5//!
6//! These are the first compute-shaped (rather than I/O-shaped)
7//! consumers of the typed-vector family: perturbing query vectors,
8//! computing ground-truth distances in evaluations, normalizing
9//! embeddings. On `jit` builds the f32 hot loops execute through
10//! the cranelift-SIMD kernels (`compile::jit::simd` — F32X4
11//! chunked with scalar tails); without `jit`, or if host ISA
12//! construction fails, the scalar reference loops below run
13//! instead. SIMD accumulation reassociates float addition, so dot
14//! products may differ from the scalar reference in the final
15//! ulps — both orders are equally valid IEEE 754 sums.
16//!
17//! Length mismatches panic with both lengths named: silently
18//! truncating to the shorter operand would corrupt distance
19//! semantics ("never ignore silently").
20
21pub use polydat::numeric::vector::{
22    add_f32, add_f32_into, check_lens, cosine_f32, dot_f32, dot_scalar, hash_vec_into, l2sq_f32,
23    l2sq_scalar, lid_mle_of, norm_f32_into, scale_f32, scale_f32_into, xxhash3_vec_into,
24};
25
26// ── Library nodes ──────────────────────────────────────────────
27
28/// `vec_add(a, b)` — element-wise sum of two f32 vectors.
29/// Panics when the lengths differ.
30#[polydat::polydat_node(category = Arithmetic)]
31fn vec_add(a: &[f32], b: &[f32]) -> Vec<f32> {
32    check_lens("vec_add", a.len(), b.len());
33    add_f32(a, b)
34}
35
36/// `vec_scale(a, k)` — multiply every element of an f32 vector by
37/// scalar `k` (applied at f32 precision).
38#[polydat::polydat_node(category = Arithmetic)]
39fn vec_scale(a: &[f32], k: f64) -> Vec<f32> {
40    scale_f32(a, k as f32)
41}
42
43/// `vec_dot(a, b)` — dot product of two f32 vectors, widened to
44/// f64 on the output wire. Panics when the lengths differ.
45#[polydat::polydat_node(category = Arithmetic)]
46fn vec_dot(a: &[f32], b: &[f32]) -> f64 {
47    check_lens("vec_dot", a.len(), b.len());
48    dot_f32(a, b) as f64
49}
50
51/// `vec_l2(a, b)` — Euclidean (L2) distance between two f32
52/// vectors. Panics when the lengths differ.
53#[polydat::polydat_node(category = Arithmetic)]
54fn vec_l2(a: &[f32], b: &[f32]) -> f64 {
55    check_lens("vec_l2", a.len(), b.len());
56    (l2sq_f32(a, b) as f64).sqrt()
57}
58
59/// `vec_cosine(a, b)` — cosine similarity of two f32 vectors:
60/// `dot(a,b) / (|a| * |b|)`. Returns 0.0 when either vector has
61/// zero magnitude (the conventional degenerate-case value: no
62/// direction, no similarity). Panics when the lengths differ.
63#[polydat::polydat_node(category = Arithmetic)]
64fn vec_cosine(a: &[f32], b: &[f32]) -> f64 {
65    check_lens("vec_cosine", a.len(), b.len());
66    cosine_f32(a, b)
67}
68
69/// `vec_norm(a)` — scale an f32 vector to unit L2 magnitude.
70/// A zero vector passes through unchanged (there is no direction
71/// to normalize onto, and emitting NaNs would poison downstream
72/// distance math silently).
73#[polydat::polydat_node(category = Arithmetic)]
74fn vec_norm(a: &[f32]) -> Vec<f32> {
75    let mut out = Vec::new();
76    norm_f32_into(a, &mut out);
77    out
78}
79
80/// `lid_mle(distances, k)` — Levina–Bickel maximum-likelihood estimate of
81/// the **local intrinsic dimensionality** at one query point, from its
82/// sorted ground-truth nearest-neighbor distances.
83///
84/// Given ascending distances `r_1 ≤ … ≤ r_k` to the `k` nearest neighbors,
85///
86/// ```text
87///     d̂ = m / Σ_{j=1..m} ln(r_k / r_j)        (m = number of valid terms)
88/// ```
89///
90/// the standard MLE (Levina & Bickel, NIPS 2004) with `r_k` as the cutoff
91/// radius. `k` is clamped to the available length. Terms with a
92/// non-positive `r_j` (exact duplicates / self-matches at distance 0, where
93/// `ln(r_k/r_j)` is undefined) are skipped — the conventional handling.
94/// Returns `0.0` for a degenerate query (fewer than one valid term, or a
95/// non-positive log-sum, e.g. all-duplicate neighbors) so the caller can
96/// filter it out of the aggregate.
97///
98/// Reporting the *distribution* of this per-query estimate (mean / p50 /
99/// p90) characterizes both the intrinsic dimension and its heterogeneity —
100/// the quantity that governs whether a 1-D locality ordering can work (a
101/// low, tight LID favors it; a high or skewed LID does not).
102///
103/// ASSUMES `distances` are true metric distances in ascending order (the
104/// dataset's `neighbor_distances` / `filtered_neighbor_distances` facet).
105/// If that facet stores *squared* distances the estimate is scaled by ½
106/// (double it); if it stores *similarities* (higher = closer) the result is
107/// meaningless — validate against the dataset's metric first.
108#[polydat::polydat_node(category = Arithmetic)]
109fn lid_mle(distances: &[f32], k: f64) -> f64 {
110    lid_mle_of(distances, k)
111}
112
113/// `hash_vec(seed, dim)` — deterministic synthetic f32 vector:
114/// element `i` is the SplitMix64 hash of `(seed, i)` mapped into
115/// `[-1, 1)`. The canonical generator for synthetic embeddings —
116/// equal seeds always produce the identical vector, so dataset-
117/// free vector workloads stay replayable. Pairs with `vec_norm`
118/// for unit vectors.
119#[polydat::polydat_node(category = Hashing)]
120fn hash_vec(seed: u64, dim: u64) -> Vec<f32> {
121    let mut out = Vec::new();
122    hash_vec_into(seed, dim, &mut out);
123    out
124}
125
126/// `xxhash3_vec(seed, dim)` — deterministic synthetic f32 vector using xxHash3.
127#[polydat::polydat_node(category = Hashing)]
128fn xxhash3_vec(seed: u64, dim: u64) -> Vec<f32> {
129    let mut out = Vec::new();
130    xxhash3_vec_into(seed, dim, &mut out);
131    out
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use polydat::ast::{PolydatNode, SliceArc, Value};
138
139    fn vecv(v: Vec<f32>) -> Value {
140        Value::VecF32(SliceArc::from_vec(v))
141    }
142
143    fn test_vec(n: usize, seed: u64) -> Vec<f32> {
144        (0..n)
145            .map(|i| {
146                let h = xxhash_rust::xxh3::xxh3_64(&(seed ^ i as u64).to_le_bytes());
147                (h as f64 / u64::MAX as f64 * 2.0 - 1.0) as f32
148            })
149            .collect()
150    }
151
152    fn eval2<N: PolydatNode>(node: &N, a: Vec<f32>, b: Vec<f32>) -> Value {
153        let mut out = [Value::None];
154        node.eval(&[vecv(a), vecv(b)], &mut out);
155        out[0].clone()
156    }
157
158    #[test]
159    fn vec_math_matches_scalar_reference() {
160        // 1029 = 257 SIMD chunks + 1 tail element.
161        let a = test_vec(1029, 1);
162        let b = test_vec(1029, 2);
163
164        let dot = eval2(&VecDot::new(), a.clone(), b.clone()).as_f64();
165        let dot_ref = dot_scalar(&a, &b) as f64;
166        assert!((dot - dot_ref).abs() / dot_ref.abs().max(1e-6) < 1e-4);
167
168        let l2 = eval2(&VecL2::new(), a.clone(), b.clone()).as_f64();
169        let l2_ref = (l2sq_scalar(&a, &b) as f64).sqrt();
170        assert!((l2 - l2_ref).abs() / l2_ref.max(1e-6) < 1e-4);
171
172        let sum = eval2(&VecAdd::new(), a.clone(), b.clone());
173        let sum = sum.as_vec_f32();
174        for i in 0..a.len() {
175            assert_eq!(sum[i], a[i] + b[i], "vec_add lane {i}");
176        }
177
178        let cos_self = eval2(&VecCosine::new(), a.clone(), a.clone()).as_f64();
179        assert!((cos_self - 1.0).abs() < 1e-4, "self-cosine = {cos_self}");
180    }
181
182    #[test]
183    fn vec_scale_and_norm() {
184        let a = test_vec(37, 3);
185        let mut out = [Value::None];
186        VecScale::new().eval(&[vecv(a.clone()), Value::F64(2.0)], &mut out);
187        let scaled = out[0].as_vec_f32();
188        for i in 0..a.len() {
189            assert_eq!(scaled[i], a[i] * 2.0, "vec_scale lane {i}");
190        }
191
192        let mut out = [Value::None];
193        VecNorm::new().eval(&[vecv(a.clone())], &mut out);
194        let unit = out[0].as_vec_f32().to_vec();
195        let mag = (dot_scalar(&unit, &unit) as f64).sqrt();
196        assert!((mag - 1.0).abs() < 1e-4, "norm magnitude = {mag}");
197
198        // Zero vector passes through unchanged.
199        let mut out = [Value::None];
200        VecNorm::new().eval(&[vecv(vec![0.0; 4])], &mut out);
201        assert_eq!(out[0].as_vec_f32(), &[0.0, 0.0, 0.0, 0.0]);
202    }
203
204    #[test]
205    fn lid_mle_matches_closed_form_and_handles_degenerate() {
206        // r_j = e^{j}, j=0..9 → ln(r_k/r_j) = 9-j; Σ_{j=0}^{8}(9-j)=45,
207        // 9 valid terms → d̂ = 9/45 = 0.2. Deterministic arithmetic check.
208        let dists: Vec<f32> = (0..10).map(|j| (j as f32).exp()).collect();
209        let mut out = [Value::None];
210        LidMle::new().eval(&[vecv(dists), Value::F64(10.0)], &mut out);
211        assert!(
212            (out[0].as_f64() - 0.2).abs() < 1e-4,
213            "got {}",
214            out[0].as_f64()
215        );
216
217        // Fewer than 2 distances → degenerate → 0.0.
218        let mut out = [Value::None];
219        LidMle::new().eval(&[vecv(vec![1.0]), Value::F64(10.0)], &mut out);
220        assert_eq!(out[0].as_f64(), 0.0);
221
222        // All-zero (duplicate) neighbors, r_k = 0 → degenerate → 0.0.
223        let mut out = [Value::None];
224        LidMle::new().eval(&[vecv(vec![0.0, 0.0, 0.0]), Value::F64(3.0)], &mut out);
225        assert_eq!(out[0].as_f64(), 0.0);
226
227        // Zero-distance self-match at r_1 is skipped, not fatal: distances
228        // [0, e, e^2] with k=3 → r_k=e^2 (ln=2), only r_2=e (ln=1) valid →
229        // 1 term, logsum=1 → d̂ = 1.0.
230        let mut out = [Value::None];
231        let d: Vec<f32> = vec![
232            0.0,
233            std::f32::consts::E,
234            std::f32::consts::E * std::f32::consts::E,
235        ];
236        LidMle::new().eval(&[vecv(d), Value::F64(3.0)], &mut out);
237        assert!(
238            (out[0].as_f64() - 1.0).abs() < 1e-4,
239            "got {}",
240            out[0].as_f64()
241        );
242    }
243
244    #[test]
245    fn hash_vec_is_deterministic_and_seed_sensitive() {
246        let mut out = [Value::None];
247        HashVec::new().eval(&[Value::U64(7), Value::U64(16)], &mut out);
248        let v1 = out[0].as_vec_f32().to_vec();
249        let mut out = [Value::None];
250        HashVec::new().eval(&[Value::U64(7), Value::U64(16)], &mut out);
251        assert_eq!(v1, out[0].as_vec_f32(), "same seed must reproduce");
252        let mut out = [Value::None];
253        HashVec::new().eval(&[Value::U64(8), Value::U64(16)], &mut out);
254        assert_ne!(v1, out[0].as_vec_f32(), "different seed must differ");
255        assert_eq!(v1.len(), 16);
256        assert!(v1.iter().all(|x| (-1.0..1.0).contains(x)));
257    }
258
259    /// §8.4 layer 3 end-to-end: a vector dataflow (synthetic
260    /// producer → element-wise math → horizontal reduce) rides
261    /// compiled kernels via the (ptr, len) slot protocol with
262    /// kernel-owned scratch, and must match typed eval. The same
263    /// flow through the hybrid kernel runs the scalar segments as
264    /// native JIT and the slice ops as slot closures (whose
265    /// bodies already execute the cranelift-SIMD kernels).
266    #[test]
267    fn vec_flow_rides_compiled_kernels() {
268        let src = r#"
269            input cycle: u64
270            a := hash_vec(cycle, 37)
271            b := hash_vec(hash(cycle), 37)
272            s := vec_add(a, b)
273            out := vec_dot(s, b)
274        "#;
275        let mut p1 = polydat::dsl::compile_polydat(src).unwrap();
276
277        let asm = polydat::dsl::compile::compile_polydat_to_assembler(src).unwrap();
278        let mut p2 = asm
279            .try_compile_raw()
280            .expect("slice-bearing nodes are P2-eligible via compiled_slot");
281
282        for cycle in [0u64, 7, 0xFEED] {
283            p1.set_inputs(&[cycle]);
284            let want = p1.pull("out").as_f64();
285            let slot = p2.resolve_output("out").unwrap();
286            let got = f64::from_bits(p2.eval_for_slot(&[cycle], slot));
287            assert_eq!(got, want, "P2 vec flow mismatch at cycle={cycle}");
288        }
289
290        #[cfg(feature = "jit")]
291        {
292            let asm = polydat::dsl::compile::compile_polydat_to_assembler(src).unwrap();
293            let mut hy = asm.compile_hybrid().unwrap();
294            for cycle in [0u64, 7, 0xFEED] {
295                p1.set_inputs(&[cycle]);
296                let want = p1.pull("out").as_f64();
297                let slot = hy.resolve_output("out").unwrap();
298                hy.eval(&[cycle]);
299                let got = f64::from_bits(hy.get_slot(slot));
300                assert_eq!(got, want, "hybrid vec flow mismatch at cycle={cycle}");
301            }
302        }
303    }
304
305    /// Scratch reuse: re-evaluating the same compiled kernel must
306    /// republish coherent (ptr, len) views even as vector contents
307    /// change cycle to cycle.
308    #[test]
309    fn vec_scratch_reuses_across_evals() {
310        let src = r#"
311            input cycle: u64
312            a := hash_vec(cycle, 16)
313            out := vec_l2(a, vec_scale(a, 2.0))
314        "#;
315        let mut p1 = polydat::dsl::compile_polydat(src).unwrap();
316        let asm = polydat::dsl::compile::compile_polydat_to_assembler(src).unwrap();
317        let mut p2 = asm.try_compile_raw().expect("P2-eligible");
318        let slot = p2.resolve_output("out").unwrap();
319        // Interleave cycles so stale-scratch bugs would surface as
320        // cross-cycle contamination.
321        for cycle in [1u64, 9, 1, 42, 9, 1] {
322            p1.set_inputs(&[cycle]);
323            let want = p1.pull("out").as_f64();
324            let got = f64::from_bits(p2.eval_for_slot(&[cycle], slot));
325            assert_eq!(got, want, "scratch reuse mismatch at cycle={cycle}");
326        }
327    }
328
329    #[test]
330    fn vec_length_mismatch_panics() {
331        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
332            let mut out = [Value::None];
333            VecDot::new().eval(&[vecv(vec![1.0]), vecv(vec![1.0, 2.0])], &mut out);
334        }));
335        assert!(r.is_err(), "vec_dot accepted mismatched lengths");
336    }
337}