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

nordisk/facett

.nornir/systemmap-fluid-design.md

System Map — real liquid in the glass pipes — design

Class: design · Function axis: f0 what it is · f1 the state and the physics · f2 gravity from the camera · f3 why nothing can leave a pipe · f4 why nothing is created or destroyed · f5 the render · f6 the numbers · f7 the proofs · f8 how to turn it on. Strict: every claim points at a real file. The alternatives considered and rejected — including the library survey — are in systemmap-fluid-reasoning.md; this file names only what was built. rayon is FORBIDDEN — gatling only, and see f6 for when to use it.

f0 — what it is

The System Map's glass pipes can carry real liquid instead of the decorative dye column: liquid with volume, which falls toward gravity, pools in the glass tanks at the junctions, and cannot leave the tube. Tip the map with a vertical drag and it pours downhill; orbit it and the downhill pipes change.

It is opt-in at the library level — SystemMap::with_liquid(false) is still the constructor default, the map exactly as it was: same dye advection, same paint, same interaction surface, same state_json, byte for byte. facett-demo now opts in: the shipped "System Map" tab is demo_systemmap_liquid() (the deck in lib.rs, the declarative shell scene in scene_demo.rs, and the mega-matrix coverage cell), so the live gallery shows real liquid. This is safe because the liquid observables are additive — state_json keeps fluid/glass_alpha and merely ADDS liquid/tilt/gravity — and the full-deck goldens render tab 0 (graph), not this tab, so the promotion moved no committed golden. The plain dye demo_systemmap() is kept as the byte-identical sibling constructor, still covered by its own unit test and the system_map.png screenshot. (Promoted 2026-07-30; guarded RED-on-revert by the unit test the_shipped_deck_system_map_tab_now_carries_real_liquid.)

Piece Where
The solver facett-cfd/src/pipeflow.rs → facett_cfd::PipeFlow
The pane wiring + paint + containment oracle facett-graph3d/src/pipes.rs → SystemMap
The demo constructor (the SHIPPED tab) facett-demo/src/demo_systemmap.rs → demo_systemmap_liquid()
The cost facett-cfd/examples/pipeflow_bench.rs
A picture to look at facett-demo/tests/systemmap_liquid_shot.rs → tests/snapshots/system_map_liquid.png

Dependency direction is unchanged: facett-graph3d → facett-cfd, no cycle. pipeflow is a sibling module of fluid, not a replacement for it.

f1 — the state, and the physics on it

A pipe network is a graph of tubes, so the state is one-dimensional along each tube:

