Skip to main content

mahbot/
embedder.rs

1//! Semantic (vector/embedding) search for archived tickets.
2//!
3//! # Why this exists
4//!
5//! This module provides a local Candle + GGUF-based embedding model that converts
6//! ticket descriptions into dense vectors. These vectors enable **semantic search** —
7//! finding archived tickets by *conceptual similarity* (e.g. `"authentication bug"`
8//! matching `"login issue"`) — which pure FTS keyword search alone cannot do.
9//!
10//! The embedding model uses **jinaai/jina-embeddings-v5-text-nano-retrieval**
11//! (Q4_K_M GGUF, ~150 MB), a EuroBERT architecture (LLaMA-style encoder without
12//! causal masking) loaded via the Candle framework. The model and tokenizer are
13//! downloaded on first use and cached in `~/.mahbot/models/`.
14//!
15//! The embedder is loaded **lazily on first `embed()` call** and cached in a global
16//! [`RwLock`]; embedding is computed at **ticket creation time** (to pre-compute a
17//! vector for future archived search) and at **search-query time** (to vectorize
18//! the query for `SearchArchivedTicketsTool`). Both paths gracefully degrade: if
19//! the model files haven't been downloaded yet (or download fails), `embed()`
20//! returns `None` and the caller falls back to FTS-only search. A background retry
21//! loop downloads the model with exponential backoff, making the embedder available
22//! without requiring a restart.
23//!
24//! ## Product decision
25//!
26//! **Do not propose removing this module or its dependencies without explicit
27//! user approval.** It is a deliberate product decision, not accidental bloat
28//! or dead code.
29
30use crate::util::UnwrapPoison;
31use anyhow::{Context, Result, anyhow};
32use candle_core::quantized::{QMatMul, gguf_file};
33use candle_core::{DType, Device, Tensor};
34use candle_nn::{Embedding, Module};
35use futures_util::StreamExt;
36use std::collections::HashMap;
37use std::path::{Path, PathBuf};
38use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
39use std::sync::{OnceLock, RwLock};
40use std::time::Duration;
41use tokenizers::Tokenizer;
42use tracing::{debug, info, warn};
43
44// ── Constants ────────────────────────────────────────────────────────
45
46/// Maximum sequence length for the embedding model (jina-embeddings-v5 supports 8192).
47const MAX_SEQ_LEN: usize = 8192;
48
49/// RoPE base frequency for the model.
50const ROPE_FREQ_BASE: f32 = 1_000_000.0;
51
52/// Timeout for model file download (10 minutes for ~150 MB).
53const MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_mins(10);
54
55/// Default pad token ID for this model (tokenizer's eos_token_id = 128001).
56const DEFAULT_PAD_ID: u32 = 128_001;
57
58// ── Model URLs ───────────────────────────────────────────────────────
59
60/// HuggingFace URL for the quantized GGUF model file.
61const MODEL_URL: &str = "https://huggingface.co/jinaai/jina-embeddings-v5-text-nano-retrieval/resolve/main/v5-nano-retrieval-Q4_K_M.gguf";
62
63/// SHA256 checksum of the model file (verified at download time).
64const MODEL_SHA256: &str = "f50822244ba0c7a348c5455b99bb8a0afd182511e8a816888c5dc65d972e51d5";
65
66/// HuggingFace URL for the tokenizer file.
67const TOKENIZER_URL: &str = "https://huggingface.co/jinaai/jina-embeddings-v5-text-nano-retrieval/resolve/main/tokenizer.json";
68
69/// SHA256 checksum of the tokenizer file (verified at download time).
70const TOKENIZER_SHA256: &str = "98d4a1d32152d6cedf85b5e88f3b205106dca1fe72aaab34e0ac13c238421069";
71
72// ── Global state ─────────────────────────────────────────────────────
73
74/// Embedder state machine.
75///
76/// 0 = UNINIT (first call triggers load/download)
77/// 1 = LOADING (background download or sync load in progress)
78/// 2 = READY (embedder is available)
79const STATE_UNINIT: u8 = 0;
80const STATE_LOADING: u8 = 1;
81const STATE_READY: u8 = 2;
82
83/// Global embedder singleton, wrapped in an Option for graceful degradation.
84static GLOBAL_EMBEDDER: OnceLock<RwLock<Option<Embedder>>> = OnceLock::new();
85
86/// Atomic state tracker to coordinate lazy initialization.
87static STATE: AtomicU8 = AtomicU8::new(STATE_UNINIT);
88
89/// Whether a background download has been spawned.
90static DOWNLOAD_SPAWNED: AtomicBool = AtomicBool::new(false);
91
92/// Returns a reference to the global singleton [`Embedder`] RwLock. Never panics.
93#[must_use]
94pub fn global_embedder() -> &'static RwLock<Option<Embedder>> {
95    GLOBAL_EMBEDDER.get_or_init(|| RwLock::new(None))
96}
97
98/// Try to initialize the embedder (sync load from cache or spawn background download).
99///
100/// Called on every [`embed()`] invocation. Returns `true` if the embedder is
101/// ready, `false` if it's still loading or permanently unavailable.
102fn ensure_embedder() -> bool {
103    // Fast path: already ready
104    if STATE.load(Ordering::Acquire) == STATE_READY {
105        return true;
106    }
107
108    // Already loading (or failed) — return false, embed() will return None.
109    // The background retry loop will eventually set state to READY.
110    if STATE.load(Ordering::Acquire) != STATE_UNINIT {
111        return false;
112    }
113
114    // Try to become the initializer (atomic CAS to prevent races)
115    if STATE
116        .compare_exchange(
117            STATE_UNINIT,
118            STATE_LOADING,
119            Ordering::AcqRel,
120            Ordering::Acquire,
121        )
122        .is_err()
123    {
124        return false;
125    }
126
127    // Thread-local: try to load cached files synchronously
128    let Some(models_dir) = models_dir() else {
129        // CONFIG not initialized yet — can't locate model cache.
130        STATE.store(STATE_UNINIT, Ordering::Release);
131        return false;
132    };
133    let model_path = models_dir.join("v5-nano-retrieval-Q4_K_M.gguf");
134    let tokenizer_path = models_dir.join("embed_tokenizer.json");
135
136    std::fs::create_dir_all(&models_dir).ok();
137
138    let cache_loaded = if model_path.exists() && tokenizer_path.exists() {
139        match Embedder::load(&model_path, &tokenizer_path) {
140            Ok(emb) => {
141                *global_embedder().write().unwrap_poison() = Some(emb);
142                STATE.store(STATE_READY, Ordering::Release);
143                true
144            }
145            Err(e) => {
146                warn!(reason = %e, "Failed to load cached embedding model");
147                // Don't delete cached files here — let the retry loop attempt
148                // to load again with backoff. The files passed SHA256 verification
149                // at download time, so the failure is likely a code-level issue
150                // (not corruption). Deleting on every transient error would force
151                // an unnecessary ~167 MB re-download with 1-minute minimum delay.
152                false
153            }
154        }
155    } else {
156        false
157    };
158
159    if cache_loaded {
160        return true;
161    }
162
163    // Spawn background download (only once)
164    if !DOWNLOAD_SPAWNED.swap(true, Ordering::AcqRel) {
165        if tokio::runtime::Handle::try_current().is_ok() {
166            tokio::spawn(download_retry_loop());
167        } else {
168            // No tokio runtime available (e.g., in unit tests without runtime).
169            // The download will be triggered on the next call when a runtime exists.
170            // Reset state to UNINIT so the next caller retries the cache check + spawn.
171            // Reset both atomics: DOWNLOAD_SPAWNED first (via AcqRel swap), then STATE.
172            // The swap provides an atomic full barrier: any concurrent thread calling
173            // DOWNLOAD_SPAWNED.swap(true, ...) either sees the old true and skips, or
174            // sees false after our store and will attempt to spawn. After the barrier,
175            // STATE is set to UNINIT so the next caller re-enters ensure_embedder().
176            DOWNLOAD_SPAWNED.store(false, Ordering::Release);
177            STATE.store(STATE_UNINIT, Ordering::Release);
178        }
179    }
180
181    false
182}
183
184// ── Public API ───────────────────────────────────────────────────────
185
186/// Embed a single text using the global embedder singleton.
187///
188/// `is_query` controls whether the text is embedded as a query (prefixed with
189/// `"Query: "`) or as a document (prefixed with `"Document: "`), as required
190/// by the embedding model's training.
191///
192/// Returns `None` if:
193/// - The model hasn't been downloaded yet (first call triggers background download).
194/// - Download is in progress.
195/// - Model loading failed (corrupted file, etc.).
196/// - The embedder mutex is poisoned.
197#[must_use]
198pub fn embed(text: &str, is_query: bool) -> Option<Vec<f32>> {
199    if !ensure_embedder() {
200        return None;
201    }
202
203    let guard = global_embedder().read().unwrap_poison();
204    let emb = guard.as_ref()?;
205    let v = if is_query {
206        emb.embed_queries(&[text]).ok()?
207    } else {
208        emb.embed_documents(&[text]).ok()?
209    };
210    v.into_iter().next()
211}
212
213// ── Background download with retry ────────────────────────────────────
214
215/// Background retry loop that downloads model and tokenizer files.
216///
217/// Uses exponential backoff (1 min → 2 min → 4 min → … → 30 min max).
218/// Continues indefinitely until both files are downloaded successfully.
219async fn download_retry_loop() {
220    let models_dir =
221        models_dir().expect("CONFIG storage_root must be set before download_retry_loop runs");
222    std::fs::create_dir_all(&models_dir).ok();
223
224    let model_dest = models_dir.join("v5-nano-retrieval-Q4_K_M.gguf");
225    let tokenizer_dest = models_dir.join("embed_tokenizer.json");
226
227    // Shared HTTP client reused across retries (avoids new TLS handshake per iteration).
228    let client = reqwest::Client::builder()
229        .timeout(MODEL_DOWNLOAD_TIMEOUT)
230        .connect_timeout(Duration::from_secs(30))
231        .build()
232        .expect("Failed to build reqwest::Client for model download");
233
234    let mut delay = Duration::from_mins(1);
235    let max_delay = Duration::from_mins(30); // 30 minutes
236
237    // Pre-check which files already exist (from a previous partial success).
238    // This avoids re-downloading valid files on retry iterations.
239    let mut model_has = model_dest.exists() && tokenizer_dest.exists();
240
241    loop {
242        if model_has {
243            // Both files already present from a previous iteration — try to load.
244            if let Ok(emb) = Embedder::load(&model_dest, &tokenizer_dest) {
245                info!("Embedding model loaded successfully (from previously downloaded files)");
246                *global_embedder().write().unwrap_poison() = Some(emb);
247                STATE.store(STATE_READY, Ordering::Release);
248                return;
249            }
250            // Loading failed — could be a code bug, not necessarily corrupted files.
251            // Don't delete cached files; the backoff will apply and we'll retry.
252            warn!("Failed to load embedding model from cached files, retrying with backoff");
253        }
254
255        // Download both files concurrently, skipping files that already exist.
256        let (model_result, tokenizer_result) = tokio::join!(
257            maybe_download(&client, MODEL_URL, &model_dest, Some(MODEL_SHA256)),
258            maybe_download(
259                &client,
260                TOKENIZER_URL,
261                &tokenizer_dest,
262                Some(TOKENIZER_SHA256)
263            ),
264        );
265
266        if let (Err(e_model), Err(e_tokenizer)) = (&model_result, &tokenizer_result) {
267            warn!(
268                model_error = %e_model,
269                tokenizer_error = %e_tokenizer,
270                retry_after_secs = delay.as_secs(),
271                "Failed to download embedding model files, retrying"
272            );
273        } else if let Err(e) = &model_result {
274            warn!(error = %e, retry_after_secs = delay.as_secs(), "Failed to download embedding model, retrying");
275        } else if let Err(e) = &tokenizer_result {
276            warn!(error = %e, retry_after_secs = delay.as_secs(), "Failed to download tokenizer, retrying");
277        }
278
279        let model_ok = model_result.is_ok();
280        let tokenizer_ok = tokenizer_result.is_ok();
281
282        if model_ok && tokenizer_ok {
283            // Both downloaded successfully — try to load the embedder
284            match Embedder::load(&model_dest, &tokenizer_dest) {
285                Ok(emb) => {
286                    info!("Embedding model loaded successfully after download");
287                    *global_embedder().write().unwrap_poison() = Some(emb);
288                    STATE.store(STATE_READY, Ordering::Release);
289                    return;
290                }
291                Err(e) => {
292                    warn!(reason = %e, "Failed to load model after download, retrying with backoff (files preserved)");
293                    // Don't delete cached files — load failure may be a code bug,
294                    // not file corruption. The backoff will apply and we'll retry.
295                }
296            }
297        } else {
298            // Partial failure: at least one download failed.
299            // Don't delete successfully downloaded files — maybe_download
300            // skips existing files on the next iteration, so a valid file
301            // from a partial success is reused without re-downloading.
302            if !model_ok {
303                // Clean up .tmp file that download_file may have left on error
304                let _ = std::fs::remove_file(model_dest.with_extension("tmp"));
305            }
306            if !tokenizer_ok {
307                let _ = std::fs::remove_file(tokenizer_dest.with_extension("tmp"));
308            }
309        }
310
311        // Track which files exist for the next iteration's pre-check.
312        model_has = model_dest.exists() && tokenizer_dest.exists();
313
314        // Wait with exponential backoff
315        tokio::time::sleep(delay).await;
316        delay = (delay * 2).min(max_delay);
317    }
318}
319
320/// Download a file unless it already exists. Uses the shared HTTP client for
321/// connection reuse across retries.
322async fn maybe_download(
323    client: &reqwest::Client,
324    url: &str,
325    dest: &Path,
326    expected_sha256: Option<&str>,
327) -> Result<()> {
328    // Skip download if the file already exists (from a previous partial success).
329    if dest.exists() {
330        return Ok(());
331    }
332    download_file(client, url, dest, expected_sha256).await
333}
334
335/// Download a single file with atomic write and size verification.
336async fn download_file(
337    client: &reqwest::Client,
338    url: &str,
339    dest: &Path,
340    expected_sha256: Option<&str>,
341) -> Result<()> {
342    use sha2::{Digest, Sha256};
343
344    let response = client
345        .get(url)
346        .send()
347        .await
348        .context("Failed to send download request")?;
349
350    let status = response.status();
351    if !status.is_success() {
352        anyhow::bail!("HTTP {status} from {url}");
353    }
354
355    let total_size = response.content_length();
356
357    // Download to temporary file, then atomically rename
358    let tmp_path = dest.with_extension("tmp");
359    let mut file = tokio::fs::File::create(&tmp_path)
360        .await
361        .context("Failed to create temp file")?;
362
363    let mut downloaded: u64 = 0;
364    let mut hasher = expected_sha256.map(|_| Sha256::new());
365    let mut stream = response.bytes_stream();
366    while let Some(chunk) = stream.next().await {
367        let chunk = chunk.context("Download stream error")?;
368        let len = chunk.len() as u64;
369        downloaded += len;
370        if let Some(ref mut h) = hasher {
371            h.update(&chunk);
372        }
373        tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
374            .await
375            .context("Failed to write download chunk")?;
376    }
377
378    // Verify file size against Content-Length header
379    if let Some(expected) = total_size
380        && downloaded != expected
381    {
382        let _ = tokio::fs::remove_file(&tmp_path).await;
383        anyhow::bail!("Download size mismatch: expected {expected} bytes, got {downloaded} bytes");
384    }
385
386    // Verify SHA256 checksum if requested (computed during download stream above)
387    if let Some(expected_hex) = expected_sha256
388        && let Some(hasher) = hasher
389    {
390        let actual_hash = format!("{:x}", hasher.finalize());
391        if actual_hash != expected_hex {
392            let _ = tokio::fs::remove_file(&tmp_path).await;
393            anyhow::bail!("SHA256 mismatch: expected {expected_hex}, got {actual_hash}");
394        }
395    }
396
397    // Atomic rename
398    tokio::fs::rename(&tmp_path, dest)
399        .await
400        .context("Failed to rename temp file to final path")?;
401
402    info!(path = %dest.display(), size = downloaded, "Downloaded model file");
403    Ok(())
404}
405
406// ── Model paths ──────────────────────────────────────────────────────
407
408/// Returns the `~/.mahbot/models/` directory via CONFIG, or `None` if CONFIG
409/// storage root hasn't been initialized yet.
410fn models_dir() -> Option<PathBuf> {
411    crate::config::CONFIG
412        .try_storage_root()
413        .map(|root| root.join("models"))
414}
415
416// ── GGUF metadata helpers ────────────────────────────────────────────
417
418/// Extract a `u32` value from GGUF metadata.
419fn get_meta_u32(metadata: &HashMap<String, gguf_file::Value>, key: &str) -> Result<u32> {
420    metadata
421        .get(key)
422        .ok_or_else(|| anyhow!("Missing metadata key '{key}'"))?
423        .to_u32()
424        .map_err(|e| anyhow!("Failed to read metadata '{key}': {e}"))
425}
426
427/// Extract an `f32` value from GGUF metadata.
428fn get_meta_f32(metadata: &HashMap<String, gguf_file::Value>, key: &str) -> Result<f32> {
429    metadata
430        .get(key)
431        .ok_or_else(|| anyhow!("Missing metadata key '{key}'"))?
432        .to_f32()
433        .map_err(|e| anyhow!("Failed to read metadata '{key}': {e}"))
434}
435
436// ── EuroBERT / LLaMA-style encoder model ────────────────────────────
437
438/// A single transformer layer (EuroBERT = LLaMA-style encoder with SwiGLU MLP).
439#[derive(Debug)]
440struct Layer {
441    /// Attention Q projection (no bias).
442    attn_q: QMatMul,
443    /// Attention K projection (no bias).
444    attn_k: QMatMul,
445    /// Attention V projection (no bias).
446    attn_v: QMatMul,
447    /// Attention output projection (no bias).
448    attn_o: QMatMul,
449    /// Pre-attention RMSNorm weight (1D, hidden_size).
450    attn_norm: Tensor,
451    /// SwiGLU gate projection (no bias).
452    ffn_gate: QMatMul,
453    /// SwiGLU up projection (no bias).
454    ffn_up: QMatMul,
455    /// SwiGLU down projection (no bias).
456    ffn_down: QMatMul,
457    /// Pre-FFN RMSNorm weight (1D, hidden_size).
458    ffn_norm: Tensor,
459}
460
461impl Layer {
462    /// Forward pass through one encoder layer.
463    #[allow(clippy::many_single_char_names)]
464    fn forward(
465        &self,
466        x: &Tensor,
467        mask: &Tensor,
468        cos: &Tensor,
469        sin: &Tensor,
470        n_head: usize,
471        head_dim: usize,
472    ) -> Result<Tensor> {
473        // ── Self-attention with pre-norm ──
474        let residual = x;
475        let h = candle_nn::ops::rms_norm(x, &self.attn_norm, 1e-5)?;
476
477        // Project to Q, K, V
478        let q = self.attn_q.forward(&h)?;
479        let k = self.attn_k.forward(&h)?;
480        let v = self.attn_v.forward(&h)?;
481
482        // Multi-head attention
483        let h = Layer::apply_attention(&q, &k, &v, mask, cos, sin, n_head, head_dim)?;
484        let h = self.attn_o.forward(&h)?;
485        let h = (h + residual)?;
486
487        // ── SwiGLU MLP with pre-norm ──
488        let residual = &h;
489        let h = candle_nn::ops::rms_norm(&h, &self.ffn_norm, 1e-5)?;
490
491        let gate = self.ffn_gate.forward(&h)?;
492        let up = self.ffn_up.forward(&h)?;
493        let h = self
494            .ffn_down
495            .forward(&(candle_nn::ops::silu(&gate)? * up)?)?;
496        let h = (h + residual)?;
497
498        Ok(h)
499    }
500
501    /// Bidirectional multi-head attention with RoPE (no KV cache).
502    #[allow(clippy::too_many_arguments)]
503    fn apply_attention(
504        q: &Tensor,
505        k: &Tensor,
506        v: &Tensor,
507        mask: &Tensor,
508        cos: &Tensor,
509        sin: &Tensor,
510        n_head: usize,
511        head_dim: usize,
512    ) -> Result<Tensor> {
513        let (b_sz, seq_len, n_embd) = q.shape().dims3()?;
514
515        // Reshape: [batch, seq, n_head * head_dim] -> [batch, seq, n_head, head_dim] -> [batch, n_head, seq, head_dim]
516        let q = q
517            .reshape((b_sz, seq_len, n_head, head_dim))?
518            .transpose(1, 2)?;
519        let k = k
520            .reshape((b_sz, seq_len, n_head, head_dim))?
521            .transpose(1, 2)?;
522        let v = v
523            .reshape((b_sz, seq_len, n_head, head_dim))?
524            .transpose(1, 2)?
525            .contiguous()?;
526
527        // Apply RoPE
528        let q = Self::apply_rotary_emb(&q, cos, sin)?;
529        let k = Self::apply_rotary_emb(&k, cos, sin)?;
530
531        // Scaled dot-product attention (no causal mask — full bidirectional)
532        #[allow(clippy::cast_precision_loss)]
533        let scale = 1.0_f64 / (head_dim as f64).sqrt();
534        let att = q.matmul(&k.t()?)?;
535        let att = (att * scale)?;
536        let mask = mask.broadcast_as(att.shape())?;
537        let att = (att + mask)?;
538        let att = candle_nn::ops::softmax_last_dim(&att)?;
539        let y = att.matmul(&v)?;
540
541        // Reshape back: [batch, n_head, seq, head_dim] -> [batch, seq, n_embd]
542        let y = y.transpose(1, 2)?.reshape((b_sz, seq_len, n_embd))?;
543        Ok(y)
544    }
545
546    /// Apply rotary position embeddings.
547    fn apply_rotary_emb(x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
548        let (_b_sz, _n_head, seq_len, head_dim) = x.shape().dims4()?;
549        let cos = cos.narrow(0, 0, seq_len)?;
550        let sin = sin.narrow(0, 0, seq_len)?;
551
552        // Reshape to broadcast over batch and head dimensions
553        let cos = cos.reshape((1, 1, seq_len, head_dim / 2))?;
554        let sin = sin.reshape((1, 1, seq_len, head_dim / 2))?;
555
556        let x_f32 = x.to_dtype(DType::F32)?;
557        // Split along head_dim into two halves: [0..d/2) and [d/2..d)
558        let chunks = x_f32.chunk(2, 3)?;
559        let x1 = &chunks[0];
560        let x2 = &chunks[1];
561        let y1 = (x1.broadcast_mul(&cos)? - x2.broadcast_mul(&sin)?)?;
562        let y2 = (x1.broadcast_mul(&sin)? + x2.broadcast_mul(&cos)?)?;
563        let result = Tensor::cat(&[&y1, &y2], 3)?;
564        // Convert back to original dtype
565        Ok(result.to_dtype(x.dtype())?)
566    }
567}
568
569// ── Embedder ─────────────────────────────────────────────────────────
570
571/// The embedding model: EuroBERT encoder + tokenizer + pooling.
572pub struct Embedder {
573    tokenizer: Tokenizer,
574    tok_embeddings: Embedding,
575    layers: Vec<Layer>,
576    output_norm: Tensor,
577    cos: Tensor,
578    sin: Tensor,
579    head_dim: usize,
580    n_head: usize,
581    pad_id: u32,
582}
583
584impl Embedder {
585    /// Load an [`Embedder`] from cached GGUF and tokenizer files.
586    ///
587    /// Does NOT download — the caller is responsible for ensuring the files exist.
588    /// Returns an error if files are missing, corrupted, or the model architecture
589    /// is unexpected.
590    #[allow(clippy::too_many_lines)]
591    pub fn load(model_path: &Path, tokenizer_path: &Path) -> Result<Self> {
592        let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| {
593            anyhow!(
594                "Failed to load tokenizer from {}: {e}",
595                tokenizer_path.display()
596            )
597        })?;
598
599        // Discover pad token ID from the tokenizer.
600        // jina-embeddings-v5 uses eos_token_id = 128001 as pad token.
601        let pad_id = tokenizer
602            .token_to_id("<|end_of_text|>")
603            .or_else(|| tokenizer.token_to_id("<|pad|>"))
604            .or_else(|| tokenizer.token_to_id("[PAD]"))
605            .map_or(DEFAULT_PAD_ID, |id| id);
606
607        debug!(pad_id, "Discovered pad token ID from tokenizer");
608
609        let device = Device::Cpu;
610
611        // Open and read GGUF file
612        let mut file = std::fs::File::open(model_path)
613            .map_err(|e| anyhow!("Failed to open model file {}: {e}", model_path.display()))?;
614        let content = gguf_file::Content::read(&mut file)
615            .map_err(|e| anyhow!("Failed to read GGUF file: {e}"))?;
616
617        // Read architecture metadata
618        let hidden_size = get_meta_u32(&content.metadata, "eurobert.embedding_length")? as usize;
619        let n_head = get_meta_u32(&content.metadata, "eurobert.attention.head_count")? as usize;
620        let head_dim = get_meta_u32(&content.metadata, "eurobert.attention.value_length")? as usize;
621        let rope_freq_base =
622            get_meta_f32(&content.metadata, "eurobert.rope.freq_base").unwrap_or(ROPE_FREQ_BASE);
623
624        // Count layers by scanning tensor names
625        let n_layers = content
626            .tensor_infos
627            .keys()
628            .filter_map(|name| {
629                let name = name.as_str();
630                if name.starts_with("blk.") && name.ends_with(".attn_q.weight") {
631                    // Extract layer index
632                    name.trim_start_matches("blk.")
633                        .split('.')
634                        .next()?
635                        .parse::<usize>()
636                        .ok()
637                } else {
638                    None
639                }
640            })
641            .max()
642            .map(|max| max + 1)
643            .context("No layer tensors found in GGUF file")?;
644
645        info!(
646            hidden_size,
647            n_head, head_dim, n_layers, rope_freq_base, "Loading EuroBERT embedding model"
648        );
649
650        // ── Load token embeddings (dequantize for use with candle_nn::Embedding) ──
651        let tok_embd_qt = content
652            .tensor(&mut file, "token_embd.weight", &device)
653            .context("Failed to load token_embd.weight")?;
654        let tok_embd_f32 = tok_embd_qt
655            .dequantize(&device)
656            .context("Failed to dequantize token_embd.weight")?;
657        let tok_embeddings = Embedding::new(tok_embd_f32, hidden_size);
658
659        // ── Load output norm ──
660        let output_norm_qt = content
661            .tensor(&mut file, "output_norm.weight", &device)
662            .context("Failed to load output_norm.weight")?;
663        let output_norm = output_norm_qt
664            .dequantize(&device)
665            .context("Failed to dequantize output_norm.weight")?;
666
667        // ── Load transformer layers ──
668        let mut layers = Vec::with_capacity(n_layers);
669        for i in 0..n_layers {
670            let prefix = format!("blk.{i}");
671
672            let attn_q = QMatMul::from_qtensor(
673                content
674                    .tensor(&mut file, &format!("{prefix}.attn_q.weight"), &device)
675                    .with_context(|| format!("Failed to load {prefix}.attn_q.weight"))?,
676            )
677            .context("Failed to create QMatMul for attn_q")?;
678
679            let attn_k = QMatMul::from_qtensor(
680                content
681                    .tensor(&mut file, &format!("{prefix}.attn_k.weight"), &device)
682                    .with_context(|| format!("Failed to load {prefix}.attn_k.weight"))?,
683            )
684            .context("Failed to create QMatMul for attn_k")?;
685
686            let attn_v = QMatMul::from_qtensor(
687                content
688                    .tensor(&mut file, &format!("{prefix}.attn_v.weight"), &device)
689                    .with_context(|| format!("Failed to load {prefix}.attn_v.weight"))?,
690            )
691            .context("Failed to create QMatMul for attn_v")?;
692
693            let attn_o = QMatMul::from_qtensor(
694                content
695                    .tensor(&mut file, &format!("{prefix}.attn_output.weight"), &device)
696                    .with_context(|| format!("Failed to load {prefix}.attn_output.weight"))?,
697            )
698            .context("Failed to create QMatMul for attn_o")?;
699
700            let attn_norm = content
701                .tensor(&mut file, &format!("{prefix}.attn_norm.weight"), &device)
702                .with_context(|| format!("Failed to load {prefix}.attn_norm.weight"))?
703                .dequantize(&device)
704                .context("Failed to dequantize attn_norm")?;
705
706            let ffn_gate = QMatMul::from_qtensor(
707                content
708                    .tensor(&mut file, &format!("{prefix}.ffn_gate.weight"), &device)
709                    .with_context(|| format!("Failed to load {prefix}.ffn_gate.weight"))?,
710            )
711            .context("Failed to create QMatMul for ffn_gate")?;
712
713            let ffn_up = QMatMul::from_qtensor(
714                content
715                    .tensor(&mut file, &format!("{prefix}.ffn_up.weight"), &device)
716                    .with_context(|| format!("Failed to load {prefix}.ffn_up.weight"))?,
717            )
718            .context("Failed to create QMatMul for ffn_up")?;
719
720            let ffn_down = QMatMul::from_qtensor(
721                content
722                    .tensor(&mut file, &format!("{prefix}.ffn_down.weight"), &device)
723                    .with_context(|| format!("Failed to load {prefix}.ffn_down.weight"))?,
724            )
725            .context("Failed to create QMatMul for ffn_down")?;
726
727            let ffn_norm = content
728                .tensor(&mut file, &format!("{prefix}.ffn_norm.weight"), &device)
729                .with_context(|| format!("Failed to load {prefix}.ffn_norm.weight"))?
730                .dequantize(&device)
731                .context("Failed to dequantize ffn_norm")?;
732
733            layers.push(Layer {
734                attn_q,
735                attn_k,
736                attn_v,
737                attn_o,
738                attn_norm,
739                ffn_gate,
740                ffn_up,
741                ffn_down,
742                ffn_norm,
743            });
744        }
745
746        // ── Precompute RoPE frequencies ──
747        let (cos, sin) = precompute_freqs_cis(head_dim, rope_freq_base, &device)?;
748
749        // ── Build embedder ──
750        let emb = Self {
751            tokenizer,
752            tok_embeddings,
753            layers,
754            output_norm,
755            cos,
756            sin,
757            head_dim,
758            n_head,
759            pad_id,
760        };
761
762        // ── Warm-up: run a single short input to validate the model ──
763        let v = emb.embed_documents(&["."])?;
764        anyhow::ensure!(
765            !v.is_empty() && !v[0].is_empty(),
766            "Embedder warm-up produced empty output"
767        );
768        anyhow::ensure!(
769            v[0].len() == hidden_size,
770            "Embedder warm-up produced wrong dimension: expected {hidden_size}, got {}",
771            v[0].len()
772        );
773
774        info!("Embedder initialized successfully (hidden_size={hidden_size}, layers={n_layers})");
775        Ok(emb)
776    }
777
778    /// Embed texts as queries (prefixed with `"Query: "`).
779    pub fn embed_queries(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
780        self.embed_prefixed("Query: ", texts)
781    }
782
783    /// Embed texts as documents (prefixed with `"Document: "`).
784    pub fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
785        self.embed_prefixed("Document: ", texts)
786    }
787
788    /// Core embedding method.
789    fn embed_prefixed(&self, prefix: &str, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
790        // ── Tokenize ──
791        let encodings: Vec<_> = texts
792            .iter()
793            .map(|t| {
794                let input = format!("{prefix}{t}");
795                self.tokenizer.encode(input, true)
796            })
797            .collect::<std::result::Result<Vec<_>, _>>()
798            .map_err(|e| anyhow!("Tokenization error: {e}"))?;
799
800        if encodings.is_empty() {
801            anyhow::bail!("Empty input");
802        }
803
804        // Determine max sequence length across batch (clamped to MAX_SEQ_LEN)
805        let max_len = encodings
806            .iter()
807            .map(|e| e.len().min(MAX_SEQ_LEN))
808            .max()
809            .context("Empty encoding")?;
810
811        let batch_size = encodings.len();
812
813        // ── Build input_ids and attention_mask ──
814        let mut input_ids_vec = vec![i64::from(self.pad_id); batch_size * max_len];
815        let mut attention_mask_vec = vec![0i64; batch_size * max_len];
816
817        for (row, enc) in encodings.iter().enumerate() {
818            let ids = enc.get_ids();
819            let mask = enc.get_attention_mask();
820            let len = ids.len().min(MAX_SEQ_LEN);
821            for col in 0..len {
822                input_ids_vec[row * max_len + col] = i64::from(ids[col]);
823                attention_mask_vec[row * max_len + col] = i64::from(mask[col]);
824            }
825        }
826
827        let input_ids = Tensor::from_vec(input_ids_vec, (batch_size, max_len), &Device::Cpu)?;
828        let attention_mask =
829            Tensor::from_vec(attention_mask_vec, (batch_size, max_len), &Device::Cpu)?;
830
831        // ── Forward pass through the model ──
832        let embeddings = self.forward(&input_ids, &attention_mask)?;
833
834        // ── Post-process: last-token pooling + L2 normalization ──
835        let result = last_token_pool_and_l2_normalize(&embeddings, &attention_mask)?;
836
837        Ok(result)
838    }
839
840    /// Full model forward pass.
841    fn forward(&self, input_ids: &Tensor, attention_mask: &Tensor) -> Result<Tensor> {
842        let (_batch_size, _seq_len) = input_ids.shape().dims2()?;
843
844        // Create bidirectional attention mask (no causal masking since this is an encoder)
845        let mask = build_attn_mask(attention_mask, &Device::Cpu)?;
846
847        // Token embeddings
848        let mut h = self.tok_embeddings.forward(input_ids)?;
849        // h: [batch, seq, hidden_size]
850
851        // Pass through all transformer layers
852        for layer in &self.layers {
853            h = layer.forward(&h, &mask, &self.cos, &self.sin, self.n_head, self.head_dim)?;
854        }
855
856        // Final norm
857        h = candle_nn::ops::rms_norm(&h, &self.output_norm, 1e-5)?;
858
859        Ok(h)
860    }
861}
862
863// ── RoPE ─────────────────────────────────────────────────────────────
864
865/// Precompute cosine and sine tables for rotary position embeddings.
866#[allow(
867    clippy::cast_precision_loss,
868    clippy::cast_possible_truncation,
869    clippy::cast_lossless
870)]
871fn precompute_freqs_cis(
872    head_dim: usize,
873    freq_base: f32,
874    device: &Device,
875) -> Result<(Tensor, Tensor)> {
876    #[allow(clippy::cast_precision_loss, clippy::cast_lossless)]
877    let theta: Vec<f32> = (0..head_dim)
878        .step_by(2)
879        .map(|i| 1.0_f32 / freq_base.powf(i as f32 / head_dim as f32))
880        .collect();
881
882    let theta = Tensor::from_vec(theta, (head_dim / 2,), device)?;
883    #[allow(clippy::cast_possible_truncation)]
884    let positions = Tensor::arange(0u32, MAX_SEQ_LEN as u32, device)?
885        .to_dtype(DType::F32)?
886        .reshape((MAX_SEQ_LEN, 1))?;
887
888    let idx_theta = positions.matmul(&theta.reshape((1, theta.elem_count()))?)?;
889    let cos = idx_theta.cos()?;
890    let sin = idx_theta.sin()?;
891
892    Ok((cos, sin))
893}
894
895// ── Attention mask ───────────────────────────────────────────────────
896
897/// Build a bidirectional attention mask from a tokenizer attention mask.
898///
899/// The input `attention_mask` has shape `[batch, seq]` with 1 for real tokens and
900/// 0 for padding. The output has shape `[batch, 1, seq, seq]` where:
901/// - Entry (i,j) is 0 if both positions i and j are real tokens,
902/// - Otherwise it's a large negative value (-1e10) that acts as -inf for softmax.
903///
904/// Uses `-1e10` instead of `f32::NEG_INFINITY` because `0 * NEG_INFINITY = NaN`
905/// per IEEE 754, which would corrupt the entire attention computation.
906fn build_attn_mask(attention_mask: &Tensor, device: &Device) -> Result<Tensor> {
907    let (batch_size, seq_len) = attention_mask.shape().dims2()?;
908
909    // Expand to [batch, 1, seq, seq]: mask[i, j] = mask[i] * mask[j]
910    // (both tokens must be real to attend)
911    let mask_f32 = attention_mask.to_dtype(DType::F32)?;
912    let mask_a = mask_f32.reshape((batch_size, 1, 1, seq_len))?;
913    let mask_b = mask_f32.reshape((batch_size, 1, seq_len, 1))?;
914    let pairwise = mask_a.broadcast_mul(&mask_b)?;
915    // pairwise now has 1 where both are real, 0 where either is padding
916
917    // Convert to attention mask using where_cond:
918    // - Where pairwise == 1 (attend): mask = 0
919    // - Where pairwise == 0 (masked): mask = -1e10
920    // Using -1e10 instead of NEG_INFINITY because 0 * NEG_INFINITY = NaN.
921    let large_neg = Tensor::new(-1e10_f32, device)?.broadcast_as(pairwise.shape())?;
922    let zero = Tensor::new(0.0_f32, device)?.broadcast_as(pairwise.shape())?;
923    // Build boolean predicate: pairwise == 0
924    let mask_cond = pairwise.eq(&zero)?;
925    let mask = mask_cond.where_cond(&large_neg, &zero)?;
926
927    Ok(mask)
928}
929
930// ── Pooling and normalization ────────────────────────────────────────
931
932/// Extract embeddings via last-token pooling and L2 normalize.
933///
934/// Takes the embedding at the last non-padding token position for each sequence,
935/// then L2-normalizes each vector.
936fn last_token_pool_and_l2_normalize(
937    embeddings: &Tensor,
938    attention_mask: &Tensor,
939) -> Result<Vec<Vec<f32>>> {
940    let (batch_size, _seq_len, hidden_size) = embeddings.shape().dims3()?;
941
942    let mut results = Vec::with_capacity(batch_size);
943
944    // Sum attention mask along seq dimension to find last real token position
945    // last_pos = sum(mask) - 1 (0-indexed)
946    let seq_lengths: Vec<i64> = attention_mask.sum(1)?.to_vec1()?;
947
948    for (i, &seq_len) in seq_lengths.iter().enumerate().take(batch_size) {
949        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
950        let last_pos = (seq_len - 1).max(0) as usize;
951
952        // Extract embedding at last position: [batch, seq, hidden] -> [hidden]
953        let token_emb = embeddings.narrow(0, i, 1)?.narrow(1, last_pos, 1)?;
954        let token_emb = token_emb.reshape(hidden_size)?;
955
956        // L2 normalize
957        let norm = token_emb
958            .sqr()?
959            .sum_all()?
960            .sqrt()?
961            .to_scalar::<f32>()?
962            .max(1e-12);
963        let normalized = token_emb.broadcast_div(&Tensor::new(norm, token_emb.device())?)?;
964
965        let vec: Vec<f32> = normalized.to_vec1()?;
966        results.push(vec);
967    }
968
969    Ok(results)
970}
971
972// ── tests ─────────────────────────────────────────────────────────────
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977    use crate::vector::cosine_similarity;
978
979    /// Initialize config storage root for tests using a temp directory.
980    /// Returns the temp dir path (used as storage root).
981    fn init_test_config() -> std::path::PathBuf {
982        use std::sync::OnceLock;
983        static CONFIG_INIT: OnceLock<tempfile::TempDir> = OnceLock::new();
984        let tmp = CONFIG_INIT
985            .get_or_init(|| tempfile::TempDir::new().expect("failed to create test temp dir"));
986        let root = tmp.path().to_path_buf();
987        let _ = crate::config::CONFIG.try_set_storage_root(root.clone());
988        root
989    }
990
991    /// Set up a storage root pointing to `~/.mahbot` for model-dependent tests.
992    /// Uses `try_set_storage_root` so it's a no-op if another test already set it.
993    /// Helper to get an embedder for tests.
994    ///
995    /// Looks for model files in CONFIG storage root first, then falls back to
996    /// `$HOME/.mahbot/models`. This ensures model-dependent tests work regardless
997    /// of whether the graceful degradation test (which uses a temp dir) ran first.
998    /// Returns `None` and skips test if the model files aren't available.
999    fn test_embedder() -> Option<Embedder> {
1000        // Skip if env var is set
1001        if std::env::var("MAHBOT_SKIP_EMBEDDER_TESTS").is_ok() {
1002            return None;
1003        }
1004
1005        // Collect all candidate models directories (deduplicated).
1006        let mut candidates = Vec::new();
1007
1008        // 1. CONFIG storage root (may be a temp dir from graceful degradation test).
1009        if let Some(root) = crate::config::CONFIG.try_storage_root() {
1010            candidates.push(root.join("models"));
1011        }
1012
1013        // 2. Real home directory cache (always present in dev/CI environments).
1014        if let Some(home) = std::env::var("HOME").ok().filter(|h| !h.is_empty()) {
1015            let real = std::path::PathBuf::from(&home)
1016                .join(".mahbot")
1017                .join("models");
1018            if !candidates.contains(&real) {
1019                candidates.push(real);
1020            }
1021        }
1022
1023        // Try each candidate until we find model files.
1024        for models_dir in &candidates {
1025            let model_path = models_dir.join("v5-nano-retrieval-Q4_K_M.gguf");
1026            let tokenizer_path = models_dir.join("embed_tokenizer.json");
1027
1028            if model_path.exists() && tokenizer_path.exists() {
1029                match Embedder::load(&model_path, &tokenizer_path) {
1030                    Ok(emb) => return Some(emb),
1031                    Err(e) => {
1032                        eprintln!("WARNING: Failed to load test embedder: {e}");
1033                        return None;
1034                    }
1035                }
1036            }
1037        }
1038
1039        // No model files found in any candidate directory.
1040        let last_candidate = candidates.last().map(|p| p.display().to_string());
1041        eprintln!(
1042            "WARNING: Model files not found. Looked in: {}. \
1043             Set MAHBOT_SKIP_EMBEDDER_TESTS=1 to suppress this warning.",
1044            last_candidate.as_deref().unwrap_or("<none>")
1045        );
1046        None
1047    }
1048
1049    /// Reset global embedder state for hermetic testing.
1050    fn reset_global_state() {
1051        *global_embedder().write().unwrap_poison() = None;
1052        STATE.store(STATE_UNINIT, Ordering::Release);
1053        DOWNLOAD_SPAWNED.store(false, Ordering::Release);
1054    }
1055
1056    #[test]
1057    fn test_embedder_graceful_degradation() {
1058        // Use a temp dir as storage root — no model files there.
1059        let _root = init_test_config();
1060        reset_global_state();
1061
1062        // verify: without model files, embed() returns None
1063        let result = embed("test", false);
1064        assert!(
1065            result.is_none(),
1066            "embed() should return None when model not available"
1067        );
1068
1069        // Verify the global embedder is still empty
1070        let guard = global_embedder().read().unwrap_poison();
1071        assert!(guard.is_none(), "global embedder should remain None");
1072    }
1073
1074    #[test]
1075    fn test_embedder_init() {
1076        let Some(emb) = test_embedder() else {
1077            return; // Skip if no model available
1078        };
1079        let v = emb.embed_documents(&["hello world"]).unwrap();
1080        assert_eq!(v.len(), 1);
1081        // jina-embeddings-v5 produces 768-dimensional vectors
1082        assert_eq!(v[0].len(), 768);
1083        // L2-normalized → unit vector (approximately norm 1)
1084        let norm: f32 = v[0].iter().map(|x| x * x).sum::<f32>().sqrt();
1085        assert!(
1086            (norm - 1.0).abs() < 1e-5,
1087            "expected unit vector, got norm={norm}"
1088        );
1089    }
1090
1091    #[test]
1092    fn test_embed_documents() {
1093        let Some(emb) = test_embedder() else { return };
1094        let docs = &["first document", "second document about something"];
1095        let v = emb.embed_documents(docs).unwrap();
1096        assert_eq!(v.len(), 2);
1097        for vec in &v {
1098            assert_eq!(vec.len(), 768);
1099            let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
1100            assert!(
1101                (norm - 1.0).abs() < 1e-5,
1102                "expected unit vector, got norm={norm}"
1103            );
1104        }
1105    }
1106
1107    #[test]
1108    fn test_embed_queries() {
1109        let Some(emb) = test_embedder() else { return };
1110        let v = emb.embed_queries(&["what is rust?"]).unwrap();
1111        assert_eq!(v.len(), 1);
1112        assert_eq!(v[0].len(), 768);
1113    }
1114
1115    #[test]
1116    fn test_similar_embeddings_are_similar() {
1117        let Some(emb) = test_embedder() else { return };
1118        let v = emb
1119            .embed_documents(&[
1120                "rust programming language",
1121                "the rust programming language",
1122                "python programming language",
1123            ])
1124            .unwrap();
1125        let sim_01 = cosine_similarity(&v[0], &v[1]);
1126        let sim_02 = cosine_similarity(&v[0], &v[2]);
1127        assert!(
1128            sim_01 > sim_02,
1129            "rust/rust ({sim_01}) should be more similar than rust/python ({sim_02})"
1130        );
1131    }
1132
1133    #[test]
1134    fn test_empty_input_fails() {
1135        let Some(emb) = test_embedder() else { return };
1136        let result = emb.embed_documents(&[]);
1137        assert!(result.is_err(), "empty input should produce an error");
1138    }
1139}