1use rightkit_onnx::ort;
2use std::path::{Path, PathBuf};
3use std::sync::{Mutex, OnceLock};
4use std::time::{Duration, Instant};
5
6#[cfg(target_os = "macos")]
7use crate::coreml::{self, CoreMlEngine};
8#[cfg(target_os = "macos")]
9use crate::coreml_units::CoreMlComputeUnit;
10use crate::coreml_units::CoreMlUnits;
11use crate::e40::{self, E40Engine, E40SessionProfile};
12use crate::parity_cache;
13use rightkit_onnx::ort::session::Session;
14use rightkit_onnx::{
15 configure_runtime, init_environment, CandidateSource, EnvironmentOptions, GlobalPool,
16 RuntimeCandidate, SessionOptions,
17};
18use thiserror::Error;
19use tokenizers::{EncodeInput, Tokenizer};
20
21pub const EMBEDDINGGEMMA_MODEL_ID: &str = "embeddinggemma-300m-q4";
22pub const EMBEDDINGGEMMA2_MODEL_ID: &str = "embeddinggemma2-q4";
26pub const EMBEDDINGGEMMA2_E40_MODEL_ID: &str = "embeddinggemma2-e40-dml";
31pub const EMBEDDINGGEMMA2_COREML_MODEL_ID: &str = "embeddinggemma2-coreml";
37const GEMMA2_MODALITY_SENTINELS: [&str; 3] = ["<|image|>", "<|video|>", "<|audio|>"];
40pub const EMBEDDINGGEMMA_NATIVE_DIM: usize = 768;
41pub const DEFAULT_TRUNCATE_DIM: usize = 256;
42pub const MAX_SEQUENCE_TOKENS: usize = 2048;
43pub const DEFAULT_MAX_BATCH: usize = 16;
45const MAX_PADDED_TOKENS_PER_CALL: usize = 16_384;
49const ACCELERATOR_BUCKETS: [usize; 6] = [64, 128, 256, 512, 1024, 2048];
52const DOCUMENT_PROMPT_PREFIX: &str = "title: none | text: ";
53const QUERY_PROMPT_PREFIX: &str = "task: search result | query: ";
54const PIPELINE_SCHEMA: &str = "rightkit-embed/pipeline/1";
57const POOLING_DESCRIPTION: &str = "model sentence_embedding [B,D] or masked mean over [B,T,D]";
58const NORMALIZATION_DESCRIPTION: &str = "l2 native, matryoshka prefix truncate, l2";
59const PROBE_PROMPT: &str = "title: none | text: rightkit embed provider probe";
60pub const ACCELERATOR_PARITY_MIN_COSINE: f32 = 0.995;
65const PARITY_QUERY: &str = "how are invoices matched to the ledger";
67const PARITY_DOCUMENT: &str = "The nightly billing job reads every invoice issued during the day, \
69looks up the matching ledger account, and compares the invoiced total with the posted amount. \
70Differences below one cent are rounded away. Larger differences are written to an exceptions \
71table together with the customer, the invoice number and the date, and the finance team reviews \
72that table each morning before the payment run starts.";
73const PARITY_LONG_SECTIONS: usize = 20;
75
76#[derive(Debug, Error)]
77pub enum EmbedError {
78 #[error("model directory is missing: {0}")]
79 MissingModelDirectory(PathBuf),
80 #[error("model artifact is missing: {0}")]
81 MissingArtifact(PathBuf),
82 #[error("invalid model configuration: {0}")]
83 InvalidConfig(String),
84 #[error("tokenizer: {0}")]
85 Tokenizer(String),
86 #[error("ONNX Runtime: {0}")]
87 Runtime(String),
88 #[error("model output: {0}")]
89 Output(String),
90 #[error("{provider} failed the vector parity check against cpu: {detail}")]
97 AcceleratorParity {
98 provider: &'static str,
99 lowest_cosine: Option<f32>,
100 floor: f32,
101 detail: String,
102 },
103}
104
105#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct AcceleratorParity {
109 pub lowest_cosine: f32,
111 pub at_tokens: usize,
113 pub pairs: usize,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct SessionCreate {
120 pub provider: &'static str,
122 pub graph_tokens: usize,
124 pub duration: Duration,
126}
127
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum ParityCacheOutcome {
133 #[default]
135 NotUsed,
136 Hit { passed: bool },
139 Miss { reason: &'static str, stored: bool },
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
148#[non_exhaustive]
149pub struct LoadTimings {
150 pub tokenizer_load: Duration,
152 pub token_table_read: Duration,
154 pub probe: Option<Duration>,
156 pub parity_check_total: Option<Duration>,
159 pub parity_cpu_reference: Option<Duration>,
162 pub parity_accelerator: Option<Duration>,
165 pub parity_cache: ParityCacheOutcome,
167 pub session_creates: Vec<SessionCreate>,
169 pub prewarm: Option<Duration>,
173 pub prewarm_per_length: Vec<(usize, Duration)>,
177 pub total: Duration,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub enum ExecutionProvider {
207 #[default]
208 Cpu,
209 Auto,
210 DirectMl,
211 CoreMl,
212}
213
214impl From<rightkit_onnx::ExecutionProvider> for ExecutionProvider {
215 fn from(value: rightkit_onnx::ExecutionProvider) -> Self {
216 match value {
217 rightkit_onnx::ExecutionProvider::Cpu => Self::Cpu,
218 rightkit_onnx::ExecutionProvider::DirectMl => Self::DirectMl,
219 rightkit_onnx::ExecutionProvider::CoreMl => Self::CoreMl,
220 }
221 }
222}
223
224impl ExecutionProvider {
225 fn requested(self) -> (rightkit_onnx::ExecutionProvider, bool) {
226 match self {
227 Self::Cpu => (rightkit_onnx::ExecutionProvider::Cpu, false),
228 Self::Auto => (
229 rightkit_onnx::ExecutionProvider::platform_accelerator(),
230 true,
231 ),
232 Self::DirectMl => (rightkit_onnx::ExecutionProvider::DirectMl, false),
233 Self::CoreMl => (rightkit_onnx::ExecutionProvider::CoreMl, false),
234 }
235 }
236}
237
238pub(crate) fn provider_name(provider: rightkit_onnx::ExecutionProvider) -> &'static str {
239 match provider {
240 rightkit_onnx::ExecutionProvider::Cpu => "cpu",
241 rightkit_onnx::ExecutionProvider::DirectMl => "directml",
242 rightkit_onnx::ExecutionProvider::CoreMl => "coreml",
243 }
244}
245
246#[derive(Debug, Clone)]
247pub struct EmbedderConfig {
248 pub model_dir: PathBuf,
249 pub runtime_path: PathBuf,
250 pub provider: ExecutionProvider,
251 pub truncate_dim: usize,
252 pub max_tokens: usize,
253 pub max_batch: usize,
254 pub intra_threads: usize,
255 pub fallback_model_dir: Option<PathBuf>,
258 pub coreml_cache_dir: Option<PathBuf>,
260 pub parity_cache_dir: Option<PathBuf>,
262 pub prewarm: Option<bool>,
265 pub coreml_units: Option<CoreMlUnits>,
268 pub(crate) e40_session_profile: Option<E40SessionProfile>,
270}
271
272impl EmbedderConfig {
273 pub fn new(model_dir: impl Into<PathBuf>, runtime_path: impl Into<PathBuf>) -> Self {
274 Self {
275 model_dir: model_dir.into(),
276 runtime_path: runtime_path.into(),
277 provider: ExecutionProvider::Cpu,
278 truncate_dim: DEFAULT_TRUNCATE_DIM,
279 max_tokens: MAX_SEQUENCE_TOKENS,
280 max_batch: DEFAULT_MAX_BATCH,
281 intra_threads: 1,
282 fallback_model_dir: None,
283 coreml_cache_dir: None,
284 parity_cache_dir: None,
285 prewarm: None,
286 coreml_units: None,
287 e40_session_profile: None,
288 }
289 }
290
291 pub fn with_prewarm(mut self, prewarm: bool) -> Self {
303 self.prewarm = Some(prewarm);
304 self
305 }
306
307 pub fn with_coreml_units(mut self, units: impl Into<CoreMlUnits>) -> Self {
315 self.coreml_units = Some(units.into());
316 self
317 }
318
319 #[cfg(feature = "bench-internals")]
322 pub fn with_e40_session_profile(mut self, profile: E40SessionProfile) -> Self {
323 self.e40_session_profile = Some(profile);
324 self
325 }
326
327 pub fn with_fallback_model_dir(mut self, dir: impl Into<PathBuf>) -> Self {
337 self.fallback_model_dir = Some(dir.into());
338 self
339 }
340
341 pub fn with_coreml_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
346 self.coreml_cache_dir = Some(dir.into());
347 self
348 }
349
350 pub fn with_parity_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
367 self.parity_cache_dir = Some(dir.into());
368 self
369 }
370
371 pub fn with_provider(mut self, provider: impl Into<ExecutionProvider>) -> Self {
372 self.provider = provider.into();
373 self
374 }
375
376 pub fn with_truncate_dim(mut self, dim: usize) -> Self {
377 self.truncate_dim = dim;
378 self
379 }
380
381 pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
382 self.max_tokens = max_tokens;
383 self
384 }
385
386 pub fn with_max_batch(mut self, max_batch: usize) -> Self {
388 self.max_batch = max_batch;
389 self
390 }
391
392 pub fn with_intra_threads(mut self, threads: usize) -> Self {
394 self.intra_threads = threads;
395 self
396 }
397}
398
399enum Backend {
402 Onnx(Mutex<Session>),
403 E40(E40Engine),
404 #[cfg(target_os = "macos")]
405 CoreMl(CoreMlEngine),
406}
407
408pub struct Embedder {
409 tokenizer: Tokenizer,
410 backend: Backend,
411 model_dir: PathBuf,
412 native_dim: usize,
413 truncate_dim: usize,
414 max_tokens: usize,
415 max_batch: usize,
416 model_id: String,
417 gemma2: bool,
418 pad_id: i64,
419 takes_token_type_ids: bool,
420 provider_used: &'static str,
421 fallback_reason: Option<String>,
422 parity: Option<AcceleratorParity>,
423 load_timings: Option<LoadTimings>,
424 pipeline_id: OnceLock<Result<String, String>>,
425}
426
427struct BuiltEmbedSession {
428 session: Session,
429 provider: rightkit_onnx::ExecutionProvider,
430 fallback: Option<String>,
431}
432
433fn build_session(
434 config: &EmbedderConfig,
435 provider: rightkit_onnx::ExecutionProvider,
436 cpu_fallback: bool,
437) -> Result<BuiltEmbedSession, EmbedError> {
438 let built = SessionOptions::new(provider)
439 .with_threads(Some(config.intra_threads))
440 .with_cpu_fallback(cpu_fallback)
441 .build_from_file_reported(&config.model_dir.join("model_q4.onnx"))
442 .map_err(|e| EmbedError::Runtime(e.to_string()))?;
443 Ok(BuiltEmbedSession {
444 session: built.session,
445 provider: built.provider,
446 fallback: built.fallback,
447 })
448}
449
450fn takes_token_type_ids(session: &Session) -> bool {
451 session
452 .inputs()
453 .iter()
454 .any(|input| input.name() == "token_type_ids")
455}
456
457fn load_tokenizer(model_dir: &Path) -> Result<(Tokenizer, i64), EmbedError> {
460 let mut tokenizer = Tokenizer::from_file(model_dir.join("tokenizer.json"))
461 .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
462 let pad_id = tokenizer
463 .get_padding()
464 .map(|padding| i64::from(padding.pad_id))
465 .or_else(|| tokenizer.token_to_id("<pad>").map(i64::from))
466 .unwrap_or(0);
467 tokenizer.with_padding(None);
468 Ok((tokenizer, pad_id))
469}
470
471impl Embedder {
472 pub fn load(config: EmbedderConfig) -> Result<Self, EmbedError> {
473 let started = Instant::now();
474 validate_config(&config)?;
475 #[cfg(target_os = "macos")]
477 {
478 if coreml::is_bundle(&config.model_dir) {
479 let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
480 let tokenizer_load = started.elapsed();
481 let mut embedder = Self::load_coreml(config, tokenizer, pad_id)?;
482 if let Some(timings) = embedder.load_timings.as_mut() {
484 timings.tokenizer_load = tokenizer_load;
485 timings.total = started.elapsed();
486 }
487 return Ok(embedder);
488 }
489 }
490 let gemma2 = bundle_is_gemma2(&config.model_dir)?;
491 let tokenizer_started = Instant::now();
492 let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
493 let tokenizer_load = tokenizer_started.elapsed();
494 let selection = configure_runtime([RuntimeCandidate::new(
495 config.runtime_path.clone(),
496 CandidateSource::Explicit,
497 )])
498 .map_err(|e| EmbedError::Runtime(e.to_string()))?;
499 init_environment(
500 &selection,
501 &EnvironmentOptions {
502 global_pool: Some(GlobalPool::from_budget()),
503 },
504 )
505 .map_err(EmbedError::Runtime)?;
506 if e40::is_bundle(&config.model_dir) {
507 let mut embedder = Self::load_e40(config, tokenizer, pad_id, tokenizer_load)?;
508 let sessions = match &embedder.backend {
510 Backend::E40(engine) => Some(engine.finish_session_log()),
511 _ => None,
512 };
513 if let (Some(timings), Some(sessions)) = (embedder.load_timings.as_mut(), sessions) {
514 timings.session_creates = sessions;
515 timings.total = started.elapsed();
516 }
517 return Ok(embedder);
518 }
519 let (requested, auto) = config.provider.requested();
520 let built = build_session(&config, requested, auto)?;
521 let accelerator = built.provider;
522 let mut embedder = Self::from_session(&config, tokenizer, pad_id, gemma2, built);
523 if auto && accelerator != rightkit_onnx::ExecutionProvider::Cpu {
524 if let Err(reason) = embedder.probe() {
525 let cpu = build_session(&config, rightkit_onnx::ExecutionProvider::Cpu, false)?;
526 embedder.takes_token_type_ids = takes_token_type_ids(&cpu.session);
527 embedder.backend = Backend::Onnx(Mutex::new(cpu.session));
528 embedder.provider_used = provider_name(cpu.provider);
529 embedder.fallback_reason = Some(format!(
530 "{} -> cpu: probe inference failed: {reason}",
531 provider_name(accelerator)
532 ));
533 }
534 }
535 if embedder.provider_used != "cpu" {
536 return embedder.admit_accelerator(&config, auto);
537 }
538 Ok(embedder)
539 }
540
541 fn from_session(
542 config: &EmbedderConfig,
543 tokenizer: Tokenizer,
544 pad_id: i64,
545 gemma2: bool,
546 built: BuiltEmbedSession,
547 ) -> Self {
548 Self {
549 tokenizer,
550 takes_token_type_ids: takes_token_type_ids(&built.session),
551 backend: Backend::Onnx(Mutex::new(built.session)),
552 model_dir: config.model_dir.clone(),
553 native_dim: EMBEDDINGGEMMA_NATIVE_DIM,
554 truncate_dim: config.truncate_dim,
555 max_tokens: config.max_tokens,
556 max_batch: config.max_batch,
557 model_id: if gemma2 {
558 EMBEDDINGGEMMA2_MODEL_ID
559 } else {
560 EMBEDDINGGEMMA_MODEL_ID
561 }
562 .to_owned(),
563 gemma2,
564 pad_id,
565 provider_used: provider_name(built.provider),
566 fallback_reason: built.fallback,
567 parity: None,
568 load_timings: None,
569 pipeline_id: OnceLock::new(),
570 }
571 }
572
573 fn from_e40(
574 config: &EmbedderConfig,
575 tokenizer: Tokenizer,
576 pad_id: i64,
577 engine: E40Engine,
578 ) -> Self {
579 Self {
580 tokenizer,
581 provider_used: provider_name(engine.provider()),
582 backend: Backend::E40(engine),
583 model_dir: config.model_dir.clone(),
584 native_dim: EMBEDDINGGEMMA_NATIVE_DIM,
585 truncate_dim: config.truncate_dim,
586 max_tokens: config.max_tokens.min(e40::MAX_TOKENS),
588 max_batch: config.max_batch,
589 model_id: EMBEDDINGGEMMA2_E40_MODEL_ID.to_owned(),
590 gemma2: true,
591 pad_id,
592 takes_token_type_ids: false,
593 fallback_reason: None,
594 parity: None,
595 load_timings: None,
596 pipeline_id: OnceLock::new(),
597 }
598 }
599
600 fn load_e40(
606 config: EmbedderConfig,
607 tokenizer: Tokenizer,
608 pad_id: i64,
609 tokenizer_load: Duration,
610 ) -> Result<Self, EmbedError> {
611 use rightkit_onnx::ExecutionProvider as Ep;
612 let (requested, auto) = match config.provider {
613 ExecutionProvider::Cpu => (Ep::Cpu, false),
614 ExecutionProvider::DirectMl => (Ep::DirectMl, false),
615 ExecutionProvider::CoreMl => {
616 return Err(EmbedError::InvalidConfig(
617 "an E40 bundle runs on DirectML or CPU, not CoreML".into(),
618 ))
619 }
620 ExecutionProvider::Auto => match Ep::platform_accelerator() {
621 Ep::DirectMl => (Ep::DirectMl, true),
622 _ => (Ep::Cpu, false),
623 },
624 };
625 let profile = config
626 .e40_session_profile
627 .unwrap_or_else(|| E40SessionProfile::shipped(config.intra_threads));
628 let engine = E40Engine::open(&config.model_dir, requested, profile)?;
629 let timings = LoadTimings {
630 tokenizer_load,
631 token_table_read: engine.token_table_read(),
632 ..LoadTimings::default()
633 };
634 let mut embedder = Self::from_e40(&config, tokenizer, pad_id, engine);
635 embedder.load_timings = Some(timings);
636 if requested == Ep::Cpu {
637 return Ok(embedder);
638 }
639 Self::admit_or_fall_back(embedder, &config, auto)
640 }
641
642 #[cfg(target_os = "macos")]
643 fn from_coreml(
644 config: &EmbedderConfig,
645 tokenizer: Tokenizer,
646 pad_id: i64,
647 engine: CoreMlEngine,
648 ) -> Self {
649 Self {
650 tokenizer,
651 provider_used: engine.provider_name(),
652 backend: Backend::CoreMl(engine),
653 model_dir: config.model_dir.clone(),
654 native_dim: EMBEDDINGGEMMA_NATIVE_DIM,
655 truncate_dim: config.truncate_dim,
656 max_tokens: config.max_tokens.min(e40::MAX_TOKENS),
658 max_batch: config.max_batch,
659 model_id: EMBEDDINGGEMMA2_COREML_MODEL_ID.to_owned(),
660 gemma2: true,
661 pad_id,
662 takes_token_type_ids: false,
663 fallback_reason: None,
664 parity: None,
665 load_timings: Some(LoadTimings::default()),
666 pipeline_id: OnceLock::new(),
667 }
668 }
669
670 #[cfg(target_os = "macos")]
682 fn load_coreml(
683 config: EmbedderConfig,
684 tokenizer: Tokenizer,
685 pad_id: i64,
686 ) -> Result<Self, EmbedError> {
687 let accelerated = || config.coreml_units.clone().unwrap_or_default();
688 let (units, auto) = match config.provider {
689 ExecutionProvider::Cpu => (CoreMlUnits::uniform(CoreMlComputeUnit::CpuOnly), false),
690 ExecutionProvider::Auto => (accelerated(), true),
691 ExecutionProvider::CoreMl => (accelerated(), false),
692 ExecutionProvider::DirectMl => {
693 return Err(EmbedError::InvalidConfig(
694 "a Core ML bundle runs on Core ML or CPU, not DirectML".into(),
695 ))
696 }
697 };
698 let cpu_only = units.is_cpu_only();
699 let engine = match CoreMlEngine::open(
700 &config.model_dir,
701 units,
702 config.coreml_cache_dir.as_deref(),
703 ) {
704 Ok(engine) => engine,
705 Err(error) if auto => {
706 return match &config.fallback_model_dir {
707 Some(dir) => {
708 Self::load_fallback(&config, dir, format!("coreml -> cpu: {error}"))
709 }
710 None => Err(error),
711 }
712 }
713 Err(error) => return Err(error),
714 };
715 let embedder = Self::from_coreml(&config, tokenizer, pad_id, engine);
716 if cpu_only {
718 return Ok(embedder);
719 }
720 Self::admit_or_fall_back(embedder, &config, auto)
721 }
722
723 fn admit_or_fall_back(
727 mut embedder: Self,
728 config: &EmbedderConfig,
729 auto: bool,
730 ) -> Result<Self, EmbedError> {
731 let config = config.clone();
732 let accelerator = embedder.provider_used;
733 if auto {
734 let probe_started = Instant::now();
735 let probed = embedder.probe();
736 if let Some(timings) = embedder.load_timings.as_mut() {
737 timings.probe = Some(probe_started.elapsed());
738 }
739 if let Err(reason) = probed {
740 return embedder.e40_not_admitted(
741 &config,
742 format!("{accelerator} -> cpu: probe inference failed: {reason}"),
743 );
744 }
745 }
746 match embedder.admit_accelerator(&config, auto) {
747 Ok(admitted) if admitted.provider_used == "cpu" => {
749 match (&config.fallback_model_dir, admitted.fallback_reason.clone()) {
750 (Some(dir), Some(reason)) => Self::load_fallback(&config, dir, reason),
751 _ => Ok(admitted),
752 }
753 }
754 Ok(admitted) => Ok(admitted),
755 Err(error) if auto => match &config.fallback_model_dir {
756 Some(dir) => {
757 Self::load_fallback(&config, dir, format!("{accelerator} -> cpu: {error}"))
758 }
759 None => Err(error),
760 },
761 Err(error) => Err(error),
762 }
763 }
764
765 fn e40_not_admitted(
768 mut self,
769 config: &EmbedderConfig,
770 reason: String,
771 ) -> Result<Self, EmbedError> {
772 if let Some(dir) = &config.fallback_model_dir {
773 return Self::load_fallback(config, dir, reason);
774 }
775 let mut cpu = self.cpu_twin(config)?;
776 cpu.fallback_reason = Some(reason);
777 cpu.load_timings = self.load_timings.take();
778 Ok(cpu)
779 }
780
781 fn load_fallback(
784 config: &EmbedderConfig,
785 dir: &Path,
786 reason: String,
787 ) -> Result<Self, EmbedError> {
788 if e40::is_bundle(dir) || coreml_bundle_files(dir)?.is_some() {
789 return Err(EmbedError::InvalidConfig(
790 "the fallback model directory must hold a Q4 bundle, not an E40 or Core ML bundle"
791 .into(),
792 ));
793 }
794 let mut fallback = config.clone();
795 fallback.model_dir = dir.to_path_buf();
796 fallback.provider = ExecutionProvider::Cpu;
797 fallback.fallback_model_dir = None;
798 let mut embedder = Self::load(fallback)?;
799 embedder.fallback_reason = Some(format!(
800 "{reason}; serving {} on cpu from the fallback model directory",
801 embedder.model_id
802 ));
803 Ok(embedder)
804 }
805
806 fn cpu_twin(&self, config: &EmbedderConfig) -> Result<Self, EmbedError> {
808 let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
809 match &self.backend {
810 Backend::Onnx(_) => {
811 let built = build_session(config, rightkit_onnx::ExecutionProvider::Cpu, false)?;
812 Ok(Self::from_session(
813 config,
814 tokenizer,
815 pad_id,
816 self.gemma2,
817 built,
818 ))
819 }
820 Backend::E40(engine) => Ok(Self::from_e40(
821 config,
822 tokenizer,
823 pad_id,
824 engine.with_provider(rightkit_onnx::ExecutionProvider::Cpu),
825 )),
826 #[cfg(target_os = "macos")]
828 Backend::CoreMl(engine) => Ok(Self::from_coreml(
829 config,
830 tokenizer,
831 pad_id,
832 engine.with_units(CoreMlUnits::uniform(CoreMlComputeUnit::CpuOnly)),
833 )),
834 }
835 }
836
837 fn is_fixed_graph(&self) -> bool {
839 match self.backend {
840 Backend::Onnx(_) => false,
841 Backend::E40(_) => true,
842 #[cfg(target_os = "macos")]
843 Backend::CoreMl(_) => true,
844 }
845 }
846
847 fn release_graphs(&self) {
850 match &self.backend {
851 Backend::Onnx(_) => {}
852 Backend::E40(engine) => engine.release(),
853 #[cfg(target_os = "macos")]
854 Backend::CoreMl(engine) => engine.release(),
855 }
856 }
857
858 fn prewarms(&self, config: &EmbedderConfig) -> bool {
860 let (fixed_graph_accelerator, default_on) = match &self.backend {
862 Backend::E40(_) => (true, false),
863 #[cfg(target_os = "macos")]
864 Backend::CoreMl(_) => (true, true),
865 Backend::Onnx(_) => (false, false),
866 };
867 config.prewarm.unwrap_or(default_on)
868 && self.provider_used != "cpu"
869 && fixed_graph_accelerator
870 }
871
872 fn prewarm_graphs(&mut self) -> Result<(), EmbedError> {
876 let (elapsed, per_length) = match &self.backend {
877 Backend::E40(engine) => (engine.prewarm(self.pad_id)?, Vec::new()),
878 #[cfg(target_os = "macos")]
879 Backend::CoreMl(engine) => engine.prewarm(self.pad_id)?,
880 Backend::Onnx(_) => return Ok(()),
881 };
882 if let Some(timings) = self.load_timings.as_mut() {
883 timings.prewarm = Some(elapsed);
884 timings.prewarm_per_length = per_length;
885 }
886 Ok(())
887 }
888
889 fn admit_accelerator(
895 mut self,
896 config: &EmbedderConfig,
897 auto: bool,
898 ) -> Result<Self, EmbedError> {
899 let provider = self.provider_used;
900 let check_started = Instant::now();
901 let cache = self.parity_cache_target(config);
902 let mut miss_reason: Option<&'static str> = None;
903 if let Some(target) = &cache {
904 match parity_cache::read(
905 &target.dir,
906 &config.model_dir,
907 &target.key,
908 parity_cache::now_unix(),
909 ) {
910 parity_cache::Lookup::Hit(verdict) => {
911 self.record_parity_timings(
912 check_started,
913 None,
914 None,
915 ParityCacheOutcome::Hit {
916 passed: verdict.passed,
917 },
918 );
919 return self.apply_cached_verdict(&verdict, config, auto);
920 }
921 parity_cache::Lookup::Miss(reason) => miss_reason = Some(reason),
922 }
923 }
924 let outcome = |stored: bool| match miss_reason {
926 Some(reason) => ParityCacheOutcome::Miss { reason, stored },
927 None => ParityCacheOutcome::NotUsed,
928 };
929 let mut cpu = self.cpu_twin(config)?;
930 let prompts = cpu.parity_prompts();
931 let tokens: Vec<usize> = cpu
932 .encode_prompts(&prompts)?
933 .iter()
934 .map(|row| row.ids.len())
935 .collect();
936 let reference = cpu.parity_vectors(&prompts)?;
937 let cpu_reference = check_started.elapsed();
938 let accelerator_started = Instant::now();
939 let candidate = self.parity_vectors(&prompts);
940 let accelerator = accelerator_started.elapsed();
941 let (lowest_cosine, detail) = match candidate {
942 Ok(candidate) => match compare_parity(
943 &reference,
944 &candidate,
945 self.native_dim,
946 ACCELERATOR_PARITY_MIN_COSINE,
947 ) {
948 Ok(lowest) => {
949 let parity = AcceleratorParity {
950 lowest_cosine: lowest.cosine,
951 at_tokens: tokens[lowest.index % tokens.len()],
952 pairs: reference.len(),
953 };
954 self.parity = Some(parity);
955 let prewarm = self.prewarms(config);
958 if !prewarm {
959 self.release_graphs();
960 }
961 let stored = cache.as_ref().is_some_and(|target| {
962 target.store(
963 &config.model_dir,
964 &parity_cache::ParityVerdict {
965 passed: true,
966 lowest_cosine: Some(parity.lowest_cosine),
967 at_tokens: parity.at_tokens,
968 pairs: parity.pairs,
969 detail: String::new(),
970 timestamp_unix: parity_cache::now_unix(),
971 },
972 )
973 });
974 self.record_parity_timings(
975 check_started,
976 Some(cpu_reference),
977 Some(accelerator),
978 outcome(stored),
979 );
980 if prewarm {
981 self.prewarm_graphs()?;
982 }
983 return Ok(self);
984 }
985 Err(miss) => {
986 let (lowest_cosine, detail) =
987 parity_miss_detail(&miss, &tokens, ACCELERATOR_PARITY_MIN_COSINE);
988 let stored = cache.as_ref().is_some_and(|target| {
991 target.store(
992 &config.model_dir,
993 &parity_cache::ParityVerdict {
994 passed: false,
995 lowest_cosine,
996 at_tokens: 0,
997 pairs: reference.len(),
998 detail: detail.clone(),
999 timestamp_unix: parity_cache::now_unix(),
1000 },
1001 )
1002 });
1003 self.record_parity_timings(
1004 check_started,
1005 Some(cpu_reference),
1006 Some(accelerator),
1007 outcome(stored),
1008 );
1009 (lowest_cosine, detail)
1010 }
1011 },
1012 Err(error) if auto => {
1013 self.record_parity_timings(
1014 check_started,
1015 Some(cpu_reference),
1016 Some(accelerator),
1017 outcome(false),
1018 );
1019 (None, format!("inference error: {error}"))
1020 }
1021 Err(error) => return Err(error),
1022 };
1023 if auto {
1024 cpu.fallback_reason =
1025 Some(format!("{provider} -> cpu: vector parity failed: {detail}"));
1026 cpu.load_timings = self.load_timings.take();
1027 return Ok(cpu);
1028 }
1029 Err(EmbedError::AcceleratorParity {
1030 provider,
1031 lowest_cosine,
1032 floor: ACCELERATOR_PARITY_MIN_COSINE,
1033 detail,
1034 })
1035 }
1036
1037 fn parity_cache_target(&self, config: &EmbedderConfig) -> Option<ParityCacheTarget> {
1041 let dir = config.parity_cache_dir.clone()?;
1042 if !matches!(self.backend, Backend::E40(_)) {
1043 return None;
1044 }
1045 let key = parity_cache::ParityKey::build(
1046 &config.model_dir,
1047 &e40::ARTIFACTS,
1048 &config.runtime_path,
1049 ort::info(),
1050 self.provider_used,
1051 ACCELERATOR_PARITY_MIN_COSINE,
1052 );
1053 Some(ParityCacheTarget { dir, key })
1054 }
1055
1056 fn record_parity_timings(
1058 &mut self,
1059 started: Instant,
1060 cpu_reference: Option<Duration>,
1061 accelerator: Option<Duration>,
1062 cache: ParityCacheOutcome,
1063 ) {
1064 if let Some(timings) = self.load_timings.as_mut() {
1065 timings.parity_check_total = Some(started.elapsed());
1066 timings.parity_cpu_reference = cpu_reference;
1067 timings.parity_accelerator = accelerator;
1068 timings.parity_cache = cache;
1069 }
1070 }
1071
1072 fn apply_cached_verdict(
1075 mut self,
1076 verdict: &parity_cache::ParityVerdict,
1077 config: &EmbedderConfig,
1078 auto: bool,
1079 ) -> Result<Self, EmbedError> {
1080 let provider = self.provider_used;
1081 if verdict.passed {
1082 self.parity = verdict
1083 .lowest_cosine
1084 .map(|lowest_cosine| AcceleratorParity {
1085 lowest_cosine,
1086 at_tokens: verdict.at_tokens,
1087 pairs: verdict.pairs,
1088 });
1089 if self.prewarms(config) {
1090 self.prewarm_graphs()?;
1091 }
1092 return Ok(self);
1093 }
1094 let detail = format!("{} (cached verdict)", verdict.detail);
1095 if auto {
1096 let mut cpu = self.cpu_twin(config)?;
1097 cpu.fallback_reason =
1098 Some(format!("{provider} -> cpu: vector parity failed: {detail}"));
1099 cpu.load_timings = self.load_timings.take();
1100 return Ok(cpu);
1101 }
1102 Err(EmbedError::AcceleratorParity {
1103 provider,
1104 lowest_cosine: verdict.lowest_cosine,
1105 floor: ACCELERATOR_PARITY_MIN_COSINE,
1106 detail,
1107 })
1108 }
1109
1110 fn parity_prompts(&self) -> Vec<String> {
1118 let mut prompts = vec![
1119 self.query_prompt(PARITY_QUERY),
1120 self.document_prompt(PARITY_DOCUMENT),
1121 self.document_prompt(&parity_long_document()),
1122 ];
1123 if self.is_fixed_graph() {
1124 prompts.insert(1, self.document_prompt(&parity_sections(1)));
1125 prompts.insert(3, self.document_prompt(&parity_sections(4)));
1126 }
1127 prompts
1128 }
1129
1130 fn parity_vectors(&self, prompts: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> {
1134 let mut vectors = Vec::with_capacity(prompts.len() * 2);
1135 for prompt in prompts {
1136 vectors.extend(self.embed_prompts_native(vec![prompt.clone()])?);
1137 }
1138 vectors.extend(self.embed_prompts_native(prompts.to_vec())?);
1139 Ok(vectors)
1140 }
1141
1142 fn probe(&self) -> Result<(), String> {
1145 let rows = self
1146 .encode_prompts(&[PROBE_PROMPT.to_owned()])
1147 .map_err(|e| e.to_string())?;
1148 let chunk = [&rows[0]];
1149 let native = self
1150 .run_chunk(&chunk, true)
1151 .map_err(|e| e.to_string())?
1152 .into_iter()
1153 .next()
1154 .ok_or_else(|| "probe returned no row".to_owned())?;
1155 probe_output_ok(&native, self.native_dim)
1156 }
1157
1158 pub fn model_id(&self) -> &str {
1159 &self.model_id
1160 }
1161
1162 pub fn native_dim(&self) -> usize {
1163 self.native_dim
1164 }
1165
1166 pub fn dimension(&self) -> usize {
1167 self.truncate_dim
1168 }
1169
1170 pub fn provider_used(&self) -> &str {
1172 self.provider_used
1173 }
1174
1175 pub fn fallback_reason(&self) -> Option<&str> {
1179 self.fallback_reason.as_deref()
1180 }
1181
1182 pub fn accelerator_parity(&self) -> Option<AcceleratorParity> {
1189 self.parity
1190 }
1191
1192 pub fn load_timings(&self) -> Option<&LoadTimings> {
1199 self.load_timings.as_ref()
1200 }
1201
1202 pub fn coreml_units(&self) -> Option<&CoreMlUnits> {
1206 match &self.backend {
1207 #[cfg(target_os = "macos")]
1208 Backend::CoreMl(engine) => Some(engine.units()),
1209 _ => None,
1210 }
1211 }
1212
1213 #[cfg(feature = "bench-internals")]
1216 pub fn bench_query_tokens(&self, text: &str) -> Result<BenchTokens, EmbedError> {
1217 let prompt = self.query_prompt(text);
1218 let ids = self
1219 .encode_prompts(std::slice::from_ref(&prompt))?
1220 .remove(0)
1221 .ids;
1222 Ok(BenchTokens {
1223 graph_tokens: e40::bucket_for(ids.len()),
1224 pad_id: self.pad_id,
1225 prompt,
1226 ids,
1227 })
1228 }
1229
1230 #[cfg(feature = "bench-internals")]
1234 pub fn bench_embed_query_split(&self, text: &str) -> Result<BenchEmbedSplit, EmbedError> {
1235 let Backend::E40(engine) = &self.backend else {
1236 return Err(EmbedError::InvalidConfig(
1237 "bench_embed_query_split needs an E40 bundle".into(),
1238 ));
1239 };
1240 let started = Instant::now();
1241 let prompt = self.query_prompt(text);
1242 let row = self
1243 .encode_prompts(std::slice::from_ref(&prompt))?
1244 .remove(0);
1245 let tokenize = started.elapsed();
1246 let (values, engine_split) = engine.run_split(&row.ids, self.pad_id)?;
1247 let post_started = Instant::now();
1248 let shape = [1, self.native_dim as i64];
1249 let mut pooled = pool_batch(&shape, &values, &[row.mask.as_slice()], self.native_dim)?;
1250 let _ = truncate_and_normalize(&pooled.remove(0), self.truncate_dim);
1251 Ok(BenchEmbedSplit {
1252 tokenize,
1253 engine: engine_split,
1254 post: post_started.elapsed(),
1255 total: started.elapsed(),
1256 })
1257 }
1258
1259 pub fn pipeline_id(&self) -> String {
1279 match self.pipeline_id.get_or_init(|| {
1280 let mut files = Vec::new();
1281 let names: &[&str] = match self.backend {
1282 Backend::E40(_) => &e40::ARTIFACTS,
1283 #[cfg(target_os = "macos")]
1284 Backend::CoreMl(_) => &coreml::ARTIFACTS,
1285 Backend::Onnx(_) => &[
1286 "model_q4.onnx",
1287 "model_q4.onnx_data",
1288 "tokenizer.json",
1289 "config.json",
1290 ],
1291 };
1292 for name in names {
1293 files.push((*name, hash_file(&self.model_dir.join(name))?));
1294 }
1295 Ok(pipeline_fingerprint(&PipelineParts {
1296 model_id: &self.model_id,
1297 native_dim: self.native_dim,
1298 truncate_dim: self.truncate_dim,
1299 max_tokens: self.max_tokens,
1300 scrubs_sentinels: self.gemma2,
1301 files: &files,
1302 }))
1303 }) {
1304 Ok(id) => id.clone(),
1305 Err(error) => format!("rkpipe1-unavailable-{}", sanitize(error)),
1307 }
1308 }
1309
1310 pub fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
1311 self.embed_prompts(vec![self.document_prompt(text)])?
1312 .pop()
1313 .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
1314 }
1315
1316 pub fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
1317 self.embed_prompts(vec![self.query_prompt(text)])?
1318 .pop()
1319 .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
1320 }
1321
1322 pub fn embed_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
1326 where
1327 I: IntoIterator<Item = &'a str>,
1328 {
1329 self.embed_prompts(texts.into_iter().map(|t| self.document_prompt(t)).collect())
1330 }
1331
1332 pub fn embed_query_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
1334 where
1335 I: IntoIterator<Item = &'a str>,
1336 {
1337 self.embed_prompts(texts.into_iter().map(|t| self.query_prompt(t)).collect())
1338 }
1339
1340 fn document_prompt(&self, text: &str) -> String {
1341 format!("{DOCUMENT_PROMPT_PREFIX}{}", self.model_text(text))
1342 }
1343
1344 fn query_prompt(&self, text: &str) -> String {
1345 format!("{QUERY_PROMPT_PREFIX}{}", self.model_text(text))
1346 }
1347
1348 fn model_text<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
1351 if !self.gemma2 || !GEMMA2_MODALITY_SENTINELS.iter().any(|s| text.contains(s)) {
1352 return std::borrow::Cow::Borrowed(text);
1353 }
1354 let mut owned = text.to_owned();
1355 for sentinel in GEMMA2_MODALITY_SENTINELS {
1356 owned = owned.replace(sentinel, &sentinel.replace('|', "\u{2502}"));
1357 }
1358 std::borrow::Cow::Owned(owned)
1359 }
1360
1361 fn encode_prompts(&self, prompts: &[String]) -> Result<Vec<TokenRow>, EmbedError> {
1362 let inputs: Vec<EncodeInput> = prompts
1363 .iter()
1364 .map(|prompt| EncodeInput::Single(prompt.as_str().into()))
1365 .collect();
1366 let encodings = self
1367 .tokenizer
1368 .encode_batch(inputs, true)
1369 .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
1370 encodings
1371 .iter()
1372 .map(|encoding| {
1373 let ids: Vec<i64> = encoding
1374 .get_ids()
1375 .iter()
1376 .take(self.max_tokens)
1377 .map(|id| i64::from(*id))
1378 .collect();
1379 if ids.is_empty() {
1380 return Err(EmbedError::Tokenizer("encoding returned no tokens".into()));
1381 }
1382 let mask: Vec<i64> = encoding
1383 .get_attention_mask()
1384 .iter()
1385 .take(ids.len())
1386 .map(|value| i64::from(*value))
1387 .collect();
1388 Ok(TokenRow { ids, mask })
1389 })
1390 .collect()
1391 }
1392
1393 fn embed_prompts(&self, prompts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedError> {
1394 Ok(self
1395 .embed_prompts_native(prompts)?
1396 .into_iter()
1397 .map(|native| truncate_and_normalize(&native, self.truncate_dim))
1398 .collect())
1399 }
1400
1401 fn embed_prompts_native(&self, prompts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedError> {
1404 if prompts.is_empty() {
1405 return Ok(Vec::new());
1406 }
1407 let rows = self.encode_prompts(&prompts)?;
1408 let lengths: Vec<usize> = rows.iter().map(|row| row.ids.len()).collect();
1409 let order = length_order(&lengths);
1410 let sorted_lengths: Vec<usize> = order.iter().map(|&i| lengths[i]).collect();
1411 let accelerated = self.provider_used != "cpu";
1412 let mut out: Vec<Option<Vec<f32>>> = vec![None; rows.len()];
1413 for range in plan_chunks(
1414 &sorted_lengths,
1415 self.max_batch.max(1),
1416 MAX_PADDED_TOKENS_PER_CALL,
1417 ) {
1418 let chunk: Vec<&TokenRow> = order[range.clone()].iter().map(|&i| &rows[i]).collect();
1419 let natives = self.run_chunk(&chunk, accelerated)?;
1420 for (&index, native) in order[range].iter().zip(natives) {
1421 out[index] = Some(native);
1422 }
1423 }
1424 out.into_iter()
1425 .map(|row| row.ok_or_else(|| EmbedError::Output("missing embedding row".into())))
1426 .collect()
1427 }
1428
1429 fn run_chunk(
1432 &self,
1433 chunk: &[&TokenRow],
1434 accelerated: bool,
1435 ) -> Result<Vec<Vec<f32>>, EmbedError> {
1436 let session = match &self.backend {
1437 Backend::Onnx(session) => session,
1438 Backend::E40(engine) => {
1442 return self.run_fixed_rows(chunk, |ids| engine.run(ids, self.pad_id));
1443 }
1444 #[cfg(target_os = "macos")]
1447 Backend::CoreMl(engine) => {
1448 return self.run_fixed_rows(chunk, |ids| engine.run(ids, self.pad_id));
1449 }
1450 };
1451 let longest = chunk.iter().map(|row| row.ids.len()).max().unwrap_or(1);
1452 let (batch_rows, padded_len) = padded_shape(chunk.len(), longest, accelerated);
1453 let padded = pad_batch(chunk, self.pad_id, padded_len, batch_rows);
1454 let (rows, len) = (padded.rows, padded.len);
1455 let tensor = |data: Vec<i64>| {
1456 ort::value::Tensor::from_array(([rows, len], data))
1457 .map_err(|e| EmbedError::Runtime(e.to_string()))
1458 };
1459 let mut inputs = ort::inputs![
1460 "input_ids" => tensor(padded.ids)?,
1461 "attention_mask" => tensor(padded.mask)?
1462 ];
1463 if self.takes_token_type_ids {
1464 inputs.push(("token_type_ids".into(), tensor(vec![0; rows * len])?.into()));
1465 }
1466 let mut session = session
1467 .lock()
1468 .map_err(|_| EmbedError::Runtime("model mutex poisoned".into()))?;
1469 let outputs = session
1470 .run(inputs)
1471 .map_err(|e| EmbedError::Runtime(e.to_string()))?;
1472 let output = outputs
1473 .get("sentence_embedding")
1474 .ok_or_else(|| EmbedError::Output("missing sentence_embedding output".into()))?;
1475 let (shape, values) = output
1476 .try_extract_tensor::<f32>()
1477 .map_err(|e| EmbedError::Output(e.to_string()))?;
1478 let masks: Vec<&[i64]> = chunk.iter().map(|row| row.mask.as_slice()).collect();
1479 pool_batch(shape.as_ref(), values, &masks, self.native_dim)
1480 }
1481}
1482
1483impl Embedder {
1484 fn run_fixed_rows(
1487 &self,
1488 chunk: &[&TokenRow],
1489 run: impl Fn(&[i64]) -> Result<Vec<f32>, EmbedError>,
1490 ) -> Result<Vec<Vec<f32>>, EmbedError> {
1491 let mut natives = Vec::with_capacity(chunk.len());
1492 for row in chunk {
1493 let values = run(&row.ids)?;
1494 let shape = [1, self.native_dim as i64];
1495 let mut pooled = pool_batch(&shape, &values, &[row.mask.as_slice()], self.native_dim)?;
1496 natives.push(pooled.remove(0));
1497 }
1498 Ok(natives)
1499 }
1500}
1501
1502struct ParityCacheTarget {
1504 dir: PathBuf,
1505 key: parity_cache::ParityKey,
1506}
1507
1508impl ParityCacheTarget {
1509 fn store(&self, model_dir: &Path, verdict: &parity_cache::ParityVerdict) -> bool {
1512 parity_cache::write(&self.dir, model_dir, &self.key, verdict).is_ok()
1513 }
1514}
1515
1516#[cfg(feature = "bench-internals")]
1518#[derive(Debug, Clone, PartialEq, Eq)]
1519pub struct BenchTokens {
1520 pub prompt: String,
1522 pub ids: Vec<i64>,
1524 pub graph_tokens: Option<usize>,
1526 pub pad_id: i64,
1528}
1529
1530#[cfg(feature = "bench-internals")]
1532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1533pub struct BenchEmbedSplit {
1534 pub tokenize: Duration,
1536 pub engine: e40::RunSplit,
1538 pub post: Duration,
1540 pub total: Duration,
1542}
1543
1544struct TokenRow {
1546 ids: Vec<i64>,
1547 mask: Vec<i64>,
1548}
1549
1550#[derive(Debug, PartialEq, Eq)]
1552struct PaddedBatch {
1553 rows: usize,
1554 len: usize,
1555 ids: Vec<i64>,
1556 mask: Vec<i64>,
1557}
1558
1559fn length_order(lengths: &[usize]) -> Vec<usize> {
1562 let mut order: Vec<usize> = (0..lengths.len()).collect();
1563 order.sort_by_key(|&i| lengths[i]);
1564 order
1565}
1566
1567fn plan_chunks(
1571 sorted_lengths: &[usize],
1572 max_rows: usize,
1573 token_budget: usize,
1574) -> Vec<std::ops::Range<usize>> {
1575 let mut ranges = Vec::new();
1576 let mut start = 0;
1577 while start < sorted_lengths.len() {
1578 let mut end = start + 1;
1579 while end < sorted_lengths.len()
1580 && end - start < max_rows
1581 && (end - start + 1) * sorted_lengths[end] <= token_budget
1582 {
1583 end += 1;
1584 }
1585 ranges.push(start..end);
1586 start = end;
1587 }
1588 ranges
1589}
1590
1591fn padded_shape(rows: usize, longest: usize, accelerated: bool) -> (usize, usize) {
1595 if !accelerated {
1596 return (rows, longest);
1597 }
1598 let len = ACCELERATOR_BUCKETS
1599 .iter()
1600 .copied()
1601 .find(|bucket| *bucket >= longest)
1602 .unwrap_or(longest);
1603 (rows.next_power_of_two(), len)
1604}
1605
1606fn pad_batch(chunk: &[&TokenRow], pad_id: i64, len: usize, rows: usize) -> PaddedBatch {
1609 let mut ids = vec![pad_id; rows * len];
1610 let mut mask = vec![0i64; rows * len];
1611 for row in 0..rows {
1612 let source = chunk[row.min(chunk.len() - 1)];
1613 let used = source.ids.len().min(len);
1614 ids[row * len..row * len + used].copy_from_slice(&source.ids[..used]);
1615 mask[row * len..row * len + used].copy_from_slice(&source.mask[..used]);
1616 }
1617 PaddedBatch {
1618 rows,
1619 len,
1620 ids,
1621 mask,
1622 }
1623}
1624
1625fn pool_batch(
1630 shape: &[i64],
1631 values: &[f32],
1632 masks: &[&[i64]],
1633 native_dim: usize,
1634) -> Result<Vec<Vec<f32>>, EmbedError> {
1635 let real = masks.len();
1636 if shape.len() == 2 && shape[1] == native_dim as i64 {
1637 let batch = shape[0] as usize;
1638 if batch >= real && values.len() == batch * native_dim {
1639 return Ok((0..real)
1640 .map(|row| normalize(values[row * native_dim..(row + 1) * native_dim].to_vec()))
1641 .collect());
1642 }
1643 }
1644 if shape.len() == 3 && shape[2] == native_dim as i64 && shape[0] > 0 && shape[1] > 0 {
1645 let (batch, tokens) = (shape[0] as usize, shape[1] as usize);
1646 if batch >= real && values.len() == batch * tokens * native_dim {
1647 let mut pooled_rows = Vec::with_capacity(real);
1648 for (row, mask) in masks.iter().enumerate() {
1649 let mut pooled = vec![0.0f32; native_dim];
1650 let mut count = 0.0f32;
1651 for token in 0..tokens {
1652 if mask.get(token).copied().unwrap_or(0) == 0 {
1653 continue;
1654 }
1655 count += 1.0;
1656 let base = (row * tokens + token) * native_dim;
1657 for (dim, value) in pooled.iter_mut().enumerate() {
1658 *value += values[base + dim];
1659 }
1660 }
1661 if count == 0.0 {
1662 return Err(EmbedError::Output(
1663 "attention mask contains no tokens".into(),
1664 ));
1665 }
1666 for value in &mut pooled {
1667 *value /= count;
1668 }
1669 pooled_rows.push(normalize(pooled));
1670 }
1671 return Ok(pooled_rows);
1672 }
1673 }
1674 Err(EmbedError::Output(format!(
1675 "expected [B,{native_dim}] or [B,tokens,{native_dim}] with B >= {real}, got shape {shape:?}"
1676 )))
1677}
1678
1679fn probe_output_ok(native: &[f32], native_dim: usize) -> Result<(), String> {
1680 if native.len() != native_dim {
1681 return Err(format!(
1682 "expected {native_dim} values, got {}",
1683 native.len()
1684 ));
1685 }
1686 if native.iter().any(|value| !value.is_finite()) {
1687 return Err("non-finite values".into());
1688 }
1689 if native.iter().map(|value| value * value).sum::<f32>() <= f32::EPSILON {
1690 return Err("all-zero output".into());
1691 }
1692 Ok(())
1693}
1694
1695fn parity_long_document() -> String {
1698 parity_sections(PARITY_LONG_SECTIONS)
1699}
1700
1701fn parity_sections(count: usize) -> String {
1703 (1..=count)
1704 .map(|n| {
1705 format!(
1706 "Section {n}: the nightly billing job reconciles every invoice against the \
1707 ledger, flags mismatched totals for account {}, and sends the exceptions to \
1708 the finance team for review before payment run {n}.",
1709 1000 + n * 7
1710 )
1711 })
1712 .collect::<Vec<_>>()
1713 .join(" ")
1714}
1715
1716#[derive(Debug, Clone, Copy, PartialEq)]
1718struct ParityLowest {
1719 index: usize,
1720 cosine: f32,
1721}
1722
1723#[derive(Debug, Clone, PartialEq)]
1725enum ParityMiss {
1726 Invalid { index: usize, reason: String },
1729 Below(ParityLowest),
1731}
1732
1733fn cosine(a: &[f32], b: &[f32]) -> f32 {
1734 let (mut dot, mut na, mut nb) = (0.0f64, 0.0f64, 0.0f64);
1735 for (x, y) in a.iter().zip(b) {
1736 let (x, y) = (f64::from(*x), f64::from(*y));
1737 dot += x * y;
1738 na += x * x;
1739 nb += y * y;
1740 }
1741 (dot / (na.sqrt() * nb.sqrt())) as f32
1742}
1743
1744fn compare_parity(
1749 reference: &[Vec<f32>],
1750 candidate: &[Vec<f32>],
1751 native_dim: usize,
1752 floor: f32,
1753) -> Result<ParityLowest, ParityMiss> {
1754 if reference.is_empty() || reference.len() != candidate.len() {
1755 return Err(ParityMiss::Invalid {
1756 index: 0,
1757 reason: format!(
1758 "expected {} vectors to compare, got {}",
1759 reference.len(),
1760 candidate.len()
1761 ),
1762 });
1763 }
1764 let mut lowest = ParityLowest {
1765 index: 0,
1766 cosine: f32::INFINITY,
1767 };
1768 for (index, (r, c)) in reference.iter().zip(candidate).enumerate() {
1769 probe_output_ok(r, native_dim).map_err(|reason| ParityMiss::Invalid {
1770 index,
1771 reason: format!("cpu reference: {reason}"),
1772 })?;
1773 probe_output_ok(c, native_dim).map_err(|reason| ParityMiss::Invalid {
1774 index,
1775 reason: format!("accelerator output: {reason}"),
1776 })?;
1777 let value = cosine(r, c);
1778 if value < lowest.cosine {
1779 lowest = ParityLowest {
1780 index,
1781 cosine: value,
1782 };
1783 }
1784 }
1785 if lowest.cosine >= floor {
1786 Ok(lowest)
1787 } else {
1788 Err(ParityMiss::Below(lowest))
1789 }
1790}
1791
1792fn parity_miss_detail(miss: &ParityMiss, tokens: &[usize], floor: f32) -> (Option<f32>, String) {
1796 let at = |index: usize| {
1797 let batched = if index >= tokens.len() {
1798 " (batched)"
1799 } else {
1800 ""
1801 };
1802 format!("~{} tokens{batched}", tokens[index % tokens.len()])
1803 };
1804 match miss {
1805 ParityMiss::Below(lowest) => (
1806 Some(lowest.cosine),
1807 format!(
1808 "cosine {:.3} at {}, floor {floor}",
1809 lowest.cosine,
1810 at(lowest.index)
1811 ),
1812 ),
1813 ParityMiss::Invalid { index, reason } => (None, format!("{reason} at {}", at(*index))),
1814 }
1815}
1816
1817struct PipelineParts<'a> {
1818 model_id: &'a str,
1819 native_dim: usize,
1820 truncate_dim: usize,
1821 max_tokens: usize,
1822 scrubs_sentinels: bool,
1823 files: &'a [(&'a str, String)],
1824}
1825
1826fn pipeline_fingerprint(parts: &PipelineParts<'_>) -> String {
1828 let mut text = String::new();
1829 let mut line = |key: &str, value: &str| {
1830 text.push_str(key);
1831 text.push('=');
1832 text.push_str(&value.len().to_string());
1833 text.push(':');
1834 text.push_str(value);
1835 text.push('\n');
1836 };
1837 line("schema", PIPELINE_SCHEMA);
1838 line("model_id", parts.model_id);
1839 line("native_dim", &parts.native_dim.to_string());
1840 line("truncate_dim", &parts.truncate_dim.to_string());
1841 line("max_tokens", &parts.max_tokens.to_string());
1842 line("document_prompt", DOCUMENT_PROMPT_PREFIX);
1843 line("query_prompt", QUERY_PROMPT_PREFIX);
1844 line("pooling", POOLING_DESCRIPTION);
1845 line("normalization", NORMALIZATION_DESCRIPTION);
1846 line("scrubs_sentinels", &parts.scrubs_sentinels.to_string());
1847 for (name, hash) in parts.files {
1848 line(&format!("file:{name}"), hash);
1849 }
1850 let digest = rightkit_fs::digest::sha256_bytes(text.as_bytes());
1852 format!("rkpipe1-{}", &digest[..32])
1853}
1854
1855fn sanitize(text: &str) -> String {
1856 text.chars()
1857 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1858 .take(48)
1859 .collect()
1860}
1861
1862pub(crate) fn hash_file(path: &Path) -> Result<String, String> {
1865 let mut file = match std::fs::File::open(path) {
1866 Ok(file) => file,
1867 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok("absent".into()),
1868 Err(error) => return Err(format!("{}: {error}", path.display())),
1869 };
1870 rightkit_fs::digest::sha256_reader(&mut file).map_err(|e| format!("{}: {e}", path.display()))
1871}
1872
1873fn bundle_is_gemma2(model_dir: &std::path::Path) -> Result<bool, EmbedError> {
1877 let path = model_dir.join("config.json");
1878 let Ok(bytes) = std::fs::read(&path) else {
1879 return Ok(false);
1880 };
1881 let config: serde_json::Value = serde_json::from_slice(&bytes)
1882 .map_err(|e| EmbedError::InvalidConfig(format!("{}: {e}", path.display())))?;
1883 Ok(config["model_type"] == "embedding_gemma2")
1884}
1885
1886#[cfg(target_os = "macos")]
1889fn coreml_bundle_files(dir: &Path) -> Result<Option<&'static [&'static str]>, EmbedError> {
1890 let files: &'static [&'static str] = &coreml::ARTIFACTS;
1891 Ok(coreml::is_bundle(dir).then_some(files))
1892}
1893
1894#[cfg(not(target_os = "macos"))]
1895fn coreml_bundle_files(dir: &Path) -> Result<Option<&'static [&'static str]>, EmbedError> {
1896 if dir.join("EmbeddingGemma2Text.mlpackage").is_dir() {
1897 return Err(EmbedError::InvalidConfig(
1898 "a Core ML bundle (EmbeddingGemma2Text.mlpackage) runs on macOS only".into(),
1899 ));
1900 }
1901 Ok(None)
1902}
1903
1904fn validate_config(config: &EmbedderConfig) -> Result<(), EmbedError> {
1905 if !config.model_dir.is_dir() {
1906 return Err(EmbedError::MissingModelDirectory(config.model_dir.clone()));
1907 }
1908 if config.truncate_dim == 0 || config.truncate_dim > EMBEDDINGGEMMA_NATIVE_DIM {
1909 return Err(EmbedError::InvalidConfig(format!(
1910 "truncate dimension must be 1..={EMBEDDINGGEMMA_NATIVE_DIM}"
1911 )));
1912 }
1913 if config.max_tokens == 0 || config.max_tokens > MAX_SEQUENCE_TOKENS {
1914 return Err(EmbedError::InvalidConfig(format!(
1915 "max tokens must be 1..={MAX_SEQUENCE_TOKENS}"
1916 )));
1917 }
1918 if config.max_batch == 0 {
1919 return Err(EmbedError::InvalidConfig("max batch must be >= 1".into()));
1920 }
1921 if config.intra_threads == 0 {
1922 return Err(EmbedError::InvalidConfig(
1923 "intra threads must be >= 1".into(),
1924 ));
1925 }
1926 let coreml_files = coreml_bundle_files(&config.model_dir)?;
1927 let coreml_bundle = coreml_files.is_some();
1928 let required: &[&str] = if let Some(files) = coreml_files {
1929 files
1930 } else if e40::is_bundle(&config.model_dir) {
1931 &e40::ARTIFACTS
1932 } else {
1933 &["model_q4.onnx", "model_q4.onnx_data", "tokenizer.json"]
1934 };
1935 for name in required {
1936 let path = config.model_dir.join(name);
1937 if !path.is_file() {
1938 return Err(EmbedError::MissingArtifact(path));
1939 }
1940 }
1941 if !coreml_bundle && !config.runtime_path.is_file() {
1943 return Err(EmbedError::MissingArtifact(config.runtime_path.clone()));
1944 }
1945 Ok(())
1946}
1947
1948fn normalize(mut values: Vec<f32>) -> Vec<f32> {
1949 let norm = values.iter().map(|value| value * value).sum::<f32>().sqrt();
1950 if norm > 0.0 {
1951 for value in &mut values {
1952 *value /= norm;
1953 }
1954 }
1955 values
1956}
1957
1958pub fn truncate_and_normalize(native: &[f32], dimension: usize) -> Vec<f32> {
1959 let mut values = native[..native.len().min(dimension)].to_vec();
1960 values.resize(dimension, 0.0);
1961 normalize(values)
1962}
1963
1964#[cfg(test)]
1965mod tests {
1966 use super::*;
1967
1968 fn row(ids: &[i64]) -> TokenRow {
1969 TokenRow {
1970 ids: ids.to_vec(),
1971 mask: vec![1; ids.len()],
1972 }
1973 }
1974
1975 fn state(ids: &[i64], dim: usize) -> Vec<f32> {
1978 ids.iter()
1979 .enumerate()
1980 .flat_map(|(pos, id)| {
1981 (0..dim)
1982 .map(move |d| ((*id as f32) * 0.37 + pos as f32 * 0.11 + d as f32 * 0.05).sin())
1983 })
1984 .collect()
1985 }
1986
1987 #[test]
1988 fn pad_batch_pads_right_with_zero_mask_and_repeats_last_row() {
1989 let a = row(&[5, 6, 7]);
1990 let b = row(&[9]);
1991 let padded = pad_batch(&[&a, &b], 0, 4, 3);
1992 assert_eq!((padded.rows, padded.len), (3, 4));
1993 assert_eq!(padded.ids, vec![5, 6, 7, 0, 9, 0, 0, 0, 9, 0, 0, 0]);
1994 assert_eq!(padded.mask, vec![1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
1995 }
1996
1997 #[test]
1998 fn pad_batch_uses_the_pad_id_for_masked_positions() {
1999 let a = row(&[5]);
2000 let padded = pad_batch(&[&a], 3, 3, 1);
2001 assert_eq!(padded.ids, vec![5, 3, 3]);
2002 assert_eq!(padded.mask, vec![1, 0, 0]);
2003 }
2004
2005 #[test]
2006 fn padded_mean_pool_matches_unpadded_rows() {
2007 let dim = 8;
2008 let rows_ids: [&[i64]; 3] = [&[3, 4, 5, 6, 7], &[10, 11], &[20, 21, 22]];
2009 let rows: Vec<TokenRow> = rows_ids.iter().map(|ids| row(ids)).collect();
2010 let refs: Vec<&TokenRow> = rows.iter().collect();
2011 let padded = pad_batch(&refs, 0, 5, 3);
2012 let mut values = vec![9999.0f32; padded.rows * padded.len * dim];
2015 for (r, ids) in rows_ids.iter().enumerate() {
2016 let s = state(ids, dim);
2017 let base = r * padded.len * dim;
2018 values[base..base + s.len()].copy_from_slice(&s);
2019 }
2020 let masks: Vec<&[i64]> = rows.iter().map(|r| r.mask.as_slice()).collect();
2021 let batched =
2022 pool_batch(&[3, padded.len as i64, dim as i64], &values, &masks, dim).unwrap();
2023 for (r, ids) in rows_ids.iter().enumerate() {
2024 let single = pool_batch(
2025 &[1, ids.len() as i64, dim as i64],
2026 &state(ids, dim),
2027 &[rows[r].mask.as_slice()],
2028 dim,
2029 )
2030 .unwrap();
2031 let diff = batched[r]
2032 .iter()
2033 .zip(&single[0])
2034 .map(|(a, b)| (a - b).abs())
2035 .fold(0.0f32, f32::max);
2036 assert!(diff <= 1e-6, "row {r} differs by {diff}");
2037 let norm: f32 = batched[r].iter().map(|v| v * v).sum::<f32>().sqrt();
2038 assert!((norm - 1.0).abs() <= 1e-5);
2039 }
2040 }
2041
2042 #[test]
2043 fn pooled_output_rows_are_normalized_and_extra_rows_dropped() {
2044 let dim = 4;
2045 let values = vec![3.0, 4.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0, 1.0, 1.0, 1.0];
2046 let m = [1i64];
2047 let out = pool_batch(&[3, 4], &values, &[&m, &m], dim).unwrap();
2048 assert_eq!(out.len(), 2);
2049 assert_eq!(out[0], vec![0.6, 0.8, 0.0, 0.0]);
2050 assert_eq!(out[1], vec![0.0, 0.0, 1.0, 0.0]);
2051 }
2052
2053 #[test]
2054 fn pool_rejects_bad_shapes_and_empty_masks() {
2055 let m = [0i64, 0];
2056 assert!(pool_batch(&[1, 2, 2], &[1.0; 4], &[&m], 2).is_err());
2057 assert!(pool_batch(&[1, 3], &[1.0; 3], &[&[1]], 2).is_err());
2058 assert!(pool_batch(&[1, 2], &[1.0; 2], &[&[1], &[1]], 2).is_err());
2059 }
2060
2061 #[test]
2062 fn chunks_respect_row_cap_token_budget_and_cover_everything() {
2063 let lengths = [1, 2, 3, 4, 5, 6, 7];
2064 assert_eq!(plan_chunks(&lengths, 3, 1000), vec![0..3, 3..6, 6..7]);
2065 assert_eq!(plan_chunks(&[1, 3, 4, 4], 8, 10), vec![0..2, 2..4]);
2067 assert_eq!(plan_chunks(&[50, 60], 8, 10), vec![0..1, 1..2]);
2069 assert!(plan_chunks(&[], 4, 10).is_empty());
2070 }
2071
2072 #[test]
2073 fn length_order_is_stable_ascending() {
2074 assert_eq!(length_order(&[5, 2, 5, 1, 2]), vec![3, 1, 4, 0, 2]);
2075 }
2076
2077 #[test]
2078 fn accelerators_use_bucketed_shapes_cpu_uses_exact() {
2079 assert_eq!(padded_shape(3, 70, false), (3, 70));
2080 assert_eq!(padded_shape(3, 70, true), (4, 128));
2081 assert_eq!(padded_shape(1, 64, true), (1, 64));
2082 assert_eq!(padded_shape(16, 2048, true), (16, 2048));
2083 }
2084
2085 #[test]
2086 fn probe_rejects_zero_nonfinite_and_wrong_size() {
2087 assert!(probe_output_ok(&[0.0; 4], 4).is_err());
2088 assert!(probe_output_ok(&[f32::NAN, 1.0, 0.0, 0.0], 4).is_err());
2089 assert!(probe_output_ok(&[1.0; 3], 4).is_err());
2090 assert!(probe_output_ok(&[0.5, 0.5, 0.5, 0.5], 4).is_ok());
2091 }
2092
2093 #[test]
2094 fn parity_floor_is_pinned() {
2095 assert_eq!(ACCELERATOR_PARITY_MIN_COSINE, 0.995);
2096 }
2097
2098 #[test]
2099 fn identical_vectors_pass_parity_and_report_the_lowest_pair() {
2100 let reference = vec![vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]];
2101 let lowest = compare_parity(&reference, &reference.clone(), 3, 0.995).unwrap();
2102 assert!((lowest.cosine - 1.0).abs() <= 1e-6);
2103 let candidate = vec![vec![1.0, 0.0, 0.0], vec![0.0, 0.6, 0.8]];
2105 let at_floor = cosine(&reference[1], &candidate[1]);
2106 let lowest = compare_parity(&reference, &candidate, 3, at_floor).unwrap();
2107 assert_eq!((lowest.index, lowest.cosine), (1, at_floor));
2108 }
2109
2110 #[test]
2111 fn a_low_cosine_pair_fails_and_reports_value_and_index() {
2112 let wrong = vec![(1.0f32 - 0.58 * 0.58).sqrt(), 0.58];
2114 let reference = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]];
2115 let candidate = vec![vec![1.0, 0.0], wrong.clone(), vec![1.0, 0.0]];
2116 match compare_parity(&reference, &candidate, 2, 0.995) {
2117 Err(ParityMiss::Below(lowest)) => {
2118 assert_eq!(lowest.index, 1);
2119 assert!((lowest.cosine - 0.58).abs() <= 1e-6, "{lowest:?}");
2120 }
2121 other => panic!("expected a Below miss, got {other:?}"),
2122 }
2123 let single = compare_parity(&[vec![0.0, 1.0]], &[wrong], 2, 0.995);
2124 match single {
2125 Err(ParityMiss::Below(lowest)) => assert!((lowest.cosine - 0.58).abs() <= 1e-6),
2126 other => panic!("expected a Below miss, got {other:?}"),
2127 }
2128 }
2129
2130 #[test]
2131 fn nonfinite_zero_or_wrongly_sized_vectors_fail_parity() {
2132 let good = vec![vec![1.0, 0.0]];
2133 for bad in [
2134 vec![f32::NAN, 1.0],
2135 vec![f32::INFINITY, 0.0],
2136 vec![0.0, 0.0],
2137 vec![1.0, 0.0, 0.0],
2138 ] {
2139 assert!(
2140 matches!(
2141 compare_parity(&good, std::slice::from_ref(&bad), 2, 0.995),
2142 Err(ParityMiss::Invalid { index: 0, .. })
2143 ),
2144 "candidate {bad:?}"
2145 );
2146 assert!(
2147 matches!(
2148 compare_parity(std::slice::from_ref(&bad), &good, 2, 0.995),
2149 Err(ParityMiss::Invalid { index: 0, .. })
2150 ),
2151 "reference {bad:?}"
2152 );
2153 }
2154 }
2155
2156 #[test]
2157 fn mismatched_or_empty_parity_inputs_fail() {
2158 let one = vec![vec![1.0, 0.0]];
2159 let two = vec![vec![1.0, 0.0], vec![1.0, 0.0]];
2160 assert!(matches!(
2161 compare_parity(&one, &two, 2, 0.995),
2162 Err(ParityMiss::Invalid { .. })
2163 ));
2164 assert!(matches!(
2165 compare_parity(&two, &one, 2, 0.995),
2166 Err(ParityMiss::Invalid { .. })
2167 ));
2168 assert!(matches!(
2169 compare_parity(&[], &[], 2, 0.995),
2170 Err(ParityMiss::Invalid { .. })
2171 ));
2172 }
2173
2174 #[test]
2175 fn parity_miss_detail_names_cosine_length_and_floor() {
2176 let tokens = [20, 100, 600];
2177 let miss = ParityMiss::Below(ParityLowest {
2178 index: 0,
2179 cosine: 0.5791,
2180 });
2181 assert_eq!(
2182 parity_miss_detail(&miss, &tokens, 0.995),
2183 (
2184 Some(0.5791),
2185 "cosine 0.579 at ~20 tokens, floor 0.995".to_owned()
2186 )
2187 );
2188 let batched = ParityMiss::Below(ParityLowest {
2189 index: 5,
2190 cosine: 0.71,
2191 });
2192 assert_eq!(
2193 parity_miss_detail(&batched, &tokens, 0.995).1,
2194 "cosine 0.710 at ~600 tokens (batched), floor 0.995"
2195 );
2196 let invalid = ParityMiss::Invalid {
2197 index: 1,
2198 reason: "accelerator output: non-finite values".into(),
2199 };
2200 assert_eq!(
2201 parity_miss_detail(&invalid, &tokens, 0.995),
2202 (
2203 None,
2204 "accelerator output: non-finite values at ~100 tokens".to_owned()
2205 )
2206 );
2207 }
2208
2209 #[test]
2210 fn parity_long_document_is_deterministic_and_long() {
2211 let text = parity_long_document();
2212 assert_eq!(text, parity_long_document());
2213 assert!(text.split_whitespace().count() >= 400, "{}", text.len());
2214 assert!(PARITY_DOCUMENT.split_whitespace().count() >= 50);
2215 }
2216
2217 #[test]
2218 fn parity_error_names_provider_cosine_and_floor() {
2219 let error = EmbedError::AcceleratorParity {
2220 provider: "directml",
2221 lowest_cosine: Some(0.579),
2222 floor: ACCELERATOR_PARITY_MIN_COSINE,
2223 detail: "cosine 0.579 at ~20 tokens, floor 0.995".into(),
2224 };
2225 assert_eq!(
2226 error.to_string(),
2227 "directml failed the vector parity check against cpu: cosine 0.579 at ~20 tokens, floor 0.995"
2228 );
2229 }
2230
2231 fn parts<'a>(files: &'a [(&'a str, String)], truncate: usize) -> PipelineParts<'a> {
2232 PipelineParts {
2233 model_id: EMBEDDINGGEMMA_MODEL_ID,
2234 native_dim: 768,
2235 truncate_dim: truncate,
2236 max_tokens: 2048,
2237 scrubs_sentinels: false,
2238 files,
2239 }
2240 }
2241
2242 #[test]
2243 fn pipeline_fingerprint_is_stable_and_sensitive() {
2244 let files = [("model_q4.onnx", "aa".to_owned())];
2245 let base = pipeline_fingerprint(&parts(&files, 256));
2246 assert_eq!(base, pipeline_fingerprint(&parts(&files, 256)));
2247 assert!(base.starts_with("rkpipe1-") && base.len() == "rkpipe1-".len() + 32);
2248 assert_ne!(base, pipeline_fingerprint(&parts(&files, 128)));
2249 let changed = [("model_q4.onnx", "ab".to_owned())];
2250 assert_ne!(base, pipeline_fingerprint(&parts(&changed, 256)));
2251 let mut other = parts(&files, 256);
2252 other.scrubs_sentinels = true;
2253 assert_ne!(base, pipeline_fingerprint(&other));
2254 other = parts(&files, 256);
2255 other.max_tokens = 512;
2256 assert_ne!(base, pipeline_fingerprint(&other));
2257 }
2258
2259 #[test]
2260 fn hash_file_reports_absent_and_content() {
2261 let dir = tempfile::tempdir().unwrap();
2262 assert_eq!(hash_file(&dir.path().join("none")).unwrap(), "absent");
2263 let path = dir.path().join("f");
2264 std::fs::write(&path, b"abc").unwrap();
2265 assert_eq!(
2266 hash_file(&path).unwrap(),
2267 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
2268 );
2269 }
2270
2271 #[test]
2272 fn config_defaults_and_builders() {
2273 let c = EmbedderConfig::new("m", "r");
2274 assert_eq!((c.max_batch, c.intra_threads), (16, 1));
2275 assert_eq!(c.provider, ExecutionProvider::Cpu);
2276 let c = c
2277 .with_max_batch(4)
2278 .with_intra_threads(3)
2279 .with_provider(ExecutionProvider::Auto);
2280 assert_eq!((c.max_batch, c.intra_threads), (4, 3));
2281 assert_eq!(c.provider, ExecutionProvider::Auto);
2282 let legacy =
2283 EmbedderConfig::new("m", "r").with_provider(rightkit_onnx::ExecutionProvider::DirectMl);
2284 assert_eq!(legacy.provider, ExecutionProvider::DirectMl);
2285 assert!(EmbedderConfig::new("m", "r").fallback_model_dir.is_none());
2286 let with_fallback = EmbedderConfig::new("m", "r").with_fallback_model_dir("q4");
2287 assert_eq!(with_fallback.fallback_model_dir, Some(PathBuf::from("q4")));
2288 assert!(EmbedderConfig::new("m", "r").coreml_cache_dir.is_none());
2289 let with_cache = EmbedderConfig::new("m", "r").with_coreml_cache_dir("cache");
2290 assert_eq!(with_cache.coreml_cache_dir, Some(PathBuf::from("cache")));
2291 assert!(EmbedderConfig::new("m", "r").coreml_units.is_none());
2292 let forced =
2293 EmbedderConfig::new("m", "r").with_coreml_units(crate::CoreMlComputeUnit::CpuAndGpu);
2294 assert_eq!(
2295 forced.coreml_units,
2296 Some(CoreMlUnits::uniform(crate::CoreMlComputeUnit::CpuAndGpu))
2297 );
2298 let custom = EmbedderConfig::new("m", "r")
2299 .with_coreml_units("256=cpu_only".parse::<CoreMlUnits>().unwrap());
2300 assert_eq!(
2301 custom.coreml_units.unwrap().unit_for(256),
2302 Some(crate::CoreMlComputeUnit::CpuOnly)
2303 );
2304 }
2305
2306 #[test]
2307 fn model_ids_are_distinct_so_pipeline_ids_differ_per_model() {
2308 let ids = [
2309 EMBEDDINGGEMMA_MODEL_ID,
2310 EMBEDDINGGEMMA2_MODEL_ID,
2311 EMBEDDINGGEMMA2_E40_MODEL_ID,
2312 EMBEDDINGGEMMA2_COREML_MODEL_ID,
2313 ];
2314 for (i, a) in ids.iter().enumerate() {
2315 for b in &ids[i + 1..] {
2316 assert_ne!(a, b);
2317 }
2318 }
2319 let files = [("model_32.onnx", "aa".to_owned())];
2320 let parts = |model_id| PipelineParts {
2321 model_id,
2322 native_dim: 768,
2323 truncate_dim: 256,
2324 max_tokens: 512,
2325 scrubs_sentinels: true,
2326 files: &files,
2327 };
2328 assert_ne!(
2329 pipeline_fingerprint(&parts(EMBEDDINGGEMMA2_E40_MODEL_ID)),
2330 pipeline_fingerprint(&parts(EMBEDDINGGEMMA2_MODEL_ID))
2331 );
2332 }
2333
2334 #[test]
2335 fn parity_sections_are_prefixes_of_the_long_document() {
2336 assert!(parity_long_document().starts_with(&parity_sections(4)));
2337 assert!(parity_sections(4).starts_with(&parity_sections(1)));
2338 assert!(
2339 parity_sections(1).split_whitespace().count()
2340 < parity_sections(4).split_whitespace().count()
2341 );
2342 }
2343}