1use crate::QuadTree;
11use graph_explorer_core::{Graph, NodeId};
12use serde::Deserialize;
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct SimulationSpec {
18 #[serde(default = "yes")]
22 pub enabled: bool,
23 #[serde(default = "d_alpha_decay")]
26 pub alpha_decay: f32,
27 #[serde(default = "d_alpha_min")]
30 pub alpha_min: f32,
31 #[serde(default = "d_velocity_decay")]
33 pub velocity_decay: f32,
34 #[serde(default = "d_reheat_alpha")]
37 pub reheat_alpha: f32,
38 #[serde(default = "d_theta")]
40 pub theta: f32,
41 #[serde(default = "d_link_distance")]
43 pub link_distance: f32,
44 #[serde(default = "d_charge")]
46 pub charge_strength: f32,
47 #[serde(default = "d_center")]
50 pub center_strength: f32,
51}
52
53fn yes() -> bool { true }
54fn d_alpha_decay() -> f32 { 0.0228 }
55fn d_alpha_min() -> f32 { 0.001 }
56fn d_velocity_decay() -> f32 { 0.4 }
57fn d_reheat_alpha() -> f32 { 0.3 }
58fn d_theta() -> f32 { 0.9 }
59fn d_link_distance() -> f32 { 120.0 }
60fn d_charge() -> f32 { -800.0 }
61fn d_center() -> f32 { 0.02 }
62
63impl Default for SimulationSpec {
64 fn default() -> Self {
65 Self {
66 enabled: yes(),
67 alpha_decay: d_alpha_decay(),
68 alpha_min: d_alpha_min(),
69 velocity_decay: d_velocity_decay(),
70 reheat_alpha: d_reheat_alpha(),
71 theta: d_theta(),
72 link_distance: d_link_distance(),
73 charge_strength: d_charge(),
74 center_strength: d_center(),
75 }
76 }
77}
78
79pub struct ForceSimulation {
80 spec: SimulationSpec,
81 index: HashMap<NodeId, usize>,
82 ids: Vec<NodeId>,
83 pos: Vec<[f32; 2]>,
84 vel: Vec<[f32; 2]>,
85 links: Vec<(usize, usize)>,
86 fixed: Option<usize>,
88 pinned_at: Option<[f32; 2]>,
92 alpha: f32,
93 spawn_seq: usize,
98}
99
100const THETA_MAX: f32 = 1.0;
107
108const ALPHA_MIN_FLOOR: f32 = 1e-4;
121
122const ALPHA_MIN_CEIL: f32 = 0.1;
129
130const ALPHA_DECAY_MIN: f32 = 0.016;
144
145const ALPHA_DECAY_MAX: f32 = 1.0;
149
150const VELOCITY_DECAY_MIN: f32 = 0.01;
159
160const VELOCITY_DECAY_MAX: f32 = 1.0;
166
167const SPAWN_RADIUS: f32 = 30.0;
169
170const ARRIVAL_RINGS: usize = 12;
177
178impl ForceSimulation {
179 pub fn new(spec: SimulationSpec) -> Self {
180 Self {
181 spec: Self::clamped(spec),
182 index: HashMap::new(),
183 ids: Vec::new(),
184 pos: Vec::new(),
185 vel: Vec::new(),
186 links: Vec::new(),
187 fixed: None,
188 pinned_at: None,
189 alpha: 0.0,
190 spawn_seq: 0,
191 }
192 }
193
194 pub fn set_spec(&mut self, spec: SimulationSpec) { self.spec = Self::clamped(spec); }
195 pub fn spec(&self) -> &SimulationSpec { &self.spec }
196
197 fn clamped(mut spec: SimulationSpec) -> SimulationSpec {
209 spec.theta = spec.theta.clamp(0.0, THETA_MAX);
210 spec.alpha_min = spec.alpha_min.clamp(ALPHA_MIN_FLOOR, ALPHA_MIN_CEIL);
211 spec.alpha_decay = spec.alpha_decay.clamp(ALPHA_DECAY_MIN, ALPHA_DECAY_MAX);
212 spec.velocity_decay =
213 spec.velocity_decay.clamp(VELOCITY_DECAY_MIN, VELOCITY_DECAY_MAX);
214 spec.reheat_alpha = spec.reheat_alpha.clamp(0.0, 1.0);
219 spec
220 }
221
222 pub fn sync(&mut self, graph: &Graph, seed: &HashMap<NodeId, [f32; 2]>) {
227 let mut ids: Vec<NodeId> = graph.nodes().map(|n| n.id.clone()).collect();
228 ids.sort(); let mut pos = Vec::with_capacity(ids.len());
231 let mut vel = Vec::with_capacity(ids.len());
232 let mut index = HashMap::with_capacity(ids.len());
233 let centroid = self.centroid();
234 let cold_start = self.index.is_empty();
240 let mut spawned_here = 0usize;
241
242 for (i, id) in ids.iter().enumerate() {
243 match self.index.get(id) {
244 Some(&old) => { pos.push(self.pos[old]); vel.push(self.vel[old]); }
245 None => {
246 match seed.get(id) {
247 Some(&at) => pos.push(at),
248 None => {
249 let ring = if cold_start {
250 spawned_here
251 } else {
252 self.spawn_seq % ARRIVAL_RINGS
253 };
254 pos.push(Self::phyllotaxis(self.spawn_seq, ring, centroid));
255 self.spawn_seq += 1;
256 spawned_here += 1;
257 }
258 }
259 vel.push([0.0, 0.0]);
260 }
261 }
262 index.insert(id.clone(), i);
263 }
264
265 self.links = graph
266 .edges()
267 .filter_map(|e| Some((*index.get(&e.source)?, *index.get(&e.target)?)))
268 .collect();
269 self.links.sort_unstable();
277
278 let pinned_id = self.fixed.and_then(|f| self.ids.get(f)).cloned();
280 self.ids = ids;
281 self.pos = pos;
282 self.vel = vel;
283 self.index = index;
284 self.fixed = pinned_id.and_then(|id| self.index.get(&id).copied());
285 self.pinned_at = self.fixed.map(|i| self.pos[i]);
286 }
287
288 fn phyllotaxis(seq: usize, ring: usize, center: [f32; 2]) -> [f32; 2] {
298 let r = SPAWN_RADIUS * (ring as f32 + 0.5).sqrt();
299 let a = seq as f32 * 2.399_963; [center[0] + r * a.cos(), center[1] + r * a.sin()]
301 }
302
303 fn centroid(&self) -> [f32; 2] {
304 if self.pos.is_empty() { return [0.0, 0.0]; }
305 let n = self.pos.len() as f32;
306 let s = self.pos.iter().fold([0.0f32, 0.0], |a, p| [a[0] + p[0], a[1] + p[1]]);
307 [s[0] / n, s[1] / n]
308 }
309
310 pub fn reheat(&mut self) {
316 self.alpha = self.spec.reheat_alpha;
317 }
318
319 pub fn reheat_full(&mut self) {
322 self.alpha = 1.0;
323 }
324
325 pub fn pin(&mut self, id: Option<&str>) {
327 self.fixed = id.and_then(|i| self.index.get(i).copied());
328 self.pinned_at = self.fixed.map(|i| self.pos[i]);
329 }
330
331 pub fn is_active(&self) -> bool { self.spec.enabled && self.alpha >= self.spec.alpha_min }
332
333 pub fn is_unsettled(&self) -> bool { self.alpha >= self.spec.alpha_min }
337
338 pub fn position(&self, id: &str) -> Option<[f32; 2]> {
339 self.index.get(id).map(|&i| self.pos[i])
340 }
341
342 pub fn velocity(&self, id: &str) -> Option<[f32; 2]> {
343 self.index.get(id).map(|&i| self.vel[i])
344 }
345
346 pub fn positions(&self) -> impl Iterator<Item = (&NodeId, [f32; 2])> + '_ {
347 self.ids.iter().zip(self.pos.iter().copied())
348 }
349
350 pub fn settle(&mut self, max: usize) {
354 for _ in 0..max {
355 if !self.tick() { return; }
356 }
357 }
358
359 pub fn settle_forced(&mut self, max: usize) {
365 let was = self.spec.enabled;
366 self.spec.enabled = true;
367 self.settle(max);
368 self.spec.enabled = was;
369 }
370
371 pub fn tick(&mut self) -> bool {
374 if !self.is_active() {
375 return false;
376 }
377 let n = self.pos.len();
378 if n == 0 {
379 self.alpha = 0.0;
380 return false;
381 }
382
383 let mut force = vec![[0.0f32, 0.0]; n];
384
385 let tree = QuadTree::build(&self.pos);
388 for (i, f) in force.iter_mut().enumerate() {
389 let r = tree.repulsion(i, &self.pos, self.spec.theta, self.spec.charge_strength);
390 f[0] += r[0];
391 f[1] += r[1];
392 }
393
394 let mut deg = vec![0u32; n];
406 for &(a, b) in &self.links {
407 deg[a] += 1;
408 deg[b] += 1;
409 }
410 for &(a, b) in &self.links {
411 let dx = self.pos[b][0] - self.pos[a][0];
412 let dy = self.pos[b][1] - self.pos[a][1];
413 let d = (dx * dx + dy * dy).sqrt().max(1e-3);
414 let strength = 1.0 / deg[a].min(deg[b]).max(1) as f32;
415 let pull = (d - self.spec.link_distance) / d * strength;
417 let (da, db) = (deg[a] as f32, deg[b] as f32);
420 let share_b = da / (da + db);
421 force[a][0] += dx * pull * (1.0 - share_b);
422 force[a][1] += dy * pull * (1.0 - share_b);
423 force[b][0] -= dx * pull * share_b;
424 force[b][1] -= dy * pull * share_b;
425 }
426
427 let c = self.centroid();
429 for (i, f) in force.iter_mut().enumerate() {
430 f[0] += (c[0] - self.pos[i][0]) * self.spec.center_strength;
431 f[1] += (c[1] - self.pos[i][1]) * self.spec.center_strength;
432 }
433
434 let friction = 1.0 - self.spec.velocity_decay;
437 let alpha = self.alpha;
438 for ((p, v), f) in self.pos.iter_mut().zip(self.vel.iter_mut()).zip(force.iter()) {
439 v[0] = (v[0] + f[0] * alpha) * friction;
440 v[1] = (v[1] + f[1] * alpha) * friction;
441 p[0] += v[0];
442 p[1] += v[1];
443 }
444
445 if let Some(f) = self.fixed {
449 if let Some(at) = self.pinned_at {
450 self.pos[f] = at;
451 }
452 self.vel[f] = [0.0, 0.0];
453 }
454
455 self.alpha -= self.alpha * self.spec.alpha_decay;
456 self.is_active()
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use graph_explorer_core::{Edge, Graph, Node};
464 use std::collections::HashMap;
465
466 fn graph_of(nodes: &[&str], edges: &[(&str, &str, &str)]) -> Graph {
467 let mut g = Graph::default();
468 for n in nodes { g.add_node(Node::new(*n)); }
469 for (id, a, b) in edges { g.add_edge(Edge::new(*id, *a, *b)); }
470 g
471 }
472
473 fn seeded(pairs: &[(&str, [f32; 2])]) -> HashMap<String, [f32; 2]> {
474 pairs.iter().map(|(k, v)| ((*k).to_string(), *v)).collect()
475 }
476
477 #[test]
478 fn is_unsettled_tracks_alpha_not_enabled() {
479 let mut sim = ForceSimulation::new(SimulationSpec::default());
480 sim.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &seeded(&[]));
481 sim.reheat_full();
482 assert!(sim.is_unsettled());
483 sim.settle(10_000);
484 assert!(!sim.is_unsettled());
485 }
486
487 #[test]
488 fn defaults_match_the_documented_schedule() {
489 let s = SimulationSpec::default();
490 assert!(s.enabled);
491 assert_eq!(s.alpha_min, 0.001);
492 assert_eq!(s.reheat_alpha, 0.3);
493 assert_eq!(s.theta, 0.9);
494 assert!((s.alpha_decay - 0.0228).abs() < 1e-4);
496 }
497
498 #[test]
499 fn spec_rejects_unknown_fields() {
500 let r: Result<SimulationSpec, _> = serde_json::from_str(r#"{"charge": -30}"#);
503 assert!(r.is_err(), "unknown field must be rejected");
504 }
505
506 #[test]
507 fn alpha_decays_to_min_in_about_three_hundred_ticks_then_stops() {
508 let mut s = ForceSimulation::new(SimulationSpec::default());
509 s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &HashMap::new());
510 s.reheat_full();
511 let mut ticks = 0;
512 while s.tick() { ticks += 1; assert!(ticks < 1000, "runaway"); }
513 assert!((250..=350).contains(&ticks), "settled in {ticks} ticks, expected ~300");
514 assert!(!s.is_active(), "a settled sim must report inactive");
515 assert!(!s.tick(), "ticking a settled sim stays settled");
516 }
517
518 #[test]
519 fn reheat_reenergises_a_settled_sim() {
520 let mut s = ForceSimulation::new(SimulationSpec::default());
521 s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &HashMap::new());
522 while s.tick() {}
523 assert!(!s.is_active());
524 s.reheat();
525 assert!(s.is_active(), "reheat must restart the schedule");
526 }
527
528 #[test]
529 fn sync_preserves_position_and_velocity_for_known_nodes() {
530 let mut s = ForceSimulation::new(SimulationSpec::default());
533 s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &seeded(&[("a", [100.0, 0.0]), ("b", [-100.0, 0.0])]));
534 s.reheat();
535 s.tick();
536 let a_before = s.position("a").unwrap();
537 let v_before = s.velocity("a").unwrap();
538 assert!(v_before[0] != 0.0 || v_before[1] != 0.0, "expected motion to compare against");
539
540 s.sync(&graph_of(&["a", "b", "c"], &[("e", "a", "b")]), &seeded(&[("c", [0.0, 50.0])]));
542 assert_eq!(s.position("a").unwrap(), a_before, "existing position preserved");
543 assert_eq!(s.velocity("a").unwrap(), v_before, "existing velocity preserved");
544 assert_eq!(s.position("c").unwrap(), [0.0, 50.0], "new node enters at its seed");
545 }
546
547 #[test]
548 fn sync_drops_nodes_that_left_the_graph() {
549 let mut s = ForceSimulation::new(SimulationSpec::default());
550 s.sync(&graph_of(&["a", "b"], &[]), &HashMap::new());
551 assert!(s.position("b").is_some());
552 s.sync(&graph_of(&["a"], &[]), &HashMap::new());
553 assert!(s.position("b").is_none(), "a removed node must leave the sim");
554 }
555
556 #[test]
557 fn an_unseeded_new_node_still_gets_a_finite_distinct_position() {
558 let mut s = ForceSimulation::new(SimulationSpec::default());
559 s.sync(&graph_of(&["a", "b", "c"], &[]), &HashMap::new());
560 let ps: Vec<[f32; 2]> = ["a", "b", "c"].iter().map(|i| s.position(i).unwrap()).collect();
561 for p in &ps { assert!(p[0].is_finite() && p[1].is_finite()); }
562 assert!(ps[0] != ps[1] && ps[1] != ps[2], "unseeded nodes must not stack");
563 }
564
565 #[test]
566 fn a_pinned_node_holds_still_while_its_neighbours_move() {
567 let mut s = ForceSimulation::new(SimulationSpec::default());
568 s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &seeded(&[("a", [0.0, 0.0]), ("b", [400.0, 0.0])]));
569 s.pin(Some("a"));
570 s.reheat_full();
571 for _ in 0..30 { s.tick(); }
572 assert_eq!(s.position("a").unwrap(), [0.0, 0.0], "pinned node must not drift");
573 assert!(s.position("b").unwrap()[0] < 400.0, "its neighbour should have been pulled in");
574 }
575
576 #[test]
577 fn a_pinned_node_still_exerts_force() {
578 let mut s = ForceSimulation::new(SimulationSpec::default());
588 s.sync(&graph_of(&["a", "b"], &[]), &seeded(&[("a", [0.0, 0.0]), ("b", [3.0, 4.0])]));
589 s.pin(Some("a"));
590 s.reheat_full();
591 for _ in 0..20 { s.tick(); }
592
593 let (a, b) = (s.position("a").unwrap(), s.position("b").unwrap());
594 assert_eq!(a, [0.0, 0.0], "the pinned node must not have moved");
595
596 let d = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
597 assert!(d > 5.0, "b sits {d} from a, no further than it started; nothing repelled it");
598
599 let unit = [(b[0] - a[0]) / d, (b[1] - a[1]) / d];
601 assert!(
602 (unit[0] - 0.6).abs() < 0.01 && (unit[1] - 0.8).abs() < 0.01,
603 "b reached {b:?}, off the a->b ray (direction {unit:?}); that is drift, not repulsion"
604 );
605 }
606
607 #[test]
612 fn a_pin_survives_the_index_shift_from_a_new_node() {
613 let mut s = ForceSimulation::new(SimulationSpec::default());
614 s.sync(&graph_of(&["m", "z"], &[]), &seeded(&[("m", [10.0, 0.0]), ("z", [200.0, 0.0])]));
615 s.pin(Some("m")); s.sync(&graph_of(&["a", "m", "z"], &[]), &seeded(&[("a", [0.0, 300.0])]));
619 s.reheat_full();
620 for _ in 0..60 { s.tick(); }
621
622 assert_eq!(
623 s.position("m").unwrap(), [10.0, 0.0],
624 "the pin must still hold m, at m's own hold point, after the reindex"
625 );
626 assert!(s.position("z").unwrap() != [200.0, 0.0], "z was not pinned and should have moved");
627 assert!(s.position("a").unwrap() != [0.0, 300.0], "a was not pinned and should have moved");
628 }
629
630 #[test]
634 fn a_pinned_node_leaving_the_graph_drops_the_pin() {
635 let mut s = ForceSimulation::new(SimulationSpec::default());
636 s.sync(
637 &graph_of(&["m", "q", "z"], &[]),
638 &seeded(&[("m", [0.0, 0.0]), ("q", [40.0, 0.0]), ("z", [0.0, 40.0])]),
639 );
640 s.pin(Some("m")); s.sync(&graph_of(&["q", "z"], &[]), &HashMap::new());
644 s.reheat_full();
645 for _ in 0..60 { s.tick(); }
646
647 assert!(s.position("m").is_none(), "m left the graph");
648 assert!(
649 s.position("q").unwrap() != [40.0, 0.0],
650 "q inherited m's index and is frozen; the pin transferred instead of dropping"
651 );
652 assert!(s.position("z").unwrap() != [0.0, 40.0], "z should be free to move");
653 }
654
655 #[test]
656 fn unpinning_releases_the_node() {
657 let mut s = ForceSimulation::new(SimulationSpec::default());
658 s.sync(&graph_of(&["a", "b"], &[]), &seeded(&[("a", [0.0, 0.0]), ("b", [5.0, 0.0])]));
659 s.pin(Some("a"));
660 s.reheat_full();
661 for _ in 0..10 { s.tick(); }
662 s.pin(None);
663 s.reheat_full();
664 for _ in 0..20 { s.tick(); }
665 assert!(s.position("a").unwrap() != [0.0, 0.0], "released node should move");
666 }
667
668 #[test]
669 fn pinning_an_unknown_id_is_a_no_op_not_a_panic() {
670 let mut s = ForceSimulation::new(SimulationSpec::default());
671 s.sync(&graph_of(&["a"], &[]), &HashMap::new());
672 s.pin(Some("ghost-that-is-not-here"));
673 s.reheat_full();
674 s.tick();
675 assert!(s.position("a").unwrap()[0].is_finite());
676 }
677
678 #[test]
679 fn disabled_spec_never_activates() {
680 let spec = SimulationSpec { enabled: false, ..SimulationSpec::default() };
681 let mut s = ForceSimulation::new(spec);
682 s.sync(&graph_of(&["a", "b"], &[]), &HashMap::new());
683 s.reheat();
684 assert!(!s.is_active(), "a disabled sim must never run");
685 assert!(!s.tick());
686 }
687
688 #[test]
689 fn settle_converges_and_respects_its_bound() {
690 let mut s = ForceSimulation::new(SimulationSpec::default());
691 s.sync(&graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]), &HashMap::new());
692 s.reheat_full();
693 s.settle(1000);
694 assert!(!s.is_active(), "settle should reach convergence");
695
696 s.reheat_full();
697 s.settle(5);
698 assert!(s.is_active(), "a bounded settle must stop early rather than spin");
699 }
700
701 #[test]
702 fn settle_forced_runs_while_disabled_and_restores_the_flag() {
703 let spec = SimulationSpec { enabled: false, ..SimulationSpec::default() };
704 let mut s = ForceSimulation::new(spec);
705 s.sync(&graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]), &HashMap::new());
706 s.reheat_full();
707 let before: Vec<[f32; 2]> = s.positions().map(|(_, p)| p).collect();
708 s.settle_forced(600);
709 assert!(!s.spec().enabled, "enabled flag must be restored");
710 assert!(!s.is_active(), "disabled sim reports inactive after the forced settle");
711 let after: Vec<[f32; 2]> = s.positions().map(|(_, p)| p).collect();
712 assert_ne!(before, after, "forced settle must actually move nodes");
713 }
714
715 #[test]
716 fn settle_forced_on_an_enabled_sim_behaves_like_settle() {
717 let mut s = ForceSimulation::new(SimulationSpec::default());
718 s.sync(&graph_of(&["a", "b"], &[("e1", "a", "b")]), &HashMap::new());
719 s.reheat_full();
720 s.settle_forced(1000);
721 assert!(s.spec().enabled);
722 assert!(!s.is_active(), "converged");
723 }
724
725 #[test]
726 fn a_triangle_settles_to_roughly_equilateral() {
727 let mut s = ForceSimulation::new(SimulationSpec::default());
728 let g = graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c"), ("e3", "c", "a")]);
729 s.sync(&g, &seeded(&[("a", [0.0, 0.0]), ("b", [10.0, 0.0]), ("c", [0.0, 10.0])]));
730 s.reheat_full();
731 s.settle(2000);
732
733 let d = |x: &str, y: &str| {
734 let (p, q) = (s.position(x).unwrap(), s.position(y).unwrap());
735 ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt()
736 };
737 let (ab, bc, ca) = (d("a", "b"), d("b", "c"), d("c", "a"));
738 let mean = (ab + bc + ca) / 3.0;
739 for (name, side) in [("ab", ab), ("bc", bc), ("ca", ca)] {
740 assert!((side - mean).abs() / mean < 0.15, "{name}={side} vs mean {mean}: not equilateral");
741 }
742 assert!(mean > 20.0, "sides collapsed to {mean}; repulsion is not holding them apart");
743 }
744
745 #[test]
746 fn linked_nodes_are_pulled_toward_link_distance() {
747 let mut s = ForceSimulation::new(SimulationSpec::default());
748 s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &seeded(&[("a", [0.0, 0.0]), ("b", [900.0, 0.0])]));
750 s.reheat_full();
751 s.settle(2000);
752 let (p, q) = (s.position("a").unwrap(), s.position("b").unwrap());
753 let d = ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt();
754 assert!(d < 400.0, "linked nodes stayed {d} apart; the spring is not pulling");
755 }
756
757 #[test]
758 fn a_disconnected_component_does_not_drift_to_infinity() {
759 let mut s = ForceSimulation::new(SimulationSpec::default());
762 s.sync(&graph_of(&["a", "b", "c", "d"], &[("e", "a", "b")]), &HashMap::new());
763 s.reheat_full();
764 s.settle(3000);
765 for id in ["a", "b", "c", "d"] {
766 let p = s.position(id).unwrap();
767 assert!(p[0].is_finite() && p[1].is_finite(), "{id} went non-finite");
768 assert!(p[0].abs() < 5000.0 && p[1].abs() < 5000.0, "{id} drifted to {p:?}");
769 }
770 }
771
772 #[test]
780 fn centering_bounds_spread_across_repeated_reheats() {
781 let mut s = ForceSimulation::new(SimulationSpec::default());
782 s.sync(&graph_of(&["a", "b", "c", "d"], &[("e", "a", "b")]), &HashMap::new());
783 for _ in 0..10 {
784 s.reheat_full();
785 s.settle(3000);
786 }
787 let c = s.centroid();
788 let radius = s
789 .positions()
790 .map(|(_, p)| ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt())
791 .fold(0.0f32, f32::max);
792 assert!(
793 radius < 600.0,
794 "spread reached {radius} after ten reheats; the centering force is not bounding it"
795 );
796 }
797
798 #[test]
799 fn an_isolated_single_node_does_not_move() {
800 let mut s = ForceSimulation::new(SimulationSpec::default());
801 s.sync(&graph_of(&["only"], &[]), &seeded(&[("only", [42.0, -7.0])]));
802 s.reheat_full();
803 s.settle(500);
804 let p = s.position("only").unwrap();
805 assert!((p[0] - 42.0).abs() < 0.01 && (p[1] + 7.0).abs() < 0.01,
806 "a lone node has nothing to push against and should sit still, got {p:?}");
807 }
808
809 #[test]
810 fn identical_inputs_produce_identical_layouts() {
811 let run = || {
814 let mut s = ForceSimulation::new(SimulationSpec::default());
815 s.sync(&graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]), &HashMap::new());
816 s.reheat_full();
817 s.settle(500);
818 ["a", "b", "c"].iter().map(|i| s.position(i).unwrap()).collect::<Vec<_>>()
819 };
820 assert_eq!(run(), run());
821 }
822
823 #[test]
829 fn a_high_degree_hub_does_not_explode() {
830 let ids: Vec<String> = (0..=26).map(|i| format!("n{i:03}")).collect();
831 let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
832 let edges: Vec<(String, String, String)> = (1..=26)
833 .map(|i| (format!("e{i}"), "n000".to_string(), format!("n{i:03}")))
834 .collect();
835 let eref: Vec<(&str, &str, &str)> =
836 edges.iter().map(|(i, a, b)| (i.as_str(), a.as_str(), b.as_str())).collect();
837
838 let mut s = ForceSimulation::new(SimulationSpec::default());
839 s.sync(&graph_of(&refs, &eref), &HashMap::new());
840 s.reheat_full();
841 s.settle(3000);
842
843 let hub = s.position("n000").unwrap();
844 assert!(hub[0].is_finite() && hub[1].is_finite(), "hub went non-finite: {hub:?}");
845 for id in &refs {
846 let p = s.position(id).unwrap();
847 assert!(p[0].is_finite() && p[1].is_finite(), "{id} went non-finite: {p:?}");
848 let r = ((p[0] - hub[0]).powi(2) + (p[1] - hub[1]).powi(2)).sqrt();
849 assert!(r < 2000.0, "{id} flew to {r} from the hub; the spring is diverging");
850 }
851 }
852
853 #[test]
856 fn a_hub_still_reels_its_spokes_to_roughly_link_distance() {
857 let ids: Vec<String> = (0..=12).map(|i| format!("n{i:03}")).collect();
858 let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
859 let edges: Vec<(String, String, String)> = (1..=12)
860 .map(|i| (format!("e{i}"), "n000".to_string(), format!("n{i:03}")))
861 .collect();
862 let eref: Vec<(&str, &str, &str)> =
863 edges.iter().map(|(i, a, b)| (i.as_str(), a.as_str(), b.as_str())).collect();
864
865 let mut s = ForceSimulation::new(SimulationSpec::default());
866 s.sync(&graph_of(&refs, &eref), &HashMap::new());
867 s.reheat_full();
868 s.settle(3000);
869
870 let hub = s.position("n000").unwrap();
871 for i in 1..=12 {
872 let p = s.position(&format!("n{i:03}")).unwrap();
873 let r = ((p[0] - hub[0]).powi(2) + (p[1] - hub[1]).powi(2)).sqrt();
874 assert!((30.0..600.0).contains(&r), "spoke n{i:03} settled at {r} from the hub");
875 }
876 }
877
878 #[test]
883 fn link_order_is_independent_of_edge_hash_order() {
884 let build = || {
885 let mut s = ForceSimulation::new(SimulationSpec::default());
886 s.sync(
887 &graph_of(
888 &["a", "b", "c", "d", "e", "f"],
889 &[("e1", "a", "b"), ("e2", "b", "c"), ("e3", "c", "d"),
890 ("e4", "d", "e"), ("e5", "e", "f"), ("e6", "f", "a")],
891 ),
892 &HashMap::new(),
893 );
894 s.links.clone()
895 };
896 for _ in 0..50 {
897 assert_eq!(build(), build(), "links order must not depend on hash iteration");
898 }
899 }
900
901 fn big_seeded_ring(n: usize) -> (Vec<String>, HashMap<String, [f32; 2]>) {
904 let ids: Vec<String> = (0..n).map(|k| format!("n{k:04}")).collect();
905 let seed = ids
906 .iter()
907 .enumerate()
908 .map(|(k, id)| {
909 let a = k as f32 / n as f32 * std::f32::consts::TAU;
910 (id.clone(), [400.0 * a.cos(), 400.0 * a.sin()])
911 })
912 .collect();
913 (ids, seed)
914 }
915
916 #[test]
920 fn a_late_unseeded_arrival_lands_near_the_centroid() {
921 let (ids, seed) = big_seeded_ring(500);
922 let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
923 let mut s = ForceSimulation::new(SimulationSpec::default());
924 s.sync(&graph_of(&refs, &[]), &seed);
925 let c = s.centroid();
926
927 let mut with_new = refs.clone();
928 with_new.push("zzz-arrives-last");
929 s.sync(&graph_of(&with_new, &[]), &HashMap::new());
930
931 let p = s.position("zzz-arrives-last").unwrap();
932 let r = ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt();
933 let link = s.spec().link_distance;
934 assert!(
935 r < 2.0 * link,
936 "arrival landed {r} from the centroid ({:.1} link distances), not near it",
937 r / link
938 );
939 }
940
941 #[test]
945 fn an_arrival_position_does_not_depend_on_where_its_id_sorts() {
946 let radius_for = |new_id: &str| {
947 let (ids, seed) = big_seeded_ring(500);
948 let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
949 let mut s = ForceSimulation::new(SimulationSpec::default());
950 s.sync(&graph_of(&refs, &[]), &seed);
951 let c = s.centroid();
952 let mut with_new = refs.clone();
953 with_new.push(new_id);
954 s.sync(&graph_of(&with_new, &[]), &HashMap::new());
955 let p = s.position(new_id).unwrap();
956 ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt()
957 };
958 let (first, last) = (radius_for("aaa-new"), radius_for("zzz-new"));
960 assert!(
961 (first - last).abs() < 1e-3,
962 "spawn radius depends on id ordering: {first} when sorting first, {last} when last"
963 );
964 }
965
966 #[test]
970 fn arrivals_in_separate_syncs_do_not_stack() {
971 let mut s = ForceSimulation::new(SimulationSpec::default());
972 s.sync(&graph_of(&["base"], &[]), &seeded(&[("base", [0.0, 0.0])]));
973
974 let mut live = vec!["base".to_string()];
975 let mut arrivals = Vec::new();
976 for k in 0..40 {
977 let id = format!("late{k:03}");
978 live.push(id.clone());
979 let refs: Vec<&str> = live.iter().map(|x| x.as_str()).collect();
980 s.sync(&graph_of(&refs, &[]), &HashMap::new()); arrivals.push(s.position(&id).unwrap());
982 }
983
984 for i in 0..arrivals.len() {
985 for j in (i + 1)..arrivals.len() {
986 let (p, q) = (arrivals[i], arrivals[j]);
987 let d = ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt();
988 assert!(d > 1.0, "arrivals {i} and {j} spawned {d} apart, effectively stacked");
989 }
990 }
991 let link = s.spec().link_distance;
993 for (k, p) in arrivals.iter().enumerate() {
994 let r = (p[0] * p[0] + p[1] * p[1]).sqrt();
995 assert!(r < 2.0 * link, "arrival {k} spawned {r} out; the radius is not bounded");
996 }
997 }
998
999 #[test]
1003 fn a_cold_start_keeps_its_full_spiral_spread() {
1004 let ids: Vec<String> = (0..500).map(|k| format!("n{k:04}")).collect();
1005 let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
1006 let mut s = ForceSimulation::new(SimulationSpec::default());
1007 s.sync(&graph_of(&refs, &[]), &HashMap::new());
1008
1009 let c = s.centroid();
1010 let max = s
1011 .positions()
1012 .map(|(_, p)| ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt())
1013 .fold(0.0f32, f32::max);
1014 let expected = 30.0 * 499.5f32.sqrt(); assert!(
1016 (max - expected).abs() < 10.0,
1017 "cold-start spread is {max}, expected ~{expected}; the sunflower was truncated"
1018 );
1019 }
1020
1021 #[test]
1027 fn theta_is_clamped_when_the_spec_is_adopted() {
1028 let s = ForceSimulation::new(SimulationSpec { theta: 4.0, ..SimulationSpec::default() });
1029 assert_eq!(s.spec().theta, 1.0, "new() must clamp an out-of-range theta");
1030
1031 let mut t = ForceSimulation::new(SimulationSpec::default());
1032 t.set_spec(SimulationSpec { theta: -3.0, ..SimulationSpec::default() });
1033 assert_eq!(t.spec().theta, 0.0, "set_spec must clamp a negative theta to exact O(n^2)");
1034
1035 let u = ForceSimulation::new(SimulationSpec { theta: 0.9, ..SimulationSpec::default() });
1036 assert_eq!(u.spec().theta, 0.9, "an in-range theta must be left exactly alone");
1037 }
1038
1039 #[test]
1040 fn the_alpha_schedule_is_clamped_when_the_spec_is_adopted() {
1041 let s = ForceSimulation::new(SimulationSpec {
1044 alpha_min: 0.0,
1045 alpha_decay: 0.0,
1046 velocity_decay: -1.0,
1047 reheat_alpha: 7.0,
1048 ..SimulationSpec::default()
1049 });
1050 assert_eq!(s.spec().alpha_min, ALPHA_MIN_FLOOR);
1051 assert_eq!(s.spec().alpha_decay, ALPHA_DECAY_MIN);
1052 assert_eq!(s.spec().velocity_decay, VELOCITY_DECAY_MIN);
1053 assert_eq!(s.spec().reheat_alpha, 1.0);
1054
1055 let mut t = ForceSimulation::new(SimulationSpec::default());
1056 t.set_spec(SimulationSpec {
1057 alpha_min: 5.0,
1058 alpha_decay: 9.0,
1059 velocity_decay: 3.0,
1060 reheat_alpha: -2.0,
1061 ..SimulationSpec::default()
1062 });
1063 assert_eq!(t.spec().alpha_min, ALPHA_MIN_CEIL);
1064 assert_eq!(t.spec().alpha_decay, ALPHA_DECAY_MAX);
1065 assert_eq!(t.spec().velocity_decay, VELOCITY_DECAY_MAX);
1066 assert_eq!(t.spec().reheat_alpha, 0.0);
1067
1068 let d = ForceSimulation::new(SimulationSpec::default());
1071 let want = SimulationSpec::default();
1072 assert_eq!(d.spec().alpha_min, want.alpha_min);
1073 assert_eq!(d.spec().alpha_decay, want.alpha_decay);
1074 assert_eq!(d.spec().velocity_decay, want.velocity_decay);
1075 assert_eq!(d.spec().reheat_alpha, want.reheat_alpha);
1076 }
1077
1078 #[test]
1079 fn a_zero_alpha_min_cannot_wedge_the_sim_active_forever() {
1080 let mut s = ForceSimulation::new(SimulationSpec {
1086 alpha_min: 0.0,
1087 ..SimulationSpec::default()
1088 });
1089 s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &HashMap::new());
1090 s.reheat_full();
1091 s.settle(5000);
1092 assert!(!s.is_active(), "a zero alpha_min must not leave the sim active forever");
1093 assert!(!s.tick(), "and the next tick must still report settled");
1094 }
1095
1096 #[test]
1097 fn every_legal_schedule_settles_within_the_reduced_motion_budget() {
1098 let hostile = [
1105 ("zero alpha_min", SimulationSpec { alpha_min: 0.0, ..Default::default() }),
1106 ("zero alpha_decay", SimulationSpec { alpha_decay: 0.0, ..Default::default() }),
1107 ("negative alpha_decay", SimulationSpec { alpha_decay: -1.0, ..Default::default() }),
1108 ("zero velocity_decay", SimulationSpec { velocity_decay: 0.0, ..Default::default() }),
1109 ("oversized reheat_alpha", SimulationSpec { reheat_alpha: 9.0, ..Default::default() }),
1110 (
1111 "all of them at once",
1112 SimulationSpec {
1113 alpha_min: 0.0,
1114 alpha_decay: 0.0,
1115 velocity_decay: -3.0,
1116 reheat_alpha: 9.0,
1117 ..Default::default()
1118 },
1119 ),
1120 ];
1121 for (what, spec) in hostile {
1122 let mut s = ForceSimulation::new(spec);
1123 s.sync(
1124 &graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]),
1125 &HashMap::new(),
1126 );
1127 s.reheat_full();
1130 s.settle(600);
1131 assert!(!s.is_active(), "{what}: still active after settle(600)");
1132 assert!(!s.tick(), "{what}: the next tick must still report settled");
1133 }
1134 }
1135
1136 #[test]
1137 fn a_negative_velocity_decay_cannot_diverge_the_layout() {
1138 let mut s = ForceSimulation::new(SimulationSpec::default());
1142 s.set_spec(SimulationSpec { velocity_decay: -5.0, ..SimulationSpec::default() });
1143 s.sync(
1144 &graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]),
1145 &seeded(&[("a", [0.0, 0.0]), ("b", [10.0, 0.0]), ("c", [0.0, 10.0])]),
1146 );
1147 s.reheat_full();
1148 s.settle(2000);
1149 for id in ["a", "b", "c"] {
1150 let p = s.position(id).expect("node present");
1151 assert!(p[0].is_finite() && p[1].is_finite(), "{id} diverged to {p:?}");
1152 }
1153 }
1154
1155 #[test]
1156 fn the_alpha_decay_floor_leaves_headroom_under_the_settle_budget() {
1157 let ticks = (ALPHA_MIN_FLOOR.ln() / (1.0 - ALPHA_DECAY_MIN).ln()).ceil();
1161 assert!(ticks <= 600.0, "worst-case settle is {ticks} ticks, over the 600 budget");
1162 }
1163
1164 #[test]
1165 fn coincident_start_positions_do_not_produce_nan() {
1166 let mut s = ForceSimulation::new(SimulationSpec::default());
1167 s.sync(&graph_of(&["a", "b", "c"], &[]),
1168 &seeded(&[("a", [0.0, 0.0]), ("b", [0.0, 0.0]), ("c", [0.0, 0.0])]));
1169 s.reheat_full();
1170 s.settle(200);
1171 for id in ["a", "b", "c"] {
1172 let p = s.position(id).unwrap();
1173 assert!(p[0].is_finite() && p[1].is_finite(), "{id} = {p:?}");
1174 }
1175 }
1176}