Skip to main content

codesynapse_core/
embedding.rs

1use rand::Rng;
2use std::collections::{HashMap, HashSet};
3use std::path::Path;
4
5#[derive(Debug, Clone)]
6pub struct Node2Vec {
7    pub dimensions: usize,
8    pub walk_length: usize,
9    pub num_walks: usize,
10    pub window_size: usize,
11    pub p: f64,
12    pub q: f64,
13    pub learning_rate: f64,
14    pub negative_samples: usize,
15}
16
17impl Default for Node2Vec {
18    fn default() -> Self {
19        Self {
20            dimensions: 64,
21            walk_length: 80,
22            num_walks: 10,
23            window_size: 10,
24            p: 1.0,
25            q: 1.0,
26            learning_rate: 0.01,
27            negative_samples: 5,
28        }
29    }
30}
31
32impl Node2Vec {
33    pub fn new(dimensions: usize, p: f64, q: f64) -> Self {
34        Self {
35            dimensions,
36            p,
37            q,
38            ..Default::default()
39        }
40    }
41
42    pub fn train(&self, edges: &[(String, String)]) -> HashMap<String, Vec<f64>> {
43        let adj = build_adjacency(edges);
44        let node_ids: Vec<&str> = adj.keys().copied().collect();
45        let node_set: HashSet<&str> = node_ids.iter().copied().collect();
46
47        let mut rng = rand::thread_rng();
48
49        let num_nodes = node_ids.len();
50        if num_nodes == 0 {
51            return HashMap::new();
52        }
53
54        let mut embeddings: HashMap<String, Vec<f64>> = HashMap::new();
55        for id in &node_ids {
56            let emb: Vec<f64> = (0..self.dimensions)
57                .map(|_| (rng.gen::<f64>() - 0.5) / self.dimensions as f64)
58                .collect();
59            embeddings.insert((*id).to_string(), emb);
60        }
61
62        let unigram_weights: Vec<f64> = {
63            let mut deg: HashMap<&str, usize> = HashMap::new();
64            for (u, v) in edges {
65                *deg.entry(u.as_str()).or_insert(0) += 1;
66                *deg.entry(v.as_str()).or_insert(0) += 1;
67            }
68            let total: usize = deg.values().sum();
69            let total_f = total.max(1) as f64;
70            node_ids
71                .iter()
72                .map(|id| {
73                    let d = deg.get(id).copied().unwrap_or(1) as f64;
74                    d.powf(0.75) / total_f.powf(0.75)
75                })
76                .collect()
77        };
78
79        let mut cumulative = Vec::with_capacity(num_nodes);
80        let mut sum = 0.0;
81        for w in &unigram_weights {
82            sum += w;
83            cumulative.push(sum);
84        }
85        let noise_norm = sum;
86
87        fn sample_noise<'a>(
88            rng: &mut impl Rng,
89            cumulative: &[f64],
90            norm: f64,
91            node_ids: &[&'a str],
92        ) -> &'a str {
93            let r = rng.gen::<f64>() * norm;
94            let idx = match cumulative.binary_search_by(|p| p.partial_cmp(&r).unwrap()) {
95                Ok(i) => i,
96                Err(i) => i.min(cumulative.len() - 1),
97            };
98            node_ids[idx]
99        }
100
101        for _epoch in 0..1 {
102            for start_node in &node_ids {
103                for _walk_idx in 0..self.num_walks {
104                    let walk = self.biased_walk(start_node, &adj, &node_set, &mut rng);
105                    #[allow(clippy::needless_range_loop)]
106                    for i in 0..walk.len() {
107                        let center = walk[i];
108                        let center_emb = embeddings.get(center).unwrap().clone();
109
110                        let left = i.saturating_sub(self.window_size);
111                        let right = (i + self.window_size + 1).min(walk.len());
112
113                        for j in left..right {
114                            if i == j {
115                                continue;
116                            }
117                            let context = walk[j];
118
119                            let dot: f64 = center_emb
120                                .iter()
121                                .zip(embeddings.get(context).unwrap().iter())
122                                .map(|(a, b)| a * b)
123                                .sum();
124                            let sigmoid_pos = 1.0 / (1.0 + (-dot).exp());
125
126                            if let Some(ctx_emb) = embeddings.get_mut(context) {
127                                let grad = self.learning_rate * (1.0 - sigmoid_pos);
128                                for k in 0..self.dimensions {
129                                    ctx_emb[k] += grad * center_emb[k];
130                                }
131                            }
132
133                            let context_emb = embeddings.get(context).unwrap().clone();
134                            if let Some(cnt_emb) = embeddings.get_mut(center) {
135                                let grad = self.learning_rate * (1.0 - sigmoid_pos);
136                                for k in 0..self.dimensions {
137                                    cnt_emb[k] += grad * context_emb[k];
138                                }
139                            }
140
141                            for _ns in 0..self.negative_samples {
142                                let noise_node =
143                                    sample_noise(&mut rng, &cumulative, noise_norm, &node_ids);
144
145                                let noise_emb = embeddings.get(noise_node).unwrap().clone();
146
147                                let dot_neg: f64 = center_emb
148                                    .iter()
149                                    .zip(noise_emb.iter())
150                                    .map(|(a, b)| a * b)
151                                    .sum();
152                                let sigmoid_neg = 1.0 / (1.0 + (-dot_neg).exp());
153
154                                if let Some(noi_emb) = embeddings.get_mut(noise_node) {
155                                    let grad_neg = self.learning_rate * (0.0 - sigmoid_neg);
156                                    for k in 0..self.dimensions {
157                                        noi_emb[k] += grad_neg * center_emb[k];
158                                    }
159                                }
160
161                                if let Some(cnt_emb) = embeddings.get_mut(center) {
162                                    let grad_neg = self.learning_rate * (0.0 - sigmoid_neg);
163                                    for k in 0..self.dimensions {
164                                        cnt_emb[k] += grad_neg * noise_emb[k];
165                                    }
166                                }
167                            }
168                        }
169                    }
170                }
171            }
172        }
173
174        embeddings
175    }
176
177    fn biased_walk<'a>(
178        &self,
179        start: &'a str,
180        adj: &HashMap<&str, Vec<&'a str>>,
181        _node_set: &HashSet<&'a str>,
182        rng: &mut impl Rng,
183    ) -> Vec<&'a str> {
184        let mut walk = Vec::with_capacity(self.walk_length);
185        walk.push(start);
186
187        let mut curr = start;
188        let mut prev: Option<&str> = None;
189
190        for _step in 1..self.walk_length {
191            let neighbors = match adj.get(curr) {
192                Some(n) if !n.is_empty() => n,
193                _ => break,
194            };
195
196            let next = if let Some(prev_node) = prev {
197                let prev_neighbors: HashSet<&str> = adj
198                    .get(prev_node)
199                    .map(|n| n.iter().copied().collect())
200                    .unwrap_or_default();
201                let weights: Vec<f64> = neighbors
202                    .iter()
203                    .map(|n| {
204                        if *n == prev_node {
205                            1.0 / self.p
206                        } else if prev_neighbors.contains(n) {
207                            1.0
208                        } else {
209                            1.0 / self.q
210                        }
211                    })
212                    .collect();
213                let total: f64 = weights.iter().sum();
214                if total <= 0.0 {
215                    neighbors[rng.gen_range(0..neighbors.len())]
216                } else {
217                    let r = rng.gen::<f64>() * total;
218                    let mut cum = 0.0;
219                    let mut chosen = neighbors[0];
220                    for (i, w) in weights.iter().enumerate() {
221                        cum += w;
222                        if cum >= r {
223                            chosen = neighbors[i];
224                            break;
225                        }
226                    }
227                    chosen
228                }
229            } else {
230                neighbors[rng.gen_range(0..neighbors.len())]
231            };
232
233            walk.push(next);
234            prev = Some(curr);
235            curr = next;
236        }
237
238        walk
239    }
240
241    pub fn find_similar(
242        &self,
243        embeddings: &HashMap<String, Vec<f64>>,
244        node_id: &str,
245        top_n: usize,
246    ) -> Vec<(String, f64)> {
247        let target = match embeddings.get(node_id) {
248            Some(e) => e,
249            None => return Vec::new(),
250        };
251
252        let mut scores: Vec<(String, f64)> = embeddings
253            .iter()
254            .filter(|(id, _)| id.as_str() != node_id)
255            .map(|(id, emb)| (id.clone(), cosine_similarity(target, emb)))
256            .collect();
257
258        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
259        scores.truncate(top_n);
260        scores
261    }
262}
263
264pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
265    let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum();
266    let norm_a: f64 = a.iter().map(|x| x * x).sum();
267    let norm_b: f64 = b.iter().map(|x| x * x).sum();
268    let denom = norm_a.sqrt() * norm_b.sqrt();
269    if denom < 1e-12 {
270        0.0
271    } else {
272        dot / denom
273    }
274}
275
276fn build_adjacency(edges: &[(String, String)]) -> HashMap<&str, Vec<&str>> {
277    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
278    for (u, v) in edges {
279        adj.entry(u.as_str()).or_default().push(v.as_str());
280        adj.entry(v.as_str()).or_default().push(u.as_str());
281    }
282    adj
283}
284
285fn fnv1a_seed(token: &str) -> u64 {
286    token.bytes().fold(0xcbf29ce484222325u64, |acc, b| {
287        acc.wrapping_mul(0x100000001b3).wrapping_add(b as u64)
288    })
289}
290
291fn xor64(mut v: u64) -> u64 {
292    v ^= v >> 30;
293    v = v.wrapping_mul(0xbf58476d1ce4e5b9);
294    v ^= v >> 27;
295    v = v.wrapping_mul(0x94d049bb133111eb);
296    v ^= v >> 31;
297    v
298}
299
300fn seeded_embedding(token: &str, dimensions: usize) -> Vec<f64> {
301    let seed = fnv1a_seed(token);
302    let raw: Vec<f64> = (0..dimensions)
303        .map(|i| {
304            let h = xor64(seed.wrapping_add((i as u64).wrapping_mul(0x9e3779b97f4a7c15)));
305            (h as f64 / u64::MAX as f64) - 0.5
306        })
307        .collect();
308    l2_normalize(&raw)
309}
310
311fn l2_normalize(v: &[f64]) -> Vec<f64> {
312    let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
313    if norm < 1e-12 {
314        v.to_vec()
315    } else {
316        v.iter().map(|x| x / norm).collect()
317    }
318}
319
320pub fn tokenize_label(label: &str) -> Vec<String> {
321    let mut parts: Vec<String> = Vec::new();
322    for seg in label.split(['_', ':', '.', '/', ' ', '-']) {
323        if seg.is_empty() {
324            continue;
325        }
326        split_camel(seg, &mut parts);
327    }
328    parts
329        .into_iter()
330        .filter(|s| !s.is_empty())
331        .map(|s| s.to_lowercase())
332        .collect()
333}
334
335fn split_camel(s: &str, out: &mut Vec<String>) {
336    let chars: Vec<char> = s.chars().collect();
337    let mut start = 0;
338    for i in 1..chars.len() {
339        let prev_lower = chars[i - 1].is_lowercase();
340        let cur_upper = chars[i].is_uppercase();
341        let next_lower = chars.get(i + 1).map(|c| c.is_lowercase()).unwrap_or(false);
342        if cur_upper && (prev_lower || next_lower) {
343            out.push(chars[start..i].iter().collect());
344            start = i;
345        }
346    }
347    out.push(chars[start..].iter().collect());
348}
349
350/// Static word embeddings via deterministic token lookup (simulates Model2Vec).
351#[derive(Debug, Clone)]
352pub struct Model2VecEmbedder {
353    pub dimensions: usize,
354    token_embeddings: HashMap<String, Vec<f64>>,
355}
356
357impl Model2VecEmbedder {
358    pub fn new(dimensions: usize) -> Self {
359        Self {
360            dimensions,
361            token_embeddings: HashMap::new(),
362        }
363    }
364
365    pub fn with_vocab(vocab: &[&str], dimensions: usize) -> Self {
366        let mut token_embeddings = HashMap::new();
367        for &tok in vocab {
368            token_embeddings.insert(tok.to_string(), seeded_embedding(tok, dimensions));
369        }
370        Self {
371            dimensions,
372            token_embeddings,
373        }
374    }
375
376    pub fn from_node_labels(labels: &[&str], dimensions: usize) -> Self {
377        let mut vocab: HashSet<String> = HashSet::new();
378        for label in labels {
379            for tok in tokenize_label(label) {
380                vocab.insert(tok);
381            }
382        }
383        let mut emb = Self::new(dimensions);
384        for tok in &vocab {
385            emb.token_embeddings
386                .insert(tok.clone(), seeded_embedding(tok, dimensions));
387        }
388        emb
389    }
390
391    pub fn embed_label(&self, label: &str) -> Vec<f64> {
392        let tokens = tokenize_label(label);
393        if tokens.is_empty() {
394            return vec![0.0; self.dimensions];
395        }
396        let mut sum = vec![0.0f64; self.dimensions];
397        let mut count = 0usize;
398        for tok in &tokens {
399            let emb = self
400                .token_embeddings
401                .get(tok)
402                .cloned()
403                .unwrap_or_else(|| seeded_embedding(tok, self.dimensions));
404            for (i, v) in emb.iter().enumerate() {
405                sum[i] += v;
406            }
407            count += 1;
408        }
409        let n = count as f64;
410        sum.iter_mut().for_each(|v| *v /= n);
411        l2_normalize(&sum)
412    }
413
414    pub fn embed_nodes(&self, nodes: &[(&str, &str)]) -> HashMap<String, Vec<f64>> {
415        nodes
416            .iter()
417            .map(|(id, label)| (id.to_string(), self.embed_label(label)))
418            .collect()
419    }
420}
421
422/// Blends Node2Vec structural embeddings with Model2Vec semantic embeddings.
423#[derive(Debug, Clone)]
424pub struct HybridEmbedder {
425    pub node2vec: Node2Vec,
426    pub model2vec: Model2VecEmbedder,
427    /// 0.0 = pure semantic, 1.0 = pure structural.
428    pub alpha: f64,
429}
430
431impl HybridEmbedder {
432    pub fn new(node2vec: Node2Vec, model2vec: Model2VecEmbedder, alpha: f64) -> Self {
433        Self {
434            node2vec,
435            model2vec,
436            alpha,
437        }
438    }
439
440    /// Embed nodes by blending structural (Node2Vec) and semantic (Model2Vec) signals.
441    /// `nodes`: slice of `(id, label)` pairs.
442    pub fn embed(
443        &self,
444        edges: &[(String, String)],
445        nodes: &[(&str, &str)],
446    ) -> HashMap<String, Vec<f64>> {
447        let structural = self.node2vec.train(edges);
448        let semantic = self.model2vec.embed_nodes(nodes);
449        let dim = self.model2vec.dimensions;
450
451        let mut result = HashMap::new();
452        for (id, _) in nodes {
453            let id_str = id.to_string();
454            let sem = semantic
455                .get(&id_str)
456                .cloned()
457                .unwrap_or_else(|| vec![0.0; dim]);
458
459            let blended = if let Some(struc) = structural.get(&id_str) {
460                let take = dim.min(struc.len());
461                let mut norm_struc = l2_normalize(&struc[..take]);
462                norm_struc.resize(dim, 0.0);
463                let v: Vec<f64> = norm_struc
464                    .iter()
465                    .zip(sem.iter())
466                    .map(|(s, m)| self.alpha * s + (1.0 - self.alpha) * m)
467                    .collect();
468                l2_normalize(&v)
469            } else {
470                sem
471            };
472
473            result.insert(id_str, blended);
474        }
475        result
476    }
477
478    pub fn find_similar(
479        &self,
480        embeddings: &HashMap<String, Vec<f64>>,
481        node_id: &str,
482        top_n: usize,
483    ) -> Vec<(String, f64)> {
484        let target = match embeddings.get(node_id) {
485            Some(e) => e,
486            None => return Vec::new(),
487        };
488        let mut scores: Vec<(String, f64)> = embeddings
489            .iter()
490            .filter(|(id, _)| id.as_str() != node_id)
491            .map(|(id, emb)| (id.clone(), cosine_similarity(target, emb)))
492            .collect();
493        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
494        scores.truncate(top_n);
495        scores
496    }
497}
498
499// ---------------------------------------------------------------------------
500// StaticEmbedder — real Model2Vec loader (tokenizer.json + model.safetensors)
501
502fn l2_normalize_f32(v: &[f32]) -> Vec<f32> {
503    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
504    if norm < 1e-12 {
505        v.to_vec()
506    } else {
507        v.iter().map(|x| x / norm).collect()
508    }
509}
510
511pub fn cosine_similarity_f32(a: &[f32], b: &[f32]) -> f32 {
512    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
513    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
514    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
515    let d = na * nb;
516    if d < 1e-12 {
517        0.0
518    } else {
519        dot / d
520    }
521}
522
523pub struct StaticEmbedder {
524    tokenizer: tokenizers::Tokenizer,
525    /// Flat row-major matrix [vocab_size × dimensions], rows are L2-normalised f32.
526    embeddings: Vec<f32>,
527    pub dimensions: usize,
528    vocab_size: usize,
529}
530
531impl StaticEmbedder {
532    /// Load from a directory containing `tokenizer.json` and `model.safetensors`.
533    pub fn from_path(model_dir: &Path) -> Result<Self, String> {
534        let tokenizer = tokenizers::Tokenizer::from_file(model_dir.join("tokenizer.json"))
535            .map_err(|e| format!("tokenizer load: {e}"))?;
536
537        let bytes = std::fs::read(model_dir.join("model.safetensors"))
538            .map_err(|e| format!("model.safetensors read: {e}"))?;
539
540        let st = safetensors::SafeTensors::deserialize(&bytes)
541            .map_err(|e| format!("safetensors parse: {e}"))?;
542
543        let view = st
544            .tensor("embeddings")
545            .map_err(|e| format!("tensor 'embeddings': {e}"))?;
546
547        let shape = view.shape();
548        if shape.len() != 2 {
549            return Err(format!("expected 2-D tensor, got shape {:?}", shape));
550        }
551        let vocab_size = shape[0];
552        let dimensions = shape[1];
553
554        if view.dtype() != safetensors::Dtype::F32 {
555            return Err(format!("expected F32 embeddings, got {:?}", view.dtype()));
556        }
557
558        let raw = view.data();
559        let embeddings: Vec<f32> = raw
560            .chunks_exact(4)
561            .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
562            .collect();
563
564        Ok(Self {
565            tokenizer,
566            embeddings,
567            dimensions,
568            vocab_size,
569        })
570    }
571
572    /// Embed text: tokenize → look up rows → mean pool → L2-normalise.
573    pub fn embed(&self, text: &str) -> Vec<f32> {
574        let enc = match self.tokenizer.encode(text, false) {
575            Ok(e) => e,
576            Err(_) => return vec![0.0f32; self.dimensions],
577        };
578        let ids = enc.get_ids();
579        if ids.is_empty() {
580            return vec![0.0f32; self.dimensions];
581        }
582
583        let mut sum = vec![0.0f32; self.dimensions];
584        let mut count = 0usize;
585        for &id in ids {
586            let idx = id as usize;
587            if idx < self.vocab_size {
588                let start = idx * self.dimensions;
589                let end = start + self.dimensions;
590                for (i, &v) in self.embeddings[start..end].iter().enumerate() {
591                    sum[i] += v;
592                }
593                count += 1;
594            }
595        }
596        if count == 0 {
597            return vec![0.0f32; self.dimensions];
598        }
599        let n = count as f32;
600        sum.iter_mut().for_each(|v| *v /= n);
601        l2_normalize_f32(&sum)
602    }
603
604    /// Embed a batch of `(id, label)` pairs into a `HashMap<id, embedding>`.
605    pub fn embed_nodes(&self, nodes: &[(&str, &str)]) -> HashMap<String, Vec<f32>> {
606        nodes
607            .iter()
608            .map(|(id, label)| (id.to_string(), self.embed(label)))
609            .collect()
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    fn make_edges() -> Vec<(String, String)> {
618        vec![
619            ("a".into(), "b".into()),
620            ("b".into(), "c".into()),
621            ("c".into(), "d".into()),
622            ("d".into(), "e".into()),
623            ("e".into(), "f".into()),
624            ("a".into(), "f".into()),
625            ("b".into(), "f".into()),
626            ("c".into(), "e".into()),
627        ]
628    }
629
630    #[test]
631    fn test_cosine_similarity_identical() {
632        let a = vec![1.0, 2.0, 3.0];
633        let b = vec![1.0, 2.0, 3.0];
634        let sim = cosine_similarity(&a, &b);
635        assert!((sim - 1.0).abs() < 1e-6);
636    }
637
638    #[test]
639    fn test_cosine_similarity_orthogonal() {
640        let a = vec![1.0, 0.0];
641        let b = vec![0.0, 1.0];
642        let sim = cosine_similarity(&a, &b);
643        assert!(sim.abs() < 1e-6);
644    }
645
646    #[test]
647    fn test_cosine_similarity_opposite() {
648        let a = vec![1.0, 0.0];
649        let b = vec![-1.0, 0.0];
650        let sim = cosine_similarity(&a, &b);
651        assert!((sim + 1.0).abs() < 1e-6);
652    }
653
654    #[test]
655    fn test_cosine_similarity_zero_vector() {
656        let a = vec![0.0, 0.0];
657        let b = vec![1.0, 0.0];
658        let sim = cosine_similarity(&a, &b);
659        assert!(sim.abs() < 1e-12);
660    }
661
662    #[test]
663    fn test_build_adjacency() {
664        let edges = make_edges();
665        let adj = build_adjacency(&edges);
666        assert!(adj.contains_key("a"));
667        assert_eq!(adj["a"].len(), 2);
668        assert!(adj["a"].contains(&"b"));
669        assert!(adj["a"].contains(&"f"));
670    }
671
672    #[test]
673    fn test_train_non_empty() {
674        let edges = make_edges();
675        let n2v = Node2Vec::new(8, 1.0, 1.0);
676        let embeddings = n2v.train(&edges);
677        assert!(!embeddings.is_empty());
678        assert!(embeddings.contains_key("a"));
679        assert_eq!(embeddings["a"].len(), 8);
680    }
681
682    #[test]
683    fn test_find_similar_returns_results() {
684        let edges = make_edges();
685        let n2v = Node2Vec::new(8, 1.0, 1.0);
686        let embeddings = n2v.train(&edges);
687
688        let similar = n2v.find_similar(&embeddings, "a", 3);
689        assert!(!similar.is_empty());
690        assert!(similar.len() <= 3);
691        for (id, score) in &similar {
692            assert_ne!(id, "a");
693            assert!(*score >= -1.1 && *score <= 1.1);
694        }
695    }
696
697    #[test]
698    fn test_find_similar_nonexistent_node() {
699        let edges = make_edges();
700        let n2v = Node2Vec::new(8, 1.0, 1.0);
701        let embeddings = n2v.train(&edges);
702        let similar = n2v.find_similar(&embeddings, "nonexistent", 3);
703        assert!(similar.is_empty());
704    }
705
706    #[test]
707    fn test_biased_walk_length() {
708        let edges = make_edges();
709        let n2v = Node2Vec::new(8, 1.0, 1.0);
710        let adj = build_adjacency(&edges);
711        let node_set: HashSet<&str> = adj.keys().copied().collect();
712        let mut rng = rand::thread_rng();
713        let walk = n2v.biased_walk("a", &adj, &node_set, &mut rng);
714        assert!(!walk.is_empty());
715        assert_eq!(walk[0], "a");
716        assert!(walk.len() <= 80);
717    }
718
719    #[test]
720    fn test_biased_walk_starts_at_start() {
721        let edges = make_edges();
722        let n2v = Node2Vec::new(8, 0.25, 1.0);
723        let adj = build_adjacency(&edges);
724        let node_set: HashSet<&str> = adj.keys().copied().collect();
725        let mut rng = rand::thread_rng();
726        let walk = n2v.biased_walk("a", &adj, &node_set, &mut rng);
727        assert_eq!(walk[0], "a");
728    }
729
730    #[test]
731    fn test_neighbors_more_similar_than_distant() {
732        let edges = make_edges();
733        let n2v = Node2Vec::new(16, 1.0, 1.0);
734        let embeddings = n2v.train(&edges);
735
736        let similar = n2v.find_similar(&embeddings, "a", 10);
737        let top_ids: Vec<&str> = similar.iter().take(4).map(|(id, _)| id.as_str()).collect();
738        let has_neighbor = top_ids.contains(&"b") || top_ids.contains(&"f");
739        assert!(
740            has_neighbor,
741            "At least one direct neighbor should be in top-4 similar to 'a': {:?}",
742            top_ids
743        );
744    }
745
746    #[test]
747    fn test_biased_walk_with_p_lt_1() {
748        let edges = vec![
749            ("a".into(), "b".into()),
750            ("b".into(), "c".into()),
751            ("b".into(), "d".into()),
752        ];
753        let n2v = Node2Vec::new(8, 0.25, 1.0);
754        let adj = build_adjacency(&edges);
755        let node_set: HashSet<&str> = adj.keys().copied().collect();
756        let mut rng = rand::thread_rng();
757        // low p encourages return to previous node
758        let walk = n2v.biased_walk("b", &adj, &node_set, &mut rng);
759        assert_eq!(walk[0], "b");
760        assert!(walk.len() >= 2);
761    }
762
763    // --- Model2Vec tests ---
764
765    #[test]
766    fn test_tokenize_underscore() {
767        let tokens = tokenize_label("foo_bar_baz");
768        assert_eq!(tokens, vec!["foo", "bar", "baz"]);
769    }
770
771    #[test]
772    fn test_tokenize_camelcase() {
773        let tokens = tokenize_label("CamelCase");
774        assert!(tokens.contains(&"camel".to_string()));
775        assert!(tokens.contains(&"case".to_string()));
776    }
777
778    #[test]
779    fn test_tokenize_colons() {
780        let tokens = tokenize_label("my::module::Func");
781        assert!(tokens.contains(&"my".to_string()));
782        assert!(tokens.contains(&"module".to_string()));
783        assert!(tokens.contains(&"func".to_string()));
784    }
785
786    #[test]
787    fn test_model2vec_embed_label_nonzero() {
788        let e = Model2VecEmbedder::new(16);
789        let emb = e.embed_label("foo_bar");
790        assert_eq!(emb.len(), 16);
791        assert!(!emb.iter().all(|&v| v == 0.0));
792    }
793
794    #[test]
795    fn test_model2vec_embed_label_empty() {
796        let e = Model2VecEmbedder::new(16);
797        let emb = e.embed_label("");
798        assert_eq!(emb, vec![0.0; 16]);
799    }
800
801    #[test]
802    fn test_model2vec_dimensions() {
803        let e = Model2VecEmbedder::new(32);
804        let emb = e.embed_label("hello_world");
805        assert_eq!(emb.len(), 32);
806    }
807
808    #[test]
809    fn test_model2vec_deterministic() {
810        let e = Model2VecEmbedder::new(16);
811        let a = e.embed_label("compute_graph");
812        let b = e.embed_label("compute_graph");
813        assert_eq!(a, b);
814    }
815
816    #[test]
817    fn test_model2vec_embed_nodes() {
818        let e = Model2VecEmbedder::new(16);
819        let nodes = vec![("n1", "foo_bar"), ("n2", "baz_qux")];
820        let embs = e.embed_nodes(&nodes);
821        assert!(embs.contains_key("n1"));
822        assert!(embs.contains_key("n2"));
823        assert_eq!(embs["n1"].len(), 16);
824    }
825
826    #[test]
827    fn test_model2vec_similar_labels_closer() {
828        let e = Model2VecEmbedder::new(64);
829        let emb_a = e.embed_label("compute_graph");
830        let emb_b = e.embed_label("compute_nodes");
831        let emb_c = e.embed_label("xyz_qwerty");
832        let sim_ab = cosine_similarity(&emb_a, &emb_b);
833        let sim_ac = cosine_similarity(&emb_a, &emb_c);
834        // "compute_graph" and "compute_nodes" share "compute" → closer
835        assert!(
836            sim_ab > sim_ac,
837            "expected sim_ab({}) > sim_ac({})",
838            sim_ab,
839            sim_ac
840        );
841    }
842
843    #[test]
844    fn test_model2vec_with_vocab() {
845        let e = Model2VecEmbedder::with_vocab(&["foo", "bar", "baz"], 16);
846        let emb = e.embed_label("foo_bar");
847        assert_eq!(emb.len(), 16);
848        assert!(!emb.iter().all(|&v| v == 0.0));
849    }
850
851    // --- HybridEmbedder tests ---
852
853    #[test]
854    fn test_hybrid_embed_all_nodes() {
855        let n2v = Node2Vec::new(16, 1.0, 1.0);
856        let m2v = Model2VecEmbedder::new(16);
857        let hybrid = HybridEmbedder::new(n2v, m2v, 0.5);
858        let edges = vec![
859            ("a".to_string(), "b".to_string()),
860            ("b".to_string(), "c".to_string()),
861        ];
862        let nodes = vec![("a", "foo_bar"), ("b", "baz_qux"), ("c", "qux_quux")];
863        let embs = hybrid.embed(&edges, &nodes);
864        assert!(embs.contains_key("a"));
865        assert!(embs.contains_key("b"));
866        assert!(embs.contains_key("c"));
867    }
868
869    #[test]
870    fn test_hybrid_dimensions() {
871        let n2v = Node2Vec::new(16, 1.0, 1.0);
872        let m2v = Model2VecEmbedder::new(16);
873        let hybrid = HybridEmbedder::new(n2v, m2v, 0.5);
874        let edges = vec![("a".to_string(), "b".to_string())];
875        let nodes = vec![("a", "foo"), ("b", "bar")];
876        let embs = hybrid.embed(&edges, &nodes);
877        assert_eq!(embs["a"].len(), 16);
878        assert_eq!(embs["b"].len(), 16);
879    }
880
881    #[test]
882    fn test_hybrid_alpha_0_pure_semantic() {
883        let n2v = Node2Vec::new(16, 1.0, 1.0);
884        let m2v = Model2VecEmbedder::new(16);
885        let hybrid = HybridEmbedder::new(n2v, m2v.clone(), 0.0);
886        let edges = vec![("a".to_string(), "b".to_string())];
887        let nodes = vec![("a", "hello_world"), ("b", "foo_bar")];
888        let embs = hybrid.embed(&edges, &nodes);
889        let sem = m2v.embed_label("hello_world");
890        let sim = cosine_similarity(&embs["a"], &sem);
891        assert!(sim > 0.99, "expected pure semantic, got sim={}", sim);
892    }
893
894    #[test]
895    fn test_hybrid_find_similar() {
896        let n2v = Node2Vec::new(16, 1.0, 1.0);
897        let m2v = Model2VecEmbedder::new(16);
898        let hybrid = HybridEmbedder::new(n2v, m2v, 0.5);
899        let edges = vec![
900            ("a".to_string(), "b".to_string()),
901            ("b".to_string(), "c".to_string()),
902        ];
903        let nodes = vec![("a", "foo"), ("b", "bar"), ("c", "baz")];
904        let embs = hybrid.embed(&edges, &nodes);
905        let similar = hybrid.find_similar(&embs, "a", 2);
906        assert!(!similar.is_empty());
907        assert!(similar.len() <= 2);
908        for (id, score) in &similar {
909            assert_ne!(id, "a");
910            assert!(*score >= -1.1 && *score <= 1.1);
911        }
912    }
913
914    #[test]
915    fn test_hybrid_isolated_node_uses_semantic() {
916        // node "z" has no edges → no structural embedding → falls back to semantic
917        let n2v = Node2Vec::new(16, 1.0, 1.0);
918        let m2v = Model2VecEmbedder::new(16);
919        let sem = m2v.embed_label("isolated_node");
920        let hybrid = HybridEmbedder::new(n2v, m2v, 0.5);
921        let edges: Vec<(String, String)> = vec![("a".to_string(), "b".to_string())];
922        let nodes = vec![("z", "isolated_node"), ("a", "foo"), ("b", "bar")];
923        let embs = hybrid.embed(&edges, &nodes);
924        let sim = cosine_similarity(&embs["z"], &sem);
925        assert!(
926            sim > 0.99,
927            "isolated node should use semantic embedding, sim={}",
928            sim
929        );
930    }
931}