Skip to main content

abstracttui_graph/layout/
force.rs

1//! The force pass (v1.5): the knowledge-graph path.
2//!
3//! An alpha-cooled placement ACT, not an animation state: repulsion
4//! between all card pairs (scaled by card radii), springs along edges,
5//! an optional rank bias along a flow axis. The pass runs a bounded
6//! iteration budget on demand, freezes as soon as the system settles,
7//! and is bit-deterministic under a fixed seed + budget: the arithmetic
8//! is restricted to IEEE-exact operations (+ - * / sqrt — no
9//! transcendentals), iteration order is input order, and the PRNG is a
10//! hand-rolled splitmix64 stream.
11//!
12//! Zero-idle is the CALLER's story: cache the returned [`Layout`] and
13//! re-render from it; re-run the pass only on graph mutation or an
14//! explicit re-layout request. Rendering never re-simulates.
15
16use crate::desc::{Direction, GraphDesc};
17
18use super::geom::{clip_border, self_loop};
19use super::resolve::Resolved;
20use super::{assemble, EdgeLayout, Layout, NodeLayout};
21
22use abstracttui::base::Rect;
23
24/// Iteration budget for [`force`] (contract spelling from the 0440
25/// item; a plain count — one iteration is one full force/integrate
26/// step over all nodes).
27pub type IterationBudget = u32;
28
29/// Options for [`force`]. Author-written, shape-stable: construct via
30/// functional record update over `Default` (ADR-0003 §2):
31///
32/// ```
33/// use abstracttui_graph::{Direction, ForceOpts};
34/// let opts = ForceOpts {
35///     seed: 7,
36///     rank_bias: Some(Direction::TopDown),
37///     ..Default::default()
38/// };
39/// # let _ = opts;
40/// ```
41#[derive(Clone, Debug, PartialEq)]
42pub struct ForceOpts {
43    /// PRNG seed for the initial scatter. Same seed + same budget +
44    /// same graph = identical `Layout` (golden-pinned).
45    pub seed: u64,
46    /// Iteration bound (default 256). The pass may freeze earlier when
47    /// the system settles; it never runs longer.
48    pub budget: IterationBudget,
49    /// Optional flow tendency: edges are nudged so targets sit
50    /// downstream of sources along the given direction's flow axis.
51    /// `None` (default) lays the graph out isotropically.
52    pub rank_bias: Option<Direction>,
53    /// Characteristic spacing in cells (default 14): edge rest length
54    /// and repulsion range both derive from it, plus the two cards'
55    /// radii.
56    pub spacing: f64,
57}
58
59impl Default for ForceOpts {
60    fn default() -> Self {
61        ForceOpts {
62            seed: 0xAB57_AC77,
63            budget: 256,
64            rank_bias: None,
65            spacing: 14.0,
66        }
67    }
68}
69
70/// Displacement threshold under which the system counts as settled.
71const SETTLE_EPSILON: f64 = 0.05;
72/// Spring constant (pull per cell of length error).
73const SPRING: f64 = 0.08;
74/// Rank-bias correction factor per cell of shortfall.
75const BIAS: f64 = 0.05;
76
77/// Force-directed layout: bounded, seeded, alpha-cooled; freezes on
78/// settle. Computes no hierarchy — every node reports rank 0, cycles
79/// need no breaking (springs are direction-blind), and `broken` is
80/// false on every edge.
81pub fn force(desc: &GraphDesc, opts: &ForceOpts) -> Layout {
82    run(desc, opts).0
83}
84
85/// The pass plus the number of iterations actually run (internal:
86/// unit tests pin the freeze-on-settle behavior through this).
87pub(crate) fn run(desc: &GraphDesc, opts: &ForceOpts) -> (Layout, u32) {
88    let resolved = Resolved::new(desc);
89    let notes = resolved.notes();
90    let n = resolved.len();
91    if n == 0 {
92        return (assemble(Vec::new(), Vec::new(), notes), 0);
93    }
94
95    let spacing = if opts.spacing > 0.0 {
96        opts.spacing
97    } else {
98        14.0
99    };
100    let radius: Vec<f64> = resolved
101        .sizes
102        .iter()
103        .map(|s| (f64::from(s.w) + f64::from(s.h)) / 4.0)
104        .collect();
105
106    // Seeded scatter in a box sized by the total card area (direct
107    // rectangular draw — no polar coordinates, no transcendentals).
108    let mut rng = SplitMix64::new(opts.seed);
109    let area: f64 = resolved
110        .sizes
111        .iter()
112        .map(|s| f64::from(s.w) * f64::from(s.h))
113        .sum();
114    let side = area.sqrt() * 2.0 + spacing;
115    let mut px: Vec<f64> = Vec::with_capacity(n);
116    let mut py: Vec<f64> = Vec::with_capacity(n);
117    for _ in 0..n {
118        px.push(rng.next_f64() * side);
119        py.push(rng.next_f64() * side);
120    }
121
122    let (bias_vertical, bias_sign) = match opts.rank_bias {
123        Some(d) => (d.is_vertical(), if d.is_reversed() { -1.0 } else { 1.0 }),
124        None => (true, 0.0),
125    };
126
127    let mut ran = 0u32;
128    let mut dx = vec![0.0f64; n];
129    let mut dy = vec![0.0f64; n];
130    for it in 0..opts.budget {
131        ran = it + 1;
132        let t = 1.0 - f64::from(it) / f64::from(opts.budget.max(1));
133        let step_cap = spacing * (0.1 + 0.9 * t);
134        dx.iter_mut().for_each(|v| *v = 0.0);
135        dy.iter_mut().for_each(|v| *v = 0.0);
136
137        // Repulsion between all pairs, scaled so bigger cards keep more
138        // distance. Coincident points separate along a deterministic,
139        // index-derived nudge.
140        for i in 0..n {
141            for j in (i + 1)..n {
142                let (mut ex, mut ey) = (px[i] - px[j], py[i] - py[j]);
143                let mut d2 = ex * ex + ey * ey;
144                if d2 < 1e-4 {
145                    ex = 0.01 * ((i + 1) as f64);
146                    ey = 0.013 * ((j + 1) as f64);
147                    d2 = ex * ex + ey * ey;
148                }
149                let k = spacing + radius[i] + radius[j];
150                let f = (k * k) / d2;
151                dx[i] += ex * f / 8.0;
152                dy[i] += ey * f / 8.0;
153                dx[j] -= ex * f / 8.0;
154                dy[j] -= ey * f / 8.0;
155            }
156        }
157
158        // Springs along edges toward their rest length.
159        for e in &resolved.edges {
160            let (ex, ey) = (px[e.to] - px[e.from], py[e.to] - py[e.from]);
161            let d = (ex * ex + ey * ey).sqrt().max(1e-3);
162            let rest = spacing + radius[e.from] + radius[e.to];
163            let f = (d - rest) * SPRING;
164            let (ux, uy) = (ex / d, ey / d);
165            dx[e.from] += ux * f;
166            dy[e.from] += uy * f;
167            dx[e.to] -= ux * f;
168            dy[e.to] -= uy * f;
169        }
170
171        // Rank bias: pull each edge's target downstream of its source
172        // along the flow axis when it falls short. Accumulates into the
173        // displacement like every other force, so the step cap and the
174        // settle measurement govern it too.
175        if bias_sign != 0.0 {
176            for e in &resolved.edges {
177                let want = (spacing + radius[e.from] + radius[e.to]) * 0.6;
178                let actual = if bias_vertical {
179                    (py[e.to] - py[e.from]) * bias_sign
180                } else {
181                    (px[e.to] - px[e.from]) * bias_sign
182                };
183                let short = want - actual;
184                if short > 0.0 {
185                    let adj = short * BIAS * bias_sign;
186                    if bias_vertical {
187                        dy[e.to] += adj;
188                        dy[e.from] -= adj;
189                    } else {
190                        dx[e.to] += adj;
191                        dx[e.from] -= adj;
192                    }
193                }
194            }
195        }
196
197        // Integrate with the cooled step cap; freeze on settle.
198        let mut max_step = 0.0f64;
199        for i in 0..n {
200            let len = (dx[i] * dx[i] + dy[i] * dy[i]).sqrt();
201            if len > 1e-12 {
202                let step = len.min(step_cap);
203                px[i] += dx[i] / len * step;
204                py[i] += dy[i] / len * step;
205                max_step = max_step.max(step);
206            }
207        }
208        if max_step < SETTLE_EPSILON {
209            break;
210        }
211    }
212
213    // Snap card centers to cells.
214    let mut nodes = Vec::with_capacity(n);
215    for g in 0..n {
216        let size = resolved.sizes[g];
217        let x = (px[g] - f64::from(size.w) / 2.0).round() as i32;
218        let y = (py[g] - f64::from(size.h) / 2.0).round() as i32;
219        nodes.push(NodeLayout::new(
220            resolved.id(desc, g),
221            Rect::new(x, y, size.w, size.h),
222            0,
223        ));
224    }
225
226    let center = |r: Rect| {
227        (
228            f64::from(r.x) + f64::from(r.w) / 2.0,
229            f64::from(r.y) + f64::from(r.h) / 2.0,
230        )
231    };
232    let mut edge_out: Vec<(usize, EdgeLayout)> = Vec::new();
233    for e in &resolved.edges {
234        let (ra, rb) = (nodes[e.from].rect, nodes[e.to].rect);
235        let waypoints = vec![clip_border(ra, center(rb)), clip_border(rb, center(ra))];
236        let d = &desc.edges[e.desc_index];
237        edge_out.push((
238            e.desc_index,
239            EdgeLayout::new(d.from.clone(), d.to.clone(), e.desc_index, waypoints),
240        ));
241    }
242    for &(g, desc_index) in &resolved.self_edges {
243        let d = &desc.edges[desc_index];
244        edge_out.push((
245            desc_index,
246            EdgeLayout::new(
247                d.from.clone(),
248                d.to.clone(),
249                desc_index,
250                self_loop(nodes[g].rect),
251            ),
252        ));
253    }
254    edge_out.sort_by_key(|(i, _)| *i);
255    let edges = edge_out.into_iter().map(|(_, e)| e).collect();
256    (assemble(nodes, edges, notes), ran)
257}
258
259/// splitmix64: tiny, seedable, std-only, bit-stable everywhere.
260struct SplitMix64 {
261    state: u64,
262}
263
264impl SplitMix64 {
265    fn new(seed: u64) -> Self {
266        SplitMix64 { state: seed }
267    }
268
269    fn next_u64(&mut self) -> u64 {
270        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
271        let mut z = self.state;
272        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
273        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
274        z ^ (z >> 31)
275    }
276
277    /// Uniform in [0, 1) from the top 53 bits.
278    fn next_f64(&mut self) -> f64 {
279        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::desc::GraphDesc;
287
288    #[test]
289    fn single_node_settles_immediately() {
290        let desc = GraphDesc::new().node("only", 6, 3);
291        let (_, ran) = run(&desc, &ForceOpts::default());
292        assert_eq!(ran, 1, "no forces, first iteration settles");
293    }
294
295    #[test]
296    fn pair_freezes_well_under_a_large_budget() {
297        let desc = GraphDesc::new()
298            .node("a", 6, 3)
299            .node("b", 6, 3)
300            .edge("a", "b");
301        let opts = ForceOpts {
302            budget: 10_000,
303            ..Default::default()
304        };
305        let (_, ran) = run(&desc, &opts);
306        assert!(
307            ran < 2_000,
308            "spring/repulsion equilibrium settles early, ran {ran}"
309        );
310    }
311
312    #[test]
313    fn prng_streams_are_seed_stable_and_in_range() {
314        let mut rng = SplitMix64::new(42);
315        let mut rng2 = SplitMix64::new(42);
316        let a: Vec<u64> = (0..8).map(|_| rng.next_u64()).collect();
317        let b: Vec<u64> = (0..8).map(|_| rng2.next_u64()).collect();
318        assert_eq!(a, b, "same seed, same stream");
319        let mut rng3 = SplitMix64::new(43);
320        assert_ne!(a[0], rng3.next_u64(), "different seed, different stream");
321        for _ in 0..64 {
322            let f = rng3.next_f64();
323            assert!((0.0..1.0).contains(&f));
324        }
325    }
326}