Skip to main content

zeppelin_embed/
epoch.rs

1//! Epoch identity and migration.
2
3mod transition;
4
5pub use transition::{DropEpochReport, EpochAliasReport, EpochTransitionError};
6
7use crate::fts::tokenizer::TokenizerEpoch;
8use crate::manifest::EpochMeta;
9
10/// Stable digest of every field that determines embedding interpretation.
11#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub struct EpochId(u64);
13
14/// Output-vector normalization applied by the embedding pipeline.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16#[repr(u16)]
17pub enum Normalization {
18    /// Leave the model output unchanged.
19    None = 0,
20    /// Normalize the model output to unit L2 length.
21    L2 = 1,
22}
23
24/// Inference runtime whose numerical behaviour produced the embedding.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26#[repr(u16)]
27pub enum EmbeddingRuntime {
28    /// Apple's Core ML runtime.
29    CoreMl = 1,
30    /// Apple's MLX runtime.
31    Mlx = 2,
32    /// A host CPU reference implementation.
33    CpuReference = 3,
34}
35
36/// Hardware classes the inference runtime may use.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38#[repr(u16)]
39pub enum ComputeUnits {
40    /// CPU execution only.
41    Cpu = 1,
42    /// CPU and GPU execution.
43    CpuAndGpu = 2,
44    /// CPU and Neural Engine execution.
45    CpuAndNeuralEngine = 3,
46    /// Every compute unit available to the runtime.
47    All = 4,
48}
49
50/// Complete immutable identity of one encoder tower.
51///
52/// Every field changes the meaning of produced vectors. Mutable store state
53/// such as generations, row counts, timestamps, and document counts is
54/// deliberately absent.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct EmbeddingTower {
57    /// Host-selected model identifier.
58    pub model_id: String,
59    /// Host-selected model version.
60    pub model_version: String,
61    /// Opaque digest of the exact model weights.
62    pub weights_digest: Vec<u8>,
63    /// Output vector dimensions.
64    pub dims: u32,
65    /// Output normalization behaviour.
66    pub normalization: Normalization,
67    /// Exact prompt or prefix applied before inference, empty for none.
68    pub prompt_prefix: String,
69    /// Maximum input token count.
70    pub max_tokens: u32,
71    /// Inference runtime.
72    pub runtime: EmbeddingRuntime,
73    /// Compute-unit selection.
74    pub compute_units: ComputeUnits,
75    /// Optional operating-system build that affects inference behaviour.
76    pub os_build: Option<String>,
77}
78
79impl EmbeddingTower {
80    /// Returns a human-readable model label for diagnostics.
81    #[must_use]
82    pub fn model_label(&self) -> String {
83        format!("{}@{}", self.model_id, self.model_version)
84    }
85}
86
87/// Complete immutable identity of one embedding interpretation.
88///
89/// The document and query towers are explicit even when they are identical.
90/// This makes an aligned asymmetric pair one epoch instead of two unrelated
91/// model declarations. The alignment digest identifies the artifact or recipe
92/// that places both tower outputs in the same vector space.
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct EmbeddingEpoch {
95    /// Encoder used for persisted document vectors.
96    pub document: EmbeddingTower,
97    /// Encoder used for query vectors compared with those documents.
98    pub query: EmbeddingTower,
99    /// Opaque digest of the pairing/alignment artifact, empty for none.
100    pub alignment_digest: Vec<u8>,
101}
102
103/// Host-provided document embedding boundary used by epoch migration.
104///
105/// One call returns either one complete vector or one typed error. No partial
106/// output buffer crosses this seam, so a migration caller can stage the vector
107/// before making any manifest-visible change.
108pub trait Embedder {
109    /// Embeds one exact source revision.
110    fn embed(
111        &mut self,
112        doc_id: crate::ingest::DocId,
113        revision: crate::ingest::Revision,
114        source: &[u8],
115    ) -> Result<Vec<f32>, EmbedderError>;
116}
117
118/// A delegate rejected or otherwise failed one embedding request.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct EmbedderFailure {
121    detail: String,
122}
123
124impl EmbedderFailure {
125    /// Creates a failure with host-readable diagnostic detail.
126    #[must_use]
127    pub fn new(detail: impl Into<String>) -> Self {
128        Self {
129            detail: detail.into(),
130        }
131    }
132
133    /// Returns the delegate-supplied failure detail.
134    #[must_use]
135    pub fn detail(&self) -> &str {
136        &self.detail
137    }
138}
139
140impl std::fmt::Display for EmbedderFailure {
141    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        write!(formatter, "embedding delegate failed: {}", self.detail)
143    }
144}
145
146impl std::error::Error for EmbedderFailure {}
147
148/// A delegate did not complete one embedding request before its deadline.
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct EmbedderTimeout {
151    detail: String,
152}
153
154impl EmbedderTimeout {
155    /// Creates a timeout with host-readable diagnostic detail.
156    #[must_use]
157    pub fn new(detail: impl Into<String>) -> Self {
158        Self {
159            detail: detail.into(),
160        }
161    }
162
163    /// Returns the delegate-supplied timeout detail.
164    #[must_use]
165    pub fn detail(&self) -> &str {
166        &self.detail
167    }
168}
169
170impl std::fmt::Display for EmbedderTimeout {
171    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        write!(formatter, "embedding delegate timed out: {}", self.detail)
173    }
174}
175
176impl std::error::Error for EmbedderTimeout {}
177
178/// Typed unsuccessful outcome from an [`Embedder`] call.
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub enum EmbedderError {
181    /// The delegate completed with a failure instead of a vector.
182    Failure(EmbedderFailure),
183    /// The delegate did not complete before its deadline.
184    Timeout(EmbedderTimeout),
185}
186
187impl std::fmt::Display for EmbedderError {
188    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        match self {
190            Self::Failure(error) => error.fmt(formatter),
191            Self::Timeout(error) => error.fmt(formatter),
192        }
193    }
194}
195
196impl std::error::Error for EmbedderError {
197    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
198        match self {
199            Self::Failure(error) => Some(error),
200            Self::Timeout(error) => Some(error),
201        }
202    }
203}
204
205impl From<EmbedderFailure> for EmbedderError {
206    fn from(error: EmbedderFailure) -> Self {
207        Self::Failure(error)
208    }
209}
210
211impl From<EmbedderTimeout> for EmbedderError {
212    fn from(error: EmbedderTimeout) -> Self {
213        Self::Timeout(error)
214    }
215}
216
217/// Digest-input framing magic. Changing it changes every embedding epoch.
218const EPOCH_MAGIC: &[u8; 8] = b"ZEEMBEP2";
219
220impl EpochId {
221    /// Computes the identity of an embedding interpretation.
222    ///
223    /// # The canonical digest input
224    ///
225    /// The input is hand-written and little-endian throughout. Its exact
226    /// persisted-meaning order is: the eight bytes `ZEEMBEP2`; document-tower
227    /// fields; query-tower fields; then a u32 byte length and opaque
228    /// alignment-digest bytes. Each tower is a u32 byte length plus
229    /// UTF-8 model id; a u32 byte length plus UTF-8 model version; a u32 byte
230    /// length plus opaque weights-digest bytes; u32 dimensions; the u16
231    /// normalization id; a u32 byte length plus UTF-8 prompt prefix; u32
232    /// maximum tokens; the u16 runtime id; the u16 compute-units id; then a
233    /// one-byte OS-build presence tag (`0` or `1`) followed, when present, by
234    /// its u32 byte length and UTF-8 bytes. No mutable store state is ever part
235    /// of this input.
236    #[must_use]
237    pub fn of(epoch: &EmbeddingEpoch) -> Self {
238        Self(xxhash_rust::xxh3::xxh3_64(&canonical_digest_input(epoch)))
239    }
240
241    /// Returns the raw digest.
242    #[must_use]
243    pub const fn value(self) -> u64 {
244        self.0
245    }
246
247    pub(crate) const fn from_value(value: u64) -> Self {
248        Self(value)
249    }
250
251    /// Returns the lowercase hex form used for diagnostics.
252    #[must_use]
253    pub fn to_hex(self) -> String {
254        format!("{:016x}", self.0)
255    }
256}
257
258impl std::fmt::Display for EpochId {
259    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        write!(formatter, "{:016x}", self.0)
261    }
262}
263
264/// Caller-declared embedding and tokenizer interpretation.
265#[derive(Clone, Debug, Eq, PartialEq)]
266pub struct StoreEpoch {
267    /// Embedding-model interpretation.
268    pub embedding: EmbeddingEpoch,
269    /// Tokenizer interpretation.
270    pub tokenizer: TokenizerEpoch,
271}
272
273impl StoreEpoch {
274    /// Returns the compact identity pair compared at admission points.
275    #[must_use]
276    pub fn identity(&self) -> EpochIdentity {
277        EpochIdentity {
278            embedding: EpochId::of(&self.embedding),
279            tokenizer: self.tokenizer,
280        }
281    }
282}
283
284/// Compact persisted and reported store interpretation.
285#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
286pub struct EpochIdentity {
287    /// Embedding identity digest.
288    pub embedding: EpochId,
289    /// Tokenizer identity digest.
290    pub tokenizer: TokenizerEpoch,
291}
292
293pub(crate) struct EpochAliasCell {
294    sequence: std::sync::atomic::AtomicU64,
295    embedding: std::sync::atomic::AtomicU64,
296    tokenizer: std::sync::atomic::AtomicU64,
297    present: std::sync::atomic::AtomicBool,
298}
299
300impl EpochAliasCell {
301    pub(crate) fn new(identity: Option<EpochIdentity>) -> Self {
302        let (present, embedding, tokenizer) = identity.map_or((false, 0, 0), |identity| {
303            (true, identity.embedding.value(), identity.tokenizer.value())
304        });
305        Self {
306            sequence: std::sync::atomic::AtomicU64::new(0),
307            embedding: std::sync::atomic::AtomicU64::new(embedding),
308            tokenizer: std::sync::atomic::AtomicU64::new(tokenizer),
309            present: std::sync::atomic::AtomicBool::new(present),
310        }
311    }
312
313    pub(crate) fn load(&self) -> Option<EpochIdentity> {
314        use std::sync::atomic::Ordering;
315
316        loop {
317            let before = self.sequence.load(Ordering::Acquire);
318            if !before.is_multiple_of(2) {
319                std::hint::spin_loop();
320                continue;
321            }
322            let present = self.present.load(Ordering::Relaxed);
323            let embedding = self.embedding.load(Ordering::Relaxed);
324            let tokenizer = self.tokenizer.load(Ordering::Relaxed);
325            let after = self.sequence.load(Ordering::Acquire);
326            if before == after {
327                return present.then_some(EpochIdentity {
328                    embedding: EpochId::from_value(embedding),
329                    tokenizer: TokenizerEpoch::from_value(tokenizer),
330                });
331            }
332        }
333    }
334
335    pub(crate) fn store(&self, identity: Option<EpochIdentity>) {
336        use std::sync::atomic::Ordering;
337
338        self.sequence.fetch_add(1, Ordering::AcqRel);
339        let (present, embedding, tokenizer) = identity.map_or((false, 0, 0), |identity| {
340            (true, identity.embedding.value(), identity.tokenizer.value())
341        });
342        self.embedding.store(embedding, Ordering::Relaxed);
343        self.tokenizer.store(tokenizer, Ordering::Relaxed);
344        self.present.store(present, Ordering::Relaxed);
345        self.sequence.fetch_add(1, Ordering::Release);
346    }
347}
348
349impl EpochIdentity {
350    /// Reconstructs an identity from one validated manifest registry entry.
351    pub fn from_meta(meta: &EpochMeta) -> Result<Self, EpochMetaError> {
352        let actual = EpochId::of(&meta.embedding);
353        if actual != meta.id {
354            return Err(EpochMetaError::EmbeddingDigestMismatch {
355                declared: meta.id,
356                actual,
357            });
358        }
359        Ok(Self {
360            embedding: meta.id,
361            tokenizer: meta.tokenizer,
362        })
363    }
364}
365
366impl From<&StoreEpoch> for EpochMeta {
367    fn from(epoch: &StoreEpoch) -> Self {
368        Self {
369            id: epoch.identity().embedding,
370            embedding: epoch.embedding.clone(),
371            tokenizer: epoch.tokenizer,
372        }
373    }
374}
375
376impl From<StoreEpoch> for EpochMeta {
377    fn from(epoch: StoreEpoch) -> Self {
378        Self::from(&epoch)
379    }
380}
381
382/// Malformed persisted epoch metadata.
383#[derive(Clone, Debug, Eq, PartialEq)]
384pub enum EpochMetaError {
385    /// The stored id did not identify the stored embedding description.
386    EmbeddingDigestMismatch {
387        /// Id carried by the registry entry.
388        declared: EpochId,
389        /// Id recomputed from the complete stored embedding epoch.
390        actual: EpochId,
391    },
392}
393
394impl std::fmt::Display for EpochMetaError {
395    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        match self {
397            Self::EmbeddingDigestMismatch { declared, actual } => write!(
398                formatter,
399                "persisted embedding epoch id {declared} does not match complete identity {actual}"
400            ),
401        }
402    }
403}
404
405impl std::error::Error for EpochMetaError {}
406
407/// A store and caller named different interpretation identities.
408#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409pub struct EpochMismatch {
410    /// Identity pinned by the store.
411    pub expected: EpochIdentity,
412    /// Identity declared by the caller.
413    pub declared: EpochIdentity,
414}
415
416impl std::fmt::Display for EpochMismatch {
417    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418        match (
419            self.expected.embedding != self.declared.embedding,
420            self.expected.tokenizer != self.declared.tokenizer,
421        ) {
422            (true, true) => write!(
423                formatter,
424                "embedding and tokenizer epochs differ: expected ({}, {}), declared ({}, {})",
425                self.expected.embedding,
426                self.expected.tokenizer,
427                self.declared.embedding,
428                self.declared.tokenizer
429            ),
430            (true, false) => write!(
431                formatter,
432                "embedding epoch differs: expected {}, declared {}",
433                self.expected.embedding, self.declared.embedding
434            ),
435            (false, true) => write!(
436                formatter,
437                "tokenizer epoch differs: expected {}, declared {}",
438                self.expected.tokenizer, self.declared.tokenizer
439            ),
440            (false, false) => formatter.write_str("embedding and tokenizer epochs match"),
441        }
442    }
443}
444
445impl std::error::Error for EpochMismatch {}
446
447fn canonical_digest_input(epoch: &EmbeddingEpoch) -> Vec<u8> {
448    let mut input = Vec::with_capacity(256);
449    input.extend_from_slice(EPOCH_MAGIC);
450    push_tower(&mut input, &epoch.document);
451    push_tower(&mut input, &epoch.query);
452    push_bytes(&mut input, &epoch.alignment_digest);
453    input
454}
455
456fn push_tower(input: &mut Vec<u8>, tower: &EmbeddingTower) {
457    push_bytes(input, tower.model_id.as_bytes());
458    push_bytes(input, tower.model_version.as_bytes());
459    push_bytes(input, &tower.weights_digest);
460    push_u32(input, tower.dims);
461    push_u16(input, tower.normalization as u16);
462    push_bytes(input, tower.prompt_prefix.as_bytes());
463    push_u32(input, tower.max_tokens);
464    push_u16(input, tower.runtime as u16);
465    push_u16(input, tower.compute_units as u16);
466    match &tower.os_build {
467        Some(build) => {
468            input.push(1);
469            push_bytes(input, build.as_bytes());
470        }
471        None => input.push(0),
472    }
473}
474
475fn push_bytes(buffer: &mut Vec<u8>, value: &[u8]) {
476    push_u32(buffer, u32::try_from(value.len()).unwrap_or(u32::MAX));
477    buffer.extend_from_slice(value);
478}
479
480fn push_u16(buffer: &mut Vec<u8>, value: u16) {
481    buffer.extend_from_slice(&value.to_le_bytes());
482}
483
484fn push_u32(buffer: &mut Vec<u8>, value: u32) {
485    buffer.extend_from_slice(&value.to_le_bytes());
486}
487
488#[cfg(test)]
489#[allow(clippy::expect_used, clippy::indexing_slicing)]
490mod tests {
491    use std::collections::BTreeSet;
492
493    use super::*;
494    use crate::manifest::EpochMeta;
495
496    fn tower(model_id: &str, prompt_prefix: &str, weight: u8) -> EmbeddingTower {
497        EmbeddingTower {
498            model_id: model_id.to_owned(),
499            model_version: "1.2.3".to_owned(),
500            weights_digest: vec![0x10, 0x20, 0x30, weight],
501            dims: 384,
502            normalization: Normalization::L2,
503            prompt_prefix: prompt_prefix.to_owned(),
504            max_tokens: 512,
505            runtime: EmbeddingRuntime::CoreMl,
506            compute_units: ComputeUnits::CpuAndNeuralEngine,
507            os_build: Some("25A100".to_owned()),
508        }
509    }
510
511    fn baseline() -> EmbeddingEpoch {
512        EmbeddingEpoch {
513            document: tower("document-embedding", "search_document: ", 0x40),
514            query: tower("query-embedding", "search_query: ", 0x41),
515            alignment_digest: vec![0xaa, 0xbb, 0xcc],
516        }
517    }
518
519    fn tower_variants(base: &EmbeddingTower) -> Vec<EmbeddingTower> {
520        let mut variants = Vec::new();
521        let mut changed = base.clone();
522        changed.model_id = "changed-model".to_owned();
523        variants.push(changed);
524
525        let mut changed = base.clone();
526        changed.model_version = "9.9.9".to_owned();
527        variants.push(changed);
528
529        let mut changed = base.clone();
530        changed.weights_digest = vec![0xff];
531        variants.push(changed);
532
533        let mut changed = base.clone();
534        changed.dims = 768;
535        variants.push(changed);
536
537        let mut changed = base.clone();
538        changed.normalization = Normalization::None;
539        variants.push(changed);
540
541        let mut changed = base.clone();
542        changed.prompt_prefix = "changed: ".to_owned();
543        variants.push(changed);
544
545        let mut changed = base.clone();
546        changed.max_tokens = 1_024;
547        variants.push(changed);
548
549        let mut changed = base.clone();
550        changed.runtime = EmbeddingRuntime::Mlx;
551        variants.push(changed);
552
553        let mut changed = base.clone();
554        changed.compute_units = ComputeUnits::All;
555        variants.push(changed);
556
557        let mut changed = base.clone();
558        changed.os_build = None;
559        variants.push(changed);
560        variants
561    }
562
563    #[test]
564    fn changing_any_interpretation_critical_field_changes_the_epoch_id() {
565        let base = baseline();
566        let mut variants = Vec::new();
567        for tower in tower_variants(&base.document) {
568            let mut changed = base.clone();
569            changed.document = tower;
570            variants.push(changed);
571        }
572        for tower in tower_variants(&base.query) {
573            let mut changed = base.clone();
574            changed.query = tower;
575            variants.push(changed);
576        }
577        let mut changed = base.clone();
578        changed.alignment_digest = vec![0xaa, 0xbb, 0xcd];
579        variants.push(changed);
580
581        let baseline_id = EpochId::of(&base);
582        let mut ids = BTreeSet::from([baseline_id]);
583        for variant in &variants {
584            let id = EpochId::of(variant);
585            assert_ne!(id, baseline_id);
586            ids.insert(id);
587        }
588        assert_eq!(ids.len(), variants.len() + 1);
589    }
590
591    #[test]
592    fn mutable_state_never_changes_the_epoch_id() {
593        struct MutableState {
594            document_count: u64,
595            generation: u64,
596            row_count: u64,
597            timestamp: u64,
598        }
599
600        let epoch = baseline();
601        let before = EpochId::of(&epoch);
602        let mut state = MutableState {
603            document_count: 0,
604            generation: 0,
605            row_count: 0,
606            timestamp: 0,
607        };
608        state.document_count = 9_001;
609        state.generation = 73;
610        state.row_count = 8_999;
611        state.timestamp = 1_800_000_000;
612
613        assert_eq!(EpochId::of(&epoch), before);
614        assert_eq!(
615            state.document_count + state.generation + state.row_count + state.timestamp,
616            1_800_018_073
617        );
618    }
619
620    #[test]
621    fn the_embedding_epoch_digest_input_is_byte_exact() {
622        assert_eq!(
623            render_hex(&canonical_digest_input(&baseline())),
624            include_str!("../tests/fixtures/format/embedding_epoch_digest_input_v2.hex")
625        );
626    }
627
628    #[test]
629    fn a_persisted_embedding_digest_mismatch_is_a_typed_error() {
630        let embedding = baseline();
631        let actual = EpochId::of(&embedding);
632        let error = EpochIdentity::from_meta(&EpochMeta {
633            id: EpochId::from_value(actual.value() ^ 1),
634            embedding,
635            tokenizer: crate::fts::tokenizer::TokenizerConfig::text_default().epoch(),
636        })
637        .expect_err("mismatched embedding digest must fail");
638
639        assert!(matches!(
640            error,
641            EpochMetaError::EmbeddingDigestMismatch { .. }
642        ));
643    }
644
645    fn render_hex(bytes: &[u8]) -> String {
646        let mut rendered = String::new();
647        for (index, byte) in bytes.iter().enumerate() {
648            if index > 0 && index % 16 == 0 {
649                rendered.push('\n');
650            } else if index > 0 {
651                rendered.push(' ');
652            }
653            rendered.push_str(&format!("{byte:02x}"));
654        }
655        rendered.push('\n');
656        rendered
657    }
658}