1mod transition;
4
5pub use transition::{DropEpochReport, EpochAliasReport, EpochTransitionError};
6
7use crate::fts::tokenizer::TokenizerEpoch;
8use crate::manifest::EpochMeta;
9
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub struct EpochId(u64);
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16#[repr(u16)]
17pub enum Normalization {
18 None = 0,
20 L2 = 1,
22}
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26#[repr(u16)]
27pub enum EmbeddingRuntime {
28 CoreMl = 1,
30 Mlx = 2,
32 CpuReference = 3,
34}
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38#[repr(u16)]
39pub enum ComputeUnits {
40 Cpu = 1,
42 CpuAndGpu = 2,
44 CpuAndNeuralEngine = 3,
46 All = 4,
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct EmbeddingTower {
57 pub model_id: String,
59 pub model_version: String,
61 pub weights_digest: Vec<u8>,
63 pub dims: u32,
65 pub normalization: Normalization,
67 pub prompt_prefix: String,
69 pub max_tokens: u32,
71 pub runtime: EmbeddingRuntime,
73 pub compute_units: ComputeUnits,
75 pub os_build: Option<String>,
77}
78
79impl EmbeddingTower {
80 #[must_use]
82 pub fn model_label(&self) -> String {
83 format!("{}@{}", self.model_id, self.model_version)
84 }
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct EmbeddingEpoch {
95 pub document: EmbeddingTower,
97 pub query: EmbeddingTower,
99 pub alignment_digest: Vec<u8>,
101}
102
103pub trait Embedder {
109 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#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct EmbedderFailure {
121 detail: String,
122}
123
124impl EmbedderFailure {
125 #[must_use]
127 pub fn new(detail: impl Into<String>) -> Self {
128 Self {
129 detail: detail.into(),
130 }
131 }
132
133 #[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#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct EmbedderTimeout {
151 detail: String,
152}
153
154impl EmbedderTimeout {
155 #[must_use]
157 pub fn new(detail: impl Into<String>) -> Self {
158 Self {
159 detail: detail.into(),
160 }
161 }
162
163 #[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#[derive(Clone, Debug, Eq, PartialEq)]
180pub enum EmbedderError {
181 Failure(EmbedderFailure),
183 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
217const EPOCH_MAGIC: &[u8; 8] = b"ZEEMBEP2";
219
220impl EpochId {
221 #[must_use]
237 pub fn of(epoch: &EmbeddingEpoch) -> Self {
238 Self(xxhash_rust::xxh3::xxh3_64(&canonical_digest_input(epoch)))
239 }
240
241 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
266pub struct StoreEpoch {
267 pub embedding: EmbeddingEpoch,
269 pub tokenizer: TokenizerEpoch,
271}
272
273impl StoreEpoch {
274 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
286pub struct EpochIdentity {
287 pub embedding: EpochId,
289 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 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#[derive(Clone, Debug, Eq, PartialEq)]
384pub enum EpochMetaError {
385 EmbeddingDigestMismatch {
387 declared: EpochId,
389 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409pub struct EpochMismatch {
410 pub expected: EpochIdentity,
412 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}