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