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#[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#[derive(Debug, Clone)]
424pub struct HybridEmbedder {
425 pub node2vec: Node2Vec,
426 pub model2vec: Model2VecEmbedder,
427 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 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
499fn 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 embeddings: Vec<f32>,
527 pub dimensions: usize,
528 vocab_size: usize,
529}
530
531impl StaticEmbedder {
532 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 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 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 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 #[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 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 #[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 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}