1use std::path::{Path, PathBuf};
2use std::sync::{Mutex, OnceLock};
3
4use ort::session::Session;
5use rightkit_ort::{
6 configure_runtime, init_environment, CandidateSource, EnvironmentOptions, GlobalPool,
7 RuntimeCandidate, SessionOptions,
8};
9use sha2::{Digest, Sha256};
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";
41
42#[derive(Debug, Error)]
43pub enum EmbedError {
44 #[error("model directory is missing: {0}")]
45 MissingModelDirectory(PathBuf),
46 #[error("model artifact is missing: {0}")]
47 MissingArtifact(PathBuf),
48 #[error("invalid model configuration: {0}")]
49 InvalidConfig(String),
50 #[error("tokenizer: {0}")]
51 Tokenizer(String),
52 #[error("ONNX Runtime: {0}")]
53 Runtime(String),
54 #[error("model output: {0}")]
55 Output(String),
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum ExecutionProvider {
67 #[default]
68 Cpu,
69 Auto,
70 DirectMl,
71 CoreMl,
72}
73
74impl From<rightkit_ort::ExecutionProvider> for ExecutionProvider {
75 fn from(value: rightkit_ort::ExecutionProvider) -> Self {
76 match value {
77 rightkit_ort::ExecutionProvider::Cpu => Self::Cpu,
78 rightkit_ort::ExecutionProvider::DirectMl => Self::DirectMl,
79 rightkit_ort::ExecutionProvider::CoreMl => Self::CoreMl,
80 }
81 }
82}
83
84impl ExecutionProvider {
85 fn requested(self) -> (rightkit_ort::ExecutionProvider, bool) {
86 match self {
87 Self::Cpu => (rightkit_ort::ExecutionProvider::Cpu, false),
88 Self::Auto => (
89 rightkit_ort::ExecutionProvider::platform_accelerator(),
90 true,
91 ),
92 Self::DirectMl => (rightkit_ort::ExecutionProvider::DirectMl, false),
93 Self::CoreMl => (rightkit_ort::ExecutionProvider::CoreMl, false),
94 }
95 }
96}
97
98fn provider_name(provider: rightkit_ort::ExecutionProvider) -> &'static str {
99 match provider {
100 rightkit_ort::ExecutionProvider::Cpu => "cpu",
101 rightkit_ort::ExecutionProvider::DirectMl => "directml",
102 rightkit_ort::ExecutionProvider::CoreMl => "coreml",
103 }
104}
105
106#[derive(Debug, Clone)]
107pub struct EmbedderConfig {
108 pub model_dir: PathBuf,
109 pub runtime_path: PathBuf,
110 pub provider: ExecutionProvider,
111 pub truncate_dim: usize,
112 pub max_tokens: usize,
113 pub max_batch: usize,
114 pub intra_threads: usize,
115}
116
117impl EmbedderConfig {
118 pub fn new(model_dir: impl Into<PathBuf>, runtime_path: impl Into<PathBuf>) -> Self {
119 Self {
120 model_dir: model_dir.into(),
121 runtime_path: runtime_path.into(),
122 provider: ExecutionProvider::Cpu,
123 truncate_dim: DEFAULT_TRUNCATE_DIM,
124 max_tokens: MAX_SEQUENCE_TOKENS,
125 max_batch: DEFAULT_MAX_BATCH,
126 intra_threads: 1,
127 }
128 }
129
130 pub fn with_provider(mut self, provider: impl Into<ExecutionProvider>) -> Self {
131 self.provider = provider.into();
132 self
133 }
134
135 pub fn with_truncate_dim(mut self, dim: usize) -> Self {
136 self.truncate_dim = dim;
137 self
138 }
139
140 pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
141 self.max_tokens = max_tokens;
142 self
143 }
144
145 pub fn with_max_batch(mut self, max_batch: usize) -> Self {
147 self.max_batch = max_batch;
148 self
149 }
150
151 pub fn with_intra_threads(mut self, threads: usize) -> Self {
153 self.intra_threads = threads;
154 self
155 }
156}
157
158pub struct Embedder {
159 tokenizer: Tokenizer,
160 session: Mutex<Session>,
161 model_dir: PathBuf,
162 native_dim: usize,
163 truncate_dim: usize,
164 max_tokens: usize,
165 max_batch: usize,
166 model_id: String,
167 gemma2: bool,
168 pad_id: i64,
169 takes_token_type_ids: bool,
170 provider_used: &'static str,
171 fallback_reason: Option<String>,
172 pipeline_id: OnceLock<Result<String, String>>,
173}
174
175struct BuiltEmbedSession {
176 session: Session,
177 provider: rightkit_ort::ExecutionProvider,
178 fallback: Option<String>,
179}
180
181fn build_session(
182 config: &EmbedderConfig,
183 provider: rightkit_ort::ExecutionProvider,
184 cpu_fallback: bool,
185) -> Result<BuiltEmbedSession, EmbedError> {
186 let built = SessionOptions::new(provider)
187 .with_threads(Some(config.intra_threads))
188 .with_cpu_fallback(cpu_fallback)
189 .build_from_file_reported(&config.model_dir.join("model_q4.onnx"))
190 .map_err(|e| EmbedError::Runtime(e.to_string()))?;
191 Ok(BuiltEmbedSession {
192 session: built.session,
193 provider: built.provider,
194 fallback: built.fallback,
195 })
196}
197
198fn takes_token_type_ids(session: &Session) -> bool {
199 session
200 .inputs()
201 .iter()
202 .any(|input| input.name() == "token_type_ids")
203}
204
205impl Embedder {
206 pub fn load(config: EmbedderConfig) -> Result<Self, EmbedError> {
207 validate_config(&config)?;
208 let gemma2 = bundle_is_gemma2(&config.model_dir)?;
209 let mut tokenizer = Tokenizer::from_file(config.model_dir.join("tokenizer.json"))
210 .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
211 let pad_id = tokenizer
212 .get_padding()
213 .map(|padding| i64::from(padding.pad_id))
214 .or_else(|| tokenizer.token_to_id("<pad>").map(i64::from))
215 .unwrap_or(0);
216 tokenizer.with_padding(None);
219 let selection = configure_runtime([RuntimeCandidate::new(
220 config.runtime_path.clone(),
221 CandidateSource::Explicit,
222 )])
223 .map_err(|e| EmbedError::Runtime(e.to_string()))?;
224 init_environment(
225 &selection,
226 &EnvironmentOptions {
227 global_pool: Some(GlobalPool::from_budget()),
228 },
229 )
230 .map_err(EmbedError::Runtime)?;
231 let (requested, auto) = config.provider.requested();
232 let built = build_session(&config, requested, auto)?;
233 let mut embedder = Self {
234 tokenizer,
235 takes_token_type_ids: takes_token_type_ids(&built.session),
236 session: Mutex::new(built.session),
237 model_dir: config.model_dir.clone(),
238 native_dim: EMBEDDINGGEMMA_NATIVE_DIM,
239 truncate_dim: config.truncate_dim,
240 max_tokens: config.max_tokens,
241 max_batch: config.max_batch,
242 model_id: if gemma2 {
243 EMBEDDINGGEMMA2_MODEL_ID
244 } else {
245 EMBEDDINGGEMMA_MODEL_ID
246 }
247 .to_owned(),
248 gemma2,
249 pad_id,
250 provider_used: provider_name(built.provider),
251 fallback_reason: built.fallback,
252 pipeline_id: OnceLock::new(),
253 };
254 if auto && built.provider != rightkit_ort::ExecutionProvider::Cpu {
255 if let Err(reason) = embedder.probe() {
256 let cpu = build_session(&config, rightkit_ort::ExecutionProvider::Cpu, false)?;
257 embedder.takes_token_type_ids = takes_token_type_ids(&cpu.session);
258 embedder.session = Mutex::new(cpu.session);
259 embedder.provider_used = provider_name(cpu.provider);
260 embedder.fallback_reason = Some(format!(
261 "{} -> cpu: probe inference failed: {reason}",
262 provider_name(built.provider)
263 ));
264 }
265 }
266 Ok(embedder)
267 }
268
269 fn probe(&self) -> Result<(), String> {
272 let rows = self
273 .encode_prompts(&[PROBE_PROMPT.to_owned()])
274 .map_err(|e| e.to_string())?;
275 let chunk = [&rows[0]];
276 let native = self
277 .run_chunk(&chunk, true)
278 .map_err(|e| e.to_string())?
279 .into_iter()
280 .next()
281 .ok_or_else(|| "probe returned no row".to_owned())?;
282 probe_output_ok(&native, self.native_dim)
283 }
284
285 pub fn model_id(&self) -> &str {
286 &self.model_id
287 }
288
289 pub fn native_dim(&self) -> usize {
290 self.native_dim
291 }
292
293 pub fn dimension(&self) -> usize {
294 self.truncate_dim
295 }
296
297 pub fn provider_used(&self) -> &str {
299 self.provider_used
300 }
301
302 pub fn fallback_reason(&self) -> Option<&str> {
304 self.fallback_reason.as_deref()
305 }
306
307 pub fn pipeline_id(&self) -> String {
324 match self.pipeline_id.get_or_init(|| {
325 let mut files = Vec::new();
326 for name in [
327 "model_q4.onnx",
328 "model_q4.onnx_data",
329 "tokenizer.json",
330 "config.json",
331 ] {
332 files.push((name, hash_file(&self.model_dir.join(name))?));
333 }
334 Ok(pipeline_fingerprint(&PipelineParts {
335 model_id: &self.model_id,
336 native_dim: self.native_dim,
337 truncate_dim: self.truncate_dim,
338 max_tokens: self.max_tokens,
339 scrubs_sentinels: self.gemma2,
340 files: &files,
341 }))
342 }) {
343 Ok(id) => id.clone(),
344 Err(error) => format!("rkpipe1-unavailable-{}", sanitize(error)),
346 }
347 }
348
349 pub fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
350 self.embed_prompts(vec![self.document_prompt(text)])?
351 .pop()
352 .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
353 }
354
355 pub fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
356 self.embed_prompts(vec![self.query_prompt(text)])?
357 .pop()
358 .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
359 }
360
361 pub fn embed_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
365 where
366 I: IntoIterator<Item = &'a str>,
367 {
368 self.embed_prompts(texts.into_iter().map(|t| self.document_prompt(t)).collect())
369 }
370
371 pub fn embed_query_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
373 where
374 I: IntoIterator<Item = &'a str>,
375 {
376 self.embed_prompts(texts.into_iter().map(|t| self.query_prompt(t)).collect())
377 }
378
379 fn document_prompt(&self, text: &str) -> String {
380 format!("{DOCUMENT_PROMPT_PREFIX}{}", self.model_text(text))
381 }
382
383 fn query_prompt(&self, text: &str) -> String {
384 format!("{QUERY_PROMPT_PREFIX}{}", self.model_text(text))
385 }
386
387 fn model_text<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
390 if !self.gemma2 || !GEMMA2_MODALITY_SENTINELS.iter().any(|s| text.contains(s)) {
391 return std::borrow::Cow::Borrowed(text);
392 }
393 let mut owned = text.to_owned();
394 for sentinel in GEMMA2_MODALITY_SENTINELS {
395 owned = owned.replace(sentinel, &sentinel.replace('|', "\u{2502}"));
396 }
397 std::borrow::Cow::Owned(owned)
398 }
399
400 fn encode_prompts(&self, prompts: &[String]) -> Result<Vec<TokenRow>, EmbedError> {
401 let inputs: Vec<EncodeInput> = prompts
402 .iter()
403 .map(|prompt| EncodeInput::Single(prompt.as_str().into()))
404 .collect();
405 let encodings = self
406 .tokenizer
407 .encode_batch(inputs, true)
408 .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
409 encodings
410 .iter()
411 .map(|encoding| {
412 let ids: Vec<i64> = encoding
413 .get_ids()
414 .iter()
415 .take(self.max_tokens)
416 .map(|id| i64::from(*id))
417 .collect();
418 if ids.is_empty() {
419 return Err(EmbedError::Tokenizer("encoding returned no tokens".into()));
420 }
421 let mask: Vec<i64> = encoding
422 .get_attention_mask()
423 .iter()
424 .take(ids.len())
425 .map(|value| i64::from(*value))
426 .collect();
427 Ok(TokenRow { ids, mask })
428 })
429 .collect()
430 }
431
432 fn embed_prompts(&self, prompts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedError> {
433 if prompts.is_empty() {
434 return Ok(Vec::new());
435 }
436 let rows = self.encode_prompts(&prompts)?;
437 let lengths: Vec<usize> = rows.iter().map(|row| row.ids.len()).collect();
438 let order = length_order(&lengths);
439 let sorted_lengths: Vec<usize> = order.iter().map(|&i| lengths[i]).collect();
440 let accelerated = self.provider_used != "cpu";
441 let mut out: Vec<Option<Vec<f32>>> = vec![None; rows.len()];
442 for range in plan_chunks(
443 &sorted_lengths,
444 self.max_batch.max(1),
445 MAX_PADDED_TOKENS_PER_CALL,
446 ) {
447 let chunk: Vec<&TokenRow> = order[range.clone()].iter().map(|&i| &rows[i]).collect();
448 let natives = self.run_chunk(&chunk, accelerated)?;
449 for (&index, native) in order[range].iter().zip(natives) {
450 out[index] = Some(truncate_and_normalize(&native, self.truncate_dim));
451 }
452 }
453 out.into_iter()
454 .map(|row| row.ok_or_else(|| EmbedError::Output("missing embedding row".into())))
455 .collect()
456 }
457
458 fn run_chunk(
461 &self,
462 chunk: &[&TokenRow],
463 accelerated: bool,
464 ) -> Result<Vec<Vec<f32>>, EmbedError> {
465 let longest = chunk.iter().map(|row| row.ids.len()).max().unwrap_or(1);
466 let (batch_rows, padded_len) = padded_shape(chunk.len(), longest, accelerated);
467 let padded = pad_batch(chunk, self.pad_id, padded_len, batch_rows);
468 let (rows, len) = (padded.rows, padded.len);
469 let tensor = |data: Vec<i64>| {
470 ort::value::Tensor::from_array(([rows, len], data))
471 .map_err(|e| EmbedError::Runtime(e.to_string()))
472 };
473 let mut inputs = ort::inputs![
474 "input_ids" => tensor(padded.ids)?,
475 "attention_mask" => tensor(padded.mask)?
476 ];
477 if self.takes_token_type_ids {
478 inputs.push(("token_type_ids".into(), tensor(vec![0; rows * len])?.into()));
479 }
480 let mut session = self
481 .session
482 .lock()
483 .map_err(|_| EmbedError::Runtime("model mutex poisoned".into()))?;
484 let outputs = session
485 .run(inputs)
486 .map_err(|e| EmbedError::Runtime(e.to_string()))?;
487 let output = outputs
488 .get("sentence_embedding")
489 .ok_or_else(|| EmbedError::Output("missing sentence_embedding output".into()))?;
490 let (shape, values) = output
491 .try_extract_tensor::<f32>()
492 .map_err(|e| EmbedError::Output(e.to_string()))?;
493 let masks: Vec<&[i64]> = chunk.iter().map(|row| row.mask.as_slice()).collect();
494 pool_batch(shape.as_ref(), values, &masks, self.native_dim)
495 }
496}
497
498struct TokenRow {
500 ids: Vec<i64>,
501 mask: Vec<i64>,
502}
503
504#[derive(Debug, PartialEq, Eq)]
506struct PaddedBatch {
507 rows: usize,
508 len: usize,
509 ids: Vec<i64>,
510 mask: Vec<i64>,
511}
512
513fn length_order(lengths: &[usize]) -> Vec<usize> {
516 let mut order: Vec<usize> = (0..lengths.len()).collect();
517 order.sort_by_key(|&i| lengths[i]);
518 order
519}
520
521fn plan_chunks(
525 sorted_lengths: &[usize],
526 max_rows: usize,
527 token_budget: usize,
528) -> Vec<std::ops::Range<usize>> {
529 let mut ranges = Vec::new();
530 let mut start = 0;
531 while start < sorted_lengths.len() {
532 let mut end = start + 1;
533 while end < sorted_lengths.len()
534 && end - start < max_rows
535 && (end - start + 1) * sorted_lengths[end] <= token_budget
536 {
537 end += 1;
538 }
539 ranges.push(start..end);
540 start = end;
541 }
542 ranges
543}
544
545fn padded_shape(rows: usize, longest: usize, accelerated: bool) -> (usize, usize) {
549 if !accelerated {
550 return (rows, longest);
551 }
552 let len = ACCELERATOR_BUCKETS
553 .iter()
554 .copied()
555 .find(|bucket| *bucket >= longest)
556 .unwrap_or(longest);
557 (rows.next_power_of_two(), len)
558}
559
560fn pad_batch(chunk: &[&TokenRow], pad_id: i64, len: usize, rows: usize) -> PaddedBatch {
563 let mut ids = vec![pad_id; rows * len];
564 let mut mask = vec![0i64; rows * len];
565 for row in 0..rows {
566 let source = chunk[row.min(chunk.len() - 1)];
567 let used = source.ids.len().min(len);
568 ids[row * len..row * len + used].copy_from_slice(&source.ids[..used]);
569 mask[row * len..row * len + used].copy_from_slice(&source.mask[..used]);
570 }
571 PaddedBatch {
572 rows,
573 len,
574 ids,
575 mask,
576 }
577}
578
579fn pool_batch(
584 shape: &[i64],
585 values: &[f32],
586 masks: &[&[i64]],
587 native_dim: usize,
588) -> Result<Vec<Vec<f32>>, EmbedError> {
589 let real = masks.len();
590 if shape.len() == 2 && shape[1] == native_dim as i64 {
591 let batch = shape[0] as usize;
592 if batch >= real && values.len() == batch * native_dim {
593 return Ok((0..real)
594 .map(|row| normalize(values[row * native_dim..(row + 1) * native_dim].to_vec()))
595 .collect());
596 }
597 }
598 if shape.len() == 3 && shape[2] == native_dim as i64 && shape[0] > 0 && shape[1] > 0 {
599 let (batch, tokens) = (shape[0] as usize, shape[1] as usize);
600 if batch >= real && values.len() == batch * tokens * native_dim {
601 let mut pooled_rows = Vec::with_capacity(real);
602 for (row, mask) in masks.iter().enumerate() {
603 let mut pooled = vec![0.0f32; native_dim];
604 let mut count = 0.0f32;
605 for token in 0..tokens {
606 if mask.get(token).copied().unwrap_or(0) == 0 {
607 continue;
608 }
609 count += 1.0;
610 let base = (row * tokens + token) * native_dim;
611 for (dim, value) in pooled.iter_mut().enumerate() {
612 *value += values[base + dim];
613 }
614 }
615 if count == 0.0 {
616 return Err(EmbedError::Output(
617 "attention mask contains no tokens".into(),
618 ));
619 }
620 for value in &mut pooled {
621 *value /= count;
622 }
623 pooled_rows.push(normalize(pooled));
624 }
625 return Ok(pooled_rows);
626 }
627 }
628 Err(EmbedError::Output(format!(
629 "expected [B,{native_dim}] or [B,tokens,{native_dim}] with B >= {real}, got shape {shape:?}"
630 )))
631}
632
633fn probe_output_ok(native: &[f32], native_dim: usize) -> Result<(), String> {
634 if native.len() != native_dim {
635 return Err(format!(
636 "expected {native_dim} values, got {}",
637 native.len()
638 ));
639 }
640 if native.iter().any(|value| !value.is_finite()) {
641 return Err("non-finite values".into());
642 }
643 if native.iter().map(|value| value * value).sum::<f32>() <= f32::EPSILON {
644 return Err("all-zero output".into());
645 }
646 Ok(())
647}
648
649struct PipelineParts<'a> {
650 model_id: &'a str,
651 native_dim: usize,
652 truncate_dim: usize,
653 max_tokens: usize,
654 scrubs_sentinels: bool,
655 files: &'a [(&'a str, String)],
656}
657
658fn pipeline_fingerprint(parts: &PipelineParts<'_>) -> String {
660 let mut text = String::new();
661 let mut line = |key: &str, value: &str| {
662 text.push_str(key);
663 text.push('=');
664 text.push_str(&value.len().to_string());
665 text.push(':');
666 text.push_str(value);
667 text.push('\n');
668 };
669 line("schema", PIPELINE_SCHEMA);
670 line("model_id", parts.model_id);
671 line("native_dim", &parts.native_dim.to_string());
672 line("truncate_dim", &parts.truncate_dim.to_string());
673 line("max_tokens", &parts.max_tokens.to_string());
674 line("document_prompt", DOCUMENT_PROMPT_PREFIX);
675 line("query_prompt", QUERY_PROMPT_PREFIX);
676 line("pooling", POOLING_DESCRIPTION);
677 line("normalization", NORMALIZATION_DESCRIPTION);
678 line("scrubs_sentinels", &parts.scrubs_sentinels.to_string());
679 for (name, hash) in parts.files {
680 line(&format!("file:{name}"), hash);
681 }
682 let digest = Sha256::digest(text.as_bytes());
683 format!("rkpipe1-{}", hex(&digest[..16]))
684}
685
686fn hex(bytes: &[u8]) -> String {
687 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
688}
689
690fn sanitize(text: &str) -> String {
691 text.chars()
692 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
693 .take(48)
694 .collect()
695}
696
697fn hash_file(path: &Path) -> Result<String, String> {
700 let mut file = match std::fs::File::open(path) {
701 Ok(file) => file,
702 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok("absent".into()),
703 Err(error) => return Err(format!("{}: {error}", path.display())),
704 };
705 let mut hasher = Sha256::new();
706 std::io::copy(&mut file, &mut hasher).map_err(|e| format!("{}: {e}", path.display()))?;
707 Ok(hex(&hasher.finalize()))
708}
709
710fn bundle_is_gemma2(model_dir: &std::path::Path) -> Result<bool, EmbedError> {
714 let path = model_dir.join("config.json");
715 let Ok(bytes) = std::fs::read(&path) else {
716 return Ok(false);
717 };
718 let config: serde_json::Value = serde_json::from_slice(&bytes)
719 .map_err(|e| EmbedError::InvalidConfig(format!("{}: {e}", path.display())))?;
720 Ok(config["model_type"] == "embedding_gemma2")
721}
722
723fn validate_config(config: &EmbedderConfig) -> Result<(), EmbedError> {
724 if !config.model_dir.is_dir() {
725 return Err(EmbedError::MissingModelDirectory(config.model_dir.clone()));
726 }
727 if config.truncate_dim == 0 || config.truncate_dim > EMBEDDINGGEMMA_NATIVE_DIM {
728 return Err(EmbedError::InvalidConfig(format!(
729 "truncate dimension must be 1..={EMBEDDINGGEMMA_NATIVE_DIM}"
730 )));
731 }
732 if config.max_tokens == 0 || config.max_tokens > MAX_SEQUENCE_TOKENS {
733 return Err(EmbedError::InvalidConfig(format!(
734 "max tokens must be 1..={MAX_SEQUENCE_TOKENS}"
735 )));
736 }
737 if config.max_batch == 0 {
738 return Err(EmbedError::InvalidConfig("max batch must be >= 1".into()));
739 }
740 if config.intra_threads == 0 {
741 return Err(EmbedError::InvalidConfig(
742 "intra threads must be >= 1".into(),
743 ));
744 }
745 for name in ["model_q4.onnx", "model_q4.onnx_data", "tokenizer.json"] {
746 let path = config.model_dir.join(name);
747 if !path.is_file() {
748 return Err(EmbedError::MissingArtifact(path));
749 }
750 }
751 if !config.runtime_path.is_file() {
752 return Err(EmbedError::MissingArtifact(config.runtime_path.clone()));
753 }
754 Ok(())
755}
756
757fn normalize(mut values: Vec<f32>) -> Vec<f32> {
758 let norm = values.iter().map(|value| value * value).sum::<f32>().sqrt();
759 if norm > 0.0 {
760 for value in &mut values {
761 *value /= norm;
762 }
763 }
764 values
765}
766
767pub fn truncate_and_normalize(native: &[f32], dimension: usize) -> Vec<f32> {
768 let mut values = native[..native.len().min(dimension)].to_vec();
769 values.resize(dimension, 0.0);
770 normalize(values)
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776
777 fn row(ids: &[i64]) -> TokenRow {
778 TokenRow {
779 ids: ids.to_vec(),
780 mask: vec![1; ids.len()],
781 }
782 }
783
784 fn state(ids: &[i64], dim: usize) -> Vec<f32> {
787 ids.iter()
788 .enumerate()
789 .flat_map(|(pos, id)| {
790 (0..dim)
791 .map(move |d| ((*id as f32) * 0.37 + pos as f32 * 0.11 + d as f32 * 0.05).sin())
792 })
793 .collect()
794 }
795
796 #[test]
797 fn pad_batch_pads_right_with_zero_mask_and_repeats_last_row() {
798 let a = row(&[5, 6, 7]);
799 let b = row(&[9]);
800 let padded = pad_batch(&[&a, &b], 0, 4, 3);
801 assert_eq!((padded.rows, padded.len), (3, 4));
802 assert_eq!(padded.ids, vec![5, 6, 7, 0, 9, 0, 0, 0, 9, 0, 0, 0]);
803 assert_eq!(padded.mask, vec![1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
804 }
805
806 #[test]
807 fn pad_batch_uses_the_pad_id_for_masked_positions() {
808 let a = row(&[5]);
809 let padded = pad_batch(&[&a], 3, 3, 1);
810 assert_eq!(padded.ids, vec![5, 3, 3]);
811 assert_eq!(padded.mask, vec![1, 0, 0]);
812 }
813
814 #[test]
815 fn padded_mean_pool_matches_unpadded_rows() {
816 let dim = 8;
817 let rows_ids: [&[i64]; 3] = [&[3, 4, 5, 6, 7], &[10, 11], &[20, 21, 22]];
818 let rows: Vec<TokenRow> = rows_ids.iter().map(|ids| row(ids)).collect();
819 let refs: Vec<&TokenRow> = rows.iter().collect();
820 let padded = pad_batch(&refs, 0, 5, 3);
821 let mut values = vec![9999.0f32; padded.rows * padded.len * dim];
824 for (r, ids) in rows_ids.iter().enumerate() {
825 let s = state(ids, dim);
826 let base = r * padded.len * dim;
827 values[base..base + s.len()].copy_from_slice(&s);
828 }
829 let masks: Vec<&[i64]> = rows.iter().map(|r| r.mask.as_slice()).collect();
830 let batched =
831 pool_batch(&[3, padded.len as i64, dim as i64], &values, &masks, dim).unwrap();
832 for (r, ids) in rows_ids.iter().enumerate() {
833 let single = pool_batch(
834 &[1, ids.len() as i64, dim as i64],
835 &state(ids, dim),
836 &[rows[r].mask.as_slice()],
837 dim,
838 )
839 .unwrap();
840 let diff = batched[r]
841 .iter()
842 .zip(&single[0])
843 .map(|(a, b)| (a - b).abs())
844 .fold(0.0f32, f32::max);
845 assert!(diff <= 1e-6, "row {r} differs by {diff}");
846 let norm: f32 = batched[r].iter().map(|v| v * v).sum::<f32>().sqrt();
847 assert!((norm - 1.0).abs() <= 1e-5);
848 }
849 }
850
851 #[test]
852 fn pooled_output_rows_are_normalized_and_extra_rows_dropped() {
853 let dim = 4;
854 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];
855 let m = [1i64];
856 let out = pool_batch(&[3, 4], &values, &[&m, &m], dim).unwrap();
857 assert_eq!(out.len(), 2);
858 assert_eq!(out[0], vec![0.6, 0.8, 0.0, 0.0]);
859 assert_eq!(out[1], vec![0.0, 0.0, 1.0, 0.0]);
860 }
861
862 #[test]
863 fn pool_rejects_bad_shapes_and_empty_masks() {
864 let m = [0i64, 0];
865 assert!(pool_batch(&[1, 2, 2], &[1.0; 4], &[&m], 2).is_err());
866 assert!(pool_batch(&[1, 3], &[1.0; 3], &[&[1]], 2).is_err());
867 assert!(pool_batch(&[1, 2], &[1.0; 2], &[&[1], &[1]], 2).is_err());
868 }
869
870 #[test]
871 fn chunks_respect_row_cap_token_budget_and_cover_everything() {
872 let lengths = [1, 2, 3, 4, 5, 6, 7];
873 assert_eq!(plan_chunks(&lengths, 3, 1000), vec![0..3, 3..6, 6..7]);
874 assert_eq!(plan_chunks(&[1, 3, 4, 4], 8, 10), vec![0..2, 2..4]);
876 assert_eq!(plan_chunks(&[50, 60], 8, 10), vec![0..1, 1..2]);
878 assert!(plan_chunks(&[], 4, 10).is_empty());
879 }
880
881 #[test]
882 fn length_order_is_stable_ascending() {
883 assert_eq!(length_order(&[5, 2, 5, 1, 2]), vec![3, 1, 4, 0, 2]);
884 }
885
886 #[test]
887 fn accelerators_use_bucketed_shapes_cpu_uses_exact() {
888 assert_eq!(padded_shape(3, 70, false), (3, 70));
889 assert_eq!(padded_shape(3, 70, true), (4, 128));
890 assert_eq!(padded_shape(1, 64, true), (1, 64));
891 assert_eq!(padded_shape(16, 2048, true), (16, 2048));
892 }
893
894 #[test]
895 fn probe_rejects_zero_nonfinite_and_wrong_size() {
896 assert!(probe_output_ok(&[0.0; 4], 4).is_err());
897 assert!(probe_output_ok(&[f32::NAN, 1.0, 0.0, 0.0], 4).is_err());
898 assert!(probe_output_ok(&[1.0; 3], 4).is_err());
899 assert!(probe_output_ok(&[0.5, 0.5, 0.5, 0.5], 4).is_ok());
900 }
901
902 fn parts<'a>(files: &'a [(&'a str, String)], truncate: usize) -> PipelineParts<'a> {
903 PipelineParts {
904 model_id: EMBEDDINGGEMMA_MODEL_ID,
905 native_dim: 768,
906 truncate_dim: truncate,
907 max_tokens: 2048,
908 scrubs_sentinels: false,
909 files,
910 }
911 }
912
913 #[test]
914 fn pipeline_fingerprint_is_stable_and_sensitive() {
915 let files = [("model_q4.onnx", "aa".to_owned())];
916 let base = pipeline_fingerprint(&parts(&files, 256));
917 assert_eq!(base, pipeline_fingerprint(&parts(&files, 256)));
918 assert!(base.starts_with("rkpipe1-") && base.len() == "rkpipe1-".len() + 32);
919 assert_ne!(base, pipeline_fingerprint(&parts(&files, 128)));
920 let changed = [("model_q4.onnx", "ab".to_owned())];
921 assert_ne!(base, pipeline_fingerprint(&parts(&changed, 256)));
922 let mut other = parts(&files, 256);
923 other.scrubs_sentinels = true;
924 assert_ne!(base, pipeline_fingerprint(&other));
925 other = parts(&files, 256);
926 other.max_tokens = 512;
927 assert_ne!(base, pipeline_fingerprint(&other));
928 }
929
930 #[test]
931 fn hash_file_reports_absent_and_content() {
932 let dir = tempfile::tempdir().unwrap();
933 assert_eq!(hash_file(&dir.path().join("none")).unwrap(), "absent");
934 let path = dir.path().join("f");
935 std::fs::write(&path, b"abc").unwrap();
936 assert_eq!(
937 hash_file(&path).unwrap(),
938 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
939 );
940 }
941
942 #[test]
943 fn config_defaults_and_builders() {
944 let c = EmbedderConfig::new("m", "r");
945 assert_eq!((c.max_batch, c.intra_threads), (16, 1));
946 assert_eq!(c.provider, ExecutionProvider::Cpu);
947 let c = c
948 .with_max_batch(4)
949 .with_intra_threads(3)
950 .with_provider(ExecutionProvider::Auto);
951 assert_eq!((c.max_batch, c.intra_threads), (4, 3));
952 assert_eq!(c.provider, ExecutionProvider::Auto);
953 let legacy =
954 EmbedderConfig::new("m", "r").with_provider(rightkit_ort::ExecutionProvider::DirectMl);
955 assert_eq!(legacy.provider, ExecutionProvider::DirectMl);
956 }
957}