One step of PipeFlow::step(dt) is three passes:

  1. Gravity → velocity, per pipe, O(pipes): v ← (v + |g|·(ĝ·d̂_p)·dt) / (1 + drag·dt). ĝ·d̂_p is the whole idea — gravity projected onto that pipe's own direction. A vertical pipe feels all of it, a 45° pipe feels 1/√2 of it, a level pipe feels none, and a pipe sloping the other way feels the negation. Tip the map and every pipe re-derives its own forcing from one shared vector. The drag term is what lets the liquid settle instead of sloshing forever.
  2. Capacity-limited transport along each pipe (f3), then the double-buffer pointer swap. Two effects share one face flux: advection (bulk transport at the pipe's axial velocity) and levelling (a depth difference between neighbours drives liquid from the deeper to the shallower). Levelling is what makes liquid find its own level: without it a tube keeps whatever surface profile it was charged with for ever, which is both wrong and renders as hatching rather than as water.
  3. Junction exchange, per pipe, O(pipes): the downhill end pours into its node tank, and the uphill node tank feeds the pipe's upper end, both gated by |cos θ| between gravity and the pipe so a level pipe barely exchanges. This is what makes liquid genuinely travel through the map: out of one tube, into a component, on out of that component's other downhill tubes.

f2 — gravity comes from the camera, and yaw alone does nothing

Gravity is fixed in the room — straight down the screen. The map is what moves. So the host expresses screen-down back in the map's own coordinates and hands it to the solver (gravity_in_model_frame in facett-graph3d/src/pipes.rs):

g = ( sin t · cos ψ ,  −cos t ,  sin t · sin ψ )        ψ = orbit yaw, t = tilt

Read the two ends of it:

Tilt is a roll of the projected plane. SystemMap::projected rotates the projected points about the pane centre by t, which under Graph3D's orthographic projection is a rotation of the object about the view axis. The paint and the gravity therefore describe the same rigid motion, which is why the liquid falls where it looks like it should. At t == 0 the rotation is skipped entirely, so the dye-mode render is bit-identical.

The gravity vector is pushed into the solver by SystemMap::reaim_gravity on every message that can move the camera, not only on the step, so SystemMap::gravity(), the published state_json and what the solver integrates can never disagree — including between frames, which is what makes an observable like fall_moment comparable across a camera move.

f3 — why nothing can leave a pipe (the containment argument)

This is the requirement, so it is worth stating as algebra rather than as a test result.

The signed flux across face j|j+1 is computed entirely from the pre-step values — the Courant fraction of the upwind cell plus the levelling term — and then limited by half the source cell's liquid and half the destination cell's free space:

want = advection + levelling
Φ(j) = min( want, ½·src[j], ½·(1 − src[j+1]) )                         want ≥ 0

Applying dst[i] = src[i] − Φ(i) + Φ(i−1) then gives, with no further clamping:

Why halves. Advection alone gives every flux in a pipe one sign (the velocity is uniform), so no cell is ever drained by both faces at once and whole-cell limits would do. Levelling breaks that: a local maximum drains through both faces simultaneously. Halving each face's allowance is what keeps the bound inductive once two-sided draining is possible — the worst case above is exactly two faces each taking their half.

fill ≤ 1 is the containment statement. A cell holding more than its cross-section is liquid outside the pipe; there is nowhere else in the representation for it to be. The junction pass obeys the same discipline: each transfer is limited by the source's liquid and the destination's free space, so tanks cannot overflow either.

The Courant cap (|v·dt| ≤ 0.45) is what makes the bound inductive: below 1, the upwind transfer can never skip a cell.

The render inherits the guarantee. liquid_band draws a cell's liquid from the tube's downhill wall inward by 2·half_w·f, so the free edge sits at half_w·(1 − 2f) ∈ [−half_w, +half_w] — still between the two walls — precisely because f ∈ [0, 1]. The bore is BORE = 0.78 of the drawn pipe's half-width, so the liquid sits inside the glass rather than on it. The tanks get the same treatment: tank_liquid_poly puts every vertex on the glass sphere's own circle.

liquid_band deliberately does not clamp f. A defensive clamp there would let a broken solver produce a correct-looking picture; leaving the map honest is what makes the oracle in f7 a real end-to-end check of invariant → geometry → pixels.

f4 — why nothing is created or destroyed

Every movement of liquid in the entire step is a paired transfer of one computed scalar: the same x is subtracted from the source and added to the destination. The advection's telescoping sum and both junction transfers are written that way.

The consequence is that conservation does not depend on the clamps being careful. No clamp can create or destroy liquid — a clamp can only refuse a transfer, and a refused transfer moves nothing. Volume is conserved to f32 rounding through arbitrary tumbling, which is what the property test measures (f7).

PipeFlow::total_volume() (pipes + tanks) is the conserved quantity and is published in state_json, so a robot driver can assert it from outside.

f5 — the render

The glass-pipe and glass-tank paint is unchanged. Liquid mode adds:

All of it is built from pure kernels (pipe_half_width, pipe_wall_quad, liquid_band, tank_liquid_poly, tank_surface) that the containment oracles call too — one definition of where the liquid is, so the painter and the proof cannot drift apart.

One render-level trap, found by looking at a PNG and now held by a test. egui derives a filled path's vertex normals from its edges. A tank that is nearly empty (or exactly full) collapses its liquid polygon onto a single point, every edge goes to zero length, the normals go NaN, and the tessellator throws a bright streak clear across the pane — liquid apparently thousands of pixels outside its tank. tank_liquid_poly therefore sweeps a full tank as a proper circle with distinct angles and dedupes coincident vertices, returning nothing rather than a degenerate polygon; tank_surface refuses to stroke a zero-length chord for the same reason. The oracle in f7 asserts no polygon handed to the tessellator has a zero-length edge.

f6 — the numbers

facett-cfd/examples/pipeflow_bench.rs, cargo run --release -p facett-cfd --example pipeflow_bench. Host oden — AMD Ryzen Threadripper PRO 3975WX, 32 physical cores, SMT off, 499 GB RAM. NORNIR_MACHINE=oden. This is a NEW host row; never merge it with the constellation's loki · 12 cores tables. 48 cells/pipe, gravity re-aimed every step (a tumbling camera — the workload the pane actually runs).

Taken on a BUSY box — load average 76–111 on 32 cores, with an osm-katana planet conversion and other agents' builds running. Both columns are therefore upper bounds, and the gatling column is the one the load really moves (it is contending for cores to spawn threads on). Stated plainly so nobody reads these as quiet-box figures. The serial/gatling ratio at the sizes that matter is three orders of magnitude, so the conclusion does not depend on the noise.

nodes pipes cells serial ns/step gatling ns/step serial % of a 16.67 ms frame
8 11 528 1 771 468 536 0.011 %
32 43 2 064 6 712 1 322 963 0.040 %
128 171 8 208 27 941 627 285 0.168 %
512 683 32 784 138 489 673 466 0.831 %
2 048 2 731 131 088 592 520 758 062 3.555 %
8 192 10 923 524 304 3 102 735 1 461 018 18.616 %
32 768 43 691 2 097 168 12 275 432 3 605 470 73.653 %

The shipped System Map has 8 pipes. That is the first row: 1.8 µs per frame, 0.011 % of the 60 fps budget — about one part in nine thousand. Runs across the afternoon read 1.1–2.6 µs, so call it a couple of microseconds. It holds 60 fps with four orders of magnitude of headroom, and it would still hold 60 fps at 2 700 pipes (3.6 % of a frame). The solver is not the thing to watch on this pane; the tessellation is.

Read the gatling column. gatling_run is std::thread::scope with one thread spawn per core per call — a batch engine, and a 60 Hz tick is not a batch. It carries a roughly fixed dispatch cost of 0.5–1.3 ms regardless of how little work there is, so at the shipped size it costs ~260× the entire serial solve in pure thread churn. It only starts winning past ~250 000 cells, by which point either lane is already eating a fifth of the frame.

So PipeFlow::step picks its lane by that measurement (PARALLEL_MIN_CELLS = 500 000), and every size a UI pane will hold runs the serial kernel. The threshold sits deliberately above the measured crossover: the serial cost is predictable while the gatling cost swings by an order of magnitude with machine load (at load 111 the same 524 k-cell arm read 13.5 ms, i.e. worse than serial). The two lanes are bit-identical, asserted by test, so the choice can only change speed.

That is ROOT LAW #0 applied after measuring, which is what measuring is for: the law says CPU fan-out goes through gatling and never rayon — it does not say fan out when the dispatch costs more than the work.

f7 — the proofs (properties, not pictures)

Every assertion below was verified RED by mutation — the bug was introduced, the named test failed, and only that test failed.

In facett-cfd/src/pipeflow.rs:

Test What it pins Mutation that turns it RED
liquid_never_leaves_the_pipe_interior 0 ≤ fill ≤ 1 and tank ≤ cap, every step of 3 000, at 95 % charge under tumbling gravity of varying magnitude drop the downwind free-space limit from the flux
volume_is_conserved_under_a_long_tumble relative volume drift < 1e-3 across 2 400 steps while gravity rotates twice round make one junction transfer unpaired (+= x·0.98)
flow_direction_follows_gravity_projected_onto_each_pipe a slug runs downhill, and runs back when gravity flips replace `
axial_gravity_is_the_cosine_projection_onto_the_pipe vertical feels ` g
a_level_pipe_does_not_pump an already-level pipe perpendicular to gravity is bit-identical after 300 steps same
a_level_pipe_finds_its_own_level a lumpy surface in a level pipe flattens (spread → <5 %) while the centre of mass and the total do not move delete the levelling flux (the profile freezes) or make it non-conservative
liquid_settles_downhill_and_comes_to_rest fall_moment (volume-weighted distance along gravity) ends higher than it started, and the field goes static flip the sign of the axial gravity in the integrator
gatling_step_matches_serial_step_bit_for_bit the two lanes agree exactly, fills and tanks any aliasing or dropped pipe in the fan-out
step_dispatches_on_size_without_changing_the_answer step == step_serial below the threshold and == step_gatling above it wiring the dispatch to the wrong lane
same_seed_and_same_dt_give_the_identical_field no clock, no runtime RNG any wall-clock read
step_is_zero_alloc_double_buffer_ping_pong both buffer pointers and capacities survive 240 steps a stray collect() on the hot path

In facett-graph3d/src/pipes.rs — the same claims at the pane, through the camera:

One honest gap. Setting DRAG = 0 leaves every test above green. That is not a missing assertion so much as a true statement about the scheme: with drag gone the velocity just saturates at MAX_VEL, the Courant cap still clamps the transfer, and a packed pipe still comes to rest — so drag is a feel parameter (how fast it stops sloshing), not a correctness one. It is called out here rather than covered by a test that would only be pretending to constrain it.

f8 — how to turn it on

let map = SystemMap::new(nodes, edges, Layout3D::Force).with_liquid(true);

In the demo this swap is already made — the shipped "System Map" tab builds demo_systemmap_liquid() (lib.rs, scene_demo.rs, and the mega-matrix cell). The plain dye demo_systemmap() remains the sibling constructor (its own unit test + the system_map.png screenshot keep the dye render covered), and the full-deck goldens render tab 0, so the promotion left every committed golden byte-identical.

Driving it headlessly: