Skip to main content

rightkit_embed/
embedder.rs

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