Skip to main content

ruvector_core/
encoding.rs

1//! Unified vector-representation plane (ADR-297 §1).
2//!
3//! One interface — [`VectorCodec`] / [`EncodedQuery`] — for every way
4//! RuVector can hold a vector: FP32, FP16, Int8, Turbo4 (and, from their own
5//! crates, PQ and RaBitQ). Storage, indexes, snapshots, WASM, and bindings
6//! consume `&dyn VectorCodec` and opaque blobs; they never name a concrete
7//! codec. This is what lets precision become a per-vector, per-query
8//! *decision* instead of a compile-time choice.
9//!
10//! Also home to [`VectorProvenance`] (ADR-297 §7): the metadata every stored
11//! vector must carry so that migrations are auditable and snapshots stay
12//! deterministic and independently verifiable.
13
14pub mod codecs;
15
16use crate::error::{Result, RuvectorError};
17use crate::types::DistanceMetric;
18use serde::{Deserialize, Serialize};
19
20/// Every representation the plane knows about. `Pq` and `RaBitQ1` are
21/// reserved here (stable blob-header / provenance values) but their codecs
22/// live in `ruvector-pq-search` / `ruvector-rabitq` (ADR-297 phase C).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum CodecKind {
25    /// 4 bytes/dim, exact.
26    Fp32,
27    /// 2 bytes/dim, ~1e-3 relative error. Hot-tier / verification codec.
28    Fp16,
29    /// 1 byte/dim + 8 B header (per-vector min/scale uniform quantization).
30    Int8,
31    /// 0.5 bytes/dim + 8 B constants (ADR-296 Lloyd-Max rotated codes).
32    Turbo4,
33    /// Product quantization (external crate; reserved).
34    Pq,
35    /// 1-bit RaBitQ (external crate; reserved).
36    RaBitQ1,
37}
38
39impl CodecKind {
40    /// Bytes per encoded vector at dimension `dim` (None for reserved kinds
41    /// whose layout is owned by external crates).
42    pub fn encoded_len(&self, dim: usize) -> Option<usize> {
43        match self {
44            CodecKind::Fp32 => Some(dim * 4),
45            CodecKind::Fp16 => Some(dim * 2),
46            CodecKind::Int8 => Some(dim + 8),
47            CodecKind::Turbo4 => Some(dim / 2 + 8),
48            CodecKind::Pq | CodecKind::RaBitQ1 => None,
49        }
50    }
51}
52
53/// A vector codec: encodes f32 vectors into opaque blobs and scores blobs
54/// without the caller knowing the representation.
55pub trait VectorCodec: Send + Sync {
56    /// Which representation this codec produces.
57    fn kind(&self) -> CodecKind;
58    /// Dimensionality of vectors this codec accepts.
59    fn dim(&self) -> usize;
60    /// Bytes per encoded vector.
61    fn encoded_len(&self) -> usize;
62    /// Encode a vector. The blob is self-sufficient for scoring.
63    fn encode(&self, v: &[f32]) -> Result<Vec<u8>>;
64    /// Reconstruct an approximation (exact only for `Fp32`).
65    fn decode(&self, blob: &[u8]) -> Result<Vec<f32>>;
66    /// Symmetric blob×blob distance under this codec's metric.
67    fn distance(&self, a: &[u8], b: &[u8]) -> Result<f32>;
68    /// Prepare a query for repeated asymmetric scoring against blobs.
69    fn make_query(&self, q: &[f32]) -> Result<Box<dyn EncodedQuery>>;
70}
71
72/// A prepared query bound to one codec + metric.
73pub trait EncodedQuery: Send + Sync {
74    /// Distance from the query to an encoded vector. Conventions match the
75    /// index plane: Euclidean root, `1 − cos` (clamped ≥ 0), `−dot`
76    /// (clamped ≥ 0), Manhattan sum.
77    fn distance_to(&self, blob: &[u8]) -> f32;
78}
79
80/// Construct a codec by kind. The single entry point bindings and storage
81/// use, so adding a codec never touches consumers.
82pub fn codec_for(
83    kind: CodecKind,
84    dim: usize,
85    metric: DistanceMetric,
86    rotation_seed: u64,
87) -> Result<Box<dyn VectorCodec>> {
88    match kind {
89        CodecKind::Fp32 => Ok(Box::new(codecs::Fp32Codec::new(dim, metric))),
90        CodecKind::Fp16 => Ok(Box::new(codecs::Fp16Codec::new(dim, metric))),
91        CodecKind::Int8 => Ok(Box::new(codecs::Int8Codec::new(dim, metric))),
92        CodecKind::Turbo4 => Ok(Box::new(codecs::Turbo4PlaneCodec::new(
93            dim,
94            metric,
95            rotation_seed,
96        )?)),
97        CodecKind::Pq | CodecKind::RaBitQ1 => Err(RuvectorError::InvalidParameter(format!(
98            "{kind:?} codec is provided by its own crate and not yet wired \
99             into the core plane (ADR-297 phase C)"
100        ))),
101    }
102}
103
104/// Provenance every stored vector carries (ADR-297 §7). Snapshots must be
105/// reproducible from (source, provenance) alone — which is why rotation
106/// seeds are recorded and codecs are versioned.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108pub struct VectorProvenance {
109    /// Embedding model identity (e.g. "text-embedding-3-large").
110    pub model_id: Option<String>,
111    /// Representation of the stored blob.
112    pub codec: CodecKind,
113    /// Version of that codec's layout/tables. Bump on any change that alters
114    /// encoded bytes for identical input.
115    pub codec_version: u16,
116    /// Rotation seed for rotated codecs (Turbo4, RaBitQ).
117    pub rotation_seed: Option<u64>,
118    /// Dimensionality.
119    pub dim: usize,
120    /// Distance metric the codes were prepared for.
121    pub metric: DistanceMetric,
122    /// Hex SHA-256 of the source object, when known.
123    pub source_hash: Option<String>,
124    /// Migration lineage, oldest first (e.g. "fp32→turbo4@2026-08-06").
125    pub lineage: Vec<String>,
126}
127
128/// Current layout version of the in-core codecs.
129pub const CODEC_VERSION: u16 = 1;
130
131impl VectorProvenance {
132    /// Provenance for a freshly encoded vector under `codec`.
133    pub fn new(codec: &dyn VectorCodec, metric: DistanceMetric) -> Self {
134        Self {
135            model_id: None,
136            codec: codec.kind(),
137            codec_version: CODEC_VERSION,
138            rotation_seed: None,
139            dim: codec.dim(),
140            metric,
141            source_hash: None,
142            lineage: Vec::new(),
143        }
144    }
145
146    /// Record a migration step (old codec → new codec).
147    pub fn push_migration(&mut self, entry: impl Into<String>) {
148        self.lineage.push(entry.into());
149    }
150}
151
152/// Shared scalar distance with the same conventions as the HNSW plane
153/// (`index::hnsw::DistanceFn`): callers across codecs must agree on score
154/// semantics or adaptive escalation would compare incomparable numbers.
155pub(crate) fn metric_distance(metric: DistanceMetric, a: &[f32], b: &[f32]) -> f32 {
156    use crate::simd_intrinsics as si;
157    match metric {
158        DistanceMetric::Euclidean => si::euclidean_distance_simd(a, b),
159        DistanceMetric::Cosine => (1.0_f32 - si::cosine_similarity_simd(a, b)).max(0.0),
160        DistanceMetric::DotProduct => (-si::dot_product_simd(a, b)).max(0.0),
161        DistanceMetric::Manhattan => si::manhattan_distance_simd(a, b),
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn encoded_len_matches_kind_table() {
171        for (kind, dim, expect) in [
172            (CodecKind::Fp32, 128, 512),
173            (CodecKind::Fp16, 128, 256),
174            (CodecKind::Int8, 128, 136),
175            (CodecKind::Turbo4, 128, 72),
176        ] {
177            assert_eq!(kind.encoded_len(dim), Some(expect));
178            let codec = codec_for(kind, dim, DistanceMetric::Euclidean, 42).unwrap();
179            assert_eq!(codec.encoded_len(), expect);
180            assert_eq!(codec.kind(), kind);
181        }
182        assert_eq!(CodecKind::Pq.encoded_len(128), None);
183        assert!(codec_for(CodecKind::Pq, 128, DistanceMetric::Euclidean, 42).is_err());
184    }
185
186    #[test]
187    fn provenance_roundtrips_serde() {
188        let codec = codec_for(CodecKind::Turbo4, 64, DistanceMetric::Cosine, 42).unwrap();
189        let mut p = VectorProvenance::new(codec.as_ref(), DistanceMetric::Cosine);
190        p.rotation_seed = Some(42);
191        p.model_id = Some("test-model".into());
192        p.push_migration("fp32→turbo4@2026-08-06");
193        let json = serde_json::to_string(&p).unwrap();
194        let back: VectorProvenance = serde_json::from_str(&json).unwrap();
195        assert_eq!(p, back);
196        assert_eq!(back.lineage.len(), 1);
197    }
198}