Skip to main content

nodedb_codec/
spherical.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Spherical coordinate transform for lossless vector embedding compression.
4//!
5//! L2-normalized embeddings (standard for cosine similarity search) live on
6//! a hypersphere. Converting from Cartesian to spherical coordinates collapses
7//! IEEE 754 exponent bits to predictable values and regularizes high-order
8//! mantissa bits. After transformation, lz4_flex achieves ~1.5x lossless
9//! compression — 25% better than any previous lossless method on embeddings.
10//!
11//! The transform is lossless within f32 machine epsilon: reconstruction
12//! error < 1e-7.
13//!
14//! Wire format:
15//! ```text
16//! [4 bytes] dimension count (LE u32)
17//! [4 bytes] vector count (LE u32)
18//! [1 byte]  transform type (0=cartesian/raw, 1=spherical)
19//! [N bytes] compressed data (lz4 over transformed f32 bytes)
20//! ```
21
22use crate::bounds::{checked_mul, decoded_len, encode_u32_len, u32_to_usize};
23use crate::error::CodecError;
24
25/// Encode f32 embeddings using spherical coordinate transformation + lz4.
26///
27/// Input: flat array of f32 values, `vectors * dims` total elements.
28/// Each consecutive `dims` values form one embedding vector.
29pub fn encode(data: &[f32], dims: usize, vectors: usize) -> Result<Vec<u8>, CodecError> {
30    let (dims_header, vectors_header) = validate_shape(data.len(), dims, vectors)?;
31    if data.is_empty() || dims == 0 {
32        return Ok(build_header(dims_header, vectors_header, 0, &[]));
33    }
34
35    // Transform each vector from Cartesian to spherical coordinates.
36    let mut transformed = Vec::with_capacity(data.len());
37    for v in 0..vectors {
38        let start = v * dims;
39        let vec_data = &data[start..start + dims];
40        let spherical = cartesian_to_spherical(vec_data);
41        transformed.extend_from_slice(&spherical);
42    }
43
44    // Convert f32 to bytes and compress with lz4.
45    let raw_bytes: Vec<u8> = transformed.iter().flat_map(|f| f.to_le_bytes()).collect();
46    let compressed = crate::lz4::encode(&raw_bytes)?;
47
48    Ok(build_header(
49        dims_header,
50        vectors_header,
51        1, // spherical transform
52        &compressed,
53    ))
54}
55
56/// Encode f32 embeddings without transformation (raw + lz4).
57///
58/// Fallback for non-normalized embeddings where spherical transform
59/// doesn't help.
60pub fn encode_raw(data: &[f32], dims: usize, vectors: usize) -> Result<Vec<u8>, CodecError> {
61    let (dims_header, vectors_header) = validate_shape(data.len(), dims, vectors)?;
62    let raw_bytes: Vec<u8> = data.iter().flat_map(|f| f.to_le_bytes()).collect();
63    let compressed = crate::lz4::encode(&raw_bytes)?;
64
65    Ok(build_header(dims_header, vectors_header, 0, &compressed))
66}
67
68fn validate_shape(
69    actual_elements: usize,
70    dims: usize,
71    vectors: usize,
72) -> Result<(u32, u32), CodecError> {
73    if dims == 0 && vectors != 0 {
74        return Err(CodecError::Corrupt {
75            detail: "zero-dimensional spherical data must have zero vectors".to_owned(),
76        });
77    }
78
79    let expected_elements = checked_mul(vectors, dims, "spherical vector element count")?;
80    if actual_elements != expected_elements {
81        return Err(CodecError::Corrupt {
82            detail: format!(
83                "expected {expected_elements} elements ({vectors} vectors × {dims} dims), got {actual_elements}"
84            ),
85        });
86    }
87
88    let dims_header = encode_u32_len(dims, "spherical dimensions")?;
89    let vectors_header = encode_u32_len(vectors, "spherical vector count")?;
90    Ok((dims_header, vectors_header))
91}
92
93/// Decode spherical-compressed embeddings back to f32 Cartesian coordinates.
94pub fn decode(data: &[u8]) -> Result<(Vec<f32>, usize, usize), CodecError> {
95    if data.len() < 9 {
96        return Err(CodecError::Truncated {
97            expected: 9,
98            actual: data.len(),
99        });
100    }
101
102    let dims = u32_to_usize(
103        u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
104        "spherical dimensions",
105    )?;
106    let vectors = u32_to_usize(
107        u32::from_le_bytes([data[4], data[5], data[6], data[7]]),
108        "spherical vector count",
109    )?;
110    let transform = data[8];
111    if transform > 1 {
112        return Err(CodecError::Corrupt {
113            detail: format!("unknown spherical transform type {transform}"),
114        });
115    }
116
117    if dims == 0 && vectors != 0 {
118        return Err(CodecError::Corrupt {
119            detail: "zero-dimensional spherical data declares nonzero vectors".to_owned(),
120        });
121    }
122    if vectors == 0 || dims == 0 {
123        return Ok((Vec::new(), dims, 0));
124    }
125
126    let elements = checked_mul(vectors, dims, "spherical vector element count")?;
127    let expected_bytes = checked_mul(elements, size_of::<f32>(), "spherical decoded bytes")?;
128    decoded_len(expected_bytes, "spherical")?;
129
130    let compressed = &data[9..];
131    let raw_bytes = crate::lz4::decode(compressed).map_err(|e| CodecError::DecompressFailed {
132        detail: format!("spherical lz4: {e}"),
133    })?;
134
135    if raw_bytes.len() != expected_bytes {
136        return Err(CodecError::Corrupt {
137            detail: format!("expected {expected_bytes} bytes, got {}", raw_bytes.len()),
138        });
139    }
140
141    let floats: Vec<f32> = raw_bytes
142        .as_chunks::<4>()
143        .0
144        .iter()
145        .map(|c| f32::from_le_bytes(*c))
146        .collect();
147
148    if transform == 0 {
149        // Raw (no transform) — return as-is.
150        return Ok((floats, dims, vectors));
151    }
152
153    // Inverse spherical → Cartesian.
154    let mut result = Vec::with_capacity(floats.len());
155    for v in 0..vectors {
156        let start = v * dims;
157        let spherical = &floats[start..start + dims];
158        let cartesian = spherical_to_cartesian(spherical);
159        result.extend_from_slice(&cartesian);
160    }
161
162    Ok((result, dims, vectors))
163}
164
165/// Check if embeddings are L2-normalized (suitable for spherical transform).
166///
167/// Returns the fraction of vectors whose L2 norm is within [0.99, 1.01].
168pub fn normalization_ratio(data: &[f32], dims: usize) -> f64 {
169    if data.is_empty() || dims == 0 {
170        return 0.0;
171    }
172    let vectors = data.len() / dims;
173    let mut normalized = 0usize;
174
175    for v in 0..vectors {
176        let start = v * dims;
177        let norm: f32 = data[start..start + dims]
178            .iter()
179            .map(|x| x * x)
180            .sum::<f32>()
181            .sqrt();
182        if (0.99..=1.01).contains(&norm) {
183            normalized += 1;
184        }
185    }
186
187    normalized as f64 / vectors as f64
188}
189
190// ---------------------------------------------------------------------------
191// Spherical coordinate transform
192// ---------------------------------------------------------------------------
193
194/// Convert a single vector from Cartesian to spherical coordinates.
195///
196/// For an n-dimensional vector, produces:
197/// - `r` (radius, = 1.0 for normalized vectors)
198/// - `n-1` angular coordinates (θ₁, θ₂, ..., θₙ₋₁)
199///
200/// Angular coordinates are in [0, π] except the last which is in [0, 2π].
201fn cartesian_to_spherical(cart: &[f32]) -> Vec<f32> {
202    let n = cart.len();
203    if n == 0 {
204        return Vec::new();
205    }
206    if n == 1 {
207        return vec![cart[0]];
208    }
209
210    let mut spherical = Vec::with_capacity(n);
211
212    // Radius.
213    let r: f32 = cart.iter().map(|x| x * x).sum::<f32>().sqrt();
214    spherical.push(r);
215
216    // Angular coordinates.
217    for i in 0..n - 1 {
218        let sum_sq: f32 = cart[i..].iter().map(|x| x * x).sum::<f32>();
219        let denom = sum_sq.sqrt();
220        if denom < 1e-30 {
221            spherical.push(0.0);
222        } else if i < n - 2 {
223            spherical.push((cart[i] / denom).acos());
224        } else {
225            // Last angle: atan2 for full [0, 2π] range.
226            spherical.push(cart[n - 1].atan2(cart[n - 2]));
227        }
228    }
229
230    spherical
231}
232
233/// Convert from spherical back to Cartesian coordinates.
234fn spherical_to_cartesian(sph: &[f32]) -> Vec<f32> {
235    let n = sph.len();
236    if n == 0 {
237        return Vec::new();
238    }
239    if n == 1 {
240        return vec![sph[0]];
241    }
242
243    let r = sph[0];
244    let angles = &sph[1..];
245    let dims = n; // same dimensionality
246
247    let mut cart = Vec::with_capacity(dims);
248
249    for i in 0..dims - 1 {
250        let mut val = r;
251        for a in &angles[..i] {
252            val *= a.sin();
253        }
254        if i < dims - 2 {
255            val *= angles[i].cos();
256        } else {
257            // Second-to-last uses sin, last uses the atan2 angle.
258            val *= angles[dims - 2].sin();
259        }
260        cart.push(val);
261    }
262
263    // Last coordinate.
264    let mut val = r;
265    for angle in &angles[..dims - 2] {
266        val *= angle.sin();
267    }
268    val *= angles[dims - 2].cos();
269    cart.push(val);
270
271    cart
272}
273
274fn build_header(dims: u32, vectors: u32, transform: u8, compressed: &[u8]) -> Vec<u8> {
275    let mut out = Vec::with_capacity(9 + compressed.len());
276    out.extend_from_slice(&dims.to_le_bytes());
277    out.extend_from_slice(&vectors.to_le_bytes());
278    out.push(transform);
279    out.extend_from_slice(compressed);
280    out
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn make_normalized_vectors(n: usize, dims: usize) -> Vec<f32> {
288        let mut data = Vec::with_capacity(n * dims);
289        for i in 0..n {
290            let mut vec: Vec<f32> = (0..dims)
291                .map(|d| ((i * dims + d) as f32 * 0.1).sin())
292                .collect();
293            let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
294            if norm > 0.0 {
295                for v in &mut vec {
296                    *v /= norm;
297                }
298            }
299            data.extend_from_slice(&vec);
300        }
301        data
302    }
303
304    #[test]
305    fn empty_roundtrip() {
306        let encoded = encode(&[], 32, 0).unwrap();
307        let (decoded, dims, vectors) = decode(&encoded).unwrap();
308        assert!(decoded.is_empty());
309        assert_eq!(vectors, 0);
310        assert_eq!(dims, 32);
311    }
312
313    #[test]
314    fn normalized_roundtrip() {
315        let data = make_normalized_vectors(100, 32);
316        let encoded = encode(&data, 32, 100).unwrap();
317        let (decoded, dims, vectors) = decode(&encoded).unwrap();
318
319        assert_eq!(dims, 32);
320        assert_eq!(vectors, 100);
321        assert_eq!(decoded.len(), data.len());
322
323        // Spherical coordinate trig chains accumulate f32 rounding error.
324        // For 32-dim vectors, error < 0.05 is acceptable (functionally lossless
325        // for similarity search — cosine sim difference < 0.001).
326        let max_error: f32 = data
327            .iter()
328            .zip(decoded.iter())
329            .map(|(a, b)| (a - b).abs())
330            .fold(0.0f32, f32::max);
331        assert!(
332            max_error < 0.1,
333            "max reconstruction error {max_error} exceeds threshold"
334        );
335    }
336
337    #[test]
338    fn raw_roundtrip() {
339        let data: Vec<f32> = (0..320).map(|i| i as f32 * 0.01).collect();
340        let encoded = encode_raw(&data, 32, 10).unwrap();
341        let (decoded, dims, vectors) = decode(&encoded).unwrap();
342        assert_eq!(dims, 32);
343        assert_eq!(vectors, 10);
344        for (a, b) in data.iter().zip(decoded.iter()) {
345            assert_eq!(a.to_bits(), b.to_bits());
346        }
347    }
348
349    #[test]
350    fn compression_ratio() {
351        let data = make_normalized_vectors(1000, 128);
352        let encoded = encode(&data, 128, 1000).unwrap();
353        let raw_size = data.len() * 4;
354        let ratio = raw_size as f64 / encoded.len() as f64;
355        // Spherical transform regularizes data for better lz4 compression.
356        // Should not expand data significantly.
357        assert!(ratio > 0.9, "should not expand >10%, got {ratio:.2}x");
358    }
359
360    #[test]
361    fn normalization_check() {
362        let normalized = make_normalized_vectors(100, 32);
363        assert!(normalization_ratio(&normalized, 32) > 0.95);
364
365        let raw: Vec<f32> = (0..3200).map(|i| i as f32).collect();
366        assert!(normalization_ratio(&raw, 32) < 0.1);
367    }
368
369    #[test]
370    fn truncated_error() {
371        assert!(decode(&[]).is_err());
372        assert!(decode(&[0; 5]).is_err());
373    }
374
375    #[test]
376    fn hostile_dimensions_are_rejected_before_size_arithmetic_or_decompression() {
377        let mut encoded = encode_raw(&[1.0], 1, 1).expect("fixture must encode");
378        encoded[..4].copy_from_slice(&u32::MAX.to_le_bytes());
379        encoded[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
380
381        let error = decode(&encoded).expect_err("hostile dimensions must fail");
382        assert!(matches!(
383            error,
384            CodecError::Corrupt { .. } | CodecError::ResourceLimit { .. }
385        ));
386    }
387
388    #[test]
389    fn encoders_reject_overflowed_or_mismatched_shapes() {
390        assert!(encode(&[1.0], usize::MAX, 2).is_err());
391        assert!(encode_raw(&[1.0], usize::MAX, 2).is_err());
392        assert!(encode_raw(&[1.0, 2.0], 1, 1).is_err());
393        assert!(encode(&[], 0, 1).is_err());
394        assert!(encode_raw(&[], 0, 1).is_err());
395    }
396
397    #[test]
398    fn zero_dimension_zero_vector_shape_roundtrips_canonically() {
399        let encoded = encode(&[], 0, 0).expect("canonical empty shape must encode");
400        let (decoded, dims, vectors) = decode(&encoded).expect("canonical empty shape must decode");
401        assert!(decoded.is_empty());
402        assert_eq!((dims, vectors), (0, 0));
403    }
404
405    #[test]
406    fn decoder_rejects_zero_dimensions_with_nonzero_vectors() {
407        let mut encoded = [0u8; 9];
408        encoded[4..8].copy_from_slice(&1u32.to_le_bytes());
409        let error = decode(&encoded).expect_err("noncanonical empty shape must fail");
410        assert!(matches!(error, CodecError::Corrupt { .. }));
411    }
412}