use crate::QuadTree;
use graph_explorer_core::{Graph, NodeId};
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SimulationSpec {
#[serde(default = "yes")]
pub enabled: bool,
#[serde(default = "d_alpha_decay")]
pub alpha_decay: f32,
#[serde(default = "d_alpha_min")]
pub alpha_min: f32,
#[serde(default = "d_velocity_decay")]
pub velocity_decay: f32,
#[serde(default = "d_reheat_alpha")]
pub reheat_alpha: f32,
#[serde(default = "d_theta")]
pub theta: f32,
#[serde(default = "d_link_distance")]
pub link_distance: f32,
#[serde(default = "d_charge")]
pub charge_strength: f32,
#[serde(default = "d_center")]
pub center_strength: f32,
}
fn yes() -> bool { true }
fn d_alpha_decay() -> f32 { 0.0228 }
fn d_alpha_min() -> f32 { 0.001 }
fn d_velocity_decay() -> f32 { 0.4 }
fn d_reheat_alpha() -> f32 { 0.3 }
fn d_theta() -> f32 { 0.9 }
fn d_link_distance() -> f32 { 120.0 }
fn d_charge() -> f32 { -800.0 }
fn d_center() -> f32 { 0.02 }
impl Default for SimulationSpec {
fn default() -> Self {
Self {
enabled: yes(),
alpha_decay: d_alpha_decay(),
alpha_min: d_alpha_min(),
velocity_decay: d_velocity_decay(),
reheat_alpha: d_reheat_alpha(),
theta: d_theta(),
link_distance: d_link_distance(),
charge_strength: d_charge(),
center_strength: d_center(),
}
}
}
pub struct ForceSimulation {
spec: SimulationSpec,
index: HashMap<NodeId, usize>,
ids: Vec<NodeId>,
pos: Vec<[f32; 2]>,
vel: Vec<[f32; 2]>,
links: Vec<(usize, usize)>,
fixed: Option<usize>,
pinned_at: Option<[f32; 2]>,
alpha: f32,
spawn_seq: usize,
}
const THETA_MAX: f32 = 1.0;
const ALPHA_MIN_FLOOR: f32 = 1e-4;
const ALPHA_MIN_CEIL: f32 = 0.1;
const ALPHA_DECAY_MIN: f32 = 0.016;
const ALPHA_DECAY_MAX: f32 = 1.0;
const VELOCITY_DECAY_MIN: f32 = 0.01;
const VELOCITY_DECAY_MAX: f32 = 1.0;
const SPAWN_RADIUS: f32 = 30.0;
const ARRIVAL_RINGS: usize = 12;
impl ForceSimulation {
pub fn new(spec: SimulationSpec) -> Self {
Self {
spec: Self::clamped(spec),
index: HashMap::new(),
ids: Vec::new(),
pos: Vec::new(),
vel: Vec::new(),
links: Vec::new(),
fixed: None,
pinned_at: None,
alpha: 0.0,
spawn_seq: 0,
}
}
pub fn set_spec(&mut self, spec: SimulationSpec) { self.spec = Self::clamped(spec); }
pub fn spec(&self) -> &SimulationSpec { &self.spec }
fn clamped(mut spec: SimulationSpec) -> SimulationSpec {
spec.theta = spec.theta.clamp(0.0, THETA_MAX);
spec.alpha_min = spec.alpha_min.clamp(ALPHA_MIN_FLOOR, ALPHA_MIN_CEIL);
spec.alpha_decay = spec.alpha_decay.clamp(ALPHA_DECAY_MIN, ALPHA_DECAY_MAX);
spec.velocity_decay =
spec.velocity_decay.clamp(VELOCITY_DECAY_MIN, VELOCITY_DECAY_MAX);
spec.reheat_alpha = spec.reheat_alpha.clamp(0.0, 1.0);
spec
}
pub fn sync(&mut self, graph: &Graph, seed: &HashMap<NodeId, [f32; 2]>) {
let mut ids: Vec<NodeId> = graph.nodes().map(|n| n.id.clone()).collect();
ids.sort();
let mut pos = Vec::with_capacity(ids.len());
let mut vel = Vec::with_capacity(ids.len());
let mut index = HashMap::with_capacity(ids.len());
let centroid = self.centroid();
let cold_start = self.index.is_empty();
let mut spawned_here = 0usize;
for (i, id) in ids.iter().enumerate() {
match self.index.get(id) {
Some(&old) => { pos.push(self.pos[old]); vel.push(self.vel[old]); }
None => {
match seed.get(id) {
Some(&at) => pos.push(at),
None => {
let ring = if cold_start {
spawned_here
} else {
self.spawn_seq % ARRIVAL_RINGS
};
pos.push(Self::phyllotaxis(self.spawn_seq, ring, centroid));
self.spawn_seq += 1;
spawned_here += 1;
}
}
vel.push([0.0, 0.0]);
}
}
index.insert(id.clone(), i);
}
self.links = graph
.edges()
.filter_map(|e| Some((*index.get(&e.source)?, *index.get(&e.target)?)))
.collect();
self.links.sort_unstable();
let pinned_id = self.fixed.and_then(|f| self.ids.get(f)).cloned();
self.ids = ids;
self.pos = pos;
self.vel = vel;
self.index = index;
self.fixed = pinned_id.and_then(|id| self.index.get(&id).copied());
self.pinned_at = self.fixed.map(|i| self.pos[i]);
}
fn phyllotaxis(seq: usize, ring: usize, center: [f32; 2]) -> [f32; 2] {
let r = SPAWN_RADIUS * (ring as f32 + 0.5).sqrt();
let a = seq as f32 * 2.399_963; [center[0] + r * a.cos(), center[1] + r * a.sin()]
}
fn centroid(&self) -> [f32; 2] {
if self.pos.is_empty() { return [0.0, 0.0]; }
let n = self.pos.len() as f32;
let s = self.pos.iter().fold([0.0f32, 0.0], |a, p| [a[0] + p[0], a[1] + p[1]]);
[s[0] / n, s[1] / n]
}
pub fn reheat(&mut self) {
self.alpha = self.spec.reheat_alpha;
}
pub fn reheat_full(&mut self) {
self.alpha = 1.0;
}
pub fn pin(&mut self, id: Option<&str>) {
self.fixed = id.and_then(|i| self.index.get(i).copied());
self.pinned_at = self.fixed.map(|i| self.pos[i]);
}
pub fn is_active(&self) -> bool { self.spec.enabled && self.alpha >= self.spec.alpha_min }
pub fn is_unsettled(&self) -> bool { self.alpha >= self.spec.alpha_min }
pub fn position(&self, id: &str) -> Option<[f32; 2]> {
self.index.get(id).map(|&i| self.pos[i])
}
pub fn velocity(&self, id: &str) -> Option<[f32; 2]> {
self.index.get(id).map(|&i| self.vel[i])
}
pub fn positions(&self) -> impl Iterator<Item = (&NodeId, [f32; 2])> + '_ {
self.ids.iter().zip(self.pos.iter().copied())
}
pub fn settle(&mut self, max: usize) {
for _ in 0..max {
if !self.tick() { return; }
}
}
pub fn settle_forced(&mut self, max: usize) {
let was = self.spec.enabled;
self.spec.enabled = true;
self.settle(max);
self.spec.enabled = was;
}
pub fn tick(&mut self) -> bool {
if !self.is_active() {
return false;
}
let n = self.pos.len();
if n == 0 {
self.alpha = 0.0;
return false;
}
let mut force = vec![[0.0f32, 0.0]; n];
let tree = QuadTree::build(&self.pos);
for (i, f) in force.iter_mut().enumerate() {
let r = tree.repulsion(i, &self.pos, self.spec.theta, self.spec.charge_strength);
f[0] += r[0];
f[1] += r[1];
}
let mut deg = vec![0u32; n];
for &(a, b) in &self.links {
deg[a] += 1;
deg[b] += 1;
}
for &(a, b) in &self.links {
let dx = self.pos[b][0] - self.pos[a][0];
let dy = self.pos[b][1] - self.pos[a][1];
let d = (dx * dx + dy * dy).sqrt().max(1e-3);
let strength = 1.0 / deg[a].min(deg[b]).max(1) as f32;
let pull = (d - self.spec.link_distance) / d * strength;
let (da, db) = (deg[a] as f32, deg[b] as f32);
let share_b = da / (da + db);
force[a][0] += dx * pull * (1.0 - share_b);
force[a][1] += dy * pull * (1.0 - share_b);
force[b][0] -= dx * pull * share_b;
force[b][1] -= dy * pull * share_b;
}
let c = self.centroid();
for (i, f) in force.iter_mut().enumerate() {
f[0] += (c[0] - self.pos[i][0]) * self.spec.center_strength;
f[1] += (c[1] - self.pos[i][1]) * self.spec.center_strength;
}
let friction = 1.0 - self.spec.velocity_decay;
let alpha = self.alpha;
for ((p, v), f) in self.pos.iter_mut().zip(self.vel.iter_mut()).zip(force.iter()) {
v[0] = (v[0] + f[0] * alpha) * friction;
v[1] = (v[1] + f[1] * alpha) * friction;
p[0] += v[0];
p[1] += v[1];
}
if let Some(f) = self.fixed {
if let Some(at) = self.pinned_at {
self.pos[f] = at;
}
self.vel[f] = [0.0, 0.0];
}
self.alpha -= self.alpha * self.spec.alpha_decay;
self.is_active()
}
}
#[cfg(test)]
mod tests {
use super::*;
use graph_explorer_core::{Edge, Graph, Node};
use std::collections::HashMap;
fn graph_of(nodes: &[&str], edges: &[(&str, &str, &str)]) -> Graph {
let mut g = Graph::default();
for n in nodes { g.add_node(Node::new(*n)); }
for (id, a, b) in edges { g.add_edge(Edge::new(*id, *a, *b)); }
g
}
fn seeded(pairs: &[(&str, [f32; 2])]) -> HashMap<String, [f32; 2]> {
pairs.iter().map(|(k, v)| ((*k).to_string(), *v)).collect()
}
#[test]
fn is_unsettled_tracks_alpha_not_enabled() {
let mut sim = ForceSimulation::new(SimulationSpec::default());
sim.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &seeded(&[]));
sim.reheat_full();
assert!(sim.is_unsettled());
sim.settle(10_000);
assert!(!sim.is_unsettled());
}
#[test]
fn defaults_match_the_documented_schedule() {
let s = SimulationSpec::default();
assert!(s.enabled);
assert_eq!(s.alpha_min, 0.001);
assert_eq!(s.reheat_alpha, 0.3);
assert_eq!(s.theta, 0.9);
assert!((s.alpha_decay - 0.0228).abs() < 1e-4);
}
#[test]
fn spec_rejects_unknown_fields() {
let r: Result<SimulationSpec, _> = serde_json::from_str(r#"{"charge": -30}"#);
assert!(r.is_err(), "unknown field must be rejected");
}
#[test]
fn alpha_decays_to_min_in_about_three_hundred_ticks_then_stops() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &HashMap::new());
s.reheat_full();
let mut ticks = 0;
while s.tick() { ticks += 1; assert!(ticks < 1000, "runaway"); }
assert!((250..=350).contains(&ticks), "settled in {ticks} ticks, expected ~300");
assert!(!s.is_active(), "a settled sim must report inactive");
assert!(!s.tick(), "ticking a settled sim stays settled");
}
#[test]
fn reheat_reenergises_a_settled_sim() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &HashMap::new());
while s.tick() {}
assert!(!s.is_active());
s.reheat();
assert!(s.is_active(), "reheat must restart the schedule");
}
#[test]
fn sync_preserves_position_and_velocity_for_known_nodes() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &seeded(&[("a", [100.0, 0.0]), ("b", [-100.0, 0.0])]));
s.reheat();
s.tick();
let a_before = s.position("a").unwrap();
let v_before = s.velocity("a").unwrap();
assert!(v_before[0] != 0.0 || v_before[1] != 0.0, "expected motion to compare against");
s.sync(&graph_of(&["a", "b", "c"], &[("e", "a", "b")]), &seeded(&[("c", [0.0, 50.0])]));
assert_eq!(s.position("a").unwrap(), a_before, "existing position preserved");
assert_eq!(s.velocity("a").unwrap(), v_before, "existing velocity preserved");
assert_eq!(s.position("c").unwrap(), [0.0, 50.0], "new node enters at its seed");
}
#[test]
fn sync_drops_nodes_that_left_the_graph() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b"], &[]), &HashMap::new());
assert!(s.position("b").is_some());
s.sync(&graph_of(&["a"], &[]), &HashMap::new());
assert!(s.position("b").is_none(), "a removed node must leave the sim");
}
#[test]
fn an_unseeded_new_node_still_gets_a_finite_distinct_position() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b", "c"], &[]), &HashMap::new());
let ps: Vec<[f32; 2]> = ["a", "b", "c"].iter().map(|i| s.position(i).unwrap()).collect();
for p in &ps { assert!(p[0].is_finite() && p[1].is_finite()); }
assert!(ps[0] != ps[1] && ps[1] != ps[2], "unseeded nodes must not stack");
}
#[test]
fn a_pinned_node_holds_still_while_its_neighbours_move() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &seeded(&[("a", [0.0, 0.0]), ("b", [400.0, 0.0])]));
s.pin(Some("a"));
s.reheat_full();
for _ in 0..30 { s.tick(); }
assert_eq!(s.position("a").unwrap(), [0.0, 0.0], "pinned node must not drift");
assert!(s.position("b").unwrap()[0] < 400.0, "its neighbour should have been pulled in");
}
#[test]
fn a_pinned_node_still_exerts_force() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b"], &[]), &seeded(&[("a", [0.0, 0.0]), ("b", [3.0, 4.0])]));
s.pin(Some("a"));
s.reheat_full();
for _ in 0..20 { s.tick(); }
let (a, b) = (s.position("a").unwrap(), s.position("b").unwrap());
assert_eq!(a, [0.0, 0.0], "the pinned node must not have moved");
let d = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
assert!(d > 5.0, "b sits {d} from a, no further than it started; nothing repelled it");
let unit = [(b[0] - a[0]) / d, (b[1] - a[1]) / d];
assert!(
(unit[0] - 0.6).abs() < 0.01 && (unit[1] - 0.8).abs() < 0.01,
"b reached {b:?}, off the a->b ray (direction {unit:?}); that is drift, not repulsion"
);
}
#[test]
fn a_pin_survives_the_index_shift_from_a_new_node() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["m", "z"], &[]), &seeded(&[("m", [10.0, 0.0]), ("z", [200.0, 0.0])]));
s.pin(Some("m"));
s.sync(&graph_of(&["a", "m", "z"], &[]), &seeded(&[("a", [0.0, 300.0])]));
s.reheat_full();
for _ in 0..60 { s.tick(); }
assert_eq!(
s.position("m").unwrap(), [10.0, 0.0],
"the pin must still hold m, at m's own hold point, after the reindex"
);
assert!(s.position("z").unwrap() != [200.0, 0.0], "z was not pinned and should have moved");
assert!(s.position("a").unwrap() != [0.0, 300.0], "a was not pinned and should have moved");
}
#[test]
fn a_pinned_node_leaving_the_graph_drops_the_pin() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(
&graph_of(&["m", "q", "z"], &[]),
&seeded(&[("m", [0.0, 0.0]), ("q", [40.0, 0.0]), ("z", [0.0, 40.0])]),
);
s.pin(Some("m"));
s.sync(&graph_of(&["q", "z"], &[]), &HashMap::new());
s.reheat_full();
for _ in 0..60 { s.tick(); }
assert!(s.position("m").is_none(), "m left the graph");
assert!(
s.position("q").unwrap() != [40.0, 0.0],
"q inherited m's index and is frozen; the pin transferred instead of dropping"
);
assert!(s.position("z").unwrap() != [0.0, 40.0], "z should be free to move");
}
#[test]
fn unpinning_releases_the_node() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b"], &[]), &seeded(&[("a", [0.0, 0.0]), ("b", [5.0, 0.0])]));
s.pin(Some("a"));
s.reheat_full();
for _ in 0..10 { s.tick(); }
s.pin(None);
s.reheat_full();
for _ in 0..20 { s.tick(); }
assert!(s.position("a").unwrap() != [0.0, 0.0], "released node should move");
}
#[test]
fn pinning_an_unknown_id_is_a_no_op_not_a_panic() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a"], &[]), &HashMap::new());
s.pin(Some("ghost-that-is-not-here"));
s.reheat_full();
s.tick();
assert!(s.position("a").unwrap()[0].is_finite());
}
#[test]
fn disabled_spec_never_activates() {
let spec = SimulationSpec { enabled: false, ..SimulationSpec::default() };
let mut s = ForceSimulation::new(spec);
s.sync(&graph_of(&["a", "b"], &[]), &HashMap::new());
s.reheat();
assert!(!s.is_active(), "a disabled sim must never run");
assert!(!s.tick());
}
#[test]
fn settle_converges_and_respects_its_bound() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]), &HashMap::new());
s.reheat_full();
s.settle(1000);
assert!(!s.is_active(), "settle should reach convergence");
s.reheat_full();
s.settle(5);
assert!(s.is_active(), "a bounded settle must stop early rather than spin");
}
#[test]
fn settle_forced_runs_while_disabled_and_restores_the_flag() {
let spec = SimulationSpec { enabled: false, ..SimulationSpec::default() };
let mut s = ForceSimulation::new(spec);
s.sync(&graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]), &HashMap::new());
s.reheat_full();
let before: Vec<[f32; 2]> = s.positions().map(|(_, p)| p).collect();
s.settle_forced(600);
assert!(!s.spec().enabled, "enabled flag must be restored");
assert!(!s.is_active(), "disabled sim reports inactive after the forced settle");
let after: Vec<[f32; 2]> = s.positions().map(|(_, p)| p).collect();
assert_ne!(before, after, "forced settle must actually move nodes");
}
#[test]
fn settle_forced_on_an_enabled_sim_behaves_like_settle() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b"], &[("e1", "a", "b")]), &HashMap::new());
s.reheat_full();
s.settle_forced(1000);
assert!(s.spec().enabled);
assert!(!s.is_active(), "converged");
}
#[test]
fn a_triangle_settles_to_roughly_equilateral() {
let mut s = ForceSimulation::new(SimulationSpec::default());
let g = graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c"), ("e3", "c", "a")]);
s.sync(&g, &seeded(&[("a", [0.0, 0.0]), ("b", [10.0, 0.0]), ("c", [0.0, 10.0])]));
s.reheat_full();
s.settle(2000);
let d = |x: &str, y: &str| {
let (p, q) = (s.position(x).unwrap(), s.position(y).unwrap());
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt()
};
let (ab, bc, ca) = (d("a", "b"), d("b", "c"), d("c", "a"));
let mean = (ab + bc + ca) / 3.0;
for (name, side) in [("ab", ab), ("bc", bc), ("ca", ca)] {
assert!((side - mean).abs() / mean < 0.15, "{name}={side} vs mean {mean}: not equilateral");
}
assert!(mean > 20.0, "sides collapsed to {mean}; repulsion is not holding them apart");
}
#[test]
fn linked_nodes_are_pulled_toward_link_distance() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &seeded(&[("a", [0.0, 0.0]), ("b", [900.0, 0.0])]));
s.reheat_full();
s.settle(2000);
let (p, q) = (s.position("a").unwrap(), s.position("b").unwrap());
let d = ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt();
assert!(d < 400.0, "linked nodes stayed {d} apart; the spring is not pulling");
}
#[test]
fn a_disconnected_component_does_not_drift_to_infinity() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b", "c", "d"], &[("e", "a", "b")]), &HashMap::new());
s.reheat_full();
s.settle(3000);
for id in ["a", "b", "c", "d"] {
let p = s.position(id).unwrap();
assert!(p[0].is_finite() && p[1].is_finite(), "{id} went non-finite");
assert!(p[0].abs() < 5000.0 && p[1].abs() < 5000.0, "{id} drifted to {p:?}");
}
}
#[test]
fn centering_bounds_spread_across_repeated_reheats() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b", "c", "d"], &[("e", "a", "b")]), &HashMap::new());
for _ in 0..10 {
s.reheat_full();
s.settle(3000);
}
let c = s.centroid();
let radius = s
.positions()
.map(|(_, p)| ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt())
.fold(0.0f32, f32::max);
assert!(
radius < 600.0,
"spread reached {radius} after ten reheats; the centering force is not bounding it"
);
}
#[test]
fn an_isolated_single_node_does_not_move() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["only"], &[]), &seeded(&[("only", [42.0, -7.0])]));
s.reheat_full();
s.settle(500);
let p = s.position("only").unwrap();
assert!((p[0] - 42.0).abs() < 0.01 && (p[1] + 7.0).abs() < 0.01,
"a lone node has nothing to push against and should sit still, got {p:?}");
}
#[test]
fn identical_inputs_produce_identical_layouts() {
let run = || {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]), &HashMap::new());
s.reheat_full();
s.settle(500);
["a", "b", "c"].iter().map(|i| s.position(i).unwrap()).collect::<Vec<_>>()
};
assert_eq!(run(), run());
}
#[test]
fn a_high_degree_hub_does_not_explode() {
let ids: Vec<String> = (0..=26).map(|i| format!("n{i:03}")).collect();
let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
let edges: Vec<(String, String, String)> = (1..=26)
.map(|i| (format!("e{i}"), "n000".to_string(), format!("n{i:03}")))
.collect();
let eref: Vec<(&str, &str, &str)> =
edges.iter().map(|(i, a, b)| (i.as_str(), a.as_str(), b.as_str())).collect();
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&refs, &eref), &HashMap::new());
s.reheat_full();
s.settle(3000);
let hub = s.position("n000").unwrap();
assert!(hub[0].is_finite() && hub[1].is_finite(), "hub went non-finite: {hub:?}");
for id in &refs {
let p = s.position(id).unwrap();
assert!(p[0].is_finite() && p[1].is_finite(), "{id} went non-finite: {p:?}");
let r = ((p[0] - hub[0]).powi(2) + (p[1] - hub[1]).powi(2)).sqrt();
assert!(r < 2000.0, "{id} flew to {r} from the hub; the spring is diverging");
}
}
#[test]
fn a_hub_still_reels_its_spokes_to_roughly_link_distance() {
let ids: Vec<String> = (0..=12).map(|i| format!("n{i:03}")).collect();
let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
let edges: Vec<(String, String, String)> = (1..=12)
.map(|i| (format!("e{i}"), "n000".to_string(), format!("n{i:03}")))
.collect();
let eref: Vec<(&str, &str, &str)> =
edges.iter().map(|(i, a, b)| (i.as_str(), a.as_str(), b.as_str())).collect();
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&refs, &eref), &HashMap::new());
s.reheat_full();
s.settle(3000);
let hub = s.position("n000").unwrap();
for i in 1..=12 {
let p = s.position(&format!("n{i:03}")).unwrap();
let r = ((p[0] - hub[0]).powi(2) + (p[1] - hub[1]).powi(2)).sqrt();
assert!((30.0..600.0).contains(&r), "spoke n{i:03} settled at {r} from the hub");
}
}
#[test]
fn link_order_is_independent_of_edge_hash_order() {
let build = || {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(
&graph_of(
&["a", "b", "c", "d", "e", "f"],
&[("e1", "a", "b"), ("e2", "b", "c"), ("e3", "c", "d"),
("e4", "d", "e"), ("e5", "e", "f"), ("e6", "f", "a")],
),
&HashMap::new(),
);
s.links.clone()
};
for _ in 0..50 {
assert_eq!(build(), build(), "links order must not depend on hash iteration");
}
}
fn big_seeded_ring(n: usize) -> (Vec<String>, HashMap<String, [f32; 2]>) {
let ids: Vec<String> = (0..n).map(|k| format!("n{k:04}")).collect();
let seed = ids
.iter()
.enumerate()
.map(|(k, id)| {
let a = k as f32 / n as f32 * std::f32::consts::TAU;
(id.clone(), [400.0 * a.cos(), 400.0 * a.sin()])
})
.collect();
(ids, seed)
}
#[test]
fn a_late_unseeded_arrival_lands_near_the_centroid() {
let (ids, seed) = big_seeded_ring(500);
let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&refs, &[]), &seed);
let c = s.centroid();
let mut with_new = refs.clone();
with_new.push("zzz-arrives-last");
s.sync(&graph_of(&with_new, &[]), &HashMap::new());
let p = s.position("zzz-arrives-last").unwrap();
let r = ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt();
let link = s.spec().link_distance;
assert!(
r < 2.0 * link,
"arrival landed {r} from the centroid ({:.1} link distances), not near it",
r / link
);
}
#[test]
fn an_arrival_position_does_not_depend_on_where_its_id_sorts() {
let radius_for = |new_id: &str| {
let (ids, seed) = big_seeded_ring(500);
let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&refs, &[]), &seed);
let c = s.centroid();
let mut with_new = refs.clone();
with_new.push(new_id);
s.sync(&graph_of(&with_new, &[]), &HashMap::new());
let p = s.position(new_id).unwrap();
((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt()
};
let (first, last) = (radius_for("aaa-new"), radius_for("zzz-new"));
assert!(
(first - last).abs() < 1e-3,
"spawn radius depends on id ordering: {first} when sorting first, {last} when last"
);
}
#[test]
fn arrivals_in_separate_syncs_do_not_stack() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["base"], &[]), &seeded(&[("base", [0.0, 0.0])]));
let mut live = vec!["base".to_string()];
let mut arrivals = Vec::new();
for k in 0..40 {
let id = format!("late{k:03}");
live.push(id.clone());
let refs: Vec<&str> = live.iter().map(|x| x.as_str()).collect();
s.sync(&graph_of(&refs, &[]), &HashMap::new()); arrivals.push(s.position(&id).unwrap());
}
for i in 0..arrivals.len() {
for j in (i + 1)..arrivals.len() {
let (p, q) = (arrivals[i], arrivals[j]);
let d = ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt();
assert!(d > 1.0, "arrivals {i} and {j} spawned {d} apart, effectively stacked");
}
}
let link = s.spec().link_distance;
for (k, p) in arrivals.iter().enumerate() {
let r = (p[0] * p[0] + p[1] * p[1]).sqrt();
assert!(r < 2.0 * link, "arrival {k} spawned {r} out; the radius is not bounded");
}
}
#[test]
fn a_cold_start_keeps_its_full_spiral_spread() {
let ids: Vec<String> = (0..500).map(|k| format!("n{k:04}")).collect();
let refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&refs, &[]), &HashMap::new());
let c = s.centroid();
let max = s
.positions()
.map(|(_, p)| ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt())
.fold(0.0f32, f32::max);
let expected = 30.0 * 499.5f32.sqrt(); assert!(
(max - expected).abs() < 10.0,
"cold-start spread is {max}, expected ~{expected}; the sunflower was truncated"
);
}
#[test]
fn theta_is_clamped_when_the_spec_is_adopted() {
let s = ForceSimulation::new(SimulationSpec { theta: 4.0, ..SimulationSpec::default() });
assert_eq!(s.spec().theta, 1.0, "new() must clamp an out-of-range theta");
let mut t = ForceSimulation::new(SimulationSpec::default());
t.set_spec(SimulationSpec { theta: -3.0, ..SimulationSpec::default() });
assert_eq!(t.spec().theta, 0.0, "set_spec must clamp a negative theta to exact O(n^2)");
let u = ForceSimulation::new(SimulationSpec { theta: 0.9, ..SimulationSpec::default() });
assert_eq!(u.spec().theta, 0.9, "an in-range theta must be left exactly alone");
}
#[test]
fn the_alpha_schedule_is_clamped_when_the_spec_is_adopted() {
let s = ForceSimulation::new(SimulationSpec {
alpha_min: 0.0,
alpha_decay: 0.0,
velocity_decay: -1.0,
reheat_alpha: 7.0,
..SimulationSpec::default()
});
assert_eq!(s.spec().alpha_min, ALPHA_MIN_FLOOR);
assert_eq!(s.spec().alpha_decay, ALPHA_DECAY_MIN);
assert_eq!(s.spec().velocity_decay, VELOCITY_DECAY_MIN);
assert_eq!(s.spec().reheat_alpha, 1.0);
let mut t = ForceSimulation::new(SimulationSpec::default());
t.set_spec(SimulationSpec {
alpha_min: 5.0,
alpha_decay: 9.0,
velocity_decay: 3.0,
reheat_alpha: -2.0,
..SimulationSpec::default()
});
assert_eq!(t.spec().alpha_min, ALPHA_MIN_CEIL);
assert_eq!(t.spec().alpha_decay, ALPHA_DECAY_MAX);
assert_eq!(t.spec().velocity_decay, VELOCITY_DECAY_MAX);
assert_eq!(t.spec().reheat_alpha, 0.0);
let d = ForceSimulation::new(SimulationSpec::default());
let want = SimulationSpec::default();
assert_eq!(d.spec().alpha_min, want.alpha_min);
assert_eq!(d.spec().alpha_decay, want.alpha_decay);
assert_eq!(d.spec().velocity_decay, want.velocity_decay);
assert_eq!(d.spec().reheat_alpha, want.reheat_alpha);
}
#[test]
fn a_zero_alpha_min_cannot_wedge_the_sim_active_forever() {
let mut s = ForceSimulation::new(SimulationSpec {
alpha_min: 0.0,
..SimulationSpec::default()
});
s.sync(&graph_of(&["a", "b"], &[("e", "a", "b")]), &HashMap::new());
s.reheat_full();
s.settle(5000);
assert!(!s.is_active(), "a zero alpha_min must not leave the sim active forever");
assert!(!s.tick(), "and the next tick must still report settled");
}
#[test]
fn every_legal_schedule_settles_within_the_reduced_motion_budget() {
let hostile = [
("zero alpha_min", SimulationSpec { alpha_min: 0.0, ..Default::default() }),
("zero alpha_decay", SimulationSpec { alpha_decay: 0.0, ..Default::default() }),
("negative alpha_decay", SimulationSpec { alpha_decay: -1.0, ..Default::default() }),
("zero velocity_decay", SimulationSpec { velocity_decay: 0.0, ..Default::default() }),
("oversized reheat_alpha", SimulationSpec { reheat_alpha: 9.0, ..Default::default() }),
(
"all of them at once",
SimulationSpec {
alpha_min: 0.0,
alpha_decay: 0.0,
velocity_decay: -3.0,
reheat_alpha: 9.0,
..Default::default()
},
),
];
for (what, spec) in hostile {
let mut s = ForceSimulation::new(spec);
s.sync(
&graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]),
&HashMap::new(),
);
s.reheat_full();
s.settle(600);
assert!(!s.is_active(), "{what}: still active after settle(600)");
assert!(!s.tick(), "{what}: the next tick must still report settled");
}
}
#[test]
fn a_negative_velocity_decay_cannot_diverge_the_layout() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.set_spec(SimulationSpec { velocity_decay: -5.0, ..SimulationSpec::default() });
s.sync(
&graph_of(&["a", "b", "c"], &[("e1", "a", "b"), ("e2", "b", "c")]),
&seeded(&[("a", [0.0, 0.0]), ("b", [10.0, 0.0]), ("c", [0.0, 10.0])]),
);
s.reheat_full();
s.settle(2000);
for id in ["a", "b", "c"] {
let p = s.position(id).expect("node present");
assert!(p[0].is_finite() && p[1].is_finite(), "{id} diverged to {p:?}");
}
}
#[test]
fn the_alpha_decay_floor_leaves_headroom_under_the_settle_budget() {
let ticks = (ALPHA_MIN_FLOOR.ln() / (1.0 - ALPHA_DECAY_MIN).ln()).ceil();
assert!(ticks <= 600.0, "worst-case settle is {ticks} ticks, over the 600 budget");
}
#[test]
fn coincident_start_positions_do_not_produce_nan() {
let mut s = ForceSimulation::new(SimulationSpec::default());
s.sync(&graph_of(&["a", "b", "c"], &[]),
&seeded(&[("a", [0.0, 0.0]), ("b", [0.0, 0.0]), ("c", [0.0, 0.0])]));
s.reheat_full();
s.settle(200);
for id in ["a", "b", "c"] {
let p = s.position(id).unwrap();
assert!(p[0].is_finite() && p[1].is_finite(), "{id} = {p:?}");
}
}
}