Skip to main content

comindly_core/
embed.rs

1//! Comindly embed — local static embeddings for semantic code search.
2//!
3//! Uses **Model2Vec** (`model2vec-rs`) static embeddings: pure-Rust, CPU-only, no ONNX
4//! runtime, tiny + fast (~ms/query) — the approach semble validated for agentic code
5//! search. Vectors are L2-normalized, so cosine similarity is a plain dot product.
6//!
7//! This is the *optional semantic layer*. It rides on top of the deterministic graph:
8//! search fuses this signal with lexical BM25 and graph centrality (see comindly's hybrid
9//! search) — never the sole retrieval path.
10
11use crate::model::Symbol;
12use std::sync::{Arc, Mutex};
13
14use anyhow::{Context, Result};
15use model2vec_rs::model::StaticModel;
16
17/// A small, well-known static model. Swap for a code-specialized Model2Vec distillation
18/// (e.g. a `potion-code` variant) for higher code accuracy — same API.
19pub const DEFAULT_MODEL: &str = "minishlab/potion-base-8M";
20
21/// Loads a Model2Vec model (from a Hugging Face repo id or a local path) and encodes text.
22pub struct Embedder {
23    model: StaticModel,
24}
25
26impl Embedder {
27    /// Load a model by HF repo id or local path. Normalizes output (cosine == dot product).
28    pub fn load(repo_or_path: &str) -> Result<Self> {
29        let model = StaticModel::from_pretrained(repo_or_path, None, Some(true), None)
30            .with_context(|| format!("load Model2Vec model `{repo_or_path}`"))?;
31        Ok(Self { model })
32    }
33
34    /// The model loaded once and shared by every reader in this process.
35    pub fn shared(repo_or_path: &str) -> Result<Arc<Self>> {
36        static LOADED: Mutex<Option<(String, Arc<Embedder>)>> = Mutex::new(None);
37        let mut loaded = LOADED.lock().unwrap_or_else(|e| e.into_inner());
38        if let Some((_, e)) = loaded.as_ref().filter(|(n, _)| n == repo_or_path) {
39            return Ok(e.clone());
40        }
41        let e = Arc::new(Self::load(repo_or_path)?);
42        *loaded = Some((repo_or_path.to_string(), e.clone()));
43        Ok(e)
44    }
45
46    /// Embed a batch of documents.
47    pub fn embed(&self, texts: &[String]) -> Vec<Vec<f32>> {
48        self.model.encode(texts)
49    }
50
51    /// Embed a single query string.
52    pub fn embed_query(&self, query: &str) -> Vec<f32> {
53        self.model.encode_single(query)
54    }
55}
56
57/// Build the text used to represent a symbol for embedding: name, signature, the
58/// human-readable descriptor path, and docstring — the tokens a natural-language query
59/// about this code would plausibly match.
60pub fn symbol_text(s: &Symbol) -> String {
61    let mut parts = vec![s.name.clone()];
62    if let Some(sig) = &s.signature {
63        parts.push(sig.clone());
64    }
65    // descriptor path as words, e.g. "acme/database/config/DbConfig" -> readable tokens
66    parts.push(s.id.descriptor.replace(['/', '_', '.'], " "));
67    if let Some(doc) = &s.docstring {
68        parts.push(doc.clone());
69    }
70    parts.join("  ")
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    fn dot(a: &[f32], b: &[f32]) -> f32 {
78        a.iter().zip(b).map(|(x, y)| x * y).sum()
79    }
80
81    // Downloads a model from Hugging Face — run explicitly: `cargo test -p comindly-embed -- --ignored`.
82    #[test]
83    #[ignore]
84    fn embeds_and_ranks_semantically() {
85        let e = Embedder::load(DEFAULT_MODEL).expect("load model");
86        let docs: Vec<String> = vec![
87            "open_database_connection  def open_database_connection(host, port)".into(),
88            "parse_json  def parse_json(text: str) -> dict".into(),
89            "UserAuthentication  class UserAuthentication  login verify password".into(),
90        ];
91        let vecs = e.embed(&docs);
92        assert_eq!(vecs.len(), 3);
93        assert!(!vecs[0].is_empty());
94
95        // A query about auth should rank the authentication doc highest.
96        let q = e.embed_query("log in and check a user's password");
97        let best = (0..3)
98            .max_by(|&i, &j| dot(&q, &vecs[i]).partial_cmp(&dot(&q, &vecs[j])).unwrap())
99            .unwrap();
100        assert_eq!(best, 2, "auth doc should rank first");
101    }
102}
103
104/// Code-aware ranking signals for hybrid search: identifier splitting, lexical scoring,
105/// query classification, and path penalties. Ideas borrowed from semble (re-implemented,
106/// not copied); comindly adds a dependency-graph centrality signal on top (applied by the caller).
107pub mod rank {
108    use std::collections::BTreeSet;
109
110    /// Split an identifier into lowercased sub-tokens, camelCase + snake_case aware, keeping the
111    /// compound form. `getHTTPResponse` -> {gethttpresponse, get, http, response};
112    /// `my_func` -> {my_func, my, func}.
113    pub fn split_identifier(ident: &str) -> Vec<String> {
114        let mut out: Vec<String> = vec![ident.to_lowercase()];
115        // snake_case
116        let snake: Vec<&str> = ident.split('_').filter(|s| !s.is_empty()).collect();
117        let mut parts: Vec<String> = Vec::new();
118        for seg in snake {
119            parts.extend(split_camel(seg));
120        }
121        for p in parts {
122            let p = p.to_lowercase();
123            if p.len() >= 2 && !out.contains(&p) {
124                out.push(p);
125            }
126        }
127        out
128    }
129
130    /// Split a camelCase/PascalCase run into words: `getHTTPResponse` -> [get, HTTP, Response].
131    fn split_camel(s: &str) -> Vec<String> {
132        let mut words = Vec::new();
133        let chars: Vec<char> = s.chars().collect();
134        let mut start = 0;
135        for i in 1..chars.len() {
136            let (prev, cur) = (chars[i - 1], chars[i]);
137            // boundary: lower->UPPER, or UPPER->UPPER followed by lower (acronym end), or letter<->digit
138            let boundary = (prev.is_lowercase() && cur.is_uppercase())
139                || (prev.is_uppercase()
140                    && cur.is_uppercase()
141                    && i + 1 < chars.len()
142                    && chars[i + 1].is_lowercase())
143                || (prev.is_alphabetic() != cur.is_alphabetic());
144            if boundary {
145                words.push(chars[start..i].iter().collect());
146                start = i;
147            }
148        }
149        if start < chars.len() {
150            words.push(chars[start..].iter().collect());
151        }
152        words
153    }
154
155    const STOPWORDS: &[&str] = &[
156        "the", "a", "an", "of", "to", "in", "and", "or", "for", "is", "how", "do", "does", "get",
157        "that", "this", "with", "on", "by",
158    ];
159
160    /// Extract meaningful lowercased query keywords (>2 chars, non-stopword).
161    pub fn query_keywords(query: &str) -> Vec<String> {
162        query
163            .split(|c: char| !c.is_alphanumeric() && c != '_')
164            .flat_map(split_identifier)
165            .filter(|w| w.len() > 2 && !STOPWORDS.contains(&w.as_str()))
166            .collect::<BTreeSet<_>>()
167            .into_iter()
168            .collect()
169    }
170
171    /// Symbol-like query? (identifier path, snake/camel case, punctuation, or a single word)
172    /// Such queries weight lexical matching higher (α smaller).
173    pub fn is_symbol_query(query: &str) -> bool {
174        let q = query.trim();
175        if q.contains("::") || q.contains('.') || q.contains('_') || q.contains('/') {
176            return true;
177        }
178        let words: Vec<&str> = q.split_whitespace().collect();
179        if words.len() == 1 {
180            let w = words[0];
181            // camelCase / PascalCase / has an uppercase interior letter
182            return w.chars().any(|c| c.is_uppercase()) || w.len() <= 24;
183        }
184        false
185    }
186
187    /// Weight on the *semantic* signal (the rest goes to lexical). Symbol queries favor lexical.
188    pub fn resolve_alpha(query: &str) -> f32 {
189        if is_symbol_query(query) {
190            0.3
191        } else {
192            0.5
193        }
194    }
195
196    /// Lexical overlap in `[0,1]`: fraction of query keywords matching the symbol's identifier
197    /// sub-tokens (prefix match allowed for stems ≥4 chars).
198    pub fn lexical_score(keywords: &[String], name: &str, descriptor_tail: &str) -> f32 {
199        if keywords.is_empty() {
200            return 0.0;
201        }
202        let mut toks: BTreeSet<String> = BTreeSet::new();
203        toks.extend(split_identifier(name));
204        toks.extend(split_identifier(descriptor_tail));
205        let hits = keywords
206            .iter()
207            .filter(|k| {
208                toks.contains(*k)
209                    || (k.len() >= 4 && toks.iter().any(|t| t.starts_with(k.as_str())))
210            })
211            .count();
212        hits as f32 / keywords.len() as f32
213    }
214
215    /// Path-based noise penalty multiplier (semble's structured penalties).
216    pub fn path_penalty(path: &str) -> f32 {
217        let p = path.to_lowercase();
218        let is_test = p.contains("/test")
219            || p.contains("test_")
220            || p.contains("_test.")
221            || p.contains(".test.")
222            || p.contains(".spec.")
223            || p.contains("/tests/")
224            || p.contains("/spec/")
225            || p.contains("__tests__");
226        if is_test
227            || p.contains("/examples")
228            || p.contains("/legacy")
229            || p.contains("/compat")
230            || p.contains("/migration")
231            || p.contains("/revision")
232        {
233            return 0.3;
234        }
235        if p.ends_with("/__init__.py") || p.ends_with("package-info.java") || p.ends_with("mod.rs")
236        {
237            return 0.5; // re-export barrels
238        }
239        if p.ends_with(".d.ts") {
240            return 0.7;
241        }
242        1.0
243    }
244
245    #[cfg(test)]
246    mod tests {
247        use super::*;
248
249        #[test]
250        fn splits_identifiers() {
251            let t = split_identifier("AsyncTaskRunner");
252            assert!(t.contains(&"async".to_string()));
253            assert!(t.contains(&"task".to_string()));
254            assert!(t.contains(&"runner".to_string()));
255            let s = split_identifier("get_user_id");
256            assert!(s.contains(&"user".to_string()) && s.contains(&"get_user_id".to_string()));
257        }
258
259        #[test]
260        fn detects_symbol_queries() {
261            assert!(is_symbol_query("AsyncTaskRunner"));
262            assert!(is_symbol_query("acme.const.Settings"));
263            assert!(is_symbol_query("get_config"));
264            assert!(!is_symbol_query("how do we connect to the database"));
265        }
266
267        #[test]
268        fn lexical_and_penalty() {
269            let kw = query_keywords("task runner");
270            assert!(lexical_score(&kw, "AsyncTaskRunner", "acme/database/runners") > 0.9);
271            assert_eq!(path_penalty("app/tests/test_api.py"), 0.3);
272            assert_eq!(path_penalty("acme/db.py"), 1.0);
273        }
274    }
275}