Skip to main content

graph_explorer_layout/
force_sim.rs

1//! A persistent force simulation: node positions and velocities that survive
2//! across frames, so a graph change nudges the layout instead of recomputing it.
3//!
4//! Parameter names mirror `d3-force` deliberately — anyone who has tuned a
5//! force graph already knows what `alpha_decay` and `velocity_decay` do.
6//!
7//! Pure: no wasm, no GPU, and no time source. Ticks are driven by the caller,
8//! never self-scheduled, which is what makes the whole thing host-testable.
9
10use 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    /// When false the simulation never ANIMATES — but a Force-source
19    /// load/relayout still runs a synchronous settle so disabled hosts get a
20    /// finished static layout (see `settle_forced`).
21    #[serde(default = "yes")]
22    pub enabled: bool,
23    /// Per-tick multiplicative decay of `alpha`. The default reaches
24    /// `alpha_min` in ~300 ticks: `1 - 0.001^(1/300)`.
25    #[serde(default = "d_alpha_decay")]
26    pub alpha_decay: f32,
27    /// Below this the sim is considered settled and stops ticking, so a graph
28    /// at rest costs nothing.
29    #[serde(default = "d_alpha_min")]
30    pub alpha_min: f32,
31    /// Friction: velocity is multiplied by `1 - velocity_decay` each tick.
32    #[serde(default = "d_velocity_decay")]
33    pub velocity_decay: f32,
34    /// `alpha` restored on a graph change. Lower than 1 so a reveal nudges
35    /// rather than re-scrambling.
36    #[serde(default = "d_reheat_alpha")]
37    pub reheat_alpha: f32,
38    /// Barnes-Hut accuracy. 0 is exact and `O(n²)`; larger approximates more.
39    #[serde(default = "d_theta")]
40    pub theta: f32,
41    /// Ideal edge length, in world units.
42    #[serde(default = "d_link_distance")]
43    pub link_distance: f32,
44    /// Many-body strength. Negative repels.
45    #[serde(default = "d_charge")]
46    pub charge_strength: f32,
47    /// Pull toward the centroid. Without it, disconnected components drift
48    /// apart forever — the old (now-deleted) one-shot layout had no such force.
49    #[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    /// At most one node is held in place — the one the user is standing on.
87    fixed: Option<usize>,
88    /// Where the pinned node is held. Captured when the pin is set, so the
89    /// node returns to exactly that point rather than to wherever the last
90    /// integration happened to leave it.
91    pinned_at: Option<[f32; 2]>,
92    alpha: f32,
93    /// Monotonic count of nodes that have entered without a seed. Persisting it
94    /// on the struct — rather than restarting per `sync` — is what stops two
95    /// arrivals in *different* syncs from being handed the same spiral point
96    /// and stacking, which is the exact failure the spiral exists to prevent.
97    spawn_seq: usize,
98}
99
100/// Upper bound for `theta`. Beyond roughly 1.25 — and as low as 0.75 for a
101/// tight cluster with a distant outlier, which is precisely the shape an
102/// expanded aggregate makes — a cell that *contains* body `i` can pass the
103/// approximation test, and `i` then repels itself through its own cell's mass.
104/// Error stays within 21% at 1.0, so that is where the config boundary sits.
105/// Clamping here rather than in `walk` keeps the check off the hot path.
106const THETA_MAX: f32 = 1.0;
107
108/// Floor for `alpha_min`. `is_active` is `alpha >= alpha_min`, and `alpha`
109/// decays multiplicatively toward zero without ever reaching it — so at
110/// `alpha_min = 0` the predicate is permanently true and the simulation never
111/// settles. That is not a slow layout, it is three broken guarantees: the
112/// host's render loop runs a Barnes-Hut tick every frame at rest, the
113/// settle-detecting `!tick()` edge never fires (so a caller that rebuilds its
114/// scene there never does, and its target goes stale indefinitely), and
115/// `settle(max)` burns its entire budget and still reports active, which is
116/// what a reduced-motion caller uses to mean "snap, do not animate".
117///
118/// `1e-4` is the smallest value that still terminates promptly: from a full
119/// reheat at the default decay it is reached in ~400 ticks.
120const ALPHA_MIN_FLOOR: f32 = 1e-4;
121
122/// Ceiling for `alpha_min`, guarding the opposite failure. `alpha_min` above
123/// the alpha a reheat restores means `is_active` is false the moment it is
124/// set, so every reheat is silently a no-op and the simulation appears dead.
125/// `0.1` leaves headroom under the default `reheat_alpha` of 0.3. A host that
126/// also lowers `reheat_alpha` below its `alpha_min` can still arrange that
127/// no-op, but it fails safe — no motion, no CPU — rather than wedging.
128const ALPHA_MIN_CEIL: f32 = 0.1;
129
130/// Floor for `alpha_decay`. The schedule is `alpha -= alpha * alpha_decay`, so
131/// at zero `alpha` never decreases: the same never-settles failure as
132/// `alpha_min = 0`, reached from the other side.
133///
134/// The value is derived, not chosen. `settle(600)` is the reduced-motion
135/// contract — settle synchronously, then snap, with no motion afterwards — and
136/// it only holds if 600 ticks is actually enough. Worst case is a full reheat
137/// (`alpha = 1.0`, the ceiling `reheat_alpha` is clamped to) decaying to the
138/// smallest legal `alpha_min`, `ALPHA_MIN_FLOOR`, which takes
139/// `ln(1e-4) / ln(1 - alpha_decay)` ticks: 571 at `0.016`, against a budget of
140/// 600. Anything slower silently converts reduced motion into permanent
141/// motion. The price is that a host cannot ask for a settle longer than about
142/// 9.5 seconds at 60fps, which is a fair ceiling on how long a graph may wobble.
143const ALPHA_DECAY_MIN: f32 = 0.016;
144
145/// Ceiling for `alpha_decay`. At 1.0 `alpha` drops to zero in a single tick,
146/// which is already the fastest meaningful schedule; above it `alpha` goes
147/// negative, which is not a faster settle, just a nonsensical state.
148const ALPHA_DECAY_MAX: f32 = 1.0;
149
150/// Floor for `velocity_decay`, which enters the integrator as
151/// `friction = 1 - velocity_decay`. At zero there is no damping at all: a node
152/// keeps whatever velocity it accumulated and coasts in a straight line until
153/// `alpha` decays out, freezing it wherever it happened to be rather than at a
154/// force fixpoint. Below zero `friction > 1` and this explicit-Euler step
155/// *amplifies* velocity every tick — exponential divergence, NaN within the
156/// usual few hundred ticks. `0.01` guarantees energy actually leaves the
157/// system.
158const VELOCITY_DECAY_MIN: f32 = 0.01;
159
160/// Ceiling for `velocity_decay`. 1.0 is full damping — velocity is discarded
161/// each tick and the step degenerates to plain gradient descent, which is
162/// stable and occasionally what a host wants. Above it `friction` is negative
163/// and velocity flips sign every tick, so the layout oscillates instead of
164/// converging (and diverges outright past 2.0).
165const VELOCITY_DECAY_MAX: f32 = 1.0;
166
167/// Radius scale for an unseeded spawn, in world units.
168const SPAWN_RADIUS: f32 = 30.0;
169
170/// How many spiral rings a *late* arrival may use before the radius wraps.
171/// `SPAWN_RADIUS * sqrt(11.5) ≈ 102`, so an arrival lands within about one
172/// link distance of the centroid instead of at `30·√(global rank)` — which for
173/// a 500-node graph was 671 units, 5.6 link distances, and nowhere near the
174/// "near the centroid" the doc contract promises. The angle keeps advancing
175/// off the unwrapped sequence, so wrapping the radius never repeats a point.
176const 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    /// Bring a host-supplied spec inside the range the forces can actually
198    /// integrate, and the alpha schedule inside the range that can actually
199    /// terminate. Done once, where the spec is adopted, so no per-tick branch
200    /// pays for it — and here rather than at the call site because this is the
201    /// config boundary: `new` and `set_spec` are the only two ways a spec gets
202    /// in, and `set_spec` is reachable from a host in a single call.
203    ///
204    /// The alpha-schedule bounds are not taste. `is_active` is the sole thing
205    /// standing between a settled graph and a render loop that ticks forever,
206    /// and it is one comparison against two of these values; see the constants
207    /// for what each unbounded end breaks.
208    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        // Nothing above 1 is reachable by `reheat_full`, and a negative alpha is
215        // just an immediately-settled one wearing a disguise. Bounding it keeps
216        // `alpha` inside `[0, 1]` for every code path, which is what makes the
217        // tick-count arithmetic in the constants above true.
218        spec.reheat_alpha = spec.reheat_alpha.clamp(0.0, 1.0);
219        spec
220    }
221
222    /// Adopt the graph's topology, preserving position and velocity for every
223    /// id already present. New ids take `seed` when given — a revealed ghost
224    /// enters exactly where it was drawn, so physics continues from the
225    /// preview rather than teleporting. Does not reheat; callers decide that.
226    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(); // deterministic index assignment, so layouts reproduce
229
230        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        // A cold start is the one case where spiral rank *is* arrival count, so
235        // `30·√N` is the right spread and is kept exactly. Every later sync is
236        // an arrival into an existing layout, where that radius would be a
237        // function of where the id happens to sort rather than of anything
238        // physical, so those wrap instead.
239        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        // `Graph::edges()` iterates a HashMap, so its order differs between two
270        // independently built copies of the same graph. Float addition is not
271        // associative, so an unsorted `links` makes the spring accumulation —
272        // and therefore the whole settled layout — vary run to run. Sorting
273        // costs one pass per topology change and restores the reproducibility
274        // the sorted `ids` above was already reaching for. Equal pairs are
275        // interchangeable (identical contributions), so a stable tie is fine.
276        self.links.sort_unstable();
277
278        // Re-resolve the pin through the id, since indices just changed.
279        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    /// Deterministic spread for a node with no seed — a sunflower spiral, so
289    /// unseeded arrivals never stack on one point.
290    ///
291    /// `seq` drives the angle and `ring` the radius. On a cold start the two
292    /// are equal and this is the classic `30·√i` sunflower. For a later arrival
293    /// `ring` wraps (see [`ARRIVAL_RINGS`]) so the radius stays near the
294    /// centroid, while `seq` keeps climbing — the golden angle is an irrational
295    /// fraction of a turn, so a never-repeating angle is on its own enough to
296    /// guarantee two spawns never coincide.
297    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; // golden angle
300        [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    /// Re-energise after a graph change. Sets `alpha` unconditionally —
311    /// `enabled` is enforced solely by `is_active`/`tick`, so a disabled sim
312    /// still accumulates the alpha a later `settle_forced` needs to have
313    /// anything to act on (reduced-motion and physics-disabled hosts reheat
314    /// exactly like a live one, then force a settle instead of ticking).
315    pub fn reheat(&mut self) {
316        self.alpha = self.spec.reheat_alpha;
317    }
318
319    /// Re-energise from scratch (`alpha = 1`), for a cold start. See `reheat`
320    /// on why this does not gate on `enabled`.
321    pub fn reheat_full(&mut self) {
322        self.alpha = 1.0;
323    }
324
325    /// Hold `id` in place. `None` releases. Unknown ids are ignored.
326    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    /// Whether `alpha` is still above the settle threshold — `is_active`
334    /// without the `enabled` term, so a disabled sim can be asked whether it
335    /// was frozen mid-settle (and so needs a `settle_forced` to finish).
336    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    /// Tick to convergence, bounded by `max` so a pathological graph cannot
351    /// hang the caller. Used by reduced-motion, which snaps rather than
352    /// animating.
353    pub fn settle(&mut self, max: usize) {
354        for _ in 0..max {
355            if !self.tick() { return; }
356        }
357    }
358
359    /// `settle`, but runs even when `spec.enabled` is false — for hosts that
360    /// need a finished layout without any animation (reduced motion, or
361    /// physics disabled while the Force layout source is active). The flag is
362    /// restored unconditionally before returning; `tick` runs no host code,
363    /// so a scoped set/restore is panic-safe in practice.
364    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    /// Advance one step. Returns false once settled (`alpha < alpha_min`), so
372    /// a caller can stop scheduling frames when the graph is at rest.
373    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        // Many-body repulsion, Barnes-Hut approximated. Rebuilt each tick
386        // because every position moved.
387        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        // Link springs: pull each edge toward `link_distance`.
395        //
396        // Both the stiffness and the split are normalised by degree, exactly as
397        // d3-force does, and it is load-bearing rather than cosmetic. A flat
398        // 0.5/0.5 split gives a degree-k node a summed stiffness of k/2, and
399        // this integrator is explicit Euler: once `friction * k/2 > 2` the node
400        // overshoots further every tick and the layout diverges. Measured with
401        // a flat split: stable to degree 18, exponential blow-up by 20, NaN at
402        // 26 — and the demo's seed graph has a node with 26 neighbours.
403        // `1/min(deg)` bounds each node's total stiffness at ~1 instead, which
404        // is stable for any degree.
405        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            // Positive when too far apart, so both ends move toward each other.
416            let pull = (d - self.spec.link_distance) / d * strength;
417            // Share of the correction taken by `b`. The busier end moves less,
418            // so a hub stays put and its leaves swing around it.
419            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        // Weak centering, so disconnected components stay in frame.
428        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        // Integrate. Force is scaled by alpha so motion damps out with the
435        // schedule, and velocity is damped by friction — d3's Verlet-ish step.
436        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        // The pinned node exerts force but never moves. Restoring position (not
446        // just zeroing velocity) is required: `sync` recaptures `pinned_at`
447        // from the current position, so drift would ratchet the hold point.
448        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        // ~300 ticks from alpha 1 to alpha_min, which is d3's schedule.
495        assert!((s.alpha_decay - 0.0228).abs() < 1e-4);
496    }
497
498    #[test]
499    fn spec_rejects_unknown_fields() {
500        // `deny_unknown_fields` is what turns a host's typo into an error
501        // instead of a silently ignored setting.
502        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        // Warm start is the whole reason a one-shot layout could not be reused:
531        // without it, every reveal teleports the graph.
532        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        // A third node arrives — a reveal.
541        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        // Pinning fixes position, not influence — otherwise the graph would
579        // collapse through the node the user is standing on.
580        //
581        // `b` starts *off* both axes so this is a test about force rather than
582        // about motion. Asserting only that `b` moved, or that one coordinate
583        // grew, is satisfied by a model that slides the whole graph a constant
584        // step each tick with no repulsion at all. Repulsion from a fixed `a`
585        // has a signature that drift does not: it is purely radial, so `b` must
586        // travel straight out along the a->b ray and stay on it.
587        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        // `a` is at the origin, so the ray is just `b` normalised: (0.6, 0.8).
600        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    /// `sync` re-resolves the pin through the node id, because sorting the ids
608    /// reshuffles every index as soon as one is added or removed. That is the
609    /// subtlest logic in the warm start and it fails *silently* — a wrong index
610    /// holds the wrong node rather than panicking — so it needs its own test.
611    #[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")); // m is index 0 of [m, z]
616
617        // "a" sorts before "m", so m slides from index 0 to index 1.
618        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    /// A pinned node that leaves the graph must drop the pin, not hand it to
631    /// whichever survivor inherits its index — that would freeze an innocent
632    /// node the user never pinned.
633    #[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")); // index 0 of [m, q, z]
641
642        // m leaves; "q" inherits index 0. A pin tracked by index would hold q.
643        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        // Start far apart; the spring should reel them in.
749        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        // Pure repulsion pushes unlinked components apart forever. The
760        // centering force is what bounds them, and nothing else tests it.
761        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    /// `a_disconnected_component_does_not_drift_to_infinity` does not actually
773    /// test the centering force: with `center_strength = 0` its worst
774    /// coordinate is 495, well inside the 5000 bound, so it passes either way.
775    /// One alpha schedule is simply too short for the drift to show. Repeated
776    /// reheats are what separate the two: unbounded growth without centering
777    /// (measured 504 -> 972 -> 1269 -> 1504 over ten reheats, still climbing)
778    /// versus a fixed point with it (291 -> 269 -> 265 -> 265).
779    #[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        // The old one-shot layout was seeded for reproducibility; the sim must not
812        // regress that, or tests and screenshots become non-deterministic.
813        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    /// A flat 0.5/0.5 spring split gives a degree-k node a summed stiffness of
824    /// k/2, which explicit Euler cannot integrate: measured, it was stable to
825    /// degree 18, diverging by 20, and NaN at 26. The demo's seed graph has a
826    /// node with 26 neighbours, so this is the shipping configuration, not a
827    /// pathological one.
828    #[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    /// Degree normalisation must not simply freeze every spring: a star's
854    /// spokes should still be reeled in toward `link_distance`.
855    #[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    /// `Graph::edges()` iterates a HashMap, so two independently built copies
879    /// of one graph can enumerate edges in different orders. Without a sort in
880    /// `sync` the non-associative f32 spring sum diverges the layouts — this
881    /// reproduced 26 times in 60 runs before `links.sort_unstable()`.
882    #[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    /// Build a wide ring of seeded nodes, so the centroid is well defined and
902    /// far from any one member.
903    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    /// The spiral radius used to be `30·√(global rank)`, so a single unseeded
917    /// arrival into a 500-node graph landed 671 units out — 5.6 link distances,
918    /// nowhere near the "near the centroid" the doc contract promises.
919    #[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    /// Radius came from the id's position in the *sorted* id list, so the same
942    /// node entering the same graph spawned at r≈21 or r≈671 purely according
943    /// to how its id happened to sort. Nothing physical justifies that.
944    #[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        // "aaa-new" sorts before every "nNNNN"; "zzz-new" sorts after them all.
959        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    /// The spawn counter lives on the struct, not on the `sync` call. A
967    /// per-sync counter would hand every solitary arrival `seq = 0` and stack
968    /// them all on one point — the precise failure the spiral exists to avoid.
969    #[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()); // one arrival per sync
981            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        // And they must all still be near the centroid, not spiralling outward.
992        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    /// A cold start is the one case where spiral rank really is arrival count,
1000    /// and its `30·√N` spread is correct today. Bounding the *arrival* radius
1001    /// must not quietly shrink it.
1002    #[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(); // the outermost seat, ~670
1015        assert!(
1016            (max - expected).abs() < 10.0,
1017            "cold-start spread is {max}, expected ~{expected}; the sunflower was truncated"
1018        );
1019    }
1020
1021    /// Above theta ≈ 1.25 — and ≈ 0.75 for a tight cluster with a distant
1022    /// outlier, which is exactly the shape an expanded aggregate makes — a cell
1023    /// that contains body `i` can pass the approximation test, and `i` starts
1024    /// repelling itself through its own cell's mass. The spec boundary is where
1025    /// that gets excluded, so no per-tick branch has to.
1026    #[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        // Both entry points, since `set_spec` is the one a host reaches in a
1042        // single call and is therefore the one that has to be safe.
1043        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        // The defaults sit inside every bound and must survive untouched, or
1069        // the clamps would be silently retuning the shipped schedule.
1070        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        // `is_active` is `alpha >= alpha_min` and `alpha` decays multiplicatively
1081        // toward zero without reaching it, so an unclamped `alpha_min: 0` is
1082        // permanently true: the host's render loop would tick Barnes-Hut every
1083        // frame at rest, and the settle edge that callers rebuild their scene on
1084        // would never fire.
1085        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        // The guarantee `ALPHA_DECAY_MIN` is derived from. A reduced-motion
1099        // caller settles synchronously with `settle(600)` and then snaps; if 600
1100        // ticks is not enough the sim is still active afterwards and animates
1101        // every frame — "no visible motion" inverted into permanent motion, plus
1102        // 600 wasted Barnes-Hut ticks per reheat. Each spec below pushes a
1103        // different schedule value to the end that used to break it.
1104        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            // `reheat_full`, not `reheat`: alpha 1.0 is the worst case the
1128            // budget has to cover.
1129            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        // `friction = 1 - velocity_decay`, so a negative decay makes friction
1139        // exceed 1 and this explicit-Euler step amplifies velocity every tick
1140        // instead of damping it — unclamped, the positions reach NaN.
1141        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        // Pins the derivation in `ALPHA_DECAY_MIN`'s doc, so that lowering the
1158        // floor (or raising `ALPHA_MIN_FLOOR`) without redoing the arithmetic
1159        // fails here rather than silently in reduced motion.
1160        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}