Skip to main content

ratel_ai_core/
embedding_config.rs

1//! Which embedding model backs a catalog's semantic/hybrid retrieval.
2//!
3//! The model is chosen per catalog and declared once, then used for both
4//! document and query embedding so the two sides can never land in different
5//! vector spaces. Four sources: the built-in default (`bge-small`), any
6//! BERT-family HuggingFace repo or on-disk directory (loaded in-process via
7//! Candle), and an OpenAI-compatible HTTP endpoint (any model, incl. Ollama).
8//!
9//! [`EmbeddingModel::resolve`] turns the cross-SDK [`EmbeddingSpec`] DTO into a
10//! validated model. The source is named explicitly: a bare string is a **local
11//! directory path**, and every other source is a keyed object
12//! (`{huggingface}` / `{local}` / `{ollama}` / `{url, model}`), symmetric across
13//! the board. Resolution/validation **lives here** (in the core) so both SDKs
14//! share one implementation instead of two that could drift. See ADR-0012.
15
16use std::collections::HashMap;
17use std::fs::File;
18use std::io::Read;
19use std::path::{Path, PathBuf};
20use std::sync::{Mutex, OnceLock};
21use std::time::SystemTime;
22
23use sha2::{Digest, Sha256};
24
25use crate::embedding::EmbedderError;
26
27/// The built-in default: bge-small, pinned to a commit so embeddings are
28/// reproducible. These are the canonical identity of the zero-config model;
29/// `embedding.rs` loads against them.
30pub(crate) const DEFAULT_REPO: &str = "BAAI/bge-small-en-v1.5";
31pub(crate) const DEFAULT_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
32/// bge asymmetric-retrieval query prefix; only the query side gets it.
33pub(crate) const DEFAULT_QUERY_INSTRUCTION: &str =
34    "Represent this sentence for searching relevant passages: ";
35
36/// Default Ollama OpenAI-compatible embeddings route. The `{ollama: model}`
37/// shortcut expands to this; a non-default host uses the full `{url, model}` form.
38pub(crate) const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434/v1/embeddings";
39
40/// How a BERT model's per-token outputs are collapsed into one sentence vector.
41/// A model is *trained* with one mode — using the other silently degrades ranking
42/// — so it is auto-detected from the repo's `1_Pooling/config.json`, with this as
43/// an explicit override.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Pooling {
46    /// The `[CLS]` (first) token's vector. bge is CLS-pooled.
47    Cls,
48    /// Masked average of all token vectors. e5/gte/MiniLM/mpnet are mean-pooled.
49    Mean,
50}
51
52impl Pooling {
53    pub(crate) fn as_str(self) -> &'static str {
54        match self {
55            Pooling::Cls => "cls",
56            Pooling::Mean => "mean",
57        }
58    }
59}
60
61impl std::str::FromStr for Pooling {
62    type Err = EmbedderError;
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        match s.to_ascii_lowercase().as_str() {
65            "cls" => Ok(Pooling::Cls),
66            "mean" => Ok(Pooling::Mean),
67            other => Err(cfg(format!(
68                "unknown pooling '{other}'; expected 'cls' or 'mean'"
69            ))),
70        }
71    }
72}
73
74/// Model-identity suffix for the fingerprint / process-cache key. Pooling and the
75/// asymmetric prefixes change the produced vectors, so two configs differing only
76/// in these must not share a cached embedder or pass the drift check.
77pub(crate) fn fingerprint_suffix(
78    pooling: Option<Pooling>,
79    query_prefix: &str,
80    doc_prefix: &str,
81) -> String {
82    let mut s = String::new();
83    if let Some(p) = pooling {
84        push_fingerprint_field(&mut s, "pool", p.as_str());
85    }
86    if !query_prefix.is_empty() {
87        push_fingerprint_field(&mut s, "q", query_prefix);
88    }
89    if !doc_prefix.is_empty() {
90        push_fingerprint_field(&mut s, "d", doc_prefix);
91    }
92    s
93}
94
95pub(crate) fn huggingface_fingerprint(repo: &str, revision: &str) -> String {
96    fingerprint("hf", &[("repo", repo), ("revision", revision)])
97}
98
99/// Runtime / process-cache Local identity: the configured directory path.
100/// Pre-PR spelling — IntentGraph and dense-cache runtime stamps depend on it.
101pub(crate) fn local_fingerprint(path: &str) -> String {
102    fingerprint("local", &[("path", path)])
103}
104
105/// RAT1 artifact Local identity: content digest of the effective model inputs.
106pub(crate) fn local_content_fingerprint(content_id: &str) -> String {
107    fingerprint("local", &[("content", content_id)])
108}
109
110pub(crate) fn endpoint_fingerprint(url: &str, model: &str) -> String {
111    fingerprint("endpoint", &[("url", url), ("model", model)])
112}
113
114/// `(len, mtime)` stamp for Local artifact coherence checks / content-id memo.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub(crate) struct FileStamp {
117    pub(crate) len: u64,
118    pub(crate) modified: Option<SystemTime>,
119}
120
121struct ContentIdMemo {
122    stamps: Vec<FileStamp>,
123    content_id: String,
124}
125
126/// Files Candle opens for a local model. Shared by load and RAT1 artifact hashing
127/// so the identity set cannot drift from what is actually read.
128#[derive(Clone, Debug)]
129pub(crate) struct LocalModelFiles {
130    pub(crate) config: PathBuf,
131    pub(crate) tokenizer: PathBuf,
132    pub(crate) weights: PathBuf,
133    /// Present when `1_Pooling/config.json` exists — used for autodetection only;
134    /// never part of the content digest (effective pooling is the resolved mode).
135    pub(crate) pooling_config: Option<PathBuf>,
136}
137
138impl LocalModelFiles {
139    /// Ordered paths whose bytes define the Local artifact content identity.
140    pub(crate) fn content_hash_paths(&self) -> [&Path; 3] {
141        [&self.config, &self.tokenizer, &self.weights]
142    }
143}
144
145/// Resolve the Local model files Candle loads from `dir`.
146pub(crate) fn resolve_local_model_files(dir: &Path) -> Result<LocalModelFiles, EmbedderError> {
147    let name = dir.display().to_string();
148    let config = dir.join("config.json");
149    let tokenizer = dir.join("tokenizer.json");
150    let weights = [dir.join("model.safetensors"), dir.join("pytorch_model.bin")]
151        .into_iter()
152        .find(|p| p.exists())
153        .ok_or_else(|| EmbedderError::Load {
154            model: name.clone(),
155            source: format!("missing model.safetensors / pytorch_model.bin in {name}"),
156        })?;
157    for (p, f) in [(&config, "config.json"), (&tokenizer, "tokenizer.json")] {
158        if !p.exists() {
159            return Err(EmbedderError::Load {
160                model: name.clone(),
161                source: format!(
162                    "missing {f} in {name} — a fast tokenizer.json is required; run \
163                     tokenizer.save_pretrained() upstream, or serve the model via an endpoint"
164                ),
165            });
166        }
167    }
168    let pooling = dir.join("1_Pooling/config.json");
169    let pooling_config = pooling.exists().then_some(pooling);
170    Ok(LocalModelFiles {
171        config,
172        tokenizer,
173        weights,
174        pooling_config,
175    })
176}
177
178/// Stat the three content-hash paths at load time (metadata only — not a digest).
179pub(crate) fn stamp_local_hash_paths(
180    files: &LocalModelFiles,
181    model: &str,
182) -> Result<[FileStamp; 3], EmbedderError> {
183    let paths = files.content_hash_paths();
184    Ok([
185        stamp_file(paths[0], model)?,
186        stamp_file(paths[1], model)?,
187        stamp_file(paths[2], model)?,
188    ])
189}
190
191pub(crate) fn stamp_file(path: &Path, model: &str) -> Result<FileStamp, EmbedderError> {
192    let meta = std::fs::metadata(path).map_err(|e| EmbedderError::Load {
193        model: model.to_string(),
194        source: format!("stat {}: {e}", path.display()),
195    })?;
196    Ok(FileStamp {
197        len: meta.len(),
198        modified: meta.modified().ok(),
199    })
200}
201
202/// Content digest of Local model inputs that produce vectors, memoized against
203/// `(len, mtime)`. Used only for RAT1 artifact identity — never for runtime
204/// cache keys or ordinary dense load. Load-stamp coherence is
205/// [`LocalContentIdentity`].
206pub(crate) fn local_model_content_id_from_paths(
207    dir: &Path,
208    paths: &[&Path],
209) -> Result<String, EmbedderError> {
210    let name = dir.display().to_string();
211    let stamps: Vec<FileStamp> = paths
212        .iter()
213        .map(|p| stamp_file(p, &name))
214        .collect::<Result<_, _>>()?;
215
216    let memo_key = dir
217        .canonicalize()
218        .unwrap_or_else(|_| dir.to_path_buf())
219        .display()
220        .to_string();
221
222    {
223        let cache = content_id_memo();
224        let guard = cache.lock().expect("local content-id memo poisoned");
225        if let Some(entry) = guard.get(&memo_key)
226            && entry.stamps == stamps
227        {
228            return Ok(entry.content_id.clone());
229        }
230    }
231
232    let path_bufs: Vec<PathBuf> = paths.iter().map(|p| p.to_path_buf()).collect();
233    let content_id = hash_local_identity_files(&path_bufs, &name)?;
234    let mut guard = content_id_memo()
235        .lock()
236        .expect("local content-id memo poisoned");
237    guard.insert(
238        memo_key,
239        ContentIdMemo {
240            stamps,
241            content_id: content_id.clone(),
242        },
243    );
244    Ok(content_id)
245}
246
247pub(crate) struct LocalContentIdentity {
248    state: Mutex<LocalIdentityState>,
249}
250
251#[derive(Clone)]
252struct LocalIdentityState {
253    stamps: Vec<FileStamp>,
254    established: Option<String>,
255}
256
257impl LocalContentIdentity {
258    pub(crate) fn new(load_stamps: [FileStamp; 3]) -> Self {
259        Self {
260            state: Mutex::new(LocalIdentityState {
261                stamps: load_stamps.to_vec(),
262                established: None,
263            }),
264        }
265    }
266
267    /// Hash and restat with the mutex released; commit only if stamps are unchanged.
268    pub(crate) fn content_id(&self, dir: &Path, paths: &[&Path]) -> Result<String, EmbedderError> {
269        loop {
270            let now = stamp_paths(dir, paths)?;
271            let snapshot = {
272                let guard = self.state.lock().expect("local content identity poisoned");
273                guard.clone()
274            };
275
276            if now == snapshot.stamps {
277                if let Some(id) = &snapshot.established {
278                    return Ok(id.clone());
279                }
280            } else if snapshot.established.is_none() {
281                return Err(drift_before_established(dir));
282            }
283
284            let id = local_model_content_id_from_paths(dir, paths)?;
285            #[cfg(test)]
286            after_hash_hook::take_and_run();
287            let after = stamp_paths(dir, paths)?;
288            if after != now {
289                continue;
290            }
291
292            let mut guard = self.state.lock().expect("local content identity poisoned");
293            if guard.stamps != snapshot.stamps || guard.established != snapshot.established {
294                continue;
295            }
296            match &snapshot.established {
297                None => {
298                    guard.stamps = now;
299                    guard.established = Some(id.clone());
300                    return Ok(id);
301                }
302                Some(known) if known == &id => {
303                    guard.stamps = now;
304                    return Ok(known.clone());
305                }
306                Some(_) => return Err(contents_differ(dir)),
307            }
308        }
309    }
310}
311
312fn stamp_paths(dir: &Path, paths: &[&Path]) -> Result<Vec<FileStamp>, EmbedderError> {
313    let model = dir.display().to_string();
314    paths.iter().map(|p| stamp_file(p, &model)).collect()
315}
316
317fn drift_before_established(dir: &Path) -> EmbedderError {
318    let model = dir.display().to_string();
319    EmbedderError::Load {
320        model: model.clone(),
321        source: format!(
322            "local model files under {model} changed after the model was loaded and before this \
323             process established its artifact identity — the resident model may no longer match \
324             the files on disk; start a new process to build or warm an embedding artifact from \
325             the current files"
326        ),
327    }
328}
329
330fn contents_differ(dir: &Path) -> EmbedderError {
331    let model = dir.display().to_string();
332    EmbedderError::Load {
333        model: model.clone(),
334        source: format!(
335            "local model files under {model} changed since the model was loaded (contents differ, \
336             not just timestamps) — the resident model still holds the previous weights; start a \
337             new process to build or warm an embedding artifact from the current files"
338        ),
339    }
340}
341
342/// Compose the portable Local RAT1 artifact identity for a directory, using the
343/// same effective pooling resolution as load (`override` → pooling file → Mean).
344/// Test/helper surface — production Local artifact identity goes through
345/// [`crate::embedding::Embedder::artifact_identity`] on a loaded Candle embedder.
346#[cfg(test)]
347pub(crate) fn local_artifact_fingerprint(
348    dir: &Path,
349    pooling_override: Option<Pooling>,
350    query_prefix: &str,
351    doc_prefix: &str,
352) -> Result<String, EmbedderError> {
353    let files = resolve_local_model_files(dir)?;
354    let detected = pooling_override.or_else(|| {
355        files
356            .pooling_config
357            .as_ref()
358            .and_then(|p| detect_pooling_config_file(p))
359    });
360    // Same fallback as `resolve_pooling` in embedding.rs (Mean when undetected).
361    let pooling = detected.unwrap_or(Pooling::Mean);
362    let paths = files.content_hash_paths();
363    let content_id = local_model_content_id_from_paths(dir, &paths)?;
364    Ok(format!(
365        "{}{}",
366        local_content_fingerprint(&content_id),
367        fingerprint_suffix(Some(pooling), query_prefix, doc_prefix)
368    ))
369}
370
371/// Pure `1_Pooling/config.json` → [`Pooling`] mapping (shared with the loader).
372pub(crate) fn parse_pooling_config(bytes: &[u8]) -> Option<Pooling> {
373    #[derive(serde::Deserialize)]
374    struct PoolingConfig {
375        #[serde(default)]
376        pooling_mode_cls_token: bool,
377        #[serde(default)]
378        pooling_mode_mean_tokens: bool,
379    }
380    let c: PoolingConfig = serde_json::from_slice(bytes).ok()?;
381    if c.pooling_mode_cls_token {
382        Some(Pooling::Cls)
383    } else if c.pooling_mode_mean_tokens {
384        Some(Pooling::Mean)
385    } else {
386        None
387    }
388}
389
390#[cfg(test)]
391fn detect_pooling_config_file(path: &Path) -> Option<Pooling> {
392    let bytes = std::fs::read(path).ok()?;
393    parse_pooling_config(&bytes)
394}
395
396fn content_id_memo() -> &'static Mutex<HashMap<String, ContentIdMemo>> {
397    static CELL: OnceLock<Mutex<HashMap<String, ContentIdMemo>>> = OnceLock::new();
398    CELL.get_or_init(|| Mutex::new(HashMap::new()))
399}
400
401#[cfg(test)]
402mod content_hash_probe {
403    use std::cell::Cell;
404
405    thread_local! {
406        static CALLS: Cell<usize> = const { Cell::new(0) };
407    }
408
409    pub(super) fn note_call() {
410        CALLS.with(|c| c.set(c.get() + 1));
411    }
412
413    pub(crate) fn reset() {
414        CALLS.with(|c| c.set(0));
415    }
416
417    pub(crate) fn count() -> usize {
418        CALLS.with(Cell::get)
419    }
420}
421
422#[cfg(test)]
423mod after_hash_hook {
424    use std::cell::RefCell;
425
426    thread_local! {
427        static HOOK: RefCell<Option<Box<dyn FnOnce()>>> = RefCell::new(None);
428    }
429
430    pub(super) fn set(hook: impl FnOnce() + 'static) {
431        HOOK.with(|h| *h.borrow_mut() = Some(Box::new(hook)));
432    }
433
434    pub(super) fn take_and_run() {
435        if let Some(hook) = HOOK.with(|h| h.borrow_mut().take()) {
436            hook();
437        }
438    }
439}
440
441#[cfg(test)]
442pub(crate) fn test_reset_content_hash_calls() {
443    content_hash_probe::reset();
444}
445
446#[cfg(test)]
447pub(crate) fn test_content_hash_calls() -> usize {
448    content_hash_probe::count()
449}
450
451fn hash_local_identity_files(files: &[PathBuf], model: &str) -> Result<String, EmbedderError> {
452    #[cfg(test)]
453    content_hash_probe::note_call();
454    let mut hasher = Sha256::new();
455    for path in files {
456        // Length-prefix each file so concatenation cannot collide across a
457        // boundary (e.g. "ab"+"c" vs "a"+"bc").
458        let mut file = File::open(path).map_err(|e| EmbedderError::Load {
459            model: model.to_string(),
460            source: format!("open {}: {e}", path.display()),
461        })?;
462        let len = file
463            .metadata()
464            .map_err(|e| EmbedderError::Load {
465                model: model.to_string(),
466                source: format!("stat {}: {e}", path.display()),
467            })?
468            .len();
469        hasher.update(len.to_le_bytes());
470        let mut buf = [0u8; 64 * 1024];
471        loop {
472            let n = file.read(&mut buf).map_err(|e| EmbedderError::Load {
473                model: model.to_string(),
474                source: format!("read {}: {e}", path.display()),
475            })?;
476            if n == 0 {
477                break;
478            }
479            hasher.update(&buf[..n]);
480        }
481    }
482    Ok(hex_lower(hasher.finalize()))
483}
484
485fn hex_lower(bytes: impl AsRef<[u8]>) -> String {
486    const HEX: &[u8; 16] = b"0123456789abcdef";
487    let bytes = bytes.as_ref();
488    let mut out = String::with_capacity(bytes.len() * 2);
489    for &b in bytes {
490        out.push(HEX[(b >> 4) as usize] as char);
491        out.push(HEX[(b & 0xf) as usize] as char);
492    }
493    out
494}
495
496/// The embedding model backing a catalog's semantic/hybrid engines.
497#[derive(Debug, Clone, PartialEq)]
498pub enum EmbeddingModel {
499    /// Built-in `bge-small-en-v1.5`, pinned. The zero-config default.
500    Default,
501    /// A BERT-family HuggingFace repo, loaded in-process via Candle. `revision`
502    /// defaults to `main`; `pooling` is auto-detected when `None`. `download`
503    /// (default `false`) must be opted into for Ratel to fetch it — otherwise it
504    /// must already be in the local cache (Ratel auto-downloads only the default).
505    HuggingFace {
506        /// HuggingFace repo id (e.g. `intfloat/e5-small-v2`).
507        repo: String,
508        /// Git revision to pin; `None` → `main`.
509        revision: Option<String>,
510        /// Query-side prefix for asymmetric models.
511        query_prefix: Option<String>,
512        /// Document-side prefix for asymmetric models.
513        doc_prefix: Option<String>,
514        /// Pooling override; `None` auto-detects.
515        pooling: Option<Pooling>,
516        /// Opt in to downloading if not already cached.
517        download: bool,
518    },
519    /// A BERT-family model directory on disk (`config.json` / `tokenizer.json` /
520    /// `model.safetensors`), loaded in-process via Candle.
521    Local {
522        /// Path to the model directory.
523        path: PathBuf,
524        /// Query-side prefix for asymmetric models.
525        query_prefix: Option<String>,
526        /// Document-side prefix for asymmetric models.
527        doc_prefix: Option<String>,
528        /// Pooling override; `None` auto-detects.
529        pooling: Option<Pooling>,
530    },
531    /// An OpenAI-compatible `/embeddings` HTTP endpoint (OpenAI, Ollama, TEI,
532    /// vLLM…). `api_key_env` names the env var holding the key (read at call time).
533    /// Pooling lives server-side, so there is no `pooling` here.
534    Endpoint {
535        /// Full endpoint URL.
536        url: String,
537        /// Model name sent in the request body.
538        model: String,
539        /// Env var holding the bearer key; `None` for no auth.
540        api_key_env: Option<String>,
541        /// Query-side prefix for asymmetric models.
542        query_prefix: Option<String>,
543        /// Document-side prefix for asymmetric models.
544        doc_prefix: Option<String>,
545    },
546}
547
548/// Normalized, cross-SDK embedding config as forwarded by the native bindings.
549/// Exactly one *primary* source must be set: either `spec` (the raw string
550/// shortcut) or one of `huggingface` / `local` / `ollama` / `url`. The rest are
551/// modifiers.
552#[derive(Debug, Clone, Default)]
553pub struct EmbeddingSpec {
554    /// Raw string shortcut — a **local model directory path** only. A repo-id or
555    /// URL string is rejected in favor of the explicit `huggingface`/`url` keys.
556    pub spec: Option<String>,
557    /// Primary source: a HuggingFace repo id.
558    pub huggingface: Option<String>,
559    /// Primary source: a local model directory path.
560    pub local: Option<String>,
561    /// Primary source: an Ollama model name (served via the local Ollama endpoint).
562    pub ollama: Option<String>,
563    /// Primary source: a full OpenAI-compatible endpoint URL.
564    pub url: Option<String>,
565    /// Model name for an endpoint source.
566    pub model: Option<String>,
567    /// Git revision for a HuggingFace source.
568    pub revision: Option<String>,
569    /// Env var holding the endpoint bearer key.
570    pub api_key_env: Option<String>,
571    /// Query-side prefix for asymmetric models.
572    pub query_prefix: Option<String>,
573    /// Document-side prefix for asymmetric models (e.g. e5's `"passage: "`).
574    pub doc_prefix: Option<String>,
575    /// `"cls"` | `"mean"` — overrides auto-detection for an in-process model.
576    pub pooling: Option<String>,
577    /// Opt in to letting Ratel download a HuggingFace model that is not yet
578    /// cached (default `false`; the built-in default always downloads).
579    pub download: Option<bool>,
580}
581
582fn cfg(message: impl Into<String>) -> EmbedderError {
583    EmbedderError::Config {
584        message: message.into(),
585    }
586}
587
588impl EmbeddingModel {
589    /// Validate a concrete Rust model value. SDK configs normally enter through
590    /// [`Self::resolve`], but Rust callers can construct public enum variants
591    /// directly; this keeps that path subject to the same nonblank-field rules.
592    ///
593    /// # Errors
594    ///
595    /// [`EmbedderError::Config`] when a required source, model, URL, or env-var
596    /// name is blank.
597    pub fn validate(&self) -> Result<(), EmbedderError> {
598        match self {
599            EmbeddingModel::Default => Ok(()),
600            EmbeddingModel::HuggingFace { repo, .. } => validate_nonblank("huggingface", repo),
601            EmbeddingModel::Local { path, .. } => {
602                validate_nonblank("local", &path.to_string_lossy())
603            }
604            EmbeddingModel::Endpoint {
605                url,
606                model,
607                api_key_env,
608                ..
609            } => {
610                validate_nonblank("url", url)?;
611                validate_nonblank("model", model)?;
612                if let Some(api_key_env) = api_key_env {
613                    validate_nonblank("api_key_env", api_key_env)?;
614                }
615                Ok(())
616            }
617        }
618    }
619
620    /// Validate and resolve a spec into a concrete model. Runs at catalog
621    /// construction, so config mistakes surface immediately (not at first search).
622    pub fn resolve(spec: EmbeddingSpec) -> Result<EmbeddingModel, EmbedderError> {
623        for (name, value) in [
624            ("spec", spec.spec.as_deref()),
625            ("huggingface", spec.huggingface.as_deref()),
626            ("local", spec.local.as_deref()),
627            ("ollama", spec.ollama.as_deref()),
628            ("url", spec.url.as_deref()),
629            ("model", spec.model.as_deref()),
630            ("api_key_env", spec.api_key_env.as_deref()),
631        ] {
632            if value.is_some_and(|value| value.trim().is_empty()) {
633                return Err(cfg(format!("embedding '{name}' must not be blank")));
634            }
635        }
636
637        let primaries = [
638            ("spec", spec.spec.is_some()),
639            ("huggingface", spec.huggingface.is_some()),
640            ("local", spec.local.is_some()),
641            ("ollama", spec.ollama.is_some()),
642            ("url", spec.url.is_some()),
643        ];
644        let set: Vec<&str> = primaries
645            .iter()
646            .filter(|(_, present)| *present)
647            .map(|(key, _)| *key)
648            .collect();
649        match set.len() {
650            0 => {
651                return Err(cfg(
652                    "no embedding source given; pass a local directory path, or one of \
653                     huggingface/local/ollama/url",
654                ));
655            }
656            1 => {}
657            _ => {
658                return Err(cfg(format!(
659                    "conflicting embedding keys {set:?}; give exactly one of \
660                     spec/huggingface/local/ollama/url",
661                )));
662            }
663        }
664
665        // Parse the pooling override once (validates the string); only in-process
666        // sources may carry it.
667        let pooling = spec
668            .pooling
669            .as_deref()
670            .map(str::parse::<Pooling>)
671            .transpose()?;
672
673        // `download` is a HuggingFace-only fetch policy.
674        if spec.download.is_some() && set[0] != "huggingface" {
675            return Err(cfg("'download' is only valid for a HuggingFace repo"));
676        }
677
678        let model = match set[0] {
679            "spec" => infer_from_string(spec.spec.as_deref().unwrap(), &spec, pooling),
680            "huggingface" => {
681                reject_endpoint_only(&spec, "a HuggingFace repo")?;
682                Ok(EmbeddingModel::HuggingFace {
683                    repo: spec.huggingface.unwrap(),
684                    revision: spec.revision,
685                    query_prefix: spec.query_prefix,
686                    doc_prefix: spec.doc_prefix,
687                    pooling,
688                    download: spec.download.unwrap_or(false),
689                })
690            }
691            "local" => {
692                reject_endpoint_only(&spec, "a local model")?;
693                if spec.revision.is_some() {
694                    return Err(cfg("'revision' is only valid for a HuggingFace repo"));
695                }
696                Ok(EmbeddingModel::Local {
697                    path: PathBuf::from(spec.local.unwrap()),
698                    query_prefix: spec.query_prefix,
699                    doc_prefix: spec.doc_prefix,
700                    pooling,
701                })
702            }
703            "ollama" => {
704                if spec.model.is_some() {
705                    return Err(cfg(
706                        "'model' is redundant with 'ollama' (the ollama value is the model name)",
707                    ));
708                }
709                if spec.api_key_env.is_some() {
710                    return Err(cfg(
711                        "'api_key_env' is not valid with the Ollama shortcut; use a full endpoint 'url'",
712                    ));
713                }
714                reject_in_process_only(&spec, pooling)?;
715                Ok(EmbeddingModel::Endpoint {
716                    url: OLLAMA_DEFAULT_URL.to_string(),
717                    model: spec.ollama.unwrap(),
718                    api_key_env: None,
719                    query_prefix: spec.query_prefix,
720                    doc_prefix: spec.doc_prefix,
721                })
722            }
723            "url" => {
724                reject_in_process_only(&spec, pooling)?;
725                let model = spec
726                    .model
727                    .ok_or_else(|| cfg("endpoint embedding requires both 'url' and 'model'"))?;
728                Ok(EmbeddingModel::Endpoint {
729                    url: spec.url.unwrap(),
730                    model,
731                    api_key_env: spec.api_key_env,
732                    query_prefix: spec.query_prefix,
733                    doc_prefix: spec.doc_prefix,
734                })
735            }
736            _ => unreachable!("primary key set is closed"),
737        }?;
738        model.validate()?;
739        Ok(model)
740    }
741
742    /// The query-side instruction prefix (bge is asymmetric). Empty unless the
743    /// model sets one; the built-in default carries bge's instruction.
744    pub(crate) fn query_prefix(&self) -> &str {
745        match self {
746            EmbeddingModel::Default => DEFAULT_QUERY_INSTRUCTION,
747            EmbeddingModel::HuggingFace { query_prefix, .. }
748            | EmbeddingModel::Local { query_prefix, .. }
749            | EmbeddingModel::Endpoint { query_prefix, .. } => {
750                query_prefix.as_deref().unwrap_or("")
751            }
752        }
753    }
754
755    /// The document-side prefix (asymmetric models like e5 use `"passage: "`).
756    /// Empty unless the model sets one.
757    pub(crate) fn doc_prefix(&self) -> &str {
758        match self {
759            EmbeddingModel::Default => "",
760            EmbeddingModel::HuggingFace { doc_prefix, .. }
761            | EmbeddingModel::Local { doc_prefix, .. }
762            | EmbeddingModel::Endpoint { doc_prefix, .. } => doc_prefix.as_deref().unwrap_or(""),
763        }
764    }
765
766    /// The explicit pooling override, if any. The built-in default is pinned to
767    /// CLS (so it never needs the pooling-config fetch); an in-process model uses
768    /// its field (auto-detected when `None`); an endpoint pools server-side.
769    pub(crate) fn pooling_override(&self) -> Option<Pooling> {
770        match self {
771            EmbeddingModel::Default => Some(Pooling::Cls),
772            EmbeddingModel::HuggingFace { pooling, .. } | EmbeddingModel::Local { pooling, .. } => {
773                *pooling
774            }
775            EmbeddingModel::Endpoint { .. } => None,
776        }
777    }
778
779    /// Human-readable model name for telemetry (the `model` field of load
780    /// events). Friendlier than the fingerprint.
781    pub(crate) fn display_name(&self) -> String {
782        match self {
783            EmbeddingModel::Default => DEFAULT_REPO.to_string(),
784            EmbeddingModel::HuggingFace { repo, .. } => repo.clone(),
785            EmbeddingModel::Local { path, .. } => path.display().to_string(),
786            EmbeddingModel::Endpoint { url, model, .. } => format!("{model} @ {url}"),
787        }
788    }
789
790    /// Pre-load identity used to **key the process model cache** so two catalogs
791    /// on the same model load it once. HF `revision` may still be `main` here;
792    /// the resolved-with-SHA form is [`crate::embedding::Embedder::fingerprint`],
793    /// stamped on the dense cache after load. Local uses the path-derived runtime
794    /// spelling (not the RAT1 content digest).
795    pub(crate) fn configured_fingerprint(&self) -> String {
796        let base = match self {
797            EmbeddingModel::Default => huggingface_fingerprint(DEFAULT_REPO, DEFAULT_REVISION),
798            EmbeddingModel::HuggingFace { repo, revision, .. } => {
799                huggingface_fingerprint(repo, revision.as_deref().unwrap_or("main"))
800            }
801            EmbeddingModel::Local { path, .. } => local_fingerprint(&path.display().to_string()),
802            EmbeddingModel::Endpoint { url, model, .. } => endpoint_fingerprint(url, model),
803        };
804        // Pooling + prefixes change the vectors, so they are part of the identity.
805        format!(
806            "{base}{}",
807            fingerprint_suffix(
808                self.pooling_override(),
809                self.query_prefix(),
810                self.doc_prefix()
811            )
812        )
813    }
814
815    /// Process-cache identity for the client/embedder instance. Credentials do
816    /// not change the vector space, so [`Self::configured_fingerprint`] excludes
817    /// them; the cached endpoint client does capture the *name* of the env var it
818    /// reads, however, so that non-secret name must distinguish client instances.
819    pub(crate) fn embedder_cache_key(&self) -> String {
820        let vector_identity = self.configured_fingerprint();
821        match self {
822            EmbeddingModel::Endpoint { api_key_env, .. } => match api_key_env {
823                Some(name) => {
824                    let mut key = vector_identity;
825                    push_fingerprint_field(&mut key, "api_key_env", name);
826                    key
827                }
828                None => format!("{vector_identity}|api_key_env=none"),
829            },
830            _ => vector_identity,
831        }
832    }
833}
834
835fn fingerprint(kind: &str, fields: &[(&str, &str)]) -> String {
836    let mut fingerprint = kind.to_string();
837    for (name, value) in fields {
838        push_fingerprint_field(&mut fingerprint, name, value);
839    }
840    fingerprint
841}
842
843fn push_fingerprint_field(fingerprint: &mut String, name: &str, value: &str) {
844    fingerprint.push_str(&format!("|{name}={}:{}", value.len(), value));
845}
846
847fn validate_nonblank(name: &str, value: &str) -> Result<(), EmbedderError> {
848    if value.trim().is_empty() {
849        return Err(cfg(format!("embedding '{name}' must not be blank")));
850    }
851    Ok(())
852}
853
854/// Reject endpoint-only modifiers on an in-process (HF/local) source.
855fn reject_endpoint_only(spec: &EmbeddingSpec, what: &str) -> Result<(), EmbedderError> {
856    if spec.model.is_some() {
857        return Err(cfg(format!(
858            "'model' is only valid with an endpoint 'url', not {what}"
859        )));
860    }
861    if spec.api_key_env.is_some() {
862        return Err(cfg(format!(
863            "'api_key_env' is only valid with an endpoint 'url', not {what}"
864        )));
865    }
866    Ok(())
867}
868
869/// Reject in-process-only modifiers on an endpoint source: `revision` (no HF
870/// fetch) and `pooling` (the server pools).
871fn reject_in_process_only(
872    spec: &EmbeddingSpec,
873    pooling: Option<Pooling>,
874) -> Result<(), EmbedderError> {
875    if spec.revision.is_some() {
876        return Err(cfg("'revision' is only valid for a HuggingFace repo"));
877    }
878    if pooling.is_some() {
879        return Err(cfg(
880            "'pooling' is only valid for an in-process model (huggingface/local); \
881             an endpoint pools server-side",
882        ));
883    }
884    Ok(())
885}
886
887/// Interpret the raw string shortcut, which is a **local model directory path
888/// only** — every non-path source (HuggingFace, endpoint) uses the explicit
889/// keyed object, symmetric with `{ollama}`/`{url}`. A URL or a repo-id-looking
890/// string is rejected with a pointer to the right object form, so the source is
891/// never guessed from an ambiguous string.
892fn infer_from_string(
893    s: &str,
894    spec: &EmbeddingSpec,
895    pooling: Option<Pooling>,
896) -> Result<EmbeddingModel, EmbedderError> {
897    if spec.model.is_some() || spec.api_key_env.is_some() {
898        return Err(cfg(
899            "'model'/'api_key_env' are only valid with an endpoint 'url'; a bare string \
900             is only a local model directory path",
901        ));
902    }
903    if spec.revision.is_some() {
904        return Err(cfg("'revision' is only valid for a HuggingFace repo; use \
905             {\"huggingface\": \"…\", \"revision\": \"…\"}"));
906    }
907    if looks_like_url(s) {
908        return Err(cfg(format!(
909            "'{s}' looks like an endpoint URL but has no model name; use \
910             {{\"url\": \"{s}\", \"model\": \"…\"}}"
911        )));
912    }
913    if looks_like_path(s) || Path::new(s).is_dir() {
914        return Ok(EmbeddingModel::Local {
915            path: PathBuf::from(s),
916            query_prefix: spec.query_prefix.clone(),
917            doc_prefix: spec.doc_prefix.clone(),
918            pooling,
919        });
920    }
921    // Not a path → most likely a HuggingFace repo id. The bare-string form is
922    // local-only, so name the explicit object rather than guess the source.
923    Err(cfg(format!(
924        "'{s}' is not a local directory path; to use a HuggingFace repo pass \
925         {{\"huggingface\": \"{s}\"}}, or give an absolute/relative directory \
926         path for a local model"
927    )))
928}
929
930/// A `scheme://…` URL. Requires `://`, so a Windows `C:\…` path never matches.
931fn looks_like_url(s: &str) -> bool {
932    match s.find("://") {
933        Some(idx) if idx > 0 => {
934            let scheme = &s[..idx];
935            scheme
936                .chars()
937                .next()
938                .is_some_and(|c| c.is_ascii_alphabetic())
939                && scheme
940                    .chars()
941                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
942        }
943        _ => false,
944    }
945}
946
947/// An unambiguous local-path intent — even if the path doesn't exist yet, so a
948/// mistyped local path errors as "not found" rather than a phantom HF repo.
949fn looks_like_path(s: &str) -> bool {
950    s.starts_with('/')
951        || s.starts_with("./")
952        || s.starts_with("../")
953        || s.starts_with('~')
954        || s.starts_with(r"\\") // UNC
955        || s.starts_with(r".\")
956        || s.starts_with(r"..\")
957        || is_windows_drive(s)
958}
959
960/// `C:\` or `C:/` — a drive letter, colon, separator.
961fn is_windows_drive(s: &str) -> bool {
962    let b = s.as_bytes();
963    b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    fn from_str(s: &str) -> Result<EmbeddingModel, EmbedderError> {
971        EmbeddingModel::resolve(EmbeddingSpec {
972            spec: Some(s.to_string()),
973            ..Default::default()
974        })
975    }
976
977    #[test]
978    fn bare_repo_id_string_is_rejected_pointing_to_huggingface() {
979        // A repo-id-looking string is not a path → rejected with a pointer to the
980        // explicit object form, so the source is never guessed.
981        let err = from_str("BAAI/bge-base-en-v1.5").unwrap_err();
982        assert!(matches!(err, EmbedderError::Config { .. }));
983        assert!(err.to_string().contains("huggingface"), "got: {err}");
984    }
985
986    #[test]
987    fn huggingface_object_infers_default_revision() {
988        assert_eq!(
989            EmbeddingModel::resolve(EmbeddingSpec {
990                huggingface: Some("BAAI/bge-base-en-v1.5".into()),
991                ..Default::default()
992            })
993            .unwrap(),
994            EmbeddingModel::HuggingFace {
995                repo: "BAAI/bge-base-en-v1.5".into(),
996                revision: None,
997                query_prefix: None,
998                doc_prefix: None,
999                pooling: None,
1000                download: false,
1001            }
1002        );
1003    }
1004
1005    #[test]
1006    fn absolute_and_relative_paths_infer_local_even_when_absent() {
1007        for p in [
1008            "/opt/models/x",
1009            "./models/x",
1010            "../x",
1011            "~/models/x",
1012            r"\\host\share\x",
1013        ] {
1014            assert!(
1015                matches!(from_str(p).unwrap(), EmbeddingModel::Local { .. }),
1016                "{p} should infer Local"
1017            );
1018        }
1019    }
1020
1021    #[test]
1022    fn windows_drive_path_is_local_not_url() {
1023        for p in [r"C:\models\bge", "C:/models/bge"] {
1024            assert!(
1025                matches!(from_str(p).unwrap(), EmbeddingModel::Local { .. }),
1026                "{p} should infer Local, not be mistaken for a URL"
1027            );
1028        }
1029    }
1030
1031    #[test]
1032    fn existing_directory_infers_local() {
1033        let dir = tempfile::tempdir().unwrap();
1034        let path = dir.path().to_str().unwrap();
1035        assert!(matches!(
1036            from_str(path).unwrap(),
1037            EmbeddingModel::Local { .. }
1038        ));
1039    }
1040
1041    #[test]
1042    fn bare_url_string_is_rejected_needs_model() {
1043        let err = from_str("https://api.openai.com/v1/embeddings").unwrap_err();
1044        assert!(matches!(err, EmbedderError::Config { .. }));
1045        assert!(err.to_string().contains("model"), "got: {err}");
1046    }
1047
1048    #[test]
1049    fn ollama_object_expands_to_localhost_endpoint() {
1050        let m = EmbeddingModel::resolve(EmbeddingSpec {
1051            ollama: Some("nomic-embed-text".into()),
1052            ..Default::default()
1053        })
1054        .unwrap();
1055        assert_eq!(
1056            m,
1057            EmbeddingModel::Endpoint {
1058                url: OLLAMA_DEFAULT_URL.into(),
1059                model: "nomic-embed-text".into(),
1060                api_key_env: None,
1061                query_prefix: None,
1062                doc_prefix: None,
1063            }
1064        );
1065    }
1066
1067    #[test]
1068    fn endpoint_object_requires_model() {
1069        let err = EmbeddingModel::resolve(EmbeddingSpec {
1070            url: Some("https://api.openai.com/v1/embeddings".into()),
1071            ..Default::default()
1072        })
1073        .unwrap_err();
1074        assert!(err.to_string().contains("'url' and 'model'"), "got: {err}");
1075    }
1076
1077    #[test]
1078    fn ollama_and_url_together_conflict() {
1079        let err = EmbeddingModel::resolve(EmbeddingSpec {
1080            ollama: Some("nomic".into()),
1081            url: Some("http://host:11434/v1/embeddings".into()),
1082            model: Some("nomic".into()),
1083            ..Default::default()
1084        })
1085        .unwrap_err();
1086        assert!(err.to_string().contains("conflicting"), "got: {err}");
1087    }
1088
1089    #[test]
1090    fn huggingface_object_with_revision() {
1091        let m = EmbeddingModel::resolve(EmbeddingSpec {
1092            huggingface: Some("BAAI/bge-base-en-v1.5".into()),
1093            revision: Some("abc123".into()),
1094            ..Default::default()
1095        })
1096        .unwrap();
1097        assert_eq!(
1098            m,
1099            EmbeddingModel::HuggingFace {
1100                repo: "BAAI/bge-base-en-v1.5".into(),
1101                revision: Some("abc123".into()),
1102                query_prefix: None,
1103                doc_prefix: None,
1104                pooling: None,
1105                download: false,
1106            }
1107        );
1108    }
1109
1110    #[test]
1111    fn empty_spec_is_rejected() {
1112        assert!(EmbeddingModel::resolve(EmbeddingSpec::default()).is_err());
1113    }
1114
1115    #[test]
1116    fn blank_source_and_endpoint_fields_are_rejected() {
1117        for spec in [
1118            EmbeddingSpec {
1119                huggingface: Some("   ".into()),
1120                ..Default::default()
1121            },
1122            EmbeddingSpec {
1123                local: Some("\t".into()),
1124                ..Default::default()
1125            },
1126            EmbeddingSpec {
1127                ollama: Some("\n".into()),
1128                ..Default::default()
1129            },
1130            EmbeddingSpec {
1131                url: Some(" ".into()),
1132                model: Some("model".into()),
1133                ..Default::default()
1134            },
1135            EmbeddingSpec {
1136                url: Some("http://localhost/v1/embeddings".into()),
1137                model: Some(" ".into()),
1138                ..Default::default()
1139            },
1140            EmbeddingSpec {
1141                url: Some("http://localhost/v1/embeddings".into()),
1142                model: Some("model".into()),
1143                api_key_env: Some(" ".into()),
1144                ..Default::default()
1145            },
1146        ] {
1147            assert!(matches!(
1148                EmbeddingModel::resolve(spec),
1149                Err(EmbedderError::Config { .. })
1150            ));
1151        }
1152    }
1153
1154    #[test]
1155    fn ollama_rejects_api_key_env_instead_of_ignoring_it() {
1156        let err = EmbeddingModel::resolve(EmbeddingSpec {
1157            ollama: Some("nomic-embed-text".into()),
1158            api_key_env: Some("OLLAMA_KEY".into()),
1159            ..Default::default()
1160        })
1161        .unwrap_err();
1162        assert!(matches!(err, EmbedderError::Config { .. }));
1163        assert!(err.to_string().contains("api_key_env"));
1164    }
1165
1166    #[test]
1167    fn download_defaults_false_and_is_huggingface_only() {
1168        // Default off — explicit HF models are cache-only unless opted in.
1169        assert!(matches!(
1170            EmbeddingModel::resolve(EmbeddingSpec {
1171                huggingface: Some("org/m".into()),
1172                ..Default::default()
1173            })
1174            .unwrap(),
1175            EmbeddingModel::HuggingFace {
1176                download: false,
1177                ..
1178            }
1179        ));
1180        // Opt in.
1181        assert!(matches!(
1182            EmbeddingModel::resolve(EmbeddingSpec {
1183                huggingface: Some("org/m".into()),
1184                download: Some(true),
1185                ..Default::default()
1186            })
1187            .unwrap(),
1188            EmbeddingModel::HuggingFace { download: true, .. }
1189        ));
1190        // Meaningless on a non-HF source.
1191        let err = EmbeddingModel::resolve(EmbeddingSpec {
1192            ollama: Some("nomic".into()),
1193            download: Some(true),
1194            ..Default::default()
1195        })
1196        .unwrap_err();
1197        assert!(err.to_string().contains("download"), "got: {err}");
1198    }
1199
1200    #[test]
1201    fn default_query_prefix_is_bge_instruction() {
1202        assert_eq!(
1203            EmbeddingModel::Default.query_prefix(),
1204            DEFAULT_QUERY_INSTRUCTION
1205        );
1206        assert_eq!(
1207            EmbeddingModel::Endpoint {
1208                url: "u".into(),
1209                model: "m".into(),
1210                api_key_env: None,
1211                query_prefix: None,
1212                doc_prefix: None,
1213            }
1214            .query_prefix(),
1215            ""
1216        );
1217    }
1218
1219    #[test]
1220    fn fingerprints_are_distinct_per_source() {
1221        // The default carries its pinned CLS pooling + bge query prefix.
1222        assert_eq!(
1223            EmbeddingModel::Default.configured_fingerprint(),
1224            format!(
1225                "hf|repo={}:{}|revision={}:{}|pool=3:cls|q={}:{}",
1226                DEFAULT_REPO.len(),
1227                DEFAULT_REPO,
1228                DEFAULT_REVISION.len(),
1229                DEFAULT_REVISION,
1230                DEFAULT_QUERY_INSTRUCTION.len(),
1231                DEFAULT_QUERY_INSTRUCTION
1232            )
1233        );
1234        assert_eq!(
1235            EmbeddingModel::HuggingFace {
1236                repo: "r".into(),
1237                revision: None,
1238                query_prefix: None,
1239                doc_prefix: None,
1240                pooling: None,
1241                download: false,
1242            }
1243            .configured_fingerprint(),
1244            "hf|repo=1:r|revision=4:main"
1245        );
1246        assert_eq!(
1247            EmbeddingModel::Endpoint {
1248                url: "u".into(),
1249                model: "m".into(),
1250                api_key_env: None,
1251                query_prefix: None,
1252                doc_prefix: None,
1253            }
1254            .configured_fingerprint(),
1255            "endpoint|url=1:u|model=1:m"
1256        );
1257    }
1258
1259    #[test]
1260    fn huggingface_and_endpoint_fingerprints_are_unchanged() {
1261        assert_eq!(
1262            huggingface_fingerprint("org/m", "abc"),
1263            "hf|repo=5:org/m|revision=3:abc"
1264        );
1265        assert_eq!(
1266            endpoint_fingerprint("http://x/v1/embeddings", "nomic"),
1267            "endpoint|url=22:http://x/v1/embeddings|model=5:nomic"
1268        );
1269    }
1270
1271    #[test]
1272    fn local_runtime_fingerprint_matches_pre_pr_path_spelling() {
1273        let model = EmbeddingModel::Local {
1274            path: PathBuf::from("/models/foo"),
1275            query_prefix: None,
1276            doc_prefix: None,
1277            pooling: None,
1278        };
1279        assert_eq!(model.configured_fingerprint(), "local|path=11:/models/foo");
1280        assert_eq!(
1281            local_fingerprint("/models/foo"),
1282            "local|path=11:/models/foo"
1283        );
1284
1285        let with_pool = EmbeddingModel::Local {
1286            path: PathBuf::from("/models/foo"),
1287            query_prefix: Some("q: ".into()),
1288            doc_prefix: Some("d: ".into()),
1289            pooling: Some(Pooling::Cls),
1290        };
1291        assert_eq!(
1292            with_pool.configured_fingerprint(),
1293            "local|path=11:/models/foo|pool=3:cls|q=3:q: |d=3:d: ".to_string()
1294        );
1295    }
1296
1297    #[test]
1298    fn local_configured_fingerprint_is_infallible_without_model_files() {
1299        let model = EmbeddingModel::Local {
1300            path: PathBuf::from("/nonexistent/local-model"),
1301            query_prefix: None,
1302            doc_prefix: None,
1303            pooling: None,
1304        };
1305        assert_eq!(
1306            model.configured_fingerprint(),
1307            "local|path=24:/nonexistent/local-model"
1308        );
1309        assert_eq!(model.embedder_cache_key(), model.configured_fingerprint());
1310    }
1311
1312    fn write_local_model(dir: &Path, config: &[u8], tokenizer: &[u8], weights: &[u8]) {
1313        std::fs::write(dir.join("config.json"), config).unwrap();
1314        std::fs::write(dir.join("tokenizer.json"), tokenizer).unwrap();
1315        std::fs::write(dir.join("model.safetensors"), weights).unwrap();
1316    }
1317
1318    fn touch_mtime(path: &Path) {
1319        use std::fs::FileTimes;
1320        use std::time::Duration;
1321
1322        let file = std::fs::File::options().write(true).open(path).unwrap();
1323        let current = file.metadata().unwrap().modified().unwrap();
1324        let times = FileTimes::new().set_modified(current + Duration::from_secs(2));
1325        file.set_times(times).unwrap();
1326    }
1327
1328    fn identity_from_dir(dir: &Path) -> (LocalContentIdentity, [PathBuf; 3]) {
1329        let files = resolve_local_model_files(dir).unwrap();
1330        let stamps = stamp_local_hash_paths(&files, "m").unwrap();
1331        (
1332            LocalContentIdentity::new(stamps),
1333            [
1334                files.config.clone(),
1335                files.tokenizer.clone(),
1336                files.weights.clone(),
1337            ],
1338        )
1339    }
1340
1341    fn path_refs(paths: &[PathBuf; 3]) -> [&Path; 3] {
1342        [&paths[0], &paths[1], &paths[2]]
1343    }
1344
1345    fn write_pooling(dir: &Path, cls: bool, mean: bool) {
1346        let pooling_dir = dir.join("1_Pooling");
1347        std::fs::create_dir_all(&pooling_dir).unwrap();
1348        std::fs::write(
1349            pooling_dir.join("config.json"),
1350            format!(r#"{{"pooling_mode_cls_token":{cls},"pooling_mode_mean_tokens":{mean}}}"#),
1351        )
1352        .unwrap();
1353    }
1354
1355    #[test]
1356    fn ordinary_local_cache_key_does_not_content_hash() {
1357        test_reset_content_hash_calls();
1358        let model = EmbeddingModel::Local {
1359            path: PathBuf::from("/models/foo"),
1360            query_prefix: None,
1361            doc_prefix: None,
1362            pooling: None,
1363        };
1364        for _ in 0..5 {
1365            let _ = model.configured_fingerprint();
1366            let _ = model.embedder_cache_key();
1367        }
1368        assert_eq!(
1369            test_content_hash_calls(),
1370            0,
1371            "runtime Local identity must not stream model bytes"
1372        );
1373    }
1374
1375    #[test]
1376    fn local_artifact_identity_does_content_hash() {
1377        let dir = tempfile::tempdir().unwrap();
1378        write_local_model(dir.path(), b"cfg", b"tok", b"w");
1379        test_reset_content_hash_calls();
1380        let _ = local_artifact_fingerprint(dir.path(), None, "", "").unwrap();
1381        assert!(
1382            test_content_hash_calls() >= 1,
1383            "artifact identity must digest model inputs"
1384        );
1385    }
1386
1387    #[test]
1388    fn local_artifact_identity_ignores_mount_path() {
1389        let a = tempfile::tempdir().unwrap();
1390        let b = tempfile::tempdir().unwrap();
1391        write_local_model(a.path(), b"cfg", b"tok", b"w");
1392        write_local_model(b.path(), b"cfg", b"tok", b"w");
1393
1394        let id_a = local_artifact_fingerprint(a.path(), None, "", "").unwrap();
1395        let id_b = local_artifact_fingerprint(b.path(), None, "", "").unwrap();
1396        assert_eq!(id_a, id_b, "artifact identity must ignore the mount path");
1397        assert!(
1398            id_a.starts_with("local|content="),
1399            "artifact identity is content-keyed; got {id_a}"
1400        );
1401        // Runtime identity remains path-keyed and therefore differs across mounts.
1402        let model_a = EmbeddingModel::Local {
1403            path: a.path().to_path_buf(),
1404            query_prefix: None,
1405            doc_prefix: None,
1406            pooling: None,
1407        };
1408        let model_b = EmbeddingModel::Local {
1409            path: b.path().to_path_buf(),
1410            query_prefix: None,
1411            doc_prefix: None,
1412            pooling: None,
1413        };
1414        assert_ne!(
1415            model_a.configured_fingerprint(),
1416            model_b.configured_fingerprint()
1417        );
1418    }
1419
1420    #[test]
1421    fn local_artifact_identity_changes_with_weights_config_tokenizer() {
1422        let dir = tempfile::tempdir().unwrap();
1423        write_local_model(dir.path(), b"cfg", b"tok", b"weights-v1");
1424        let base = local_artifact_fingerprint(dir.path(), None, "", "").unwrap();
1425
1426        std::fs::write(dir.path().join("model.safetensors"), b"weights-v2-longer").unwrap();
1427        assert_ne!(
1428            base,
1429            local_artifact_fingerprint(dir.path(), None, "", "").unwrap()
1430        );
1431
1432        write_local_model(dir.path(), b"cfg-changed", b"tok", b"weights-v2-longer");
1433        let after_cfg = local_artifact_fingerprint(dir.path(), None, "", "").unwrap();
1434        assert_ne!(base, after_cfg);
1435
1436        write_local_model(
1437            dir.path(),
1438            b"cfg-changed",
1439            b"tok-changed",
1440            b"weights-v2-longer",
1441        );
1442        assert_ne!(
1443            after_cfg,
1444            local_artifact_fingerprint(dir.path(), None, "", "").unwrap()
1445        );
1446    }
1447
1448    #[test]
1449    fn local_artifact_identity_uses_resolved_pooling_not_irrelevant_file() {
1450        let dir = tempfile::tempdir().unwrap();
1451        write_local_model(dir.path(), b"cfg", b"tok", b"w");
1452        write_pooling(dir.path(), false, true); // mean in file
1453
1454        let with_override =
1455            local_artifact_fingerprint(dir.path(), Some(Pooling::Cls), "", "").unwrap();
1456        assert!(
1457            with_override.contains("|pool=3:cls"),
1458            "override must win; got {with_override}"
1459        );
1460
1461        // Mutating an unused pooling file must not change artifact identity.
1462        write_pooling(dir.path(), true, false);
1463        let after_file_change =
1464            local_artifact_fingerprint(dir.path(), Some(Pooling::Cls), "", "").unwrap();
1465        assert_eq!(with_override, after_file_change);
1466
1467        // Without override, resolved pooling from the file must affect identity.
1468        let cls = local_artifact_fingerprint(dir.path(), None, "", "").unwrap();
1469        write_pooling(dir.path(), false, true);
1470        let mean = local_artifact_fingerprint(dir.path(), None, "", "").unwrap();
1471        assert_ne!(cls, mean);
1472        assert!(cls.contains("|pool=3:cls"));
1473        assert!(mean.contains("|pool=4:mean"));
1474    }
1475
1476    #[test]
1477    fn local_artifact_identity_includes_prefixes() {
1478        let dir = tempfile::tempdir().unwrap();
1479        write_local_model(dir.path(), b"cfg", b"tok", b"w");
1480        let plain = local_artifact_fingerprint(dir.path(), None, "", "").unwrap();
1481        let with_q = local_artifact_fingerprint(dir.path(), None, "query: ", "").unwrap();
1482        let with_qd = local_artifact_fingerprint(dir.path(), None, "query: ", "passage: ").unwrap();
1483        assert_ne!(plain, with_q);
1484        assert_ne!(with_q, with_qd);
1485        assert!(with_qd.contains("|q=7:query: "));
1486        assert!(with_qd.contains("|d=9:passage: "));
1487    }
1488
1489    #[test]
1490    fn local_artifact_identity_same_length_weight_change_still_mismatches() {
1491        let dir = tempfile::tempdir().unwrap();
1492        write_local_model(dir.path(), b"cfg", b"tok", b"weights-AAAA");
1493        let files = resolve_local_model_files(dir.path()).unwrap();
1494        let paths: Vec<PathBuf> = files
1495            .content_hash_paths()
1496            .iter()
1497            .map(|p| (*p).to_path_buf())
1498            .collect();
1499
1500        // Hash bytes directly — not via the (len,mtime) memo — so same-length
1501        // overwrites cannot hide behind coarse filesystem timestamps.
1502        let before = hash_local_identity_files(&paths, "m").unwrap();
1503        std::fs::write(dir.path().join("model.safetensors"), b"weights-BBBB").unwrap();
1504        let after = hash_local_identity_files(&paths, "m").unwrap();
1505
1506        assert_ne!(
1507            before, after,
1508            "same-length weight bytes must still change the content digest"
1509        );
1510        assert_ne!(
1511            local_content_fingerprint(&before),
1512            local_content_fingerprint(&after)
1513        );
1514    }
1515
1516    #[test]
1517    fn endpoint_client_cache_key_includes_env_name_but_vector_identity_does_not() {
1518        let endpoint = |api_key_env: &str| EmbeddingModel::Endpoint {
1519            url: "https://example.test/v1/embeddings".into(),
1520            model: "embed-v1".into(),
1521            api_key_env: Some(api_key_env.into()),
1522            query_prefix: None,
1523            doc_prefix: None,
1524        };
1525        let a = endpoint("KEY_A");
1526        let b = endpoint("KEY_B");
1527
1528        assert_eq!(a.configured_fingerprint(), b.configured_fingerprint());
1529        assert_ne!(a.embedder_cache_key(), b.embedder_cache_key());
1530        assert!(a.embedder_cache_key().contains("KEY_A"));
1531    }
1532
1533    #[test]
1534    fn fingerprint_fields_cannot_collide_through_delimiters() {
1535        let endpoint = |url: &str, model: &str, query_prefix: &str, doc_prefix: Option<&str>| {
1536            EmbeddingModel::Endpoint {
1537                url: url.into(),
1538                model: model.into(),
1539                api_key_env: None,
1540                query_prefix: Some(query_prefix.into()),
1541                doc_prefix: doc_prefix.map(str::to_string),
1542            }
1543        };
1544
1545        assert_ne!(
1546            endpoint("https://example.test#a", "b", "", None).configured_fingerprint(),
1547            endpoint("https://example.test", "a#b", "", None).configured_fingerprint()
1548        );
1549        assert_ne!(
1550            endpoint("u", "m", "x|d=y", None).configured_fingerprint(),
1551            endpoint("u", "m", "x", Some("y")).configured_fingerprint()
1552        );
1553    }
1554
1555    #[test]
1556    fn endpoint_cache_key_distinguishes_no_key_from_literal_sentinel_name() {
1557        let endpoint = |api_key_env| EmbeddingModel::Endpoint {
1558            url: "u".into(),
1559            model: "m".into(),
1560            api_key_env,
1561            query_prefix: None,
1562            doc_prefix: None,
1563        };
1564
1565        assert_ne!(
1566            endpoint(None).embedder_cache_key(),
1567            endpoint(Some("<none>".into())).embedder_cache_key()
1568        );
1569    }
1570
1571    #[test]
1572    fn pooling_override_parses_and_is_rejected_on_endpoint() {
1573        // Valid on huggingface.
1574        assert!(matches!(
1575            EmbeddingModel::resolve(EmbeddingSpec {
1576                huggingface: Some("org/m".into()),
1577                pooling: Some("mean".into()),
1578                ..Default::default()
1579            })
1580            .unwrap(),
1581            EmbeddingModel::HuggingFace {
1582                pooling: Some(Pooling::Mean),
1583                ..
1584            }
1585        ));
1586        // A bad value is a Config error.
1587        assert!(
1588            EmbeddingModel::resolve(EmbeddingSpec {
1589                huggingface: Some("org/m".into()),
1590                pooling: Some("median".into()),
1591                ..Default::default()
1592            })
1593            .is_err()
1594        );
1595        // Meaningless on an endpoint (the server pools).
1596        let err = EmbeddingModel::resolve(EmbeddingSpec {
1597            ollama: Some("nomic".into()),
1598            pooling: Some("mean".into()),
1599            ..Default::default()
1600        })
1601        .unwrap_err();
1602        assert!(err.to_string().contains("pooling"), "got: {err}");
1603    }
1604
1605    #[test]
1606    fn doc_prefix_threads_through_and_affects_fingerprint() {
1607        let m = EmbeddingModel::resolve(EmbeddingSpec {
1608            huggingface: Some("intfloat/e5-small-v2".into()),
1609            query_prefix: Some("query: ".into()),
1610            doc_prefix: Some("passage: ".into()),
1611            ..Default::default()
1612        })
1613        .unwrap();
1614        assert_eq!(m.doc_prefix(), "passage: ");
1615        assert!(m.configured_fingerprint().contains("|d=9:passage: "));
1616        // Pooling is part of identity: same repo, different pooling → different key.
1617        let cls = EmbeddingModel::resolve(EmbeddingSpec {
1618            huggingface: Some("org/m".into()),
1619            pooling: Some("cls".into()),
1620            ..Default::default()
1621        })
1622        .unwrap();
1623        let mean = EmbeddingModel::resolve(EmbeddingSpec {
1624            huggingface: Some("org/m".into()),
1625            pooling: Some("mean".into()),
1626            ..Default::default()
1627        })
1628        .unwrap();
1629        assert_ne!(cls.configured_fingerprint(), mean.configured_fingerprint());
1630    }
1631
1632    #[test]
1633    fn missing_local_tokenizer_error_message_is_unchanged() {
1634        let dir = tempfile::tempdir().unwrap();
1635        std::fs::write(dir.path().join("config.json"), b"{}").unwrap();
1636        std::fs::write(dir.path().join("model.safetensors"), b"w").unwrap();
1637        let err = resolve_local_model_files(dir.path()).unwrap_err();
1638        let msg = err.to_string();
1639        assert!(
1640            msg.contains("missing tokenizer.json")
1641                && msg.contains("fast tokenizer.json is required"),
1642            "got: {msg}"
1643        );
1644    }
1645
1646    #[test]
1647    fn resolve_and_stamp_local_model_files_does_not_content_hash() {
1648        let dir = tempfile::tempdir().unwrap();
1649        write_local_model(dir.path(), b"cfg", b"tok", b"w");
1650        test_reset_content_hash_calls();
1651        let files = resolve_local_model_files(dir.path()).unwrap();
1652        let _ = stamp_local_hash_paths(&files, "m").unwrap();
1653        assert_eq!(
1654            test_content_hash_calls(),
1655            0,
1656            "resolve + stamp may touch metadata but must not digest weights"
1657        );
1658    }
1659
1660    #[test]
1661    fn metadata_only_touch_keeps_local_artifact_identity_stable() {
1662        let dir = tempfile::tempdir().unwrap();
1663        write_local_model(dir.path(), b"cfg", b"tok", b"w");
1664        let (identity, paths) = identity_from_dir(dir.path());
1665        let refs = path_refs(&paths);
1666
1667        let established = identity.content_id(dir.path(), &refs).unwrap();
1668        touch_mtime(&dir.path().join("config.json"));
1669        let after = identity.content_id(dir.path(), &refs).unwrap();
1670        assert_eq!(
1671            established, after,
1672            "byte-identical mtime touch must keep the established identity"
1673        );
1674    }
1675
1676    #[test]
1677    fn metadata_only_touch_is_recoverable_and_stable_across_repeats() {
1678        let dir = tempfile::tempdir().unwrap();
1679        write_local_model(dir.path(), b"cfg", b"tok", b"w");
1680        let (identity, paths) = identity_from_dir(dir.path());
1681        let refs = path_refs(&paths);
1682
1683        test_reset_content_hash_calls();
1684        let established = identity.content_id(dir.path(), &refs).unwrap();
1685        assert_eq!(
1686            test_content_hash_calls(),
1687            1,
1688            "first establishment hashes once"
1689        );
1690
1691        for _ in 0..2 {
1692            touch_mtime(&dir.path().join("config.json"));
1693            let before = test_content_hash_calls();
1694            let after_touch = identity.content_id(dir.path(), &refs).unwrap();
1695            assert_eq!(after_touch, established);
1696            assert_eq!(
1697                test_content_hash_calls(),
1698                before + 1,
1699                "each metadata-only touch recomputes the digest once"
1700            );
1701            let repeat = identity.content_id(dir.path(), &refs).unwrap();
1702            assert_eq!(repeat, established);
1703            assert_eq!(
1704                test_content_hash_calls(),
1705                before + 1,
1706                "unchanged stamps after an accepted touch must take the fast path"
1707            );
1708        }
1709    }
1710
1711    #[test]
1712    fn content_drift_after_established_identity_fails_closed() {
1713        let dir = tempfile::tempdir().unwrap();
1714        write_local_model(dir.path(), b"cfg", b"tok", b"weights-v1");
1715        let (identity, paths) = identity_from_dir(dir.path());
1716        let refs = path_refs(&paths);
1717
1718        identity.content_id(dir.path(), &refs).unwrap();
1719        std::fs::write(dir.path().join("model.safetensors"), b"weights-v2-longer").unwrap();
1720        let err = identity.content_id(dir.path(), &refs).unwrap_err();
1721        let msg = err.to_string();
1722        assert!(
1723            msg.contains("contents differ") && msg.contains("start a new process"),
1724            "content drift after establishment must name contents and a new process; got {msg}"
1725        );
1726        assert!(
1727            !msg.contains("reload the embedder"),
1728            "error must not suggest an impossible reload; got {msg}"
1729        );
1730    }
1731
1732    #[test]
1733    fn stamp_drift_before_established_identity_fails_closed() {
1734        let dir = tempfile::tempdir().unwrap();
1735        write_local_model(dir.path(), b"cfg", b"tok", b"w");
1736        let (identity, paths) = identity_from_dir(dir.path());
1737        let refs = path_refs(&paths);
1738
1739        touch_mtime(&dir.path().join("config.json"));
1740        let err = identity.content_id(dir.path(), &refs).unwrap_err();
1741        let msg = err.to_string();
1742        assert!(
1743            msg.contains("before this process established its artifact identity")
1744                && msg.contains("start a new process"),
1745            "stamp drift before establishment must fail closed with a new-process remedy; got {msg}"
1746        );
1747        assert!(
1748            !msg.contains("reload the embedder"),
1749            "error must not suggest an impossible reload; got {msg}"
1750        );
1751    }
1752
1753    #[test]
1754    fn same_length_content_swap_after_established_identity_fails_closed() {
1755        let dir = tempfile::tempdir().unwrap();
1756        write_local_model(dir.path(), b"cfg", b"tok", b"weights-v1");
1757        let (identity, paths) = identity_from_dir(dir.path());
1758        let refs = path_refs(&paths);
1759
1760        identity.content_id(dir.path(), &refs).unwrap();
1761        std::fs::write(dir.path().join("model.safetensors"), b"weights-v2").unwrap();
1762        let err = identity.content_id(dir.path(), &refs).unwrap_err();
1763        let msg = err.to_string();
1764        assert!(
1765            msg.contains("contents differ"),
1766            "same-length weight swap must fail closed; got {msg}"
1767        );
1768    }
1769
1770    #[test]
1771    fn content_id_does_not_commit_digest_when_stamps_change_during_hash() {
1772        let dir = tempfile::tempdir().unwrap();
1773        write_local_model(dir.path(), b"cfg", b"tok", b"w");
1774        let (identity, paths) = identity_from_dir(dir.path());
1775        let refs = path_refs(&paths);
1776        let config = dir.path().join("config.json");
1777
1778        super::after_hash_hook::set(move || touch_mtime(&config));
1779        test_reset_content_hash_calls();
1780        let err = identity.content_id(dir.path(), &refs).unwrap_err();
1781        let msg = err.to_string();
1782        assert!(
1783            msg.contains("before this process established its artifact identity")
1784                && msg.contains("start a new process"),
1785            "TOCTOU during first hash must not establish identity; got {msg}"
1786        );
1787        assert!(
1788            test_content_hash_calls() >= 1,
1789            "the interrupted establishment must have hashed"
1790        );
1791
1792        let err = identity.content_id(dir.path(), &refs).unwrap_err();
1793        assert!(
1794            err.to_string()
1795                .contains("before this process established its artifact identity"),
1796            "identity must remain unestablished after a discarded in-flight digest; got {err}"
1797        );
1798    }
1799}