Skip to main content

docgen_core/
graphlayout.rs

1//! Deterministic force-directed layout for the `/graph/` view.
2//!
3//! Consumes the already-built [`crate::graph::LinkGraph`] (wikilink edges) plus
4//! per-doc `(slug, title)` metadata and runs a fixed-iteration spring layout in
5//! pure Rust to produce stable 2D node positions. No RNG, no clock, no
6//! `HashMap` iteration in the hot loop: index-ordered `Vec`s + a `BTreeMap` for
7//! slug lookups built once.
8//!
9//! ## Cross-platform determinism
10//! The only transcendental operations in the whole pipeline are the `sin`/`cos`
11//! in [`seed_point`]; everything after seeding is `+ - * / sqrt`, all of which
12//! are IEEE-754 correctly-rounded and therefore identical on every platform.
13//! `sin`/`cos`, however, are NOT guaranteed correctly-rounded across libm
14//! implementations (glibc/musl/macOS/Windows can differ by ~1 ULP). To keep the
15//! whole layout byte-identical across machines, [`seed_point`] quantizes its
16//! output to a fixed 2-decimal grid: a sub-ULP libm difference cannot survive
17//! that rounding, so the seeds — and hence the entire deterministic force loop
18//! fed only by correctly-rounded ops — are platform-independent. Given the same
19//! inputs, [`layout_graph`] produces byte-identical output across runs and
20//! machines (locked by the `golden_*` snapshot tests below).
21
22use std::collections::BTreeMap;
23
24use serde::Serialize;
25
26use crate::graph::LinkGraph;
27
28/// One laid-out graph node: a doc plus its 2D position and link degree.
29#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct GraphNode {
31    pub slug: String,
32    pub title: String,
33    pub x: f64,
34    pub y: f64,
35    pub degree: u32,
36}
37
38/// One graph edge (undirected for display; carries the directed slugs).
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40pub struct GraphDataEdge {
41    pub from: String,
42    pub to: String,
43}
44
45/// The serializable layout result embedded in `/graph/` and consumed by the island.
46#[derive(Debug, Clone, PartialEq, Serialize)]
47pub struct GraphData {
48    pub nodes: Vec<GraphNode>,
49    pub edges: Vec<GraphDataEdge>,
50}
51
52/// Fixed layout parameters. [`LayoutParams::default`] is the canonical, tested seed.
53#[derive(Debug, Clone, Copy)]
54pub struct LayoutParams {
55    /// Layout width (matches the original viewBox).
56    pub width: f64,
57    /// Layout height.
58    pub height: f64,
59    /// Inset kept clear of the clamp walls.
60    pub padding: f64,
61    /// Fixed iteration count (determinism: never time- or convergence-gated).
62    pub iterations: usize,
63}
64
65impl Default for LayoutParams {
66    fn default() -> Self {
67        LayoutParams {
68            width: 1420.0,
69            height: 760.0,
70            padding: 74.0,
71            iterations: 180,
72        }
73    }
74}
75
76/// Round to 2 decimals. Used both to quantize the libm-derived seed (for
77/// cross-platform determinism) and the final coordinates (for stable JSON).
78fn round2(v: f64) -> f64 {
79    (v * 100.0).round() / 100.0
80}
81
82/// Deterministic golden-angle spiral seed for node `i` of `total`, spread within
83/// the padded layout box. Purely a function of `(i, total, params)`.
84///
85/// The output is quantized to a 2-decimal grid so that the only non-correctly-
86/// rounded ops in the pipeline (`sin`/`cos`) cannot introduce cross-platform
87/// drift: a sub-ULP libm difference rounds away here, and every later op is
88/// IEEE-754 correctly-rounded. See the module-level determinism note.
89fn seed_point(i: usize, total: usize, params: &LayoutParams) -> (f64, f64) {
90    // Golden angle ~= 2.39996 rad.
91    let ga = std::f64::consts::PI * (3.0 - 5.0_f64.sqrt());
92    let angle = i as f64 * ga;
93    let radius_frac = ((i as f64 + 0.5) / total.max(1) as f64).sqrt();
94    let cx = params.width / 2.0;
95    let cy = params.height / 2.0;
96    let rmax = params.width.min(params.height) / 2.0 - params.padding;
97    // Quantize the libm-derived raw position before clamping so the seed is
98    // platform-independent (sin/cos are not guaranteed correctly-rounded).
99    let x = round2(cx + radius_frac * rmax * angle.cos());
100    let y = round2(cy + radius_frac * rmax * angle.sin());
101    (
102        x.clamp(params.padding, params.width - params.padding),
103        y.clamp(params.padding, params.height - params.padding),
104    )
105}
106
107/// Deterministic force-directed layout. `nodes_meta`: `(slug, title)` in a STABLE
108/// order (doc input order). `graph`: the already-built [`LinkGraph`]. Returns a
109/// [`GraphData`] with positions clamped inside
110/// `[padding, width-padding] x [padding, height-padding]`.
111pub fn layout_graph(
112    nodes_meta: &[(String, String)],
113    graph: &LinkGraph,
114    params: LayoutParams,
115) -> GraphData {
116    let n = nodes_meta.len();
117    if n == 0 {
118        return GraphData {
119            nodes: Vec::new(),
120            edges: Vec::new(),
121        };
122    }
123
124    // Slug -> index, built once. BTreeMap for stable, no-hash lookup.
125    let index_of: BTreeMap<&str, usize> = nodes_meta
126        .iter()
127        .enumerate()
128        .map(|(i, (s, _))| (s.as_str(), i))
129        .collect();
130
131    // Filtered edge list as index pairs: drop self-loops and edges touching a
132    // slug not in `nodes_meta` (ghosts can't be drawn). Edges are undirected for
133    // display, so collapse reciprocal pairs (a->b and b->a) into a single edge —
134    // otherwise degree and the drawn line would be double-counted. Index order
135    // preserved (the LinkGraph edges are already sorted by `build_link_graph`).
136    let mut edges_idx: Vec<(usize, usize)> = Vec::with_capacity(graph.edges.len());
137    let mut graph_edges: Vec<GraphDataEdge> = Vec::with_capacity(graph.edges.len());
138    let mut degree: Vec<u32> = vec![0; n];
139    let mut seen: std::collections::BTreeSet<(usize, usize)> = std::collections::BTreeSet::new();
140    for e in &graph.edges {
141        if e.from == e.to {
142            continue;
143        }
144        let (Some(&s), Some(&t)) = (index_of.get(e.from.as_str()), index_of.get(e.to.as_str()))
145        else {
146            continue;
147        };
148        // Canonical undirected key: insert returns false if already emitted.
149        let key = if s <= t { (s, t) } else { (t, s) };
150        if !seen.insert(key) {
151            continue;
152        }
153        edges_idx.push((s, t));
154        graph_edges.push(GraphDataEdge {
155            from: e.from.clone(),
156            to: e.to.clone(),
157        });
158        degree[s] += 1;
159        degree[t] += 1;
160    }
161
162    // Seed positions (deterministic spiral); keep seeds for the anchor pull.
163    let seeds: Vec<(f64, f64)> = (0..n).map(|i| seed_point(i, n, &params)).collect();
164    let (mut xs, mut ys): (Vec<f64>, Vec<f64>) = seeds.iter().copied().unzip();
165
166    // Fixed-iteration spring relaxation (faithful port of the original graph.ts
167    // constants), index-ordered throughout for determinism.
168    for _ in 0..params.iterations {
169        // 1) repulsion across every unordered pair.
170        for a in 0..n {
171            // `xs[a]`/`ys[a]` are mutated as `b` advances, so read them fresh
172            // each pair (split_at_mut gives disjoint, bounds-check-free halves).
173            let (left, right) = xs.split_at_mut(a + 1);
174            let (lefty, righty) = ys.split_at_mut(a + 1);
175            let xa = &mut left[a];
176            let ya = &mut lefty[a];
177            for k in 0..right.len() {
178                let xb = &mut right[k];
179                let yb = &mut righty[k];
180                let mut dx = *xb - *xa;
181                let mut dy = *yb - *ya;
182                if dx == 0.0 {
183                    dx = 0.01;
184                }
185                if dy == 0.0 {
186                    dy = 0.01;
187                }
188                let dist_sq = dx * dx + dy * dy;
189                let dist = dist_sq.sqrt();
190                let strength = (2800.0 / dist_sq).min(3.8);
191                let px = dx / dist * strength;
192                let py = dy / dist * strength;
193                *xa -= px;
194                *ya -= py;
195                *xb += px;
196                *yb += py;
197            }
198        }
199        // 2) attraction along edges (target length 132, factor 0.018).
200        for &(s, t) in &edges_idx {
201            let dx = xs[t] - xs[s];
202            let dy = ys[t] - ys[s];
203            let dist = (dx * dx + dy * dy).sqrt().max(1.0);
204            let pull = (dist - 132.0) * 0.018;
205            let px = dx / dist * pull;
206            let py = dy / dist * pull;
207            xs[s] += px;
208            ys[s] += py;
209            xs[t] -= px;
210            ys[t] -= py;
211        }
212        // 3) anchor pull toward the spiral seed + clamp into bounds.
213        for i in 0..n {
214            xs[i] += (seeds[i].0 - xs[i]) * 0.012;
215            ys[i] += (seeds[i].1 - ys[i]) * 0.006;
216            xs[i] = xs[i].clamp(params.padding, params.width - params.padding);
217            ys[i] = ys[i].clamp(params.padding, params.height - params.padding);
218        }
219    }
220
221    // Round to 2dp for byte-stable JSON across platforms + smaller files.
222    let nodes: Vec<GraphNode> = (0..n)
223        .map(|i| GraphNode {
224            slug: nodes_meta[i].0.clone(),
225            title: nodes_meta[i].1.clone(),
226            x: round2(xs[i]),
227            y: round2(ys[i]),
228            degree: degree[i],
229        })
230        .collect();
231
232    GraphData {
233        nodes,
234        edges: graph_edges,
235    }
236}
237
238/// Serialize [`GraphData`] to compact JSON (stable key order via serde derive).
239pub fn graph_data_json(data: &GraphData) -> String {
240    serde_json::to_string(data).unwrap()
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::model::LinkEdge;
247
248    fn lg(edges: &[(&str, &str)]) -> LinkGraph {
249        LinkGraph {
250            edges: edges
251                .iter()
252                .map(|(f, t)| LinkEdge {
253                    from: f.to_string(),
254                    to: t.to_string(),
255                })
256                .collect(),
257            backlinks: Default::default(),
258        }
259    }
260
261    // --- A-1 ---
262
263    #[test]
264    fn graphdata_serializes_with_stable_keys() {
265        let d = GraphData {
266            nodes: vec![GraphNode {
267                slug: "a".into(),
268                title: "A".into(),
269                x: 1.0,
270                y: 2.0,
271                degree: 3,
272            }],
273            edges: vec![GraphDataEdge {
274                from: "a".into(),
275                to: "b".into(),
276            }],
277        };
278        let j = graph_data_json(&d);
279        assert!(j.contains(r#""slug":"a""#));
280        assert!(j.contains(r#""x":1.0"#));
281        assert!(j.contains(r#""degree":3"#));
282        assert!(j.contains(r#""from":"a""#));
283        assert!(j.contains(r#""to":"b""#));
284        assert!(!j.contains(",\n"));
285        let _v: serde_json::Value = serde_json::from_str(&j).unwrap();
286    }
287
288    // --- A-2 ---
289
290    #[test]
291    fn degree_counts_each_incident_edge_undirected() {
292        let meta = vec![
293            ("a".into(), "A".into()),
294            ("b".into(), "B".into()),
295            ("c".into(), "C".into()),
296        ];
297        // a<->b reciprocal collapses to ONE undirected edge; a-c is a second.
298        let g = lg(&[("a", "b"), ("a", "c"), ("b", "a")]);
299        let d = layout_graph(&meta, &g, LayoutParams::default());
300        let deg = |s: &str| d.nodes.iter().find(|n| n.slug == s).unwrap().degree;
301        assert_eq!(deg("a"), 2);
302        assert_eq!(deg("b"), 1);
303        assert_eq!(deg("c"), 1);
304        // Exactly two undirected edges drawn (no duplicated reciprocal line).
305        assert_eq!(d.edges.len(), 2);
306    }
307
308    #[test]
309    fn reciprocal_links_collapse_to_single_undirected_edge() {
310        let meta = vec![("a".into(), "A".into()), ("b".into(), "B".into())];
311        let g = lg(&[("a", "b"), ("b", "a")]);
312        let d = layout_graph(&meta, &g, LayoutParams::default());
313        assert_eq!(d.edges.len(), 1);
314        assert_eq!(d.nodes.iter().find(|n| n.slug == "a").unwrap().degree, 1);
315        assert_eq!(d.nodes.iter().find(|n| n.slug == "b").unwrap().degree, 1);
316    }
317
318    #[test]
319    fn self_loops_are_ignored_for_degree_and_edges() {
320        let meta = vec![("a".into(), "A".into()), ("b".into(), "B".into())];
321        let g = lg(&[("a", "a"), ("a", "b")]);
322        let d = layout_graph(&meta, &g, LayoutParams::default());
323        assert_eq!(d.nodes.iter().find(|n| n.slug == "a").unwrap().degree, 1);
324        assert!(!d.edges.iter().any(|e| e.from == e.to));
325        assert_eq!(d.edges.len(), 1);
326    }
327
328    #[test]
329    fn edges_to_unknown_slugs_are_dropped() {
330        let meta = vec![("a".into(), "A".into())];
331        let g = lg(&[("a", "ghost")]);
332        let d = layout_graph(&meta, &g, LayoutParams::default());
333        assert!(d.edges.is_empty());
334        assert_eq!(d.nodes.iter().find(|n| n.slug == "a").unwrap().degree, 0);
335    }
336
337    // --- A-3 ---
338
339    #[test]
340    fn seed_positions_are_deterministic_and_in_bounds() {
341        let meta: Vec<_> = (0..7).map(|i| (format!("n{i}"), format!("N{i}"))).collect();
342        let g = lg(&[]);
343        let p = LayoutParams::default();
344        let d1 = layout_graph(&meta, &g, p);
345        let d2 = layout_graph(&meta, &g, p);
346        assert_eq!(d1, d2);
347        for n in &d1.nodes {
348            assert!(
349                n.x >= p.padding && n.x <= p.width - p.padding,
350                "x oob: {}",
351                n.x
352            );
353            assert!(
354                n.y >= p.padding && n.y <= p.height - p.padding,
355                "y oob: {}",
356                n.y
357            );
358        }
359    }
360
361    #[test]
362    fn single_node_sits_inside_bounds_without_nan() {
363        let meta = vec![("only".into(), "Only".into())];
364        let d = layout_graph(&meta, &lg(&[]), LayoutParams::default());
365        assert_eq!(d.nodes.len(), 1);
366        let n = &d.nodes[0];
367        assert!(n.x.is_finite() && n.y.is_finite());
368    }
369
370    #[test]
371    fn empty_graph_yields_empty_graphdata() {
372        let d = layout_graph(&[], &lg(&[]), LayoutParams::default());
373        assert!(d.nodes.is_empty() && d.edges.is_empty());
374        let _v: serde_json::Value = serde_json::from_str(&graph_data_json(&d)).unwrap();
375    }
376
377    // --- A-4 ---
378
379    #[test]
380    fn connected_pair_settles_near_target_edge_length() {
381        let meta = vec![("a".into(), "A".into()), ("b".into(), "B".into())];
382        let g = lg(&[("a", "b")]);
383        let p = LayoutParams::default();
384        let d1 = layout_graph(&meta, &g, p);
385        let d2 = layout_graph(&meta, &g, p);
386        assert_eq!(d1, d2);
387        let a = &d1.nodes[0];
388        let b = &d1.nodes[1];
389        let dist = ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt();
390        assert!(
391            dist > 40.0 && dist < 320.0,
392            "pair distance {dist} not in plausible band"
393        );
394        for n in &d1.nodes {
395            assert!(n.x.is_finite() && n.y.is_finite());
396        }
397    }
398
399    #[test]
400    fn all_positions_stay_in_bounds_after_forces() {
401        let meta: Vec<_> = (0..20)
402            .map(|i| (format!("n{i}"), format!("N{i}")))
403            .collect();
404        let owned: Vec<(String, String)> =
405            (1..15).map(|i| ("n0".into(), format!("n{i}"))).collect();
406        let mut e: Vec<(&str, &str)> = Vec::new();
407        for (f, t) in &owned {
408            e.push((f, t));
409        }
410        let g = lg(&e);
411        let p = LayoutParams::default();
412        let d = layout_graph(&meta, &g, p);
413        for n in &d.nodes {
414            assert!(n.x >= p.padding - 0.01 && n.x <= p.width - p.padding + 0.01);
415            assert!(n.y >= p.padding - 0.01 && n.y <= p.height - p.padding + 0.01);
416        }
417    }
418
419    #[test]
420    fn disconnected_components_do_not_collapse_to_a_point() {
421        let meta = vec![
422            ("a".into(), "A".into()),
423            ("b".into(), "B".into()),
424            ("c".into(), "C".into()),
425            ("d".into(), "D".into()),
426        ];
427        let g = lg(&[("a", "b"), ("c", "d")]);
428        let d = layout_graph(&meta, &g, LayoutParams::default());
429        for i in 0..d.nodes.len() {
430            for j in (i + 1)..d.nodes.len() {
431                let (p, q) = (&d.nodes[i], &d.nodes[j]);
432                assert!(
433                    (p.x - q.x).abs() > 0.01 || (p.y - q.y).abs() > 0.01,
434                    "coincident nodes"
435                );
436            }
437        }
438    }
439
440    /// Manual byte scan: any `.` followed by 3+ ASCII digits (over-precise coord).
441    fn has_three_decimals(j: &str) -> bool {
442        let b = j.as_bytes();
443        for i in 0..b.len() {
444            if b[i] == b'.' {
445                let mut k = i + 1;
446                while k < b.len() && b[k].is_ascii_digit() {
447                    k += 1;
448                }
449                if k - (i + 1) >= 3 {
450                    return true;
451                }
452            }
453        }
454        false
455    }
456
457    // --- Golden snapshot: pins the seed formula, every force constant, the
458    // iteration count, and the 2dp rounding. A drift in any of those (or a
459    // cross-platform f64 regression) flips this exact-bytes assertion. ---
460
461    #[test]
462    fn golden_three_node_layout_is_pinned_exactly() {
463        let meta = vec![
464            ("a".into(), "A".into()),
465            ("b".into(), "B".into()),
466            ("c".into(), "C".into()),
467        ];
468        let g = lg(&[("a", "b"), ("a", "c"), ("b", "a")]);
469        let j = graph_data_json(&layout_graph(&meta, &g, LayoutParams::default()));
470        assert_eq!(j, GOLDEN_THREE_NODE);
471    }
472
473    const GOLDEN_THREE_NODE: &str = "{\"nodes\":[{\"slug\":\"a\",\"title\":\"A\",\"x\":766.17,\"y\":358.41,\"degree\":2},{\"slug\":\"b\",\"title\":\"B\",\"x\":610.91,\"y\":459.58,\"degree\":1},{\"slug\":\"c\",\"title\":\"C\",\"x\":742.71,\"y\":189.89,\"degree\":1}],\"edges\":[{\"from\":\"a\",\"to\":\"b\"},{\"from\":\"a\",\"to\":\"c\"}]}";
474
475    #[test]
476    fn coordinates_are_rounded_for_stable_json() {
477        let meta: Vec<_> = (0..5).map(|i| (format!("n{i}"), format!("N{i}"))).collect();
478        let j = graph_data_json(&layout_graph(
479            &meta,
480            &lg(&[("n0", "n1")]),
481            LayoutParams::default(),
482        ));
483        assert!(!has_three_decimals(&j), "json has over-precise coords: {j}");
484    }
485}