Skip to main content

microscope_memory/
embeddings.rs

1#![allow(dead_code)]
2// Embedding module for semantic vector search
3// Supports OpenAI, HuggingFace, and custom embeddings
4
5use std::collections::HashMap;
6use std::f32;
7
8pub const EMBEDDING_DIM: usize = 1536; // OpenAI ada-002 dimension
9
10/// Embedding provider trait
11pub trait EmbeddingProvider: Send + Sync {
12    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError>;
13    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError>;
14    fn dimension(&self) -> usize;
15}
16
17#[derive(Debug)]
18pub enum EmbeddingError {
19    ApiError(String),
20    InvalidDimension,
21    NetworkError,
22}
23
24impl std::fmt::Display for EmbeddingError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            EmbeddingError::ApiError(msg) => write!(f, "API error: {}", msg),
28            EmbeddingError::InvalidDimension => write!(f, "Invalid embedding dimension"),
29            EmbeddingError::NetworkError => write!(f, "Network error"),
30        }
31    }
32}
33
34/// Cached embedding storage
35pub struct EmbeddingCache {
36    embeddings: HashMap<String, Vec<f32>>,
37    dimension: usize,
38}
39
40impl EmbeddingCache {
41    pub fn new(dimension: usize) -> Self {
42        Self {
43            embeddings: HashMap::new(),
44            dimension,
45        }
46    }
47
48    pub fn insert(&mut self, text: String, embedding: Vec<f32>) {
49        if embedding.len() == self.dimension {
50            self.embeddings.insert(text, embedding);
51        }
52    }
53
54    pub fn get(&self, text: &str) -> Option<&Vec<f32>> {
55        self.embeddings.get(text)
56    }
57
58    pub fn contains(&self, text: &str) -> bool {
59        self.embeddings.contains_key(text)
60    }
61}
62
63/// Fast SIMD-accelerated cosine similarity
64#[cfg(target_arch = "x86_64")]
65pub fn cosine_similarity_simd(a: &[f32], b: &[f32]) -> f32 {
66    use std::arch::x86_64::*;
67
68    if a.len() != b.len() {
69        return 0.0;
70    }
71
72    unsafe {
73        let mut dot_sum = _mm256_setzero_ps();
74        let mut norm_a = _mm256_setzero_ps();
75        let mut norm_b = _mm256_setzero_ps();
76
77        let chunks = a.len() / 8;
78
79        for i in 0..chunks {
80            let va = _mm256_loadu_ps(a.as_ptr().add(i * 8));
81            let vb = _mm256_loadu_ps(b.as_ptr().add(i * 8));
82
83            dot_sum = _mm256_fmadd_ps(va, vb, dot_sum);
84            norm_a = _mm256_fmadd_ps(va, va, norm_a);
85            norm_b = _mm256_fmadd_ps(vb, vb, norm_b);
86        }
87
88        // Sum the vector components
89        let dot = horizontal_sum_ps256(dot_sum);
90        let na = horizontal_sum_ps256(norm_a).sqrt();
91        let nb = horizontal_sum_ps256(norm_b).sqrt();
92
93        // Handle remaining elements
94        let mut dot_rem = 0.0;
95        let mut na_rem = 0.0;
96        let mut nb_rem = 0.0;
97
98        for i in (chunks * 8)..a.len() {
99            dot_rem += a[i] * b[i];
100            na_rem += a[i] * a[i];
101            nb_rem += b[i] * b[i];
102        }
103
104        (dot + dot_rem) / ((na + na_rem.sqrt()) * (nb + nb_rem.sqrt()))
105    }
106}
107
108#[cfg(not(target_arch = "x86_64"))]
109pub fn cosine_similarity_simd(a: &[f32], b: &[f32]) -> f32 {
110    cosine_similarity_scalar(a, b)
111}
112
113/// Fallback scalar cosine similarity
114pub fn cosine_similarity_scalar(a: &[f32], b: &[f32]) -> f32 {
115    if a.len() != b.len() {
116        return 0.0;
117    }
118
119    let mut dot = 0.0;
120    let mut norm_a = 0.0;
121    let mut norm_b = 0.0;
122
123    for i in 0..a.len() {
124        dot += a[i] * b[i];
125        norm_a += a[i] * a[i];
126        norm_b += b[i] * b[i];
127    }
128
129    dot / (norm_a.sqrt() * norm_b.sqrt())
130}
131
132#[cfg(target_arch = "x86_64")]
133unsafe fn horizontal_sum_ps256(v: std::arch::x86_64::__m256) -> f32 {
134    use std::arch::x86_64::*;
135
136    let high = _mm256_extractf128_ps(v, 1);
137    let low = _mm256_castps256_ps128(v);
138    let sum = _mm_add_ps(high, low);
139    let shuf = _mm_shuffle_ps(sum, sum, 0x0E);
140    let sums = _mm_add_ps(sum, shuf);
141    let shuf2 = _mm_movehl_ps(sums, sums);
142    let result = _mm_add_ss(sums, shuf2);
143    _mm_cvtss_f32(result)
144}
145
146/// Mock embedding provider for testing
147pub struct MockEmbeddingProvider {
148    dimension: usize,
149}
150
151impl MockEmbeddingProvider {
152    pub fn new(dimension: usize) -> Self {
153        Self { dimension }
154    }
155}
156
157impl EmbeddingProvider for MockEmbeddingProvider {
158    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
159        // Simple hash-based embedding for testing
160        let mut embedding = vec![0.0; self.dimension];
161        let hash = text
162            .bytes()
163            .fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
164
165        for (i, slot) in embedding.iter_mut().enumerate() {
166            let val = ((hash.wrapping_mul(i as u64 + 1)) % 1000) as f32 / 1000.0;
167            *slot = val * 2.0 - 1.0; // Normalize to [-1, 1]
168        }
169
170        // Normalize to unit vector
171        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
172        if norm > 0.0 {
173            for val in &mut embedding {
174                *val /= norm;
175            }
176        }
177
178        Ok(embedding)
179    }
180
181    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
182        texts.iter().map(|t| self.embed(t)).collect()
183    }
184
185    fn dimension(&self) -> usize {
186        self.dimension
187    }
188}
189
190// ─── Python subprocess embedding provider ────────────
191/// Real embedding provider backed by the bundled `embed.py` subprocess
192/// (sentence-transformers / MiniLM). Wire protocol (see embed.py):
193/// the script writes a u32 LE dimension header on stdout, then for every
194/// line it reads on stdin it writes `dim * f32 LE` bytes on stdout.
195///
196/// The child is spawned once and kept alive; reads are bounded by a timeout
197/// so a hung model can never block the host process forever.
198#[cfg(not(target_arch = "wasm32"))]
199pub struct PythonEmbeddingProvider {
200    child: std::sync::Mutex<std::process::Child>,
201    stdin: std::sync::Mutex<std::process::ChildStdin>,
202    rx: std::sync::Mutex<std::sync::mpsc::Receiver<Result<Vec<u8>, String>>>,
203    dim: usize,
204    timeout: std::time::Duration,
205}
206
207#[cfg(not(target_arch = "wasm32"))]
208impl PythonEmbeddingProvider {
209    /// Spawn `embed.py` with the given HuggingFace model id and read the
210    /// dimension header. Python binary, script path and timeout can be
211    /// overridden via MICROSCOPE_PYTHON, MICROSCOPE_EMBED_SCRIPT and
212    /// MICROSCOPE_EMBED_TIMEOUT_MS respectively.
213    pub fn new(model: &str) -> Result<Self, EmbeddingError> {
214        let python = std::env::var("MICROSCOPE_PYTHON").unwrap_or_else(|_| "python".to_string());
215        let script = std::env::var("MICROSCOPE_EMBED_SCRIPT").unwrap_or_else(|_| {
216            let manifest = format!("{}/embed.py", env!("CARGO_MANIFEST_DIR"));
217            if std::path::Path::new(&manifest).exists() {
218                manifest
219            } else {
220                std::env::var("MICROSCOPE_HOME")
221                    .map(|h| format!("{}/embed.py", h))
222                    .unwrap_or_else(|_| "embed.py".to_string())
223            }
224        });
225        let timeout_ms = std::env::var("MICROSCOPE_EMBED_TIMEOUT_MS")
226            .ok()
227            .and_then(|v| v.parse::<u64>().ok())
228            .unwrap_or(120_000);
229
230        let mut child = std::process::Command::new(&python)
231            .arg(&script)
232            .arg(model)
233            .stdin(std::process::Stdio::piped())
234            .stdout(std::process::Stdio::piped())
235            .stderr(std::process::Stdio::null())
236            .spawn()
237            .map_err(|e| EmbeddingError::ApiError(format!("spawn {} {}: {}", python, script, e)))?;
238        let stdin = child
239            .stdin
240            .take()
241            .ok_or_else(|| EmbeddingError::ApiError("embedding stdin unavailable".into()))?;
242        let mut stdout = child
243            .stdout
244            .take()
245            .ok_or_else(|| EmbeddingError::ApiError("embedding stdout unavailable".into()))?;
246
247        // Persistent reader thread: header first, then one vector per line.
248        let (tx, rx) = std::sync::mpsc::channel();
249        std::thread::spawn(move || {
250            use std::io::Read;
251            let mut header = [0u8; 4];
252            let dim = match stdout.read_exact(&mut header) {
253                Ok(()) => u32::from_le_bytes(header) as usize,
254                Err(e) => {
255                    let _ = tx.send(Err(format!("read dim header: {}", e)));
256                    return;
257                }
258            };
259            let _ = tx.send(Ok(header.to_vec()));
260            let mut buf = vec![0u8; dim * 4];
261            loop {
262                match stdout.read_exact(&mut buf) {
263                    Ok(()) => {
264                        if tx.send(Ok(buf.clone())).is_err() {
265                            break;
266                        }
267                    }
268                    Err(e) => {
269                        let _ = tx.send(Err(format!("read embedding: {}", e)));
270                        break;
271                    }
272                }
273            }
274        });
275
276        let timeout = std::time::Duration::from_millis(timeout_ms);
277        let header_msg = rx
278            .recv_timeout(timeout)
279            .map_err(|_| {
280                let _ = child.kill();
281                EmbeddingError::ApiError(format!(
282                    "embedding model '{}' did not initialize within {} ms",
283                    model, timeout_ms
284                ))
285            })?
286            .map_err(EmbeddingError::ApiError)?;
287        let dim = u32::from_le_bytes(header_msg[0..4].try_into().unwrap()) as usize;
288
289        Ok(Self {
290            child: std::sync::Mutex::new(child),
291            stdin: std::sync::Mutex::new(stdin),
292            rx: std::sync::Mutex::new(rx),
293            dim,
294            timeout,
295        })
296    }
297
298    fn read_vector(&self) -> Result<Vec<f32>, EmbeddingError> {
299        let rx = self
300            .rx
301            .lock()
302            .map_err(|_| EmbeddingError::ApiError("embedding receiver lock poisoned".into()))?;
303        match rx.recv_timeout(self.timeout) {
304            Ok(Ok(bytes)) => {
305                let mut v = Vec::with_capacity(bytes.len() / 4);
306                for chunk in bytes.as_chunks::<4>().0 {
307                    v.push(f32::from_le_bytes(*chunk));
308                }
309                Ok(v)
310            }
311            Ok(Err(e)) => Err(EmbeddingError::ApiError(e)),
312            Err(_) => {
313                let mut child = self.child.lock().map_err(|_| {
314                    EmbeddingError::ApiError("embedding child lock poisoned".into())
315                })?;
316                let _ = child.kill();
317                Err(EmbeddingError::ApiError(format!(
318                    "embedding provider timed out after {} ms",
319                    self.timeout.as_millis()
320                )))
321            }
322        }
323    }
324}
325
326#[cfg(not(target_arch = "wasm32"))]
327impl Drop for PythonEmbeddingProvider {
328    fn drop(&mut self) {
329        if let Ok(mut child) = self.child.lock() {
330            let _ = child.kill();
331            let _ = child.wait();
332        }
333    }
334}
335
336#[cfg(not(target_arch = "wasm32"))]
337impl EmbeddingProvider for PythonEmbeddingProvider {
338    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
339        use std::io::Write;
340        let mut stdin = self
341            .stdin
342            .lock()
343            .map_err(|_| EmbeddingError::ApiError("embedding stdin lock poisoned".into()))?;
344        writeln!(stdin, "{}", text)
345            .map_err(|e| EmbeddingError::ApiError(format!("write stdin: {}", e)))?;
346        stdin
347            .flush()
348            .map_err(|e| EmbeddingError::ApiError(format!("flush stdin: {}", e)))?;
349        drop(stdin);
350        self.read_vector()
351    }
352
353    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
354        use std::io::Write;
355        let mut stdin = self
356            .stdin
357            .lock()
358            .map_err(|_| EmbeddingError::ApiError("embedding stdin lock poisoned".into()))?;
359        for t in texts {
360            writeln!(stdin, "{}", t)
361                .map_err(|e| EmbeddingError::ApiError(format!("write stdin: {}", e)))?;
362        }
363        stdin
364            .flush()
365            .map_err(|e| EmbeddingError::ApiError(format!("flush stdin: {}", e)))?;
366        drop(stdin);
367        let mut out = Vec::with_capacity(texts.len());
368        for _ in texts {
369            out.push(self.read_vector()?);
370        }
371        Ok(out)
372    }
373
374    fn dimension(&self) -> usize {
375        self.dim
376    }
377}
378
379/// Run `f` with the configured provider, reusing the previous instance when
380/// (provider, model, dimension) is unchanged.
381///
382/// A long-lived process used to rebuild the provider on every call, and for the
383/// candle provider that means re-reading the tokenizer and re-faulting the model
384/// weights: measured at 127-137 ms of a recall on the eval index, paid again for
385/// every query. The MCP server is the case that matters — one process, many
386/// queries — and it was doing exactly that.
387///
388/// The cache is keyed by the configuration, so a different provider, model or
389/// dimension rebuilds it. A construction that falls back to the mock is cached
390/// like any other value: `provider_from_config` reports the fallback on stderr
391/// and returns a working provider either way, so caching it cannot hide an
392/// error. A panic inside `f` must not poison the cache for the next call, so the
393/// lock recovers rather than propagating.
394pub fn with_cached_provider<R>(
395    cfg: &crate::config::Embedding,
396    idx_dim: usize,
397    f: impl FnOnce(&dyn EmbeddingProvider) -> R,
398) -> R {
399    type Cache = (String, String, usize, Box<dyn EmbeddingProvider>);
400    static CACHE: std::sync::OnceLock<std::sync::Mutex<Option<Cache>>> = std::sync::OnceLock::new();
401    let mut guard = CACHE
402        .get_or_init(|| std::sync::Mutex::new(None))
403        .lock()
404        .unwrap_or_else(|e| e.into_inner());
405    let rebuild = match guard.as_ref() {
406        Some((p, m, d, _)) => (p, m, d) != (&cfg.provider, &cfg.model, &idx_dim),
407        None => true,
408    };
409    if rebuild {
410        *guard = Some((
411            cfg.provider.clone(),
412            cfg.model.clone(),
413            idx_dim,
414            provider_from_config(cfg, idx_dim),
415        ));
416    }
417    match guard.as_ref() {
418        Some((_, _, _, provider)) => f(provider.as_ref()),
419        None => unreachable!("the provider was installed above"),
420    }
421}
422
423/// Build the embedding provider requested by config with an honest fallback:
424/// a configured provider that cannot be initialized is reported on stderr
425/// instead of silently degrading to the mock. The mock is returned for
426/// "mock"/"none" (and as a last resort), with `idx_dim` as its dimension.
427pub fn provider_from_config(
428    cfg: &crate::config::Embedding,
429    idx_dim: usize,
430) -> Box<dyn EmbeddingProvider> {
431    match cfg.provider.as_str() {
432        #[cfg(not(target_arch = "wasm32"))]
433        "python" => match PythonEmbeddingProvider::new(&cfg.model) {
434            Ok(p) => Box::new(p),
435            Err(e) => {
436                eprintln!(
437                    "  WARN: python embedding provider unavailable: {} — using mock",
438                    e
439                );
440                Box::new(MockEmbeddingProvider::new(idx_dim))
441            }
442        },
443        "candle" => {
444            #[cfg(feature = "embeddings")]
445            {
446                match CandleEmbeddingProvider::new(&cfg.model) {
447                    Ok(p) => Box::new(p),
448                    Err(e) => {
449                        // A silent fallback to the hash-based mock provider
450                        // produces plausible-looking but semantically empty
451                        // results, and previously it printed a WARN and
452                        // continued. That is worse than failing: it makes a
453                        // misconfigured build look like a working one.
454                        eprintln!(
455                            "  ERROR: candle provider '{}' failed to load: {:?}\n  \
456                             Rebuild with --features \"native embeddings\", or set \
457                             [embedding] provider = \"python\" / \"mock\" explicitly.\n  \
458                             Refusing to fall back to mock: it would silently \
459                             return meaningless results.",
460                            cfg.model, e
461                        );
462                        std::process::exit(2);
463                    }
464                }
465            }
466            #[cfg(not(feature = "embeddings"))]
467            {
468                eprintln!(
469                    "  ERROR: [embedding] provider = \"candle\" requires the \
470                     'embeddings' feature, but the binary was built without it.\n  \
471                     Rebuild with --features \"native embeddings\", or set \
472                     [embedding] provider = \"python\" / \"mock\" explicitly.\n  \
473                     Refusing to fall back to mock: it would silently return \
474                     meaningless results."
475                );
476                std::process::exit(2);
477            }
478        }
479        "none" | "mock" => Box::new(MockEmbeddingProvider::new(idx_dim)),
480        "bge-small" => {
481            #[cfg(feature = "embeddings")]
482            {
483                // BAAI/bge-small-en-v1.5: 33M params, 384 dim
484                // Config is loaded from the model's config.json on first use.
485                match CandleEmbeddingProvider::with_config(
486                    "BAAI/bge-small-en-v1.5",
487                    None,
488                    384,
489                    cfg.use_gpu,
490                ) {
491                    Ok(p) => {
492                        eprintln!(
493                            "  [bge-small] Provider ready (dim=384, use_gpu={})",
494                            cfg.use_gpu
495                        );
496                        Box::new(p)
497                    }
498                    Err(EmbeddingError::NetworkError) => {
499                        eprintln!("  [bge-small] Network/model download failed. Check internet / HuggingFace cache. Falling back to mock provider.");
500                        Box::new(MockEmbeddingProvider::new(idx_dim))
501                    }
502                    Err(e) => {
503                        eprintln!(
504                            "  [bge-small] Init failed: {}. Falling back to mock provider.",
505                            e
506                        );
507                        Box::new(MockEmbeddingProvider::new(idx_dim))
508                    }
509                }
510            }
511            #[cfg(not(feature = "embeddings"))]
512            {
513                eprintln!("  WARN: bge-small requires the 'embeddings' feature — using mock");
514                Box::new(MockEmbeddingProvider::new(idx_dim))
515            }
516        }
517        other => {
518            eprintln!(
519                "  WARN: unknown embedding provider '{}' — using mock",
520                other
521            );
522            Box::new(MockEmbeddingProvider::new(idx_dim))
523        }
524    }
525}
526
527/// Embedding-enhanced block header
528#[repr(C, packed)]
529pub struct EmbeddedBlockHeader {
530    // Original fields
531    pub x: f32,
532    pub y: f32,
533    pub z: f32,
534    pub zoom: f32,
535    pub depth: u8,
536    pub layer_id: u8,
537    pub data_offset: u32,
538    pub data_len: u16,
539    pub parent_idx: u32,
540    pub child_count: u16,
541    pub crc16: [u8; 2], // CRC16-CCITT (0x0000 = no checksum)
542
543    // New embedding fields
544    pub embedding_offset: u32, // Offset into embedding file
545    pub has_embedding: bool,   // Whether this block has an embedding
546}
547
548// ─── Candle-based real embedding provider ────────────
549#[cfg(feature = "embeddings")]
550pub struct CandleEmbeddingProvider {
551    model: candle_transformers::models::bert::BertModel,
552    tokenizer: tokenizers::Tokenizer,
553    dim: usize,
554    device: candle_core::Device,
555}
556
557#[cfg(feature = "embeddings")]
558impl CandleEmbeddingProvider {
559    pub fn new(model_id: &str) -> Result<Self, EmbeddingError> {
560        // 0 means "derive from the model's own config.json (hidden_size)".
561        // Hardcoding 768 made every non-BERT-large model fail: the embedding
562        // index rejects any vector whose length differs from `dim`, so a
563        // 384-dim model such as all-MiniLM-L6-v2 stored zero vectors while the
564        // build still reported success.
565        Self::with_config(model_id, None, 0, false)
566    }
567
568    /// Create with explicit BERT config (for non-standard models like bge-small).
569    pub fn with_config(
570        model_id: &str,
571        config_override: Option<candle_transformers::models::bert::Config>,
572        dim_override: usize,
573        use_gpu: bool,
574    ) -> Result<Self, EmbeddingError> {
575        use candle_core::Device;
576        use hf_hub::api::sync::Api;
577
578        // Lazy GPU activation: try CUDA only if requested and available.
579        let device = if use_gpu {
580            match Device::cuda_if_available(0) {
581                Ok(d) => {
582                    eprintln!("  [bge] GPU device: {:?}", d);
583                    d
584                }
585                Err(e) => {
586                    eprintln!("  [bge] CUDA unavailable ({}) — falling back to CPU", e);
587                    Device::Cpu
588                }
589            }
590        } else {
591            Device::Cpu
592        };
593        let api = Api::new().map_err(|_| EmbeddingError::NetworkError)?;
594        let repo = api.model(model_id.to_string());
595
596        // Load tokenizer
597        let tokenizer_path = repo
598            .get("tokenizer.json")
599            .map_err(|_| EmbeddingError::NetworkError)?;
600        let tokenizer = tokenizers::Tokenizer::from_file(tokenizer_path)
601            .map_err(|e| EmbeddingError::ApiError(format!("tokenizer load: {}", e)))?;
602
603        // Load model weights
604        let weights_path = repo
605            .get("model.safetensors")
606            .map_err(|_| EmbeddingError::NetworkError)?;
607        // Safety: safetensors file is valid and will remain mapped for the lifetime of the model
608        let vb = unsafe {
609            candle_nn::VarBuilder::from_mmaped_safetensors(
610                &[weights_path],
611                candle_core::DType::F32,
612                &device,
613            )
614        }
615        .map_err(|e| EmbeddingError::ApiError(format!("varbuilder: {}", e)))?;
616
617        // Load config: either from override, or from model's config.json
618        let config = if let Some(cfg) = config_override {
619            cfg
620        } else {
621            // Try to load config.json from the model repo
622            let config_path = repo
623                .get("config.json")
624                .map_err(|_| EmbeddingError::NetworkError)?;
625            let config_str = std::fs::read_to_string(&config_path)
626                .map_err(|e| EmbeddingError::ApiError(format!("config read: {}", e)))?;
627            serde_json::from_str(&config_str)
628                .map_err(|e| EmbeddingError::ApiError(format!("config parse: {}", e)))?
629        };
630        // Derive the embedding width from the model's own config.json.
631        // `candle_transformers`' Config keeps hidden_size private, and
632        // hardcoding a width breaks every model that is not BERT-large: the
633        // embedding index rejects vectors whose length differs from `dim`, so
634        // a 384-dim model (all-MiniLM-L6-v2) stored zero vectors and the build
635        // still reported success.
636        let declared_dim: usize = if dim_override > 0 {
637            dim_override
638        } else {
639            // Re-read the raw JSON: the typed Config does not expose hidden_size.
640            let raw = repo
641                .get("config.json")
642                .ok()
643                .and_then(|p| std::fs::read_to_string(&p).ok())
644                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
645                .and_then(|v| v.get("hidden_size").and_then(|h| h.as_u64()))
646                .ok_or_else(|| {
647                    EmbeddingError::ApiError(
648                        "cannot determine embedding dimension: config.json has no \
649                         hidden_size, and no dim override was given"
650                            .into(),
651                    )
652                })?;
653            raw as usize
654        };
655        if declared_dim == 0 {
656            return Err(EmbeddingError::ApiError(
657                "embedding dimension resolved to 0".into(),
658            ));
659        }
660
661        let model = candle_transformers::models::bert::BertModel::load(vb, &config)
662            .map_err(|e| EmbeddingError::ApiError(format!("model load: {}", e)))?;
663
664        Ok(Self {
665            model,
666            tokenizer,
667            dim: declared_dim,
668            device,
669        })
670    }
671
672    fn embed_inner(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
673        use candle_core::Tensor;
674
675        let encoding = self
676            .tokenizer
677            .encode(text, true)
678            .map_err(|e| EmbeddingError::ApiError(format!("tokenize: {}", e)))?;
679
680        // The tokenizer pads to 512 (the model max), and candle's
681        // BertModel::forward takes no attention mask -- it never constructs
682        // one. So a padded sequence was attended to and then mean-pooled over
683        // all 512 positions: five real tokens and 507 [PAD]. That made every
684        // embedding ~99% padding, which is why the stored vectors score
685        // 0.32-0.37 against the reference model where a correct implementation
686        // scores 1.0, while still being perfectly self-consistent (the same
687        // text always gave the same padding-dominated vector, so every
688        // consistency check passed).
689        //
690        // Trimming to the real length before the forward fixes both halves at
691        // once: the encoder no longer attends to padding, and the mean is over
692        // real tokens only. The attention mask is the source of truth for how
693        // long the real sequence is, because the ids alone cannot tell a
694        // genuine [PAD] from padding.
695        let mask = encoding.get_attention_mask();
696        let real_len = mask.iter().filter(|&&m| m != 0).count().max(1);
697        let all_ids = encoding.get_ids();
698        let all_type_ids = encoding.get_type_ids();
699        let ids = &all_ids[..real_len.min(all_ids.len())];
700        let type_ids = &all_type_ids[..real_len.min(all_type_ids.len())];
701        let len = ids.len();
702
703        // Diagnostic: `MICROSCOPE_EMBED_DEBUG=<path>` appends the token ids
704        // and the input text for every embed, so the tokenizer can be compared
705        // against a reference implementation. This exists because the provider
706        // is internally consistent and still does not reproduce the model it
707        // loads -- the stored vectors score 0.32-0.37 against the reference
708        // all-MiniLM-L6-v2 embedding of the same text, where a correct
709        // implementation scores 1.0. Nothing here runs unless the variable is
710        // set, and nothing here can influence the result.
711        if let Ok(path) = std::env::var("MICROSCOPE_EMBED_DEBUG") {
712            use std::io::Write as _;
713            if let Ok(mut f) = std::fs::OpenOptions::new()
714                .create(true)
715                .append(true)
716                .open(&path)
717            {
718                let _ = writeln!(f, "ids\t{:?}\ttext\t{:?}", ids, text);
719            }
720        }
721
722        let input_ids = Tensor::new(ids, &self.device)
723            .map_err(|e| EmbeddingError::ApiError(e.to_string()))?
724            .reshape((1, len))
725            .map_err(|e| EmbeddingError::ApiError(e.to_string()))?;
726        let token_type_ids = Tensor::new(type_ids, &self.device)
727            .map_err(|e| EmbeddingError::ApiError(e.to_string()))?
728            .reshape((1, len))
729            .map_err(|e| EmbeddingError::ApiError(e.to_string()))?;
730
731        let output = self
732            .model
733            .forward(&input_ids, &token_type_ids)
734            .map_err(|e| EmbeddingError::ApiError(format!("forward: {}", e)))?;
735
736        // Mean pooling over sequence dimension
737        let pooled = output
738            .mean(1)
739            .map_err(|e| EmbeddingError::ApiError(format!("mean pool: {}", e)))?
740            .squeeze(0)
741            .map_err(|e| EmbeddingError::ApiError(format!("squeeze: {}", e)))?;
742
743        let mut embedding: Vec<f32> = pooled
744            .to_vec1()
745            .map_err(|e| EmbeddingError::ApiError(format!("to_vec: {}", e)))?;
746
747        // L2 normalize
748        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
749        if norm > 0.0 {
750            for val in &mut embedding {
751                *val /= norm;
752            }
753        }
754
755        Ok(embedding)
756    }
757}
758
759#[cfg(feature = "embeddings")]
760impl EmbeddingProvider for CandleEmbeddingProvider {
761    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
762        self.embed_inner(text)
763    }
764
765    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
766        texts.iter().map(|t| self.embed_inner(t)).collect()
767    }
768
769    fn dimension(&self) -> usize {
770        self.dim
771    }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777
778    #[test]
779    fn test_cosine_similarity() {
780        let a = vec![1.0, 0.0, 0.0];
781        let b = vec![1.0, 0.0, 0.0];
782        assert!((cosine_similarity_scalar(&a, &b) - 1.0).abs() < 0.001);
783
784        let c = vec![0.0, 1.0, 0.0];
785        assert!((cosine_similarity_scalar(&a, &c) - 0.0).abs() < 0.001);
786
787        let d = vec![-1.0, 0.0, 0.0];
788        assert!((cosine_similarity_scalar(&a, &d) - -1.0).abs() < 0.001);
789    }
790
791    #[test]
792    fn test_mock_embeddings() {
793        let provider = MockEmbeddingProvider::new(128);
794        let embedding = provider.embed("test text").unwrap();
795
796        assert_eq!(embedding.len(), 128);
797
798        // Check normalization
799        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
800        assert!((norm - 1.0).abs() < 0.001);
801    }
802
803    #[test]
804    fn test_embedding_cache() {
805        let mut cache = EmbeddingCache::new(3);
806        let embedding = vec![1.0, 0.0, 0.0];
807
808        cache.insert("test".to_string(), embedding.clone());
809        assert!(cache.contains("test"));
810        assert_eq!(cache.get("test"), Some(&embedding));
811    }
812}