Skip to main content

rightkit_embed/
embedder.rs

1use rightkit_onnx::ort;
2use std::path::{Path, PathBuf};
3use std::sync::{Mutex, OnceLock};
4
5use rightkit_onnx::ort::session::Session;
6use rightkit_onnx::{
7    configure_runtime, init_environment, CandidateSource, EnvironmentOptions, GlobalPool,
8    RuntimeCandidate, SessionOptions,
9};
10use thiserror::Error;
11use tokenizers::{EncodeInput, Tokenizer};
12
13pub const EMBEDDINGGEMMA_MODEL_ID: &str = "embeddinggemma-300m-q4";
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/// An accelerator is admitted only if every parity prompt's native-dimension
42/// vector has at least this cosine similarity to the CPU provider's vector for
43/// the same prompt. Two correct runs of the same Q4 graph agree at about 0.999;
44/// a DirectML run of EmbeddingGemma 2 Q4 measured 0.58-0.72.
45pub const ACCELERATOR_PARITY_MIN_COSINE: f32 = 0.995;
46/// Short query-style parity prompt (about 20 tokens with its prefix).
47const PARITY_QUERY: &str = "how are invoices matched to the ledger";
48/// Medium document-style parity prompt (roughly 90 tokens with its prefix).
49const PARITY_DOCUMENT: &str = "The nightly billing job reads every invoice issued during the day, \
50looks up the matching ledger account, and compares the invoiced total with the posted amount. \
51Differences below one cent are rounded away. Larger differences are written to an exceptions \
52table together with the customer, the invoice number and the date, and the finance team reviews \
53that table each morning before the payment run starts.";
54/// Sections in the long parity prompt (several hundred tokens).
55const PARITY_LONG_SECTIONS: usize = 20;
56
57#[derive(Debug, Error)]
58pub enum EmbedError {
59    #[error("model directory is missing: {0}")]
60    MissingModelDirectory(PathBuf),
61    #[error("model artifact is missing: {0}")]
62    MissingArtifact(PathBuf),
63    #[error("invalid model configuration: {0}")]
64    InvalidConfig(String),
65    #[error("tokenizer: {0}")]
66    Tokenizer(String),
67    #[error("ONNX Runtime: {0}")]
68    Runtime(String),
69    #[error("model output: {0}")]
70    Output(String),
71    /// An explicitly requested accelerator did not reproduce the CPU
72    /// provider's vectors at load (or could not be compared), so it was not
73    /// admitted. `lowest_cosine` is `None` when the failure is not a low
74    /// cosine (non-finite or zero output, wrong size); `detail` names the
75    /// prompt length. `Auto` never returns this: it falls back to CPU with the
76    /// same text in `fallback_reason`.
77    #[error("{provider} failed the vector parity check against cpu: {detail}")]
78    AcceleratorParity {
79        provider: &'static str,
80        lowest_cosine: Option<f32>,
81        floor: f32,
82        detail: String,
83    },
84}
85
86/// Result of the load-time vector parity check an accelerator passed; see
87/// [`Embedder::accelerator_parity`].
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct AcceleratorParity {
90    /// Lowest cosine to the CPU provider's vector over all compared pairs.
91    pub lowest_cosine: f32,
92    /// Token count of the prompt that produced it.
93    pub at_tokens: usize,
94    /// Pairs compared.
95    pub pairs: usize,
96}
97
98/// Where inference runs.
99///
100/// `Cpu` is the default. `Auto` registers the platform accelerator (DirectML
101/// on Windows, CoreML on macOS) and falls back to CPU, with the reason
102/// recorded, when registration, model load, the probe inference or the vector
103/// parity check fails. `DirectMl` / `CoreMl` request that accelerator
104/// explicitly and fail loudly instead of falling back: a failed parity check
105/// is [`EmbedError::AcceleratorParity`].
106///
107/// An accelerator is used only if it reproduces the CPU provider's vectors.
108/// Whenever the session was built on anything other than CPU, `Embedder::load`
109/// builds a CPU session for the same model, embeds fixed short, medium and
110/// long prompts (alone and as one batch) through both sessions with the
111/// crate's real inference path, and requires every pair to have cosine
112/// similarity of at least [`ACCELERATOR_PARITY_MIN_COSINE`] on the full
113/// native-dimension vector, with finite non-zero output. On success the CPU
114/// session is dropped before `load` returns, so steady-state memory is
115/// unchanged; the check costs one extra CPU session build and a few inferences
116/// at load. `Cpu` does none of this. The check cannot be turned off.
117///
118/// Measured: DirectML (ONNX Runtime 1.24.4) returns wrong EmbeddingGemma 2 Q4
119/// vectors (cosine to CPU 0.58-0.72) that are finite, non-zero and faster
120/// than CPU, so a probe or speed check alone would accept them. CoreML is
121/// unmeasured; it must pass the same check.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum ExecutionProvider {
124    #[default]
125    Cpu,
126    Auto,
127    DirectMl,
128    CoreMl,
129}
130
131impl From<rightkit_onnx::ExecutionProvider> for ExecutionProvider {
132    fn from(value: rightkit_onnx::ExecutionProvider) -> Self {
133        match value {
134            rightkit_onnx::ExecutionProvider::Cpu => Self::Cpu,
135            rightkit_onnx::ExecutionProvider::DirectMl => Self::DirectMl,
136            rightkit_onnx::ExecutionProvider::CoreMl => Self::CoreMl,
137        }
138    }
139}
140
141impl ExecutionProvider {
142    fn requested(self) -> (rightkit_onnx::ExecutionProvider, bool) {
143        match self {
144            Self::Cpu => (rightkit_onnx::ExecutionProvider::Cpu, false),
145            Self::Auto => (
146                rightkit_onnx::ExecutionProvider::platform_accelerator(),
147                true,
148            ),
149            Self::DirectMl => (rightkit_onnx::ExecutionProvider::DirectMl, false),
150            Self::CoreMl => (rightkit_onnx::ExecutionProvider::CoreMl, false),
151        }
152    }
153}
154
155fn provider_name(provider: rightkit_onnx::ExecutionProvider) -> &'static str {
156    match provider {
157        rightkit_onnx::ExecutionProvider::Cpu => "cpu",
158        rightkit_onnx::ExecutionProvider::DirectMl => "directml",
159        rightkit_onnx::ExecutionProvider::CoreMl => "coreml",
160    }
161}
162
163#[derive(Debug, Clone)]
164pub struct EmbedderConfig {
165    pub model_dir: PathBuf,
166    pub runtime_path: PathBuf,
167    pub provider: ExecutionProvider,
168    pub truncate_dim: usize,
169    pub max_tokens: usize,
170    pub max_batch: usize,
171    pub intra_threads: usize,
172}
173
174impl EmbedderConfig {
175    pub fn new(model_dir: impl Into<PathBuf>, runtime_path: impl Into<PathBuf>) -> Self {
176        Self {
177            model_dir: model_dir.into(),
178            runtime_path: runtime_path.into(),
179            provider: ExecutionProvider::Cpu,
180            truncate_dim: DEFAULT_TRUNCATE_DIM,
181            max_tokens: MAX_SEQUENCE_TOKENS,
182            max_batch: DEFAULT_MAX_BATCH,
183            intra_threads: 1,
184        }
185    }
186
187    pub fn with_provider(mut self, provider: impl Into<ExecutionProvider>) -> Self {
188        self.provider = provider.into();
189        self
190    }
191
192    pub fn with_truncate_dim(mut self, dim: usize) -> Self {
193        self.truncate_dim = dim;
194        self
195    }
196
197    pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
198        self.max_tokens = max_tokens;
199        self
200    }
201
202    /// Rows per ONNX call for the batch methods (default 16, minimum 1).
203    pub fn with_max_batch(mut self, max_batch: usize) -> Self {
204        self.max_batch = max_batch;
205        self
206    }
207
208    /// Intra-op threads for the session (default 1, minimum 1).
209    pub fn with_intra_threads(mut self, threads: usize) -> Self {
210        self.intra_threads = threads;
211        self
212    }
213}
214
215pub struct Embedder {
216    tokenizer: Tokenizer,
217    session: Mutex<Session>,
218    model_dir: PathBuf,
219    native_dim: usize,
220    truncate_dim: usize,
221    max_tokens: usize,
222    max_batch: usize,
223    model_id: String,
224    gemma2: bool,
225    pad_id: i64,
226    takes_token_type_ids: bool,
227    provider_used: &'static str,
228    fallback_reason: Option<String>,
229    parity: Option<AcceleratorParity>,
230    pipeline_id: OnceLock<Result<String, String>>,
231}
232
233struct BuiltEmbedSession {
234    session: Session,
235    provider: rightkit_onnx::ExecutionProvider,
236    fallback: Option<String>,
237}
238
239fn build_session(
240    config: &EmbedderConfig,
241    provider: rightkit_onnx::ExecutionProvider,
242    cpu_fallback: bool,
243) -> Result<BuiltEmbedSession, EmbedError> {
244    let built = SessionOptions::new(provider)
245        .with_threads(Some(config.intra_threads))
246        .with_cpu_fallback(cpu_fallback)
247        .build_from_file_reported(&config.model_dir.join("model_q4.onnx"))
248        .map_err(|e| EmbedError::Runtime(e.to_string()))?;
249    Ok(BuiltEmbedSession {
250        session: built.session,
251        provider: built.provider,
252        fallback: built.fallback,
253    })
254}
255
256fn takes_token_type_ids(session: &Session) -> bool {
257    session
258        .inputs()
259        .iter()
260        .any(|input| input.name() == "token_type_ids")
261}
262
263/// Tokenizer with padding owned by this crate (masked, to the chunk maximum),
264/// never by the tokenizer file's own padding strategy, and the pad id.
265fn load_tokenizer(model_dir: &Path) -> Result<(Tokenizer, i64), EmbedError> {
266    let mut tokenizer = Tokenizer::from_file(model_dir.join("tokenizer.json"))
267        .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
268    let pad_id = tokenizer
269        .get_padding()
270        .map(|padding| i64::from(padding.pad_id))
271        .or_else(|| tokenizer.token_to_id("<pad>").map(i64::from))
272        .unwrap_or(0);
273    tokenizer.with_padding(None);
274    Ok((tokenizer, pad_id))
275}
276
277impl Embedder {
278    pub fn load(config: EmbedderConfig) -> Result<Self, EmbedError> {
279        validate_config(&config)?;
280        let gemma2 = bundle_is_gemma2(&config.model_dir)?;
281        let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
282        let selection = configure_runtime([RuntimeCandidate::new(
283            config.runtime_path.clone(),
284            CandidateSource::Explicit,
285        )])
286        .map_err(|e| EmbedError::Runtime(e.to_string()))?;
287        init_environment(
288            &selection,
289            &EnvironmentOptions {
290                global_pool: Some(GlobalPool::from_budget()),
291            },
292        )
293        .map_err(EmbedError::Runtime)?;
294        let (requested, auto) = config.provider.requested();
295        let built = build_session(&config, requested, auto)?;
296        let accelerator = built.provider;
297        let mut embedder = Self::from_session(&config, tokenizer, pad_id, gemma2, built);
298        if auto && accelerator != rightkit_onnx::ExecutionProvider::Cpu {
299            if let Err(reason) = embedder.probe() {
300                let cpu = build_session(&config, rightkit_onnx::ExecutionProvider::Cpu, false)?;
301                embedder.takes_token_type_ids = takes_token_type_ids(&cpu.session);
302                embedder.session = Mutex::new(cpu.session);
303                embedder.provider_used = provider_name(cpu.provider);
304                embedder.fallback_reason = Some(format!(
305                    "{} -> cpu: probe inference failed: {reason}",
306                    provider_name(accelerator)
307                ));
308            }
309        }
310        if embedder.provider_used != "cpu" {
311            return embedder.admit_accelerator(&config, auto);
312        }
313        Ok(embedder)
314    }
315
316    fn from_session(
317        config: &EmbedderConfig,
318        tokenizer: Tokenizer,
319        pad_id: i64,
320        gemma2: bool,
321        built: BuiltEmbedSession,
322    ) -> Self {
323        Self {
324            tokenizer,
325            takes_token_type_ids: takes_token_type_ids(&built.session),
326            session: Mutex::new(built.session),
327            model_dir: config.model_dir.clone(),
328            native_dim: EMBEDDINGGEMMA_NATIVE_DIM,
329            truncate_dim: config.truncate_dim,
330            max_tokens: config.max_tokens,
331            max_batch: config.max_batch,
332            model_id: if gemma2 {
333                EMBEDDINGGEMMA2_MODEL_ID
334            } else {
335                EMBEDDINGGEMMA_MODEL_ID
336            }
337            .to_owned(),
338            gemma2,
339            pad_id,
340            provider_used: provider_name(built.provider),
341            fallback_reason: built.fallback,
342            parity: None,
343            pipeline_id: OnceLock::new(),
344        }
345    }
346
347    /// Vector parity gate for an embedder built on an accelerator (`self`).
348    /// Builds a CPU embedder for the same model and embeds the parity prompts
349    /// through both with the real inference path. Passing keeps `self` and
350    /// drops the CPU embedder; failing returns the CPU embedder with the
351    /// reason under `Auto`, and [`EmbedError::AcceleratorParity`] otherwise.
352    fn admit_accelerator(
353        mut self,
354        config: &EmbedderConfig,
355        auto: bool,
356    ) -> Result<Self, EmbedError> {
357        let provider = self.provider_used;
358        let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
359        let cpu_built = build_session(config, rightkit_onnx::ExecutionProvider::Cpu, false)?;
360        let mut cpu = Self::from_session(config, tokenizer, pad_id, self.gemma2, cpu_built);
361        let prompts = cpu.parity_prompts();
362        let tokens: Vec<usize> = cpu
363            .encode_prompts(&prompts)?
364            .iter()
365            .map(|row| row.ids.len())
366            .collect();
367        let reference = cpu.parity_vectors(&prompts)?;
368        let (lowest_cosine, detail) = match self.parity_vectors(&prompts) {
369            Ok(candidate) => match compare_parity(
370                &reference,
371                &candidate,
372                self.native_dim,
373                ACCELERATOR_PARITY_MIN_COSINE,
374            ) {
375                Ok(lowest) => {
376                    self.parity = Some(AcceleratorParity {
377                        lowest_cosine: lowest.cosine,
378                        at_tokens: tokens[lowest.index % tokens.len()],
379                        pairs: reference.len(),
380                    });
381                    return Ok(self);
382                }
383                Err(miss) => parity_miss_detail(&miss, &tokens, ACCELERATOR_PARITY_MIN_COSINE),
384            },
385            Err(error) if auto => (None, format!("inference error: {error}")),
386            Err(error) => return Err(error),
387        };
388        if auto {
389            cpu.fallback_reason =
390                Some(format!("{provider} -> cpu: vector parity failed: {detail}"));
391            return Ok(cpu);
392        }
393        Err(EmbedError::AcceleratorParity {
394            provider,
395            lowest_cosine,
396            floor: ACCELERATOR_PARITY_MIN_COSINE,
397            detail,
398        })
399    }
400
401    /// Fixed parity prompts of clearly different token lengths: a short
402    /// query, a medium document and a long document, with this crate's prefixes.
403    fn parity_prompts(&self) -> Vec<String> {
404        vec![
405            self.query_prompt(PARITY_QUERY),
406            self.document_prompt(PARITY_DOCUMENT),
407            self.document_prompt(&parity_long_document()),
408        ]
409    }
410
411    /// Native-dimension normalized vectors for `prompts`: first each prompt
412    /// alone (its own accelerator shape bucket), then all together as one
413    /// batch. `2 * prompts.len()` vectors.
414    fn parity_vectors(&self, prompts: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> {
415        let mut vectors = Vec::with_capacity(prompts.len() * 2);
416        for prompt in prompts {
417            vectors.extend(self.embed_prompts_native(vec![prompt.clone()])?);
418        }
419        vectors.extend(self.embed_prompts_native(prompts.to_vec())?);
420        Ok(vectors)
421    }
422
423    /// One real inference through the live session; any error, shape mismatch,
424    /// non-finite or all-zero output rejects the accelerator.
425    fn probe(&self) -> Result<(), String> {
426        let rows = self
427            .encode_prompts(&[PROBE_PROMPT.to_owned()])
428            .map_err(|e| e.to_string())?;
429        let chunk = [&rows[0]];
430        let native = self
431            .run_chunk(&chunk, true)
432            .map_err(|e| e.to_string())?
433            .into_iter()
434            .next()
435            .ok_or_else(|| "probe returned no row".to_owned())?;
436        probe_output_ok(&native, self.native_dim)
437    }
438
439    pub fn model_id(&self) -> &str {
440        &self.model_id
441    }
442
443    pub fn native_dim(&self) -> usize {
444        self.native_dim
445    }
446
447    pub fn dimension(&self) -> usize {
448        self.truncate_dim
449    }
450
451    /// Provider serving inference: `"cpu"`, `"directml"` or `"coreml"`.
452    pub fn provider_used(&self) -> &str {
453        self.provider_used
454    }
455
456    /// Why an accelerator was replaced by CPU, when it was. A vector parity
457    /// failure reads like `directml -> cpu: vector parity failed: cosine 0.579
458    /// at ~20 tokens, floor 0.995`.
459    pub fn fallback_reason(&self) -> Option<&str> {
460        self.fallback_reason.as_deref()
461    }
462
463    /// The load-time vector parity result of the accelerator in use: lowest
464    /// cosine to the CPU provider's vectors, the prompt length it occurred at
465    /// and the pairs compared. `None` when the provider is CPU (no parity
466    /// work is done) and when an accelerator was replaced by CPU (the failure
467    /// is in [`Self::fallback_reason`]). Always at least
468    /// [`ACCELERATOR_PARITY_MIN_COSINE`] when `Some`.
469    pub fn accelerator_parity(&self) -> Option<AcceleratorParity> {
470        self.parity
471    }
472
473    /// Stable fingerprint of everything that changes the produced vectors, so
474    /// callers can detect when stored vectors need re-embedding. Fed by:
475    ///
476    /// - the pipeline schema version of this crate's embedding math;
477    /// - model id and native dimension;
478    /// - SHA-256 of `model_q4.onnx`, `model_q4.onnx_data`, `tokenizer.json`
479    ///   and `config.json` (absent file recorded as absent);
480    /// - the document and query prompt templates;
481    /// - pooling and normalization descriptions;
482    /// - Matryoshka truncate dimension and max token length;
483    /// - whether Gemma 2 modality sentinels are scrubbed.
484    ///
485    /// Not fed by provider, thread count or batch size: those change only
486    /// floating-point noise (see `max_batch` tolerance in the README), not
487    /// the pipeline. The accelerator parity check is not an input either: it
488    /// only decides which provider serves the same math, and an admitted
489    /// accelerator reproduces the CPU vectors to the parity floor. Computed
490    /// once on first call (hashes the model files) and cached; format
491    /// `rkpipe1-<32 hex>`.
492    pub fn pipeline_id(&self) -> String {
493        match self.pipeline_id.get_or_init(|| {
494            let mut files = Vec::new();
495            for name in [
496                "model_q4.onnx",
497                "model_q4.onnx_data",
498                "tokenizer.json",
499                "config.json",
500            ] {
501                files.push((name, hash_file(&self.model_dir.join(name))?));
502            }
503            Ok(pipeline_fingerprint(&PipelineParts {
504                model_id: &self.model_id,
505                native_dim: self.native_dim,
506                truncate_dim: self.truncate_dim,
507                max_tokens: self.max_tokens,
508                scrubs_sentinels: self.gemma2,
509                files: &files,
510            }))
511        }) {
512            Ok(id) => id.clone(),
513            // Unreadable artifact: an id that can never equal a good one.
514            Err(error) => format!("rkpipe1-unavailable-{}", sanitize(error)),
515        }
516    }
517
518    pub fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
519        self.embed_prompts(vec![self.document_prompt(text)])?
520            .pop()
521            .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
522    }
523
524    pub fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
525        self.embed_prompts(vec![self.query_prompt(text)])?
526            .pop()
527            .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
528    }
529
530    /// Embeds documents in input order. Texts are tokenized together, ordered
531    /// by length so chunks pad little, run `max_batch` rows per ONNX call with
532    /// the attention mask honoured, then returned in input order.
533    pub fn embed_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
534    where
535        I: IntoIterator<Item = &'a str>,
536    {
537        self.embed_prompts(texts.into_iter().map(|t| self.document_prompt(t)).collect())
538    }
539
540    /// Query-prompt counterpart of [`Self::embed_batch`].
541    pub fn embed_query_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
542    where
543        I: IntoIterator<Item = &'a str>,
544    {
545        self.embed_prompts(texts.into_iter().map(|t| self.query_prompt(t)).collect())
546    }
547
548    fn document_prompt(&self, text: &str) -> String {
549        format!("{DOCUMENT_PROMPT_PREFIX}{}", self.model_text(text))
550    }
551
552    fn query_prompt(&self, text: &str) -> String {
553        format!("{QUERY_PROMPT_PREFIX}{}", self.model_text(text))
554    }
555
556    /// EmbeddingGemma 2's text-only graph must never see modality sentinels;
557    /// their literal spellings are defanged before tokenization.
558    fn model_text<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
559        if !self.gemma2 || !GEMMA2_MODALITY_SENTINELS.iter().any(|s| text.contains(s)) {
560            return std::borrow::Cow::Borrowed(text);
561        }
562        let mut owned = text.to_owned();
563        for sentinel in GEMMA2_MODALITY_SENTINELS {
564            owned = owned.replace(sentinel, &sentinel.replace('|', "\u{2502}"));
565        }
566        std::borrow::Cow::Owned(owned)
567    }
568
569    fn encode_prompts(&self, prompts: &[String]) -> Result<Vec<TokenRow>, EmbedError> {
570        let inputs: Vec<EncodeInput> = prompts
571            .iter()
572            .map(|prompt| EncodeInput::Single(prompt.as_str().into()))
573            .collect();
574        let encodings = self
575            .tokenizer
576            .encode_batch(inputs, true)
577            .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
578        encodings
579            .iter()
580            .map(|encoding| {
581                let ids: Vec<i64> = encoding
582                    .get_ids()
583                    .iter()
584                    .take(self.max_tokens)
585                    .map(|id| i64::from(*id))
586                    .collect();
587                if ids.is_empty() {
588                    return Err(EmbedError::Tokenizer("encoding returned no tokens".into()));
589                }
590                let mask: Vec<i64> = encoding
591                    .get_attention_mask()
592                    .iter()
593                    .take(ids.len())
594                    .map(|value| i64::from(*value))
595                    .collect();
596                Ok(TokenRow { ids, mask })
597            })
598            .collect()
599    }
600
601    fn embed_prompts(&self, prompts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedError> {
602        Ok(self
603            .embed_prompts_native(prompts)?
604            .into_iter()
605            .map(|native| truncate_and_normalize(&native, self.truncate_dim))
606            .collect())
607    }
608
609    /// Normalized native-dimension vector per prompt, in input order, before
610    /// Matryoshka truncation.
611    fn embed_prompts_native(&self, prompts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedError> {
612        if prompts.is_empty() {
613            return Ok(Vec::new());
614        }
615        let rows = self.encode_prompts(&prompts)?;
616        let lengths: Vec<usize> = rows.iter().map(|row| row.ids.len()).collect();
617        let order = length_order(&lengths);
618        let sorted_lengths: Vec<usize> = order.iter().map(|&i| lengths[i]).collect();
619        let accelerated = self.provider_used != "cpu";
620        let mut out: Vec<Option<Vec<f32>>> = vec![None; rows.len()];
621        for range in plan_chunks(
622            &sorted_lengths,
623            self.max_batch.max(1),
624            MAX_PADDED_TOKENS_PER_CALL,
625        ) {
626            let chunk: Vec<&TokenRow> = order[range.clone()].iter().map(|&i| &rows[i]).collect();
627            let natives = self.run_chunk(&chunk, accelerated)?;
628            for (&index, native) in order[range].iter().zip(natives) {
629                out[index] = Some(native);
630            }
631        }
632        out.into_iter()
633            .map(|row| row.ok_or_else(|| EmbedError::Output("missing embedding row".into())))
634            .collect()
635    }
636
637    /// One ONNX call over `chunk`; returns the normalized native-dimension
638    /// vector per row, in chunk order.
639    fn run_chunk(
640        &self,
641        chunk: &[&TokenRow],
642        accelerated: bool,
643    ) -> Result<Vec<Vec<f32>>, EmbedError> {
644        let longest = chunk.iter().map(|row| row.ids.len()).max().unwrap_or(1);
645        let (batch_rows, padded_len) = padded_shape(chunk.len(), longest, accelerated);
646        let padded = pad_batch(chunk, self.pad_id, padded_len, batch_rows);
647        let (rows, len) = (padded.rows, padded.len);
648        let tensor = |data: Vec<i64>| {
649            ort::value::Tensor::from_array(([rows, len], data))
650                .map_err(|e| EmbedError::Runtime(e.to_string()))
651        };
652        let mut inputs = ort::inputs![
653            "input_ids" => tensor(padded.ids)?,
654            "attention_mask" => tensor(padded.mask)?
655        ];
656        if self.takes_token_type_ids {
657            inputs.push(("token_type_ids".into(), tensor(vec![0; rows * len])?.into()));
658        }
659        let mut session = self
660            .session
661            .lock()
662            .map_err(|_| EmbedError::Runtime("model mutex poisoned".into()))?;
663        let outputs = session
664            .run(inputs)
665            .map_err(|e| EmbedError::Runtime(e.to_string()))?;
666        let output = outputs
667            .get("sentence_embedding")
668            .ok_or_else(|| EmbedError::Output("missing sentence_embedding output".into()))?;
669        let (shape, values) = output
670            .try_extract_tensor::<f32>()
671            .map_err(|e| EmbedError::Output(e.to_string()))?;
672        let masks: Vec<&[i64]> = chunk.iter().map(|row| row.mask.as_slice()).collect();
673        pool_batch(shape.as_ref(), values, &masks, self.native_dim)
674    }
675}
676
677/// One tokenized, truncated text.
678struct TokenRow {
679    ids: Vec<i64>,
680    mask: Vec<i64>,
681}
682
683/// Row-major `[rows, len]` model inputs.
684#[derive(Debug, PartialEq, Eq)]
685struct PaddedBatch {
686    rows: usize,
687    len: usize,
688    ids: Vec<i64>,
689    mask: Vec<i64>,
690}
691
692/// Stable ascending-length order (ties keep input order) so chunks of similar
693/// length share a call and pad little.
694fn length_order(lengths: &[usize]) -> Vec<usize> {
695    let mut order: Vec<usize> = (0..lengths.len()).collect();
696    order.sort_by_key(|&i| lengths[i]);
697    order
698}
699
700/// Splits ascending `sorted_lengths` into contiguous ranges of at most
701/// `max_rows` rows whose padded size `rows * longest` stays within
702/// `token_budget`; a lone row is always allowed.
703fn plan_chunks(
704    sorted_lengths: &[usize],
705    max_rows: usize,
706    token_budget: usize,
707) -> Vec<std::ops::Range<usize>> {
708    let mut ranges = Vec::new();
709    let mut start = 0;
710    while start < sorted_lengths.len() {
711        let mut end = start + 1;
712        while end < sorted_lengths.len()
713            && end - start < max_rows
714            && (end - start + 1) * sorted_lengths[end] <= token_budget
715        {
716            end += 1;
717        }
718        ranges.push(start..end);
719        start = end;
720    }
721    ranges
722}
723
724/// `(rows, len)` of the tensor sent to the model. CPU uses the exact chunk
725/// maximum. Accelerators recompile per shape, so length rounds up to a bucket
726/// and rows to a power of two (extra rows repeat the last row and are dropped).
727fn padded_shape(rows: usize, longest: usize, accelerated: bool) -> (usize, usize) {
728    if !accelerated {
729        return (rows, longest);
730    }
731    let len = ACCELERATOR_BUCKETS
732        .iter()
733        .copied()
734        .find(|bucket| *bucket >= longest)
735        .unwrap_or(longest);
736    (rows.next_power_of_two(), len)
737}
738
739/// Right-pads every row to `len` with `pad_id` ids and zero mask; rows beyond
740/// `chunk.len()` (up to `rows`) repeat the last row.
741fn pad_batch(chunk: &[&TokenRow], pad_id: i64, len: usize, rows: usize) -> PaddedBatch {
742    let mut ids = vec![pad_id; rows * len];
743    let mut mask = vec![0i64; rows * len];
744    for row in 0..rows {
745        let source = chunk[row.min(chunk.len() - 1)];
746        let used = source.ids.len().min(len);
747        ids[row * len..row * len + used].copy_from_slice(&source.ids[..used]);
748        mask[row * len..row * len + used].copy_from_slice(&source.mask[..used]);
749    }
750    PaddedBatch {
751        rows,
752        len,
753        ids,
754        mask,
755    }
756}
757
758/// Turns a model output into one L2-normalized native vector per real row
759/// (`masks.len()` rows; extra padding rows are ignored). Accepts the model's
760/// pooled `[B, D]` output, or token states `[B, T, D]` which are mean-pooled
761/// per row over the attention mask (positions beyond a row's mask are padding).
762fn pool_batch(
763    shape: &[i64],
764    values: &[f32],
765    masks: &[&[i64]],
766    native_dim: usize,
767) -> Result<Vec<Vec<f32>>, EmbedError> {
768    let real = masks.len();
769    if shape.len() == 2 && shape[1] == native_dim as i64 {
770        let batch = shape[0] as usize;
771        if batch >= real && values.len() == batch * native_dim {
772            return Ok((0..real)
773                .map(|row| normalize(values[row * native_dim..(row + 1) * native_dim].to_vec()))
774                .collect());
775        }
776    }
777    if shape.len() == 3 && shape[2] == native_dim as i64 && shape[0] > 0 && shape[1] > 0 {
778        let (batch, tokens) = (shape[0] as usize, shape[1] as usize);
779        if batch >= real && values.len() == batch * tokens * native_dim {
780            let mut pooled_rows = Vec::with_capacity(real);
781            for (row, mask) in masks.iter().enumerate() {
782                let mut pooled = vec![0.0f32; native_dim];
783                let mut count = 0.0f32;
784                for token in 0..tokens {
785                    if mask.get(token).copied().unwrap_or(0) == 0 {
786                        continue;
787                    }
788                    count += 1.0;
789                    let base = (row * tokens + token) * native_dim;
790                    for (dim, value) in pooled.iter_mut().enumerate() {
791                        *value += values[base + dim];
792                    }
793                }
794                if count == 0.0 {
795                    return Err(EmbedError::Output(
796                        "attention mask contains no tokens".into(),
797                    ));
798                }
799                for value in &mut pooled {
800                    *value /= count;
801                }
802                pooled_rows.push(normalize(pooled));
803            }
804            return Ok(pooled_rows);
805        }
806    }
807    Err(EmbedError::Output(format!(
808        "expected [B,{native_dim}] or [B,tokens,{native_dim}] with B >= {real}, got shape {shape:?}"
809    )))
810}
811
812fn probe_output_ok(native: &[f32], native_dim: usize) -> Result<(), String> {
813    if native.len() != native_dim {
814        return Err(format!(
815            "expected {native_dim} values, got {}",
816            native.len()
817        ));
818    }
819    if native.iter().any(|value| !value.is_finite()) {
820        return Err("non-finite values".into());
821    }
822    if native.iter().map(|value| value * value).sum::<f32>() <= f32::EPSILON {
823        return Err("all-zero output".into());
824    }
825    Ok(())
826}
827
828/// Deterministic long parity document: `PARITY_LONG_SECTIONS` numbered
829/// sections, several hundred tokens.
830fn parity_long_document() -> String {
831    (1..=PARITY_LONG_SECTIONS)
832        .map(|n| {
833            format!(
834                "Section {n}: the nightly billing job reconciles every invoice against the \
835                 ledger, flags mismatched totals for account {}, and sends the exceptions to \
836                 the finance team for review before payment run {n}.",
837                1000 + n * 7
838            )
839        })
840        .collect::<Vec<_>>()
841        .join(" ")
842}
843
844/// Lowest cosine over the compared pairs and the pair it came from.
845#[derive(Debug, Clone, Copy, PartialEq)]
846struct ParityLowest {
847    index: usize,
848    cosine: f32,
849}
850
851/// Why two vector sets are not parity-equal.
852#[derive(Debug, Clone, PartialEq)]
853enum ParityMiss {
854    /// A pair could not be compared: wrong count or size, non-finite or zero
855    /// output on either side.
856    Invalid { index: usize, reason: String },
857    /// The lowest pair is under the floor.
858    Below(ParityLowest),
859}
860
861fn cosine(a: &[f32], b: &[f32]) -> f32 {
862    let (mut dot, mut na, mut nb) = (0.0f64, 0.0f64, 0.0f64);
863    for (x, y) in a.iter().zip(b) {
864        let (x, y) = (f64::from(*x), f64::from(*y));
865        dot += x * y;
866        na += x * x;
867        nb += y * y;
868    }
869    (dot / (na.sqrt() * nb.sqrt())) as f32
870}
871
872/// Pairwise cosine parity of `candidate` against `reference` (same prompts,
873/// same order, native dimension). Every vector must have `native_dim` finite
874/// values and a non-zero norm; the lowest pair must be at least `floor`.
875/// Returns the lowest pair when parity holds.
876fn compare_parity(
877    reference: &[Vec<f32>],
878    candidate: &[Vec<f32>],
879    native_dim: usize,
880    floor: f32,
881) -> Result<ParityLowest, ParityMiss> {
882    if reference.is_empty() || reference.len() != candidate.len() {
883        return Err(ParityMiss::Invalid {
884            index: 0,
885            reason: format!(
886                "expected {} vectors to compare, got {}",
887                reference.len(),
888                candidate.len()
889            ),
890        });
891    }
892    let mut lowest = ParityLowest {
893        index: 0,
894        cosine: f32::INFINITY,
895    };
896    for (index, (r, c)) in reference.iter().zip(candidate).enumerate() {
897        probe_output_ok(r, native_dim).map_err(|reason| ParityMiss::Invalid {
898            index,
899            reason: format!("cpu reference: {reason}"),
900        })?;
901        probe_output_ok(c, native_dim).map_err(|reason| ParityMiss::Invalid {
902            index,
903            reason: format!("accelerator output: {reason}"),
904        })?;
905        let value = cosine(r, c);
906        if value < lowest.cosine {
907            lowest = ParityLowest {
908                index,
909                cosine: value,
910            };
911        }
912    }
913    if lowest.cosine >= floor {
914        Ok(lowest)
915    } else {
916        Err(ParityMiss::Below(lowest))
917    }
918}
919
920/// `(lowest cosine if any, text)` for a parity miss. `tokens` holds the token
921/// count of each parity prompt; pair `i` is prompt `i % tokens.len()`, and pairs
922/// past the first `tokens.len()` are the batched run.
923fn parity_miss_detail(miss: &ParityMiss, tokens: &[usize], floor: f32) -> (Option<f32>, String) {
924    let at = |index: usize| {
925        let batched = if index >= tokens.len() {
926            " (batched)"
927        } else {
928            ""
929        };
930        format!("~{} tokens{batched}", tokens[index % tokens.len()])
931    };
932    match miss {
933        ParityMiss::Below(lowest) => (
934            Some(lowest.cosine),
935            format!(
936                "cosine {:.3} at {}, floor {floor}",
937                lowest.cosine,
938                at(lowest.index)
939            ),
940        ),
941        ParityMiss::Invalid { index, reason } => (None, format!("{reason} at {}", at(*index))),
942    }
943}
944
945struct PipelineParts<'a> {
946    model_id: &'a str,
947    native_dim: usize,
948    truncate_dim: usize,
949    max_tokens: usize,
950    scrubs_sentinels: bool,
951    files: &'a [(&'a str, String)],
952}
953
954/// Pure fingerprint over the pipeline description; see [`Embedder::pipeline_id`].
955fn pipeline_fingerprint(parts: &PipelineParts<'_>) -> String {
956    let mut text = String::new();
957    let mut line = |key: &str, value: &str| {
958        text.push_str(key);
959        text.push('=');
960        text.push_str(&value.len().to_string());
961        text.push(':');
962        text.push_str(value);
963        text.push('\n');
964    };
965    line("schema", PIPELINE_SCHEMA);
966    line("model_id", parts.model_id);
967    line("native_dim", &parts.native_dim.to_string());
968    line("truncate_dim", &parts.truncate_dim.to_string());
969    line("max_tokens", &parts.max_tokens.to_string());
970    line("document_prompt", DOCUMENT_PROMPT_PREFIX);
971    line("query_prompt", QUERY_PROMPT_PREFIX);
972    line("pooling", POOLING_DESCRIPTION);
973    line("normalization", NORMALIZATION_DESCRIPTION);
974    line("scrubs_sentinels", &parts.scrubs_sentinels.to_string());
975    for (name, hash) in parts.files {
976        line(&format!("file:{name}"), hash);
977    }
978    // First 16 bytes of the SHA-256 = first 32 lowercase hex characters.
979    let digest = rightkit_fs::digest::sha256_bytes(text.as_bytes());
980    format!("rkpipe1-{}", &digest[..32])
981}
982
983fn sanitize(text: &str) -> String {
984    text.chars()
985        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
986        .take(48)
987        .collect()
988}
989
990/// SHA-256 hex of a file, or `absent` when it does not exist. Other read
991/// errors fail the fingerprint rather than hashing a partial file.
992fn hash_file(path: &Path) -> Result<String, String> {
993    let mut file = match std::fs::File::open(path) {
994        Ok(file) => file,
995        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok("absent".into()),
996        Err(error) => return Err(format!("{}: {error}", path.display())),
997    };
998    rightkit_fs::digest::sha256_reader(&mut file).map_err(|e| format!("{}: {e}", path.display()))
999}
1000
1001/// Reads only the top-level `model_type`; nested sub-configs (audio, text,
1002/// vision) carry their own types and are ignored. A missing config.json is the
1003/// original EmbeddingGemma bundle.
1004fn bundle_is_gemma2(model_dir: &std::path::Path) -> Result<bool, EmbedError> {
1005    let path = model_dir.join("config.json");
1006    let Ok(bytes) = std::fs::read(&path) else {
1007        return Ok(false);
1008    };
1009    let config: serde_json::Value = serde_json::from_slice(&bytes)
1010        .map_err(|e| EmbedError::InvalidConfig(format!("{}: {e}", path.display())))?;
1011    Ok(config["model_type"] == "embedding_gemma2")
1012}
1013
1014fn validate_config(config: &EmbedderConfig) -> Result<(), EmbedError> {
1015    if !config.model_dir.is_dir() {
1016        return Err(EmbedError::MissingModelDirectory(config.model_dir.clone()));
1017    }
1018    if config.truncate_dim == 0 || config.truncate_dim > EMBEDDINGGEMMA_NATIVE_DIM {
1019        return Err(EmbedError::InvalidConfig(format!(
1020            "truncate dimension must be 1..={EMBEDDINGGEMMA_NATIVE_DIM}"
1021        )));
1022    }
1023    if config.max_tokens == 0 || config.max_tokens > MAX_SEQUENCE_TOKENS {
1024        return Err(EmbedError::InvalidConfig(format!(
1025            "max tokens must be 1..={MAX_SEQUENCE_TOKENS}"
1026        )));
1027    }
1028    if config.max_batch == 0 {
1029        return Err(EmbedError::InvalidConfig("max batch must be >= 1".into()));
1030    }
1031    if config.intra_threads == 0 {
1032        return Err(EmbedError::InvalidConfig(
1033            "intra threads must be >= 1".into(),
1034        ));
1035    }
1036    for name in ["model_q4.onnx", "model_q4.onnx_data", "tokenizer.json"] {
1037        let path = config.model_dir.join(name);
1038        if !path.is_file() {
1039            return Err(EmbedError::MissingArtifact(path));
1040        }
1041    }
1042    if !config.runtime_path.is_file() {
1043        return Err(EmbedError::MissingArtifact(config.runtime_path.clone()));
1044    }
1045    Ok(())
1046}
1047
1048fn normalize(mut values: Vec<f32>) -> Vec<f32> {
1049    let norm = values.iter().map(|value| value * value).sum::<f32>().sqrt();
1050    if norm > 0.0 {
1051        for value in &mut values {
1052            *value /= norm;
1053        }
1054    }
1055    values
1056}
1057
1058pub fn truncate_and_normalize(native: &[f32], dimension: usize) -> Vec<f32> {
1059    let mut values = native[..native.len().min(dimension)].to_vec();
1060    values.resize(dimension, 0.0);
1061    normalize(values)
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067
1068    fn row(ids: &[i64]) -> TokenRow {
1069        TokenRow {
1070            ids: ids.to_vec(),
1071            mask: vec![1; ids.len()],
1072        }
1073    }
1074
1075    /// Deterministic per-token state independent of padding, like a model whose
1076    /// masked positions cannot influence valid ones.
1077    fn state(ids: &[i64], dim: usize) -> Vec<f32> {
1078        ids.iter()
1079            .enumerate()
1080            .flat_map(|(pos, id)| {
1081                (0..dim)
1082                    .map(move |d| ((*id as f32) * 0.37 + pos as f32 * 0.11 + d as f32 * 0.05).sin())
1083            })
1084            .collect()
1085    }
1086
1087    #[test]
1088    fn pad_batch_pads_right_with_zero_mask_and_repeats_last_row() {
1089        let a = row(&[5, 6, 7]);
1090        let b = row(&[9]);
1091        let padded = pad_batch(&[&a, &b], 0, 4, 3);
1092        assert_eq!((padded.rows, padded.len), (3, 4));
1093        assert_eq!(padded.ids, vec![5, 6, 7, 0, 9, 0, 0, 0, 9, 0, 0, 0]);
1094        assert_eq!(padded.mask, vec![1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
1095    }
1096
1097    #[test]
1098    fn pad_batch_uses_the_pad_id_for_masked_positions() {
1099        let a = row(&[5]);
1100        let padded = pad_batch(&[&a], 3, 3, 1);
1101        assert_eq!(padded.ids, vec![5, 3, 3]);
1102        assert_eq!(padded.mask, vec![1, 0, 0]);
1103    }
1104
1105    #[test]
1106    fn padded_mean_pool_matches_unpadded_rows() {
1107        let dim = 8;
1108        let rows_ids: [&[i64]; 3] = [&[3, 4, 5, 6, 7], &[10, 11], &[20, 21, 22]];
1109        let rows: Vec<TokenRow> = rows_ids.iter().map(|ids| row(ids)).collect();
1110        let refs: Vec<&TokenRow> = rows.iter().collect();
1111        let padded = pad_batch(&refs, 0, 5, 3);
1112        // Model output for the padded batch: valid positions carry real state,
1113        // masked positions carry garbage that must be ignored.
1114        let mut values = vec![9999.0f32; padded.rows * padded.len * dim];
1115        for (r, ids) in rows_ids.iter().enumerate() {
1116            let s = state(ids, dim);
1117            let base = r * padded.len * dim;
1118            values[base..base + s.len()].copy_from_slice(&s);
1119        }
1120        let masks: Vec<&[i64]> = rows.iter().map(|r| r.mask.as_slice()).collect();
1121        let batched =
1122            pool_batch(&[3, padded.len as i64, dim as i64], &values, &masks, dim).unwrap();
1123        for (r, ids) in rows_ids.iter().enumerate() {
1124            let single = pool_batch(
1125                &[1, ids.len() as i64, dim as i64],
1126                &state(ids, dim),
1127                &[rows[r].mask.as_slice()],
1128                dim,
1129            )
1130            .unwrap();
1131            let diff = batched[r]
1132                .iter()
1133                .zip(&single[0])
1134                .map(|(a, b)| (a - b).abs())
1135                .fold(0.0f32, f32::max);
1136            assert!(diff <= 1e-6, "row {r} differs by {diff}");
1137            let norm: f32 = batched[r].iter().map(|v| v * v).sum::<f32>().sqrt();
1138            assert!((norm - 1.0).abs() <= 1e-5);
1139        }
1140    }
1141
1142    #[test]
1143    fn pooled_output_rows_are_normalized_and_extra_rows_dropped() {
1144        let dim = 4;
1145        let values = vec![3.0, 4.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0, 1.0, 1.0, 1.0];
1146        let m = [1i64];
1147        let out = pool_batch(&[3, 4], &values, &[&m, &m], dim).unwrap();
1148        assert_eq!(out.len(), 2);
1149        assert_eq!(out[0], vec![0.6, 0.8, 0.0, 0.0]);
1150        assert_eq!(out[1], vec![0.0, 0.0, 1.0, 0.0]);
1151    }
1152
1153    #[test]
1154    fn pool_rejects_bad_shapes_and_empty_masks() {
1155        let m = [0i64, 0];
1156        assert!(pool_batch(&[1, 2, 2], &[1.0; 4], &[&m], 2).is_err());
1157        assert!(pool_batch(&[1, 3], &[1.0; 3], &[&[1]], 2).is_err());
1158        assert!(pool_batch(&[1, 2], &[1.0; 2], &[&[1], &[1]], 2).is_err());
1159    }
1160
1161    #[test]
1162    fn chunks_respect_row_cap_token_budget_and_cover_everything() {
1163        let lengths = [1, 2, 3, 4, 5, 6, 7];
1164        assert_eq!(plan_chunks(&lengths, 3, 1000), vec![0..3, 3..6, 6..7]);
1165        // budget: 2 rows * 3 = 6 ok, 3 rows * 4 = 12 > 10 stops the chunk.
1166        assert_eq!(plan_chunks(&[1, 3, 4, 4], 8, 10), vec![0..2, 2..4]);
1167        // a lone oversized row is still allowed
1168        assert_eq!(plan_chunks(&[50, 60], 8, 10), vec![0..1, 1..2]);
1169        assert!(plan_chunks(&[], 4, 10).is_empty());
1170    }
1171
1172    #[test]
1173    fn length_order_is_stable_ascending() {
1174        assert_eq!(length_order(&[5, 2, 5, 1, 2]), vec![3, 1, 4, 0, 2]);
1175    }
1176
1177    #[test]
1178    fn accelerators_use_bucketed_shapes_cpu_uses_exact() {
1179        assert_eq!(padded_shape(3, 70, false), (3, 70));
1180        assert_eq!(padded_shape(3, 70, true), (4, 128));
1181        assert_eq!(padded_shape(1, 64, true), (1, 64));
1182        assert_eq!(padded_shape(16, 2048, true), (16, 2048));
1183    }
1184
1185    #[test]
1186    fn probe_rejects_zero_nonfinite_and_wrong_size() {
1187        assert!(probe_output_ok(&[0.0; 4], 4).is_err());
1188        assert!(probe_output_ok(&[f32::NAN, 1.0, 0.0, 0.0], 4).is_err());
1189        assert!(probe_output_ok(&[1.0; 3], 4).is_err());
1190        assert!(probe_output_ok(&[0.5, 0.5, 0.5, 0.5], 4).is_ok());
1191    }
1192
1193    #[test]
1194    fn parity_floor_is_pinned() {
1195        assert_eq!(ACCELERATOR_PARITY_MIN_COSINE, 0.995);
1196    }
1197
1198    #[test]
1199    fn identical_vectors_pass_parity_and_report_the_lowest_pair() {
1200        let reference = vec![vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]];
1201        let lowest = compare_parity(&reference, &reference.clone(), 3, 0.995).unwrap();
1202        assert!((lowest.cosine - 1.0).abs() <= 1e-6);
1203        // A pair exactly at the floor passes ("at or above").
1204        let candidate = vec![vec![1.0, 0.0, 0.0], vec![0.0, 0.6, 0.8]];
1205        let at_floor = cosine(&reference[1], &candidate[1]);
1206        let lowest = compare_parity(&reference, &candidate, 3, at_floor).unwrap();
1207        assert_eq!((lowest.index, lowest.cosine), (1, at_floor));
1208    }
1209
1210    #[test]
1211    fn a_low_cosine_pair_fails_and_reports_value_and_index() {
1212        // Unit vector at cosine 0.58 to the reference `[0, 1]`.
1213        let wrong = vec![(1.0f32 - 0.58 * 0.58).sqrt(), 0.58];
1214        let reference = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]];
1215        let candidate = vec![vec![1.0, 0.0], wrong.clone(), vec![1.0, 0.0]];
1216        match compare_parity(&reference, &candidate, 2, 0.995) {
1217            Err(ParityMiss::Below(lowest)) => {
1218                assert_eq!(lowest.index, 1);
1219                assert!((lowest.cosine - 0.58).abs() <= 1e-6, "{lowest:?}");
1220            }
1221            other => panic!("expected a Below miss, got {other:?}"),
1222        }
1223        let single = compare_parity(&[vec![0.0, 1.0]], &[wrong], 2, 0.995);
1224        match single {
1225            Err(ParityMiss::Below(lowest)) => assert!((lowest.cosine - 0.58).abs() <= 1e-6),
1226            other => panic!("expected a Below miss, got {other:?}"),
1227        }
1228    }
1229
1230    #[test]
1231    fn nonfinite_zero_or_wrongly_sized_vectors_fail_parity() {
1232        let good = vec![vec![1.0, 0.0]];
1233        for bad in [
1234            vec![f32::NAN, 1.0],
1235            vec![f32::INFINITY, 0.0],
1236            vec![0.0, 0.0],
1237            vec![1.0, 0.0, 0.0],
1238        ] {
1239            assert!(
1240                matches!(
1241                    compare_parity(&good, std::slice::from_ref(&bad), 2, 0.995),
1242                    Err(ParityMiss::Invalid { index: 0, .. })
1243                ),
1244                "candidate {bad:?}"
1245            );
1246            assert!(
1247                matches!(
1248                    compare_parity(std::slice::from_ref(&bad), &good, 2, 0.995),
1249                    Err(ParityMiss::Invalid { index: 0, .. })
1250                ),
1251                "reference {bad:?}"
1252            );
1253        }
1254    }
1255
1256    #[test]
1257    fn mismatched_or_empty_parity_inputs_fail() {
1258        let one = vec![vec![1.0, 0.0]];
1259        let two = vec![vec![1.0, 0.0], vec![1.0, 0.0]];
1260        assert!(matches!(
1261            compare_parity(&one, &two, 2, 0.995),
1262            Err(ParityMiss::Invalid { .. })
1263        ));
1264        assert!(matches!(
1265            compare_parity(&two, &one, 2, 0.995),
1266            Err(ParityMiss::Invalid { .. })
1267        ));
1268        assert!(matches!(
1269            compare_parity(&[], &[], 2, 0.995),
1270            Err(ParityMiss::Invalid { .. })
1271        ));
1272    }
1273
1274    #[test]
1275    fn parity_miss_detail_names_cosine_length_and_floor() {
1276        let tokens = [20, 100, 600];
1277        let miss = ParityMiss::Below(ParityLowest {
1278            index: 0,
1279            cosine: 0.5791,
1280        });
1281        assert_eq!(
1282            parity_miss_detail(&miss, &tokens, 0.995),
1283            (
1284                Some(0.5791),
1285                "cosine 0.579 at ~20 tokens, floor 0.995".to_owned()
1286            )
1287        );
1288        let batched = ParityMiss::Below(ParityLowest {
1289            index: 5,
1290            cosine: 0.71,
1291        });
1292        assert_eq!(
1293            parity_miss_detail(&batched, &tokens, 0.995).1,
1294            "cosine 0.710 at ~600 tokens (batched), floor 0.995"
1295        );
1296        let invalid = ParityMiss::Invalid {
1297            index: 1,
1298            reason: "accelerator output: non-finite values".into(),
1299        };
1300        assert_eq!(
1301            parity_miss_detail(&invalid, &tokens, 0.995),
1302            (
1303                None,
1304                "accelerator output: non-finite values at ~100 tokens".to_owned()
1305            )
1306        );
1307    }
1308
1309    #[test]
1310    fn parity_long_document_is_deterministic_and_long() {
1311        let text = parity_long_document();
1312        assert_eq!(text, parity_long_document());
1313        assert!(text.split_whitespace().count() >= 400, "{}", text.len());
1314        assert!(PARITY_DOCUMENT.split_whitespace().count() >= 50);
1315    }
1316
1317    #[test]
1318    fn parity_error_names_provider_cosine_and_floor() {
1319        let error = EmbedError::AcceleratorParity {
1320            provider: "directml",
1321            lowest_cosine: Some(0.579),
1322            floor: ACCELERATOR_PARITY_MIN_COSINE,
1323            detail: "cosine 0.579 at ~20 tokens, floor 0.995".into(),
1324        };
1325        assert_eq!(
1326            error.to_string(),
1327            "directml failed the vector parity check against cpu: cosine 0.579 at ~20 tokens, floor 0.995"
1328        );
1329    }
1330
1331    fn parts<'a>(files: &'a [(&'a str, String)], truncate: usize) -> PipelineParts<'a> {
1332        PipelineParts {
1333            model_id: EMBEDDINGGEMMA_MODEL_ID,
1334            native_dim: 768,
1335            truncate_dim: truncate,
1336            max_tokens: 2048,
1337            scrubs_sentinels: false,
1338            files,
1339        }
1340    }
1341
1342    #[test]
1343    fn pipeline_fingerprint_is_stable_and_sensitive() {
1344        let files = [("model_q4.onnx", "aa".to_owned())];
1345        let base = pipeline_fingerprint(&parts(&files, 256));
1346        assert_eq!(base, pipeline_fingerprint(&parts(&files, 256)));
1347        assert!(base.starts_with("rkpipe1-") && base.len() == "rkpipe1-".len() + 32);
1348        assert_ne!(base, pipeline_fingerprint(&parts(&files, 128)));
1349        let changed = [("model_q4.onnx", "ab".to_owned())];
1350        assert_ne!(base, pipeline_fingerprint(&parts(&changed, 256)));
1351        let mut other = parts(&files, 256);
1352        other.scrubs_sentinels = true;
1353        assert_ne!(base, pipeline_fingerprint(&other));
1354        other = parts(&files, 256);
1355        other.max_tokens = 512;
1356        assert_ne!(base, pipeline_fingerprint(&other));
1357    }
1358
1359    #[test]
1360    fn hash_file_reports_absent_and_content() {
1361        let dir = tempfile::tempdir().unwrap();
1362        assert_eq!(hash_file(&dir.path().join("none")).unwrap(), "absent");
1363        let path = dir.path().join("f");
1364        std::fs::write(&path, b"abc").unwrap();
1365        assert_eq!(
1366            hash_file(&path).unwrap(),
1367            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1368        );
1369    }
1370
1371    #[test]
1372    fn config_defaults_and_builders() {
1373        let c = EmbedderConfig::new("m", "r");
1374        assert_eq!((c.max_batch, c.intra_threads), (16, 1));
1375        assert_eq!(c.provider, ExecutionProvider::Cpu);
1376        let c = c
1377            .with_max_batch(4)
1378            .with_intra_threads(3)
1379            .with_provider(ExecutionProvider::Auto);
1380        assert_eq!((c.max_batch, c.intra_threads), (4, 3));
1381        assert_eq!(c.provider, ExecutionProvider::Auto);
1382        let legacy =
1383            EmbedderConfig::new("m", "r").with_provider(rightkit_onnx::ExecutionProvider::DirectMl);
1384        assert_eq!(legacy.provider, ExecutionProvider::DirectMl);
1385    }
1386}