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

nordisk/knut

.nornir/popviz-design.md

knut-popviz — design

Class: design · Function axis: f0 headless-core + thin-GUI-shell · f1 the shared knut-popsim model · f2 population gen to Arrow · f3 introspectable state · f4 the load driver · f5 the egui app. Strict: this file states only the shape we run. The system-wide why lives in reasoning.md; this function ships design + guide.

Reuse: popviz.uses = [falkordb, graphar, popsim] — see falkordb, graphar; it drives graphar-falkordb's FalkorDbLoader and wraps the shared knut-popsim model (f1 below).

f0 — headless core, thin GUI shell (the whole)

knut-popviz is a demonstrator + test harness for the bulk-insert path: it generates the synthetic 12-label Swedish-population graph as Arrow and loads it into FalkorDB through graphar-falkordb's FalkorDbLoader (UNWIND-batched Cypher — no falkordb_bulk_loader), while a desktop egui app shows a progress bar per label and live rows/s. crates/knut-popviz/src.

The whole design is one split:

The lib (PopvizState, vertex_batches, run_load) has no egui types and lives in the default build; the egui binary (feature gui) is a pure rendering of PopvizState. So the load logic and the introspection are unit-tested with no display, and the GUI is a thin shell over the same shared state (Arc<Mutex<PopvizState>>) the driver mutates.

Two laws hold it:

law statement enforced by
same introspection contract as the pipeline viewer PopvizState::state_json() is the external-state oracle; the GUI dumps it to $KNUT_POPVIZ_STATE ~every 300 ms state_json (lib.rs:132); pipelines-design.md#f6 shares the contract
--autostart for robots --autostart / KNUT_POPVIZ_AUTOSTART kicks the load off on the first frame — a headless run loads + writes the JSON with no click main.rs:57,:102

Rule: any new visible field MUST be added to state_json().

f1 — the shared knut-popsim model (knut-popsim)

The single source of truth for the demo population — a pure, wasm-clean, deterministic model, re-exported here (pub use knut_popsim::{geo, LABELS, EKONOMISKT_BISTAND_RATE}, lib.rs:20,:36) and shared with the facett wasm demo. crates/knut-popsim/src.

It has no FalkorDB, Arrow, tonic, reqwest, tokio, or filesystem deps (serde is the only optional one), so it compiles for wasm32-unknown-unknown. Every consumer converts the same PopGraph to its own world.

