1use serde::{Deserialize, Serialize};
17
18use crate::error::{VectorError, check_dim};
19
20pub const MAGIC: &[u8; 6] = b"NDSQ\0\0";
24
25pub const SQ8_FORMAT_VERSION: u8 = 1;
27
28#[derive(Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack)]
30pub struct Sq8Codec {
31 pub dim: usize,
32 mins: Vec<f32>,
34 maxs: Vec<f32>,
36 scales: Vec<f32>,
39 inv_scales: Vec<f32>,
41}
42
43impl Sq8Codec {
44 pub fn calibrate(vectors: &[&[f32]], dim: usize) -> Result<Self, VectorError> {
55 if vectors.is_empty() {
56 return Err(VectorError::InvalidInput {
57 detail: "SQ8 calibration needs at least one vector".into(),
58 });
59 }
60 if dim == 0 {
61 return Err(VectorError::InvalidInput {
62 detail: "SQ8 calibration needs a non-zero dimension".into(),
63 });
64 }
65 for v in vectors {
66 check_dim(dim, v.len())?;
67 }
68
69 let mut mins = vec![f32::MAX; dim];
70 let mut maxs = vec![f32::MIN; dim];
71
72 for v in vectors {
73 for d in 0..dim {
74 if v[d] < mins[d] {
75 mins[d] = v[d];
76 }
77 if v[d] > maxs[d] {
78 maxs[d] = v[d];
79 }
80 }
81 }
82
83 let mut scales = vec![0.0f32; dim];
84 let mut inv_scales = vec![0.0f32; dim];
85 for d in 0..dim {
86 let range = maxs[d] - mins[d];
87 if range > f32::EPSILON {
88 scales[d] = range / 255.0;
89 inv_scales[d] = 255.0 / range;
90 }
91 }
92
93 Ok(Self {
94 dim,
95 mins,
96 maxs,
97 scales,
98 inv_scales,
99 })
100 }
101
102 pub fn unit_range(dim: usize) -> Self {
105 Self {
106 dim,
107 mins: vec![0.0; dim],
108 maxs: vec![1.0; dim],
109 scales: vec![1.0 / 255.0; dim],
110 inv_scales: vec![255.0; dim],
111 }
112 }
113
114 pub fn quantize(&self, vector: &[f32]) -> Vec<u8> {
116 debug_assert_eq!(vector.len(), self.dim);
117 let mut out = Vec::with_capacity(self.dim);
118 for ((&v, &min), (&max, &inv_scale)) in vector
119 .iter()
120 .zip(self.mins.iter())
121 .zip(self.maxs.iter().zip(self.inv_scales.iter()))
122 {
123 let clamped = v.clamp(min, max);
124 let q = ((clamped - min) * inv_scale).round() as u8;
125 out.push(q);
126 }
127 out
128 }
129
130 pub fn quantize_batch(&self, vectors: &[&[f32]]) -> Vec<u8> {
134 let mut out = Vec::with_capacity(self.dim * vectors.len());
135 for v in vectors {
136 out.extend(self.quantize(v));
137 }
138 out
139 }
140
141 pub fn dequantize(&self, quantized: &[u8]) -> Vec<f32> {
143 debug_assert_eq!(quantized.len(), self.dim);
144 let mut out = Vec::with_capacity(self.dim);
145 for ((&q, &min), &scale) in quantized
146 .iter()
147 .zip(self.mins.iter())
148 .zip(self.scales.iter())
149 {
150 out.push(min + q as f32 * scale);
151 }
152 out
153 }
154
155 #[inline]
160 pub fn asymmetric_l2(&self, query: &[f32], candidate: &[u8]) -> f32 {
161 debug_assert_eq!(query.len(), self.dim);
162 debug_assert_eq!(candidate.len(), self.dim);
163 let mut sum = 0.0f32;
164 for d in 0..self.dim {
165 let dequant = self.mins[d] + candidate[d] as f32 * self.scales[d];
166 let diff = query[d] - dequant;
167 sum += diff * diff;
168 }
169 sum
170 }
171
172 #[inline]
174 pub fn asymmetric_cosine(&self, query: &[f32], candidate: &[u8]) -> f32 {
175 debug_assert_eq!(query.len(), self.dim);
176 debug_assert_eq!(candidate.len(), self.dim);
177 let mut dot = 0.0f32;
178 let mut norm_q = 0.0f32;
179 let mut norm_c = 0.0f32;
180 for d in 0..self.dim {
181 let dequant = self.mins[d] + candidate[d] as f32 * self.scales[d];
182 dot += query[d] * dequant;
183 norm_q += query[d] * query[d];
184 norm_c += dequant * dequant;
185 }
186 let denom = (norm_q * norm_c).sqrt();
187 if denom < f32::EPSILON {
188 return 1.0;
189 }
190 (1.0 - dot / denom).max(0.0)
191 }
192
193 #[inline]
195 pub fn asymmetric_ip(&self, query: &[f32], candidate: &[u8]) -> f32 {
196 debug_assert_eq!(query.len(), self.dim);
197 debug_assert_eq!(candidate.len(), self.dim);
198 let mut dot = 0.0f32;
199 for d in 0..self.dim {
200 let dequant = self.mins[d] + candidate[d] as f32 * self.scales[d];
201 dot += query[d] * dequant;
202 }
203 -dot
204 }
205
206 pub fn dim(&self) -> usize {
208 self.dim
209 }
210
211 pub fn to_bytes(&self) -> Vec<u8> {
215 let payload = zerompk::to_msgpack_vec(self).unwrap_or_default();
216 let mut out = Vec::with_capacity(7 + payload.len());
217 out.extend_from_slice(MAGIC);
218 out.push(SQ8_FORMAT_VERSION);
219 out.extend_from_slice(&payload);
220 out
221 }
222
223 pub fn from_bytes(bytes: &[u8]) -> Result<Self, VectorError> {
228 if bytes.len() < 7 || &bytes[0..6] != MAGIC {
229 return Err(VectorError::InvalidMagic);
230 }
231 let version = bytes[6];
232 if version != SQ8_FORMAT_VERSION {
233 return Err(VectorError::UnsupportedVersion {
234 found: version,
235 expected: SQ8_FORMAT_VERSION,
236 });
237 }
238 zerompk::from_msgpack::<Self>(&bytes[7..])
239 .map_err(|e| VectorError::DeserializationFailed(e.to_string()))
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn make_codec() -> Sq8Codec {
248 let vecs: Vec<Vec<f32>> = (0..100)
249 .map(|i| vec![i as f32 * 0.1, (i as f32).sin(), (i as f32).cos()])
250 .collect();
251 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
252 Sq8Codec::calibrate(&refs, 3).unwrap()
253 }
254
255 #[test]
256 fn sq8_codec_golden_format() {
257 let codec = make_codec();
258 let bytes = codec.to_bytes();
259 assert_eq!(&bytes[0..6], MAGIC);
261 assert_eq!(bytes[6], SQ8_FORMAT_VERSION);
263 let decoded = zerompk::from_msgpack::<Sq8Codec>(&bytes[7..]).unwrap();
265 assert_eq!(decoded.dim, 3);
266 }
267
268 #[test]
269 fn sq8_roundtrip() {
270 let codec = make_codec();
271 let bytes = codec.to_bytes();
272 let restored = Sq8Codec::from_bytes(&bytes).unwrap();
273 assert_eq!(restored.dim, codec.dim);
274 assert_eq!(restored.inv_scales.len(), codec.inv_scales.len());
275 for (a, b) in restored.inv_scales.iter().zip(codec.inv_scales.iter()) {
276 assert!((a - b).abs() < 1e-6, "inv_scales mismatch: {a} vs {b}");
277 }
278 }
279
280 #[test]
281 fn sq8_invalid_magic_returns_error() {
282 let mut bytes = make_codec().to_bytes();
283 bytes[0] = b'X'; assert!(matches!(
285 Sq8Codec::from_bytes(&bytes),
286 Err(VectorError::InvalidMagic)
287 ));
288 }
289
290 #[test]
291 fn sq8_version_mismatch_returns_error() {
292 let mut bytes = make_codec().to_bytes();
293 bytes[6] = 0; assert!(matches!(
295 Sq8Codec::from_bytes(&bytes),
296 Err(VectorError::UnsupportedVersion {
297 found: 0,
298 expected: 1
299 })
300 ));
301 }
302
303 fn make_vectors() -> Vec<Vec<f32>> {
304 (0..100)
305 .map(|i| vec![i as f32 * 0.1, (i as f32).sin(), (i as f32).cos()])
306 .collect()
307 }
308
309 #[test]
310 fn quantize_dequantize_roundtrip() {
311 let vecs = make_vectors();
312 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
313 let codec = Sq8Codec::calibrate(&refs, 3).unwrap();
314
315 for v in &vecs {
316 let q = codec.quantize(v);
317 let dq = codec.dequantize(&q);
318 for d in 0..3 {
319 let error = (v[d] - dq[d]).abs();
320 let range = codec.maxs[d] - codec.mins[d];
321 assert!(
323 error <= range / 255.0 + 1e-6,
324 "d={d}: error={error}, max_step={}",
325 range / 255.0
326 );
327 }
328 }
329 }
330
331 #[test]
332 fn asymmetric_l2_close_to_exact() {
333 let vecs = make_vectors();
334 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
335 let codec = Sq8Codec::calibrate(&refs, 3).unwrap();
336
337 let query = &[5.0, 0.5, -0.5];
338 for v in &vecs {
339 let q = codec.quantize(v);
340 let exact = crate::distance::l2_squared(query, v);
341 let approx = codec.asymmetric_l2(query, &q);
342 let rel_error = if exact > 0.01 {
344 (exact - approx).abs() / exact
345 } else {
346 (exact - approx).abs()
347 };
348 assert!(
349 rel_error < 0.05 || (exact - approx).abs() < 0.1,
350 "exact={exact}, approx={approx}, rel_error={rel_error}"
351 );
352 }
353 }
354
355 #[test]
356 fn batch_quantize() {
357 let vecs = make_vectors();
358 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
359 let codec = Sq8Codec::calibrate(&refs, 3).unwrap();
360
361 let batch = codec.quantize_batch(&refs);
362 assert_eq!(batch.len(), 3 * 100);
363
364 let single = codec.quantize(&vecs[0]);
366 assert_eq!(&batch[0..3], &single[..]);
367 }
368
369 #[test]
370 fn constant_dimension_handled() {
371 let vecs: Vec<Vec<f32>> = (0..10).map(|i| vec![5.0, i as f32]).collect();
373 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
374 let codec = Sq8Codec::calibrate(&refs, 2).unwrap();
375
376 let q = codec.quantize(&[5.0, 3.0]);
378 assert_eq!(q[0], 0); }
380
381 #[test]
382 fn calibrate_rejects_bad_input_with_typed_errors() {
383 use crate::error::VectorError;
384 assert!(matches!(
385 Sq8Codec::calibrate(&[], 3),
386 Err(VectorError::InvalidInput { .. })
387 ));
388 let v = [1.0_f32, 2.0];
389 assert!(matches!(
390 Sq8Codec::calibrate(&[&v], 0),
391 Err(VectorError::InvalidInput { .. })
392 ));
393 assert!(matches!(
394 Sq8Codec::calibrate(&[&v], 3),
395 Err(VectorError::DimensionMismatch {
396 expected: 3,
397 got: 2
398 })
399 ));
400 }
401}