1use rightkit_onnx::ort;
2use std::path::{Path, PathBuf};
3use std::sync::{Mutex, OnceLock};
4
5use rightkit_onnx::ort::session::Session;
6use rightkit_onnx::{
7 configure_runtime, init_environment, CandidateSource, EnvironmentOptions, GlobalPool,
8 RuntimeCandidate, SessionOptions,
9};
10use thiserror::Error;
11use tokenizers::{EncodeInput, Tokenizer};
12
13pub const EMBEDDINGGEMMA_MODEL_ID: &str = "embeddinggemma-300m-q4";
14pub const EMBEDDINGGEMMA2_MODEL_ID: &str = "embeddinggemma2-q4";
18const GEMMA2_MODALITY_SENTINELS: [&str; 3] = ["<|image|>", "<|video|>", "<|audio|>"];
21pub const EMBEDDINGGEMMA_NATIVE_DIM: usize = 768;
22pub const DEFAULT_TRUNCATE_DIM: usize = 256;
23pub const MAX_SEQUENCE_TOKENS: usize = 2048;
24pub const DEFAULT_MAX_BATCH: usize = 16;
26const MAX_PADDED_TOKENS_PER_CALL: usize = 16_384;
30const ACCELERATOR_BUCKETS: [usize; 6] = [64, 128, 256, 512, 1024, 2048];
33const DOCUMENT_PROMPT_PREFIX: &str = "title: none | text: ";
34const QUERY_PROMPT_PREFIX: &str = "task: search result | query: ";
35const PIPELINE_SCHEMA: &str = "rightkit-embed/pipeline/1";
38const POOLING_DESCRIPTION: &str = "model sentence_embedding [B,D] or masked mean over [B,T,D]";
39const NORMALIZATION_DESCRIPTION: &str = "l2 native, matryoshka prefix truncate, l2";
40const PROBE_PROMPT: &str = "title: none | text: rightkit embed provider probe";
41pub const ACCELERATOR_PARITY_MIN_COSINE: f32 = 0.995;
46const PARITY_QUERY: &str = "how are invoices matched to the ledger";
48const PARITY_DOCUMENT: &str = "The nightly billing job reads every invoice issued during the day, \
50looks up the matching ledger account, and compares the invoiced total with the posted amount. \
51Differences below one cent are rounded away. Larger differences are written to an exceptions \
52table together with the customer, the invoice number and the date, and the finance team reviews \
53that table each morning before the payment run starts.";
54const PARITY_LONG_SECTIONS: usize = 20;
56
57#[derive(Debug, Error)]
58pub enum EmbedError {
59 #[error("model directory is missing: {0}")]
60 MissingModelDirectory(PathBuf),
61 #[error("model artifact is missing: {0}")]
62 MissingArtifact(PathBuf),
63 #[error("invalid model configuration: {0}")]
64 InvalidConfig(String),
65 #[error("tokenizer: {0}")]
66 Tokenizer(String),
67 #[error("ONNX Runtime: {0}")]
68 Runtime(String),
69 #[error("model output: {0}")]
70 Output(String),
71 #[error("{provider} failed the vector parity check against cpu: {detail}")]
78 AcceleratorParity {
79 provider: &'static str,
80 lowest_cosine: Option<f32>,
81 floor: f32,
82 detail: String,
83 },
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct AcceleratorParity {
90 pub lowest_cosine: f32,
92 pub at_tokens: usize,
94 pub pairs: usize,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum ExecutionProvider {
124 #[default]
125 Cpu,
126 Auto,
127 DirectMl,
128 CoreMl,
129}
130
131impl From<rightkit_onnx::ExecutionProvider> for ExecutionProvider {
132 fn from(value: rightkit_onnx::ExecutionProvider) -> Self {
133 match value {
134 rightkit_onnx::ExecutionProvider::Cpu => Self::Cpu,
135 rightkit_onnx::ExecutionProvider::DirectMl => Self::DirectMl,
136 rightkit_onnx::ExecutionProvider::CoreMl => Self::CoreMl,
137 }
138 }
139}
140
141impl ExecutionProvider {
142 fn requested(self) -> (rightkit_onnx::ExecutionProvider, bool) {
143 match self {
144 Self::Cpu => (rightkit_onnx::ExecutionProvider::Cpu, false),
145 Self::Auto => (
146 rightkit_onnx::ExecutionProvider::platform_accelerator(),
147 true,
148 ),
149 Self::DirectMl => (rightkit_onnx::ExecutionProvider::DirectMl, false),
150 Self::CoreMl => (rightkit_onnx::ExecutionProvider::CoreMl, false),
151 }
152 }
153}
154
155fn provider_name(provider: rightkit_onnx::ExecutionProvider) -> &'static str {
156 match provider {
157 rightkit_onnx::ExecutionProvider::Cpu => "cpu",
158 rightkit_onnx::ExecutionProvider::DirectMl => "directml",
159 rightkit_onnx::ExecutionProvider::CoreMl => "coreml",
160 }
161}
162
163#[derive(Debug, Clone)]
164pub struct EmbedderConfig {
165 pub model_dir: PathBuf,
166 pub runtime_path: PathBuf,
167 pub provider: ExecutionProvider,
168 pub truncate_dim: usize,
169 pub max_tokens: usize,
170 pub max_batch: usize,
171 pub intra_threads: usize,
172}
173
174impl EmbedderConfig {
175 pub fn new(model_dir: impl Into<PathBuf>, runtime_path: impl Into<PathBuf>) -> Self {
176 Self {
177 model_dir: model_dir.into(),
178 runtime_path: runtime_path.into(),
179 provider: ExecutionProvider::Cpu,
180 truncate_dim: DEFAULT_TRUNCATE_DIM,
181 max_tokens: MAX_SEQUENCE_TOKENS,
182 max_batch: DEFAULT_MAX_BATCH,
183 intra_threads: 1,
184 }
185 }
186
187 pub fn with_provider(mut self, provider: impl Into<ExecutionProvider>) -> Self {
188 self.provider = provider.into();
189 self
190 }
191
192 pub fn with_truncate_dim(mut self, dim: usize) -> Self {
193 self.truncate_dim = dim;
194 self
195 }
196
197 pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
198 self.max_tokens = max_tokens;
199 self
200 }
201
202 pub fn with_max_batch(mut self, max_batch: usize) -> Self {
204 self.max_batch = max_batch;
205 self
206 }
207
208 pub fn with_intra_threads(mut self, threads: usize) -> Self {
210 self.intra_threads = threads;
211 self
212 }
213}
214
215pub struct Embedder {
216 tokenizer: Tokenizer,
217 session: Mutex<Session>,
218 model_dir: PathBuf,
219 native_dim: usize,
220 truncate_dim: usize,
221 max_tokens: usize,
222 max_batch: usize,
223 model_id: String,
224 gemma2: bool,
225 pad_id: i64,
226 takes_token_type_ids: bool,
227 provider_used: &'static str,
228 fallback_reason: Option<String>,
229 parity: Option<AcceleratorParity>,
230 pipeline_id: OnceLock<Result<String, String>>,
231}
232
233struct BuiltEmbedSession {
234 session: Session,
235 provider: rightkit_onnx::ExecutionProvider,
236 fallback: Option<String>,
237}
238
239fn build_session(
240 config: &EmbedderConfig,
241 provider: rightkit_onnx::ExecutionProvider,
242 cpu_fallback: bool,
243) -> Result<BuiltEmbedSession, EmbedError> {
244 let built = SessionOptions::new(provider)
245 .with_threads(Some(config.intra_threads))
246 .with_cpu_fallback(cpu_fallback)
247 .build_from_file_reported(&config.model_dir.join("model_q4.onnx"))
248 .map_err(|e| EmbedError::Runtime(e.to_string()))?;
249 Ok(BuiltEmbedSession {
250 session: built.session,
251 provider: built.provider,
252 fallback: built.fallback,
253 })
254}
255
256fn takes_token_type_ids(session: &Session) -> bool {
257 session
258 .inputs()
259 .iter()
260 .any(|input| input.name() == "token_type_ids")
261}
262
263fn load_tokenizer(model_dir: &Path) -> Result<(Tokenizer, i64), EmbedError> {
266 let mut tokenizer = Tokenizer::from_file(model_dir.join("tokenizer.json"))
267 .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
268 let pad_id = tokenizer
269 .get_padding()
270 .map(|padding| i64::from(padding.pad_id))
271 .or_else(|| tokenizer.token_to_id("<pad>").map(i64::from))
272 .unwrap_or(0);
273 tokenizer.with_padding(None);
274 Ok((tokenizer, pad_id))
275}
276
277impl Embedder {
278 pub fn load(config: EmbedderConfig) -> Result<Self, EmbedError> {
279 validate_config(&config)?;
280 let gemma2 = bundle_is_gemma2(&config.model_dir)?;
281 let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
282 let selection = configure_runtime([RuntimeCandidate::new(
283 config.runtime_path.clone(),
284 CandidateSource::Explicit,
285 )])
286 .map_err(|e| EmbedError::Runtime(e.to_string()))?;
287 init_environment(
288 &selection,
289 &EnvironmentOptions {
290 global_pool: Some(GlobalPool::from_budget()),
291 },
292 )
293 .map_err(EmbedError::Runtime)?;
294 let (requested, auto) = config.provider.requested();
295 let built = build_session(&config, requested, auto)?;
296 let accelerator = built.provider;
297 let mut embedder = Self::from_session(&config, tokenizer, pad_id, gemma2, built);
298 if auto && accelerator != rightkit_onnx::ExecutionProvider::Cpu {
299 if let Err(reason) = embedder.probe() {
300 let cpu = build_session(&config, rightkit_onnx::ExecutionProvider::Cpu, false)?;
301 embedder.takes_token_type_ids = takes_token_type_ids(&cpu.session);
302 embedder.session = Mutex::new(cpu.session);
303 embedder.provider_used = provider_name(cpu.provider);
304 embedder.fallback_reason = Some(format!(
305 "{} -> cpu: probe inference failed: {reason}",
306 provider_name(accelerator)
307 ));
308 }
309 }
310 if embedder.provider_used != "cpu" {
311 return embedder.admit_accelerator(&config, auto);
312 }
313 Ok(embedder)
314 }
315
316 fn from_session(
317 config: &EmbedderConfig,
318 tokenizer: Tokenizer,
319 pad_id: i64,
320 gemma2: bool,
321 built: BuiltEmbedSession,
322 ) -> Self {
323 Self {
324 tokenizer,
325 takes_token_type_ids: takes_token_type_ids(&built.session),
326 session: Mutex::new(built.session),
327 model_dir: config.model_dir.clone(),
328 native_dim: EMBEDDINGGEMMA_NATIVE_DIM,
329 truncate_dim: config.truncate_dim,
330 max_tokens: config.max_tokens,
331 max_batch: config.max_batch,
332 model_id: if gemma2 {
333 EMBEDDINGGEMMA2_MODEL_ID
334 } else {
335 EMBEDDINGGEMMA_MODEL_ID
336 }
337 .to_owned(),
338 gemma2,
339 pad_id,
340 provider_used: provider_name(built.provider),
341 fallback_reason: built.fallback,
342 parity: None,
343 pipeline_id: OnceLock::new(),
344 }
345 }
346
347 fn admit_accelerator(
353 mut self,
354 config: &EmbedderConfig,
355 auto: bool,
356 ) -> Result<Self, EmbedError> {
357 let provider = self.provider_used;
358 let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
359 let cpu_built = build_session(config, rightkit_onnx::ExecutionProvider::Cpu, false)?;
360 let mut cpu = Self::from_session(config, tokenizer, pad_id, self.gemma2, cpu_built);
361 let prompts = cpu.parity_prompts();
362 let tokens: Vec<usize> = cpu
363 .encode_prompts(&prompts)?
364 .iter()
365 .map(|row| row.ids.len())
366 .collect();
367 let reference = cpu.parity_vectors(&prompts)?;
368 let (lowest_cosine, detail) = match self.parity_vectors(&prompts) {
369 Ok(candidate) => match compare_parity(
370 &reference,
371 &candidate,
372 self.native_dim,
373 ACCELERATOR_PARITY_MIN_COSINE,
374 ) {
375 Ok(lowest) => {
376 self.parity = Some(AcceleratorParity {
377 lowest_cosine: lowest.cosine,
378 at_tokens: tokens[lowest.index % tokens.len()],
379 pairs: reference.len(),
380 });
381 return Ok(self);
382 }
383 Err(miss) => parity_miss_detail(&miss, &tokens, ACCELERATOR_PARITY_MIN_COSINE),
384 },
385 Err(error) if auto => (None, format!("inference error: {error}")),
386 Err(error) => return Err(error),
387 };
388 if auto {
389 cpu.fallback_reason =
390 Some(format!("{provider} -> cpu: vector parity failed: {detail}"));
391 return Ok(cpu);
392 }
393 Err(EmbedError::AcceleratorParity {
394 provider,
395 lowest_cosine,
396 floor: ACCELERATOR_PARITY_MIN_COSINE,
397 detail,
398 })
399 }
400
401 fn parity_prompts(&self) -> Vec<String> {
404 vec![
405 self.query_prompt(PARITY_QUERY),
406 self.document_prompt(PARITY_DOCUMENT),
407 self.document_prompt(&parity_long_document()),
408 ]
409 }
410
411 fn parity_vectors(&self, prompts: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> {
415 let mut vectors = Vec::with_capacity(prompts.len() * 2);
416 for prompt in prompts {
417 vectors.extend(self.embed_prompts_native(vec![prompt.clone()])?);
418 }
419 vectors.extend(self.embed_prompts_native(prompts.to_vec())?);
420 Ok(vectors)
421 }
422
423 fn probe(&self) -> Result<(), String> {
426 let rows = self
427 .encode_prompts(&[PROBE_PROMPT.to_owned()])
428 .map_err(|e| e.to_string())?;
429 let chunk = [&rows[0]];
430 let native = self
431 .run_chunk(&chunk, true)
432 .map_err(|e| e.to_string())?
433 .into_iter()
434 .next()
435 .ok_or_else(|| "probe returned no row".to_owned())?;
436 probe_output_ok(&native, self.native_dim)
437 }
438
439 pub fn model_id(&self) -> &str {
440 &self.model_id
441 }
442
443 pub fn native_dim(&self) -> usize {
444 self.native_dim
445 }
446
447 pub fn dimension(&self) -> usize {
448 self.truncate_dim
449 }
450
451 pub fn provider_used(&self) -> &str {
453 self.provider_used
454 }
455
456 pub fn fallback_reason(&self) -> Option<&str> {
460 self.fallback_reason.as_deref()
461 }
462
463 pub fn accelerator_parity(&self) -> Option<AcceleratorParity> {
470 self.parity
471 }
472
473 pub fn pipeline_id(&self) -> String {
493 match self.pipeline_id.get_or_init(|| {
494 let mut files = Vec::new();
495 for name in [
496 "model_q4.onnx",
497 "model_q4.onnx_data",
498 "tokenizer.json",
499 "config.json",
500 ] {
501 files.push((name, hash_file(&self.model_dir.join(name))?));
502 }
503 Ok(pipeline_fingerprint(&PipelineParts {
504 model_id: &self.model_id,
505 native_dim: self.native_dim,
506 truncate_dim: self.truncate_dim,
507 max_tokens: self.max_tokens,
508 scrubs_sentinels: self.gemma2,
509 files: &files,
510 }))
511 }) {
512 Ok(id) => id.clone(),
513 Err(error) => format!("rkpipe1-unavailable-{}", sanitize(error)),
515 }
516 }
517
518 pub fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
519 self.embed_prompts(vec![self.document_prompt(text)])?
520 .pop()
521 .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
522 }
523
524 pub fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
525 self.embed_prompts(vec![self.query_prompt(text)])?
526 .pop()
527 .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
528 }
529
530 pub fn embed_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
534 where
535 I: IntoIterator<Item = &'a str>,
536 {
537 self.embed_prompts(texts.into_iter().map(|t| self.document_prompt(t)).collect())
538 }
539
540 pub fn embed_query_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
542 where
543 I: IntoIterator<Item = &'a str>,
544 {
545 self.embed_prompts(texts.into_iter().map(|t| self.query_prompt(t)).collect())
546 }
547
548 fn document_prompt(&self, text: &str) -> String {
549 format!("{DOCUMENT_PROMPT_PREFIX}{}", self.model_text(text))
550 }
551
552 fn query_prompt(&self, text: &str) -> String {
553 format!("{QUERY_PROMPT_PREFIX}{}", self.model_text(text))
554 }
555
556 fn model_text<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
559 if !self.gemma2 || !GEMMA2_MODALITY_SENTINELS.iter().any(|s| text.contains(s)) {
560 return std::borrow::Cow::Borrowed(text);
561 }
562 let mut owned = text.to_owned();
563 for sentinel in GEMMA2_MODALITY_SENTINELS {
564 owned = owned.replace(sentinel, &sentinel.replace('|', "\u{2502}"));
565 }
566 std::borrow::Cow::Owned(owned)
567 }
568
569 fn encode_prompts(&self, prompts: &[String]) -> Result<Vec<TokenRow>, EmbedError> {
570 let inputs: Vec<EncodeInput> = prompts
571 .iter()
572 .map(|prompt| EncodeInput::Single(prompt.as_str().into()))
573 .collect();
574 let encodings = self
575 .tokenizer
576 .encode_batch(inputs, true)
577 .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
578 encodings
579 .iter()
580 .map(|encoding| {
581 let ids: Vec<i64> = encoding
582 .get_ids()
583 .iter()
584 .take(self.max_tokens)
585 .map(|id| i64::from(*id))
586 .collect();
587 if ids.is_empty() {
588 return Err(EmbedError::Tokenizer("encoding returned no tokens".into()));
589 }
590 let mask: Vec<i64> = encoding
591 .get_attention_mask()
592 .iter()
593 .take(ids.len())
594 .map(|value| i64::from(*value))
595 .collect();
596 Ok(TokenRow { ids, mask })
597 })
598 .collect()
599 }
600
601 fn embed_prompts(&self, prompts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedError> {
602 Ok(self
603 .embed_prompts_native(prompts)?
604 .into_iter()
605 .map(|native| truncate_and_normalize(&native, self.truncate_dim))
606 .collect())
607 }
608
609 fn embed_prompts_native(&self, prompts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedError> {
612 if prompts.is_empty() {
613 return Ok(Vec::new());
614 }
615 let rows = self.encode_prompts(&prompts)?;
616 let lengths: Vec<usize> = rows.iter().map(|row| row.ids.len()).collect();
617 let order = length_order(&lengths);
618 let sorted_lengths: Vec<usize> = order.iter().map(|&i| lengths[i]).collect();
619 let accelerated = self.provider_used != "cpu";
620 let mut out: Vec<Option<Vec<f32>>> = vec![None; rows.len()];
621 for range in plan_chunks(
622 &sorted_lengths,
623 self.max_batch.max(1),
624 MAX_PADDED_TOKENS_PER_CALL,
625 ) {
626 let chunk: Vec<&TokenRow> = order[range.clone()].iter().map(|&i| &rows[i]).collect();
627 let natives = self.run_chunk(&chunk, accelerated)?;
628 for (&index, native) in order[range].iter().zip(natives) {
629 out[index] = Some(native);
630 }
631 }
632 out.into_iter()
633 .map(|row| row.ok_or_else(|| EmbedError::Output("missing embedding row".into())))
634 .collect()
635 }
636
637 fn run_chunk(
640 &self,
641 chunk: &[&TokenRow],
642 accelerated: bool,
643 ) -> Result<Vec<Vec<f32>>, EmbedError> {
644 let longest = chunk.iter().map(|row| row.ids.len()).max().unwrap_or(1);
645 let (batch_rows, padded_len) = padded_shape(chunk.len(), longest, accelerated);
646 let padded = pad_batch(chunk, self.pad_id, padded_len, batch_rows);
647 let (rows, len) = (padded.rows, padded.len);
648 let tensor = |data: Vec<i64>| {
649 ort::value::Tensor::from_array(([rows, len], data))
650 .map_err(|e| EmbedError::Runtime(e.to_string()))
651 };
652 let mut inputs = ort::inputs![
653 "input_ids" => tensor(padded.ids)?,
654 "attention_mask" => tensor(padded.mask)?
655 ];
656 if self.takes_token_type_ids {
657 inputs.push(("token_type_ids".into(), tensor(vec![0; rows * len])?.into()));
658 }
659 let mut session = self
660 .session
661 .lock()
662 .map_err(|_| EmbedError::Runtime("model mutex poisoned".into()))?;
663 let outputs = session
664 .run(inputs)
665 .map_err(|e| EmbedError::Runtime(e.to_string()))?;
666 let output = outputs
667 .get("sentence_embedding")
668 .ok_or_else(|| EmbedError::Output("missing sentence_embedding output".into()))?;
669 let (shape, values) = output
670 .try_extract_tensor::<f32>()
671 .map_err(|e| EmbedError::Output(e.to_string()))?;
672 let masks: Vec<&[i64]> = chunk.iter().map(|row| row.mask.as_slice()).collect();
673 pool_batch(shape.as_ref(), values, &masks, self.native_dim)
674 }
675}
676
677struct TokenRow {
679 ids: Vec<i64>,
680 mask: Vec<i64>,
681}
682
683#[derive(Debug, PartialEq, Eq)]
685struct PaddedBatch {
686 rows: usize,
687 len: usize,
688 ids: Vec<i64>,
689 mask: Vec<i64>,
690}
691
692fn length_order(lengths: &[usize]) -> Vec<usize> {
695 let mut order: Vec<usize> = (0..lengths.len()).collect();
696 order.sort_by_key(|&i| lengths[i]);
697 order
698}
699
700fn plan_chunks(
704 sorted_lengths: &[usize],
705 max_rows: usize,
706 token_budget: usize,
707) -> Vec<std::ops::Range<usize>> {
708 let mut ranges = Vec::new();
709 let mut start = 0;
710 while start < sorted_lengths.len() {
711 let mut end = start + 1;
712 while end < sorted_lengths.len()
713 && end - start < max_rows
714 && (end - start + 1) * sorted_lengths[end] <= token_budget
715 {
716 end += 1;
717 }
718 ranges.push(start..end);
719 start = end;
720 }
721 ranges
722}
723
724fn padded_shape(rows: usize, longest: usize, accelerated: bool) -> (usize, usize) {
728 if !accelerated {
729 return (rows, longest);
730 }
731 let len = ACCELERATOR_BUCKETS
732 .iter()
733 .copied()
734 .find(|bucket| *bucket >= longest)
735 .unwrap_or(longest);
736 (rows.next_power_of_two(), len)
737}
738
739fn pad_batch(chunk: &[&TokenRow], pad_id: i64, len: usize, rows: usize) -> PaddedBatch {
742 let mut ids = vec![pad_id; rows * len];
743 let mut mask = vec![0i64; rows * len];
744 for row in 0..rows {
745 let source = chunk[row.min(chunk.len() - 1)];
746 let used = source.ids.len().min(len);
747 ids[row * len..row * len + used].copy_from_slice(&source.ids[..used]);
748 mask[row * len..row * len + used].copy_from_slice(&source.mask[..used]);
749 }
750 PaddedBatch {
751 rows,
752 len,
753 ids,
754 mask,
755 }
756}
757
758fn pool_batch(
763 shape: &[i64],
764 values: &[f32],
765 masks: &[&[i64]],
766 native_dim: usize,
767) -> Result<Vec<Vec<f32>>, EmbedError> {
768 let real = masks.len();
769 if shape.len() == 2 && shape[1] == native_dim as i64 {
770 let batch = shape[0] as usize;
771 if batch >= real && values.len() == batch * native_dim {
772 return Ok((0..real)
773 .map(|row| normalize(values[row * native_dim..(row + 1) * native_dim].to_vec()))
774 .collect());
775 }
776 }
777 if shape.len() == 3 && shape[2] == native_dim as i64 && shape[0] > 0 && shape[1] > 0 {
778 let (batch, tokens) = (shape[0] as usize, shape[1] as usize);
779 if batch >= real && values.len() == batch * tokens * native_dim {
780 let mut pooled_rows = Vec::with_capacity(real);
781 for (row, mask) in masks.iter().enumerate() {
782 let mut pooled = vec![0.0f32; native_dim];
783 let mut count = 0.0f32;
784 for token in 0..tokens {
785 if mask.get(token).copied().unwrap_or(0) == 0 {
786 continue;
787 }
788 count += 1.0;
789 let base = (row * tokens + token) * native_dim;
790 for (dim, value) in pooled.iter_mut().enumerate() {
791 *value += values[base + dim];
792 }
793 }
794 if count == 0.0 {
795 return Err(EmbedError::Output(
796 "attention mask contains no tokens".into(),
797 ));
798 }
799 for value in &mut pooled {
800 *value /= count;
801 }
802 pooled_rows.push(normalize(pooled));
803 }
804 return Ok(pooled_rows);
805 }
806 }
807 Err(EmbedError::Output(format!(
808 "expected [B,{native_dim}] or [B,tokens,{native_dim}] with B >= {real}, got shape {shape:?}"
809 )))
810}
811
812fn probe_output_ok(native: &[f32], native_dim: usize) -> Result<(), String> {
813 if native.len() != native_dim {
814 return Err(format!(
815 "expected {native_dim} values, got {}",
816 native.len()
817 ));
818 }
819 if native.iter().any(|value| !value.is_finite()) {
820 return Err("non-finite values".into());
821 }
822 if native.iter().map(|value| value * value).sum::<f32>() <= f32::EPSILON {
823 return Err("all-zero output".into());
824 }
825 Ok(())
826}
827
828fn parity_long_document() -> String {
831 (1..=PARITY_LONG_SECTIONS)
832 .map(|n| {
833 format!(
834 "Section {n}: the nightly billing job reconciles every invoice against the \
835 ledger, flags mismatched totals for account {}, and sends the exceptions to \
836 the finance team for review before payment run {n}.",
837 1000 + n * 7
838 )
839 })
840 .collect::<Vec<_>>()
841 .join(" ")
842}
843
844#[derive(Debug, Clone, Copy, PartialEq)]
846struct ParityLowest {
847 index: usize,
848 cosine: f32,
849}
850
851#[derive(Debug, Clone, PartialEq)]
853enum ParityMiss {
854 Invalid { index: usize, reason: String },
857 Below(ParityLowest),
859}
860
861fn cosine(a: &[f32], b: &[f32]) -> f32 {
862 let (mut dot, mut na, mut nb) = (0.0f64, 0.0f64, 0.0f64);
863 for (x, y) in a.iter().zip(b) {
864 let (x, y) = (f64::from(*x), f64::from(*y));
865 dot += x * y;
866 na += x * x;
867 nb += y * y;
868 }
869 (dot / (na.sqrt() * nb.sqrt())) as f32
870}
871
872fn compare_parity(
877 reference: &[Vec<f32>],
878 candidate: &[Vec<f32>],
879 native_dim: usize,
880 floor: f32,
881) -> Result<ParityLowest, ParityMiss> {
882 if reference.is_empty() || reference.len() != candidate.len() {
883 return Err(ParityMiss::Invalid {
884 index: 0,
885 reason: format!(
886 "expected {} vectors to compare, got {}",
887 reference.len(),
888 candidate.len()
889 ),
890 });
891 }
892 let mut lowest = ParityLowest {
893 index: 0,
894 cosine: f32::INFINITY,
895 };
896 for (index, (r, c)) in reference.iter().zip(candidate).enumerate() {
897 probe_output_ok(r, native_dim).map_err(|reason| ParityMiss::Invalid {
898 index,
899 reason: format!("cpu reference: {reason}"),
900 })?;
901 probe_output_ok(c, native_dim).map_err(|reason| ParityMiss::Invalid {
902 index,
903 reason: format!("accelerator output: {reason}"),
904 })?;
905 let value = cosine(r, c);
906 if value < lowest.cosine {
907 lowest = ParityLowest {
908 index,
909 cosine: value,
910 };
911 }
912 }
913 if lowest.cosine >= floor {
914 Ok(lowest)
915 } else {
916 Err(ParityMiss::Below(lowest))
917 }
918}
919
920fn parity_miss_detail(miss: &ParityMiss, tokens: &[usize], floor: f32) -> (Option<f32>, String) {
924 let at = |index: usize| {
925 let batched = if index >= tokens.len() {
926 " (batched)"
927 } else {
928 ""
929 };
930 format!("~{} tokens{batched}", tokens[index % tokens.len()])
931 };
932 match miss {
933 ParityMiss::Below(lowest) => (
934 Some(lowest.cosine),
935 format!(
936 "cosine {:.3} at {}, floor {floor}",
937 lowest.cosine,
938 at(lowest.index)
939 ),
940 ),
941 ParityMiss::Invalid { index, reason } => (None, format!("{reason} at {}", at(*index))),
942 }
943}
944
945struct PipelineParts<'a> {
946 model_id: &'a str,
947 native_dim: usize,
948 truncate_dim: usize,
949 max_tokens: usize,
950 scrubs_sentinels: bool,
951 files: &'a [(&'a str, String)],
952}
953
954fn pipeline_fingerprint(parts: &PipelineParts<'_>) -> String {
956 let mut text = String::new();
957 let mut line = |key: &str, value: &str| {
958 text.push_str(key);
959 text.push('=');
960 text.push_str(&value.len().to_string());
961 text.push(':');
962 text.push_str(value);
963 text.push('\n');
964 };
965 line("schema", PIPELINE_SCHEMA);
966 line("model_id", parts.model_id);
967 line("native_dim", &parts.native_dim.to_string());
968 line("truncate_dim", &parts.truncate_dim.to_string());
969 line("max_tokens", &parts.max_tokens.to_string());
970 line("document_prompt", DOCUMENT_PROMPT_PREFIX);
971 line("query_prompt", QUERY_PROMPT_PREFIX);
972 line("pooling", POOLING_DESCRIPTION);
973 line("normalization", NORMALIZATION_DESCRIPTION);
974 line("scrubs_sentinels", &parts.scrubs_sentinels.to_string());
975 for (name, hash) in parts.files {
976 line(&format!("file:{name}"), hash);
977 }
978 let digest = rightkit_fs::digest::sha256_bytes(text.as_bytes());
980 format!("rkpipe1-{}", &digest[..32])
981}
982
983fn sanitize(text: &str) -> String {
984 text.chars()
985 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
986 .take(48)
987 .collect()
988}
989
990fn hash_file(path: &Path) -> Result<String, String> {
993 let mut file = match std::fs::File::open(path) {
994 Ok(file) => file,
995 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok("absent".into()),
996 Err(error) => return Err(format!("{}: {error}", path.display())),
997 };
998 rightkit_fs::digest::sha256_reader(&mut file).map_err(|e| format!("{}: {e}", path.display()))
999}
1000
1001fn bundle_is_gemma2(model_dir: &std::path::Path) -> Result<bool, EmbedError> {
1005 let path = model_dir.join("config.json");
1006 let Ok(bytes) = std::fs::read(&path) else {
1007 return Ok(false);
1008 };
1009 let config: serde_json::Value = serde_json::from_slice(&bytes)
1010 .map_err(|e| EmbedError::InvalidConfig(format!("{}: {e}", path.display())))?;
1011 Ok(config["model_type"] == "embedding_gemma2")
1012}
1013
1014fn validate_config(config: &EmbedderConfig) -> Result<(), EmbedError> {
1015 if !config.model_dir.is_dir() {
1016 return Err(EmbedError::MissingModelDirectory(config.model_dir.clone()));
1017 }
1018 if config.truncate_dim == 0 || config.truncate_dim > EMBEDDINGGEMMA_NATIVE_DIM {
1019 return Err(EmbedError::InvalidConfig(format!(
1020 "truncate dimension must be 1..={EMBEDDINGGEMMA_NATIVE_DIM}"
1021 )));
1022 }
1023 if config.max_tokens == 0 || config.max_tokens > MAX_SEQUENCE_TOKENS {
1024 return Err(EmbedError::InvalidConfig(format!(
1025 "max tokens must be 1..={MAX_SEQUENCE_TOKENS}"
1026 )));
1027 }
1028 if config.max_batch == 0 {
1029 return Err(EmbedError::InvalidConfig("max batch must be >= 1".into()));
1030 }
1031 if config.intra_threads == 0 {
1032 return Err(EmbedError::InvalidConfig(
1033 "intra threads must be >= 1".into(),
1034 ));
1035 }
1036 for name in ["model_q4.onnx", "model_q4.onnx_data", "tokenizer.json"] {
1037 let path = config.model_dir.join(name);
1038 if !path.is_file() {
1039 return Err(EmbedError::MissingArtifact(path));
1040 }
1041 }
1042 if !config.runtime_path.is_file() {
1043 return Err(EmbedError::MissingArtifact(config.runtime_path.clone()));
1044 }
1045 Ok(())
1046}
1047
1048fn normalize(mut values: Vec<f32>) -> Vec<f32> {
1049 let norm = values.iter().map(|value| value * value).sum::<f32>().sqrt();
1050 if norm > 0.0 {
1051 for value in &mut values {
1052 *value /= norm;
1053 }
1054 }
1055 values
1056}
1057
1058pub fn truncate_and_normalize(native: &[f32], dimension: usize) -> Vec<f32> {
1059 let mut values = native[..native.len().min(dimension)].to_vec();
1060 values.resize(dimension, 0.0);
1061 normalize(values)
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067
1068 fn row(ids: &[i64]) -> TokenRow {
1069 TokenRow {
1070 ids: ids.to_vec(),
1071 mask: vec![1; ids.len()],
1072 }
1073 }
1074
1075 fn state(ids: &[i64], dim: usize) -> Vec<f32> {
1078 ids.iter()
1079 .enumerate()
1080 .flat_map(|(pos, id)| {
1081 (0..dim)
1082 .map(move |d| ((*id as f32) * 0.37 + pos as f32 * 0.11 + d as f32 * 0.05).sin())
1083 })
1084 .collect()
1085 }
1086
1087 #[test]
1088 fn pad_batch_pads_right_with_zero_mask_and_repeats_last_row() {
1089 let a = row(&[5, 6, 7]);
1090 let b = row(&[9]);
1091 let padded = pad_batch(&[&a, &b], 0, 4, 3);
1092 assert_eq!((padded.rows, padded.len), (3, 4));
1093 assert_eq!(padded.ids, vec![5, 6, 7, 0, 9, 0, 0, 0, 9, 0, 0, 0]);
1094 assert_eq!(padded.mask, vec![1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
1095 }
1096
1097 #[test]
1098 fn pad_batch_uses_the_pad_id_for_masked_positions() {
1099 let a = row(&[5]);
1100 let padded = pad_batch(&[&a], 3, 3, 1);
1101 assert_eq!(padded.ids, vec![5, 3, 3]);
1102 assert_eq!(padded.mask, vec![1, 0, 0]);
1103 }
1104
1105 #[test]
1106 fn padded_mean_pool_matches_unpadded_rows() {
1107 let dim = 8;
1108 let rows_ids: [&[i64]; 3] = [&[3, 4, 5, 6, 7], &[10, 11], &[20, 21, 22]];
1109 let rows: Vec<TokenRow> = rows_ids.iter().map(|ids| row(ids)).collect();
1110 let refs: Vec<&TokenRow> = rows.iter().collect();
1111 let padded = pad_batch(&refs, 0, 5, 3);
1112 let mut values = vec![9999.0f32; padded.rows * padded.len * dim];
1115 for (r, ids) in rows_ids.iter().enumerate() {
1116 let s = state(ids, dim);
1117 let base = r * padded.len * dim;
1118 values[base..base + s.len()].copy_from_slice(&s);
1119 }
1120 let masks: Vec<&[i64]> = rows.iter().map(|r| r.mask.as_slice()).collect();
1121 let batched =
1122 pool_batch(&[3, padded.len as i64, dim as i64], &values, &masks, dim).unwrap();
1123 for (r, ids) in rows_ids.iter().enumerate() {
1124 let single = pool_batch(
1125 &[1, ids.len() as i64, dim as i64],
1126 &state(ids, dim),
1127 &[rows[r].mask.as_slice()],
1128 dim,
1129 )
1130 .unwrap();
1131 let diff = batched[r]
1132 .iter()
1133 .zip(&single[0])
1134 .map(|(a, b)| (a - b).abs())
1135 .fold(0.0f32, f32::max);
1136 assert!(diff <= 1e-6, "row {r} differs by {diff}");
1137 let norm: f32 = batched[r].iter().map(|v| v * v).sum::<f32>().sqrt();
1138 assert!((norm - 1.0).abs() <= 1e-5);
1139 }
1140 }
1141
1142 #[test]
1143 fn pooled_output_rows_are_normalized_and_extra_rows_dropped() {
1144 let dim = 4;
1145 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];
1146 let m = [1i64];
1147 let out = pool_batch(&[3, 4], &values, &[&m, &m], dim).unwrap();
1148 assert_eq!(out.len(), 2);
1149 assert_eq!(out[0], vec![0.6, 0.8, 0.0, 0.0]);
1150 assert_eq!(out[1], vec![0.0, 0.0, 1.0, 0.0]);
1151 }
1152
1153 #[test]
1154 fn pool_rejects_bad_shapes_and_empty_masks() {
1155 let m = [0i64, 0];
1156 assert!(pool_batch(&[1, 2, 2], &[1.0; 4], &[&m], 2).is_err());
1157 assert!(pool_batch(&[1, 3], &[1.0; 3], &[&[1]], 2).is_err());
1158 assert!(pool_batch(&[1, 2], &[1.0; 2], &[&[1], &[1]], 2).is_err());
1159 }
1160
1161 #[test]
1162 fn chunks_respect_row_cap_token_budget_and_cover_everything() {
1163 let lengths = [1, 2, 3, 4, 5, 6, 7];
1164 assert_eq!(plan_chunks(&lengths, 3, 1000), vec![0..3, 3..6, 6..7]);
1165 assert_eq!(plan_chunks(&[1, 3, 4, 4], 8, 10), vec![0..2, 2..4]);
1167 assert_eq!(plan_chunks(&[50, 60], 8, 10), vec![0..1, 1..2]);
1169 assert!(plan_chunks(&[], 4, 10).is_empty());
1170 }
1171
1172 #[test]
1173 fn length_order_is_stable_ascending() {
1174 assert_eq!(length_order(&[5, 2, 5, 1, 2]), vec![3, 1, 4, 0, 2]);
1175 }
1176
1177 #[test]
1178 fn accelerators_use_bucketed_shapes_cpu_uses_exact() {
1179 assert_eq!(padded_shape(3, 70, false), (3, 70));
1180 assert_eq!(padded_shape(3, 70, true), (4, 128));
1181 assert_eq!(padded_shape(1, 64, true), (1, 64));
1182 assert_eq!(padded_shape(16, 2048, true), (16, 2048));
1183 }
1184
1185 #[test]
1186 fn probe_rejects_zero_nonfinite_and_wrong_size() {
1187 assert!(probe_output_ok(&[0.0; 4], 4).is_err());
1188 assert!(probe_output_ok(&[f32::NAN, 1.0, 0.0, 0.0], 4).is_err());
1189 assert!(probe_output_ok(&[1.0; 3], 4).is_err());
1190 assert!(probe_output_ok(&[0.5, 0.5, 0.5, 0.5], 4).is_ok());
1191 }
1192
1193 #[test]
1194 fn parity_floor_is_pinned() {
1195 assert_eq!(ACCELERATOR_PARITY_MIN_COSINE, 0.995);
1196 }
1197
1198 #[test]
1199 fn identical_vectors_pass_parity_and_report_the_lowest_pair() {
1200 let reference = vec![vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]];
1201 let lowest = compare_parity(&reference, &reference.clone(), 3, 0.995).unwrap();
1202 assert!((lowest.cosine - 1.0).abs() <= 1e-6);
1203 let candidate = vec![vec![1.0, 0.0, 0.0], vec![0.0, 0.6, 0.8]];
1205 let at_floor = cosine(&reference[1], &candidate[1]);
1206 let lowest = compare_parity(&reference, &candidate, 3, at_floor).unwrap();
1207 assert_eq!((lowest.index, lowest.cosine), (1, at_floor));
1208 }
1209
1210 #[test]
1211 fn a_low_cosine_pair_fails_and_reports_value_and_index() {
1212 let wrong = vec![(1.0f32 - 0.58 * 0.58).sqrt(), 0.58];
1214 let reference = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]];
1215 let candidate = vec![vec![1.0, 0.0], wrong.clone(), vec![1.0, 0.0]];
1216 match compare_parity(&reference, &candidate, 2, 0.995) {
1217 Err(ParityMiss::Below(lowest)) => {
1218 assert_eq!(lowest.index, 1);
1219 assert!((lowest.cosine - 0.58).abs() <= 1e-6, "{lowest:?}");
1220 }
1221 other => panic!("expected a Below miss, got {other:?}"),
1222 }
1223 let single = compare_parity(&[vec![0.0, 1.0]], &[wrong], 2, 0.995);
1224 match single {
1225 Err(ParityMiss::Below(lowest)) => assert!((lowest.cosine - 0.58).abs() <= 1e-6),
1226 other => panic!("expected a Below miss, got {other:?}"),
1227 }
1228 }
1229
1230 #[test]
1231 fn nonfinite_zero_or_wrongly_sized_vectors_fail_parity() {
1232 let good = vec![vec![1.0, 0.0]];
1233 for bad in [
1234 vec![f32::NAN, 1.0],
1235 vec![f32::INFINITY, 0.0],
1236 vec![0.0, 0.0],
1237 vec![1.0, 0.0, 0.0],
1238 ] {
1239 assert!(
1240 matches!(
1241 compare_parity(&good, std::slice::from_ref(&bad), 2, 0.995),
1242 Err(ParityMiss::Invalid { index: 0, .. })
1243 ),
1244 "candidate {bad:?}"
1245 );
1246 assert!(
1247 matches!(
1248 compare_parity(std::slice::from_ref(&bad), &good, 2, 0.995),
1249 Err(ParityMiss::Invalid { index: 0, .. })
1250 ),
1251 "reference {bad:?}"
1252 );
1253 }
1254 }
1255
1256 #[test]
1257 fn mismatched_or_empty_parity_inputs_fail() {
1258 let one = vec![vec![1.0, 0.0]];
1259 let two = vec![vec![1.0, 0.0], vec![1.0, 0.0]];
1260 assert!(matches!(
1261 compare_parity(&one, &two, 2, 0.995),
1262 Err(ParityMiss::Invalid { .. })
1263 ));
1264 assert!(matches!(
1265 compare_parity(&two, &one, 2, 0.995),
1266 Err(ParityMiss::Invalid { .. })
1267 ));
1268 assert!(matches!(
1269 compare_parity(&[], &[], 2, 0.995),
1270 Err(ParityMiss::Invalid { .. })
1271 ));
1272 }
1273
1274 #[test]
1275 fn parity_miss_detail_names_cosine_length_and_floor() {
1276 let tokens = [20, 100, 600];
1277 let miss = ParityMiss::Below(ParityLowest {
1278 index: 0,
1279 cosine: 0.5791,
1280 });
1281 assert_eq!(
1282 parity_miss_detail(&miss, &tokens, 0.995),
1283 (
1284 Some(0.5791),
1285 "cosine 0.579 at ~20 tokens, floor 0.995".to_owned()
1286 )
1287 );
1288 let batched = ParityMiss::Below(ParityLowest {
1289 index: 5,
1290 cosine: 0.71,
1291 });
1292 assert_eq!(
1293 parity_miss_detail(&batched, &tokens, 0.995).1,
1294 "cosine 0.710 at ~600 tokens (batched), floor 0.995"
1295 );
1296 let invalid = ParityMiss::Invalid {
1297 index: 1,
1298 reason: "accelerator output: non-finite values".into(),
1299 };
1300 assert_eq!(
1301 parity_miss_detail(&invalid, &tokens, 0.995),
1302 (
1303 None,
1304 "accelerator output: non-finite values at ~100 tokens".to_owned()
1305 )
1306 );
1307 }
1308
1309 #[test]
1310 fn parity_long_document_is_deterministic_and_long() {
1311 let text = parity_long_document();
1312 assert_eq!(text, parity_long_document());
1313 assert!(text.split_whitespace().count() >= 400, "{}", text.len());
1314 assert!(PARITY_DOCUMENT.split_whitespace().count() >= 50);
1315 }
1316
1317 #[test]
1318 fn parity_error_names_provider_cosine_and_floor() {
1319 let error = EmbedError::AcceleratorParity {
1320 provider: "directml",
1321 lowest_cosine: Some(0.579),
1322 floor: ACCELERATOR_PARITY_MIN_COSINE,
1323 detail: "cosine 0.579 at ~20 tokens, floor 0.995".into(),
1324 };
1325 assert_eq!(
1326 error.to_string(),
1327 "directml failed the vector parity check against cpu: cosine 0.579 at ~20 tokens, floor 0.995"
1328 );
1329 }
1330
1331 fn parts<'a>(files: &'a [(&'a str, String)], truncate: usize) -> PipelineParts<'a> {
1332 PipelineParts {
1333 model_id: EMBEDDINGGEMMA_MODEL_ID,
1334 native_dim: 768,
1335 truncate_dim: truncate,
1336 max_tokens: 2048,
1337 scrubs_sentinels: false,
1338 files,
1339 }
1340 }
1341
1342 #[test]
1343 fn pipeline_fingerprint_is_stable_and_sensitive() {
1344 let files = [("model_q4.onnx", "aa".to_owned())];
1345 let base = pipeline_fingerprint(&parts(&files, 256));
1346 assert_eq!(base, pipeline_fingerprint(&parts(&files, 256)));
1347 assert!(base.starts_with("rkpipe1-") && base.len() == "rkpipe1-".len() + 32);
1348 assert_ne!(base, pipeline_fingerprint(&parts(&files, 128)));
1349 let changed = [("model_q4.onnx", "ab".to_owned())];
1350 assert_ne!(base, pipeline_fingerprint(&parts(&changed, 256)));
1351 let mut other = parts(&files, 256);
1352 other.scrubs_sentinels = true;
1353 assert_ne!(base, pipeline_fingerprint(&other));
1354 other = parts(&files, 256);
1355 other.max_tokens = 512;
1356 assert_ne!(base, pipeline_fingerprint(&other));
1357 }
1358
1359 #[test]
1360 fn hash_file_reports_absent_and_content() {
1361 let dir = tempfile::tempdir().unwrap();
1362 assert_eq!(hash_file(&dir.path().join("none")).unwrap(), "absent");
1363 let path = dir.path().join("f");
1364 std::fs::write(&path, b"abc").unwrap();
1365 assert_eq!(
1366 hash_file(&path).unwrap(),
1367 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1368 );
1369 }
1370
1371 #[test]
1372 fn config_defaults_and_builders() {
1373 let c = EmbedderConfig::new("m", "r");
1374 assert_eq!((c.max_batch, c.intra_threads), (16, 1));
1375 assert_eq!(c.provider, ExecutionProvider::Cpu);
1376 let c = c
1377 .with_max_batch(4)
1378 .with_intra_threads(3)
1379 .with_provider(ExecutionProvider::Auto);
1380 assert_eq!((c.max_batch, c.intra_threads), (4, 3));
1381 assert_eq!(c.provider, ExecutionProvider::Auto);
1382 let legacy =
1383 EmbedderConfig::new("m", "r").with_provider(rightkit_onnx::ExecutionProvider::DirectMl);
1384 assert_eq!(legacy.provider, ExecutionProvider::DirectMl);
1385 }
1386}