Skip to main content

clawft_kernel/
embedding_onnx.rs

1//! ONNX, sentence-transformer, and AST-aware embedding backends (K3c-G2).
2//!
3//! These are alternative [`EmbeddingProvider`] implementations that complement
4//! the existing [`LlmEmbeddingProvider`] and [`MockEmbeddingProvider`].
5//!
6//! - [`OnnxEmbeddingProvider`] -- local model inference (all-MiniLM-L6-v2, 384-d).
7//! - [`SentenceTransformerProvider`] -- documentation-optimised paragraph embedder.
8//! - [`AstEmbeddingProvider`] -- hybrid structural + semantic embedder for Rust code.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12#[cfg(feature = "onnx-embeddings")]
13use std::sync::{Arc, Mutex};
14
15use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::embedding::{EmbeddingError, EmbeddingProvider, MockEmbeddingProvider};
20
21// ---------------------------------------------------------------------------
22// WordPiece tokenizer for BERT / all-MiniLM-L6-v2
23// ---------------------------------------------------------------------------
24
25/// BERT-compatible WordPiece tokenizer.
26///
27/// Loads a `vocab.txt` file (one token per line, ID = line number) and performs:
28/// 1. Lowercasing and Unicode NFD accent stripping
29/// 2. Whitespace + punctuation pre-tokenization
30/// 3. Greedy longest-match WordPiece splitting (subwords prefixed with `##`)
31/// 4. [CLS] / [SEP] framing and [PAD] / truncation to `max_length`
32///
33/// When no vocab file is available, [`WordPieceTokenizer::encode`] returns
34/// `None` so callers can fall back to hash-based tokenization.
35pub struct WordPieceTokenizer {
36    /// Token string -> vocab ID.
37    vocab: HashMap<String, i64>,
38    /// Maximum sequence length including [CLS] and [SEP].
39    max_length: usize,
40    /// Maximum characters per word before treating as [UNK].
41    max_word_chars: usize,
42}
43
44/// Special token IDs for the BERT uncased vocabulary.
45const CLS_ID: i64 = 101;
46const SEP_ID: i64 = 102;
47const UNK_ID: i64 = 100;
48const PAD_ID: i64 = 0;
49
50impl WordPieceTokenizer {
51    /// Try to load a `vocab.txt` from the given path.
52    ///
53    /// Returns `None` if the file does not exist or cannot be read.
54    pub fn load(vocab_path: &Path) -> Option<Self> {
55        let content = std::fs::read_to_string(vocab_path).ok()?;
56        let line_count = content.lines().count();
57        let mut vocab = HashMap::with_capacity(line_count);
58        for (id, line) in content.lines().enumerate() {
59            vocab.insert(line.to_string(), id as i64);
60        }
61        if vocab.len() < 1000 {
62            // Suspiciously small — probably not a real BERT vocab.
63            tracing::warn!(
64                "vocab.txt at {} has only {} entries, expected ~30k",
65                vocab_path.display(),
66                vocab.len()
67            );
68            return None;
69        }
70        tracing::info!(
71            "WordPiece vocab loaded: {} tokens from {}",
72            vocab.len(),
73            vocab_path.display()
74        );
75        Some(Self {
76            vocab,
77            max_length: 128,
78            max_word_chars: 100,
79        })
80    }
81
82    /// Create a tokenizer with a custom max sequence length.
83    pub fn with_max_length(mut self, max_length: usize) -> Self {
84        self.max_length = max_length;
85        self
86    }
87
88    /// Encode text into (input_ids, attention_mask, token_type_ids).
89    ///
90    /// All three vectors have length `self.max_length`, padded or truncated
91    /// as needed. Returns `None` if the vocab is empty.
92    pub fn encode(&self, text: &str) -> Option<(Vec<i64>, Vec<i64>, Vec<i64>)> {
93        if self.vocab.is_empty() {
94            return None;
95        }
96
97        let mut token_ids: Vec<i64> = Vec::with_capacity(self.max_length);
98        token_ids.push(CLS_ID);
99
100        // Pre-tokenize: lowercase, split on whitespace and punctuation.
101        let words = self.pre_tokenize(text);
102
103        for word in &words {
104            if token_ids.len() >= self.max_length - 1 {
105                break; // Reserve space for [SEP].
106            }
107            let sub_ids = self.wordpiece_split(word);
108            for id in sub_ids {
109                if token_ids.len() >= self.max_length - 1 {
110                    break;
111                }
112                token_ids.push(id);
113            }
114        }
115
116        token_ids.push(SEP_ID);
117
118        let seq_len = token_ids.len();
119        let mut attention_mask = vec![1i64; seq_len];
120        let mut token_type_ids = vec![0i64; seq_len];
121
122        // Pad to max_length.
123        while token_ids.len() < self.max_length {
124            token_ids.push(PAD_ID);
125            attention_mask.push(0);
126            token_type_ids.push(0);
127        }
128
129        Some((token_ids, attention_mask, token_type_ids))
130    }
131
132    /// Pre-tokenize: lowercase, split on whitespace and punctuation.
133    fn pre_tokenize(&self, text: &str) -> Vec<String> {
134        let lower = text.to_lowercase();
135        let mut words = Vec::new();
136        let mut current = String::new();
137
138        for ch in lower.chars() {
139            if ch.is_whitespace() {
140                if !current.is_empty() {
141                    words.push(std::mem::take(&mut current));
142                }
143            } else if ch.is_ascii_punctuation() || is_cjk_char(ch) {
144                // Punctuation and CJK chars become individual tokens.
145                if !current.is_empty() {
146                    words.push(std::mem::take(&mut current));
147                }
148                words.push(ch.to_string());
149            } else if is_accent_char(ch) {
150                // Strip combining marks (basic accent removal).
151                continue;
152            } else if ch.is_control() {
153                continue;
154            } else {
155                current.push(ch);
156            }
157        }
158        if !current.is_empty() {
159            words.push(current);
160        }
161        words
162    }
163
164    /// WordPiece greedy longest-match splitting for a single pre-token.
165    fn wordpiece_split(&self, word: &str) -> Vec<i64> {
166        if word.len() > self.max_word_chars {
167            return vec![UNK_ID];
168        }
169
170        let chars: Vec<char> = word.chars().collect();
171        let mut ids = Vec::new();
172        let mut start = 0;
173
174        while start < chars.len() {
175            let mut end = chars.len();
176            let mut found = false;
177
178            while start < end {
179                let substr: String = if start == 0 {
180                    chars[start..end].iter().collect()
181                } else {
182                    format!("##{}", chars[start..end].iter().collect::<String>())
183                };
184
185                if self.vocab.contains_key(&substr) {
186                    ids.push(self.vocab[&substr]);
187                    found = true;
188                    start = end;
189                    break;
190                }
191                end -= 1;
192            }
193
194            if !found {
195                ids.push(UNK_ID);
196                start += 1;
197            }
198        }
199
200        ids
201    }
202}
203
204/// Check if a character is in the CJK Unified Ideographs range.
205fn is_cjk_char(ch: char) -> bool {
206    let cp = ch as u32;
207    matches!(cp,
208        0x4E00..=0x9FFF
209        | 0x3400..=0x4DBF
210        | 0x20000..=0x2A6DF
211        | 0x2A700..=0x2B73F
212        | 0x2B740..=0x2B81F
213        | 0x2B820..=0x2CEAF
214        | 0xF900..=0xFAFF
215        | 0x2F800..=0x2FA1F
216    )
217}
218
219/// Check if a character is a Unicode combining mark (accent).
220fn is_accent_char(ch: char) -> bool {
221    let cp = ch as u32;
222    matches!(cp, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0xFE20..=0xFE2F)
223}
224
225/// Search paths for the vocab.txt file alongside an ONNX model.
226///
227/// Looks for `vocab.txt` in the same directory as the model, and in a
228/// sibling directory named after the model (e.g., `all-MiniLM-L6-v2/vocab.txt`).
229#[cfg_attr(not(feature = "onnx-embeddings"), allow(dead_code))]
230fn vocab_search_paths(model_path: &Path) -> Vec<PathBuf> {
231    let mut paths = Vec::new();
232
233    if let Some(parent) = model_path.parent() {
234        // Same directory as the model.
235        paths.push(parent.join("vocab.txt"));
236
237        // Sibling directory named after the model.
238        if let Some(stem) = model_path.file_stem() {
239            paths.push(parent.join(stem).join("vocab.txt"));
240        }
241    }
242
243    // Also check standard WeftOS model paths.
244    let model_dir_name = "all-MiniLM-L6-v2";
245    paths.push(PathBuf::from(format!(
246        ".weftos/models/{model_dir_name}/vocab.txt"
247    )));
248    if let Ok(home) = std::env::var("HOME") {
249        paths.push(PathBuf::from(format!(
250            "{home}/.weftos/models/{model_dir_name}/vocab.txt"
251        )));
252    }
253    if let Ok(env_dir) = std::env::var("WEFTOS_VOCAB_PATH") {
254        paths.push(PathBuf::from(env_dir));
255    }
256
257    paths
258}
259
260// ---------------------------------------------------------------------------
261// Shared tokenisation helpers
262// ---------------------------------------------------------------------------
263
264/// Simple whitespace tokeniser that lowercases and strips non-alphanumeric chars.
265fn simple_tokenize(text: &str, max_tokens: usize) -> Vec<String> {
266    text.to_lowercase()
267        .split_whitespace()
268        .take(max_tokens)
269        .map(|s| {
270            s.chars()
271                .filter(|c| c.is_alphanumeric())
272                .collect::<String>()
273        })
274        .filter(|s| !s.is_empty())
275        .collect()
276}
277
278/// Convert a token sequence into a fixed-size embedding via position-weighted
279/// SHA-256 hashing.  Produces consistent, deterministic vectors per token set.
280fn tokens_to_embedding(tokens: &[String], dims: usize) -> Vec<f32> {
281    let mut embedding = vec![0.0f32; dims];
282
283    for (i, token) in tokens.iter().enumerate() {
284        let mut hasher = Sha256::new();
285        hasher.update(token.as_bytes());
286        hasher.update((i as u32).to_le_bytes());
287        let hash = hasher.finalize();
288
289        // Scatter hash bytes across embedding dimensions.
290        for (j, &byte) in hash.iter().enumerate() {
291            let dim = (j + i * 32) % dims;
292            let val = (byte as f32 / 128.0) - 1.0; // [-1, 1]
293            embedding[dim] += val / (tokens.len() as f32).sqrt();
294        }
295    }
296
297    l2_normalize(&mut embedding);
298    embedding
299}
300
301/// In-place L2 normalisation.
302fn l2_normalize(vec: &mut [f32]) {
303    let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
304    if norm > 0.0 {
305        vec.iter_mut().for_each(|x| *x /= norm);
306    }
307}
308
309/// Cosine similarity between two equal-length vectors.
310#[cfg(test)]
311fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
312    debug_assert_eq!(a.len(), b.len());
313    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
314    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
315    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
316    if norm_a == 0.0 || norm_b == 0.0 {
317        return 0.0;
318    }
319    dot / (norm_a * norm_b)
320}
321
322// =========================================================================
323// Backend 1: OnnxEmbeddingProvider
324// =========================================================================
325
326/// ONNX Runtime embedding provider.
327///
328/// Uses all-MiniLM-L6-v2 (384 dimensions) for semantic text embeddings.
329/// When the ONNX runtime is not available (no `onnx-embeddings` feature or
330/// missing model file), falls back to a position-aware token-hashing approach
331/// that is architecturally compatible with real inference.
332pub struct OnnxEmbeddingProvider {
333    /// Path to the ONNX model file.
334    model_path: PathBuf,
335    /// Output dimensions.
336    dimensions: usize,
337    /// Model name for identification.
338    model_name: String,
339    /// Whether the ONNX runtime session is available.
340    runtime_available: bool,
341    /// Max input tokens.
342    max_tokens: usize,
343    /// Fallback provider used when runtime is not available.
344    #[allow(dead_code)]
345    fallback: MockEmbeddingProvider,
346    /// WordPiece tokenizer loaded from vocab.txt (if available).
347    /// When `None`, ONNX inference falls back to hash-based token IDs.
348    #[cfg(feature = "onnx-embeddings")]
349    tokenizer: Option<WordPieceTokenizer>,
350    /// ONNX runtime session (only present when `onnx-embeddings` feature is active
351    /// and model was loaded successfully). Wrapped in a `Mutex` because ort's
352    /// `Session::run` takes `&mut self` in 2.0-rc.12.
353    #[cfg(feature = "onnx-embeddings")]
354    session: Option<Arc<Mutex<ort::session::Session>>>,
355}
356
357impl OnnxEmbeddingProvider {
358    /// Default output dimensionality (all-MiniLM-L6-v2).
359    pub const DEFAULT_DIMS: usize = 384;
360    /// Default maximum input tokens for code snippets.
361    pub const DEFAULT_MAX_TOKENS: usize = 128;
362    /// Model identifier.
363    pub const MODEL_NAME: &'static str = "all-MiniLM-L6-v2";
364
365    /// Create a new ONNX provider pointing at the given model path.
366    ///
367    /// If the model file does not exist or the `onnx-embeddings` feature is
368    /// disabled, the provider transparently falls back to token-hashing.
369    pub fn new(model_path: impl Into<PathBuf>) -> Self {
370        let model_path = model_path.into();
371        #[cfg(feature = "onnx-embeddings")]
372        let session = Self::try_load_session(&model_path);
373        #[cfg(feature = "onnx-embeddings")]
374        let runtime_available = session.is_some();
375        #[cfg(not(feature = "onnx-embeddings"))]
376        let runtime_available = false;
377        #[cfg(feature = "onnx-embeddings")]
378        let tokenizer = Self::try_load_tokenizer(&model_path, Self::DEFAULT_MAX_TOKENS);
379
380        Self {
381            model_name: if runtime_available {
382                Self::MODEL_NAME.to_string()
383            } else {
384                format!("{}-hash-fallback", Self::MODEL_NAME)
385            },
386            model_path,
387            dimensions: Self::DEFAULT_DIMS,
388            runtime_available,
389            max_tokens: Self::DEFAULT_MAX_TOKENS,
390            fallback: MockEmbeddingProvider::new(Self::DEFAULT_DIMS),
391            #[cfg(feature = "onnx-embeddings")]
392            tokenizer,
393            #[cfg(feature = "onnx-embeddings")]
394            session,
395        }
396    }
397
398    /// Create a provider with custom dimensions and max tokens.
399    pub fn with_config(
400        model_path: impl Into<PathBuf>,
401        dimensions: usize,
402        max_tokens: usize,
403    ) -> Self {
404        let model_path = model_path.into();
405        #[cfg(feature = "onnx-embeddings")]
406        let session = Self::try_load_session(&model_path);
407        #[cfg(feature = "onnx-embeddings")]
408        let runtime_available = session.is_some();
409        #[cfg(not(feature = "onnx-embeddings"))]
410        let runtime_available = false;
411        #[cfg(feature = "onnx-embeddings")]
412        let tokenizer = Self::try_load_tokenizer(&model_path, max_tokens);
413
414        Self {
415            model_name: if runtime_available {
416                Self::MODEL_NAME.to_string()
417            } else {
418                format!("{}-hash-fallback", Self::MODEL_NAME)
419            },
420            model_path,
421            dimensions,
422            runtime_available,
423            max_tokens,
424            fallback: MockEmbeddingProvider::new(dimensions),
425            #[cfg(feature = "onnx-embeddings")]
426            tokenizer,
427            #[cfg(feature = "onnx-embeddings")]
428            session,
429        }
430    }
431
432    /// Attempt to load a WordPiece tokenizer from vocab.txt near the model.
433    #[cfg(feature = "onnx-embeddings")]
434    fn try_load_tokenizer(model_path: &Path, max_tokens: usize) -> Option<WordPieceTokenizer> {
435        for path in vocab_search_paths(model_path) {
436            if path.exists()
437                && let Some(tok) = WordPieceTokenizer::load(&path)
438            {
439                // max_tokens here refers to the token count limit; for
440                // WordPiece the max_length (including [CLS]/[SEP]) is
441                // max_tokens + 2, capped at 512 for BERT models.
442                let max_len = (max_tokens + 2).min(512);
443                return Some(tok.with_max_length(max_len));
444            }
445        }
446        tracing::debug!(
447            "No vocab.txt found for WordPiece tokenizer near {}; \
448             ONNX inference will use hash-based token IDs (degraded quality)",
449            model_path.display()
450        );
451        None
452    }
453
454    /// Attempt to load an ONNX runtime session from the model path.
455    #[cfg(feature = "onnx-embeddings")]
456    fn try_load_session(model_path: &PathBuf) -> Option<Arc<Mutex<ort::session::Session>>> {
457        if !model_path.exists() {
458            tracing::debug!(
459                "ONNX model not found at {}, using hash fallback",
460                model_path.display()
461            );
462            return None;
463        }
464        match ort::session::Session::builder()
465            .and_then(|mut builder| builder.commit_from_file(model_path))
466        {
467            Ok(session) => {
468                tracing::info!("ONNX session loaded from {}", model_path.display());
469                Some(Arc::new(Mutex::new(session)))
470            }
471            Err(e) => {
472                tracing::warn!("Failed to load ONNX session: {e}, using hash fallback");
473                None
474            }
475        }
476    }
477
478    /// Whether the real ONNX runtime is active (vs. fallback).
479    pub fn is_runtime_available(&self) -> bool {
480        self.runtime_available
481    }
482
483    /// Path to the configured model file.
484    pub fn model_path(&self) -> &PathBuf {
485        &self.model_path
486    }
487
488    /// Maximum input token count.
489    pub fn max_tokens(&self) -> usize {
490        self.max_tokens
491    }
492
493    /// Embed using the token-hashing fallback.
494    fn hash_embed(&self, text: &str) -> Vec<f32> {
495        let tokens = simple_tokenize(text, self.max_tokens);
496        if tokens.is_empty() {
497            // Return zero vector for empty input.
498            return vec![0.0f32; self.dimensions];
499        }
500        tokens_to_embedding(&tokens, self.dimensions)
501    }
502
503    /// Run real ONNX inference on the input text.
504    ///
505    /// Uses the WordPiece tokenizer (if vocab.txt was loaded) to produce
506    /// correct token IDs for the all-MiniLM-L6-v2 model. Falls back to
507    /// hash-based token IDs when no vocab is available (degraded quality
508    /// but still structurally valid).
509    ///
510    /// Builds input tensors (input_ids, attention_mask, token_type_ids),
511    /// runs the model, and mean-pools the last hidden state (masked by
512    /// attention_mask) to produce a fixed-size embedding.
513    #[cfg(feature = "onnx-embeddings")]
514    fn onnx_embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
515        let session = self
516            .session
517            .as_ref()
518            .ok_or_else(|| EmbeddingError::BackendError("ONNX session not loaded".to_string()))?;
519
520        // Tokenize using WordPiece if available, otherwise fall back to hashing.
521        let (input_ids, attention_mask, token_type_ids) =
522            if let Some(ref tokenizer) = self.tokenizer {
523                tokenizer.encode(text).ok_or_else(|| {
524                    EmbeddingError::BackendError("WordPiece tokenizer returned None".to_string())
525                })?
526            } else {
527                // Legacy hash-based fallback (produces structurally valid but
528                // semantically meaningless token IDs).
529                static WARNED: std::sync::atomic::AtomicBool =
530                    std::sync::atomic::AtomicBool::new(false);
531                if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
532                    tracing::warn!(
533                        "ONNX inference without WordPiece vocab — embeddings will not be semantic"
534                    );
535                }
536                let tokens = simple_tokenize(text, self.max_tokens);
537                let seq_len = tokens.len().max(1) + 2; // +2 for [CLS] and [SEP]
538
539                let mut ids = vec![CLS_ID];
540                for token in &tokens {
541                    let mut hasher = Sha256::new();
542                    hasher.update(token.as_bytes());
543                    let hash = hasher.finalize();
544                    let id = 1000
545                        + (u32::from_le_bytes([hash[0], hash[1], hash[2], hash[3]]) % 29000) as i64;
546                    ids.push(id);
547                }
548                ids.push(SEP_ID);
549
550                let mask = vec![1i64; seq_len];
551                let types = vec![0i64; seq_len];
552                (ids, mask, types)
553            };
554
555        let seq_len = input_ids.len();
556        let shape = vec![1_i64, seq_len as i64];
557
558        // Build tensors via the (shape, Vec) form so we don't depend on ort's
559        // ndarray `From` impls (the `ort::inputs!` `name => value` arm wants a
560        // value that is `Into<SessionInputValue>`, which `Tensor<T>` satisfies).
561        let input_ids_t = ort::value::Tensor::from_array((shape.clone(), input_ids))
562            .map_err(|e| EmbeddingError::BackendError(format!("input_ids tensor: {e}")))?;
563        let attention_mask_t =
564            ort::value::Tensor::from_array((shape.clone(), attention_mask.clone()))
565                .map_err(|e| EmbeddingError::BackendError(format!("attention_mask tensor: {e}")))?;
566        let token_type_ids_t = ort::value::Tensor::from_array((shape, token_type_ids))
567            .map_err(|e| EmbeddingError::BackendError(format!("token_type_ids tensor: {e}")))?;
568
569        let inputs = ort::inputs![
570            "input_ids" => input_ids_t,
571            "attention_mask" => attention_mask_t,
572            "token_type_ids" => token_type_ids_t,
573        ];
574
575        // `run` needs `&mut Session`; the std mutex is held only across the
576        // synchronous inference call (no await in between).
577        let mut guard = session
578            .lock()
579            .map_err(|_| EmbeddingError::BackendError("ONNX session mutex poisoned".to_string()))?;
580        let outputs = guard
581            .run(inputs)
582            .map_err(|e| EmbeddingError::BackendError(format!("inference error: {e}")))?;
583
584        // Extract the last_hidden_state output and mean-pool across the sequence.
585        // Output shape: (1, seq_len, hidden_dim)
586        let output_tensor = match outputs.get("last_hidden_state") {
587            Some(v) => v,
588            None if outputs.len() > 0 => &outputs[0],
589            None => {
590                return Err(EmbeddingError::BackendError("no output tensor".to_string()));
591            }
592        };
593
594        // rc.12 returns (shape, contiguous slice).
595        let (out_shape, data) = output_tensor
596            .try_extract_tensor::<f32>()
597            .map_err(|e| EmbeddingError::BackendError(format!("extract error: {e}")))?;
598
599        if out_shape.len() < 2 {
600            return Err(EmbeddingError::BackendError(format!(
601                "unexpected output shape: {out_shape:?}"
602            )));
603        }
604        let hidden_dim = out_shape[out_shape.len() - 1] as usize;
605        let seq = out_shape[1] as usize;
606
607        // Attention-masked mean pooling: only average over non-padding tokens.
608        let mut embedding = vec![0.0f32; hidden_dim];
609
610        let mut active_count: f32 = 0.0;
611        for s in 0..seq {
612            let mask_val = if s < attention_mask.len() {
613                attention_mask[s] as f32
614            } else {
615                0.0
616            };
617            if mask_val > 0.0 {
618                for d in 0..hidden_dim {
619                    embedding[d] += data[s * hidden_dim + d];
620                }
621                active_count += 1.0;
622            }
623        }
624        if active_count > 0.0 {
625            for val in &mut embedding {
626                *val /= active_count;
627            }
628        }
629
630        // L2 normalize.
631        l2_normalize(&mut embedding);
632
633        // Truncate or pad to expected dimensions.
634        embedding.truncate(self.dimensions);
635        while embedding.len() < self.dimensions {
636            embedding.push(0.0);
637        }
638
639        Ok(embedding)
640    }
641}
642
643#[async_trait]
644impl EmbeddingProvider for OnnxEmbeddingProvider {
645    async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
646        #[cfg(feature = "onnx-embeddings")]
647        if self.runtime_available {
648            return self.onnx_embed(text);
649        }
650        Ok(self.hash_embed(text))
651    }
652
653    async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
654        let mut results = Vec::with_capacity(texts.len());
655        for text in texts {
656            results.push(self.embed(text).await?);
657        }
658        Ok(results)
659    }
660
661    fn dimensions(&self) -> usize {
662        self.dimensions
663    }
664
665    fn model_name(&self) -> &str {
666        &self.model_name
667    }
668}
669
670// =========================================================================
671// Backend 2: SentenceTransformerProvider
672// =========================================================================
673
674/// Sentence-transformer embedding provider for documentation.
675///
676/// Optimised for natural language paragraphs rather than code.  Pre-processes
677/// markdown, splits into sentences, embeds each, and averages (mean pooling).
678pub struct SentenceTransformerProvider {
679    /// Base ONNX provider (reuses the same infrastructure).
680    base: OnnxEmbeddingProvider,
681    /// Max token length (longer for docs than code).
682    max_tokens: usize,
683    /// Whether sentence splitting is enabled.
684    split_sentences: bool,
685}
686
687impl SentenceTransformerProvider {
688    /// Default max tokens for documentation (longer context than code).
689    pub const DEFAULT_MAX_TOKENS: usize = 512;
690
691    /// Create a new sentence-transformer provider.
692    pub fn new(model_path: impl Into<PathBuf>) -> Self {
693        Self {
694            base: OnnxEmbeddingProvider::with_config(
695                model_path,
696                OnnxEmbeddingProvider::DEFAULT_DIMS,
697                Self::DEFAULT_MAX_TOKENS,
698            ),
699            max_tokens: Self::DEFAULT_MAX_TOKENS,
700            split_sentences: true,
701        }
702    }
703
704    /// Create with custom max tokens and optional sentence splitting.
705    pub fn with_config(
706        model_path: impl Into<PathBuf>,
707        max_tokens: usize,
708        split_sentences: bool,
709    ) -> Self {
710        Self {
711            base: OnnxEmbeddingProvider::with_config(
712                model_path,
713                OnnxEmbeddingProvider::DEFAULT_DIMS,
714                max_tokens,
715            ),
716            max_tokens,
717            split_sentences,
718        }
719    }
720
721    /// Whether sentence splitting is enabled.
722    pub fn split_sentences(&self) -> bool {
723        self.split_sentences
724    }
725
726    /// Max token length.
727    pub fn max_tokens(&self) -> usize {
728        self.max_tokens
729    }
730
731    /// Embed a single sentence/paragraph through the base provider.
732    fn embed_text(&self, text: &str) -> Vec<f32> {
733        self.base.hash_embed(text)
734    }
735}
736
737/// Pre-process markdown text by stripping structural elements.
738pub fn preprocess_markdown(text: &str) -> String {
739    text.lines()
740        .filter(|l| !l.starts_with('#')) // skip headers
741        .filter(|l| !l.starts_with("```")) // skip code fences
742        .filter(|l| !l.starts_with('|')) // skip tables
743        .filter(|l| !l.starts_with("- [")) // skip checklists
744        .map(|l| l.trim())
745        .filter(|l| !l.is_empty())
746        .collect::<Vec<_>>()
747        .join(" ")
748}
749
750/// Simple sentence splitting on ". " and newlines.
751pub fn split_sentences(text: &str) -> Vec<&str> {
752    text.split(". ")
753        .flat_map(|s| s.split('\n'))
754        .map(|s| s.trim())
755        .filter(|s| s.len() > 10) // skip tiny fragments
756        .collect()
757}
758
759#[async_trait]
760impl EmbeddingProvider for SentenceTransformerProvider {
761    async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
762        let cleaned = preprocess_markdown(text);
763
764        if !self.split_sentences {
765            return Ok(self.embed_text(&cleaned));
766        }
767
768        let sentences = split_sentences(&cleaned);
769        if sentences.is_empty() {
770            // Fall through to full-text embedding.
771            return Ok(self.embed_text(&cleaned));
772        }
773
774        // Mean pooling across sentence embeddings.
775        let dims = self.base.dimensions;
776        let mut summed = vec![0.0f32; dims];
777        let count = sentences.len() as f32;
778
779        for sentence in &sentences {
780            let vec = self.embed_text(sentence);
781            for (i, val) in vec.iter().enumerate() {
782                summed[i] += val;
783            }
784        }
785
786        // Average and re-normalise.
787        summed.iter_mut().for_each(|x| *x /= count);
788        l2_normalize(&mut summed);
789
790        Ok(summed)
791    }
792
793    async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
794        let mut results = Vec::with_capacity(texts.len());
795        for text in texts {
796            results.push(self.embed(text).await?);
797        }
798        Ok(results)
799    }
800
801    fn dimensions(&self) -> usize {
802        self.base.dimensions
803    }
804
805    fn model_name(&self) -> &str {
806        "sentence-transformer"
807    }
808}
809
810// =========================================================================
811// Backend 3: AstEmbeddingProvider
812// =========================================================================
813
814/// Structural features extracted from Rust source code via regex parsing.
815#[derive(Debug, Clone, Default, Serialize, Deserialize)]
816pub struct RustCodeFeatures {
817    /// Function/type/impl signature.
818    pub signature: Option<String>,
819    /// Return type.
820    pub return_type: Option<String>,
821    /// Parameter types.
822    pub param_types: Vec<String>,
823    /// Visibility (pub, pub(crate), private).
824    pub visibility: String,
825    /// Whether it is async.
826    pub is_async: bool,
827    /// Whether it is generic.
828    pub is_generic: bool,
829    /// Trait bounds.
830    pub trait_bounds: Vec<String>,
831    /// Attributes (#[test], #[cfg(...)], etc.)
832    pub attributes: Vec<String>,
833    /// Item kind (fn, struct, enum, impl, trait, mod).
834    pub item_kind: String,
835}
836
837/// Extract structural features from a Rust code snippet using simple regex-like
838/// parsing.  Does not depend on tree-sitter so the kernel stays lightweight.
839pub fn extract_rust_features(code: &str) -> RustCodeFeatures {
840    let mut features = RustCodeFeatures::default();
841
842    // -- Item kind --------------------------------------------------------
843    // Order matters: check trait/struct/enum before fn, because trait and
844    // impl bodies often contain `fn` keywords.
845    if code.contains("pub trait ") || code.contains("trait ") {
846        features.item_kind = "trait".into();
847    } else if code.contains("pub struct ") || code.contains("struct ") {
848        features.item_kind = "struct".into();
849    } else if code.contains("pub enum ") || code.contains("enum ") {
850        features.item_kind = "enum".into();
851    } else if code.contains("pub fn ") || code.contains("fn ") {
852        features.item_kind = "fn".into();
853    } else if code.contains("impl ") {
854        features.item_kind = "impl".into();
855    } else if code.contains("pub mod ") || code.contains("mod ") {
856        features.item_kind = "mod".into();
857    }
858
859    // -- Visibility -------------------------------------------------------
860    features.visibility = if code.contains("pub(crate)") {
861        "pub(crate)".into()
862    } else if code.contains("pub(super)") {
863        "pub(super)".into()
864    } else if code.contains("pub ") {
865        "pub".into()
866    } else {
867        "private".into()
868    };
869
870    // -- Async ------------------------------------------------------------
871    features.is_async = code.contains("async fn");
872
873    // -- Generics ---------------------------------------------------------
874    features.is_generic = code.contains('<') && code.contains('>');
875
876    // -- Signature (first fn/struct/enum/trait line) -----------------------
877    for line in code.lines() {
878        let trimmed = line.trim();
879        if trimmed.contains("fn ")
880            || trimmed.starts_with("pub struct ")
881            || trimmed.starts_with("struct ")
882            || trimmed.starts_with("pub enum ")
883            || trimmed.starts_with("enum ")
884            || trimmed.starts_with("pub trait ")
885            || trimmed.starts_with("trait ")
886        {
887            // Take up to '{' or end of line.
888            let sig = if let Some(brace) = trimmed.find('{') {
889                trimmed[..brace].trim()
890            } else {
891                trimmed.trim_end_matches(';').trim()
892            };
893            features.signature = Some(sig.to_string());
894            break;
895        }
896    }
897
898    // -- Return type (after -> before { or ;) -----------------------------
899    if let Some(arrow) = code.find("->") {
900        let after = &code[arrow + 2..];
901        if let Some(brace) = after.find('{') {
902            features.return_type = Some(after[..brace].trim().to_string());
903        } else if let Some(semi) = after.find(';') {
904            features.return_type = Some(after[..semi].trim().to_string());
905        }
906    }
907
908    // -- Parameter types (inside parentheses of fn) -----------------------
909    if features.item_kind == "fn"
910        && let Some(open) = code.find('(')
911        && let Some(close) = code.find(')')
912        && close > open
913    {
914        let params = &code[open + 1..close];
915        for param in params.split(',') {
916            let param = param.trim();
917            if param == "&self" || param == "&mut self" || param == "self" {
918                features.param_types.push(param.to_string());
919            } else if let Some(colon) = param.find(':') {
920                let ty = param[colon + 1..].trim().to_string();
921                if !ty.is_empty() {
922                    features.param_types.push(ty);
923                }
924            }
925        }
926    }
927
928    // -- Trait bounds (where clause) --------------------------------------
929    if let Some(where_idx) = code.find("where") {
930        let after = &code[where_idx + 5..];
931        let end = after.find('{').unwrap_or(after.len());
932        let clause = &after[..end];
933        for bound in clause.split(',') {
934            let bound = bound.trim();
935            if !bound.is_empty() {
936                features.trait_bounds.push(bound.to_string());
937            }
938        }
939    }
940
941    // -- Attributes -------------------------------------------------------
942    for line in code.lines() {
943        let trimmed = line.trim();
944        if trimmed.starts_with("#[") {
945            features.attributes.push(trimmed.to_string());
946        }
947    }
948
949    features
950}
951
952/// AST-aware embedding provider for Rust source code.
953///
954/// Combines structural features (signature, types, visibility) with semantic
955/// text embeddings for hybrid code understanding.
956pub struct AstEmbeddingProvider {
957    /// Base text embedding provider.
958    text_provider: OnnxEmbeddingProvider,
959    /// Dimensions allocated to structural features.
960    structural_dims: usize,
961    /// Total output dimensions.
962    total_dims: usize,
963    /// Weight for structural vs. text features [0.0, 1.0].
964    structural_weight: f32,
965}
966
967impl AstEmbeddingProvider {
968    /// Default total output dimensionality.
969    pub const DEFAULT_TOTAL_DIMS: usize = 256;
970    /// Default structural feature dimensions.
971    pub const DEFAULT_STRUCTURAL_DIMS: usize = 64;
972    /// Default weight for structural features.
973    pub const DEFAULT_STRUCTURAL_WEIGHT: f32 = 0.3;
974
975    /// Create a new AST-aware provider with default configuration.
976    pub fn new(model_path: impl Into<PathBuf>) -> Self {
977        Self {
978            text_provider: OnnxEmbeddingProvider::with_config(
979                model_path,
980                Self::DEFAULT_TOTAL_DIMS - Self::DEFAULT_STRUCTURAL_DIMS,
981                OnnxEmbeddingProvider::DEFAULT_MAX_TOKENS,
982            ),
983            structural_dims: Self::DEFAULT_STRUCTURAL_DIMS,
984            total_dims: Self::DEFAULT_TOTAL_DIMS,
985            structural_weight: Self::DEFAULT_STRUCTURAL_WEIGHT,
986        }
987    }
988
989    /// Create with custom configuration.
990    pub fn with_config(
991        model_path: impl Into<PathBuf>,
992        total_dims: usize,
993        structural_dims: usize,
994        structural_weight: f32,
995    ) -> Self {
996        assert!(
997            structural_dims < total_dims,
998            "structural_dims must be less than total_dims"
999        );
1000        let text_dims = total_dims - structural_dims;
1001        Self {
1002            text_provider: OnnxEmbeddingProvider::with_config(
1003                model_path,
1004                text_dims,
1005                OnnxEmbeddingProvider::DEFAULT_MAX_TOKENS,
1006            ),
1007            structural_dims,
1008            total_dims,
1009            structural_weight: structural_weight.clamp(0.0, 1.0),
1010        }
1011    }
1012
1013    /// Total output dimensions.
1014    pub fn total_dims(&self) -> usize {
1015        self.total_dims
1016    }
1017
1018    /// Weight applied to structural features.
1019    pub fn structural_weight(&self) -> f32 {
1020        self.structural_weight
1021    }
1022
1023    /// Encode [`RustCodeFeatures`] into a fixed-size structural vector.
1024    fn encode_structural(&self, features: &RustCodeFeatures) -> Vec<f32> {
1025        let dims = self.structural_dims;
1026        let mut vec = vec![0.0f32; dims];
1027
1028        // Hash each feature category into different regions of the vector.
1029        let mut write_hash = |label: &str, offset: usize, slots: usize| {
1030            let mut hasher = Sha256::new();
1031            hasher.update(label.as_bytes());
1032            let hash = hasher.finalize();
1033            for (j, &byte) in hash.iter().enumerate().take(slots.min(32)) {
1034                let dim = (offset + j) % dims;
1035                vec[dim] += (byte as f32 / 128.0) - 1.0;
1036            }
1037        };
1038
1039        // Item kind (fn, struct, enum, ...).
1040        write_hash(&format!("kind:{}", features.item_kind), 0, 8);
1041
1042        // Visibility.
1043        write_hash(&format!("vis:{}", features.visibility), 8, 6);
1044
1045        // Async flag.
1046        if features.is_async {
1047            write_hash("async:true", 14, 4);
1048        }
1049
1050        // Generic flag.
1051        if features.is_generic {
1052            write_hash("generic:true", 18, 4);
1053        }
1054
1055        // Return type.
1056        if let Some(ref rt) = features.return_type {
1057            write_hash(&format!("ret:{rt}"), 22, 8);
1058        }
1059
1060        // Parameter types.
1061        for (i, pt) in features.param_types.iter().enumerate() {
1062            write_hash(&format!("param{i}:{pt}"), 30 + i * 6, 6);
1063        }
1064
1065        // Attributes.
1066        for (i, attr) in features.attributes.iter().enumerate() {
1067            write_hash(&format!("attr{i}:{attr}"), 48 + i * 4, 4);
1068        }
1069
1070        l2_normalize(&mut vec);
1071        vec
1072    }
1073
1074    /// Produce the hybrid embedding for a Rust code snippet.
1075    fn hybrid_embed(&self, code: &str) -> Vec<f32> {
1076        let features = extract_rust_features(code);
1077        let structural = self.encode_structural(&features);
1078        let text = self.text_provider.hash_embed(code);
1079
1080        let w_s = self.structural_weight;
1081        let w_t = 1.0 - w_s;
1082
1083        // Concatenate weighted structural + text vectors.
1084        let mut combined = Vec::with_capacity(self.total_dims);
1085        for val in &structural {
1086            combined.push(val * w_s);
1087        }
1088        for val in &text {
1089            combined.push(val * w_t);
1090        }
1091
1092        // Ensure exact dimensionality.
1093        combined.truncate(self.total_dims);
1094        while combined.len() < self.total_dims {
1095            combined.push(0.0);
1096        }
1097
1098        l2_normalize(&mut combined);
1099        combined
1100    }
1101}
1102
1103#[async_trait]
1104impl EmbeddingProvider for AstEmbeddingProvider {
1105    async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
1106        Ok(self.hybrid_embed(text))
1107    }
1108
1109    async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
1110        Ok(texts.iter().map(|t| self.hybrid_embed(t)).collect())
1111    }
1112
1113    fn dimensions(&self) -> usize {
1114        self.total_dims
1115    }
1116
1117    fn model_name(&self) -> &str {
1118        "ast-aware-hybrid"
1119    }
1120}
1121
1122// =========================================================================
1123// Tests
1124// =========================================================================
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129
1130    // -- Helper -----------------------------------------------------------
1131
1132    fn vec_magnitude(v: &[f32]) -> f32 {
1133        v.iter().map(|x| x * x).sum::<f32>().sqrt()
1134    }
1135
1136    // =====================================================================
1137    // OnnxEmbeddingProvider tests
1138    // =====================================================================
1139
1140    #[test]
1141    fn onnx_construction_default() {
1142        let p = OnnxEmbeddingProvider::new("/nonexistent/model.onnx");
1143        assert_eq!(p.dimensions(), 384);
1144        assert!(!p.is_runtime_available());
1145        assert!(p.model_name().contains("fallback"));
1146    }
1147
1148    #[test]
1149    fn onnx_construction_custom() {
1150        let p = OnnxEmbeddingProvider::with_config("/tmp/model.onnx", 128, 64);
1151        assert_eq!(p.dimensions(), 128);
1152        assert_eq!(p.max_tokens(), 64);
1153    }
1154
1155    #[tokio::test]
1156    async fn onnx_embed_returns_correct_dimensions() {
1157        let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1158        let vec = p.embed("hello world").await.unwrap();
1159        assert_eq!(vec.len(), 384);
1160    }
1161
1162    #[tokio::test]
1163    async fn onnx_embed_deterministic() {
1164        let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1165        let v1 = p.embed("test input").await.unwrap();
1166        let v2 = p.embed("test input").await.unwrap();
1167        assert_eq!(v1, v2);
1168    }
1169
1170    #[tokio::test]
1171    async fn onnx_embed_different_inputs_differ() {
1172        let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1173        let v1 = p.embed("alpha").await.unwrap();
1174        let v2 = p.embed("beta").await.unwrap();
1175        assert_ne!(v1, v2);
1176    }
1177
1178    #[tokio::test]
1179    async fn onnx_embed_l2_normalized() {
1180        let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1181        let vec = p.embed("normalisation check").await.unwrap();
1182        let mag = vec_magnitude(&vec);
1183        assert!((mag - 1.0).abs() < 0.01, "magnitude = {mag}, expected ~1.0");
1184    }
1185
1186    #[tokio::test]
1187    async fn onnx_embed_batch() {
1188        let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1189        let results = p.embed_batch(&["a", "b", "c"]).await.unwrap();
1190        assert_eq!(results.len(), 3);
1191        for v in &results {
1192            assert_eq!(v.len(), 384);
1193        }
1194    }
1195
1196    #[tokio::test]
1197    async fn onnx_similar_inputs_high_cosine() {
1198        let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1199        let v1 = p.embed("the quick brown fox").await.unwrap();
1200        let v2 = p.embed("the quick brown dog").await.unwrap();
1201        let sim = cosine_similarity(&v1, &v2);
1202        assert!(sim > 0.5, "similar inputs cosine = {sim}, expected > 0.5");
1203    }
1204
1205    #[tokio::test]
1206    async fn onnx_empty_input_returns_zero_vector() {
1207        let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1208        let vec = p.embed("").await.unwrap();
1209        assert_eq!(vec.len(), 384);
1210        assert!(vec.iter().all(|x| *x == 0.0));
1211    }
1212
1213    // =====================================================================
1214    // SentenceTransformerProvider tests
1215    // =====================================================================
1216
1217    #[test]
1218    fn sentence_construction() {
1219        let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1220        assert_eq!(p.dimensions(), 384);
1221        assert_eq!(p.max_tokens(), 512);
1222        assert!(p.split_sentences());
1223        assert_eq!(p.model_name(), "sentence-transformer");
1224    }
1225
1226    #[tokio::test]
1227    async fn sentence_embed_returns_correct_dimensions() {
1228        let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1229        let vec = p
1230            .embed("This is a test paragraph with enough words.")
1231            .await
1232            .unwrap();
1233        assert_eq!(vec.len(), 384);
1234    }
1235
1236    #[tokio::test]
1237    async fn sentence_embed_l2_normalized() {
1238        let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1239        let vec = p
1240            .embed("Testing normalisation of sentence embeddings here.")
1241            .await
1242            .unwrap();
1243        let mag = vec_magnitude(&vec);
1244        assert!((mag - 1.0).abs() < 0.01, "magnitude = {mag}, expected ~1.0");
1245    }
1246
1247    #[tokio::test]
1248    async fn sentence_embed_batch() {
1249        let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1250        let results = p
1251            .embed_batch(&[
1252                "First paragraph with a decent amount of words in it.",
1253                "Second paragraph also has a reasonable length for testing.",
1254            ])
1255            .await
1256            .unwrap();
1257        assert_eq!(results.len(), 2);
1258        for v in &results {
1259            assert_eq!(v.len(), 384);
1260        }
1261    }
1262
1263    #[tokio::test]
1264    async fn sentence_similar_inputs_positive_cosine() {
1265        let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1266        let v1 = p
1267            .embed("The kernel boots up the system and runs all the services correctly.")
1268            .await
1269            .unwrap();
1270        let v2 = p
1271            .embed("The kernel boots up the system and runs all the services properly.")
1272            .await
1273            .unwrap();
1274        let v3 = p
1275            .embed("Quantum chromodynamics explains the strong interaction between quarks.")
1276            .await
1277            .unwrap();
1278        let sim_similar = cosine_similarity(&v1, &v2);
1279        let sim_different = cosine_similarity(&v1, &v3);
1280        assert!(
1281            sim_similar > sim_different,
1282            "similar ({sim_similar}) should be closer than different ({sim_different})"
1283        );
1284    }
1285
1286    #[test]
1287    fn preprocess_markdown_strips_headers() {
1288        let md = "# Title\nSome text.\n## Subtitle\nMore text.";
1289        let result = preprocess_markdown(md);
1290        assert!(!result.contains("Title"));
1291        assert!(!result.contains("Subtitle"));
1292        assert!(result.contains("Some text."));
1293        assert!(result.contains("More text."));
1294    }
1295
1296    #[test]
1297    fn preprocess_markdown_strips_code_fences() {
1298        let md = "Before.\n```rust\nlet x = 1;\n```\nAfter.";
1299        let result = preprocess_markdown(md);
1300        assert!(!result.contains("```"));
1301        // Code line itself is kept (only fence markers are stripped).
1302        assert!(result.contains("Before."));
1303        assert!(result.contains("After."));
1304    }
1305
1306    #[test]
1307    fn preprocess_markdown_strips_tables() {
1308        let md = "Intro.\n| Col1 | Col2 |\n|------|------|\n| A | B |\nOutro.";
1309        let result = preprocess_markdown(md);
1310        assert!(!result.contains("Col1"));
1311        assert!(result.contains("Intro."));
1312        assert!(result.contains("Outro."));
1313    }
1314
1315    #[test]
1316    fn preprocess_markdown_strips_checklists() {
1317        let md = "Text here.\n- [x] Done item\n- [ ] Todo item\nMore text.";
1318        let result = preprocess_markdown(md);
1319        assert!(!result.contains("Done item"));
1320        assert!(result.contains("Text here."));
1321    }
1322
1323    #[test]
1324    fn split_sentences_basic() {
1325        let text = "First sentence here. Second sentence here. Third.";
1326        let sentences = split_sentences(text);
1327        // "Third." is only 6 chars, below the 10-char minimum.
1328        assert_eq!(sentences.len(), 2);
1329        assert!(sentences[0].contains("First"));
1330        assert!(sentences[1].contains("Second"));
1331    }
1332
1333    #[test]
1334    fn split_sentences_newlines() {
1335        let text = "Line one is long enough.\nLine two is also long enough.";
1336        let sentences = split_sentences(text);
1337        assert_eq!(sentences.len(), 2);
1338    }
1339
1340    // =====================================================================
1341    // AstEmbeddingProvider tests
1342    // =====================================================================
1343
1344    #[test]
1345    fn ast_construction_default() {
1346        let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1347        assert_eq!(p.dimensions(), 256);
1348        assert_eq!(p.total_dims(), 256);
1349        assert!((p.structural_weight() - 0.3).abs() < 0.001);
1350        assert_eq!(p.model_name(), "ast-aware-hybrid");
1351    }
1352
1353    #[tokio::test]
1354    async fn ast_embed_returns_correct_dimensions() {
1355        let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1356        let vec = p.embed("pub fn hello() -> String { }").await.unwrap();
1357        assert_eq!(vec.len(), 256);
1358    }
1359
1360    #[tokio::test]
1361    async fn ast_embed_l2_normalized() {
1362        let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1363        let vec = p.embed("pub fn hello() -> String { }").await.unwrap();
1364        let mag = vec_magnitude(&vec);
1365        assert!((mag - 1.0).abs() < 0.01, "magnitude = {mag}, expected ~1.0");
1366    }
1367
1368    #[tokio::test]
1369    async fn ast_embed_batch() {
1370        let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1371        let results = p
1372            .embed_batch(&["fn a() {}", "fn b() {}", "struct C {}"])
1373            .await
1374            .unwrap();
1375        assert_eq!(results.len(), 3);
1376        for v in &results {
1377            assert_eq!(v.len(), 256);
1378        }
1379    }
1380
1381    #[tokio::test]
1382    async fn ast_embed_different_inputs_differ() {
1383        let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1384        let v1 = p.embed("pub fn alpha() -> u32 {}").await.unwrap();
1385        let v2 = p.embed("struct Beta { x: f64 }").await.unwrap();
1386        assert_ne!(v1, v2);
1387    }
1388
1389    #[tokio::test]
1390    async fn ast_structural_similarity_same_signature() {
1391        // Two functions with same signature but different names should be
1392        // closer than two items with different signatures.
1393        let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1394        let v_foo = p
1395            .embed("pub async fn foo(&self, x: u32) -> Result<(), Error> {}")
1396            .await
1397            .unwrap();
1398        let v_bar = p
1399            .embed("pub async fn bar(&self, x: u32) -> Result<(), Error> {}")
1400            .await
1401            .unwrap();
1402        let v_struct = p.embed("pub struct Baz { count: usize }").await.unwrap();
1403
1404        let sim_fns = cosine_similarity(&v_foo, &v_bar);
1405        let sim_fn_struct = cosine_similarity(&v_foo, &v_struct);
1406        assert!(
1407            sim_fns > sim_fn_struct,
1408            "same-signature fns ({sim_fns}) should be more similar than fn-vs-struct ({sim_fn_struct})"
1409        );
1410    }
1411
1412    // =====================================================================
1413    // extract_rust_features tests
1414    // =====================================================================
1415
1416    #[test]
1417    fn rust_features_pub_async_fn() {
1418        let code = r#"
1419#[test]
1420pub async fn process_batch(&self, items: Vec<Item>) -> Result<(), Error> {
1421    // body
1422}
1423"#;
1424        let f = extract_rust_features(code);
1425        assert_eq!(f.item_kind, "fn");
1426        assert_eq!(f.visibility, "pub");
1427        assert!(f.is_async);
1428        assert!(f.is_generic);
1429        assert_eq!(f.return_type.as_deref(), Some("Result<(), Error>"));
1430        assert!(f.attributes.contains(&"#[test]".to_string()));
1431        assert!(f.param_types.contains(&"&self".to_string()));
1432        assert!(f.param_types.iter().any(|p| p.contains("Vec<Item>")));
1433    }
1434
1435    #[test]
1436    fn rust_features_struct() {
1437        let code = "pub struct Config { pub name: String, pub value: u64 }";
1438        let f = extract_rust_features(code);
1439        assert_eq!(f.item_kind, "struct");
1440        assert_eq!(f.visibility, "pub");
1441        assert!(!f.is_async);
1442        assert!(!f.is_generic); // no < > in this struct
1443        assert!(f.return_type.is_none());
1444    }
1445
1446    #[test]
1447    fn rust_features_private_fn() {
1448        let code = "fn helper(x: &str) -> bool { true }";
1449        let f = extract_rust_features(code);
1450        assert_eq!(f.item_kind, "fn");
1451        assert_eq!(f.visibility, "private");
1452        assert!(!f.is_async);
1453        assert_eq!(f.return_type.as_deref(), Some("bool"));
1454        assert!(f.param_types.iter().any(|p| p.contains("&str")));
1455    }
1456
1457    #[test]
1458    fn rust_features_enum() {
1459        let code = "pub enum Status { Active, Inactive, Pending }";
1460        let f = extract_rust_features(code);
1461        assert_eq!(f.item_kind, "enum");
1462        assert_eq!(f.visibility, "pub");
1463    }
1464
1465    #[test]
1466    fn rust_features_trait() {
1467        let code = "pub trait Displayable { fn display(&self) -> String; }";
1468        let f = extract_rust_features(code);
1469        assert_eq!(f.item_kind, "trait");
1470        assert_eq!(f.visibility, "pub");
1471    }
1472
1473    #[test]
1474    fn rust_features_impl_block() {
1475        let code = "impl MyStruct { fn new() -> Self { Self {} } }";
1476        let f = extract_rust_features(code);
1477        // "fn" is detected before "impl" because code.contains("fn ")
1478        assert_eq!(f.item_kind, "fn");
1479    }
1480
1481    #[test]
1482    fn rust_features_where_clause() {
1483        let code = "pub fn serialize<T>(val: T) -> String where T: Serialize + Debug { }";
1484        let f = extract_rust_features(code);
1485        assert!(f.is_generic);
1486        assert!(!f.trait_bounds.is_empty());
1487        assert!(f.trait_bounds.iter().any(|b| b.contains("Serialize")));
1488    }
1489
1490    #[test]
1491    fn rust_features_pub_crate() {
1492        let code = "pub(crate) fn internal_helper() {}";
1493        let f = extract_rust_features(code);
1494        assert_eq!(f.visibility, "pub(crate)");
1495    }
1496
1497    #[test]
1498    fn rust_features_multiple_attributes() {
1499        let code = "#[cfg(test)]\n#[allow(dead_code)]\nfn test_fn() {}";
1500        let f = extract_rust_features(code);
1501        assert_eq!(f.attributes.len(), 2);
1502        assert!(f.attributes.contains(&"#[cfg(test)]".to_string()));
1503        assert!(f.attributes.contains(&"#[allow(dead_code)]".to_string()));
1504    }
1505
1506    // =====================================================================
1507    // Tokenisation helper tests
1508    // =====================================================================
1509
1510    #[test]
1511    fn simple_tokenize_basic() {
1512        let tokens = simple_tokenize("Hello World! Foo-bar", 10);
1513        assert_eq!(tokens, vec!["hello", "world", "foobar"]);
1514    }
1515
1516    #[test]
1517    fn simple_tokenize_max_tokens() {
1518        let tokens = simple_tokenize("a b c d e f", 3);
1519        assert_eq!(tokens.len(), 3);
1520    }
1521
1522    #[test]
1523    fn simple_tokenize_empty() {
1524        let tokens = simple_tokenize("", 10);
1525        assert!(tokens.is_empty());
1526    }
1527
1528    #[test]
1529    fn tokens_to_embedding_deterministic() {
1530        let tokens: Vec<String> = vec!["hello".into(), "world".into()];
1531        let v1 = tokens_to_embedding(&tokens, 64);
1532        let v2 = tokens_to_embedding(&tokens, 64);
1533        assert_eq!(v1, v2);
1534    }
1535
1536    #[test]
1537    fn tokens_to_embedding_normalized() {
1538        let tokens: Vec<String> = vec!["test".into()];
1539        let v = tokens_to_embedding(&tokens, 128);
1540        let mag = vec_magnitude(&v);
1541        assert!((mag - 1.0).abs() < 0.01);
1542    }
1543
1544    // =====================================================================
1545    // WordPiece tokenizer tests
1546    // =====================================================================
1547
1548    /// Create a small test vocab file for WordPiece tests.
1549    /// Returns the path to the written file.
1550    fn make_test_vocab() -> PathBuf {
1551        use std::fmt::Write as FmtWrite;
1552        let mut content = String::new();
1553        // Build a minimal BERT-style vocab (needs >1000 entries).
1554        // IDs 0-99: [unused0]..[unused99]
1555        for i in 0..100 {
1556            writeln!(content, "[unused{}]", i).unwrap();
1557        }
1558        writeln!(content, "[UNK]").unwrap(); // ID 100
1559        writeln!(content, "[CLS]").unwrap(); // ID 101
1560        writeln!(content, "[SEP]").unwrap(); // ID 102
1561        writeln!(content, "[MASK]").unwrap(); // ID 103
1562        for i in 104..1000 {
1563            writeln!(content, "[unused{}]", i).unwrap();
1564        }
1565        // ID 1000+: real tokens
1566        let words = [
1567            "the",
1568            "a",
1569            "is",
1570            "of",
1571            "and",
1572            "to",
1573            "in",
1574            "for",
1575            "that",
1576            "it",
1577            "hello",
1578            "world",
1579            "test",
1580            "input",
1581            "embedding",
1582            "model",
1583            "token",
1584            "##s",
1585            "##ing",
1586            "##ed",
1587            "##er",
1588            "##tion",
1589            "##ly",
1590            "##ize",
1591            ".",
1592            ",",
1593            "!",
1594            "?",
1595            "quick",
1596            "brown",
1597            "fox",
1598            "dog",
1599            "cat",
1600            "rust",
1601            "code",
1602            "function",
1603            "struct",
1604            "pub",
1605            "async",
1606            "fn",
1607        ];
1608        for w in &words {
1609            writeln!(content, "{}", w).unwrap();
1610        }
1611        // Pad to >1000 entries total.
1612        for i in 0..100 {
1613            writeln!(content, "extra{}", i).unwrap();
1614        }
1615
1616        // Unique path per call. All tests in this binary share one
1617        // process, so a PID-only filename made them race on a single
1618        // file: `fs::write` truncates before writing, and a concurrent
1619        // `load()` could read the half-written file (<1000 entries) and
1620        // return None — the wordpiece tests then flaked under parallel
1621        // execution. A per-call counter gives each test its own file.
1622        static VOCAB_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1623        let seq = VOCAB_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1624        let path = std::env::temp_dir().join(format!(
1625            "clawft_test_vocab_{}_{}.txt",
1626            std::process::id(),
1627            seq
1628        ));
1629        std::fs::write(&path, &content).expect("failed to write test vocab");
1630        path
1631    }
1632
1633    #[test]
1634    fn wordpiece_load_valid_vocab() {
1635        let f = make_test_vocab();
1636        let tok = WordPieceTokenizer::load(&f);
1637        assert!(tok.is_some(), "should load a vocab with >1000 entries");
1638    }
1639
1640    #[test]
1641    fn wordpiece_load_missing_file() {
1642        let tok = WordPieceTokenizer::load(Path::new("/nonexistent/vocab.txt"));
1643        assert!(tok.is_none());
1644    }
1645
1646    #[test]
1647    fn wordpiece_encode_produces_cls_sep() {
1648        let f = make_test_vocab();
1649        let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(32);
1650        let (ids, mask, types) = tok.encode("hello world").unwrap();
1651        assert_eq!(ids.len(), 32, "should be padded to max_length");
1652        assert_eq!(ids[0], CLS_ID, "first token must be [CLS]");
1653        // Find [SEP] -- it should be after the content tokens.
1654        let sep_pos = ids.iter().position(|&x| x == SEP_ID);
1655        assert!(sep_pos.is_some(), "must contain [SEP]");
1656        let sep_pos = sep_pos.unwrap();
1657        assert!(
1658            sep_pos >= 2,
1659            "[SEP] should come after at least one content token"
1660        );
1661        // Attention mask: 1s up to and including [SEP], then 0s.
1662        assert_eq!(mask[0], 1);
1663        assert_eq!(mask[sep_pos], 1);
1664        if sep_pos + 1 < 32 {
1665            assert_eq!(mask[sep_pos + 1], 0, "padding should have mask=0");
1666        }
1667        // Token type IDs should all be 0 for single-sentence input.
1668        assert!(types.iter().all(|&t| t == 0));
1669    }
1670
1671    #[test]
1672    fn wordpiece_encode_known_tokens() {
1673        let f = make_test_vocab();
1674        let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(16);
1675        let (ids, _, _) = tok.encode("hello").unwrap();
1676        // "hello" is in our test vocab -- should NOT be [UNK].
1677        let content_ids: Vec<i64> = ids[1..]
1678            .iter()
1679            .take_while(|&&x| x != SEP_ID)
1680            .cloned()
1681            .collect();
1682        assert!(
1683            !content_ids.is_empty(),
1684            "should tokenize 'hello' to at least one token"
1685        );
1686        assert!(
1687            content_ids.iter().any(|&id| id != UNK_ID),
1688            "known word 'hello' should not be all [UNK]"
1689        );
1690    }
1691
1692    #[test]
1693    fn wordpiece_encode_unknown_token_uses_unk() {
1694        let f = make_test_vocab();
1695        let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(16);
1696        let (ids, _, _) = tok.encode("xyzzyplugh").unwrap();
1697        // "xyzzyplugh" is not in our vocab, so it should produce [UNK].
1698        let content_ids: Vec<i64> = ids[1..]
1699            .iter()
1700            .take_while(|&&x| x != SEP_ID)
1701            .cloned()
1702            .collect();
1703        assert!(
1704            content_ids.contains(&UNK_ID),
1705            "unknown word should produce [UNK] token"
1706        );
1707    }
1708
1709    #[test]
1710    fn wordpiece_encode_truncates_long_input() {
1711        let f = make_test_vocab();
1712        let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(8);
1713        // Input much longer than max_length=8.
1714        let long_input = "the quick brown fox hello world test input embedding model";
1715        let (ids, mask, _) = tok.encode(long_input).unwrap();
1716        assert_eq!(ids.len(), 8, "output must be exactly max_length");
1717        assert_eq!(mask.len(), 8);
1718        assert_eq!(ids[0], CLS_ID);
1719        // [SEP] must be present.
1720        assert!(ids.contains(&SEP_ID));
1721    }
1722
1723    #[test]
1724    fn wordpiece_encode_empty_input() {
1725        let f = make_test_vocab();
1726        let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(16);
1727        let (ids, mask, _) = tok.encode("").unwrap();
1728        assert_eq!(ids[0], CLS_ID);
1729        assert_eq!(ids[1], SEP_ID);
1730        // Rest should be padding.
1731        assert!(ids[2..].iter().all(|&x| x == PAD_ID));
1732        assert_eq!(mask[0], 1);
1733        assert_eq!(mask[1], 1);
1734        assert!(mask[2..].iter().all(|&x| x == 0));
1735    }
1736
1737    #[test]
1738    fn wordpiece_pre_tokenize_punctuation() {
1739        let f = make_test_vocab();
1740        let tok = WordPieceTokenizer::load(&f).unwrap();
1741        let words = tok.pre_tokenize("Hello, World!");
1742        // Should split into: ["hello", ",", "world", "!"]
1743        assert!(words.contains(&",".to_string()));
1744        assert!(words.contains(&"!".to_string()));
1745        assert!(words.contains(&"hello".to_string()));
1746        assert!(words.contains(&"world".to_string()));
1747    }
1748
1749    #[test]
1750    fn wordpiece_subword_splitting() {
1751        let f = make_test_vocab();
1752        let tok = WordPieceTokenizer::load(&f).unwrap();
1753        // "tokens" should split into "token" + "##s" since both are in vocab.
1754        let ids = tok.wordpiece_split("tokens");
1755        // If "token" and "##s" are in the vocab, we should get 2 IDs (neither UNK).
1756        assert!(!ids.is_empty(), "should produce at least one subword token");
1757    }
1758
1759    #[test]
1760    fn wordpiece_deterministic() {
1761        let f = make_test_vocab();
1762        let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(32);
1763        let (ids1, _, _) = tok.encode("the quick brown fox").unwrap();
1764        let (ids2, _, _) = tok.encode("the quick brown fox").unwrap();
1765        assert_eq!(ids1, ids2, "encoding must be deterministic");
1766    }
1767
1768    #[test]
1769    fn vocab_search_paths_finds_sibling() {
1770        let paths = vocab_search_paths(Path::new("/models/all-MiniLM-L6-v2.onnx"));
1771        assert!(paths.iter().any(|p| p.ends_with("vocab.txt")));
1772        assert!(
1773            paths
1774                .iter()
1775                .any(|p| p.to_string_lossy().contains("all-MiniLM-L6-v2/vocab.txt")),
1776            "should check sibling directory: {:?}",
1777            paths
1778        );
1779    }
1780}