Skip to main content

oxihuman_morph/
influence_map.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use std::collections::HashMap;
7
8use oxihuman_core::parser::target::TargetFile;
9
10/// For a single vertex: which targets affect it and their delta magnitudes.
11#[derive(Debug, Clone)]
12pub struct VertexInfluence {
13    pub vertex_id: u32,
14    /// (target_name, delta_magnitude) sorted by magnitude descending.
15    pub influences: Vec<(String, f32)>,
16}
17
18impl VertexInfluence {
19    /// Total influence magnitude across all targets.
20    pub fn total_magnitude(&self) -> f32 {
21        self.influences.iter().map(|(_, m)| m).sum()
22    }
23
24    /// Name of the strongest influencing target, or None if no influences.
25    pub fn dominant_target(&self) -> Option<&str> {
26        self.influences.first().map(|(name, _)| name.as_str())
27    }
28}
29
30/// Map from vertex_id to VertexInfluence.
31/// Built from a collection of TargetFile objects.
32#[derive(Debug)]
33pub struct InfluenceMap {
34    pub vertex_count: usize,
35    influences: HashMap<u32, VertexInfluence>,
36}
37
38impl InfluenceMap {
39    /// Build from a list of (name, target) pairs.
40    pub fn build(targets: &[(&str, &TargetFile)]) -> Self {
41        let mut map: HashMap<u32, Vec<(String, f32)>> = HashMap::new();
42
43        for (name, target) in targets {
44            for delta in &target.deltas {
45                let mag = (delta.dx * delta.dx + delta.dy * delta.dy + delta.dz * delta.dz).sqrt();
46                map.entry(delta.vid)
47                    .or_default()
48                    .push((name.to_string(), mag));
49            }
50        }
51
52        let mut influences: HashMap<u32, VertexInfluence> = HashMap::new();
53        for (vid, mut infl_list) in map {
54            infl_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
55            influences.insert(
56                vid,
57                VertexInfluence {
58                    vertex_id: vid,
59                    influences: infl_list,
60                },
61            );
62        }
63
64        let vertex_count = influences.len();
65        Self {
66            vertex_count,
67            influences,
68        }
69    }
70
71    /// Get influence info for a specific vertex. Returns None if vertex is unaffected.
72    pub fn get(&self, vertex_id: u32) -> Option<&VertexInfluence> {
73        self.influences.get(&vertex_id)
74    }
75
76    /// Number of vertices that are affected by at least one target.
77    pub fn affected_vertex_count(&self) -> usize {
78        self.influences.len()
79    }
80
81    /// Iterate over all VertexInfluence entries.
82    pub fn iter(&self) -> impl Iterator<Item = &VertexInfluence> {
83        self.influences.values()
84    }
85
86    /// Find the top-N most-influenced vertices (by total magnitude).
87    pub fn top_vertices(&self, n: usize) -> Vec<&VertexInfluence> {
88        let mut all: Vec<&VertexInfluence> = self.influences.values().collect();
89        all.sort_by(|a, b| {
90            b.total_magnitude()
91                .partial_cmp(&a.total_magnitude())
92                .unwrap_or(std::cmp::Ordering::Equal)
93        });
94        all.truncate(n);
95        all
96    }
97
98    /// Find all vertices affected by a specific target.
99    pub fn vertices_for_target(&self, target_name: &str) -> Vec<u32> {
100        let mut result: Vec<u32> = self
101            .influences
102            .values()
103            .filter(|vi| vi.influences.iter().any(|(name, _)| name == target_name))
104            .map(|vi| vi.vertex_id)
105            .collect();
106        result.sort_unstable();
107        result
108    }
109
110    /// Find all targets that affect a specific vertex.
111    pub fn targets_for_vertex(&self, vertex_id: u32) -> Vec<(&str, f32)> {
112        self.influences
113            .get(&vertex_id)
114            .map(|vi| {
115                vi.influences
116                    .iter()
117                    .map(|(name, mag)| (name.as_str(), *mag))
118                    .collect()
119            })
120            .unwrap_or_default()
121    }
122
123    /// Compute per-target statistics: (target_name, vertex_count, total_delta_magnitude).
124    pub fn target_stats(&self) -> Vec<(String, usize, f32)> {
125        let mut stats: HashMap<String, (usize, f32)> = HashMap::new();
126        for vi in self.influences.values() {
127            for (name, mag) in &vi.influences {
128                let entry = stats.entry(name.clone()).or_insert((0, 0.0));
129                entry.0 += 1;
130                entry.1 += mag;
131            }
132        }
133        let mut result: Vec<(String, usize, f32)> = stats
134            .into_iter()
135            .map(|(name, (count, total))| (name, count, total))
136            .collect();
137        result.sort_by(|a, b| a.0.cmp(&b.0));
138        result
139    }
140
141    /// Find "isolated" vertices: affected by only 1 target.
142    pub fn isolated_vertices(&self) -> Vec<u32> {
143        let mut result: Vec<u32> = self
144            .influences
145            .values()
146            .filter(|vi| vi.influences.len() == 1)
147            .map(|vi| vi.vertex_id)
148            .collect();
149        result.sort_unstable();
150        result
151    }
152
153    /// Find "shared" vertices: affected by N or more targets.
154    pub fn shared_vertices(&self, min_targets: usize) -> Vec<u32> {
155        let mut result: Vec<u32> = self
156            .influences
157            .values()
158            .filter(|vi| vi.influences.len() >= min_targets)
159            .map(|vi| vi.vertex_id)
160            .collect();
161        result.sort_unstable();
162        result
163    }
164}
165
166/// Summary statistics for an InfluenceMap.
167#[derive(Debug, Clone)]
168pub struct InfluenceMapStats {
169    /// Total number of affected vertices.
170    pub affected_vertices: usize,
171    /// Total number of distinct targets referenced.
172    pub target_count: usize,
173    /// Average number of targets per affected vertex.
174    pub avg_targets_per_vertex: f32,
175    /// Maximum number of targets on a single vertex.
176    pub max_targets_per_vertex: usize,
177    /// Total sum of all delta magnitudes.
178    pub total_magnitude: f32,
179}
180
181/// Convenience constructor: build an InfluenceMap from owned (name, TargetFile) pairs.
182pub fn build_influence_map(targets: &[(&str, &TargetFile)]) -> InfluenceMap {
183    InfluenceMap::build(targets)
184}
185
186/// Return the top-`n` (target_name, magnitude) pairs for a given vertex,
187/// sorted descending by magnitude. Returns empty vec if vertex not found.
188pub fn top_influences_for_vertex(
189    map: &InfluenceMap,
190    vertex_id: u32,
191    n: usize,
192) -> Vec<(String, f32)> {
193    map.get(vertex_id)
194        .map(|vi| vi.influences.iter().take(n).cloned().collect())
195        .unwrap_or_default()
196}
197
198/// Fraction of `vertex_ids` that are covered (affected) by `target_name`.
199/// Returns 0.0 if `vertex_ids` is empty.
200pub fn target_vertex_coverage(map: &InfluenceMap, target_name: &str, vertex_ids: &[u32]) -> f32 {
201    if vertex_ids.is_empty() {
202        return 0.0;
203    }
204    let covered = vertex_ids
205        .iter()
206        .filter(|&&vid| {
207            map.get(vid)
208                .map(|vi| vi.influences.iter().any(|(n, _)| n == target_name))
209                .unwrap_or(false)
210        })
211        .count();
212    covered as f32 / vertex_ids.len() as f32
213}
214
215/// Jaccard overlap between two targets: |vertices(A) ∩ vertices(B)| / |vertices(A) ∪ vertices(B)|.
216/// Returns 0.0 if both targets affect zero vertices.
217pub fn vertex_target_overlap(map: &InfluenceMap, target_a: &str, target_b: &str) -> f32 {
218    let set_a: std::collections::HashSet<u32> =
219        map.vertices_for_target(target_a).into_iter().collect();
220    let set_b: std::collections::HashSet<u32> =
221        map.vertices_for_target(target_b).into_iter().collect();
222
223    let intersection = set_a.intersection(&set_b).count();
224    let union = set_a.union(&set_b).count();
225
226    if union == 0 {
227        0.0
228    } else {
229        intersection as f32 / union as f32
230    }
231}
232
233/// Compute aggregate statistics for an InfluenceMap.
234pub fn influence_map_stats(map: &InfluenceMap) -> InfluenceMapStats {
235    let affected_vertices = map.affected_vertex_count();
236
237    let mut total_targets_sum: usize = 0;
238    let mut max_targets_per_vertex: usize = 0;
239
240    for vi in map.iter() {
241        let cnt = vi.influences.len();
242        total_targets_sum += cnt;
243        if cnt > max_targets_per_vertex {
244            max_targets_per_vertex = cnt;
245        }
246    }
247
248    let stats = map.target_stats();
249    let target_count = stats.len();
250    let total_magnitude: f32 = stats.iter().map(|(_, _, m)| m).sum();
251
252    let avg_targets_per_vertex = if affected_vertices == 0 {
253        0.0
254    } else {
255        total_targets_sum as f32 / affected_vertices as f32
256    };
257
258    InfluenceMapStats {
259        affected_vertices,
260        target_count,
261        avg_targets_per_vertex,
262        max_targets_per_vertex,
263        total_magnitude,
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use oxihuman_core::parser::target::{Delta, TargetFile};
271
272    fn make_target(name: &str, deltas: Vec<Delta>) -> TargetFile {
273        TargetFile {
274            name: name.to_string(),
275            deltas,
276        }
277    }
278
279    fn delta(vid: u32, dx: f32, dy: f32, dz: f32) -> Delta {
280        Delta { vid, dx, dy, dz }
281    }
282
283    // ── InfluenceMap::build ────────────────────────────────────────────────
284
285    #[test]
286    fn build_empty_no_vertices() {
287        let map = InfluenceMap::build(&[]);
288        assert_eq!(map.vertex_count, 0);
289        assert_eq!(map.affected_vertex_count(), 0);
290    }
291
292    #[test]
293    fn build_single_target_single_vertex() {
294        let t = make_target("height", vec![delta(10, 1.0, 0.0, 0.0)]);
295        let map = InfluenceMap::build(&[("height", &t)]);
296        assert_eq!(map.vertex_count, 1);
297        let vi = map.get(10).expect("should succeed");
298        assert_eq!(vi.vertex_id, 10);
299        assert_eq!(vi.influences.len(), 1);
300        assert_eq!(vi.influences[0].0, "height");
301        assert!((vi.influences[0].1 - 1.0).abs() < 1e-6);
302    }
303
304    #[test]
305    fn build_multiple_targets_same_vertex() {
306        let t1 = make_target("m1", vec![delta(42, 1.0, 0.0, 0.0)]);
307        let t2 = make_target("m2", vec![delta(42, 0.0, 1.0, 0.0)]);
308        let t3 = make_target("m3", vec![delta(42, 0.0, 0.0, 1.0)]);
309        let map = InfluenceMap::build(&[("m1", &t1), ("m2", &t2), ("m3", &t3)]);
310        assert_eq!(map.vertex_count, 1);
311        let vi = map.get(42).expect("should succeed");
312        assert_eq!(vi.influences.len(), 3);
313    }
314
315    // ── VertexInfluence helpers ────────────────────────────────────────────
316
317    #[test]
318    fn vertex_influence_total_magnitude() {
319        let t1 = make_target("a", vec![delta(5, 3.0, 4.0, 0.0)]); // mag = 5.0
320        let t2 = make_target("b", vec![delta(5, 0.0, 0.0, 2.0)]); // mag = 2.0
321        let map = InfluenceMap::build(&[("a", &t1), ("b", &t2)]);
322        let vi = map.get(5).expect("should succeed");
323        assert!((vi.total_magnitude() - 7.0).abs() < 1e-5);
324    }
325
326    #[test]
327    fn vertex_influence_dominant_target() {
328        let t1 = make_target("small", vec![delta(7, 0.0, 0.0, 1.0)]); // mag = 1.0
329        let t2 = make_target("large", vec![delta(7, 3.0, 4.0, 0.0)]); // mag = 5.0
330        let map = InfluenceMap::build(&[("small", &t1), ("large", &t2)]);
331        let vi = map.get(7).expect("should succeed");
332        assert_eq!(vi.dominant_target(), Some("large"));
333    }
334
335    #[test]
336    fn dominant_target_none_for_empty() {
337        let vi = VertexInfluence {
338            vertex_id: 0,
339            influences: vec![],
340        };
341        assert_eq!(vi.dominant_target(), None);
342        assert!((vi.total_magnitude() - 0.0).abs() < 1e-9);
343    }
344
345    // ── affected_vertex_count & top_vertices ──────────────────────────────
346
347    #[test]
348    fn affected_vertex_count_correct() {
349        let t = make_target(
350            "t",
351            vec![
352                delta(1, 0.1, 0.0, 0.0),
353                delta(2, 0.2, 0.0, 0.0),
354                delta(3, 0.3, 0.0, 0.0),
355            ],
356        );
357        let map = InfluenceMap::build(&[("t", &t)]);
358        assert_eq!(map.affected_vertex_count(), 3);
359    }
360
361    #[test]
362    fn top_vertices_sorted_desc() {
363        let t = make_target(
364            "t",
365            vec![
366                delta(1, 1.0, 0.0, 0.0), // mag 1
367                delta(2, 3.0, 4.0, 0.0), // mag 5
368                delta(3, 0.0, 2.0, 0.0), // mag 2
369            ],
370        );
371        let map = InfluenceMap::build(&[("t", &t)]);
372        let top = map.top_vertices(2);
373        assert_eq!(top.len(), 2);
374        assert_eq!(top[0].vertex_id, 2); // highest total mag = 5
375        assert_eq!(top[1].vertex_id, 3); // second = 2
376    }
377
378    #[test]
379    fn top_vertices_clamps_to_available() {
380        let t = make_target("t", vec![delta(0, 1.0, 0.0, 0.0)]);
381        let map = InfluenceMap::build(&[("t", &t)]);
382        let top = map.top_vertices(100);
383        assert_eq!(top.len(), 1);
384    }
385
386    // ── vertices_for_target & targets_for_vertex ──────────────────────────
387
388    #[test]
389    fn vertices_for_target_correct() {
390        let t1 = make_target(
391            "alpha",
392            vec![delta(10, 1.0, 0.0, 0.0), delta(20, 1.0, 0.0, 0.0)],
393        );
394        let t2 = make_target(
395            "beta",
396            vec![delta(20, 0.5, 0.0, 0.0), delta(30, 0.5, 0.0, 0.0)],
397        );
398        let map = InfluenceMap::build(&[("alpha", &t1), ("beta", &t2)]);
399        let verts_alpha = map.vertices_for_target("alpha");
400        assert_eq!(verts_alpha, vec![10, 20]);
401        let verts_beta = map.vertices_for_target("beta");
402        assert_eq!(verts_beta, vec![20, 30]);
403    }
404
405    #[test]
406    fn vertices_for_unknown_target_empty() {
407        let t = make_target("real", vec![delta(1, 1.0, 0.0, 0.0)]);
408        let map = InfluenceMap::build(&[("real", &t)]);
409        assert!(map.vertices_for_target("ghost").is_empty());
410    }
411
412    #[test]
413    fn targets_for_vertex_returns_all() {
414        let t1 = make_target("x", vec![delta(99, 1.0, 0.0, 0.0)]);
415        let t2 = make_target("y", vec![delta(99, 0.0, 2.0, 0.0)]);
416        let t3 = make_target("z", vec![delta(99, 0.0, 0.0, 3.0)]);
417        let map = InfluenceMap::build(&[("x", &t1), ("y", &t2), ("z", &t3)]);
418        let targets = map.targets_for_vertex(99);
419        assert_eq!(targets.len(), 3);
420        let names: Vec<&str> = targets.iter().map(|(n, _)| *n).collect();
421        assert!(names.contains(&"x"));
422        assert!(names.contains(&"y"));
423        assert!(names.contains(&"z"));
424    }
425
426    #[test]
427    fn targets_for_unknown_vertex_empty() {
428        let map = InfluenceMap::build(&[]);
429        assert!(map.targets_for_vertex(999).is_empty());
430    }
431
432    // ── target_stats ──────────────────────────────────────────────────────
433
434    #[test]
435    fn target_stats_vertex_count_correct() {
436        let t1 = make_target("aa", vec![delta(1, 1.0, 0.0, 0.0), delta(2, 2.0, 0.0, 0.0)]);
437        let t2 = make_target(
438            "bb",
439            vec![
440                delta(2, 0.5, 0.0, 0.0),
441                delta(3, 0.5, 0.0, 0.0),
442                delta(4, 0.5, 0.0, 0.0),
443            ],
444        );
445        let map = InfluenceMap::build(&[("aa", &t1), ("bb", &t2)]);
446        let stats = map.target_stats();
447        let aa = stats
448            .iter()
449            .find(|(n, _, _)| n == "aa")
450            .expect("should succeed");
451        let bb = stats
452            .iter()
453            .find(|(n, _, _)| n == "bb")
454            .expect("should succeed");
455        assert_eq!(aa.1, 2); // aa affects 2 vertices
456        assert_eq!(bb.1, 3); // bb affects 3 vertices
457        assert!((aa.2 - 3.0).abs() < 1e-5); // 1.0 + 2.0
458        assert!((bb.2 - 1.5).abs() < 1e-5); // 0.5 + 0.5 + 0.5
459    }
460
461    // ── isolated_vertices & shared_vertices ───────────────────────────────
462
463    #[test]
464    fn isolated_vertices_single_target() {
465        let t1 = make_target("only", vec![delta(5, 1.0, 0.0, 0.0)]);
466        let t2 = make_target(
467            "shared",
468            vec![delta(5, 0.5, 0.0, 0.0), delta(6, 0.5, 0.0, 0.0)],
469        );
470        let map = InfluenceMap::build(&[("only", &t1), ("shared", &t2)]);
471        let isolated = map.isolated_vertices();
472        // vertex 5 has 2 targets, vertex 6 has 1 target
473        assert_eq!(isolated, vec![6]);
474    }
475
476    #[test]
477    fn shared_vertices_min_two() {
478        let t1 = make_target("p", vec![delta(1, 1.0, 0.0, 0.0), delta(2, 1.0, 0.0, 0.0)]);
479        let t2 = make_target("q", vec![delta(2, 1.0, 0.0, 0.0), delta(3, 1.0, 0.0, 0.0)]);
480        let t3 = make_target("r", vec![delta(2, 1.0, 0.0, 0.0)]);
481        let map = InfluenceMap::build(&[("p", &t1), ("q", &t2), ("r", &t3)]);
482        let shared2 = map.shared_vertices(2);
483        // vertex 2 has 3 targets, vertex 3 has 1, vertex 1 has 1
484        assert_eq!(shared2, vec![2]);
485        let shared3 = map.shared_vertices(3);
486        assert_eq!(shared3, vec![2]);
487        let shared4 = map.shared_vertices(4);
488        assert!(shared4.is_empty());
489    }
490
491    // ── influences sorted by magnitude ────────────────────────────────────
492
493    #[test]
494    fn influences_sorted_by_magnitude() {
495        let t_big = make_target("big", vec![delta(0, 3.0, 4.0, 0.0)]); // mag = 5.0
496        let t_mid = make_target("mid", vec![delta(0, 0.0, 2.0, 0.0)]); // mag = 2.0
497        let t_small = make_target("small", vec![delta(0, 1.0, 0.0, 0.0)]); // mag = 1.0
498        let map = InfluenceMap::build(&[("small", &t_small), ("big", &t_big), ("mid", &t_mid)]);
499        let vi = map.get(0).expect("should succeed");
500        assert_eq!(vi.influences.len(), 3);
501        assert_eq!(vi.influences[0].0, "big");
502        assert!((vi.influences[0].1 - 5.0).abs() < 1e-5);
503        assert_eq!(vi.influences[1].0, "mid");
504        assert!((vi.influences[1].1 - 2.0).abs() < 1e-5);
505        assert_eq!(vi.influences[2].0, "small");
506        assert!((vi.influences[2].1 - 1.0).abs() < 1e-5);
507    }
508
509    // ── Free functions ────────────────────────────────────────────────────
510
511    #[test]
512    fn build_influence_map_fn_equivalent() {
513        let t = make_target("t", vec![delta(1, 1.0, 0.0, 0.0)]);
514        let map = build_influence_map(&[("t", &t)]);
515        assert_eq!(map.vertex_count, 1);
516        assert!(map.get(1).is_some());
517    }
518
519    #[test]
520    fn top_influences_for_vertex_returns_n() {
521        let t1 = make_target("big", vec![delta(0, 3.0, 4.0, 0.0)]); // mag 5
522        let t2 = make_target("mid", vec![delta(0, 0.0, 2.0, 0.0)]); // mag 2
523        let t3 = make_target("small", vec![delta(0, 1.0, 0.0, 0.0)]); // mag 1
524        let map = InfluenceMap::build(&[("big", &t1), ("mid", &t2), ("small", &t3)]);
525        let top2 = top_influences_for_vertex(&map, 0, 2);
526        assert_eq!(top2.len(), 2);
527        assert_eq!(top2[0].0, "big");
528        assert_eq!(top2[1].0, "mid");
529    }
530
531    #[test]
532    fn top_influences_for_unknown_vertex_empty() {
533        let map = InfluenceMap::build(&[]);
534        assert!(top_influences_for_vertex(&map, 999, 5).is_empty());
535    }
536
537    #[test]
538    fn target_vertex_coverage_fraction() {
539        let t1 = make_target(
540            "cover",
541            vec![delta(1, 1.0, 0.0, 0.0), delta(2, 1.0, 0.0, 0.0)],
542        );
543        let map = InfluenceMap::build(&[("cover", &t1)]);
544        // vertices [1, 2, 3]: 3 is not covered
545        let cov = target_vertex_coverage(&map, "cover", &[1, 2, 3]);
546        assert!((cov - 2.0 / 3.0).abs() < 1e-5);
547    }
548
549    #[test]
550    fn target_vertex_coverage_empty_returns_zero() {
551        let map = InfluenceMap::build(&[]);
552        assert!((target_vertex_coverage(&map, "any", &[])).abs() < 1e-9);
553    }
554
555    #[test]
556    fn vertex_target_overlap_identical_sets() {
557        let t = make_target("t", vec![delta(1, 1.0, 0.0, 0.0), delta(2, 1.0, 0.0, 0.0)]);
558        let map = InfluenceMap::build(&[("t", &t)]);
559        // Same target compared against itself -> Jaccard = 1.0
560        let overlap = vertex_target_overlap(&map, "t", "t");
561        assert!((overlap - 1.0).abs() < 1e-5);
562    }
563
564    #[test]
565    fn vertex_target_overlap_disjoint_sets() {
566        let t1 = make_target("a", vec![delta(1, 1.0, 0.0, 0.0)]);
567        let t2 = make_target("b", vec![delta(2, 1.0, 0.0, 0.0)]);
568        let map = InfluenceMap::build(&[("a", &t1), ("b", &t2)]);
569        let overlap = vertex_target_overlap(&map, "a", "b");
570        assert!((overlap - 0.0).abs() < 1e-5);
571    }
572
573    #[test]
574    fn vertex_target_overlap_partial() {
575        // a: {1, 2}, b: {2, 3}  => intersection={2}, union={1,2,3} => Jaccard=1/3
576        let t1 = make_target("a", vec![delta(1, 1.0, 0.0, 0.0), delta(2, 1.0, 0.0, 0.0)]);
577        let t2 = make_target("b", vec![delta(2, 1.0, 0.0, 0.0), delta(3, 1.0, 0.0, 0.0)]);
578        let map = InfluenceMap::build(&[("a", &t1), ("b", &t2)]);
579        let overlap = vertex_target_overlap(&map, "a", "b");
580        assert!((overlap - 1.0 / 3.0).abs() < 1e-5);
581    }
582
583    #[test]
584    fn influence_map_stats_basic() {
585        let t1 = make_target("x", vec![delta(1, 1.0, 0.0, 0.0), delta(2, 1.0, 0.0, 0.0)]);
586        let t2 = make_target("y", vec![delta(2, 0.0, 1.0, 0.0), delta(3, 0.0, 0.0, 1.0)]);
587        let map = InfluenceMap::build(&[("x", &t1), ("y", &t2)]);
588        let stats = influence_map_stats(&map);
589        assert_eq!(stats.affected_vertices, 3); // vertices 1, 2, 3
590        assert_eq!(stats.target_count, 2); // targets x, y
591                                           // total magnitude: x->v1=1, x->v2=1, y->v2=1, y->v3=1 => 4.0
592        assert!((stats.total_magnitude - 4.0).abs() < 1e-5);
593        // max_targets_per_vertex: vertex 2 has 2 targets
594        assert_eq!(stats.max_targets_per_vertex, 2);
595    }
596
597    #[test]
598    fn influence_map_stats_empty() {
599        let map = InfluenceMap::build(&[]);
600        let stats = influence_map_stats(&map);
601        assert_eq!(stats.affected_vertices, 0);
602        assert_eq!(stats.target_count, 0);
603        assert!((stats.total_magnitude).abs() < 1e-9);
604        assert!((stats.avg_targets_per_vertex).abs() < 1e-9);
605    }
606
607    #[test]
608    fn iter_visits_all_vertices() {
609        let t = make_target(
610            "t",
611            vec![
612                delta(10, 1.0, 0.0, 0.0),
613                delta(20, 2.0, 0.0, 0.0),
614                delta(30, 3.0, 0.0, 0.0),
615            ],
616        );
617        let map = InfluenceMap::build(&[("t", &t)]);
618        let mut vids: Vec<u32> = map.iter().map(|vi| vi.vertex_id).collect();
619        vids.sort_unstable();
620        assert_eq!(vids, vec![10, 20, 30]);
621    }
622}