Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/graph3d-dataset-design.md

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:

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:

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/10000 at 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

  1. dataset/generate is dominated by format! 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.
  2. Quiet-box numbers. Everything in §4 is contention-bounded; the authoritative run is the bencher's.
  3. 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.