Skip to main content

rightkit_embed/
embedder.rs

1use rightkit_onnx::ort;
2use std::path::{Path, PathBuf};
3use std::sync::{Mutex, OnceLock};
4use std::time::{Duration, Instant};
5
6#[cfg(target_os = "macos")]
7use crate::coreml::{self, CoreMlEngine};
8#[cfg(target_os = "macos")]
9use crate::coreml_units::CoreMlComputeUnit;
10use crate::coreml_units::CoreMlUnits;
11use crate::e40::{self, E40Engine, E40SessionProfile};
12use crate::parity_cache;
13use rightkit_onnx::ort::session::Session;
14use rightkit_onnx::{
15    configure_runtime, init_environment, CandidateSource, EnvironmentOptions, GlobalPool,
16    RuntimeCandidate, SessionOptions,
17};
18use thiserror::Error;
19use tokenizers::{EncodeInput, Tokenizer};
20
21pub const EMBEDDINGGEMMA_MODEL_ID: &str = "embeddinggemma-300m-q4";
22/// EmbeddingGemma 2 text-only Q4 export (b64, accuracy level 4). Selected when
23/// the model directory's top-level `config.json` `model_type` is
24/// `embedding_gemma2`.
25pub const EMBEDDINGGEMMA2_MODEL_ID: &str = "embeddinggemma2-q4";
26/// EmbeddingGemma 2 E40 export: fp16, five fixed-length graphs and a host-side token
27/// table (`embed_tokens.f16.npy`), run on DirectML (Windows) or CPU. Selected when the model
28/// directory holds that layout. Same weights as the Q4 model, different numerics, so a
29/// different pipeline id.
30pub const EMBEDDINGGEMMA2_E40_MODEL_ID: &str = "embeddinggemma2-e40-dml";
31/// EmbeddingGemma 2 Core ML build (macOS 15+): `EmbeddingGemma2Text.mlpackage` (one fixed-shape
32/// function per length 32, 64, 128, 256, 512) and a host-side bf16 token table, run through the
33/// Core ML framework directly (an `.mlpackage` cannot be loaded through ONNX Runtime). Selected
34/// when the model directory holds that layout. Same weights, different numerics from the Q4 and
35/// E40 models, so a different pipeline id.
36pub const EMBEDDINGGEMMA2_COREML_MODEL_ID: &str = "embeddinggemma2-coreml";
37/// Literal special tokens that encode to EmbeddingGemma 2's image, video and
38/// audio sentinel ids; the text-only graph is exact only without them.
39const GEMMA2_MODALITY_SENTINELS: [&str; 3] = ["<|image|>", "<|video|>", "<|audio|>"];
40pub const EMBEDDINGGEMMA_NATIVE_DIM: usize = 768;
41pub const DEFAULT_TRUNCATE_DIM: usize = 256;
42pub const MAX_SEQUENCE_TOKENS: usize = 2048;
43/// Default rows per ONNX call for `embed_batch` / `embed_query_batch`.
44pub const DEFAULT_MAX_BATCH: usize = 16;
45/// Upper bound on padded tokens (`rows * padded_len`) in one ONNX call. Keeps
46/// attention memory bounded when long texts share a chunk; a single row always
47/// fits regardless.
48const MAX_PADDED_TOKENS_PER_CALL: usize = 16_384;
49/// Fixed sequence-length buckets used on accelerators, which recompile per
50/// input shape (DirectML measured 345-623 ms dynamic vs ~26 ms padded).
51const ACCELERATOR_BUCKETS: [usize; 6] = [64, 128, 256, 512, 1024, 2048];
52const DOCUMENT_PROMPT_PREFIX: &str = "title: none | text: ";
53const QUERY_PROMPT_PREFIX: &str = "task: search result | query: ";
54/// Bump when the embedding math changes in a way the other fingerprint inputs
55/// do not capture (pooling, prompt assembly, truncation order).
56const PIPELINE_SCHEMA: &str = "rightkit-embed/pipeline/1";
57const POOLING_DESCRIPTION: &str = "model sentence_embedding [B,D] or masked mean over [B,T,D]";
58const NORMALIZATION_DESCRIPTION: &str = "l2 native, matryoshka prefix truncate, l2";
59const PROBE_PROMPT: &str = "title: none | text: rightkit embed provider probe";
60/// An accelerator is admitted only if every parity prompt's native-dimension
61/// vector has at least this cosine similarity to the CPU provider's vector for
62/// the same prompt. Two correct runs of the same Q4 graph agree at about 0.999;
63/// a DirectML run of EmbeddingGemma 2 Q4 measured 0.58-0.72.
64pub const ACCELERATOR_PARITY_MIN_COSINE: f32 = 0.995;
65/// Short query-style parity prompt (about 20 tokens with its prefix).
66const PARITY_QUERY: &str = "how are invoices matched to the ledger";
67/// Medium document-style parity prompt (roughly 90 tokens with its prefix).
68const PARITY_DOCUMENT: &str = "The nightly billing job reads every invoice issued during the day, \
69looks up the matching ledger account, and compares the invoiced total with the posted amount. \
70Differences below one cent are rounded away. Larger differences are written to an exceptions \
71table together with the customer, the invoice number and the date, and the finance team reviews \
72that table each morning before the payment run starts.";
73/// Sections in the long parity prompt (several hundred tokens).
74const PARITY_LONG_SECTIONS: usize = 20;
75
76#[derive(Debug, Error)]
77pub enum EmbedError {
78    #[error("model directory is missing: {0}")]
79    MissingModelDirectory(PathBuf),
80    #[error("model artifact is missing: {0}")]
81    MissingArtifact(PathBuf),
82    #[error("invalid model configuration: {0}")]
83    InvalidConfig(String),
84    #[error("tokenizer: {0}")]
85    Tokenizer(String),
86    #[error("ONNX Runtime: {0}")]
87    Runtime(String),
88    #[error("model output: {0}")]
89    Output(String),
90    /// An explicitly requested accelerator did not reproduce the CPU
91    /// provider's vectors at load (or could not be compared), so it was not
92    /// admitted. `lowest_cosine` is `None` when the failure is not a low
93    /// cosine (non-finite or zero output, wrong size); `detail` names the
94    /// prompt length. `Auto` never returns this: it falls back to CPU with the
95    /// same text in `fallback_reason`.
96    #[error("{provider} failed the vector parity check against cpu: {detail}")]
97    AcceleratorParity {
98        provider: &'static str,
99        lowest_cosine: Option<f32>,
100        floor: f32,
101        detail: String,
102    },
103}
104
105/// Result of the load-time vector parity check an accelerator passed; see
106/// [`Embedder::accelerator_parity`].
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct AcceleratorParity {
109    /// Lowest cosine to the CPU provider's vector over all compared pairs.
110    pub lowest_cosine: f32,
111    /// Token count of the prompt that produced it.
112    pub at_tokens: usize,
113    /// Pairs compared.
114    pub pairs: usize,
115}
116
117/// One graph build during an E40 load.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct SessionCreate {
120    /// Provider the graph was built on: `"directml"` or `"cpu"` (the parity reference).
121    pub provider: &'static str,
122    /// Length of the graph (32, 64, 128, 256 or 512 tokens).
123    pub graph_tokens: usize,
124    /// Time `build_from_file` took.
125    pub duration: Duration,
126}
127
128/// What the load-time parity check did with the opt-in verdict cache
129/// ([`EmbedderConfig::with_parity_cache_dir`]).
130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum ParityCacheOutcome {
133    /// No cache directory configured, or no parity check ran (CPU provider, non-E40 bundle).
134    #[default]
135    NotUsed,
136    /// A stored verdict for the same model files, runtime and provider was used and no CPU
137    /// session was built. `passed` is the stored verdict (a stored failure serves CPU).
138    Hit { passed: bool },
139    /// Nothing usable was stored, so the check ran. `stored` says whether its verdict was
140    /// written (a failed write, or an inference error, leaves the cache as it was).
141    Miss { reason: &'static str, stored: bool },
142}
143
144/// Where an E40 load spent its time; see [`Embedder::load_timings`]. Phases are wall-clock
145/// durations of the work as `Embedder::load` ran it; they nest (`session_creates` happen inside
146/// the probe and the parity check) and do not add up to `total` exactly.
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148#[non_exhaustive]
149pub struct LoadTimings {
150    /// Reading `tokenizer.json`.
151    pub tokenizer_load: Duration,
152    /// Reading and validating `embed_tokens.f16.npy`.
153    pub token_table_read: Duration,
154    /// Probe inference on the accelerator (`Auto` only), including its graph build.
155    pub probe: Option<Duration>,
156    /// The whole parity check, from the cache lookup to the stored verdict. `None` when no
157    /// check ran (CPU provider).
158    pub parity_check_total: Option<Duration>,
159    /// Inside the check: CPU reference sessions, tokenizer reload and CPU embeddings. `None`
160    /// on a cache hit.
161    pub parity_cpu_reference: Option<Duration>,
162    /// Inside the check: the accelerator's embeddings of the parity prompts, including its
163    /// graph builds. `None` on a cache hit.
164    pub parity_accelerator: Option<Duration>,
165    /// What the verdict cache did.
166    pub parity_cache: ParityCacheOutcome,
167    /// Every graph built during the load, in build order, on both providers.
168    pub session_creates: Vec<SessionCreate>,
169    /// Pre-warm after admission ([`EmbedderConfig::with_prewarm`]): the graph builds still
170    /// missing plus one dummy run per graph. Its graph builds are also in `session_creates`.
171    /// `None` when no pre-warm ran (disabled, CPU provider, or an accelerator not admitted).
172    pub prewarm: Option<Duration>,
173    /// Core ML only: the pre-warm time of each function as `(length in tokens, time)`, shortest
174    /// first. The `cpu_and_gpu` lengths (256, 512) carry the shader build, up to 51 s cold.
175    /// Empty for every other backend and when no pre-warm ran.
176    pub prewarm_per_length: Vec<(usize, Duration)>,
177    /// `Embedder::load` from entry to return.
178    pub total: Duration,
179}
180
181/// Where inference runs.
182///
183/// `Cpu` is the default. `Auto` registers the platform accelerator (DirectML
184/// on Windows, CoreML on macOS) and falls back to CPU, with the reason
185/// recorded, when registration, model load, the probe inference or the vector
186/// parity check fails. `DirectMl` / `CoreMl` request that accelerator
187/// explicitly and fail loudly instead of falling back: a failed parity check
188/// is [`EmbedError::AcceleratorParity`].
189///
190/// An accelerator is used only if it reproduces the CPU provider's vectors.
191/// Whenever the session was built on anything other than CPU, `Embedder::load`
192/// builds a CPU session for the same model, embeds fixed short, medium and
193/// long prompts (alone and as one batch) through both sessions with the
194/// crate's real inference path, and requires every pair to have cosine
195/// similarity of at least [`ACCELERATOR_PARITY_MIN_COSINE`] on the full
196/// native-dimension vector, with finite non-zero output. On success the CPU
197/// session is dropped before `load` returns, so steady-state memory is
198/// unchanged; the check costs one extra CPU session build and a few inferences
199/// at load. `Cpu` does none of this. The check cannot be turned off.
200///
201/// Measured: DirectML (ONNX Runtime 1.24.4) returns wrong EmbeddingGemma 2 Q4
202/// vectors (cosine to CPU 0.58-0.72) that are finite, non-zero and faster
203/// than CPU, so a probe or speed check alone would accept them. CoreML is
204/// unmeasured; it must pass the same check.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub enum ExecutionProvider {
207    #[default]
208    Cpu,
209    Auto,
210    DirectMl,
211    CoreMl,
212}
213
214impl From<rightkit_onnx::ExecutionProvider> for ExecutionProvider {
215    fn from(value: rightkit_onnx::ExecutionProvider) -> Self {
216        match value {
217            rightkit_onnx::ExecutionProvider::Cpu => Self::Cpu,
218            rightkit_onnx::ExecutionProvider::DirectMl => Self::DirectMl,
219            rightkit_onnx::ExecutionProvider::CoreMl => Self::CoreMl,
220        }
221    }
222}
223
224impl ExecutionProvider {
225    fn requested(self) -> (rightkit_onnx::ExecutionProvider, bool) {
226        match self {
227            Self::Cpu => (rightkit_onnx::ExecutionProvider::Cpu, false),
228            Self::Auto => (
229                rightkit_onnx::ExecutionProvider::platform_accelerator(),
230                true,
231            ),
232            Self::DirectMl => (rightkit_onnx::ExecutionProvider::DirectMl, false),
233            Self::CoreMl => (rightkit_onnx::ExecutionProvider::CoreMl, false),
234        }
235    }
236}
237
238pub(crate) fn provider_name(provider: rightkit_onnx::ExecutionProvider) -> &'static str {
239    match provider {
240        rightkit_onnx::ExecutionProvider::Cpu => "cpu",
241        rightkit_onnx::ExecutionProvider::DirectMl => "directml",
242        rightkit_onnx::ExecutionProvider::CoreMl => "coreml",
243    }
244}
245
246#[derive(Debug, Clone)]
247pub struct EmbedderConfig {
248    pub model_dir: PathBuf,
249    pub runtime_path: PathBuf,
250    pub provider: ExecutionProvider,
251    pub truncate_dim: usize,
252    pub max_tokens: usize,
253    pub max_batch: usize,
254    pub intra_threads: usize,
255    /// A Q4 bundle directory served on CPU when an E40 bundle in `model_dir` cannot be served
256    /// on its accelerator under `Auto` (see [`Self::with_fallback_model_dir`]).
257    pub fallback_model_dir: Option<PathBuf>,
258    /// Where a Core ML bundle's compiled model is cached (see [`Self::with_coreml_cache_dir`]).
259    pub coreml_cache_dir: Option<PathBuf>,
260    /// Where an E40 bundle's parity verdict is cached (see [`Self::with_parity_cache_dir`]).
261    pub parity_cache_dir: Option<PathBuf>,
262    /// Build and run every kept E40 graph or Core ML function at load (see
263    /// [`Self::with_prewarm`]). `None` is the backend default: on for Core ML, off for E40.
264    pub prewarm: Option<bool>,
265    /// Core ML compute units per length; `None` is [`CoreMlUnits::per_length_default`] (see
266    /// [`Self::with_coreml_units`]).
267    pub coreml_units: Option<CoreMlUnits>,
268    /// E40 session options override; `None` is the shipped profile. Benchmark use only.
269    pub(crate) e40_session_profile: Option<E40SessionProfile>,
270}
271
272impl EmbedderConfig {
273    pub fn new(model_dir: impl Into<PathBuf>, runtime_path: impl Into<PathBuf>) -> Self {
274        Self {
275            model_dir: model_dir.into(),
276            runtime_path: runtime_path.into(),
277            provider: ExecutionProvider::Cpu,
278            truncate_dim: DEFAULT_TRUNCATE_DIM,
279            max_tokens: MAX_SEQUENCE_TOKENS,
280            max_batch: DEFAULT_MAX_BATCH,
281            intra_threads: 1,
282            fallback_model_dir: None,
283            coreml_cache_dir: None,
284            parity_cache_dir: None,
285            prewarm: None,
286            coreml_units: None,
287            e40_session_profile: None,
288        }
289    }
290
291    /// Pre-warm an E40 or Core ML bundle served on an accelerator. The default is on for Core ML
292    /// and off for E40: measured on Windows, E40 pre-warm added 6-18 s to a 2 s cached load, while
293    /// a lazily built graph costs about 1-2 s on its first query. After the
294    /// parity check passes (or its cached verdict is used) every graph that is kept has its
295    /// session built and one dummy run executed during `load`, so no query pays a graph build.
296    /// The graphs the parity check built are kept instead of dropped. Costs load time (recorded
297    /// in [`LoadTimings::prewarm`]) and keeps all five sessions or functions resident; `false`
298    /// restores the earlier behaviour: graphs are dropped after the check and built on first
299    /// use. For Core ML the first call of a function on `cpu_and_gpu` was measured at up to
300    /// 51 s cold (shader build), so with `false` the first query of that length pays it.
301    /// Ignored for CPU and Q4.
302    pub fn with_prewarm(mut self, prewarm: bool) -> Self {
303        self.prewarm = Some(prewarm);
304        self
305    }
306
307    /// Core ML compute units of each function (macOS, Core ML bundle). The default is
308    /// [`CoreMlUnits::per_length_default`]: `cpu_and_ne` up to 128 tokens, `cpu_and_gpu` for
309    /// 256 and 512. Pass a [`crate::CoreMlComputeUnit`] to force one unit for every length (`all`,
310    /// `cpu_and_ne`, `cpu_and_gpu` or `cpu_only`), or a [`CoreMlUnits`] (for example parsed
311    /// from `"256=cpu_only"`) for a custom map. Applies when the provider is `Auto` or `CoreMl`;
312    /// `Cpu` always runs `cpu_only`. A map that is `cpu_only` everywhere is served as `"cpu"`
313    /// with no parity check. Ignored for every other bundle.
314    pub fn with_coreml_units(mut self, units: impl Into<CoreMlUnits>) -> Self {
315        self.coreml_units = Some(units.into());
316        self
317    }
318
319    /// Selects the E40 ONNX Runtime session options (see [`E40SessionProfile`]); the default
320    /// is the shipped profile. Benchmark support (`bench-internals`): not part of the stable API.
321    #[cfg(feature = "bench-internals")]
322    pub fn with_e40_session_profile(mut self, profile: E40SessionProfile) -> Self {
323        self.e40_session_profile = Some(profile);
324        self
325    }
326
327    /// Q4 bundle directory (EmbeddingGemma 2 Q4 or 300M Q4) to serve on CPU instead when
328    /// `model_dir` holds an E40 or Core ML bundle and `Auto` cannot admit its accelerator: the
329    /// probe or the vector parity check failed, or the CPU reference could not be built (for
330    /// Core ML also: macOS older than 15, or the package would not compile). Without it
331    /// `Auto` serves the E40 graphs on CPU (Core ML: the same package on `cpuOnly`). Never used
332    /// for an explicit `DirectMl` / `CoreMl`, which fail instead, and ignored when `model_dir`
333    /// is not an E40 or Core ML bundle. The embedder then reports the
334    /// Q4 model's `model_id()` and `pipeline_id()` (vectors differ, so consumers reindex) and
335    /// says why in `fallback_reason()`.
336    pub fn with_fallback_model_dir(mut self, dir: impl Into<PathBuf>) -> Self {
337        self.fallback_model_dir = Some(dir.into());
338        self
339    }
340
341    /// Directory for the compiled Core ML model of a Core ML bundle (macOS). Default
342    /// `<model_dir>/.coreml-compiled`. The compile is keyed on the package file hashes and the
343    /// macOS version, reused when present, and written atomically; stale entries of older keys
344    /// are not removed. Ignored for every other bundle.
345    pub fn with_coreml_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
346        self.coreml_cache_dir = Some(dir.into());
347        self
348    }
349
350    /// Opt-in directory for the E40 load-time parity verdict. There is no default location and
351    /// nothing is written without it. The verdict (lowest cosine, pass or fail, timestamp) is
352    /// stored as one small JSON file per model directory, keyed on the bundle files' names,
353    /// sizes and modification times (not their content hash, which would cost about as much
354    /// as the check), the ONNX Runtime library (path, size, mtime, the sibling `DirectML.dll`
355    /// and ORT's build string) and the provider name. ORT exposes no cheap GPU adapter or
356    /// driver identity, and neither does std (the DXGI or registry route needs a new
357    /// dependency or a child process), so the key does not see a GPU or driver change. A
358    /// stored verdict, pass or fail, is therefore trusted for 7 days only; an older one is a
359    /// miss and the check re-runs.
360    ///
361    /// A stored pass skips the CPU reference sessions: the load builds no CPU graph. A stored
362    /// failure still serves CPU (`Auto`) or returns `AcceleratorParity` (explicit `DirectMl`).
363    /// A missing, corrupt or non-matching file runs the check and rewrites the file. A failure
364    /// caused by an inference error is not stored. Delete the file to force a re-check. Applies
365    /// to E40 bundles only; ignored for every other bundle.
366    pub fn with_parity_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
367        self.parity_cache_dir = Some(dir.into());
368        self
369    }
370
371    pub fn with_provider(mut self, provider: impl Into<ExecutionProvider>) -> Self {
372        self.provider = provider.into();
373        self
374    }
375
376    pub fn with_truncate_dim(mut self, dim: usize) -> Self {
377        self.truncate_dim = dim;
378        self
379    }
380
381    pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
382        self.max_tokens = max_tokens;
383        self
384    }
385
386    /// Rows per ONNX call for the batch methods (default 16, minimum 1).
387    pub fn with_max_batch(mut self, max_batch: usize) -> Self {
388        self.max_batch = max_batch;
389        self
390    }
391
392    /// Intra-op threads for the session (default 1, minimum 1).
393    pub fn with_intra_threads(mut self, threads: usize) -> Self {
394        self.intra_threads = threads;
395        self
396    }
397}
398
399/// What turns token rows into vectors: one dynamic-shape Q4 ONNX session, the fixed-length
400/// E40 graphs with a host-side token table, or (macOS) the fixed-length Core ML functions.
401enum Backend {
402    Onnx(Mutex<Session>),
403    E40(E40Engine),
404    #[cfg(target_os = "macos")]
405    CoreMl(CoreMlEngine),
406}
407
408pub struct Embedder {
409    tokenizer: Tokenizer,
410    backend: Backend,
411    model_dir: PathBuf,
412    native_dim: usize,
413    truncate_dim: usize,
414    max_tokens: usize,
415    max_batch: usize,
416    model_id: String,
417    gemma2: bool,
418    pad_id: i64,
419    takes_token_type_ids: bool,
420    provider_used: &'static str,
421    fallback_reason: Option<String>,
422    parity: Option<AcceleratorParity>,
423    load_timings: Option<LoadTimings>,
424    pipeline_id: OnceLock<Result<String, String>>,
425}
426
427struct BuiltEmbedSession {
428    session: Session,
429    provider: rightkit_onnx::ExecutionProvider,
430    fallback: Option<String>,
431}
432
433fn build_session(
434    config: &EmbedderConfig,
435    provider: rightkit_onnx::ExecutionProvider,
436    cpu_fallback: bool,
437) -> Result<BuiltEmbedSession, EmbedError> {
438    let built = SessionOptions::new(provider)
439        .with_threads(Some(config.intra_threads))
440        .with_cpu_fallback(cpu_fallback)
441        .build_from_file_reported(&config.model_dir.join("model_q4.onnx"))
442        .map_err(|e| EmbedError::Runtime(e.to_string()))?;
443    Ok(BuiltEmbedSession {
444        session: built.session,
445        provider: built.provider,
446        fallback: built.fallback,
447    })
448}
449
450fn takes_token_type_ids(session: &Session) -> bool {
451    session
452        .inputs()
453        .iter()
454        .any(|input| input.name() == "token_type_ids")
455}
456
457/// Tokenizer with padding owned by this crate (masked, to the chunk maximum),
458/// never by the tokenizer file's own padding strategy, and the pad id.
459fn load_tokenizer(model_dir: &Path) -> Result<(Tokenizer, i64), EmbedError> {
460    let mut tokenizer = Tokenizer::from_file(model_dir.join("tokenizer.json"))
461        .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
462    let pad_id = tokenizer
463        .get_padding()
464        .map(|padding| i64::from(padding.pad_id))
465        .or_else(|| tokenizer.token_to_id("<pad>").map(i64::from))
466        .unwrap_or(0);
467    tokenizer.with_padding(None);
468    Ok((tokenizer, pad_id))
469}
470
471impl Embedder {
472    pub fn load(config: EmbedderConfig) -> Result<Self, EmbedError> {
473        let started = Instant::now();
474        validate_config(&config)?;
475        // The Core ML bundle does not touch ONNX Runtime at all (no runtime path is needed).
476        #[cfg(target_os = "macos")]
477        {
478            if coreml::is_bundle(&config.model_dir) {
479                let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
480                let tokenizer_load = started.elapsed();
481                let mut embedder = Self::load_coreml(config, tokenizer, pad_id)?;
482                // A Q4 embedder served from `fallback_model_dir` carries no timings.
483                if let Some(timings) = embedder.load_timings.as_mut() {
484                    timings.tokenizer_load = tokenizer_load;
485                    timings.total = started.elapsed();
486                }
487                return Ok(embedder);
488            }
489        }
490        let gemma2 = bundle_is_gemma2(&config.model_dir)?;
491        let tokenizer_started = Instant::now();
492        let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
493        let tokenizer_load = tokenizer_started.elapsed();
494        let selection = configure_runtime([RuntimeCandidate::new(
495            config.runtime_path.clone(),
496            CandidateSource::Explicit,
497        )])
498        .map_err(|e| EmbedError::Runtime(e.to_string()))?;
499        init_environment(
500            &selection,
501            &EnvironmentOptions {
502                global_pool: Some(GlobalPool::from_budget()),
503            },
504        )
505        .map_err(EmbedError::Runtime)?;
506        if e40::is_bundle(&config.model_dir) {
507            let mut embedder = Self::load_e40(config, tokenizer, pad_id, tokenizer_load)?;
508            // Only an E40 embedder carries timings (a Q4 fallback does not).
509            let sessions = match &embedder.backend {
510                Backend::E40(engine) => Some(engine.finish_session_log()),
511                _ => None,
512            };
513            if let (Some(timings), Some(sessions)) = (embedder.load_timings.as_mut(), sessions) {
514                timings.session_creates = sessions;
515                timings.total = started.elapsed();
516            }
517            return Ok(embedder);
518        }
519        let (requested, auto) = config.provider.requested();
520        let built = build_session(&config, requested, auto)?;
521        let accelerator = built.provider;
522        let mut embedder = Self::from_session(&config, tokenizer, pad_id, gemma2, built);
523        if auto && accelerator != rightkit_onnx::ExecutionProvider::Cpu {
524            if let Err(reason) = embedder.probe() {
525                let cpu = build_session(&config, rightkit_onnx::ExecutionProvider::Cpu, false)?;
526                embedder.takes_token_type_ids = takes_token_type_ids(&cpu.session);
527                embedder.backend = Backend::Onnx(Mutex::new(cpu.session));
528                embedder.provider_used = provider_name(cpu.provider);
529                embedder.fallback_reason = Some(format!(
530                    "{} -> cpu: probe inference failed: {reason}",
531                    provider_name(accelerator)
532                ));
533            }
534        }
535        if embedder.provider_used != "cpu" {
536            return embedder.admit_accelerator(&config, auto);
537        }
538        Ok(embedder)
539    }
540
541    fn from_session(
542        config: &EmbedderConfig,
543        tokenizer: Tokenizer,
544        pad_id: i64,
545        gemma2: bool,
546        built: BuiltEmbedSession,
547    ) -> Self {
548        Self {
549            tokenizer,
550            takes_token_type_ids: takes_token_type_ids(&built.session),
551            backend: Backend::Onnx(Mutex::new(built.session)),
552            model_dir: config.model_dir.clone(),
553            native_dim: EMBEDDINGGEMMA_NATIVE_DIM,
554            truncate_dim: config.truncate_dim,
555            max_tokens: config.max_tokens,
556            max_batch: config.max_batch,
557            model_id: if gemma2 {
558                EMBEDDINGGEMMA2_MODEL_ID
559            } else {
560                EMBEDDINGGEMMA_MODEL_ID
561            }
562            .to_owned(),
563            gemma2,
564            pad_id,
565            provider_used: provider_name(built.provider),
566            fallback_reason: built.fallback,
567            parity: None,
568            load_timings: None,
569            pipeline_id: OnceLock::new(),
570        }
571    }
572
573    fn from_e40(
574        config: &EmbedderConfig,
575        tokenizer: Tokenizer,
576        pad_id: i64,
577        engine: E40Engine,
578    ) -> Self {
579        Self {
580            tokenizer,
581            provider_used: provider_name(engine.provider()),
582            backend: Backend::E40(engine),
583            model_dir: config.model_dir.clone(),
584            native_dim: EMBEDDINGGEMMA_NATIVE_DIM,
585            truncate_dim: config.truncate_dim,
586            // The longest graph holds 512 tokens; longer texts are truncated to it.
587            max_tokens: config.max_tokens.min(e40::MAX_TOKENS),
588            max_batch: config.max_batch,
589            model_id: EMBEDDINGGEMMA2_E40_MODEL_ID.to_owned(),
590            gemma2: true,
591            pad_id,
592            takes_token_type_ids: false,
593            fallback_reason: None,
594            parity: None,
595            load_timings: None,
596            pipeline_id: OnceLock::new(),
597        }
598    }
599
600    /// Loads an E40 bundle. Provider mapping: `Cpu` runs the graphs on CPU; `Auto` is DirectML
601    /// on Windows (probe, then the vector parity check) and CPU elsewhere; `DirectMl` is the
602    /// same without any fallback; `CoreMl` is refused (the graphs are not a Core ML model).
603    /// A graph is built on first use, so a provider that cannot build one surfaces in the probe
604    /// (`Auto`) or the parity check, never as a graph quietly running on CPU.
605    fn load_e40(
606        config: EmbedderConfig,
607        tokenizer: Tokenizer,
608        pad_id: i64,
609        tokenizer_load: Duration,
610    ) -> Result<Self, EmbedError> {
611        use rightkit_onnx::ExecutionProvider as Ep;
612        let (requested, auto) = match config.provider {
613            ExecutionProvider::Cpu => (Ep::Cpu, false),
614            ExecutionProvider::DirectMl => (Ep::DirectMl, false),
615            ExecutionProvider::CoreMl => {
616                return Err(EmbedError::InvalidConfig(
617                    "an E40 bundle runs on DirectML or CPU, not CoreML".into(),
618                ))
619            }
620            ExecutionProvider::Auto => match Ep::platform_accelerator() {
621                Ep::DirectMl => (Ep::DirectMl, true),
622                _ => (Ep::Cpu, false),
623            },
624        };
625        let profile = config
626            .e40_session_profile
627            .unwrap_or_else(|| E40SessionProfile::shipped(config.intra_threads));
628        let engine = E40Engine::open(&config.model_dir, requested, profile)?;
629        let timings = LoadTimings {
630            tokenizer_load,
631            token_table_read: engine.token_table_read(),
632            ..LoadTimings::default()
633        };
634        let mut embedder = Self::from_e40(&config, tokenizer, pad_id, engine);
635        embedder.load_timings = Some(timings);
636        if requested == Ep::Cpu {
637            return Ok(embedder);
638        }
639        Self::admit_or_fall_back(embedder, &config, auto)
640    }
641
642    #[cfg(target_os = "macos")]
643    fn from_coreml(
644        config: &EmbedderConfig,
645        tokenizer: Tokenizer,
646        pad_id: i64,
647        engine: CoreMlEngine,
648    ) -> Self {
649        Self {
650            tokenizer,
651            provider_used: engine.provider_name(),
652            backend: Backend::CoreMl(engine),
653            model_dir: config.model_dir.clone(),
654            native_dim: EMBEDDINGGEMMA_NATIVE_DIM,
655            truncate_dim: config.truncate_dim,
656            // The longest function holds 512 tokens; longer texts are truncated to it.
657            max_tokens: config.max_tokens.min(e40::MAX_TOKENS),
658            max_batch: config.max_batch,
659            model_id: EMBEDDINGGEMMA2_COREML_MODEL_ID.to_owned(),
660            gemma2: true,
661            pad_id,
662            takes_token_type_ids: false,
663            fallback_reason: None,
664            parity: None,
665            load_timings: Some(LoadTimings::default()),
666            pipeline_id: OnceLock::new(),
667        }
668    }
669
670    /// Loads a Core ML bundle (macOS). Provider mapping: `Cpu` runs the package on Core ML
671    /// `cpuOnly` (reported as `"cpu"`); `Auto` and `CoreMl` run it on the per-length compute
672    /// units of [`EmbedderConfig::with_coreml_units`] (default `cpu_and_ne` up to 128 tokens,
673    /// `cpu_and_gpu` above; `"coreml"`) after a probe (`Auto` only) and the vector parity check
674    /// against the same package on `cpuOnly`, then a pre-warm of every function; `DirectMl` is
675    /// refused. `CoreMl` has no fallback. Under `Auto`,
676    /// anything that stops the package from loading (macOS older than 15, a compile error) or
677    /// from being admitted serves `with_fallback_model_dir`'s Q4 bundle if one is configured,
678    /// else the package on `cpuOnly` (load errors with no fallback dir are returned). The first
679    /// load of a function compiles it for the Neural Engine, which the card says takes several
680    /// minutes once; Core ML caches that, so the first `Auto` load is slow and later ones are not.
681    #[cfg(target_os = "macos")]
682    fn load_coreml(
683        config: EmbedderConfig,
684        tokenizer: Tokenizer,
685        pad_id: i64,
686    ) -> Result<Self, EmbedError> {
687        let accelerated = || config.coreml_units.clone().unwrap_or_default();
688        let (units, auto) = match config.provider {
689            ExecutionProvider::Cpu => (CoreMlUnits::uniform(CoreMlComputeUnit::CpuOnly), false),
690            ExecutionProvider::Auto => (accelerated(), true),
691            ExecutionProvider::CoreMl => (accelerated(), false),
692            ExecutionProvider::DirectMl => {
693                return Err(EmbedError::InvalidConfig(
694                    "a Core ML bundle runs on Core ML or CPU, not DirectML".into(),
695                ))
696            }
697        };
698        let cpu_only = units.is_cpu_only();
699        let engine = match CoreMlEngine::open(
700            &config.model_dir,
701            units,
702            config.coreml_cache_dir.as_deref(),
703        ) {
704            Ok(engine) => engine,
705            Err(error) if auto => {
706                return match &config.fallback_model_dir {
707                    Some(dir) => {
708                        Self::load_fallback(&config, dir, format!("coreml -> cpu: {error}"))
709                    }
710                    None => Err(error),
711                }
712            }
713            Err(error) => return Err(error),
714        };
715        let embedder = Self::from_coreml(&config, tokenizer, pad_id, engine);
716        // CPU is the reference itself: nothing to probe, check or pre-warm.
717        if cpu_only {
718            return Ok(embedder);
719        }
720        Self::admit_or_fall_back(embedder, &config, auto)
721    }
722
723    /// Shared tail of the fixed-graph loads (E40, Core ML): `embedder` runs on an accelerator.
724    /// Under `auto`, probe it; then run the vector parity check; whatever is not admitted is
725    /// replaced by the configured Q4 fallback bundle, or else by the same bundle on CPU.
726    fn admit_or_fall_back(
727        mut embedder: Self,
728        config: &EmbedderConfig,
729        auto: bool,
730    ) -> Result<Self, EmbedError> {
731        let config = config.clone();
732        let accelerator = embedder.provider_used;
733        if auto {
734            let probe_started = Instant::now();
735            let probed = embedder.probe();
736            if let Some(timings) = embedder.load_timings.as_mut() {
737                timings.probe = Some(probe_started.elapsed());
738            }
739            if let Err(reason) = probed {
740                return embedder.e40_not_admitted(
741                    &config,
742                    format!("{accelerator} -> cpu: probe inference failed: {reason}"),
743                );
744            }
745        }
746        match embedder.admit_accelerator(&config, auto) {
747            // Under `Auto` a failed parity check already returned the CPU twin with the reason.
748            Ok(admitted) if admitted.provider_used == "cpu" => {
749                match (&config.fallback_model_dir, admitted.fallback_reason.clone()) {
750                    (Some(dir), Some(reason)) => Self::load_fallback(&config, dir, reason),
751                    _ => Ok(admitted),
752                }
753            }
754            Ok(admitted) => Ok(admitted),
755            Err(error) if auto => match &config.fallback_model_dir {
756                Some(dir) => {
757                    Self::load_fallback(&config, dir, format!("{accelerator} -> cpu: {error}"))
758                }
759                None => Err(error),
760            },
761            Err(error) => Err(error),
762        }
763    }
764
765    /// `self`, an E40 embedder on an accelerator, was not admitted under `Auto` for `reason`:
766    /// serve the configured Q4 fallback bundle on CPU, or else the E40 graphs on CPU.
767    fn e40_not_admitted(
768        mut self,
769        config: &EmbedderConfig,
770        reason: String,
771    ) -> Result<Self, EmbedError> {
772        if let Some(dir) = &config.fallback_model_dir {
773            return Self::load_fallback(config, dir, reason);
774        }
775        let mut cpu = self.cpu_twin(config)?;
776        cpu.fallback_reason = Some(reason);
777        cpu.load_timings = self.load_timings.take();
778        Ok(cpu)
779    }
780
781    /// The Q4 fallback bundle on CPU, with `reason` and the model it serves in
782    /// `fallback_reason()`. It reports its own model id and pipeline id.
783    fn load_fallback(
784        config: &EmbedderConfig,
785        dir: &Path,
786        reason: String,
787    ) -> Result<Self, EmbedError> {
788        if e40::is_bundle(dir) || coreml_bundle_files(dir)?.is_some() {
789            return Err(EmbedError::InvalidConfig(
790                "the fallback model directory must hold a Q4 bundle, not an E40 or Core ML bundle"
791                    .into(),
792            ));
793        }
794        let mut fallback = config.clone();
795        fallback.model_dir = dir.to_path_buf();
796        fallback.provider = ExecutionProvider::Cpu;
797        fallback.fallback_model_dir = None;
798        let mut embedder = Self::load(fallback)?;
799        embedder.fallback_reason = Some(format!(
800            "{reason}; serving {} on cpu from the fallback model directory",
801            embedder.model_id
802        ));
803        Ok(embedder)
804    }
805
806    /// A CPU embedder over the same bundle as `self` (the E40 twin shares the token table).
807    fn cpu_twin(&self, config: &EmbedderConfig) -> Result<Self, EmbedError> {
808        let (tokenizer, pad_id) = load_tokenizer(&config.model_dir)?;
809        match &self.backend {
810            Backend::Onnx(_) => {
811                let built = build_session(config, rightkit_onnx::ExecutionProvider::Cpu, false)?;
812                Ok(Self::from_session(
813                    config,
814                    tokenizer,
815                    pad_id,
816                    self.gemma2,
817                    built,
818                ))
819            }
820            Backend::E40(engine) => Ok(Self::from_e40(
821                config,
822                tokenizer,
823                pad_id,
824                engine.with_provider(rightkit_onnx::ExecutionProvider::Cpu),
825            )),
826            // The parity reference: the same compiled package on `cpuOnly`.
827            #[cfg(target_os = "macos")]
828            Backend::CoreMl(engine) => Ok(Self::from_coreml(
829                config,
830                tokenizer,
831                pad_id,
832                engine.with_units(CoreMlUnits::uniform(CoreMlComputeUnit::CpuOnly)),
833            )),
834        }
835    }
836
837    /// True for the fixed-length backends (E40, Core ML): one function per bucket.
838    fn is_fixed_graph(&self) -> bool {
839        match self.backend {
840            Backend::Onnx(_) => false,
841            Backend::E40(_) => true,
842            #[cfg(target_os = "macos")]
843            Backend::CoreMl(_) => true,
844        }
845    }
846
847    /// Drops built E40 graphs / loaded Core ML functions (the next text rebuilds the one it
848    /// needs); no-op for Q4.
849    fn release_graphs(&self) {
850        match &self.backend {
851            Backend::Onnx(_) => {}
852            Backend::E40(engine) => engine.release(),
853            #[cfg(target_os = "macos")]
854            Backend::CoreMl(engine) => engine.release(),
855        }
856    }
857
858    /// True when `self` is an E40 or Core ML embedder on an accelerator and pre-warm is enabled.
859    fn prewarms(&self, config: &EmbedderConfig) -> bool {
860        // (fixed-graph accelerator, default when the caller did not choose)
861        let (fixed_graph_accelerator, default_on) = match &self.backend {
862            Backend::E40(_) => (true, false),
863            #[cfg(target_os = "macos")]
864            Backend::CoreMl(_) => (true, true),
865            Backend::Onnx(_) => (false, false),
866        };
867        config.prewarm.unwrap_or(default_on)
868            && self.provider_used != "cpu"
869            && fixed_graph_accelerator
870    }
871
872    /// Builds the missing E40 graphs (or loads the Core ML functions) and runs one dummy text
873    /// through each; records the time in `load_timings.prewarm` (Core ML also per length in
874    /// `load_timings.prewarm_per_length`).
875    fn prewarm_graphs(&mut self) -> Result<(), EmbedError> {
876        let (elapsed, per_length) = match &self.backend {
877            Backend::E40(engine) => (engine.prewarm(self.pad_id)?, Vec::new()),
878            #[cfg(target_os = "macos")]
879            Backend::CoreMl(engine) => engine.prewarm(self.pad_id)?,
880            Backend::Onnx(_) => return Ok(()),
881        };
882        if let Some(timings) = self.load_timings.as_mut() {
883            timings.prewarm = Some(elapsed);
884            timings.prewarm_per_length = per_length;
885        }
886        Ok(())
887    }
888
889    /// Vector parity gate for an embedder built on an accelerator (`self`).
890    /// Builds a CPU embedder for the same model and embeds the parity prompts
891    /// through both with the real inference path. Passing keeps `self` and
892    /// drops the CPU embedder; failing returns the CPU embedder with the
893    /// reason under `Auto`, and [`EmbedError::AcceleratorParity`] otherwise.
894    fn admit_accelerator(
895        mut self,
896        config: &EmbedderConfig,
897        auto: bool,
898    ) -> Result<Self, EmbedError> {
899        let provider = self.provider_used;
900        let check_started = Instant::now();
901        let cache = self.parity_cache_target(config);
902        let mut miss_reason: Option<&'static str> = None;
903        if let Some(target) = &cache {
904            match parity_cache::read(
905                &target.dir,
906                &config.model_dir,
907                &target.key,
908                parity_cache::now_unix(),
909            ) {
910                parity_cache::Lookup::Hit(verdict) => {
911                    self.record_parity_timings(
912                        check_started,
913                        None,
914                        None,
915                        ParityCacheOutcome::Hit {
916                            passed: verdict.passed,
917                        },
918                    );
919                    return self.apply_cached_verdict(&verdict, config, auto);
920                }
921                parity_cache::Lookup::Miss(reason) => miss_reason = Some(reason),
922            }
923        }
924        // What the cache did once the check has run; `stored` is whether a verdict was written.
925        let outcome = |stored: bool| match miss_reason {
926            Some(reason) => ParityCacheOutcome::Miss { reason, stored },
927            None => ParityCacheOutcome::NotUsed,
928        };
929        let mut cpu = self.cpu_twin(config)?;
930        let prompts = cpu.parity_prompts();
931        let tokens: Vec<usize> = cpu
932            .encode_prompts(&prompts)?
933            .iter()
934            .map(|row| row.ids.len())
935            .collect();
936        let reference = cpu.parity_vectors(&prompts)?;
937        let cpu_reference = check_started.elapsed();
938        let accelerator_started = Instant::now();
939        let candidate = self.parity_vectors(&prompts);
940        let accelerator = accelerator_started.elapsed();
941        let (lowest_cosine, detail) = match candidate {
942            Ok(candidate) => match compare_parity(
943                &reference,
944                &candidate,
945                self.native_dim,
946                ACCELERATOR_PARITY_MIN_COSINE,
947            ) {
948                Ok(lowest) => {
949                    let parity = AcceleratorParity {
950                        lowest_cosine: lowest.cosine,
951                        at_tokens: tokens[lowest.index % tokens.len()],
952                        pairs: reference.len(),
953                    };
954                    self.parity = Some(parity);
955                    // The parity prompts built every E40 graph. Without pre-warm keep only
956                    // what texts use; with it the graphs stay and are warmed below.
957                    let prewarm = self.prewarms(config);
958                    if !prewarm {
959                        self.release_graphs();
960                    }
961                    let stored = cache.as_ref().is_some_and(|target| {
962                        target.store(
963                            &config.model_dir,
964                            &parity_cache::ParityVerdict {
965                                passed: true,
966                                lowest_cosine: Some(parity.lowest_cosine),
967                                at_tokens: parity.at_tokens,
968                                pairs: parity.pairs,
969                                detail: String::new(),
970                                timestamp_unix: parity_cache::now_unix(),
971                            },
972                        )
973                    });
974                    self.record_parity_timings(
975                        check_started,
976                        Some(cpu_reference),
977                        Some(accelerator),
978                        outcome(stored),
979                    );
980                    if prewarm {
981                        self.prewarm_graphs()?;
982                    }
983                    return Ok(self);
984                }
985                Err(miss) => {
986                    let (lowest_cosine, detail) =
987                        parity_miss_detail(&miss, &tokens, ACCELERATOR_PARITY_MIN_COSINE);
988                    // A mismatch is a property of model, runtime and adapter, so it is kept
989                    // like a pass; an inference error below may be transient and is not.
990                    let stored = cache.as_ref().is_some_and(|target| {
991                        target.store(
992                            &config.model_dir,
993                            &parity_cache::ParityVerdict {
994                                passed: false,
995                                lowest_cosine,
996                                at_tokens: 0,
997                                pairs: reference.len(),
998                                detail: detail.clone(),
999                                timestamp_unix: parity_cache::now_unix(),
1000                            },
1001                        )
1002                    });
1003                    self.record_parity_timings(
1004                        check_started,
1005                        Some(cpu_reference),
1006                        Some(accelerator),
1007                        outcome(stored),
1008                    );
1009                    (lowest_cosine, detail)
1010                }
1011            },
1012            Err(error) if auto => {
1013                self.record_parity_timings(
1014                    check_started,
1015                    Some(cpu_reference),
1016                    Some(accelerator),
1017                    outcome(false),
1018                );
1019                (None, format!("inference error: {error}"))
1020            }
1021            Err(error) => return Err(error),
1022        };
1023        if auto {
1024            cpu.fallback_reason =
1025                Some(format!("{provider} -> cpu: vector parity failed: {detail}"));
1026            cpu.load_timings = self.load_timings.take();
1027            return Ok(cpu);
1028        }
1029        Err(EmbedError::AcceleratorParity {
1030            provider,
1031            lowest_cosine,
1032            floor: ACCELERATOR_PARITY_MIN_COSINE,
1033            detail,
1034        })
1035    }
1036
1037    /// Cache directory and key for this embedder's parity verdict: only for an E40 backend with
1038    /// `EmbedderConfig::with_parity_cache_dir` set. The adapter part of the key is the provider
1039    /// name; ORT offers no cheap adapter identity.
1040    fn parity_cache_target(&self, config: &EmbedderConfig) -> Option<ParityCacheTarget> {
1041        let dir = config.parity_cache_dir.clone()?;
1042        if !matches!(self.backend, Backend::E40(_)) {
1043            return None;
1044        }
1045        let key = parity_cache::ParityKey::build(
1046            &config.model_dir,
1047            &e40::ARTIFACTS,
1048            &config.runtime_path,
1049            ort::info(),
1050            self.provider_used,
1051            ACCELERATOR_PARITY_MIN_COSINE,
1052        );
1053        Some(ParityCacheTarget { dir, key })
1054    }
1055
1056    /// Records the parity phase of the load into `load_timings` (E40 loads only).
1057    fn record_parity_timings(
1058        &mut self,
1059        started: Instant,
1060        cpu_reference: Option<Duration>,
1061        accelerator: Option<Duration>,
1062        cache: ParityCacheOutcome,
1063    ) {
1064        if let Some(timings) = self.load_timings.as_mut() {
1065            timings.parity_check_total = Some(started.elapsed());
1066            timings.parity_cpu_reference = cpu_reference;
1067            timings.parity_accelerator = accelerator;
1068            timings.parity_cache = cache;
1069        }
1070    }
1071
1072    /// Admission from a stored verdict: no CPU session is built for a pass. A stored failure
1073    /// behaves like a failed check: CPU under `Auto`, `AcceleratorParity` otherwise.
1074    fn apply_cached_verdict(
1075        mut self,
1076        verdict: &parity_cache::ParityVerdict,
1077        config: &EmbedderConfig,
1078        auto: bool,
1079    ) -> Result<Self, EmbedError> {
1080        let provider = self.provider_used;
1081        if verdict.passed {
1082            self.parity = verdict
1083                .lowest_cosine
1084                .map(|lowest_cosine| AcceleratorParity {
1085                    lowest_cosine,
1086                    at_tokens: verdict.at_tokens,
1087                    pairs: verdict.pairs,
1088                });
1089            if self.prewarms(config) {
1090                self.prewarm_graphs()?;
1091            }
1092            return Ok(self);
1093        }
1094        let detail = format!("{} (cached verdict)", verdict.detail);
1095        if auto {
1096            let mut cpu = self.cpu_twin(config)?;
1097            cpu.fallback_reason =
1098                Some(format!("{provider} -> cpu: vector parity failed: {detail}"));
1099            cpu.load_timings = self.load_timings.take();
1100            return Ok(cpu);
1101        }
1102        Err(EmbedError::AcceleratorParity {
1103            provider,
1104            lowest_cosine: verdict.lowest_cosine,
1105            floor: ACCELERATOR_PARITY_MIN_COSINE,
1106            detail,
1107        })
1108    }
1109
1110    /// Fixed parity prompts of clearly different token lengths: a short
1111    /// query, a medium document and a long document, with this crate's prefixes.
1112    ///
1113    /// An E40 bundle has one fixed-length graph per bucket (32, 64, 128, 256, 512 tokens) and
1114    /// admission has to exercise every one, so it gets two more prompts: about 50 tokens (the
1115    /// 64 graph, one section) and about 190 (the 256 graph, four sections). The others land
1116    /// in 32 (query), 128 (medium document) and 512 (long document, truncated).
1117    fn parity_prompts(&self) -> Vec<String> {
1118        let mut prompts = vec![
1119            self.query_prompt(PARITY_QUERY),
1120            self.document_prompt(PARITY_DOCUMENT),
1121            self.document_prompt(&parity_long_document()),
1122        ];
1123        if self.is_fixed_graph() {
1124            prompts.insert(1, self.document_prompt(&parity_sections(1)));
1125            prompts.insert(3, self.document_prompt(&parity_sections(4)));
1126        }
1127        prompts
1128    }
1129
1130    /// Native-dimension normalized vectors for `prompts`: first each prompt
1131    /// alone (its own accelerator shape bucket), then all together as one
1132    /// batch. `2 * prompts.len()` vectors.
1133    fn parity_vectors(&self, prompts: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> {
1134        let mut vectors = Vec::with_capacity(prompts.len() * 2);
1135        for prompt in prompts {
1136            vectors.extend(self.embed_prompts_native(vec![prompt.clone()])?);
1137        }
1138        vectors.extend(self.embed_prompts_native(prompts.to_vec())?);
1139        Ok(vectors)
1140    }
1141
1142    /// One real inference through the live session; any error, shape mismatch,
1143    /// non-finite or all-zero output rejects the accelerator.
1144    fn probe(&self) -> Result<(), String> {
1145        let rows = self
1146            .encode_prompts(&[PROBE_PROMPT.to_owned()])
1147            .map_err(|e| e.to_string())?;
1148        let chunk = [&rows[0]];
1149        let native = self
1150            .run_chunk(&chunk, true)
1151            .map_err(|e| e.to_string())?
1152            .into_iter()
1153            .next()
1154            .ok_or_else(|| "probe returned no row".to_owned())?;
1155        probe_output_ok(&native, self.native_dim)
1156    }
1157
1158    pub fn model_id(&self) -> &str {
1159        &self.model_id
1160    }
1161
1162    pub fn native_dim(&self) -> usize {
1163        self.native_dim
1164    }
1165
1166    pub fn dimension(&self) -> usize {
1167        self.truncate_dim
1168    }
1169
1170    /// Provider serving inference: `"cpu"`, `"directml"` or `"coreml"`.
1171    pub fn provider_used(&self) -> &str {
1172        self.provider_used
1173    }
1174
1175    /// Why an accelerator was replaced by CPU, when it was. A vector parity
1176    /// failure reads like `directml -> cpu: vector parity failed: cosine 0.579
1177    /// at ~20 tokens, floor 0.995`.
1178    pub fn fallback_reason(&self) -> Option<&str> {
1179        self.fallback_reason.as_deref()
1180    }
1181
1182    /// The load-time vector parity result of the accelerator in use: lowest
1183    /// cosine to the CPU provider's vectors, the prompt length it occurred at
1184    /// and the pairs compared. `None` when the provider is CPU (no parity
1185    /// work is done) and when an accelerator was replaced by CPU (the failure
1186    /// is in [`Self::fallback_reason`]). Always at least
1187    /// [`ACCELERATOR_PARITY_MIN_COSINE`] when `Some`.
1188    pub fn accelerator_parity(&self) -> Option<AcceleratorParity> {
1189        self.parity
1190    }
1191
1192    /// Where `Embedder::load` spent its time, for an E40 bundle (including one that fell back
1193    /// to the E40 graphs on CPU): tokenizer load, token-table read, probe, every graph build
1194    /// and the parity check (or its cached verdict). For a Core ML bundle (macOS) it holds the
1195    /// tokenizer load, probe, parity check, pre-warm (total and per length) and the total; the
1196    /// phases it has no figure for stay zero or `None`. `None` for every other bundle and for a
1197    /// Q4 embedder served from `fallback_model_dir`.
1198    pub fn load_timings(&self) -> Option<&LoadTimings> {
1199        self.load_timings.as_ref()
1200    }
1201
1202    /// The Core ML compute units in use per length: `Some` for a Core ML bundle (macOS) that
1203    /// was not replaced by a Q4 fallback, `None` for every other embedder. An embedder that
1204    /// fell back to the package on CPU reports `cpu_only` everywhere.
1205    pub fn coreml_units(&self) -> Option<&CoreMlUnits> {
1206        match &self.backend {
1207            #[cfg(target_os = "macos")]
1208            Backend::CoreMl(engine) => Some(engine.units()),
1209            _ => None,
1210        }
1211    }
1212
1213    /// Token ids, prompt and graph length of `text` as `embed_query` would run it. Benchmark
1214    /// support (`bench-internals`): not part of the stable API.
1215    #[cfg(feature = "bench-internals")]
1216    pub fn bench_query_tokens(&self, text: &str) -> Result<BenchTokens, EmbedError> {
1217        let prompt = self.query_prompt(text);
1218        let ids = self
1219            .encode_prompts(std::slice::from_ref(&prompt))?
1220            .remove(0)
1221            .ids;
1222        Ok(BenchTokens {
1223            graph_tokens: e40::bucket_for(ids.len()),
1224            pad_id: self.pad_id,
1225            prompt,
1226            ids,
1227        })
1228    }
1229
1230    /// One `embed_query` on an E40 embedder with the time split by phase: tokenizing, the
1231    /// engine's own phases (`E40Engine::run_split`, the production path) and the pooling and
1232    /// truncation after it. Benchmark support (`bench-internals`): not part of the stable API.
1233    #[cfg(feature = "bench-internals")]
1234    pub fn bench_embed_query_split(&self, text: &str) -> Result<BenchEmbedSplit, EmbedError> {
1235        let Backend::E40(engine) = &self.backend else {
1236            return Err(EmbedError::InvalidConfig(
1237                "bench_embed_query_split needs an E40 bundle".into(),
1238            ));
1239        };
1240        let started = Instant::now();
1241        let prompt = self.query_prompt(text);
1242        let row = self
1243            .encode_prompts(std::slice::from_ref(&prompt))?
1244            .remove(0);
1245        let tokenize = started.elapsed();
1246        let (values, engine_split) = engine.run_split(&row.ids, self.pad_id)?;
1247        let post_started = Instant::now();
1248        let shape = [1, self.native_dim as i64];
1249        let mut pooled = pool_batch(&shape, &values, &[row.mask.as_slice()], self.native_dim)?;
1250        let _ = truncate_and_normalize(&pooled.remove(0), self.truncate_dim);
1251        Ok(BenchEmbedSplit {
1252            tokenize,
1253            engine: engine_split,
1254            post: post_started.elapsed(),
1255            total: started.elapsed(),
1256        })
1257    }
1258
1259    /// Stable fingerprint of everything that changes the produced vectors, so
1260    /// callers can detect when stored vectors need re-embedding. Fed by:
1261    ///
1262    /// - the pipeline schema version of this crate's embedding math;
1263    /// - model id and native dimension;
1264    /// - SHA-256 of `model_q4.onnx`, `model_q4.onnx_data`, `tokenizer.json`
1265    ///   and `config.json` (absent file recorded as absent);
1266    /// - the document and query prompt templates;
1267    /// - pooling and normalization descriptions;
1268    /// - Matryoshka truncate dimension and max token length;
1269    /// - whether Gemma 2 modality sentinels are scrubbed.
1270    ///
1271    /// Not fed by provider, thread count or batch size: those change only
1272    /// floating-point noise (see `max_batch` tolerance in the README), not
1273    /// the pipeline. The accelerator parity check is not an input either: it
1274    /// only decides which provider serves the same math, and an admitted
1275    /// accelerator reproduces the CPU vectors to the parity floor. Computed
1276    /// once on first call (hashes the model files) and cached; format
1277    /// `rkpipe1-<32 hex>`.
1278    pub fn pipeline_id(&self) -> String {
1279        match self.pipeline_id.get_or_init(|| {
1280            let mut files = Vec::new();
1281            let names: &[&str] = match self.backend {
1282                Backend::E40(_) => &e40::ARTIFACTS,
1283                #[cfg(target_os = "macos")]
1284                Backend::CoreMl(_) => &coreml::ARTIFACTS,
1285                Backend::Onnx(_) => &[
1286                    "model_q4.onnx",
1287                    "model_q4.onnx_data",
1288                    "tokenizer.json",
1289                    "config.json",
1290                ],
1291            };
1292            for name in names {
1293                files.push((*name, hash_file(&self.model_dir.join(name))?));
1294            }
1295            Ok(pipeline_fingerprint(&PipelineParts {
1296                model_id: &self.model_id,
1297                native_dim: self.native_dim,
1298                truncate_dim: self.truncate_dim,
1299                max_tokens: self.max_tokens,
1300                scrubs_sentinels: self.gemma2,
1301                files: &files,
1302            }))
1303        }) {
1304            Ok(id) => id.clone(),
1305            // Unreadable artifact: an id that can never equal a good one.
1306            Err(error) => format!("rkpipe1-unavailable-{}", sanitize(error)),
1307        }
1308    }
1309
1310    pub fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
1311        self.embed_prompts(vec![self.document_prompt(text)])?
1312            .pop()
1313            .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
1314    }
1315
1316    pub fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
1317        self.embed_prompts(vec![self.query_prompt(text)])?
1318            .pop()
1319            .ok_or_else(|| EmbedError::Output("no embedding produced".into()))
1320    }
1321
1322    /// Embeds documents in input order. Texts are tokenized together, ordered
1323    /// by length so chunks pad little, run `max_batch` rows per ONNX call with
1324    /// the attention mask honoured, then returned in input order.
1325    pub fn embed_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
1326    where
1327        I: IntoIterator<Item = &'a str>,
1328    {
1329        self.embed_prompts(texts.into_iter().map(|t| self.document_prompt(t)).collect())
1330    }
1331
1332    /// Query-prompt counterpart of [`Self::embed_batch`].
1333    pub fn embed_query_batch<'a, I>(&self, texts: I) -> Result<Vec<Vec<f32>>, EmbedError>
1334    where
1335        I: IntoIterator<Item = &'a str>,
1336    {
1337        self.embed_prompts(texts.into_iter().map(|t| self.query_prompt(t)).collect())
1338    }
1339
1340    fn document_prompt(&self, text: &str) -> String {
1341        format!("{DOCUMENT_PROMPT_PREFIX}{}", self.model_text(text))
1342    }
1343
1344    fn query_prompt(&self, text: &str) -> String {
1345        format!("{QUERY_PROMPT_PREFIX}{}", self.model_text(text))
1346    }
1347
1348    /// EmbeddingGemma 2's text-only graph must never see modality sentinels;
1349    /// their literal spellings are defanged before tokenization.
1350    fn model_text<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
1351        if !self.gemma2 || !GEMMA2_MODALITY_SENTINELS.iter().any(|s| text.contains(s)) {
1352            return std::borrow::Cow::Borrowed(text);
1353        }
1354        let mut owned = text.to_owned();
1355        for sentinel in GEMMA2_MODALITY_SENTINELS {
1356            owned = owned.replace(sentinel, &sentinel.replace('|', "\u{2502}"));
1357        }
1358        std::borrow::Cow::Owned(owned)
1359    }
1360
1361    fn encode_prompts(&self, prompts: &[String]) -> Result<Vec<TokenRow>, EmbedError> {
1362        let inputs: Vec<EncodeInput> = prompts
1363            .iter()
1364            .map(|prompt| EncodeInput::Single(prompt.as_str().into()))
1365            .collect();
1366        let encodings = self
1367            .tokenizer
1368            .encode_batch(inputs, true)
1369            .map_err(|e| EmbedError::Tokenizer(e.to_string()))?;
1370        encodings
1371            .iter()
1372            .map(|encoding| {
1373                let ids: Vec<i64> = encoding
1374                    .get_ids()
1375                    .iter()
1376                    .take(self.max_tokens)
1377                    .map(|id| i64::from(*id))
1378                    .collect();
1379                if ids.is_empty() {
1380                    return Err(EmbedError::Tokenizer("encoding returned no tokens".into()));
1381                }
1382                let mask: Vec<i64> = encoding
1383                    .get_attention_mask()
1384                    .iter()
1385                    .take(ids.len())
1386                    .map(|value| i64::from(*value))
1387                    .collect();
1388                Ok(TokenRow { ids, mask })
1389            })
1390            .collect()
1391    }
1392
1393    fn embed_prompts(&self, prompts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedError> {
1394        Ok(self
1395            .embed_prompts_native(prompts)?
1396            .into_iter()
1397            .map(|native| truncate_and_normalize(&native, self.truncate_dim))
1398            .collect())
1399    }
1400
1401    /// Normalized native-dimension vector per prompt, in input order, before
1402    /// Matryoshka truncation.
1403    fn embed_prompts_native(&self, prompts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedError> {
1404        if prompts.is_empty() {
1405            return Ok(Vec::new());
1406        }
1407        let rows = self.encode_prompts(&prompts)?;
1408        let lengths: Vec<usize> = rows.iter().map(|row| row.ids.len()).collect();
1409        let order = length_order(&lengths);
1410        let sorted_lengths: Vec<usize> = order.iter().map(|&i| lengths[i]).collect();
1411        let accelerated = self.provider_used != "cpu";
1412        let mut out: Vec<Option<Vec<f32>>> = vec![None; rows.len()];
1413        for range in plan_chunks(
1414            &sorted_lengths,
1415            self.max_batch.max(1),
1416            MAX_PADDED_TOKENS_PER_CALL,
1417        ) {
1418            let chunk: Vec<&TokenRow> = order[range.clone()].iter().map(|&i| &rows[i]).collect();
1419            let natives = self.run_chunk(&chunk, accelerated)?;
1420            for (&index, native) in order[range].iter().zip(natives) {
1421                out[index] = Some(native);
1422            }
1423        }
1424        out.into_iter()
1425            .map(|row| row.ok_or_else(|| EmbedError::Output("missing embedding row".into())))
1426            .collect()
1427    }
1428
1429    /// One ONNX call over `chunk`; returns the normalized native-dimension
1430    /// vector per row, in chunk order.
1431    fn run_chunk(
1432        &self,
1433        chunk: &[&TokenRow],
1434        accelerated: bool,
1435    ) -> Result<Vec<Vec<f32>>, EmbedError> {
1436        let session = match &self.backend {
1437            Backend::Onnx(session) => session,
1438            // E40 graphs are batch 1 with a fixed length: each row runs alone through the
1439            // smallest graph that holds it, so there is no padding of rows or bucket rounding
1440            // here. The graph's pooled output is [1, native_dim] and already normalised.
1441            Backend::E40(engine) => {
1442                return self.run_fixed_rows(chunk, |ids| engine.run(ids, self.pad_id));
1443            }
1444            // Same shape of work for Core ML: one text per call through the smallest function
1445            // that holds it, output [1, native_dim] already normalised in the graph.
1446            #[cfg(target_os = "macos")]
1447            Backend::CoreMl(engine) => {
1448                return self.run_fixed_rows(chunk, |ids| engine.run(ids, self.pad_id));
1449            }
1450        };
1451        let longest = chunk.iter().map(|row| row.ids.len()).max().unwrap_or(1);
1452        let (batch_rows, padded_len) = padded_shape(chunk.len(), longest, accelerated);
1453        let padded = pad_batch(chunk, self.pad_id, padded_len, batch_rows);
1454        let (rows, len) = (padded.rows, padded.len);
1455        let tensor = |data: Vec<i64>| {
1456            ort::value::Tensor::from_array(([rows, len], data))
1457                .map_err(|e| EmbedError::Runtime(e.to_string()))
1458        };
1459        let mut inputs = ort::inputs![
1460            "input_ids" => tensor(padded.ids)?,
1461            "attention_mask" => tensor(padded.mask)?
1462        ];
1463        if self.takes_token_type_ids {
1464            inputs.push(("token_type_ids".into(), tensor(vec![0; rows * len])?.into()));
1465        }
1466        let mut session = session
1467            .lock()
1468            .map_err(|_| EmbedError::Runtime("model mutex poisoned".into()))?;
1469        let outputs = session
1470            .run(inputs)
1471            .map_err(|e| EmbedError::Runtime(e.to_string()))?;
1472        let output = outputs
1473            .get("sentence_embedding")
1474            .ok_or_else(|| EmbedError::Output("missing sentence_embedding output".into()))?;
1475        let (shape, values) = output
1476            .try_extract_tensor::<f32>()
1477            .map_err(|e| EmbedError::Output(e.to_string()))?;
1478        let masks: Vec<&[i64]> = chunk.iter().map(|row| row.mask.as_slice()).collect();
1479        pool_batch(shape.as_ref(), values, &masks, self.native_dim)
1480    }
1481}
1482
1483impl Embedder {
1484    /// Runs each row alone through a fixed-length backend (`run` returns the `[1, native_dim]`
1485    /// output for one row's ids) and pools it as a pooled `[1, native_dim]` output.
1486    fn run_fixed_rows(
1487        &self,
1488        chunk: &[&TokenRow],
1489        run: impl Fn(&[i64]) -> Result<Vec<f32>, EmbedError>,
1490    ) -> Result<Vec<Vec<f32>>, EmbedError> {
1491        let mut natives = Vec::with_capacity(chunk.len());
1492        for row in chunk {
1493            let values = run(&row.ids)?;
1494            let shape = [1, self.native_dim as i64];
1495            let mut pooled = pool_batch(&shape, &values, &[row.mask.as_slice()], self.native_dim)?;
1496            natives.push(pooled.remove(0));
1497        }
1498        Ok(natives)
1499    }
1500}
1501
1502/// Where a parity verdict is stored and what it is valid for.
1503struct ParityCacheTarget {
1504    dir: PathBuf,
1505    key: parity_cache::ParityKey,
1506}
1507
1508impl ParityCacheTarget {
1509    /// Writes `verdict`; true when it was stored. A failed write only means the next load
1510    /// re-runs the check.
1511    fn store(&self, model_dir: &Path, verdict: &parity_cache::ParityVerdict) -> bool {
1512        parity_cache::write(&self.dir, model_dir, &self.key, verdict).is_ok()
1513    }
1514}
1515
1516/// Tokenization of one query text (`bench-internals`).
1517#[cfg(feature = "bench-internals")]
1518#[derive(Debug, Clone, PartialEq, Eq)]
1519pub struct BenchTokens {
1520    /// The prompt after this crate's query prefix and sentinel scrubbing.
1521    pub prompt: String,
1522    /// Exact token ids fed to the graph, truncated to the longest graph.
1523    pub ids: Vec<i64>,
1524    /// Length of the E40 graph that would run it; `None` above the longest graph.
1525    pub graph_tokens: Option<usize>,
1526    /// Id of the padding rows.
1527    pub pad_id: i64,
1528}
1529
1530/// Phases of one E40 `embed_query` (`bench-internals`).
1531#[cfg(feature = "bench-internals")]
1532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1533pub struct BenchEmbedSplit {
1534    /// Prompt assembly and tokenizer.
1535    pub tokenize: Duration,
1536    /// The engine's phases; `engine.total` includes lookup, tensors, `Session::run` and copy-out.
1537    pub engine: e40::RunSplit,
1538    /// Pooling, Matryoshka truncation and normalisation after the engine.
1539    pub post: Duration,
1540    /// The whole call.
1541    pub total: Duration,
1542}
1543
1544/// One tokenized, truncated text.
1545struct TokenRow {
1546    ids: Vec<i64>,
1547    mask: Vec<i64>,
1548}
1549
1550/// Row-major `[rows, len]` model inputs.
1551#[derive(Debug, PartialEq, Eq)]
1552struct PaddedBatch {
1553    rows: usize,
1554    len: usize,
1555    ids: Vec<i64>,
1556    mask: Vec<i64>,
1557}
1558
1559/// Stable ascending-length order (ties keep input order) so chunks of similar
1560/// length share a call and pad little.
1561fn length_order(lengths: &[usize]) -> Vec<usize> {
1562    let mut order: Vec<usize> = (0..lengths.len()).collect();
1563    order.sort_by_key(|&i| lengths[i]);
1564    order
1565}
1566
1567/// Splits ascending `sorted_lengths` into contiguous ranges of at most
1568/// `max_rows` rows whose padded size `rows * longest` stays within
1569/// `token_budget`; a lone row is always allowed.
1570fn plan_chunks(
1571    sorted_lengths: &[usize],
1572    max_rows: usize,
1573    token_budget: usize,
1574) -> Vec<std::ops::Range<usize>> {
1575    let mut ranges = Vec::new();
1576    let mut start = 0;
1577    while start < sorted_lengths.len() {
1578        let mut end = start + 1;
1579        while end < sorted_lengths.len()
1580            && end - start < max_rows
1581            && (end - start + 1) * sorted_lengths[end] <= token_budget
1582        {
1583            end += 1;
1584        }
1585        ranges.push(start..end);
1586        start = end;
1587    }
1588    ranges
1589}
1590
1591/// `(rows, len)` of the tensor sent to the model. CPU uses the exact chunk
1592/// maximum. Accelerators recompile per shape, so length rounds up to a bucket
1593/// and rows to a power of two (extra rows repeat the last row and are dropped).
1594fn padded_shape(rows: usize, longest: usize, accelerated: bool) -> (usize, usize) {
1595    if !accelerated {
1596        return (rows, longest);
1597    }
1598    let len = ACCELERATOR_BUCKETS
1599        .iter()
1600        .copied()
1601        .find(|bucket| *bucket >= longest)
1602        .unwrap_or(longest);
1603    (rows.next_power_of_two(), len)
1604}
1605
1606/// Right-pads every row to `len` with `pad_id` ids and zero mask; rows beyond
1607/// `chunk.len()` (up to `rows`) repeat the last row.
1608fn pad_batch(chunk: &[&TokenRow], pad_id: i64, len: usize, rows: usize) -> PaddedBatch {
1609    let mut ids = vec![pad_id; rows * len];
1610    let mut mask = vec![0i64; rows * len];
1611    for row in 0..rows {
1612        let source = chunk[row.min(chunk.len() - 1)];
1613        let used = source.ids.len().min(len);
1614        ids[row * len..row * len + used].copy_from_slice(&source.ids[..used]);
1615        mask[row * len..row * len + used].copy_from_slice(&source.mask[..used]);
1616    }
1617    PaddedBatch {
1618        rows,
1619        len,
1620        ids,
1621        mask,
1622    }
1623}
1624
1625/// Turns a model output into one L2-normalized native vector per real row
1626/// (`masks.len()` rows; extra padding rows are ignored). Accepts the model's
1627/// pooled `[B, D]` output, or token states `[B, T, D]` which are mean-pooled
1628/// per row over the attention mask (positions beyond a row's mask are padding).
1629fn pool_batch(
1630    shape: &[i64],
1631    values: &[f32],
1632    masks: &[&[i64]],
1633    native_dim: usize,
1634) -> Result<Vec<Vec<f32>>, EmbedError> {
1635    let real = masks.len();
1636    if shape.len() == 2 && shape[1] == native_dim as i64 {
1637        let batch = shape[0] as usize;
1638        if batch >= real && values.len() == batch * native_dim {
1639            return Ok((0..real)
1640                .map(|row| normalize(values[row * native_dim..(row + 1) * native_dim].to_vec()))
1641                .collect());
1642        }
1643    }
1644    if shape.len() == 3 && shape[2] == native_dim as i64 && shape[0] > 0 && shape[1] > 0 {
1645        let (batch, tokens) = (shape[0] as usize, shape[1] as usize);
1646        if batch >= real && values.len() == batch * tokens * native_dim {
1647            let mut pooled_rows = Vec::with_capacity(real);
1648            for (row, mask) in masks.iter().enumerate() {
1649                let mut pooled = vec![0.0f32; native_dim];
1650                let mut count = 0.0f32;
1651                for token in 0..tokens {
1652                    if mask.get(token).copied().unwrap_or(0) == 0 {
1653                        continue;
1654                    }
1655                    count += 1.0;
1656                    let base = (row * tokens + token) * native_dim;
1657                    for (dim, value) in pooled.iter_mut().enumerate() {
1658                        *value += values[base + dim];
1659                    }
1660                }
1661                if count == 0.0 {
1662                    return Err(EmbedError::Output(
1663                        "attention mask contains no tokens".into(),
1664                    ));
1665                }
1666                for value in &mut pooled {
1667                    *value /= count;
1668                }
1669                pooled_rows.push(normalize(pooled));
1670            }
1671            return Ok(pooled_rows);
1672        }
1673    }
1674    Err(EmbedError::Output(format!(
1675        "expected [B,{native_dim}] or [B,tokens,{native_dim}] with B >= {real}, got shape {shape:?}"
1676    )))
1677}
1678
1679fn probe_output_ok(native: &[f32], native_dim: usize) -> Result<(), String> {
1680    if native.len() != native_dim {
1681        return Err(format!(
1682            "expected {native_dim} values, got {}",
1683            native.len()
1684        ));
1685    }
1686    if native.iter().any(|value| !value.is_finite()) {
1687        return Err("non-finite values".into());
1688    }
1689    if native.iter().map(|value| value * value).sum::<f32>() <= f32::EPSILON {
1690        return Err("all-zero output".into());
1691    }
1692    Ok(())
1693}
1694
1695/// Deterministic long parity document: `PARITY_LONG_SECTIONS` numbered
1696/// sections, several hundred tokens.
1697fn parity_long_document() -> String {
1698    parity_sections(PARITY_LONG_SECTIONS)
1699}
1700
1701/// The first `count` numbered parity sections, space-separated.
1702fn parity_sections(count: usize) -> String {
1703    (1..=count)
1704        .map(|n| {
1705            format!(
1706                "Section {n}: the nightly billing job reconciles every invoice against the \
1707                 ledger, flags mismatched totals for account {}, and sends the exceptions to \
1708                 the finance team for review before payment run {n}.",
1709                1000 + n * 7
1710            )
1711        })
1712        .collect::<Vec<_>>()
1713        .join(" ")
1714}
1715
1716/// Lowest cosine over the compared pairs and the pair it came from.
1717#[derive(Debug, Clone, Copy, PartialEq)]
1718struct ParityLowest {
1719    index: usize,
1720    cosine: f32,
1721}
1722
1723/// Why two vector sets are not parity-equal.
1724#[derive(Debug, Clone, PartialEq)]
1725enum ParityMiss {
1726    /// A pair could not be compared: wrong count or size, non-finite or zero
1727    /// output on either side.
1728    Invalid { index: usize, reason: String },
1729    /// The lowest pair is under the floor.
1730    Below(ParityLowest),
1731}
1732
1733fn cosine(a: &[f32], b: &[f32]) -> f32 {
1734    let (mut dot, mut na, mut nb) = (0.0f64, 0.0f64, 0.0f64);
1735    for (x, y) in a.iter().zip(b) {
1736        let (x, y) = (f64::from(*x), f64::from(*y));
1737        dot += x * y;
1738        na += x * x;
1739        nb += y * y;
1740    }
1741    (dot / (na.sqrt() * nb.sqrt())) as f32
1742}
1743
1744/// Pairwise cosine parity of `candidate` against `reference` (same prompts,
1745/// same order, native dimension). Every vector must have `native_dim` finite
1746/// values and a non-zero norm; the lowest pair must be at least `floor`.
1747/// Returns the lowest pair when parity holds.
1748fn compare_parity(
1749    reference: &[Vec<f32>],
1750    candidate: &[Vec<f32>],
1751    native_dim: usize,
1752    floor: f32,
1753) -> Result<ParityLowest, ParityMiss> {
1754    if reference.is_empty() || reference.len() != candidate.len() {
1755        return Err(ParityMiss::Invalid {
1756            index: 0,
1757            reason: format!(
1758                "expected {} vectors to compare, got {}",
1759                reference.len(),
1760                candidate.len()
1761            ),
1762        });
1763    }
1764    let mut lowest = ParityLowest {
1765        index: 0,
1766        cosine: f32::INFINITY,
1767    };
1768    for (index, (r, c)) in reference.iter().zip(candidate).enumerate() {
1769        probe_output_ok(r, native_dim).map_err(|reason| ParityMiss::Invalid {
1770            index,
1771            reason: format!("cpu reference: {reason}"),
1772        })?;
1773        probe_output_ok(c, native_dim).map_err(|reason| ParityMiss::Invalid {
1774            index,
1775            reason: format!("accelerator output: {reason}"),
1776        })?;
1777        let value = cosine(r, c);
1778        if value < lowest.cosine {
1779            lowest = ParityLowest {
1780                index,
1781                cosine: value,
1782            };
1783        }
1784    }
1785    if lowest.cosine >= floor {
1786        Ok(lowest)
1787    } else {
1788        Err(ParityMiss::Below(lowest))
1789    }
1790}
1791
1792/// `(lowest cosine if any, text)` for a parity miss. `tokens` holds the token
1793/// count of each parity prompt; pair `i` is prompt `i % tokens.len()`, and pairs
1794/// past the first `tokens.len()` are the batched run.
1795fn parity_miss_detail(miss: &ParityMiss, tokens: &[usize], floor: f32) -> (Option<f32>, String) {
1796    let at = |index: usize| {
1797        let batched = if index >= tokens.len() {
1798            " (batched)"
1799        } else {
1800            ""
1801        };
1802        format!("~{} tokens{batched}", tokens[index % tokens.len()])
1803    };
1804    match miss {
1805        ParityMiss::Below(lowest) => (
1806            Some(lowest.cosine),
1807            format!(
1808                "cosine {:.3} at {}, floor {floor}",
1809                lowest.cosine,
1810                at(lowest.index)
1811            ),
1812        ),
1813        ParityMiss::Invalid { index, reason } => (None, format!("{reason} at {}", at(*index))),
1814    }
1815}
1816
1817struct PipelineParts<'a> {
1818    model_id: &'a str,
1819    native_dim: usize,
1820    truncate_dim: usize,
1821    max_tokens: usize,
1822    scrubs_sentinels: bool,
1823    files: &'a [(&'a str, String)],
1824}
1825
1826/// Pure fingerprint over the pipeline description; see [`Embedder::pipeline_id`].
1827fn pipeline_fingerprint(parts: &PipelineParts<'_>) -> String {
1828    let mut text = String::new();
1829    let mut line = |key: &str, value: &str| {
1830        text.push_str(key);
1831        text.push('=');
1832        text.push_str(&value.len().to_string());
1833        text.push(':');
1834        text.push_str(value);
1835        text.push('\n');
1836    };
1837    line("schema", PIPELINE_SCHEMA);
1838    line("model_id", parts.model_id);
1839    line("native_dim", &parts.native_dim.to_string());
1840    line("truncate_dim", &parts.truncate_dim.to_string());
1841    line("max_tokens", &parts.max_tokens.to_string());
1842    line("document_prompt", DOCUMENT_PROMPT_PREFIX);
1843    line("query_prompt", QUERY_PROMPT_PREFIX);
1844    line("pooling", POOLING_DESCRIPTION);
1845    line("normalization", NORMALIZATION_DESCRIPTION);
1846    line("scrubs_sentinels", &parts.scrubs_sentinels.to_string());
1847    for (name, hash) in parts.files {
1848        line(&format!("file:{name}"), hash);
1849    }
1850    // First 16 bytes of the SHA-256 = first 32 lowercase hex characters.
1851    let digest = rightkit_fs::digest::sha256_bytes(text.as_bytes());
1852    format!("rkpipe1-{}", &digest[..32])
1853}
1854
1855fn sanitize(text: &str) -> String {
1856    text.chars()
1857        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1858        .take(48)
1859        .collect()
1860}
1861
1862/// SHA-256 hex of a file, or `absent` when it does not exist. Other read
1863/// errors fail the fingerprint rather than hashing a partial file.
1864pub(crate) fn hash_file(path: &Path) -> Result<String, String> {
1865    let mut file = match std::fs::File::open(path) {
1866        Ok(file) => file,
1867        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok("absent".into()),
1868        Err(error) => return Err(format!("{}: {error}", path.display())),
1869    };
1870    rightkit_fs::digest::sha256_reader(&mut file).map_err(|e| format!("{}: {e}", path.display()))
1871}
1872
1873/// Reads only the top-level `model_type`; nested sub-configs (audio, text,
1874/// vision) carry their own types and are ignored. A missing config.json is the
1875/// original EmbeddingGemma bundle.
1876fn bundle_is_gemma2(model_dir: &std::path::Path) -> Result<bool, EmbedError> {
1877    let path = model_dir.join("config.json");
1878    let Ok(bytes) = std::fs::read(&path) else {
1879        return Ok(false);
1880    };
1881    let config: serde_json::Value = serde_json::from_slice(&bytes)
1882        .map_err(|e| EmbedError::InvalidConfig(format!("{}: {e}", path.display())))?;
1883    Ok(config["model_type"] == "embedding_gemma2")
1884}
1885
1886/// The files a Core ML bundle in `dir` must hold, or `None` when `dir` is not one. A Core ML
1887/// bundle is macOS only; elsewhere its presence is refused with a clear message.
1888#[cfg(target_os = "macos")]
1889fn coreml_bundle_files(dir: &Path) -> Result<Option<&'static [&'static str]>, EmbedError> {
1890    let files: &'static [&'static str] = &coreml::ARTIFACTS;
1891    Ok(coreml::is_bundle(dir).then_some(files))
1892}
1893
1894#[cfg(not(target_os = "macos"))]
1895fn coreml_bundle_files(dir: &Path) -> Result<Option<&'static [&'static str]>, EmbedError> {
1896    if dir.join("EmbeddingGemma2Text.mlpackage").is_dir() {
1897        return Err(EmbedError::InvalidConfig(
1898            "a Core ML bundle (EmbeddingGemma2Text.mlpackage) runs on macOS only".into(),
1899        ));
1900    }
1901    Ok(None)
1902}
1903
1904fn validate_config(config: &EmbedderConfig) -> Result<(), EmbedError> {
1905    if !config.model_dir.is_dir() {
1906        return Err(EmbedError::MissingModelDirectory(config.model_dir.clone()));
1907    }
1908    if config.truncate_dim == 0 || config.truncate_dim > EMBEDDINGGEMMA_NATIVE_DIM {
1909        return Err(EmbedError::InvalidConfig(format!(
1910            "truncate dimension must be 1..={EMBEDDINGGEMMA_NATIVE_DIM}"
1911        )));
1912    }
1913    if config.max_tokens == 0 || config.max_tokens > MAX_SEQUENCE_TOKENS {
1914        return Err(EmbedError::InvalidConfig(format!(
1915            "max tokens must be 1..={MAX_SEQUENCE_TOKENS}"
1916        )));
1917    }
1918    if config.max_batch == 0 {
1919        return Err(EmbedError::InvalidConfig("max batch must be >= 1".into()));
1920    }
1921    if config.intra_threads == 0 {
1922        return Err(EmbedError::InvalidConfig(
1923            "intra threads must be >= 1".into(),
1924        ));
1925    }
1926    let coreml_files = coreml_bundle_files(&config.model_dir)?;
1927    let coreml_bundle = coreml_files.is_some();
1928    let required: &[&str] = if let Some(files) = coreml_files {
1929        files
1930    } else if e40::is_bundle(&config.model_dir) {
1931        &e40::ARTIFACTS
1932    } else {
1933        &["model_q4.onnx", "model_q4.onnx_data", "tokenizer.json"]
1934    };
1935    for name in required {
1936        let path = config.model_dir.join(name);
1937        if !path.is_file() {
1938            return Err(EmbedError::MissingArtifact(path));
1939        }
1940    }
1941    // A Core ML bundle never uses ONNX Runtime, so it needs no runtime path.
1942    if !coreml_bundle && !config.runtime_path.is_file() {
1943        return Err(EmbedError::MissingArtifact(config.runtime_path.clone()));
1944    }
1945    Ok(())
1946}
1947
1948fn normalize(mut values: Vec<f32>) -> Vec<f32> {
1949    let norm = values.iter().map(|value| value * value).sum::<f32>().sqrt();
1950    if norm > 0.0 {
1951        for value in &mut values {
1952            *value /= norm;
1953        }
1954    }
1955    values
1956}
1957
1958pub fn truncate_and_normalize(native: &[f32], dimension: usize) -> Vec<f32> {
1959    let mut values = native[..native.len().min(dimension)].to_vec();
1960    values.resize(dimension, 0.0);
1961    normalize(values)
1962}
1963
1964#[cfg(test)]
1965mod tests {
1966    use super::*;
1967
1968    fn row(ids: &[i64]) -> TokenRow {
1969        TokenRow {
1970            ids: ids.to_vec(),
1971            mask: vec![1; ids.len()],
1972        }
1973    }
1974
1975    /// Deterministic per-token state independent of padding, like a model whose
1976    /// masked positions cannot influence valid ones.
1977    fn state(ids: &[i64], dim: usize) -> Vec<f32> {
1978        ids.iter()
1979            .enumerate()
1980            .flat_map(|(pos, id)| {
1981                (0..dim)
1982                    .map(move |d| ((*id as f32) * 0.37 + pos as f32 * 0.11 + d as f32 * 0.05).sin())
1983            })
1984            .collect()
1985    }
1986
1987    #[test]
1988    fn pad_batch_pads_right_with_zero_mask_and_repeats_last_row() {
1989        let a = row(&[5, 6, 7]);
1990        let b = row(&[9]);
1991        let padded = pad_batch(&[&a, &b], 0, 4, 3);
1992        assert_eq!((padded.rows, padded.len), (3, 4));
1993        assert_eq!(padded.ids, vec![5, 6, 7, 0, 9, 0, 0, 0, 9, 0, 0, 0]);
1994        assert_eq!(padded.mask, vec![1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
1995    }
1996
1997    #[test]
1998    fn pad_batch_uses_the_pad_id_for_masked_positions() {
1999        let a = row(&[5]);
2000        let padded = pad_batch(&[&a], 3, 3, 1);
2001        assert_eq!(padded.ids, vec![5, 3, 3]);
2002        assert_eq!(padded.mask, vec![1, 0, 0]);
2003    }
2004
2005    #[test]
2006    fn padded_mean_pool_matches_unpadded_rows() {
2007        let dim = 8;
2008        let rows_ids: [&[i64]; 3] = [&[3, 4, 5, 6, 7], &[10, 11], &[20, 21, 22]];
2009        let rows: Vec<TokenRow> = rows_ids.iter().map(|ids| row(ids)).collect();
2010        let refs: Vec<&TokenRow> = rows.iter().collect();
2011        let padded = pad_batch(&refs, 0, 5, 3);
2012        // Model output for the padded batch: valid positions carry real state,
2013        // masked positions carry garbage that must be ignored.
2014        let mut values = vec![9999.0f32; padded.rows * padded.len * dim];
2015        for (r, ids) in rows_ids.iter().enumerate() {
2016            let s = state(ids, dim);
2017            let base = r * padded.len * dim;
2018            values[base..base + s.len()].copy_from_slice(&s);
2019        }
2020        let masks: Vec<&[i64]> = rows.iter().map(|r| r.mask.as_slice()).collect();
2021        let batched =
2022            pool_batch(&[3, padded.len as i64, dim as i64], &values, &masks, dim).unwrap();
2023        for (r, ids) in rows_ids.iter().enumerate() {
2024            let single = pool_batch(
2025                &[1, ids.len() as i64, dim as i64],
2026                &state(ids, dim),
2027                &[rows[r].mask.as_slice()],
2028                dim,
2029            )
2030            .unwrap();
2031            let diff = batched[r]
2032                .iter()
2033                .zip(&single[0])
2034                .map(|(a, b)| (a - b).abs())
2035                .fold(0.0f32, f32::max);
2036            assert!(diff <= 1e-6, "row {r} differs by {diff}");
2037            let norm: f32 = batched[r].iter().map(|v| v * v).sum::<f32>().sqrt();
2038            assert!((norm - 1.0).abs() <= 1e-5);
2039        }
2040    }
2041
2042    #[test]
2043    fn pooled_output_rows_are_normalized_and_extra_rows_dropped() {
2044        let dim = 4;
2045        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];
2046        let m = [1i64];
2047        let out = pool_batch(&[3, 4], &values, &[&m, &m], dim).unwrap();
2048        assert_eq!(out.len(), 2);
2049        assert_eq!(out[0], vec![0.6, 0.8, 0.0, 0.0]);
2050        assert_eq!(out[1], vec![0.0, 0.0, 1.0, 0.0]);
2051    }
2052
2053    #[test]
2054    fn pool_rejects_bad_shapes_and_empty_masks() {
2055        let m = [0i64, 0];
2056        assert!(pool_batch(&[1, 2, 2], &[1.0; 4], &[&m], 2).is_err());
2057        assert!(pool_batch(&[1, 3], &[1.0; 3], &[&[1]], 2).is_err());
2058        assert!(pool_batch(&[1, 2], &[1.0; 2], &[&[1], &[1]], 2).is_err());
2059    }
2060
2061    #[test]
2062    fn chunks_respect_row_cap_token_budget_and_cover_everything() {
2063        let lengths = [1, 2, 3, 4, 5, 6, 7];
2064        assert_eq!(plan_chunks(&lengths, 3, 1000), vec![0..3, 3..6, 6..7]);
2065        // budget: 2 rows * 3 = 6 ok, 3 rows * 4 = 12 > 10 stops the chunk.
2066        assert_eq!(plan_chunks(&[1, 3, 4, 4], 8, 10), vec![0..2, 2..4]);
2067        // a lone oversized row is still allowed
2068        assert_eq!(plan_chunks(&[50, 60], 8, 10), vec![0..1, 1..2]);
2069        assert!(plan_chunks(&[], 4, 10).is_empty());
2070    }
2071
2072    #[test]
2073    fn length_order_is_stable_ascending() {
2074        assert_eq!(length_order(&[5, 2, 5, 1, 2]), vec![3, 1, 4, 0, 2]);
2075    }
2076
2077    #[test]
2078    fn accelerators_use_bucketed_shapes_cpu_uses_exact() {
2079        assert_eq!(padded_shape(3, 70, false), (3, 70));
2080        assert_eq!(padded_shape(3, 70, true), (4, 128));
2081        assert_eq!(padded_shape(1, 64, true), (1, 64));
2082        assert_eq!(padded_shape(16, 2048, true), (16, 2048));
2083    }
2084
2085    #[test]
2086    fn probe_rejects_zero_nonfinite_and_wrong_size() {
2087        assert!(probe_output_ok(&[0.0; 4], 4).is_err());
2088        assert!(probe_output_ok(&[f32::NAN, 1.0, 0.0, 0.0], 4).is_err());
2089        assert!(probe_output_ok(&[1.0; 3], 4).is_err());
2090        assert!(probe_output_ok(&[0.5, 0.5, 0.5, 0.5], 4).is_ok());
2091    }
2092
2093    #[test]
2094    fn parity_floor_is_pinned() {
2095        assert_eq!(ACCELERATOR_PARITY_MIN_COSINE, 0.995);
2096    }
2097
2098    #[test]
2099    fn identical_vectors_pass_parity_and_report_the_lowest_pair() {
2100        let reference = vec![vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]];
2101        let lowest = compare_parity(&reference, &reference.clone(), 3, 0.995).unwrap();
2102        assert!((lowest.cosine - 1.0).abs() <= 1e-6);
2103        // A pair exactly at the floor passes ("at or above").
2104        let candidate = vec![vec![1.0, 0.0, 0.0], vec![0.0, 0.6, 0.8]];
2105        let at_floor = cosine(&reference[1], &candidate[1]);
2106        let lowest = compare_parity(&reference, &candidate, 3, at_floor).unwrap();
2107        assert_eq!((lowest.index, lowest.cosine), (1, at_floor));
2108    }
2109
2110    #[test]
2111    fn a_low_cosine_pair_fails_and_reports_value_and_index() {
2112        // Unit vector at cosine 0.58 to the reference `[0, 1]`.
2113        let wrong = vec![(1.0f32 - 0.58 * 0.58).sqrt(), 0.58];
2114        let reference = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]];
2115        let candidate = vec![vec![1.0, 0.0], wrong.clone(), vec![1.0, 0.0]];
2116        match compare_parity(&reference, &candidate, 2, 0.995) {
2117            Err(ParityMiss::Below(lowest)) => {
2118                assert_eq!(lowest.index, 1);
2119                assert!((lowest.cosine - 0.58).abs() <= 1e-6, "{lowest:?}");
2120            }
2121            other => panic!("expected a Below miss, got {other:?}"),
2122        }
2123        let single = compare_parity(&[vec![0.0, 1.0]], &[wrong], 2, 0.995);
2124        match single {
2125            Err(ParityMiss::Below(lowest)) => assert!((lowest.cosine - 0.58).abs() <= 1e-6),
2126            other => panic!("expected a Below miss, got {other:?}"),
2127        }
2128    }
2129
2130    #[test]
2131    fn nonfinite_zero_or_wrongly_sized_vectors_fail_parity() {
2132        let good = vec![vec![1.0, 0.0]];
2133        for bad in [
2134            vec![f32::NAN, 1.0],
2135            vec![f32::INFINITY, 0.0],
2136            vec![0.0, 0.0],
2137            vec![1.0, 0.0, 0.0],
2138        ] {
2139            assert!(
2140                matches!(
2141                    compare_parity(&good, std::slice::from_ref(&bad), 2, 0.995),
2142                    Err(ParityMiss::Invalid { index: 0, .. })
2143                ),
2144                "candidate {bad:?}"
2145            );
2146            assert!(
2147                matches!(
2148                    compare_parity(std::slice::from_ref(&bad), &good, 2, 0.995),
2149                    Err(ParityMiss::Invalid { index: 0, .. })
2150                ),
2151                "reference {bad:?}"
2152            );
2153        }
2154    }
2155
2156    #[test]
2157    fn mismatched_or_empty_parity_inputs_fail() {
2158        let one = vec![vec![1.0, 0.0]];
2159        let two = vec![vec![1.0, 0.0], vec![1.0, 0.0]];
2160        assert!(matches!(
2161            compare_parity(&one, &two, 2, 0.995),
2162            Err(ParityMiss::Invalid { .. })
2163        ));
2164        assert!(matches!(
2165            compare_parity(&two, &one, 2, 0.995),
2166            Err(ParityMiss::Invalid { .. })
2167        ));
2168        assert!(matches!(
2169            compare_parity(&[], &[], 2, 0.995),
2170            Err(ParityMiss::Invalid { .. })
2171        ));
2172    }
2173
2174    #[test]
2175    fn parity_miss_detail_names_cosine_length_and_floor() {
2176        let tokens = [20, 100, 600];
2177        let miss = ParityMiss::Below(ParityLowest {
2178            index: 0,
2179            cosine: 0.5791,
2180        });
2181        assert_eq!(
2182            parity_miss_detail(&miss, &tokens, 0.995),
2183            (
2184                Some(0.5791),
2185                "cosine 0.579 at ~20 tokens, floor 0.995".to_owned()
2186            )
2187        );
2188        let batched = ParityMiss::Below(ParityLowest {
2189            index: 5,
2190            cosine: 0.71,
2191        });
2192        assert_eq!(
2193            parity_miss_detail(&batched, &tokens, 0.995).1,
2194            "cosine 0.710 at ~600 tokens (batched), floor 0.995"
2195        );
2196        let invalid = ParityMiss::Invalid {
2197            index: 1,
2198            reason: "accelerator output: non-finite values".into(),
2199        };
2200        assert_eq!(
2201            parity_miss_detail(&invalid, &tokens, 0.995),
2202            (
2203                None,
2204                "accelerator output: non-finite values at ~100 tokens".to_owned()
2205            )
2206        );
2207    }
2208
2209    #[test]
2210    fn parity_long_document_is_deterministic_and_long() {
2211        let text = parity_long_document();
2212        assert_eq!(text, parity_long_document());
2213        assert!(text.split_whitespace().count() >= 400, "{}", text.len());
2214        assert!(PARITY_DOCUMENT.split_whitespace().count() >= 50);
2215    }
2216
2217    #[test]
2218    fn parity_error_names_provider_cosine_and_floor() {
2219        let error = EmbedError::AcceleratorParity {
2220            provider: "directml",
2221            lowest_cosine: Some(0.579),
2222            floor: ACCELERATOR_PARITY_MIN_COSINE,
2223            detail: "cosine 0.579 at ~20 tokens, floor 0.995".into(),
2224        };
2225        assert_eq!(
2226            error.to_string(),
2227            "directml failed the vector parity check against cpu: cosine 0.579 at ~20 tokens, floor 0.995"
2228        );
2229    }
2230
2231    fn parts<'a>(files: &'a [(&'a str, String)], truncate: usize) -> PipelineParts<'a> {
2232        PipelineParts {
2233            model_id: EMBEDDINGGEMMA_MODEL_ID,
2234            native_dim: 768,
2235            truncate_dim: truncate,
2236            max_tokens: 2048,
2237            scrubs_sentinels: false,
2238            files,
2239        }
2240    }
2241
2242    #[test]
2243    fn pipeline_fingerprint_is_stable_and_sensitive() {
2244        let files = [("model_q4.onnx", "aa".to_owned())];
2245        let base = pipeline_fingerprint(&parts(&files, 256));
2246        assert_eq!(base, pipeline_fingerprint(&parts(&files, 256)));
2247        assert!(base.starts_with("rkpipe1-") && base.len() == "rkpipe1-".len() + 32);
2248        assert_ne!(base, pipeline_fingerprint(&parts(&files, 128)));
2249        let changed = [("model_q4.onnx", "ab".to_owned())];
2250        assert_ne!(base, pipeline_fingerprint(&parts(&changed, 256)));
2251        let mut other = parts(&files, 256);
2252        other.scrubs_sentinels = true;
2253        assert_ne!(base, pipeline_fingerprint(&other));
2254        other = parts(&files, 256);
2255        other.max_tokens = 512;
2256        assert_ne!(base, pipeline_fingerprint(&other));
2257    }
2258
2259    #[test]
2260    fn hash_file_reports_absent_and_content() {
2261        let dir = tempfile::tempdir().unwrap();
2262        assert_eq!(hash_file(&dir.path().join("none")).unwrap(), "absent");
2263        let path = dir.path().join("f");
2264        std::fs::write(&path, b"abc").unwrap();
2265        assert_eq!(
2266            hash_file(&path).unwrap(),
2267            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
2268        );
2269    }
2270
2271    #[test]
2272    fn config_defaults_and_builders() {
2273        let c = EmbedderConfig::new("m", "r");
2274        assert_eq!((c.max_batch, c.intra_threads), (16, 1));
2275        assert_eq!(c.provider, ExecutionProvider::Cpu);
2276        let c = c
2277            .with_max_batch(4)
2278            .with_intra_threads(3)
2279            .with_provider(ExecutionProvider::Auto);
2280        assert_eq!((c.max_batch, c.intra_threads), (4, 3));
2281        assert_eq!(c.provider, ExecutionProvider::Auto);
2282        let legacy =
2283            EmbedderConfig::new("m", "r").with_provider(rightkit_onnx::ExecutionProvider::DirectMl);
2284        assert_eq!(legacy.provider, ExecutionProvider::DirectMl);
2285        assert!(EmbedderConfig::new("m", "r").fallback_model_dir.is_none());
2286        let with_fallback = EmbedderConfig::new("m", "r").with_fallback_model_dir("q4");
2287        assert_eq!(with_fallback.fallback_model_dir, Some(PathBuf::from("q4")));
2288        assert!(EmbedderConfig::new("m", "r").coreml_cache_dir.is_none());
2289        let with_cache = EmbedderConfig::new("m", "r").with_coreml_cache_dir("cache");
2290        assert_eq!(with_cache.coreml_cache_dir, Some(PathBuf::from("cache")));
2291        assert!(EmbedderConfig::new("m", "r").coreml_units.is_none());
2292        let forced =
2293            EmbedderConfig::new("m", "r").with_coreml_units(crate::CoreMlComputeUnit::CpuAndGpu);
2294        assert_eq!(
2295            forced.coreml_units,
2296            Some(CoreMlUnits::uniform(crate::CoreMlComputeUnit::CpuAndGpu))
2297        );
2298        let custom = EmbedderConfig::new("m", "r")
2299            .with_coreml_units("256=cpu_only".parse::<CoreMlUnits>().unwrap());
2300        assert_eq!(
2301            custom.coreml_units.unwrap().unit_for(256),
2302            Some(crate::CoreMlComputeUnit::CpuOnly)
2303        );
2304    }
2305
2306    #[test]
2307    fn model_ids_are_distinct_so_pipeline_ids_differ_per_model() {
2308        let ids = [
2309            EMBEDDINGGEMMA_MODEL_ID,
2310            EMBEDDINGGEMMA2_MODEL_ID,
2311            EMBEDDINGGEMMA2_E40_MODEL_ID,
2312            EMBEDDINGGEMMA2_COREML_MODEL_ID,
2313        ];
2314        for (i, a) in ids.iter().enumerate() {
2315            for b in &ids[i + 1..] {
2316                assert_ne!(a, b);
2317            }
2318        }
2319        let files = [("model_32.onnx", "aa".to_owned())];
2320        let parts = |model_id| PipelineParts {
2321            model_id,
2322            native_dim: 768,
2323            truncate_dim: 256,
2324            max_tokens: 512,
2325            scrubs_sentinels: true,
2326            files: &files,
2327        };
2328        assert_ne!(
2329            pipeline_fingerprint(&parts(EMBEDDINGGEMMA2_E40_MODEL_ID)),
2330            pipeline_fingerprint(&parts(EMBEDDINGGEMMA2_MODEL_ID))
2331        );
2332    }
2333
2334    #[test]
2335    fn parity_sections_are_prefixes_of_the_long_document() {
2336        assert!(parity_long_document().starts_with(&parity_sections(4)));
2337        assert!(parity_sections(4).starts_with(&parity_sections(1)));
2338        assert!(
2339            parity_sections(1).split_whitespace().count()
2340                < parity_sections(4).split_whitespace().count()
2341        );
2342    }
2343}