1use 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
27pub(crate) const DEFAULT_REPO: &str = "BAAI/bge-small-en-v1.5";
31pub(crate) const DEFAULT_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
32pub(crate) const DEFAULT_QUERY_INSTRUCTION: &str =
34 "Represent this sentence for searching relevant passages: ";
35
36pub(crate) const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434/v1/embeddings";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Pooling {
46 Cls,
48 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
74pub(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
99pub(crate) fn local_fingerprint(path: &str) -> String {
102 fingerprint("local", &[("path", path)])
103}
104
105pub(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#[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#[derive(Clone, Debug)]
129pub(crate) struct LocalModelFiles {
130 pub(crate) config: PathBuf,
131 pub(crate) tokenizer: PathBuf,
132 pub(crate) weights: PathBuf,
133 pub(crate) pooling_config: Option<PathBuf>,
136}
137
138impl LocalModelFiles {
139 pub(crate) fn content_hash_paths(&self) -> [&Path; 3] {
141 [&self.config, &self.tokenizer, &self.weights]
142 }
143}
144
145pub(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
178pub(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
202pub(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 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#[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 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
371pub(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 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#[derive(Debug, Clone, PartialEq)]
498pub enum EmbeddingModel {
499 Default,
501 HuggingFace {
506 repo: String,
508 revision: Option<String>,
510 query_prefix: Option<String>,
512 doc_prefix: Option<String>,
514 pooling: Option<Pooling>,
516 download: bool,
518 },
519 Local {
522 path: PathBuf,
524 query_prefix: Option<String>,
526 doc_prefix: Option<String>,
528 pooling: Option<Pooling>,
530 },
531 Endpoint {
535 url: String,
537 model: String,
539 api_key_env: Option<String>,
541 query_prefix: Option<String>,
543 doc_prefix: Option<String>,
545 },
546}
547
548#[derive(Debug, Clone, Default)]
553pub struct EmbeddingSpec {
554 pub spec: Option<String>,
557 pub huggingface: Option<String>,
559 pub local: Option<String>,
561 pub ollama: Option<String>,
563 pub url: Option<String>,
565 pub model: Option<String>,
567 pub revision: Option<String>,
569 pub api_key_env: Option<String>,
571 pub query_prefix: Option<String>,
573 pub doc_prefix: Option<String>,
575 pub pooling: Option<String>,
577 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 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 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 let pooling = spec
668 .pooling
669 .as_deref()
670 .map(str::parse::<Pooling>)
671 .transpose()?;
672
673 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 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 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 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 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 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 format!(
806 "{base}{}",
807 fingerprint_suffix(
808 self.pooling_override(),
809 self.query_prefix(),
810 self.doc_prefix()
811 )
812 )
813 }
814
815 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
854fn 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
869fn 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
887fn 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 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
930fn 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
947fn 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"\\") || s.starts_with(r".\")
956 || s.starts_with(r"..\")
957 || is_windows_drive(s)
958}
959
960fn 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 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 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 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 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 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 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); 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 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 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 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 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 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 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 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}