item signature where
Labels pub const LABELS: [(&str, i64); 12] — the 12 register labels A–L + scale-1.0 counts lib.rs:47
Tie rates PERSON_TIE_PROB: [f64; 12], PERSON_REL: [&str; 12] lib.rs:70,:87
Sample pub fn simulate(SimParams) -> PopGraph lib.rs:243
Schema pub fn schema() -> PopGraph (12 label nodes, 11 hub + 4 cross edges) lib.rs:288
Params pub struct SimParams { n_persons: u32, n_kommun: u16, seed: u64 } lib.rs:209
Graph pub struct PopGraph { nodes: Vec<PopNode>, edges: Vec<PopEdge> }; PopNode { label: u8, kommun: u16 }, PopEdge { src, dst, rel: &'static str } lib.rs:193,:114,:135
Geo pub mod geo (address_for, is_ekonomiskt_bistand, cluster_hotspots, CITIES, SWEDEN_BBOX) geo.rs

Laws: pure + wasm-clean (the build must pass for wasm32-unknown-unknown, default and serde); determinism is the contract (simulate uses a seeded LCG, no rand — same SimParams → byte-identical graph; geo uses splitmix64 seeded by node id, so a person always lands in the same place); generic graph, not backend-specific (no facett/FalkorDB types). serde (optional) derives on the model; PopEdge's &'static str rel interns on deserialize (intern_rel over ALL_RELS, lib.rs:153,:146).

f2 — population gen to Arrow (src/lib.rs)

vertex_batches(label, count, offset) (lib.rs:198) emits one label's vertices as Arrow RecordBatches of ≤CHUNK (25 000) rows. Every batch carries _gar_id (Int64) + idx + name; geo-bearing labels (Person/Address/Property, via label_has_geo, lib.rs:40) additionally carry lon/lat (Float64) + kommun (Utf8) from knut-popsim::geo::address_for, and Person carries the ekonomiskt_bistand (Boolean) flag from geo::is_ekonomiskt_bistand. Those columns flow verbatim through skade→Iceberg and FalkorDB.

f3 — the introspectable state (src/lib.rs)

PopvizState (lib.rs:79) is the whole observable truth, shared Arc<Mutex<…>> between the driver thread and the UI.

item shape where
PopvizState { phase, labels: Vec<LabelProgress>, elapsed_s, error, target } lib.rs:79
Phase Idle/Vertices/Indexes/Done/Failed (label) lib.rs:56,:65
LabelProgress { name, loaded, total } per label lib.rs:48
derived total/loaded/fraction/rows_per_s lib.rs:108–:122
state_json() the oracle: phase, target, total, loaded, fraction, rows_per_s, elapsed_s, per-label {loaded,total}, error, placeholders (e.g. "not started") lib.rs:132

state_json() lives in the default build (no gui), so the data-layer matrix asserts on it with no display. Same external-state contract as the pipeline viewer's $KNUT_VIEWER_STATE.

f4 — the load driver (src/lib.rs)

run_load(params: LoadParams, state: Arc<Mutex<PopvizState>>, wake) (lib.rs:250) is the blocking driver (the caller runs it on its own thread). It builds a multi-thread tokio runtime, computes per-label totals + _gar_id offsets, sets Phase::Vertices, and dispatches on LoadParams.target:

item shape where
LoadParams { url, graph, scale, drop, target } lib.rs:183
Target Falkor | Iceberg { warehouse } (Iceberg needs feature iceberg) lib.rs:164
Falkor path connect → (--drop DETACH DELETE barrier) → drive falkor_load_phase_order() = [Vertices, Indexes, Edges]: rayon parallel batch-gen (vertex_batches par_iter) → per-label concurrent insert_vertices_multi_par(…, concurrency, on_progress) → Phase::Indexes create_index per label (BEFORE edges) → Phase::Edges parallel edge-gen → insert_edges_keyed_par → Phase::Done load_falkor
Falkor multicore concurrency = available_parallelism(); the "sim" (Arrow gen) fans across cores with rayon, the "sink" (UNWIND-CREATE) dispatches up to concurrency queries in flight over the multiplexed link — fixing the ~1-core serial ingest. Same produced graph (_gar_id in the batch column; order-independent). Progress on_progress(rows) folds into buffer_unordered completion load_falkor
Falkor index-before-edges the _gar_id index is built after vertices, before edges (falkordb-design f3b): edge endpoint MATCHes then hit an index (O(1)) instead of a full O(V) label scan, so the edge phase is O(edges) not O(edges×vertices) — the server-side half of the ingest speedup the client-side pipelining can't reach. Order guarded DB-free by falkor_indexes_are_built_before_edges falkor_load_phase_order, load_falkor
Iceberg path one table per label into the warehouse via skade — no database, no container load_iceberg (lib.rs:374)

On the happy path it ends in Phase::Done; any error lands in Phase::Failed with error set. wake repaints the UI after each batch.

The Iceberg path is what lets the driver be robot-tested end to end with no external service: robot_iceberg_load_reaches_done_no_db (tests/robot.rs:147, #[cfg(feature = "iceberg")]) drives the real run_load into a tempfile warehouse and asserts the shared done-state bar — a genuine end-to-end drive, not synthetic state-poking, that still runs unattended in CI. See popviz-guide.md for the full tier matrix.

f5 — the egui app (src/main.rs [feature gui])

main.rs is a pure rendering of PopvizState: a progress bar per label, overall rows/s, a Start button, and the $KNUT_POPVIZ_STATE dump every ~300 ms. It spawns run_load on a worker thread and reads the shared state each frame. --autostart / KNUT_POPVIZ_AUTOSTART (main.rs:57) kicks the load on the first frame (main.rs:102); KNUT_POPVIZ_STATE (main.rs:59) sets the dump path. --start-db / KNUT_FALKORDB_AUTOSTART (main.rs:215) optionally boots a FalkorDB container first for the Falkor target.

Architecture map

Area Where
Population model knut-popsim (LABELS, geo, PopGraph, simulate/schema) — re-exported; the canonical wasm-clean model. f1
Population gen (Arrow) src/lib.rs (vertex_batches — _gar_id + cols, geo from knut-popsim::geo)
Introspectable state src/lib.rs (PopvizState, Phase, LabelProgress, state_json)
Load driver src/lib.rs (run_load — connect → per-label insert_vertices → create_index)
egui app src/main.rs [gui] — progress bars + --autostart + $KNUT_POPVIZ_STATE dump

Robot + test-matrix coverage (the UI is driven in code)

knut-popviz is a thin egui shell over PopvizState; state_json() is the whole UI contract, so the robot asserts JSON instead of pixels. Coverage (tests/robot.rs, tests/testmatrix_wiring.rs) — all reuse edda's nornir-robotui/nornir-testmatrix, none hand-rolled:

Tier Runs What it proves
synthetic default, container-free, CI phase progression idle→loading→indexing→done + the failure path
iceberg --features iceberg, container-free, CI a REAL run_load into a scratch skade/Iceberg warehouse reaches done (no DB, no Podman)
live FalkorDB #[ignore], needs $KNUT_FALKORDB_URL the real bulk-loader end-to-end against FalkorDB
matrix-emit (new) --features "iceberg testmatrix" the real run_load Ok arm emits a GREEN knut-popviz/run_load row into the nornir matrix end-to-end — closes the gap between "the wrapper forwards" and "the loader calls it"

The remote/introspection surface (state_json + the functional_status emit) is exercised in code, unattended — no human, no live DB — so the matrix stays honest on every CI run. See the load-path map: assets/popviz-loadpath.svg.