Skip to main content

ratel_ai_core/
embedding.rs

1//! Dense embedders behind the semantic/hybrid engines. The model is configurable
2//! per catalog (see [`crate::embedding_config`] and ADR-0012); this module owns
3//! the two backends and the process-wide, identity-keyed embedder cache.
4//!
5//! - [`CandleEmbedder`] runs a **BERT-family** model in-process via Candle — the
6//!   built-in default (`bge-small-en-v1.5`), any HuggingFace repo, or an on-disk
7//!   directory. Pure-Rust inference (no C++/ONNX native dep) keeps the SDK
8//!   wheels/addons clean cross-platform. Pooling (CLS or mean) is auto-detected
9//!   from the model's `1_Pooling/config.json` (overridable, warn-then-assume-mean
10//!   when absent), then we L2-normalize, so cosine similarity is a dot product
11//!   (see [`crate::dense_search`]). It supports asymmetric models on both sides —
12//!   an optional query prefix and doc prefix. Weights load from
13//!   `model.safetensors` or a `pytorch_model.bin` fallback.
14//! - [`EndpointEmbedder`] calls an OpenAI-compatible `/embeddings` HTTP endpoint
15//!   (OpenAI, Ollama, TEI, vLLM…) — any model, including non-BERT ones. Returned
16//!   vectors are **re-normalized** on ingestion (an arbitrary endpoint may not
17//!   normalize), so `dense_search`'s unit-vector assumption always holds.
18//!
19//! In-process weights are **not** bundled and live in the shared HuggingFace
20//! cache (`~/.cache/huggingface`, `HF_HOME`-overridable). Ratel **auto-downloads
21//! only the built-in default**; an explicit HuggingFace model is **cache-only**
22//! (it must already be present, or opt in with `download=true`) — a missing one
23//! errors as [`EmbedderError::NotCached`], symmetric with Ollama's "not pulled",
24//! so an explicit embedding build never silently pulls a multi-GB model. Once cached, load is
25//! offline and deterministic when the revision is pinned. Two catalogs on the
26//! same model share one resident embedder (keyed by model fingerprint); a cold
27//! download emits a [`TraceEvent::EmbedderDownload`].
28//!
29//! **Footprint & failure modes.** A resident BERT model is ~130 MB+ of f32
30//! weights plus runtime buffers, and inference is CPU-only, so a constrained
31//! machine may load or embed slowly — surfaced as a [`TraceEvent::EmbedderLoad`]
32//! with status `slow` — or, if it runs out of memory, be killed by the OS (an
33//! uncatchable SIGKILL, nothing we can flag). Load and inference are otherwise
34//! **fallible**: a failure returns a typed [`EmbedderError`] (network, unwritable
35//! cache, corrupt weights, inference, dimension mismatch, config) rather than
36//! aborting the process, and a failed load is **not cached**, so a later call
37//! retries once the cause clears.
38
39use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex, OnceLock};
42use std::time::{Duration, Instant};
43
44use candle_core::{DType, Device, IndexOp, Tensor};
45use candle_nn::VarBuilder;
46use candle_transformers::models::bert::{BertModel, Config};
47use hf_hub::api::sync::{ApiBuilder, ApiRepo};
48use hf_hub::{Repo, RepoType};
49use serde::Deserialize;
50use tokenizers::{Tokenizer, TruncationDirection, TruncationParams, TruncationStrategy};
51
52use crate::embedding_config::{
53    DEFAULT_REPO, DEFAULT_REVISION, EmbeddingModel, OLLAMA_DEFAULT_URL, Pooling,
54    endpoint_fingerprint, fingerprint_suffix, huggingface_fingerprint, local_fingerprint,
55};
56use crate::trace::{EmbedderLoadStatus, TraceEvent, TraceSink};
57
58/// HTTP timeout for a single endpoint embedding request, so a stalled endpoint
59/// can't hang an embedding build/query forever.
60const ENDPOINT_TIMEOUT_SECS: u64 = 30;
61
62/// Maximum number of inputs sent in one OpenAI-compatible embeddings request.
63const ENDPOINT_BATCH_SIZE: usize = 64;
64
65/// Maximum response body accepted for one endpoint chunk.
66const ENDPOINT_RESPONSE_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
67
68/// Default cold-load latency (ms) above which the load is flagged `slow`, a hint
69/// that the machine may be underpowered. Override with `RATEL_EMBED_SLOW_MS`.
70const DEFAULT_SLOW_LOAD_MS: u64 = 5_000;
71
72/// Human-readable reason attached to a `slow` load event.
73const SLOW_LOAD_REASON: &str = "embedding model load was slow — this machine may be underpowered \
74     for in-process CPU inference; expect slow embedding builds and queries";
75
76/// A recoverable embedder failure. Returned instead of panicking so a load or
77/// inference problem surfaces to the SDK as a **catchable** error (with a
78/// remediation hint in `Display`) rather than aborting the host process.
79#[derive(Debug, Clone)]
80pub enum EmbedderError {
81    /// A configured embedding source could not be reached: offline, DNS/TLS,
82    /// timeout, or an endpoint/model request returned a 4xx.
83    Download {
84        /// The configured model/source display name.
85        model: String,
86        /// The underlying fetch error.
87        source: String,
88    },
89    /// The HuggingFace cache could not be written: permissions, disk full, or a
90    /// read-only filesystem.
91    CacheUnwritable {
92        /// The underlying filesystem error.
93        source: String,
94    },
95    /// Model files are unusable: missing or corrupt weights, or a
96    /// config/tokenizer that failed to parse.
97    Load {
98        /// The configured model/source display name.
99        model: String,
100        /// The underlying load error.
101        source: String,
102    },
103    /// Embedding a specific text failed (tokenization or the forward pass).
104    Inference {
105        /// The underlying tokenizer/inference error.
106        source: String,
107    },
108    /// A semantic/hybrid search was requested but the embedding cache is not
109    /// built for the current corpus — `build_embeddings` was never run. No model
110    /// is loaded; the caller must build the embeddings first.
111    EmbeddingsNotBuilt,
112    /// A vector's dimension does not match the embedding cache's. Cosine over
113    /// mismatched dimensions is silently wrong, so this is a hard error — raised
114    /// when a query (or an endpoint's response) has a different width than the
115    /// vectors the cache was built with.
116    DimensionMismatch {
117        /// Vector width the cache was built with.
118        expected: usize,
119        /// Vector width actually seen.
120        got: usize,
121        /// The active model, named for diagnosis.
122        model: String,
123    },
124    /// A vector was produced by a different resolved model than the vectors
125    /// already in the cache. Mixing vector spaces is never safe; callers must
126    /// explicitly rebuild the full corpus to adopt the active model.
127    ModelMismatch {
128        /// Resolved model identity that built the cache.
129        built: String,
130        /// Resolved model identity returned by the active embedder.
131        active: String,
132    },
133    /// The embedding configuration is invalid — a bad source combination, a
134    /// missing required field, or a named `api_key_env` that is not set. Surfaced
135    /// at catalog construction or on first use.
136    Config {
137        /// What is wrong with the configuration.
138        message: String,
139    },
140    /// An explicitly-configured HuggingFace model is not in the local cache, and
141    /// Ratel auto-downloads only the built-in default. The user must fetch it
142    /// first — symmetric with Ollama's "model not pulled".
143    NotCached {
144        /// The repo id that is not cached.
145        model: String,
146        /// The requested revision, if pinned.
147        revision: Option<String>,
148    },
149}
150
151impl EmbedderError {
152    /// One-line remediation hint, embedded in the `Display` message.
153    fn hint(&self) -> &'static str {
154        match self {
155            EmbedderError::Download { .. } => {
156                "check source availability, connectivity, and the configured model identifier"
157            }
158            EmbedderError::CacheUnwritable { .. } => {
159                "check ~/.cache/huggingface permissions and free disk space (or set HF_HOME)"
160            }
161            EmbedderError::Load { .. } => {
162                "check that model files and configuration are present, readable, and compatible"
163            }
164            EmbedderError::Inference { .. } => {
165                "check model input/configuration or the endpoint response and retry"
166            }
167            EmbedderError::EmbeddingsNotBuilt => {
168                "embed the corpus before running a semantic/hybrid search"
169            }
170            EmbedderError::DimensionMismatch { .. } => {
171                "the configured model changed; re-embed the corpus with the new model"
172            }
173            EmbedderError::ModelMismatch { .. } => {
174                "the configured model changed; re-embed the corpus with the new model"
175            }
176            EmbedderError::Config { .. } => {
177                "give exactly one embedding source (a model id / path / url) with its required fields"
178            }
179            EmbedderError::NotCached { .. } => {
180                "Ratel auto-downloads only the default model; pre-download this one, pass download=true, or use a local path / endpoint"
181            }
182        }
183    }
184}
185
186impl std::fmt::Display for EmbedderError {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        let hint = self.hint();
189        match self {
190            EmbedderError::Download { model, source } => write!(
191                f,
192                "failed to download embedding model {model}: {source} (hint: {hint})"
193            ),
194            EmbedderError::CacheUnwritable { source } => write!(
195                f,
196                "embedding model cache is not writable: {source} (hint: {hint})"
197            ),
198            EmbedderError::Load { model, source } => write!(
199                f,
200                "failed to load embedding model {model}: {source} (hint: {hint})"
201            ),
202            EmbedderError::Inference { source } => {
203                write!(f, "embedding failed: {source} (hint: {hint})")
204            }
205            EmbedderError::DimensionMismatch {
206                expected,
207                got,
208                model,
209            } => write!(
210                f,
211                "embedding dimension mismatch for {model}: expected {expected}, got {got} (hint: {hint})"
212            ),
213            EmbedderError::ModelMismatch { built, active } => write!(
214                f,
215                "embedding model mismatch: cache was built with {built}, active model is {active} (hint: {hint})"
216            ),
217            EmbedderError::Config { message } => write!(f, "{message} (hint: {hint})"),
218            EmbedderError::NotCached { model, revision } => {
219                let rev = revision
220                    .as_deref()
221                    .map(|r| format!(" --revision {r}"))
222                    .unwrap_or_default();
223                write!(
224                    f,
225                    "embedding model {model} is not in the local HuggingFace cache — download it \
226                     first: `huggingface-cli download {model}{rev}` (hint: {hint})"
227                )
228            }
229            EmbedderError::EmbeddingsNotBuilt => {
230                write!(
231                    f,
232                    "embeddings are not computed for semantic search (hint: {hint})"
233                )
234            }
235        }
236    }
237}
238
239impl std::error::Error for EmbedderError {}
240
241/// An embedding value paired with the resolved identity of the model that
242/// produced it. Carrying identity in the result avoids races when one process-
243/// cached endpoint embedder is shared by concurrent catalogs.
244pub(crate) struct Embedded<T> {
245    pub(crate) value: T,
246    pub(crate) fingerprint: String,
247}
248
249/// Maps a tool's searchable text (and a query) to an L2-normalized vector.
250/// A trait so the model is swappable — a HuggingFace/local BERT model or a
251/// remote endpoint can back the same registries without touching them.
252pub(crate) trait Embedder: Send + Sync {
253    fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError>;
254    fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError>;
255
256    /// Embed a batch of documents. The default loops `embed_doc` (fine for an
257    /// in-process model); an endpoint embedder overrides it with ordered HTTP
258    /// chunks, since per-document round-trips would be pathological.
259    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
260        texts.iter().map(|t| self.embed_doc(t)).collect()
261    }
262
263    /// Embed a query and return the vector with its resolved model identity.
264    /// Fixed-identity embedders use [`Self::fingerprint`]; endpoint embedders
265    /// override this to carry the response's optional resolved `model`.
266    fn embed_query_with_identity(&self, text: &str) -> Result<Embedded<Vec<f32>>, EmbedderError> {
267        Ok(Embedded {
268            value: self.embed_query(text)?,
269            fingerprint: self.fingerprint(),
270        })
271    }
272
273    /// Embed documents and return the complete batch with its resolved model
274    /// identity. The cache validates and commits this result atomically.
275    fn embed_batch_with_identity(
276        &self,
277        texts: &[String],
278    ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
279        Ok(Embedded {
280            value: self.embed_batch(texts)?,
281            fingerprint: self.fingerprint(),
282        })
283    }
284
285    /// Resolved model identity (concrete HF revision/SHA, local path, or endpoint
286    /// URL + model), encoded as collision-proof length-delimited fields. It is
287    /// stamped on the dense cache so a later model swap over an existing
288    /// embedding set is detectable. Test stubs use the default.
289    fn fingerprint(&self) -> String {
290        "unknown".to_string()
291    }
292}
293
294/// A one-time notice that the embedding model was actually downloaded (cold HF
295/// cache), carrying the real byte size — surfaced so a multi-second first-run
296/// fetch is never a silent surprise.
297struct DownloadNotice {
298    model: String,
299    bytes: u64,
300}
301
302/// One-time notices produced by a cold load, surfaced by the telemetry layer.
303#[derive(Default)]
304struct LoadNotices {
305    /// Set when a cold HF fetch actually downloaded weights.
306    download: Option<DownloadNotice>,
307    /// The model name, set when pooling could not be detected and Mean was
308    /// assumed — so the guess is never silent (the user can set `pooling`).
309    pooling_assumed: Option<String>,
310}
311
312/// Process-wide embedder cache, **keyed by model identity**, loaded once per
313/// model on first use. Two catalogs on the same model share one resident
314/// embedder; different models coexist. A failed load is **not** cached (the key
315/// stays empty), so a later call retries — a transient network blip must not
316/// poison a model for the whole process. Returns the embedder, the cold-load
317/// latency (`Some` only on the loading call), and a download notice (`Some` only
318/// when a cold HF fetch actually downloaded).
319/// A resolved embedder plus one-time cold-load telemetry: the load latency
320/// (`Some` on the loading call) and any cold-load notices (download / assumed
321/// pooling).
322type ResolvedEmbedder = (Arc<dyn Embedder>, Option<u64>, LoadNotices);
323
324fn embedder_for(model: &EmbeddingModel) -> Result<ResolvedEmbedder, EmbedderError> {
325    static CELL: OnceLock<Mutex<HashMap<String, LoadSlot<dyn Embedder>>>> = OnceLock::new();
326    let cache = CELL.get_or_init(|| Mutex::new(HashMap::new()));
327    let mut notices = LoadNotices::default();
328    let (emb, load_ms) = get_or_load_keyed(cache, &model.embedder_cache_key(), || {
329        let (emb, n) = build_embedder(model)?;
330        notices = n;
331        Ok(emb)
332    })?;
333    Ok((emb, load_ms, notices))
334}
335
336/// Construct the embedder for a model (may hit the network/disk). Threads the
337/// query/doc prefixes and pooling override in, and returns cold-load notices.
338fn build_embedder(
339    model: &EmbeddingModel,
340) -> Result<(Arc<dyn Embedder>, LoadNotices), EmbedderError> {
341    let query_prefix = model.query_prefix();
342    let doc_prefix = model.doc_prefix();
343    let pooling = model.pooling_override();
344    match model {
345        EmbeddingModel::Default => {
346            // The built-in default is the one model Ratel always auto-downloads.
347            let (e, n) = CandleEmbedder::load_hf(
348                DEFAULT_REPO,
349                DEFAULT_REVISION,
350                query_prefix,
351                doc_prefix,
352                pooling,
353                true,
354            )?;
355            Ok((Arc::new(e), n))
356        }
357        EmbeddingModel::HuggingFace {
358            repo,
359            revision,
360            download,
361            ..
362        } => {
363            let (e, n) = CandleEmbedder::load_hf(
364                repo,
365                revision.as_deref().unwrap_or("main"),
366                query_prefix,
367                doc_prefix,
368                pooling,
369                *download,
370            )?;
371            Ok((Arc::new(e), n))
372        }
373        EmbeddingModel::Local { path, .. } => {
374            let (e, n) = CandleEmbedder::load_path(path, query_prefix, doc_prefix, pooling)?;
375            Ok((Arc::new(e), n))
376        }
377        EmbeddingModel::Endpoint {
378            url,
379            model,
380            api_key_env,
381            ..
382        } => {
383            let e = EndpointEmbedder::new(
384                url.clone(),
385                model.clone(),
386                api_key_env.clone(),
387                query_prefix.into(),
388                doc_prefix.into(),
389            )?;
390            Ok((Arc::new(e), LoadNotices::default()))
391        }
392    }
393}
394
395/// A per-key load slot in a keyed cache: `None` until the value is loaded, then
396/// `Some`. Guarded by its own mutex so a `load` serializes callers of *that* key
397/// without holding the whole-map lock.
398type LoadSlot<T> = Arc<Mutex<Option<Arc<T>>>>;
399
400/// Get-or-load into a keyed cache with **no failure caching**: on `Err` the key's
401/// slot stays empty so a later call retries. Returns the value plus `Some(load_ms)`
402/// on the call that performed the load, `None` on warm reuse. Generic so the
403/// non-poisoning + once contract is unit-tested without touching the network.
404///
405/// The map lock is held only long enough to get/create the key's slot; `load`
406/// runs against that per-key slot mutex, never the map. So **different keys load
407/// concurrently** (a cold load of one model no longer blocks another), while
408/// **same-key loads stay single-flight** (concurrent cold misses share the slot,
409/// so `load` runs once and exactly one caller reports `Some(load_ms)`).
410fn get_or_load_keyed<T: ?Sized>(
411    cache: &Mutex<HashMap<String, LoadSlot<T>>>,
412    key: &str,
413    load: impl FnOnce() -> Result<Arc<T>, EmbedderError>,
414) -> Result<(Arc<T>, Option<u64>), EmbedderError> {
415    // Hold the map lock only to get/create this key's slot, then release it.
416    let slot = {
417        let mut guard = cache.lock().expect("embedder cache mutex poisoned");
418        Arc::clone(guard.entry(key.to_string()).or_default())
419    };
420    // Serialize per key on the slot — the map lock is NOT held across `load`.
421    let mut slot = slot.lock().expect("embedder cache slot mutex poisoned");
422    if let Some(existing) = slot.as_ref() {
423        return Ok((existing.clone(), None));
424    }
425    let started = Instant::now();
426    let loaded = load()?; // Err leaves the slot `None`, so a later call retries.
427    let took_ms = started.elapsed().as_millis() as u64;
428    *slot = Some(loaded.clone());
429    Ok((loaded, Some(took_ms)))
430}
431
432/// Resolve the process embedder and record the one-time load-telemetry event on
433/// `sink` (a slow/failed load is also logged to stderr). Registries call this so
434/// the [`TraceEvent::EmbedderLoad`] flag is emitted from the layer that owns a
435/// sink; the embedder itself stays sink-agnostic.
436pub(crate) fn embedder_with_telemetry(
437    model: &EmbeddingModel,
438    sink: &dyn TraceSink,
439) -> Result<Arc<dyn Embedder>, EmbedderError> {
440    let display = model.display_name();
441    let (result, load_ms, notices) = match embedder_for(model) {
442        Ok((emb, ms, notices)) => (Ok(emb), ms, notices),
443        Err(e) => (Err(e), None, LoadNotices::default()),
444    };
445    if let Some(DownloadNotice { model, bytes }) = notices.download {
446        let mb = bytes as f64 / 1_048_576.0;
447        eprintln!("ratel: downloaded embedding model {model} ({mb:.0} MB, one-time)");
448        sink.record(TraceEvent::EmbedderDownload { model, bytes });
449    }
450    if let Some(model) = notices.pooling_assumed {
451        eprintln!(
452            "ratel: pooling not detected for {model}, assuming mean; \
453             set pooling=\"cls\"|\"mean\" to override"
454        );
455        sink.record(TraceEvent::EmbedderPoolingAssumed {
456            model,
457            pooling: "mean".to_string(),
458        });
459    }
460    if let Some(event) = embedder_load_event(&display, load_ms, result.as_ref().err()) {
461        if let TraceEvent::EmbedderLoad {
462            status,
463            took_ms,
464            reason,
465            ..
466        } = &event
467            && !matches!(status, EmbedderLoadStatus::Ok)
468        {
469            eprintln!(
470                "ratel: embedding model load {status:?} ({took_ms}ms): {}",
471                reason.as_deref().unwrap_or("")
472            );
473        }
474        sink.record(event);
475    }
476    result
477}
478
479/// Decide the load-telemetry event for a cold-load outcome. `None` on warm reuse
480/// (no `load_ms`, no error). Pure, so the slow/failed thresholding is unit-tested
481/// without the network.
482fn embedder_load_event(
483    model: &str,
484    load_ms: Option<u64>,
485    error: Option<&EmbedderError>,
486) -> Option<TraceEvent> {
487    match (load_ms, error) {
488        (_, Some(err)) => Some(TraceEvent::EmbedderLoad {
489            model: model.to_string(),
490            status: EmbedderLoadStatus::Failed,
491            took_ms: load_ms.unwrap_or(0),
492            reason: Some(err.to_string()),
493        }),
494        (Some(ms), None) => {
495            let slow = ms > slow_load_ms();
496            Some(TraceEvent::EmbedderLoad {
497                model: model.to_string(),
498                status: if slow {
499                    EmbedderLoadStatus::Slow
500                } else {
501                    EmbedderLoadStatus::Ok
502                },
503                took_ms: ms,
504                reason: slow.then(|| SLOW_LOAD_REASON.to_string()),
505            })
506        }
507        (None, None) => None,
508    }
509}
510
511fn slow_load_ms() -> u64 {
512    std::env::var("RATEL_EMBED_SLOW_MS")
513        .ok()
514        .and_then(|v| v.parse().ok())
515        .unwrap_or(DEFAULT_SLOW_LOAD_MS)
516}
517
518/// A BERT-family embedding model run in-process via Candle — the backend for the
519/// built-in default, any HuggingFace repo, and any on-disk model directory.
520/// Carries its pooling mode, asymmetric prefixes, and a resolved fingerprint.
521/// Documents per padded forward pass in [`CandleEmbedder::embed_batch_inner`]. A
522/// whole-corpus `rebuild` hands the full slice in at once, so this bounds peak
523/// activation memory; it does not affect the produced vectors (each row's output is
524/// independent of its chunk-mates).
525const EMBED_BATCH_CHUNK: usize = 32;
526
527pub(crate) struct CandleEmbedder {
528    model: BertModel,
529    tokenizer: Tokenizer,
530    device: Device,
531    pooling: Pooling,
532    query_prefix: String,
533    doc_prefix: String,
534    fingerprint: String,
535}
536
537/// The resolved files + pooling a build needs.
538struct Loaded {
539    config: PathBuf,
540    tokenizer: PathBuf,
541    weights: PathBuf,
542    pooling: Pooling,
543}
544
545impl CandleEmbedder {
546    /// Load a BERT-family model from a HuggingFace repo. When `allow_download` is
547    /// set (only the built-in default, or an explicit opt-in) missing files are
548    /// fetched into the shared HF cache — `from_env` honors `HF_HOME` /
549    /// `HF_ENDPOINT`. Otherwise it is **cache-only**: a model not already present
550    /// errors as [`EmbedderError::NotCached`] (Ratel doesn't silently download
551    /// non-default models — symmetric with Ollama's "not pulled"). Weights are
552    /// `model.safetensors`, falling back to `pytorch_model.bin`.
553    fn load_hf(
554        repo_id: &str,
555        revision: &str,
556        query_prefix: &str,
557        doc_prefix: &str,
558        pooling_override: Option<Pooling>,
559        allow_download: bool,
560    ) -> Result<(Self, LoadNotices), EmbedderError> {
561        let device = Device::Cpu;
562        let repo_spec =
563            Repo::with_revision(repo_id.to_string(), RepoType::Model, revision.to_string());
564        let cache_repo = hf_hub::Cache::from_env().repo(repo_spec.clone());
565
566        let (config_path, tokenizer_path, weights_path, pooling_file, download) = if allow_download
567        {
568            // Cold-cache detection *before* fetching, so we only announce a real
569            // download — and give a heads-up before the (blocking) fetch, since a
570            // multi-second/GB download with no message reads as a hang.
571            let was_cached = cache_repo.get("model.safetensors").is_some()
572                || cache_repo.get("pytorch_model.bin").is_some();
573            if !was_cached {
574                eprintln!(
575                    "ratel: downloading embedding model {repo_id} (one-time; this may take a moment)…"
576                );
577            }
578            let api = ApiBuilder::from_env()
579                .build()
580                .map_err(|e| EmbedderError::Download {
581                    model: repo_id.to_string(),
582                    source: e.to_string(),
583                })?;
584            let repo = api.repo(repo_spec);
585            let config = fetch_cached(&repo, "config.json", repo_id)?;
586            let tokenizer = fetch_cached(&repo, "tokenizer.json", repo_id)?;
587            // Prefer safetensors; fall back to a pickled `pytorch_model.bin`.
588            let weights = match fetch_cached(&repo, "model.safetensors", repo_id) {
589                Ok(p) => p,
590                Err(EmbedderError::Download { source, .. }) if is_not_found(&source) => {
591                    fetch_cached(&repo, "pytorch_model.bin", repo_id)?
592                }
593                Err(e) => return Err(e),
594            };
595            let pooling_file = fetch_optional(&repo, "1_Pooling/config.json");
596            let notice = (!was_cached).then(|| DownloadNotice {
597                model: repo_id.to_string(),
598                bytes: [&config, &tokenizer, &weights]
599                    .iter()
600                    .filter_map(|p| std::fs::metadata(p).ok().map(|m| m.len()))
601                    .sum(),
602            });
603            (config, tokenizer, weights, pooling_file, notice)
604        } else {
605            // Cache-only: never touch the network. A file missing from the cache
606            // means the model was never downloaded → NotCached.
607            let not_cached = || EmbedderError::NotCached {
608                model: repo_id.to_string(),
609                revision: (revision != "main").then(|| revision.to_string()),
610            };
611            let config = cache_repo.get("config.json").ok_or_else(not_cached)?;
612            let tokenizer = cache_repo.get("tokenizer.json").ok_or_else(not_cached)?;
613            let weights = cache_repo
614                .get("model.safetensors")
615                .or_else(|| cache_repo.get("pytorch_model.bin"))
616                .ok_or_else(not_cached)?;
617            let pooling_file = cache_repo.get("1_Pooling/config.json");
618            (config, tokenizer, weights, pooling_file, None)
619        };
620
621        // Pooling: override wins, else the repo's `1_Pooling/config.json`, else Mean.
622        let detected =
623            pooling_override.or_else(|| pooling_file.and_then(|p| detect_pooling_file(&p)));
624        let (pooling, pooling_assumed) = resolve_pooling(detected);
625        let notices = LoadNotices {
626            download,
627            pooling_assumed: pooling_assumed.then(|| repo_id.to_string()),
628        };
629
630        // Resolve `main` (or any ref) to the concrete commit so the fingerprint
631        // pins a real snapshot, not a moving label.
632        let sha = snapshot_sha(&weights_path).unwrap_or_else(|| revision.to_string());
633        let loaded = Loaded {
634            config: config_path,
635            tokenizer: tokenizer_path,
636            weights: weights_path,
637            pooling,
638        };
639        let embedder = Self::build(
640            device,
641            &loaded,
642            query_prefix,
643            doc_prefix,
644            huggingface_fingerprint(repo_id, &sha),
645            repo_id,
646        )?;
647        Ok((embedder, notices))
648    }
649
650    /// Load a BERT-family model directly from a directory of files (no hf-hub, no
651    /// network) — the air-gapped / bring-your-own-checkpoint path.
652    fn load_path(
653        dir: &Path,
654        query_prefix: &str,
655        doc_prefix: &str,
656        pooling_override: Option<Pooling>,
657    ) -> Result<(Self, LoadNotices), EmbedderError> {
658        let device = Device::Cpu;
659        let name = dir.display().to_string();
660        let config_path = dir.join("config.json");
661        let tokenizer_path = dir.join("tokenizer.json");
662        // Prefer safetensors; fall back to a pickled `pytorch_model.bin`.
663        let weights_path = [dir.join("model.safetensors"), dir.join("pytorch_model.bin")]
664            .into_iter()
665            .find(|p| p.exists())
666            .ok_or_else(|| EmbedderError::Load {
667                model: name.clone(),
668                source: format!("missing model.safetensors / pytorch_model.bin in {name}"),
669            })?;
670        for (p, f) in [
671            (&config_path, "config.json"),
672            (&tokenizer_path, "tokenizer.json"),
673        ] {
674            if !p.exists() {
675                return Err(EmbedderError::Load {
676                    model: name.clone(),
677                    source: format!(
678                        "missing {f} in {name} — a fast tokenizer.json is required; run \
679                         tokenizer.save_pretrained() upstream, or serve the model via an endpoint"
680                    ),
681                });
682            }
683        }
684
685        let detected =
686            pooling_override.or_else(|| detect_pooling_file(&dir.join("1_Pooling/config.json")));
687        let (pooling, pooling_assumed) = resolve_pooling(detected);
688        let notices = LoadNotices {
689            download: None,
690            pooling_assumed: pooling_assumed.then(|| name.clone()),
691        };
692
693        let loaded = Loaded {
694            config: config_path,
695            tokenizer: tokenizer_path,
696            weights: weights_path,
697            pooling,
698        };
699        let embedder = Self::build(
700            device,
701            &loaded,
702            query_prefix,
703            doc_prefix,
704            local_fingerprint(&name),
705            &name,
706        )?;
707        Ok((embedder, notices))
708    }
709
710    /// Shared file→model build. A non-BERT checkpoint fails `BertModel::load`;
711    /// the error signposts the endpoint/Ollama route (any model can run there).
712    fn build(
713        device: Device,
714        loaded: &Loaded,
715        query_prefix: &str,
716        doc_prefix: &str,
717        base_fingerprint: String,
718        model_name: &str,
719    ) -> Result<Self, EmbedderError> {
720        let load_err = |source: String| EmbedderError::Load {
721            model: model_name.to_string(),
722            source,
723        };
724
725        let config_bytes = std::fs::read(&loaded.config).map_err(|e| load_err(e.to_string()))?;
726        let config: Config =
727            serde_json::from_slice(&config_bytes).map_err(|e| load_err(e.to_string()))?;
728
729        let mut tokenizer =
730            Tokenizer::from_file(&loaded.tokenizer).map_err(|e| load_err(e.to_string()))?;
731        // Cap at the model's positional limit so long tool text can't index past
732        // the position embeddings.
733        tokenizer
734            .with_truncation(Some(TruncationParams {
735                max_length: config.max_position_embeddings,
736                strategy: TruncationStrategy::LongestFirst,
737                direction: TruncationDirection::Right,
738                stride: 0,
739            }))
740            .map_err(|e| load_err(e.to_string()))?;
741
742        // Upstream weights are f32; load them directly for reproducible CPU math.
743        // safetensors is mmap'd; a `.bin`/`.pth` checkpoint is loaded via pickle.
744        let is_safetensors =
745            loaded.weights.extension().and_then(|e| e.to_str()) == Some("safetensors");
746        let vb = if is_safetensors {
747            unsafe {
748                VarBuilder::from_mmaped_safetensors(&[&loaded.weights], DType::F32, &device)
749                    .map_err(|e| load_err(e.to_string()))?
750            }
751        } else {
752            VarBuilder::from_pth(&loaded.weights, DType::F32, &device)
753                .map_err(|e| load_err(e.to_string()))?
754        };
755        let model = BertModel::load(vb, &config).map_err(|e| EmbedderError::Load {
756            model: model_name.to_string(),
757            source: format!(
758                "{e} — if this is not a BERT-family model it can't run in-process; \
759                 serve it in a local model server and use {{\"ollama\": \"…\"}} or \
760                 {{\"url\", \"model\"}} (e.g. Ollama at {OLLAMA_DEFAULT_URL})"
761            ),
762        })?;
763
764        // Pooling + prefixes change the vectors, so they are part of the identity.
765        let fingerprint = format!(
766            "{base_fingerprint}{}",
767            fingerprint_suffix(Some(loaded.pooling), query_prefix, doc_prefix)
768        );
769        Ok(Self {
770            model,
771            tokenizer,
772            device,
773            pooling: loaded.pooling,
774            query_prefix: query_prefix.to_string(),
775            doc_prefix: doc_prefix.to_string(),
776            fingerprint,
777        })
778    }
779
780    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
781        self.embed_inner(text)
782            .map_err(|e| EmbedderError::Inference {
783                source: e.to_string(),
784            })
785    }
786
787    fn embed_inner(&self, text: &str) -> candle_core::Result<Vec<f32>> {
788        let encoding = self
789            .tokenizer
790            .encode(text, true)
791            .map_err(|e| candle_core::Error::Msg(e.to_string()))?;
792        let ids = encoding.get_ids();
793        let input_ids = Tensor::new(ids, &self.device)?.unsqueeze(0)?; // (1, seq)
794        let token_type_ids = input_ids.zeros_like()?;
795        let mask: Vec<u32> = encoding.get_attention_mask().to_vec();
796        let attention_mask = Tensor::new(mask.as_slice(), &self.device)?.unsqueeze(0)?;
797
798        // (1, seq, hidden)
799        let sequence_output =
800            self.model
801                .forward(&input_ids, &token_type_ids, Some(&attention_mask))?;
802        let pooled = match self.pooling {
803            // CLS pooling = the first token's hidden state.
804            Pooling::Cls => sequence_output.i((0, 0))?, // (hidden,)
805            // Mean pooling = masked average over the real (non-pad) tokens.
806            Pooling::Mean => mean_pool(&sequence_output, &attention_mask)?,
807        };
808        let vec = pooled.to_vec1::<f32>()?;
809        Ok(l2_normalize(vec))
810    }
811
812    /// Batched twin of [`Self::embed_inner`]: one padded forward pass per chunk of
813    /// documents instead of one per document. A padded (masked) key contributes
814    /// exactly zero to every real token's attention (candle adds `f32::MIN` before
815    /// softmax), and right-padding never leaks into real tokens, so each vector is
816    /// **bit-for-bit identical** to the per-document path — chunking only bounds the
817    /// activation memory a whole-corpus `rebuild` would otherwise allocate at once.
818    fn embed_batch_inner(&self, texts: &[String]) -> candle_core::Result<Vec<Vec<f32>>> {
819        let mut out = Vec::with_capacity(texts.len());
820        for chunk in texts.chunks(EMBED_BATCH_CHUNK) {
821            // Documents get the doc-side prefix, mirroring `embed_doc`.
822            let inputs: Vec<String> = if self.doc_prefix.is_empty() {
823                chunk.to_vec()
824            } else {
825                chunk
826                    .iter()
827                    .map(|t| format!("{}{}", self.doc_prefix, t))
828                    .collect()
829            };
830            let encodings = self
831                .tokenizer
832                .encode_batch(inputs, true)
833                .map_err(|e| candle_core::Error::Msg(e.to_string()))?;
834            let n = encodings.len();
835            let max_len = encodings
836                .iter()
837                .map(|e| e.get_ids().len())
838                .max()
839                .unwrap_or(0);
840            // Right-pad ids (pad id 0 — masked out, so the value is irrelevant) and
841            // the attention mask into rectangular `(n, max_len)` buffers.
842            let mut ids = vec![0u32; n * max_len];
843            let mut mask = vec![0u32; n * max_len];
844            for (row, enc) in encodings.iter().enumerate() {
845                let e_ids = enc.get_ids();
846                let e_mask = enc.get_attention_mask();
847                let base = row * max_len;
848                ids[base..base + e_ids.len()].copy_from_slice(e_ids);
849                mask[base..base + e_mask.len()].copy_from_slice(e_mask);
850            }
851            let input_ids = Tensor::from_vec(ids, (n, max_len), &self.device)?;
852            let attention_mask = Tensor::from_vec(mask, (n, max_len), &self.device)?;
853            let token_type_ids = input_ids.zeros_like()?;
854
855            // (n, max_len, hidden)
856            let sequence_output =
857                self.model
858                    .forward(&input_ids, &token_type_ids, Some(&attention_mask))?;
859            let pooled = match self.pooling {
860                // CLS pooling = each row's first token.
861                Pooling::Cls => sequence_output.narrow(1, 0, 1)?.squeeze(1)?, // (n, hidden)
862                // Mean pooling = masked average over the real tokens, per row.
863                Pooling::Mean => mean_pool_batch(&sequence_output, &attention_mask)?,
864            };
865            for row in pooled.to_vec2::<f32>()? {
866                out.push(l2_normalize(row));
867            }
868        }
869        Ok(out)
870    }
871}
872
873/// Masked mean over tokens: `Σ hidden[t]·mask[t] / Σ mask[t]`. `sequence_output`
874/// is `(1, seq, hidden)`, `attention_mask` is `(1, seq)`; returns `(hidden,)`.
875fn mean_pool(sequence_output: &Tensor, attention_mask: &Tensor) -> candle_core::Result<Tensor> {
876    let mask = attention_mask.to_dtype(DType::F32)?.unsqueeze(2)?; // (1, seq, 1)
877    let summed = sequence_output.broadcast_mul(&mask)?.sum(1)?; // (1, hidden)
878    let counts = mask.sum(1)?; // (1, 1)
879    summed.broadcast_div(&counts)?.i(0) // (hidden,)
880}
881
882/// Masked mean over tokens for a whole batch — the row-wise twin of [`mean_pool`].
883/// `sequence_output` is `(n, seq, hidden)`, `attention_mask` is `(n, seq)`; returns
884/// `(n, hidden)`. Padded tokens (mask 0) contribute nothing and don't count.
885fn mean_pool_batch(
886    sequence_output: &Tensor,
887    attention_mask: &Tensor,
888) -> candle_core::Result<Tensor> {
889    let mask = attention_mask.to_dtype(DType::F32)?.unsqueeze(2)?; // (n, seq, 1)
890    let summed = sequence_output.broadcast_mul(&mask)?.sum(1)?; // (n, hidden)
891    let counts = mask.sum(1)?; // (n, 1)
892    summed.broadcast_div(&counts) // (n, hidden)
893}
894
895impl Embedder for CandleEmbedder {
896    fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
897        if self.doc_prefix.is_empty() {
898            self.embed(text)
899        } else {
900            self.embed(&format!("{}{}", self.doc_prefix, text))
901        }
902    }
903
904    fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
905        if self.query_prefix.is_empty() {
906            self.embed(text)
907        } else {
908            self.embed(&format!("{}{}", self.query_prefix, text))
909        }
910    }
911
912    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
913        self.embed_batch_inner(texts)
914            .map_err(|e| EmbedderError::Inference {
915                source: e.to_string(),
916            })
917    }
918
919    fn fingerprint(&self) -> String {
920        self.fingerprint.clone()
921    }
922}
923
924/// sentence-transformers pooling config (`1_Pooling/config.json`).
925#[derive(Deserialize)]
926struct PoolingConfig {
927    #[serde(default)]
928    pooling_mode_cls_token: bool,
929    #[serde(default)]
930    pooling_mode_mean_tokens: bool,
931}
932
933/// Read a `1_Pooling/config.json` file into a [`Pooling`], or `None` if absent /
934/// unparseable / neither cls-nor-mean.
935fn detect_pooling_file(path: &Path) -> Option<Pooling> {
936    let bytes = std::fs::read(path).ok()?;
937    parse_pooling_config(&bytes)
938}
939
940/// Pure `1_Pooling/config.json` → [`Pooling`] mapping (unit-tested offline).
941fn parse_pooling_config(bytes: &[u8]) -> Option<Pooling> {
942    let c: PoolingConfig = serde_json::from_slice(bytes).ok()?;
943    if c.pooling_mode_cls_token {
944        Some(Pooling::Cls)
945    } else if c.pooling_mode_mean_tokens {
946        Some(Pooling::Mean)
947    } else {
948        None
949    }
950}
951
952/// Resolve pooling: a detected/overridden mode, else assume Mean (and flag it so
953/// the assumption is surfaced, never silent).
954fn resolve_pooling(detected: Option<Pooling>) -> (Pooling, bool) {
955    match detected {
956        Some(p) => (p, false),
957        None => (Pooling::Mean, true),
958    }
959}
960
961/// Best-effort optional fetch of a small side file (pooling config, alt weights):
962/// `None` on any error, so a missing file never fails the load.
963fn fetch_optional(repo: &ApiRepo, file: &str) -> Option<PathBuf> {
964    repo.get(file).ok()
965}
966
967/// Whether an hf-hub fetch error is a "file not in repo" (so we try a fallback
968/// file) rather than a real network/cache failure.
969fn is_not_found(source: &str) -> bool {
970    let l = source.to_lowercase();
971    l.contains("404") || l.contains("not found") || l.contains("entry not found")
972}
973
974/// The concrete commit SHA a HuggingFace fetch resolved to, read from the cached
975/// snapshot path (`…/snapshots/<sha>/<file>`). `None` if the path isn't in that
976/// layout — the caller falls back to the requested revision string.
977fn snapshot_sha(weights_path: &Path) -> Option<String> {
978    let name = weights_path.parent()?.file_name()?.to_str()?;
979    (name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit())).then(|| name.to_string())
980}
981
982/// OpenAI-compatible HTTP embedding endpoint (OpenAI, Ollama, TEI, vLLM…). Any
983/// model can back it, including non-BERT ones the in-process path can't run.
984/// Vectors are **re-normalized on ingestion** — an arbitrary endpoint may return
985/// un-normalized embeddings, and `dense_search` assumes unit vectors.
986pub(crate) struct EndpointEmbedder {
987    url: String,
988    model: String,
989    api_key_env: Option<String>,
990    query_prefix: String,
991    doc_prefix: String,
992    agent: ureq::Agent,
993    fingerprint: String,
994}
995
996/// OpenAI `/embeddings` response shape: `{ "data": [{ "embedding": [...], "index": n }] }`.
997#[derive(Deserialize)]
998struct EmbeddingsResponse {
999    data: Vec<EmbeddingData>,
1000    #[serde(default)]
1001    model: Option<String>,
1002}
1003
1004#[derive(Deserialize)]
1005struct EmbeddingData {
1006    embedding: Vec<f32>,
1007    index: usize,
1008}
1009
1010struct ParsedEmbeddings {
1011    vectors: Vec<Vec<f32>>,
1012    model: Option<String>,
1013}
1014
1015impl EndpointEmbedder {
1016    fn new(
1017        url: String,
1018        model: String,
1019        api_key_env: Option<String>,
1020        query_prefix: String,
1021        doc_prefix: String,
1022    ) -> Result<Self, EmbedderError> {
1023        let agent: ureq::Agent = ureq::Agent::config_builder()
1024            .timeout_global(Some(Duration::from_secs(ENDPOINT_TIMEOUT_SECS)))
1025            .build()
1026            .into();
1027        // Prefixes are part of the identity (they change the vectors).
1028        let fingerprint = format!(
1029            "{}{}",
1030            endpoint_fingerprint(&url, &model),
1031            fingerprint_suffix(None, &query_prefix, &doc_prefix)
1032        );
1033        Ok(Self {
1034            url,
1035            model,
1036            api_key_env,
1037            query_prefix,
1038            doc_prefix,
1039            agent,
1040            fingerprint,
1041        })
1042    }
1043
1044    /// Read the API key from the named env var (at call time, so it can be set
1045    /// after construction). A named-but-unset var is a clear `Config` error, not
1046    /// a downstream 401.
1047    fn api_key(&self) -> Result<Option<String>, EmbedderError> {
1048        match &self.api_key_env {
1049            None => Ok(None),
1050            Some(var) => std::env::var(var)
1051                .map(Some)
1052                .map_err(|_| EmbedderError::Config {
1053                    message: format!(
1054                        "api_key_env=\"{var}\" but that environment variable is not set"
1055                    ),
1056                }),
1057        }
1058    }
1059
1060    fn request_chunk(&self, inputs: &[String]) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
1061        let key = self.api_key()?;
1062        let body = serde_json::json!({ "model": self.model, "input": inputs });
1063        let mut req = self
1064            .agent
1065            .post(&self.url)
1066            .header("content-type", "application/json");
1067        if let Some(k) = key {
1068            req = req.header("authorization", &format!("Bearer {k}"));
1069        }
1070        let mut resp = req.send_json(&body).map_err(|e| self.classify(e))?;
1071        let parsed: EmbeddingsResponse = resp
1072            .body_mut()
1073            .with_config()
1074            .limit(ENDPOINT_RESPONSE_LIMIT_BYTES)
1075            .read_json()
1076            .map_err(|e| EmbedderError::Inference {
1077                source: format!("malformed or oversized endpoint response: {e}"),
1078            })?;
1079        let parsed = parse_embeddings(parsed, inputs.len())?;
1080        let resolved_model = parsed.model.as_deref().unwrap_or(&self.model);
1081        Ok(Embedded {
1082            value: parsed.vectors,
1083            fingerprint: self.fingerprint_for_model(resolved_model),
1084        })
1085    }
1086
1087    fn request(&self, inputs: &[String]) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
1088        if inputs.is_empty() {
1089            return Ok(Embedded {
1090                value: Vec::new(),
1091                fingerprint: self.fingerprint.clone(),
1092            });
1093        }
1094
1095        let mut vectors = Vec::with_capacity(inputs.len());
1096        let mut fingerprint: Option<String> = None;
1097        let mut dimension = None;
1098        for chunk in inputs.chunks(ENDPOINT_BATCH_SIZE) {
1099            let embedded = self.request_chunk(chunk)?;
1100            if let Some(first) = &fingerprint {
1101                if first != &embedded.fingerprint {
1102                    return Err(EmbedderError::ModelMismatch {
1103                        built: first.clone(),
1104                        active: embedded.fingerprint,
1105                    });
1106                }
1107            } else {
1108                fingerprint = Some(embedded.fingerprint.clone());
1109            }
1110            let chunk_dimension = embedded
1111                .value
1112                .first()
1113                .expect("non-empty request chunk has a non-empty response")
1114                .len();
1115            if let Some(expected) = dimension {
1116                if expected != chunk_dimension {
1117                    return Err(EmbedderError::Inference {
1118                        source: format!(
1119                            "endpoint returned mixed embedding dimensions across chunks: expected {expected}, got {chunk_dimension}"
1120                        ),
1121                    });
1122                }
1123            } else {
1124                dimension = Some(chunk_dimension);
1125            }
1126            vectors.extend(embedded.value);
1127        }
1128        Ok(Embedded {
1129            value: vectors,
1130            fingerprint: fingerprint.expect("non-empty input produced at least one chunk"),
1131        })
1132    }
1133
1134    fn fingerprint_for_model(&self, model: &str) -> String {
1135        format!(
1136            "{}{}",
1137            endpoint_fingerprint(&self.url, model),
1138            fingerprint_suffix(None, &self.query_prefix, &self.doc_prefix)
1139        )
1140    }
1141
1142    /// Map an endpoint transport/HTTP error to a typed `EmbedderError`, with an
1143    /// `ollama pull` hint on a 404 from a local Ollama.
1144    fn classify(&self, e: ureq::Error) -> EmbedderError {
1145        let status = match &e {
1146            ureq::Error::StatusCode(code) => Some(*code),
1147            _ => None,
1148        };
1149        let is_local_ollama =
1150            self.url.contains("localhost:11434") || self.url.contains("127.0.0.1:11434");
1151        match status {
1152            Some(401) | Some(403) => EmbedderError::Config {
1153                message: format!(
1154                    "endpoint rejected the request ({}); check api_key_env / the key",
1155                    status.unwrap()
1156                ),
1157            },
1158            // A model that isn't served yet: on a local Ollama, tell them to pull it.
1159            Some(404) => {
1160                let hint = if is_local_ollama {
1161                    format!(" — run: ollama pull {}", self.model)
1162                } else {
1163                    String::new()
1164                };
1165                EmbedderError::Download {
1166                    model: self.model.clone(),
1167                    source: format!("endpoint returned 404 for model '{}'{hint}", self.model),
1168                }
1169            }
1170            // A transport error (connection refused, timeout, DNS): on a local
1171            // Ollama, the server most likely isn't running — say how to start it.
1172            _ if is_local_ollama => EmbedderError::Download {
1173                model: self.model.clone(),
1174                source: format!(
1175                    "could not reach Ollama at {} ({e}) — is it running? start it with \
1176                     `ollama serve`, then `ollama pull {}`",
1177                    self.url, self.model
1178                ),
1179            },
1180            _ => EmbedderError::Download {
1181                model: self.model.clone(),
1182                source: e.to_string(),
1183            },
1184        }
1185    }
1186}
1187
1188/// Turn a parsed endpoint response into ordered, L2-normalized vectors. Pure, so
1189/// the ordering + normalization guard is unit-tested without the network.
1190fn parse_embeddings(
1191    resp: EmbeddingsResponse,
1192    expected_len: usize,
1193) -> Result<ParsedEmbeddings, EmbedderError> {
1194    if resp.data.len() != expected_len {
1195        return Err(EmbedderError::Inference {
1196            source: format!(
1197                "endpoint returned {} embeddings for {expected_len} inputs",
1198                resp.data.len()
1199            ),
1200        });
1201    }
1202    if resp
1203        .model
1204        .as_deref()
1205        .is_some_and(|model| model.trim().is_empty())
1206    {
1207        return Err(EmbedderError::Inference {
1208            source: "endpoint returned a blank model identity".into(),
1209        });
1210    }
1211
1212    let mut ordered: Vec<Option<Vec<f32>>> = (0..expected_len).map(|_| None).collect();
1213    let mut dimension = None;
1214    for data in resp.data {
1215        if data.index >= expected_len {
1216            return Err(EmbedderError::Inference {
1217                source: format!(
1218                    "endpoint returned out-of-range embedding index {} for {expected_len} inputs",
1219                    data.index
1220                ),
1221            });
1222        }
1223        if ordered[data.index].is_some() {
1224            return Err(EmbedderError::Inference {
1225                source: format!("endpoint returned duplicate embedding index {}", data.index),
1226            });
1227        }
1228        let vector = normalize_endpoint_vector(data.embedding, &mut dimension)?;
1229        ordered[data.index] = Some(vector);
1230    }
1231
1232    let vectors = ordered
1233        .into_iter()
1234        .enumerate()
1235        .map(|(index, vector)| {
1236            vector.ok_or_else(|| EmbedderError::Inference {
1237                source: format!("endpoint response is missing embedding index {index}"),
1238            })
1239        })
1240        .collect::<Result<_, _>>()?;
1241    Ok(ParsedEmbeddings {
1242        vectors,
1243        model: resp.model,
1244    })
1245}
1246
1247fn normalize_endpoint_vector(
1248    mut vector: Vec<f32>,
1249    dimension: &mut Option<usize>,
1250) -> Result<Vec<f32>, EmbedderError> {
1251    if vector.is_empty() {
1252        return Err(EmbedderError::Inference {
1253            source: "endpoint returned an empty embedding vector".into(),
1254        });
1255    }
1256    if vector.iter().any(|value| !value.is_finite()) {
1257        return Err(EmbedderError::Inference {
1258            source: "endpoint returned a non-finite embedding value".into(),
1259        });
1260    }
1261    match *dimension {
1262        Some(expected) if vector.len() != expected => {
1263            return Err(EmbedderError::Inference {
1264                source: format!(
1265                    "endpoint returned mixed embedding dimensions: expected {expected}, got {}",
1266                    vector.len()
1267                ),
1268            });
1269        }
1270        None => *dimension = Some(vector.len()),
1271        Some(_) => {}
1272    }
1273
1274    let norm = vector
1275        .iter()
1276        .map(|value| f64::from(*value).powi(2))
1277        .sum::<f64>()
1278        .sqrt();
1279    if !norm.is_finite() || norm == 0.0 {
1280        return Err(EmbedderError::Inference {
1281            source: "endpoint returned a zero or non-normalizable embedding vector".into(),
1282        });
1283    }
1284    for value in &mut vector {
1285        *value = (f64::from(*value) / norm) as f32;
1286    }
1287    Ok(vector)
1288}
1289
1290impl Embedder for EndpointEmbedder {
1291    fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
1292        self.embed_batch_with_identity(std::slice::from_ref(&text.to_string()))?
1293            .value
1294            .into_iter()
1295            .next()
1296            .ok_or_else(|| EmbedderError::Inference {
1297                source: "endpoint returned no embedding".into(),
1298            })
1299    }
1300
1301    fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
1302        Ok(self.embed_query_with_identity(text)?.value)
1303    }
1304
1305    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
1306        Ok(self.embed_batch_with_identity(texts)?.value)
1307    }
1308
1309    fn embed_query_with_identity(&self, text: &str) -> Result<Embedded<Vec<f32>>, EmbedderError> {
1310        let q = if self.query_prefix.is_empty() {
1311            text.to_string()
1312        } else {
1313            format!("{}{}", self.query_prefix, text)
1314        };
1315        let embedded = self.request(&[q])?;
1316        let fingerprint = embedded.fingerprint;
1317        let value = embedded
1318            .value
1319            .into_iter()
1320            .next()
1321            .ok_or_else(|| EmbedderError::Inference {
1322                source: "endpoint returned no embedding".into(),
1323            })?;
1324        Ok(Embedded { value, fingerprint })
1325    }
1326
1327    fn embed_batch_with_identity(
1328        &self,
1329        texts: &[String],
1330    ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
1331        if self.doc_prefix.is_empty() {
1332            self.request(texts)
1333        } else {
1334            let prefixed: Vec<String> = texts
1335                .iter()
1336                .map(|t| format!("{}{}", self.doc_prefix, t))
1337                .collect();
1338            self.request(&prefixed)
1339        }
1340    }
1341
1342    fn fingerprint(&self) -> String {
1343        self.fingerprint.clone()
1344    }
1345}
1346
1347/// Resolve one model file from the HF cache, tolerating the cross-process
1348/// download race on a cold cache. hf-hub guards each blob with a *non-blocking*
1349/// `flock` and gives up after ~5s (5 × 1s); a first fetch of the ~130 MB weights
1350/// takes longer, so when several processes load the embedder at once on a cold
1351/// cache — parallel test workers, a web server's worker pool cold-starting,
1352/// `multiprocessing` — every process but the lock holder gets `LockAcquisition`
1353/// and would fail. Retry with backoff: the losers wait for the winner's download
1354/// to land, then `get()` returns the now-cached blob without locking (hf-hub
1355/// checks the cache before it locks). Any other failure is classified and
1356/// returned immediately. See ADR-0011.
1357fn fetch_cached(repo: &ApiRepo, file: &str, model: &str) -> Result<PathBuf, EmbedderError> {
1358    // ~30 × (up to hf-hub's own ~5s lock wait + 1s backoff) comfortably outlasts
1359    // a single cold-cache download; the loser normally succeeds within a few.
1360    const MAX_ATTEMPTS: u32 = 30;
1361    const BACKOFF: Duration = Duration::from_secs(1);
1362
1363    let mut attempt = 1;
1364    loop {
1365        match repo.get(file) {
1366            Ok(path) => return Ok(path),
1367            Err(e) => {
1368                let msg = e.to_string();
1369                if attempt < MAX_ATTEMPTS && is_lock_contention(&msg) {
1370                    attempt += 1;
1371                    std::thread::sleep(BACKOFF);
1372                    continue;
1373                }
1374                return Err(classify_fetch_error(model, &msg));
1375            }
1376        }
1377    }
1378}
1379
1380/// True only when an hf-hub fetch failed because another process holds the
1381/// download lock — the one error worth retrying, since the blob appears once the
1382/// winner finishes. Every other failure is terminal and classified below.
1383fn is_lock_contention(err: &str) -> bool {
1384    err.contains("Lock acquisition failed")
1385}
1386
1387/// Map an hf-hub fetch error string to a typed [`EmbedderError`]. A cache
1388/// permission/space problem is distinct (and actionable) from a network/model
1389/// problem; everything else is treated as a download failure.
1390fn classify_fetch_error(model: &str, msg: &str) -> EmbedderError {
1391    let lower = msg.to_lowercase();
1392    let unwritable = lower.contains("permission denied")
1393        || lower.contains("read-only")
1394        || lower.contains("no space")
1395        || lower.contains("os error 13") // EACCES
1396        || lower.contains("os error 28") // ENOSPC
1397        || lower.contains("os error 30"); // EROFS
1398    if unwritable {
1399        EmbedderError::CacheUnwritable {
1400            source: msg.to_string(),
1401        }
1402    } else {
1403        EmbedderError::Download {
1404            model: model.to_string(),
1405            source: msg.to_string(),
1406        }
1407    }
1408}
1409
1410/// Scale to unit L2 norm so downstream cosine similarity is a plain dot product.
1411/// A zero vector is returned unchanged (no NaNs).
1412fn l2_normalize(mut v: Vec<f32>) -> Vec<f32> {
1413    let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1414    if norm > 0.0 {
1415        for x in &mut v {
1416            *x /= norm;
1417        }
1418    }
1419    v
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424    use std::io::{Read, Write};
1425    use std::net::TcpListener;
1426    use std::sync::mpsc;
1427
1428    use super::*;
1429    use crate::{Origin, SearchMethod, Tool, ToolRegistry};
1430
1431    #[test]
1432    fn only_lock_contention_is_retried() {
1433        // The cold-cache download race: retry and wait for the winner.
1434        assert!(is_lock_contention(
1435            "Lock acquisition failed: /home/u/.cache/huggingface/hub/models--BAAI--bge-small-en-v1.5/blobs/abc.lock"
1436        ));
1437        // Everything else is terminal — classify, don't spin.
1438        assert!(!is_lock_contention("request error: connection refused"));
1439        assert!(!is_lock_contention("Http(reqwest::Error { status: 404 })"));
1440        assert!(!is_lock_contention(
1441            "No such file or directory (os error 2)"
1442        ));
1443    }
1444
1445    #[test]
1446    fn classifies_cache_permission_and_space_as_unwritable() {
1447        assert!(matches!(
1448            classify_fetch_error("m", "Permission denied (os error 13)"),
1449            EmbedderError::CacheUnwritable { .. }
1450        ));
1451        assert!(matches!(
1452            classify_fetch_error("m", "No space left on device (os error 28)"),
1453            EmbedderError::CacheUnwritable { .. }
1454        ));
1455        assert!(matches!(
1456            classify_fetch_error("m", "Read-only file system (os error 30)"),
1457            EmbedderError::CacheUnwritable { .. }
1458        ));
1459    }
1460
1461    #[test]
1462    fn classifies_network_and_http_as_download() {
1463        assert!(matches!(
1464            classify_fetch_error("m", "error sending request: dns error: failed to lookup"),
1465            EmbedderError::Download { .. }
1466        ));
1467        assert!(matches!(
1468            classify_fetch_error("m", "Http status client error (404 Not Found)"),
1469            EmbedderError::Download { .. }
1470        ));
1471    }
1472
1473    #[test]
1474    fn error_display_carries_source_and_hint() {
1475        let s = EmbedderError::Download {
1476            model: "embed-v1 @ https://embeddings.example.test".into(),
1477            source: "connection refused".into(),
1478        }
1479        .to_string();
1480        assert!(s.contains("connection refused"), "got: {s}");
1481        assert!(s.contains("hint:"), "got: {s}");
1482        assert!(!s.contains("revision"), "got: {s}");
1483
1484        let load = EmbedderError::Load {
1485            model: "/models/embed".into(),
1486            source: "missing config.json".into(),
1487        }
1488        .to_string();
1489        assert!(!load.contains("re-download"), "got: {load}");
1490
1491        let inference = EmbedderError::Inference {
1492            source: "endpoint returned duplicate index 0".into(),
1493        }
1494        .to_string();
1495        assert!(!inference.contains("underpowered"), "got: {inference}");
1496    }
1497
1498    #[test]
1499    fn get_or_load_keyed_does_not_cache_failure_and_reports_latency_once() {
1500        let cache: Mutex<HashMap<String, LoadSlot<i32>>> = Mutex::new(HashMap::new());
1501        let boom = || {
1502            Err::<Arc<i32>, _>(EmbedderError::Inference {
1503                source: "boom".into(),
1504            })
1505        };
1506        // A failed load must NOT be cached.
1507        assert!(get_or_load_keyed(&cache, "k", boom).is_err());
1508        // The next call retries and loads; it reports the load latency.
1509        let (v, ms) =
1510            get_or_load_keyed(&cache, "k", || Ok::<_, EmbedderError>(Arc::new(7))).unwrap();
1511        assert_eq!(*v, 7);
1512        assert!(ms.is_some(), "the loading call reports latency");
1513        // Warm reuse keeps the first value and reports no latency.
1514        let (v2, ms2) =
1515            get_or_load_keyed(&cache, "k", || Ok::<_, EmbedderError>(Arc::new(999))).unwrap();
1516        assert_eq!(*v2, 7);
1517        assert!(ms2.is_none(), "warm reuse reports no load latency");
1518    }
1519
1520    #[test]
1521    fn distinct_key_loads_run_concurrently() {
1522        use std::sync::Barrier;
1523        use std::sync::atomic::{AtomicUsize, Ordering};
1524        use std::thread;
1525
1526        // Two loads for *different* keys must be able to run at the same time.
1527        // The barrier only releases once BOTH loads have entered — so if the
1528        // cache serializes distinct-key loads (map lock held across `load`), the
1529        // second thread can never start and the receive below times out.
1530        let cache: Arc<Mutex<HashMap<String, LoadSlot<i32>>>> =
1531            Arc::new(Mutex::new(HashMap::new()));
1532        let barrier = Arc::new(Barrier::new(2));
1533        let inflight = Arc::new(AtomicUsize::new(0));
1534        let (done_tx, done_rx) = mpsc::channel();
1535
1536        for (i, key) in ["a", "b"].into_iter().enumerate() {
1537            let cache = Arc::clone(&cache);
1538            let barrier = Arc::clone(&barrier);
1539            let inflight = Arc::clone(&inflight);
1540            let done_tx = done_tx.clone();
1541            thread::spawn(move || {
1542                let result = get_or_load_keyed(&cache, key, || {
1543                    inflight.fetch_add(1, Ordering::SeqCst);
1544                    barrier.wait(); // both distinct-key loads must be in-flight here
1545                    Ok::<_, EmbedderError>(Arc::new(i as i32))
1546                });
1547                let _ = done_tx.send(result.map(|(v, _)| *v));
1548            });
1549        }
1550        drop(done_tx);
1551
1552        let mut got = Vec::new();
1553        for _ in 0..2 {
1554            let value = done_rx
1555                .recv_timeout(Duration::from_secs(5))
1556                .expect("distinct-key loads did not run concurrently (map lock held across load)");
1557            got.push(value.unwrap());
1558        }
1559        got.sort_unstable();
1560        assert_eq!(got, vec![0, 1]);
1561        assert_eq!(inflight.load(Ordering::SeqCst), 2);
1562    }
1563
1564    #[test]
1565    fn same_key_load_is_single_flight() {
1566        use std::sync::Barrier;
1567        use std::sync::atomic::{AtomicUsize, Ordering};
1568        use std::thread;
1569
1570        // Many threads race the SAME key: `load` must run exactly once, and
1571        // exactly one caller reports a load latency; the rest see warm reuse.
1572        const THREADS: usize = 8;
1573        let cache: Arc<Mutex<HashMap<String, LoadSlot<i32>>>> =
1574            Arc::new(Mutex::new(HashMap::new()));
1575        let loads = Arc::new(AtomicUsize::new(0));
1576        let start = Arc::new(Barrier::new(THREADS));
1577        let (tx, rx) = mpsc::channel();
1578
1579        for _ in 0..THREADS {
1580            let cache = Arc::clone(&cache);
1581            let loads = Arc::clone(&loads);
1582            let start = Arc::clone(&start);
1583            let tx = tx.clone();
1584            thread::spawn(move || {
1585                start.wait(); // maximize the race on the cold miss
1586                let result = get_or_load_keyed(&cache, "shared", || {
1587                    loads.fetch_add(1, Ordering::SeqCst);
1588                    Ok::<_, EmbedderError>(Arc::new(42))
1589                });
1590                let (value, ms) = result.unwrap();
1591                let _ = tx.send((*value, ms.is_some()));
1592            });
1593        }
1594        drop(tx);
1595
1596        let mut reported = 0;
1597        for _ in 0..THREADS {
1598            let (value, loaded) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
1599            assert_eq!(value, 42);
1600            if loaded {
1601                reported += 1;
1602            }
1603        }
1604        assert_eq!(
1605            loads.load(Ordering::SeqCst),
1606            1,
1607            "load must run exactly once per key"
1608        );
1609        assert_eq!(reported, 1, "exactly one caller reports the load latency");
1610    }
1611
1612    #[test]
1613    fn load_event_flags_slow_ok_failed_and_warm() {
1614        // Above the 5s default → slow (underpowered-machine flag).
1615        assert!(matches!(
1616            embedder_load_event("m", Some(10_000), None),
1617            Some(TraceEvent::EmbedderLoad {
1618                status: EmbedderLoadStatus::Slow,
1619                took_ms: 10_000,
1620                ..
1621            })
1622        ));
1623        // Comfortably under → ok.
1624        assert!(matches!(
1625            embedder_load_event("m", Some(5), None),
1626            Some(TraceEvent::EmbedderLoad {
1627                status: EmbedderLoadStatus::Ok,
1628                ..
1629            })
1630        ));
1631        // A load error → failed, carrying the reason.
1632        let err = EmbedderError::Inference { source: "x".into() };
1633        assert!(matches!(
1634            embedder_load_event("m", None, Some(&err)),
1635            Some(TraceEvent::EmbedderLoad {
1636                status: EmbedderLoadStatus::Failed,
1637                ..
1638            })
1639        ));
1640        // Warm reuse → no event.
1641        assert!(embedder_load_event("m", None, None).is_none());
1642    }
1643
1644    #[test]
1645    fn endpoint_embeddings_are_normalized_and_ordered_by_index() {
1646        // An endpoint may return un-normalized vectors in any order; ingestion
1647        // must L2-normalize (so cosine==dot holds) and restore index order.
1648        let resp = EmbeddingsResponse {
1649            model: Some("resolved-model".into()),
1650            data: vec![
1651                EmbeddingData {
1652                    embedding: vec![0.0, 3.0], // index 1, un-normalized
1653                    index: 1,
1654                },
1655                EmbeddingData {
1656                    embedding: vec![4.0, 0.0], // index 0, un-normalized
1657                    index: 0,
1658                },
1659            ],
1660        };
1661        let out = parse_embeddings(resp, 2).expect("parse");
1662        assert_eq!(out.vectors[0], vec![1.0, 0.0], "index 0 first, normalized");
1663        assert_eq!(out.vectors[1], vec![0.0, 1.0], "index 1 second, normalized");
1664        assert_eq!(out.model.as_deref(), Some("resolved-model"));
1665    }
1666
1667    #[test]
1668    fn endpoint_response_count_mismatch_errors() {
1669        let resp = EmbeddingsResponse {
1670            model: None,
1671            data: vec![EmbeddingData {
1672                embedding: vec![1.0],
1673                index: 0,
1674            }],
1675        };
1676        assert!(matches!(
1677            parse_embeddings(resp, 2),
1678            Err(EmbedderError::Inference { .. })
1679        ));
1680    }
1681
1682    fn response(vectors: &[(usize, Vec<f32>)]) -> EmbeddingsResponse {
1683        EmbeddingsResponse {
1684            model: None,
1685            data: vectors
1686                .iter()
1687                .cloned()
1688                .map(|(index, embedding)| EmbeddingData { embedding, index })
1689                .collect(),
1690        }
1691    }
1692
1693    #[test]
1694    fn endpoint_response_requires_an_exact_index_permutation() {
1695        assert!(
1696            serde_json::from_value::<EmbeddingsResponse>(serde_json::json!({
1697                "data": [{ "embedding": [1.0] }]
1698            }))
1699            .is_err(),
1700            "index is required"
1701        );
1702        for malformed in [
1703            response(&[(0, vec![1.0]), (0, vec![1.0])]),
1704            response(&[(0, vec![1.0]), (2, vec![1.0])]),
1705        ] {
1706            assert!(matches!(
1707                parse_embeddings(malformed, 2),
1708                Err(EmbedderError::Inference { .. })
1709            ));
1710        }
1711    }
1712
1713    #[test]
1714    fn endpoint_response_rejects_invalid_vectors() {
1715        for malformed in [
1716            response(&[(0, vec![])]),
1717            response(&[(0, vec![0.0, 0.0])]),
1718            response(&[(0, vec![f32::NAN, 1.0])]),
1719            response(&[(0, vec![f32::INFINITY, 1.0])]),
1720            response(&[(0, vec![1.0, 0.0]), (1, vec![1.0])]),
1721        ] {
1722            let expected_len = malformed.data.len();
1723            assert!(matches!(
1724                parse_embeddings(malformed, expected_len),
1725                Err(EmbedderError::Inference { .. })
1726            ));
1727        }
1728    }
1729
1730    #[test]
1731    fn endpoint_rejects_a_response_over_64_mib() {
1732        let (url, requests_rx, server) = mock_endpoint(vec![MockReply::Oversized]);
1733        let embedder = EndpointEmbedder::new(
1734            url,
1735            "requested-model".into(),
1736            None,
1737            String::new(),
1738            String::new(),
1739        )
1740        .unwrap();
1741
1742        let err = embedder
1743            .embed_batch(&["one".to_string()])
1744            .expect_err("oversized response must fail");
1745        let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1746        server.join().unwrap();
1747
1748        assert!(err.to_string().contains("oversized"), "got: {err}");
1749        assert_eq!(requests.len(), 1);
1750    }
1751
1752    #[test]
1753    fn endpoint_batches_65_inputs_as_64_plus_1_and_preserves_global_order() {
1754        let (url, requests_rx, server) = mock_endpoint(vec![
1755            MockReply::Embeddings("resolved-model"),
1756            MockReply::Embeddings("resolved-model"),
1757        ]);
1758
1759        let embedder = EndpointEmbedder::new(
1760            url,
1761            "requested-model".into(),
1762            None,
1763            String::new(),
1764            String::new(),
1765        )
1766        .unwrap();
1767        let inputs = (0..65).map(|index| index.to_string()).collect::<Vec<_>>();
1768        let embedded = embedder.embed_batch_with_identity(&inputs).unwrap();
1769        let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1770        server.join().unwrap();
1771
1772        assert_eq!(
1773            requests
1774                .iter()
1775                .map(|request| request.inputs.len())
1776                .collect::<Vec<_>>(),
1777            vec![64, 1]
1778        );
1779        assert_eq!(
1780            requests
1781                .into_iter()
1782                .flat_map(|request| request.inputs)
1783                .collect::<Vec<_>>(),
1784            inputs
1785        );
1786        assert_eq!(embedded.value.len(), 65);
1787        assert!(embedded.fingerprint.contains("resolved-model"));
1788    }
1789
1790    #[test]
1791    fn second_chunk_failure_commits_neither_chunk_and_retry_sends_all_inputs() {
1792        let (url, requests_rx, server) = mock_endpoint(vec![
1793            MockReply::Embeddings("resolved-model"),
1794            MockReply::Status(500),
1795            MockReply::Embeddings("resolved-model"),
1796            MockReply::Embeddings("resolved-model"),
1797        ]);
1798        let mut registry = ToolRegistry::with_embedding(EmbeddingModel::Endpoint {
1799            url,
1800            model: "requested-model".into(),
1801            api_key_env: None,
1802            query_prefix: None,
1803            doc_prefix: None,
1804        });
1805        for index in 0..65 {
1806            registry.register(tool_for_endpoint(index));
1807        }
1808
1809        assert!(registry.build_embeddings().is_err());
1810        registry.build_embeddings().unwrap();
1811        let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1812        server.join().unwrap();
1813
1814        assert_eq!(
1815            requests
1816                .iter()
1817                .map(|request| request.inputs.len())
1818                .collect::<Vec<_>>(),
1819            vec![64, 1, 64, 1]
1820        );
1821    }
1822
1823    #[test]
1824    fn endpoint_cache_separates_api_key_env_names_and_sends_each_bearer_token() {
1825        const KEY_A: &str = "RATEL_CORE_ENDPOINT_TEST_KEY_A";
1826        const KEY_B: &str = "RATEL_CORE_ENDPOINT_TEST_KEY_B";
1827        // Unique test-only names are not read by any other thread in the process.
1828        unsafe {
1829            std::env::set_var(KEY_A, "alpha-token");
1830            std::env::set_var(KEY_B, "beta-token");
1831        }
1832        let (url, requests_rx, server) = mock_endpoint(vec![
1833            MockReply::Embeddings("resolved-model"),
1834            MockReply::Embeddings("resolved-model"),
1835        ]);
1836        for (id, env_name) in [("a", KEY_A), ("b", KEY_B)] {
1837            let mut registry = ToolRegistry::with_embedding(EmbeddingModel::Endpoint {
1838                url: url.clone(),
1839                model: "requested-model".into(),
1840                api_key_env: Some(env_name.into()),
1841                query_prefix: None,
1842                doc_prefix: None,
1843            });
1844            registry.register(Tool {
1845                id: id.into(),
1846                name: id.into(),
1847                description: "endpoint auth test".into(),
1848                input_schema: serde_json::json!({}),
1849                output_schema: serde_json::json!({}),
1850            });
1851            registry.build_embeddings().unwrap();
1852        }
1853        let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1854        server.join().unwrap();
1855        unsafe {
1856            std::env::remove_var(KEY_A);
1857            std::env::remove_var(KEY_B);
1858        }
1859
1860        assert_eq!(
1861            requests
1862                .into_iter()
1863                .map(|request| request.authorization)
1864                .collect::<Vec<_>>(),
1865            vec![
1866                Some("Bearer alpha-token".into()),
1867                Some("Bearer beta-token".into())
1868            ]
1869        );
1870    }
1871
1872    #[test]
1873    fn response_model_drift_is_hard_and_rebuild_adopts_the_new_identity() {
1874        let (url, requests_rx, server) = mock_endpoint(vec![
1875            MockReply::Embeddings("model-a"),
1876            MockReply::Embeddings("model-b"),
1877            MockReply::Embeddings("model-b"),
1878            MockReply::Embeddings("model-b"),
1879        ]);
1880        let mut registry = ToolRegistry::with_embedding(EmbeddingModel::Endpoint {
1881            url,
1882            model: "requested-model".into(),
1883            api_key_env: None,
1884            query_prefix: None,
1885            doc_prefix: None,
1886        });
1887        registry.register(tool_for_endpoint(0));
1888        registry.build_embeddings().unwrap();
1889
1890        assert!(matches!(
1891            registry.search_with_method("tool", 1, Origin::Direct, SearchMethod::Semantic),
1892            Err(EmbedderError::ModelMismatch { .. })
1893        ));
1894        registry.rebuild_embeddings().unwrap();
1895        assert_eq!(
1896            registry
1897                .search_with_method("tool", 1, Origin::Direct, SearchMethod::Semantic)
1898                .unwrap()
1899                .len(),
1900            1
1901        );
1902        let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1903        server.join().unwrap();
1904        assert_eq!(requests.len(), 4);
1905    }
1906
1907    fn tool_for_endpoint(index: usize) -> Tool {
1908        Tool {
1909            id: format!("tool-{index}"),
1910            name: format!("tool-{index}"),
1911            description: format!("endpoint tool {index}"),
1912            input_schema: serde_json::json!({}),
1913            output_schema: serde_json::json!({}),
1914        }
1915    }
1916
1917    #[derive(Clone, Copy)]
1918    enum MockReply {
1919        Embeddings(&'static str),
1920        Status(u16),
1921        Oversized,
1922    }
1923
1924    struct MockRequest {
1925        inputs: Vec<String>,
1926        authorization: Option<String>,
1927    }
1928
1929    type MockServer = (
1930        String,
1931        mpsc::Receiver<Vec<MockRequest>>,
1932        std::thread::JoinHandle<()>,
1933    );
1934
1935    fn mock_endpoint(replies: Vec<MockReply>) -> MockServer {
1936        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1937        listener.set_nonblocking(true).unwrap();
1938        let url = format!("http://{}/v1/embeddings", listener.local_addr().unwrap());
1939        let (requests_tx, requests_rx) = mpsc::channel();
1940        let server = std::thread::spawn(move || {
1941            let deadline = Instant::now() + Duration::from_secs(5);
1942            let mut replies = std::collections::VecDeque::from(replies);
1943            let mut requests = Vec::new();
1944            while let Some(reply) = replies.front().copied() {
1945                match listener.accept() {
1946                    Ok((mut stream, _)) => {
1947                        stream.set_nonblocking(false).unwrap();
1948                        let (body, authorization) = read_http_request(&mut stream);
1949                        let inputs = body["input"]
1950                            .as_array()
1951                            .expect("input array")
1952                            .iter()
1953                            .map(|value| value.as_str().expect("string input").to_string())
1954                            .collect::<Vec<_>>();
1955                        write_mock_response(&mut stream, reply, inputs.len());
1956                        requests.push(MockRequest {
1957                            inputs,
1958                            authorization,
1959                        });
1960                        replies.pop_front();
1961                    }
1962                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1963                        if Instant::now() >= deadline {
1964                            break;
1965                        }
1966                        std::thread::sleep(Duration::from_millis(5));
1967                    }
1968                    Err(error) => panic!("accept failed: {error}"),
1969                }
1970            }
1971            requests_tx.send(requests).unwrap();
1972        });
1973        (url, requests_rx, server)
1974    }
1975
1976    fn write_mock_response(stream: &mut std::net::TcpStream, reply: MockReply, input_len: usize) {
1977        let (status, response) = match reply {
1978            MockReply::Embeddings(model) => {
1979                let data = (0..input_len)
1980                    .map(|index| {
1981                        serde_json::json!({
1982                            "index": index,
1983                            "embedding": [1.0, 0.0]
1984                        })
1985                    })
1986                    .collect::<Vec<_>>();
1987                (
1988                    "200 OK",
1989                    serde_json::json!({ "data": data, "model": model }).to_string(),
1990                )
1991            }
1992            MockReply::Status(code) => {
1993                ("500 Internal Server Error", format!("{{\"code\":{code}}}"))
1994            }
1995            MockReply::Oversized => {
1996                write_oversized_response(stream);
1997                return;
1998            }
1999        };
2000        write!(
2001            stream,
2002            "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
2003            response.len(),
2004            response
2005        )
2006        .unwrap();
2007    }
2008
2009    fn write_oversized_response(stream: &mut std::net::TcpStream) {
2010        const PREFIX: &[u8] = b"{\"data\":[],\"padding\":\"";
2011        const SUFFIX: &[u8] = b"\"}";
2012        const CHUNK: &[u8] = &[b'x'; 64 * 1024];
2013        let padding_len = ENDPOINT_RESPONSE_LIMIT_BYTES;
2014        let content_len = PREFIX.len() as u64 + padding_len + SUFFIX.len() as u64;
2015        write!(
2016            stream,
2017            "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {content_len}\r\nconnection: close\r\n\r\n"
2018        )
2019        .unwrap();
2020        if stream.write_all(PREFIX).is_err() {
2021            return;
2022        }
2023        let mut remaining = padding_len;
2024        while remaining > 0 {
2025            let len = remaining.min(CHUNK.len() as u64) as usize;
2026            if stream.write_all(&CHUNK[..len]).is_err() {
2027                return;
2028            }
2029            remaining -= len as u64;
2030        }
2031        let _ = stream.write_all(SUFFIX);
2032    }
2033
2034    fn read_http_request(stream: &mut std::net::TcpStream) -> (serde_json::Value, Option<String>) {
2035        stream
2036            .set_read_timeout(Some(Duration::from_secs(2)))
2037            .unwrap();
2038        let mut request = Vec::new();
2039        let mut buffer = [0_u8; 4096];
2040        loop {
2041            let read = stream.read(&mut buffer).unwrap();
2042            assert!(read > 0, "connection closed before request body");
2043            request.extend_from_slice(&buffer[..read]);
2044            if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
2045                let body_start = header_end + 4;
2046                let headers = std::str::from_utf8(&request[..header_end]).unwrap();
2047                let content_len = headers
2048                    .lines()
2049                    .find_map(|line| {
2050                        let (name, value) = line.split_once(':')?;
2051                        name.eq_ignore_ascii_case("content-length")
2052                            .then(|| value.trim().parse::<usize>().unwrap())
2053                    })
2054                    .expect("content-length");
2055                if request.len() >= body_start + content_len {
2056                    let authorization = headers.lines().find_map(|line| {
2057                        let (name, value) = line.split_once(':')?;
2058                        name.eq_ignore_ascii_case("authorization")
2059                            .then(|| value.trim().to_string())
2060                    });
2061                    let body =
2062                        serde_json::from_slice(&request[body_start..body_start + content_len])
2063                            .unwrap();
2064                    return (body, authorization);
2065                }
2066            }
2067        }
2068    }
2069
2070    #[test]
2071    fn mean_pool_averages_only_unmasked_tokens() {
2072        let dev = Device::Cpu;
2073        // (1, 2, 2): two tokens, hidden = 2.
2074        let seq = Tensor::new(&[[[1.0f32, 2.0], [3.0, 4.0]]], &dev).unwrap();
2075        // Both tokens count → column means [2, 3].
2076        let all = Tensor::new(&[[1u32, 1]], &dev).unwrap();
2077        assert_eq!(
2078            mean_pool(&seq, &all).unwrap().to_vec1::<f32>().unwrap(),
2079            vec![2.0, 3.0]
2080        );
2081        // Only the first token counts (second is padding) → [1, 2].
2082        let first = Tensor::new(&[[1u32, 0]], &dev).unwrap();
2083        assert_eq!(
2084            mean_pool(&seq, &first).unwrap().to_vec1::<f32>().unwrap(),
2085            vec![1.0, 2.0]
2086        );
2087    }
2088
2089    #[test]
2090    fn mean_pool_batch_averages_each_row_over_its_own_unmasked_tokens() {
2091        let dev = Device::Cpu;
2092        // (2, 2, 2): two rows, two tokens, hidden = 2.
2093        let seq = Tensor::new(
2094            &[[[1.0f32, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]],
2095            &dev,
2096        )
2097        .unwrap();
2098        // Row 0: both tokens count → [2, 3]. Row 1: second token padded → [5, 6].
2099        let mask = Tensor::new(&[[1u32, 1], [1, 0]], &dev).unwrap();
2100        assert_eq!(
2101            mean_pool_batch(&seq, &mask)
2102                .unwrap()
2103                .to_vec2::<f32>()
2104                .unwrap(),
2105            vec![vec![2.0, 3.0], vec![5.0, 6.0]]
2106        );
2107    }
2108
2109    #[test]
2110    fn parse_pooling_config_maps_cls_mean_and_none() {
2111        assert_eq!(
2112            parse_pooling_config(br#"{"pooling_mode_cls_token": true}"#),
2113            Some(Pooling::Cls)
2114        );
2115        assert_eq!(
2116            parse_pooling_config(br#"{"pooling_mode_mean_tokens": true}"#),
2117            Some(Pooling::Mean)
2118        );
2119        // A mode we don't support (max) → None → caller assumes Mean.
2120        assert_eq!(
2121            parse_pooling_config(br#"{"pooling_mode_max_tokens": true}"#),
2122            None
2123        );
2124        assert_eq!(parse_pooling_config(b"not json"), None);
2125    }
2126
2127    #[test]
2128    fn resolve_pooling_assumes_mean_and_flags_it() {
2129        assert_eq!(resolve_pooling(Some(Pooling::Cls)), (Pooling::Cls, false));
2130        assert_eq!(resolve_pooling(Some(Pooling::Mean)), (Pooling::Mean, false));
2131        assert_eq!(resolve_pooling(None), (Pooling::Mean, true));
2132    }
2133
2134    #[test]
2135    fn is_not_found_distinguishes_missing_file_from_network_error() {
2136        assert!(is_not_found("Http status client error (404 Not Found)"));
2137        assert!(is_not_found("Entry Not Found"));
2138        assert!(!is_not_found("error sending request: connection refused"));
2139    }
2140
2141    #[test]
2142    fn endpoint_missing_api_key_env_is_a_config_error() {
2143        let e = EndpointEmbedder::new(
2144            "http://localhost:11434/v1/embeddings".into(),
2145            "nomic".into(),
2146            Some("RATEL_TEST_DEFINITELY_UNSET_KEY".into()),
2147            String::new(),
2148            String::new(),
2149        )
2150        .unwrap();
2151        let err = e.api_key().unwrap_err();
2152        assert!(matches!(err, EmbedderError::Config { .. }));
2153        assert!(err.to_string().contains("RATEL_TEST_DEFINITELY_UNSET_KEY"));
2154    }
2155
2156    #[test]
2157    #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2158    fn embeds_to_unit_norm_384_vectors_deterministically() {
2159        let e = embedder_for(&EmbeddingModel::Default)
2160            .expect("load embedder")
2161            .0;
2162        let a = e.embed_doc("read a file from disk").expect("embed");
2163        let b = e.embed_doc("read a file from disk").expect("embed");
2164        assert_eq!(a.len(), 384, "bge-small is 384-dim");
2165        assert_eq!(a, b, "same text must embed identically (determinism)");
2166        let norm = a.iter().map(|x| x * x).sum::<f32>().sqrt();
2167        assert!((norm - 1.0).abs() < 1e-3, "expected unit norm, got {norm}");
2168    }
2169
2170    #[test]
2171    #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2172    fn embed_batch_matches_looping_embed_doc_cls() {
2173        // The batched forward must be bit-for-bit identical to embedding each doc
2174        // alone (CLS pooling, built-in bge-small). Differing lengths exercise padding;
2175        // > EMBED_BATCH_CHUNK items span a chunk boundary.
2176        let e = embedder_for(&EmbeddingModel::Default)
2177            .expect("load embedder")
2178            .0;
2179        let docs: Vec<String> = (0..EMBED_BATCH_CHUNK + 5)
2180            .map(|i| format!("{}read a file from disk", "word ".repeat(i % 7)))
2181            .collect();
2182        let batched = e.embed_batch(&docs).expect("embed_batch");
2183        let looped: Vec<Vec<f32>> = docs
2184            .iter()
2185            .map(|d| e.embed_doc(d).expect("embed_doc"))
2186            .collect();
2187        assert_eq!(
2188            batched, looped,
2189            "batched embedding must be bit-for-bit identical to the per-doc path"
2190        );
2191    }
2192
2193    #[test]
2194    #[ignore = "downloads a mean-pooled model (gte-small); run with `cargo test -- --ignored`"]
2195    fn embed_batch_matches_looping_embed_doc_mean() {
2196        // Same exact-equality contract on the mean-pooled path (gte-small).
2197        let model = EmbeddingModel::HuggingFace {
2198            repo: "thenlper/gte-small".into(),
2199            revision: None,
2200            query_prefix: None,
2201            doc_prefix: None,
2202            pooling: None,  // auto-detected → Mean
2203            download: true, // ignored test: allow the fetch
2204        };
2205        let e = embedder_for(&model).expect("load gte-small").0;
2206        let docs: Vec<String> = (0..EMBED_BATCH_CHUNK + 3)
2207            .map(|i| format!("{}deploy the service", "x ".repeat(i % 5)))
2208            .collect();
2209        let batched = e.embed_batch(&docs).expect("embed_batch");
2210        let looped: Vec<Vec<f32>> = docs
2211            .iter()
2212            .map(|d| e.embed_doc(d).expect("embed_doc"))
2213            .collect();
2214        assert_eq!(
2215            batched, looped,
2216            "mean-pooled batched embedding must be bit-for-bit identical to the per-doc path"
2217        );
2218    }
2219
2220    #[test]
2221    #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2222    fn query_prefix_changes_the_embedding() {
2223        let e = embedder_for(&EmbeddingModel::Default)
2224            .expect("load embedder")
2225            .0;
2226        let doc = e.embed_doc("delete a file").expect("embed");
2227        let query = e.embed_query("delete a file").expect("embed");
2228        assert_ne!(doc, query, "query instruction prefix must shift the vector");
2229    }
2230
2231    #[test]
2232    #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2233    fn ranks_synonyms_above_lexically_unrelated_text() {
2234        // The "missing gold" case BM25 can't see: query and doc share no words.
2235        let e = embedder_for(&EmbeddingModel::Default)
2236            .expect("load embedder")
2237            .0;
2238        let q = e.embed_query("remove a file").expect("embed");
2239        let delete = e
2240            .embed_doc("delete a path from the filesystem")
2241            .expect("embed");
2242        let weather = e
2243            .embed_doc("get the current weather forecast")
2244            .expect("embed");
2245        let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>();
2246        assert!(
2247            dot(&q, &delete) > dot(&q, &weather),
2248            "semantic match should beat an unrelated tool"
2249        );
2250    }
2251
2252    #[test]
2253    #[ignore = "downloads a mean-pooled model (gte-small); run with `cargo test -- --ignored`"]
2254    fn mean_pooled_model_ranks_synonyms_correctly() {
2255        // gte-small is *mean*-pooled and ships `1_Pooling/config.json`, so pooling
2256        // must auto-detect Mean — CLS-on-a-mean-model is the silent-quality bug this
2257        // guards against. Assert a synonym query still ranks the related doc first.
2258        let model = EmbeddingModel::HuggingFace {
2259            repo: "thenlper/gte-small".into(),
2260            revision: None,
2261            query_prefix: None,
2262            doc_prefix: None,
2263            pooling: None,  // auto-detected → Mean
2264            download: true, // ignored test: allow the fetch
2265        };
2266        let e = embedder_for(&model).expect("load gte-small").0;
2267        let q = e.embed_query("remove a file").expect("embed");
2268        let delete = e
2269            .embed_doc("delete a path from the filesystem")
2270            .expect("embed");
2271        let weather = e
2272            .embed_doc("get the current weather forecast")
2273            .expect("embed");
2274        let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>();
2275        assert!(
2276            dot(&q, &delete) > dot(&q, &weather),
2277            "mean-pooled semantic match should beat an unrelated tool"
2278        );
2279    }
2280}