GRAPH3D — the dataset that fills the sphere
nornir:class=design
Rickard, on the running demo: "Graph3d looks nicely now, please fill it with more data. Longer chains of data. The sphere only has data around the sphere surface." And then, after looking again: "graph 3d ADD MORE DATA in facett demo!! MUCH MORE!!!"
Three complaints, one root cause and one fix. Only the chosen design is named here.
1. Why the sphere was hollow — it was the DATA, not the layout
Layout3D::Force seeds every node on a Fibonacci sphere shell and then relaxes it with Fruchterman–Reingold. What pulls a node off that shell and into the interior is topology:
- a long path cannot lie on a shell — it has to fold inward;
- a cycle encloses something, and the relaxation puts nodes in what it encloses;
- a hub ends up in the middle of the things it holds.
The demo fed the pane pop_model::graph3d_nodes_edges(.., 140) — 140 nodes wired as depth-1 person→entity stars. No paths, no cycles, no hubs. There was nothing for the relaxation to fold, so nothing moved off the seed shell.
Measured (facett-graph3d/tests/dataset_volume.rs), as the share of nodes inside 60% of the cloud's p95 radius. A uniformly filled ball scores 0.216; a shell scores 0:
| nodes | depth-1 stars (what the demo had) | generated chains + rungs + hubs |
| 2000 | 0.000 | 0.583 |
| 20000 | 0.197 | 0.532 |
| 100000 | 0.100 | 0.252 |
The star control is always below the uniform-ball value and the generated cloud is always above it, so the assertion needs no tuned constant — and the control is run in the test file rather than described in a doc.
2. The generator
facett-graph3d/src/dataset.rs. A GraphSpec → GeneratedGraph, pure and deterministic (a fixed SplitMix64 keyed on seed; no wall clock, no RNG crate, no hash iteration), so demo data is byte-identical run to run and machine to machine.
| ingredient | knob | what it buys |
| backbones | chains × chain_len |
DEPTH. A chain of 139 cannot lie on a shell. chain_len is floored at MIN_CHAIN_LEN (24) at every size, so "longer chains" is a structural invariant, not a property of one fixture |
| side branches | branch_every, branch_len |
degree variance + more depth, hanging off the spine |
| rungs | rungs |
cross-links between neighbouring backbones. This is the one that fills the middle: a forest of chains still relaxes onto a shell because nothing holds its interior; a cycle does |
| hubs | hubs |
a few very-high-degree collectors reaching across all chains — degree spans 1 … dozens instead of 1 … 2 |
GraphSpec::sized(n) derives a balanced set for a node budget: chain count grows with the budget while chain_len grows only as the cube root, so a bigger dataset gets more long chains rather than a few absurd ones.
Measured graph diameter (BFS double sweep over the real edge list, not asserted from the spec): 24–31, against 3 for the star control. It is lower than chain_len because the rungs are shortcuts — that is a small-world effect and it is the correct behaviour; the chains are still there to trace with your eye.
3. MUCH MORE — and what made it safe
The demo default is GRAPH3D_DEFAULT_NODES = 100000 nodes / ~132000 edges — 714× the 140 it held. $FACETT_GRAPH3D_NODES overrides it, so 300000 is one env var away and the generator scales (it stays deterministic and stays solid).
Two things had to be true first.
The instanced GPU cloud had to be armed. It is, as of 2026-07-22 (facett-demo's gpu feature is default-on and main.rs calls install_cloud_renderer). The cloud draws node discs + edge filaments as GPU instances, so node count stops being the constraint that graph3d-rotate-perf-design.md measured as a 33.75 ms/frame single-core tessellation wall at 30000 nodes.
…and the CPU FALLBACK had to be bounded. draw_cloud is deliberately fail-safe: with no RenderState, on a GPU-less box, or on wasm it returns false and the CPU painter draws instead. Unbounded, that turns "raise the data" into "freeze the pane" — trading a hollow cloud for a stuck one. So the fallback now carries a budget, exactly like the OSM-2D point layer:
CPU_PAINT_MAX_NODES = 12000,CPU_PAINT_MAX_EDGES = 24000;- above it the CPU pass paints a depth-strided sample, so per-frame tessellation is bounded by the budget rather than by the dataset;
- below it the stride is
1and the paint is byte-identical — which is what keeps every existing snapshot golden valid (they are all small graphs); - the strides are published as
state_json.cpu_paint_stride, so the degradation is observable data rather than an invisible quality loss.
Proven by tests/cpu_paint_budget.rs: a 100000-node cloud tessellates no more than ~3× a budget-sized one, and the sample is still a full-value sample (> half the budget), not a blank.
The startup cost had to come down too. Fruchterman–Reingold convergence is a property of the step schedule, not of n: the 100 relaxation passes were chosen for graphs of a few hundred and then paid, unchanged, by graphs of a hundred thousand. Above LARGE_GRAPH_ITER_N = 20000 the budget drops to LARGE_GRAPH_ITERS = 30, which reaches the same structure (the volume table above is measured with it) for a fraction of the wall clock. Strictly below the threshold nothing changes, so every existing golden and determinism test is byte-identical.
4. The numbers
Host oden — Threadripper PRO 3975WX, 32 physical cores, SMT off, 499 GB RAM, NVIDIA RTX 4090 24 GB (driver 595.71.05). NORNIR_MACHINE=oden; a NEW host row, never merged with the loki · 12 cores tables. Plain ns.
⚠ CONTAMINATED, recorded rather than quietly discarded. Every arm below was taken with sibling agents building in the same checkout — load average 86–133 on 32 cores. These are min-of-N (min is the robust estimator under contention), and they are therefore upper bounds on the true cost, in places by a wide margin: the criterion run taken at load 131 read
frame/project_all/10000at 31795000 ns against a min-of-N of 1527043 ns at 30000 nodes — a 20× contention artefact on a smaller input. The Loki/oden bencher's quiet-box re-run on push is authoritative.
| arm | 30000 nodes | 100000 nodes | 300000 nodes |
dataset/generate (once) |
3916632 | 14183065 | 40301758 |
layout/force (once, at construction) |
505704223 | 863001860 | 1738836418 |
frame/project_all (EVERY frame) |
1527043 | 3771146 | 5817063 |
Only the third is in the frame budget. At the 100000-node default, project_all is 3771146 ns — 3.8 ms of a 16.7 ms 60 fps budget under load average 90, with the GPU carrying the actual draw. The one-off layout is 863 ms, which is what a host waits once at startup.
The bench lives in facett-graph3d/benches/heavy_graph3d.rs (dataset/generate, layout/force, frame/project_all, and frame/gpu_scene_at under the wgpu feature), swept 10k → 100k with Throughput::Elements; GRAPH3D_BENCH_N overrides the sweep.
5. Open
dataset/generateis dominated byformat!for node labels (14 ms at 100000). Labels are only ever read for the handful of nodes with a visible label; a lazy label (index → string on demand) would make generation nearly free.- Quiet-box numbers. Everything in §4 is contention-bounded; the authoritative run is the bencher's.
- The real GPU ceiling is unmeasured. §4 measures the CPU side. Finding where the instanced lane actually saturates on the 4090 needs a live-window frame timing harness, not a headless one.