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