Skip to main content

rightkit_embed/
embedder.rs

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";
14/// EmbeddingGemma 2 text-only Q4 export (b64, accuracy level 4). Selected when
15/// the model directory's top-level `config.json` `model_type` is
16/// `embedding_gemma2`.
17pub const EMBEDDINGGEMMA2_MODEL_ID: &str = "embeddinggemma2-q4";
18/// Literal special tokens that encode to EmbeddingGemma 2's image, video and
19/// audio sentinel ids; the text-only graph is exact only without them.
20const 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;
24/// Default rows per ONNX call for `embed_batch` / `embed_query_batch`.
25pub const DEFAULT_MAX_BATCH: usize = 16;
26/// Upper bound on padded tokens (`rows * padded_len`) in one ONNX call. Keeps
27/// attention memory bounded when long texts share a chunk; a single row always
28/// fits regardless.
29const MAX_PADDED_TOKENS_PER_CALL: usize = 16_384;
30/// Fixed sequence-length buckets used on accelerators, which recompile per
31/// input shape (DirectML measured 345-623 ms dynamic vs ~26 ms padded).
32const 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: ";
35/// Bump when the embedding math changes in a way the other fingerprint inputs
36/// do not capture (pooling, prompt assembly, truncation order).
37const 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/// Where inference runs.
59///
60/// `Cpu` is the default. `Auto` registers the platform accelerator (DirectML
61/// on Windows, CoreML on macOS) and falls back to CPU, with the reason
62/// recorded, when registration, model load or a probe inference fails.
63/// `DirectMl` / `CoreMl` request that accelerator explicitly and fail loudly
64/// instead of falling back.
65#[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    /// Rows per ONNX call for the batch methods (default 16, minimum 1).
146    pub fn with_max_batch(mut self, max_batch: usize) -> Self {
147        self.max_batch = max_batch;
148        self
149    }
150
151    /// Intra-op threads for the session (default 1, minimum 1).
152    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        // Padding is owned here (masked, to the chunk maximum), never by the
217        // tokenizer file's own padding strategy.
218        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    /// One real inference through the live session; any error, shape mismatch,
270    /// non-finite or all-zero output rejects the accelerator.
271    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    /// Provider serving inference: `"cpu"`, `"directml"` or `"coreml"`.
298    pub fn provider_used(&self) -> &str {
299        self.provider_used
300    }
301
302    /// Why an accelerator was replaced by CPU, when it was.
303    pub fn fallback_reason(&self) -> Option<&str> {
304        self.fallback_reason.as_deref()
305    }
306
307    /// Stable fingerprint of everything that changes the produced vectors, so
308    /// callers can detect when stored vectors need re-embedding. Fed by:
309    ///
310    /// - the pipeline schema version of this crate's embedding math;
311    /// - model id and native dimension;
312    /// - SHA-256 of `model_q4.onnx`, `model_q4.onnx_data`, `tokenizer.json`
313    ///   and `config.json` (absent file recorded as absent);
314    /// - the document and query prompt templates;
315    /// - pooling and normalization descriptions;
316    /// - Matryoshka truncate dimension and max token length;
317    /// - whether Gemma 2 modality sentinels are scrubbed.
318    ///
319    /// Not fed by provider, thread count or batch size: those change only
320    /// floating-point noise (see `max_batch` tolerance in the README), not
321    /// the pipeline. Computed once on first call (hashes the model files) and
322    /// cached; format `rkpipe1-<32 hex>`.
323    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            // Unreadable artifact: an id that can never equal a good one.
345            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    /// Embeds documents in input order. Texts are tokenized together, ordered
362    /// by length so chunks pad little, run `max_batch` rows per ONNX call with
363    /// the attention mask honoured, then returned in input order.
364    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    /// Query-prompt counterpart of [`Self::embed_batch`].
372    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    /// EmbeddingGemma 2's text-only graph must never see modality sentinels;
388    /// their literal spellings are defanged before tokenization.
389    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    /// One ONNX call over `chunk`; returns the normalized native-dimension
459    /// vector per row, in chunk order.
460    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
498/// One tokenized, truncated text.
499struct TokenRow {
500    ids: Vec<i64>,
501    mask: Vec<i64>,
502}
503
504/// Row-major `[rows, len]` model inputs.
505#[derive(Debug, PartialEq, Eq)]
506struct PaddedBatch {
507    rows: usize,
508    len: usize,
509    ids: Vec<i64>,
510    mask: Vec<i64>,
511}
512
513/// Stable ascending-length order (ties keep input order) so chunks of similar
514/// length share a call and pad little.
515fn 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
521/// Splits ascending `sorted_lengths` into contiguous ranges of at most
522/// `max_rows` rows whose padded size `rows * longest` stays within
523/// `token_budget`; a lone row is always allowed.
524fn 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
545/// `(rows, len)` of the tensor sent to the model. CPU uses the exact chunk
546/// maximum. Accelerators recompile per shape, so length rounds up to a bucket
547/// and rows to a power of two (extra rows repeat the last row and are dropped).
548fn 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
560/// Right-pads every row to `len` with `pad_id` ids and zero mask; rows beyond
561/// `chunk.len()` (up to `rows`) repeat the last row.
562fn 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
579/// Turns a model output into one L2-normalized native vector per real row
580/// (`masks.len()` rows; extra padding rows are ignored). Accepts the model's
581/// pooled `[B, D]` output, or token states `[B, T, D]` which are mean-pooled
582/// per row over the attention mask (positions beyond a row's mask are padding).
583fn 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
658/// Pure fingerprint over the pipeline description; see [`Embedder::pipeline_id`].
659fn 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
697/// SHA-256 hex of a file, or `absent` when it does not exist. Other read
698/// errors fail the fingerprint rather than hashing a partial file.
699fn 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
710/// Reads only the top-level `model_type`; nested sub-configs (audio, text,
711/// vision) carry their own types and are ignored. A missing config.json is the
712/// original EmbeddingGemma bundle.
713fn 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    /// Deterministic per-token state independent of padding, like a model whose
785    /// masked positions cannot influence valid ones.
786    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        // Model output for the padded batch: valid positions carry real state,
822        // masked positions carry garbage that must be ignored.
823        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        // budget: 2 rows * 3 = 6 ok, 3 rows * 4 = 12 > 10 stops the chunk.
875        assert_eq!(plan_chunks(&[1, 3, 4, 4], 8, 10), vec![0..2, 2..4]);
876        // a lone oversized row is still allowed
877        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}