facett-demo/src/pop_model.rs
//! **The population query / LOD layer** over the canonical `knut-popsim` model —
//! the thin "graph-mem database that works in wasm" the showcase queries.
//!
//! `knut_popsim::simulate()` hands back a generic [`PopGraph`](knut_popsim::PopGraph)
//! (people + their entity ties, deterministic in a seed). This module is the
//! **query + level-of-detail (LOD) + colouring** layer the demo's population tabs
//! drive on top of it — it never invents a second model, it folds/filters the one
//! `PopGraph`:
//!
//! * [`LABEL_COLORS`] — the 12-label palette (identity / asset / financial /
//! enforcement hues) so the graph reads as a colour-coded data model.
//! * [`PopQuery`] — the live filter state (scale, active labels, active relations,
//! colour mode, seed) the toggle buttons drive.
//! * [`PopStats`] — counts (per label, total nodes/edges) **at the requested
//! scale** (1M → 100M), computed analytically from the per-label tie rates so we
//! report 11-million-person numbers without ever materialising 400M nodes.
//! * the three **views** ([`PopView`]) the showcase renders, each lowered to a
//! [`GraphModel`] for the shared L0 render kernel, with an explicit, surfaced
//! visible-node cap:
//! - [`full_model`] — the population, LOD-sampled to the cap (force-clustered).
//! - [`debt_model`] — the debt sub-graph (Person → OWES → Debt → ESCALATES_TO
//! → KfmCase), the enforcement story.
//! - [`kommun_model`] — the per-kommun aggregation (one node per kommun, sized
//! by population, tied by shared-population edges) — the full-scale meta view.
//!
//! Everything here is pure + deterministic (seeded), wasm-clean (no deps beyond
//! `knut_popsim` + `facett_graphview`'s model), and exposes counts as data.
use facett_graphview::{Color, Decorations, GraphEdge, GraphModel, GraphNode, Pos};
use knut_popsim::{
self as pop, LABELS, PERSON, PERSON_REL, PERSON_TIE_PROB, PopGraph, SimParams, geo,
};
/// The canonical 11-million-person scale (the default n_persons the field opens on).
pub const CANONICAL_PERSONS: u64 = 11_000_000;
/// The scale slider's lower bound (1 million people).
pub const SCALE_MIN: u64 = 1_000_000;
/// The scale slider's upper bound (100 million people — bigger than Sweden, the
/// stress end of the range; rendered purely via aggregation/LOD, never node-by-node).
pub const SCALE_MAX: u64 = 100_000_000;
/// The hard cap on how many `Person` nodes we ever actually *generate* (call
/// `simulate` on) for the instance views — above this we sample a representative
/// slice and aggregate the rest, so memory stays bounded native AND on wasm.
pub const GEN_CAP: u32 = 6_000;
/// The **native** generation cap — far higher than the wasm [`GEN_CAP`], because
/// the native path generates the sample **in parallel across all cores** (the
/// znippy **gatling** fork-join pool, `gatling_for_each`, driven by
/// [`sample_parallel`]) so it can afford a much bigger, denser, more beautiful
/// population than the single-threaded wasm fallback.
/// Native therefore visibly does MORE work than wasm (bigger sample, smoother web).
#[cfg(not(target_arch = "wasm32"))]
pub const GEN_CAP_NATIVE: u32 = 60_000;
/// The hard cap on **rendered** nodes for any single instance view (the LOD budget,
/// surfaced in state_json as "rendering N of TOTAL"). Far below the generated set so
/// the L0 kernel stays snappy and the picture stays legible.
pub const RENDER_CAP: usize = 1_400;
/// The 12-label palette — identity / asset / financial / enforcement hues, parallel
/// to [`knut_popsim::LABELS`]. Distinct enough that a coloured graph reads as a
/// colour-coded register at a glance.
pub const LABEL_COLORS: [Color; 12] = [
Color::rgb(0x4f, 0xc3, 0xf7), // Person — cyan (the hub)
Color::rgb(0x81, 0xc7, 0x84), // Address — green (assets)
Color::rgb(0xff, 0xb7, 0x4d), // Company — amber
Color::rgb(0xff, 0x8a, 0x65), // Debt — orange (financial)
Color::rgb(0xff, 0xa7, 0x26), // Loan — deep amber
Color::rgb(0xb0, 0xbe, 0xc5), // TaxRecord — slate
Color::rgb(0xe5, 0x73, 0x73), // KfmCase — red (enforcement)
Color::rgb(0xd5, 0x4f, 0x4f), // PoliceRecord— deep red
Color::rgb(0xba, 0x68, 0xc8), // FkBenefit — purple (welfare)
Color::rgb(0x9c, 0x6a, 0xde), // AfRecord — violet (work)
Color::rgb(0xa1, 0x88, 0x7f), // Vehicle — taupe
Color::rgb(0x4d, 0xb6, 0xac), // Property — teal (assets)
];
/// How node colour is chosen across a view.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorMode {
/// Colour each node by its label (the 12-colour register palette).
ByLabel,
/// Colour each node by its kommun (evenly-spaced hues around the wheel).
ByKommun,
/// Colour each node by its **debt degree** on a cool→hot heat scale (people with
/// more OWES/KfmCase ties glow hotter) — the surveillance "follow the money" lens.
ByDebtHeat,
}
impl ColorMode {
pub const ALL: [(ColorMode, &'static str); 3] = [
(ColorMode::ByLabel, "by-label"),
(ColorMode::ByKommun, "by-kommun"),
(ColorMode::ByDebtHeat, "debt-heat"),
];
pub fn label(self) -> &'static str {
match self {
ColorMode::ByLabel => "by-label",
ColorMode::ByKommun => "by-kommun",
ColorMode::ByDebtHeat => "debt-heat",
}
}
}
/// Which of the linked graphs a model is built for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PopView {
/// The full population, LOD-sampled to [`RENDER_CAP`] (force-clustered web).
Full,
/// The debt → enforcement sub-graph (Person → Debt → KfmCase).
Debt,
/// The per-kommun aggregation (one node per kommun, sized by population).
Kommun,
}
impl PopView {
pub fn label(self) -> &'static str {
match self {
PopView::Full => "full",
PopView::Debt => "debt",
PopView::Kommun => "kommun",
}
}
}
/// The live filter / scale state the toggle buttons + scale field drive. Cheap +
/// `Copy`; the views are pure functions of it (deterministic in `seed`).
#[derive(Clone, Copy, Debug)]
pub struct PopQuery {
/// Requested population scale (1M..=100M) — what the STATS report. Rendering
/// honours [`RENDER_CAP`]/[`GEN_CAP`] regardless (LOD).
pub n_persons: u64,
/// Number of kommuner to spread the population over (clamped to the 14 cities).
pub n_kommun: u16,
/// PRNG seed — the deterministic dial.
pub seed: u64,
/// Per-label visibility (index parallel to [`LABELS`]); a disabled label's nodes
/// + their edges are dropped from every view.
pub labels: [bool; 12],
/// Per-relation visibility, indexed parallel to [`relation_names`].
pub relations: [bool; REL_COUNT],
/// How nodes are coloured.
pub color: ColorMode,
}
/// The canonical relation set the toggles expose: the 11 Person→entity relations
/// plus the Debt→KfmCase escalation (the cross-edge that makes the debt story).
pub const REL_COUNT: usize = 12;
/// The relation names parallel to [`PopQuery::relations`] — the 11 Person→entity
/// rels (skipping the unused self "IS") + the ESCALATES_TO debt escalation.
pub fn relation_names() -> [&'static str; REL_COUNT] {
[
PERSON_REL[1], // LIVES_AT
PERSON_REL[2], // OWNS (company)
PERSON_REL[3], // OWES (debt)
PERSON_REL[4], // HOLDS (loan)
PERSON_REL[5], // FILED (tax)
PERSON_REL[6], // SUBJECT_OF (kfm)
PERSON_REL[7], // FLAGGED_BY (police)
PERSON_REL[8], // RECEIVES (fk)
PERSON_REL[9], // REGISTERED_AT (af)
PERSON_REL[10], // DRIVES (vehicle)
PERSON_REL[11], // OWNS_PROPERTY (property)
"ESCALATES_TO", // Debt → KfmCase
]
}
impl Default for PopQuery {
fn default() -> Self {
Self {
n_persons: CANONICAL_PERSONS,
n_kommun: geo::CITIES.len() as u16,
seed: 0xC0FFEE,
labels: [true; 12],
relations: [true; REL_COUNT],
color: ColorMode::ByLabel,
}
}
}
impl PopQuery {
/// Is the relation named `rel` currently enabled? Unknown names default visible.
pub fn rel_enabled(&self, rel: &str) -> bool {
match relation_names().iter().position(|r| *r == rel) {
Some(i) => self.relations[i],
None => true,
}
}
/// The hard generation cap for the *current target*: the big [`GEN_CAP_NATIVE`]
/// on native (we parallelise the generation across cores, [`sample_parallel`]),
/// the small wasm-safe [`GEN_CAP`] in the browser (single-thread fallback).
#[cfg(not(target_arch = "wasm32"))]
pub fn target_gen_cap() -> u32 {
GEN_CAP_NATIVE
}
/// See the native sibling — the wasm path keeps the small, memory-safe cap.
#[cfg(target_arch = "wasm32")]
pub fn target_gen_cap() -> u32 {
GEN_CAP
}
/// The [`SimParams`] for the *generated* (sampled) slice — n_persons clamped to
/// the active [`target_gen_cap`] so we never blow memory while still honouring
/// the scale field for the STATS. Deterministic in `seed`.
pub fn gen_params(&self) -> SimParams {
let n = self.n_persons.min(Self::target_gen_cap() as u64) as u32;
SimParams { n_persons: n.max(1), n_kommun: self.n_kommun.max(1), seed: self.seed }
}
/// Generate the sampled `PopGraph` slice (deterministic). This is the actual
/// in-memory graph the instance views (full / debt) draw.
///
/// **Native** runs [`sample_parallel`] — disjoint person-ranges generated by one
/// gatling worker per core (the znippy `gatling_for_each` no-barrier fan-out) and
/// merged — so it can afford the big [`GEN_CAP_NATIVE`] sample. **Wasm** keeps the
/// single-threaded
/// [`knut_popsim::simulate`] at the small [`GEN_CAP`] (no threads in the
/// browser sandbox). Both are deterministic in `seed`.
pub fn sample(&self) -> PopGraph {
#[cfg(not(target_arch = "wasm32"))]
{
sample_parallel(self.gen_params())
}
#[cfg(target_arch = "wasm32")]
{
pop::simulate(self.gen_params())
}
}
}
/// **Parallel population generation** (native only) — the znippy **gatling**
/// no-barrier fork-join fan-out (`gatling_for_each`, one worker per core; rayon is
/// FORBIDDEN). The `n_persons` are split into one disjoint range per gatling worker;
/// each worker generates its slice **independently** (re-deriving the same per-
/// person RNG stream knut-popsim uses, seeded from `(seed, person_index)` so the
/// output is deterministic and does not depend on the worker count), and the
/// per-worker `PopGraph`s (returned in index order) are concatenated with their node
/// indices re-based so the edges still reference the right nodes. No locks, no
/// barrier — workers never touch shared mutable state; the merge is a single
/// sequential append.
///
/// Generating chunk-locally (vs. one shared sequential RNG) is what makes it
/// embarrassingly parallel: each person's entity draws come from a hash of
/// `(seed, person_index, label_index)`, so person `p`'s graph is the same whichever
/// gatling worker computes it — a deterministic, worker-count-independent sample.
#[cfg(not(target_arch = "wasm32"))]
pub fn sample_parallel(params: SimParams) -> PopGraph {
let n = params.n_persons as usize;
let n_kommun = (params.n_kommun as usize).clamp(1, geo::CITIES.len());
if n == 0 {
return PopGraph { nodes: Vec::new(), edges: Vec::new() };
}
// One chunk per available core, but never more chunks than people, and cap the
// fan-out so tiny samples don't spawn a swarm of threads.
let cores = znippy_zoomies::gatling_forkjoin::default_workers();
let workers = cores.clamp(1, 32).min(n.max(1));
let chunk = n.div_ceil(workers);
// Fan the disjoint person-ranges out across the znippy **gatling** fork-join pool
// (`gatling_for_each`, one no-barrier worker per core; rayon is FORBIDDEN). Each
// worker generates its own slice independently; `gatling_for_each` hands the parts
// back **in index (worker) order**, so concatenating them below replays persons
// `0..n` in global order — byte-identical to a single contiguous generation, no
// matter how many workers ran. (Empty trailing chunks are no-ops in the merge.)
let seed = params.seed;
let parts: Vec<PopGraph> =
znippy_zoomies::gatling_forkjoin::gatling_for_each(workers, 0, |w| {
let start = w * chunk;
if start >= n {
return PopGraph { nodes: Vec::new(), edges: Vec::new() };
}
let end = (start + chunk).min(n);
gen_person_range(seed, start, end, n_kommun)
});
// Merge: concat nodes, re-base each part's edge endpoints by the running offset.
let total_nodes: usize = parts.iter().map(|p| p.nodes.len()).sum();
let total_edges: usize = parts.iter().map(|p| p.edges.len()).sum();
let mut nodes = Vec::with_capacity(total_nodes);
let mut edges = Vec::with_capacity(total_edges);
for part in parts {
let base = nodes.len() as u32;
nodes.extend(part.nodes);
for e in part.edges {
edges.push(pop::PopEdge { src: e.src + base, dst: e.dst + base, rel: e.rel });
}
}
PopGraph { nodes, edges }
}
/// Generate the population slice for persons `[start, end)` as a self-contained
/// `PopGraph` (node indices LOCAL to this slice — the merge re-bases them). The
/// per-person RNG is seeded from `(seed, person_index)` so this is deterministic
/// and independent of how the work was chunked. Mirrors knut-popsim's per-person
/// wiring (kommun by city weighting; each entity drawn by [`PERSON_TIE_PROB`]).
#[cfg(not(target_arch = "wasm32"))]
fn gen_person_range(seed: u64, start: usize, end: usize, n_kommun: usize) -> PopGraph {
let mut nodes: Vec<pop::PopNode> = Vec::with_capacity((end - start) * 4);
let mut edges: Vec<pop::PopEdge> = Vec::with_capacity((end - start) * 3);
for p in start..end {
// Per-person RNG: a splitmix-style hash of (seed, p) so person p is the same
// whichever thread draws it (thread-count-independent determinism).
let mut rng = splitmix64(seed ^ ((p as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)));
let kommun = (geo::city_index_for(p as i64) % n_kommun) as u16;
let person_idx = nodes.len() as u32;
nodes.push(pop::PopNode { label: PERSON as u8, kommun });
for li in 1..LABELS.len() {
if unit(&mut rng) < PERSON_TIE_PROB[li] {
let entity_idx = nodes.len() as u32;
nodes.push(pop::PopNode { label: li as u8, kommun });
edges.push(pop::PopEdge { src: person_idx, dst: entity_idx, rel: PERSON_REL[li] });
}
}
}
PopGraph { nodes, edges }
}
/// One splitmix64 step — a fast, well-distributed mixer so each person's draws are
/// independent without a shared sequential RNG.
#[cfg(not(target_arch = "wasm32"))]
#[inline]
fn splitmix64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Advance the per-person RNG and return a uniform `f64` in `[0,1)`.
#[cfg(not(target_arch = "wasm32"))]
#[inline]
fn unit(state: &mut u64) -> f64 {
*state = splitmix64(*state);
(*state >> 11) as f64 / (1u64 << 53) as f64
}
/// Analytic population statistics **at the requested scale** — computed from the
/// per-label tie rates, NOT by materialising the graph. Lets the demo honestly
/// report 11-million-person (or 100-million) counts while only ever generating a
/// few-thousand-person sample for rendering.
#[derive(Clone, Debug, PartialEq)]
pub struct PopStats {
/// The requested person scale (echoes [`PopQuery::n_persons`]).
pub n_persons: u64,
/// Expected node count per label at that scale (index parallel to [`LABELS`]).
pub per_label: [u64; 12],
/// Expected total node count (sum of `per_label`).
pub total_nodes: u64,
/// Expected total edge count (one Person→entity edge per non-Person node).
pub total_edges: u64,
}
impl PopStats {
/// Compute the analytic stats for a scale: `Person` = n, and each entity label
/// `i` ≈ `n * PERSON_TIE_PROB[i]` (the same Bernoulli the sampler draws). Honest
/// to within sampling noise and exact in expectation.
pub fn at_scale(n_persons: u64) -> Self {
let mut per_label = [0u64; 12];
per_label[PERSON] = n_persons;
for i in 1..12 {
per_label[i] = (n_persons as f64 * PERSON_TIE_PROB[i]).round() as u64;
}
let total_nodes: u64 = per_label.iter().sum();
// One edge per entity node (each entity is wired to its owning Person).
let total_edges: u64 = total_nodes - per_label[PERSON];
Self { n_persons, per_label, total_nodes, total_edges }
}
}
/// Evenly-spaced community hue for kommun `k` of `n` (HSV around the wheel).
pub fn kommun_color(k: usize, n: usize) -> Color {
let h = (k as f32 / n.max(1) as f32) * 360.0;
hsv(h, 0.60, 0.88)
}
/// The cool→hot **debt-heat** colour for a normalised degree `t` in `[0,1]`
/// (0 = calm teal, 1 = hot magenta-red) — the "follow the money" lens.
pub fn debt_heat(t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
// teal (38,166,154) → amber (255,193,7) → red (229,57,53)
let (a, b, seg) = if t < 0.5 {
([38.0, 166.0, 154.0], [255.0, 193.0, 7.0], t * 2.0)
} else {
([255.0, 193.0, 7.0], [229.0, 57.0, 53.0], (t - 0.5) * 2.0)
};
let lerp = |x: f32, y: f32| (x + (y - x) * seg) as u8;
Color::rgb(lerp(a[0], b[0]), lerp(a[1], b[1]), lerp(a[2], b[2]))
}
/// Pick a node colour for a `PopGraph` node under the active colour mode. `debt_deg`
/// is the node's normalised debt degree (only used by [`ColorMode::ByDebtHeat`]).
fn node_color(q: &PopQuery, label: u8, kommun: u16, debt_deg: f32) -> Color {
match q.color {
ColorMode::ByLabel => LABEL_COLORS[label as usize],
ColorMode::ByKommun => kommun_color(kommun as usize, geo::CITIES.len()),
ColorMode::ByDebtHeat => debt_heat(debt_deg),
}
}
/// Per-person **debt degree** over a sampled graph: how many OWES / SUBJECT_OF
/// (KfmCase) ties each Person carries, normalised to its max — the heat input.
/// Indexed by node index; non-Person nodes inherit their owner's heat.
fn debt_degrees(g: &PopGraph) -> Vec<f32> {
let debt_li = pop::label_index("Debt").unwrap() as u8;
let kfm_li = pop::label_index("KfmCase").unwrap() as u8;
let mut raw = vec![0u32; g.nodes.len()];
for e in &g.edges {
let dst = g.nodes[e.dst as usize].label;
if dst == debt_li || dst == kfm_li {
raw[e.src as usize] += 1; // credit the Person
raw[e.dst as usize] += 1; // and the entity itself reads hot
}
}
let max = raw.iter().copied().max().unwrap_or(0).max(1) as f32;
raw.iter().map(|&r| r as f32 / max).collect()
}
/// Lay a sampled `PopGraph` out as a **force-clustered hub-and-spoke** model: each
/// Person sits on a coarse community grid (by kommun), its entities orbit it. Pure +
/// deterministic (positions from indices), so snapshots are stable. Honours the
/// label + relation toggles and the [`RENDER_CAP`] LOD budget.
pub fn full_model(q: &PopQuery, g: &PopGraph) -> GraphModel {
let heat = debt_degrees(g);
let n_kommun = geo::CITIES.len();
// Coarse community grid: lay kommuner on a ring, people scatter inside their cell.
let cols = (n_kommun as f32).sqrt().ceil() as usize;
let cell = 520.0_f32;
let mut nodes: Vec<GraphNode> = Vec::new();
let mut edges: Vec<GraphEdge> = Vec::new();
// Per-person adjacency (its visible entity ties), so we can place each person
// together with its orbiting entities and spend the LOD budget on BOTH — that
// way edges always exist and label/relation toggles actually shrink the picture.
let mut ties: std::collections::HashMap<u32, Vec<(u32, usize)>> = std::collections::HashMap::new();
for (ei, e) in g.edges.iter().enumerate() {
let entity = &g.nodes[e.dst as usize];
let li = entity.label as usize;
if !q.labels[li] || !q.rel_enabled(e.rel) {
continue;
}
ties.entry(e.src).or_default().push((e.dst, ei));
}
// Walk persons in id order; place each + its visible entities until the cap.
'people: for (i, n) in g.nodes.iter().enumerate() {
if n.label as usize != PERSON || !q.labels[PERSON] {
continue;
}
if nodes.len() >= RENDER_CAP {
break;
}
let k = n.kommun as usize;
let (kx, ky) = ((k % cols) as f32 * cell, (k / cols) as f32 * cell);
// Deterministic jitter inside the cell from the node index.
let jx = ((i * 2654435761) % 1000) as f32 / 1000.0 - 0.5;
let jy = ((i * 40503) % 1000) as f32 / 1000.0 - 0.5;
let px = kx + jx * cell * 0.7;
let py = ky + jy * cell * 0.7;
let person_id = format!("p{i}");
nodes.push(GraphNode {
id: person_id.clone(),
label: format!("{} · {}", n.label_name(), n.kommun_name()),
fill: node_color(q, n.label, n.kommun, heat[i]),
stroke: Color::WHITE,
pos: Pos::new(px, py),
});
// Its entities orbit it (a ring), respecting the budget.
if let Some(es) = ties.get(&(i as u32)) {
for (slot, &(dst, ei)) in es.iter().enumerate() {
if nodes.len() >= RENDER_CAP {
break 'people;
}
let entity = &g.nodes[dst as usize];
let ang = (slot as f32) * 0.8 + (ei as f32) * 0.013;
let r = 70.0 + (slot as f32) * 6.0;
let ex = px + r * ang.cos();
let ey = py + r * ang.sin();
nodes.push(GraphNode {
id: format!("e{dst}"),
label: entity.label_name().to_string(),
fill: node_color(q, entity.label, entity.kommun, heat[dst as usize]),
stroke: Color::rgba(255, 255, 255, 120),
pos: Pos::new(ex, ey),
});
edges.push(GraphEdge {
from: person_id.clone(),
to: format!("e{dst}"),
color: edge_color(q, entity.label, heat[i]),
dashed: false,
// Carry the REAL relation name so the beauty layer can style the
// tie by relation (ribbon / debt-flow / liquid) AND a click
// highlights its relation set — the neon-dashboard contract.
label: Some(g.edges[ei].rel.to_string()),
});
}
}
}
GraphModel { nodes, edges }
}
/// Edge tint: a dim version of the destination label colour (or the heat colour in
/// debt mode), so edges read as faint ties that don't drown the nodes.
fn edge_color(q: &PopQuery, label: u8, heat: f32) -> Color {
let c = match q.color {
ColorMode::ByDebtHeat => debt_heat(heat),
_ => LABEL_COLORS[label as usize],
};
Color::rgba(c.r, c.g, c.b, 90)
}
/// The **debt sub-graph**: Person → OWES → Debt → ESCALATES_TO → KfmCase. We keep
/// only people who carry a Debt tie, draw their Debt nodes, and synthesise the
/// canonical Debt→KfmCase escalation for the subset that also has a KfmCase — the
/// enforcement story (the cross-edge `schema()` documents). Force-laid as three
/// columns (Person | Debt | KfmCase) so the escalation flows left→right.
pub fn debt_model(q: &PopQuery, g: &PopGraph) -> GraphModel {
let debt_li = pop::label_index("Debt").unwrap() as u8;
let kfm_li = pop::label_index("KfmCase").unwrap() as u8;
let owes = PERSON_REL[3]; // OWES
let subj = PERSON_REL[6]; // SUBJECT_OF
let heat = debt_degrees(g);
let show_person = q.labels[PERSON];
let show_debt = q.labels[debt_li as usize];
let show_kfm = q.labels[kfm_li as usize];
let show_owes = q.rel_enabled(owes);
let show_escalate = q.rel_enabled("ESCALATES_TO");
let mut nodes: Vec<GraphNode> = Vec::new();
let mut edges: Vec<GraphEdge> = Vec::new();
// Map each Person → its (debt node idx, has_kfm) so we can wire the escalation.
let mut person_debt: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
let mut person_has_kfm: std::collections::HashSet<u32> = std::collections::HashSet::new();
for e in &g.edges {
let dst = &g.nodes[e.dst as usize];
if dst.label == debt_li && e.rel == owes {
person_debt.entry(e.src).or_insert(e.dst);
}
if dst.label == kfm_li && e.rel == subj {
person_has_kfm.insert(e.src);
}
}
// Cap the rows so the three-column escalation flow reads clearly left→right
// (one row per indebted person; too many rows squashes the columns into a strip).
const DEBT_ROWS: usize = 22;
// Order people so the ones who ESCALATE (have a KfmCase) come first — the story.
let mut ordered: Vec<(u32, u32)> = person_debt.iter().map(|(&p, &d)| (p, d)).collect();
ordered.sort_by_key(|&(p, _)| (!person_has_kfm.contains(&p), p));
let mut row = 0.0f32;
for (psrc, dnode) in ordered.into_iter().take(DEBT_ROWS) {
let person = &g.nodes[psrc as usize];
let h = heat[psrc as usize];
let y = row * 96.0;
row += 1.0;
// Column 0: Person.
if show_person {
nodes.push(GraphNode {
id: format!("p{psrc}"),
label: format!("Person · {}", person.kommun_name()),
fill: node_color(q, person.label, person.kommun, h),
stroke: Color::WHITE,
pos: Pos::new(0.0, y),
});
}
// Column 1: Debt.
if show_debt {
nodes.push(GraphNode {
id: format!("d{dnode}"),
label: "Debt".into(),
fill: node_color(q, debt_li, person.kommun, h),
stroke: Color::rgba(255, 255, 255, 140),
pos: Pos::new(520.0, y),
});
if show_person && show_owes {
edges.push(GraphEdge {
from: format!("p{psrc}"),
to: format!("d{dnode}"),
color: Color::rgba(255, 138, 101, 170), // OWES — orange
dashed: false,
label: Some("OWES".into()),
});
}
}
// Column 2: KfmCase (enforcement) — only for the escalated subset.
if person_has_kfm.contains(&psrc) && show_kfm {
nodes.push(GraphNode {
id: format!("k{psrc}"),
label: "KfmCase".into(),
fill: node_color(q, kfm_li, person.kommun, 1.0),
stroke: Color::rgba(255, 255, 255, 160),
pos: Pos::new(1040.0, y),
});
if show_debt && show_escalate {
edges.push(GraphEdge {
from: format!("d{dnode}"),
to: format!("k{psrc}"),
color: Color::rgba(229, 57, 53, 200), // ESCALATES_TO — red
dashed: true,
label: Some("ESCALATES_TO".into()),
});
}
}
}
GraphModel { nodes, edges }
}
/// The **per-kommun aggregation**: one node per kommun, sized (encoded in the label,
/// and via a larger marker downstream) by its sampled population, laid out on its
/// real geographic position (lon/lat → world space). Edges tie geographically
/// adjacent / co-populated kommuner so the map reads as a connected country. The
/// full-scale meta view — this is what we show at 11M/100M instead of 11M dots.
pub fn kommun_model(q: &PopQuery, g: &PopGraph) -> GraphModel {
let n_k = geo::CITIES.len();
// Sampled population per kommun (Person nodes only).
let mut pop_count = vec![0u64; n_k];
for n in &g.nodes {
if n.label as usize == PERSON {
pop_count[n.kommun as usize] += 1;
}
}
let max = pop_count.iter().copied().max().unwrap_or(1).max(1) as f32;
// Scale the sampled per-kommun share up to the requested population for labels.
let total_sample: u64 = pop_count.iter().sum::<u64>().max(1);
let scale = q.n_persons as f64 / total_sample as f64;
let mut nodes: Vec<GraphNode> = Vec::with_capacity(n_k);
for (k, city) in geo::CITIES.iter().enumerate() {
// lon/lat → world space (Sweden is tall, so scale lat up; flip y for screen).
let x = (city.lon as f32 - 15.0) * 220.0;
let y = (66.0 - city.lat as f32) * 240.0;
let est = (pop_count[k] as f64 * scale).round() as u64;
let t = pop_count[k] as f32 / max;
let fill = match q.color {
ColorMode::ByDebtHeat => debt_heat(t),
_ => kommun_color(k, n_k),
};
nodes.push(GraphNode {
id: format!("k{k}"),
label: format!("{} · {}", city.kommun, human(est as i64)),
fill,
stroke: Color::WHITE,
pos: Pos::new(x, y),
});
}
// Edges: each kommun ties to its 2 nearest neighbours (a sparse country graph).
let mut edges: Vec<GraphEdge> = Vec::new();
for a in 0..n_k {
let mut d: Vec<(f32, usize)> = (0..n_k)
.filter(|&b| b != a)
.map(|b| {
let dx = geo::CITIES[a].lon - geo::CITIES[b].lon;
let dy = geo::CITIES[a].lat - geo::CITIES[b].lat;
((dx * dx + dy * dy) as f32, b)
})
.collect();
d.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap());
for &(_, b) in d.iter().take(2) {
if a < b {
edges.push(GraphEdge {
from: format!("k{a}"),
to: format!("k{b}"),
color: Color::rgba(120, 170, 230, 110),
dashed: false,
label: None,
});
}
}
}
GraphModel { nodes, edges }
}
/// The label index (`0..12`, parallel to [`LABELS`]) for each node id in a built
/// view model — the bridge the beauty layer needs to pick the neon palette +
/// animation per node. Parses the node's label string (`"Person · …"`, `"Debt"`,
/// `"KfmCase"`, …) back to a [`knut_popsim::label_index`]; kommun-view city names
/// (no label prefix) fall back to `Person` (the cyan anchor), which is the right
/// hue for a population aggregate.
pub fn label_indices(model: &GraphModel) -> std::collections::HashMap<String, u8> {
model
.nodes
.iter()
.map(|n| {
let head = n.label.split(" · ").next().unwrap_or(&n.label).trim();
let li = pop::label_index(head).unwrap_or(pop::PERSON) as u8;
(n.id.clone(), li)
})
.collect()
}
/// Build the model for a [`PopView`] from a query + its sampled graph.
pub fn view_model(view: PopView, q: &PopQuery, g: &PopGraph) -> GraphModel {
match view {
PopView::Full => full_model(q, g),
PopView::Debt => debt_model(q, g),
PopView::Kommun => kommun_model(q, g),
}
}
/// Caller-supplied [`Decorations`] for a view — domain-agnostic node sizing (and a
/// status ring) the L0 engine renders without learning what the metric means:
/// * Kommun view — each kommun marker is **scaled by its population** (big orbs for
/// Stockholm, small for Luleå), so the map reads as a proportional-symbol map.
/// * Debt view — the KfmCase (enforcement) nodes get a red status **ring** (the
/// "escalated" flag).
pub fn view_decorations(view: PopView, _q: &PopQuery, g: &PopGraph, model: &GraphModel) -> Decorations {
use facett_graphview::NodeDecoration;
let mut deco = Decorations::default();
match view {
PopView::Kommun => {
// Recompute per-kommun sampled population to size each "k{idx}" node.
let n_k = geo::CITIES.len();
let mut pop_count = vec![0u64; n_k];
for n in &g.nodes {
if n.label as usize == PERSON {
pop_count[n.kommun as usize] += 1;
}
}
let max = pop_count.iter().copied().max().unwrap_or(1).max(1) as f32;
for (k, &count) in pop_count.iter().enumerate() {
let t = count as f32 / max;
// 0.9× (smallest) → 3.4× (Stockholm): a clear proportional-symbol map.
let scale = 0.9 + t * 2.5;
deco.nodes.insert(
format!("k{k}"),
NodeDecoration { scale: Some(scale), ..Default::default() },
);
}
}
PopView::Debt => {
// Ring the KfmCase (enforcement) nodes — the "escalated" status flag.
for n in &model.nodes {
if n.id.starts_with('k') {
deco.nodes.insert(
n.id.clone(),
NodeDecoration {
ring: Some(Color::rgba(229, 57, 53, 230)),
scale: Some(1.2),
..Default::default()
},
);
}
}
}
PopView::Full => {}
}
deco
}
/// Compact human count: 11_000_000 → "11M", 1_200_000 → "1.2M", 60_000 → "60k".
pub fn human(n: i64) -> String {
let n = n as f64;
if n >= 1e6 {
let m = n / 1e6;
if m.fract().abs() < 0.05 { format!("{m:.0}M") } else { format!("{m:.1}M") }
} else if n >= 1e3 {
format!("{:.0}k", n / 1e3)
} else {
format!("{n:.0}")
}
}
/// Minimal HSV→Color (s,v in 0..1, h in degrees).
fn hsv(h: f32, s: f32, v: f32) -> Color {
let c = v * s;
let hp = h.rem_euclid(360.0) / 60.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r, g, b) = match hp as i32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = v - c;
Color::rgb(((r + m) * 255.0) as u8, ((g + m) * 255.0) as u8, ((b + m) * 255.0) as u8)
}
/// The `(label, colour)` nodes + index edges [`facett::graph3d::Graph3D::new`] takes.
pub type Graph3dNodesEdges = (Vec<(String, egui::Color32)>, Vec<(usize, usize)>);
/// The 3D population node cloud the `graph3d` tab renders — a small Person+entity
/// sample as `(label, egui::Color32)` nodes + index edges (the shape `Graph3D::new`
/// wants). Coloured by the active mode; honours label/relation toggles + the cap.
pub fn graph3d_nodes_edges(
q: &PopQuery,
g: &PopGraph,
cap: usize,
) -> Graph3dNodesEdges {
let heat = debt_degrees(g);
let mut nodes: Vec<(String, egui::Color32)> = Vec::new();
let mut edges: Vec<(usize, usize)> = Vec::new();
let mut idx_of: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
// Per-person ties (so we interleave person + entities and the cap covers both).
let mut ties: std::collections::HashMap<u32, Vec<u32>> = std::collections::HashMap::new();
for e in &g.edges {
let li = g.nodes[e.dst as usize].label as usize;
if !q.labels[li] || !q.rel_enabled(e.rel) {
continue;
}
ties.entry(e.src).or_default().push(e.dst);
}
'people: for (i, n) in g.nodes.iter().enumerate() {
if n.label as usize != PERSON || !q.labels[PERSON] {
continue;
}
if nodes.len() >= cap {
break;
}
let from = nodes.len();
idx_of.insert(i as u32, from);
let c: egui::Color32 = node_color(q, n.label, n.kommun, heat[i]).into();
nodes.push((format!("{}·{}", n.label_name(), n.kommun_name()), c));
if let Some(es) = ties.get(&(i as u32)) {
for &dst in es {
if nodes.len() >= cap {
break 'people;
}
let entity = &g.nodes[dst as usize];
let to = nodes.len();
idx_of.insert(dst, to);
let ec: egui::Color32 = node_color(q, entity.label, entity.kommun, heat[dst as usize]).into();
nodes.push((entity.label_name().to_string(), ec));
edges.push((from, to));
}
}
}
(nodes, edges)
}
/// The 12-label **schema meta-graph** as a [`GraphModel`] — re-exposes
/// [`knut_popsim::schema`] (the data model) laid out as a Person-hub star with the
/// documented cross-edges, coloured by label + sized labels. The "graph DB at a
/// glance" the `graph` tab opens on.
pub fn schema_model() -> GraphModel {
let s = pop::schema();
// Person hub at centre; other labels on a ring around it.
let mut nodes: Vec<GraphNode> = Vec::with_capacity(s.nodes.len());
let n = s.nodes.len();
for (i, node) in s.nodes.iter().enumerate() {
let (x, y) = if i == PERSON {
(0.0, 0.0)
} else {
let a = (i as f32 / (n - 1) as f32) * std::f32::consts::TAU;
(a.cos() * 420.0, a.sin() * 420.0)
};
nodes.push(GraphNode {
id: format!("L{i}"),
label: format!("{} · {}", node.label_name(), human(LABELS[i].1)),
fill: LABEL_COLORS[i],
stroke: Color::WHITE,
pos: Pos::new(x, y),
});
}
let edges = s
.edges
.iter()
.map(|e| GraphEdge {
from: format!("L{}", e.src),
to: format!("L{}", e.dst),
color: Color::rgba(180, 190, 210, 150),
dashed: e.rel == "ESCALATES_TO",
label: Some(e.rel.to_string()),
})
.collect();
GraphModel { nodes, edges }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stats_scale_to_eleven_million_without_materialising() {
// The canonical 11M scale: Person == 11M exactly, entities follow the tie
// rates, all computed analytically (no 400M-node allocation).
let s = PopStats::at_scale(CANONICAL_PERSONS);
assert_eq!(s.per_label[PERSON], 11_000_000);
// Address rate 0.95 → ~10.45M; TaxRecord 0.99 → ~10.89M.
assert_eq!(s.per_label[1], 10_450_000);
assert_eq!(s.per_label[5], 10_890_000);
// Totals are the sum, and edges = non-Person nodes (one tie each).
assert_eq!(s.total_nodes, s.per_label.iter().sum::<u64>());
assert_eq!(s.total_edges, s.total_nodes - 11_000_000);
assert!(s.total_nodes > 50_000_000, "11M people fan out to tens of millions of nodes");
}
#[test]
fn stats_track_the_full_scale_range() {
for n in [SCALE_MIN, CANONICAL_PERSONS, SCALE_MAX] {
let s = PopStats::at_scale(n);
assert_eq!(s.per_label[PERSON], n, "Person count echoes the requested scale");
assert!(s.total_nodes > n, "entities add to the person count");
}
// 100M is honestly bigger than 11M in every label.
let big = PopStats::at_scale(SCALE_MAX);
let mid = PopStats::at_scale(CANONICAL_PERSONS);
assert!(big.total_nodes > mid.total_nodes * 8, "100M ≫ 11M");
}
#[test]
fn generation_is_capped_but_scale_is_honoured() {
// Even asking for 100M, we only ever GENERATE the target cap of persons
// (memory-safe). Native caps higher (GEN_CAP_NATIVE) because it generates
// in parallel; wasm caps at the small GEN_CAP. The STATS still report 100M.
let q = PopQuery { n_persons: SCALE_MAX, ..PopQuery::default() };
let params = q.gen_params();
assert_eq!(params.n_persons, PopQuery::target_gen_cap(), "generation clamped to the target cap");
let g = q.sample();
assert_eq!(
g.count_label(PERSON),
PopQuery::target_gen_cap() as usize,
"sample really has target_gen_cap persons"
);
// But the STATS still report the requested 100M scale.
assert_eq!(PopStats::at_scale(q.n_persons).per_label[PERSON], SCALE_MAX);
}
/// INJECT-ASSERT (native gatling): the parallel sampler produces a graph whose
/// size + content do NOT depend on the gatling worker chunking — generating the
/// same person-range in one chunk vs. fanned across `gatling_for_each` workers
/// merges to the identical node/edge sequence (deterministic, worker-count-
/// independent), AND it really generates the big native sample (visibly MORE than
/// the wasm GEN_CAP).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn parallel_sample_is_deterministic_and_bigger_than_wasm() {
let params = SimParams { n_persons: 5_000, n_kommun: 14, seed: 0xABCD };
// The merged gatling-parallel graph == a SERIAL reference: the same range
// generated as one contiguous slice. The gatling fan-out returns its parts in
// index order and the merge re-bases edges, so the whole `PopGraph` (nodes AND
// edges, order + values) must be byte-identical to the single-chunk serial
// build — regardless of how many gatling workers ran.
let parallel = sample_parallel(params);
let serial = {
// gen_person_range over the whole range, in a single "chunk" — the serial
// reference the fan-out must reproduce exactly.
super::gen_person_range(params.seed, 0, params.n_persons as usize, 14)
};
assert_eq!(parallel.nodes.len(), serial.nodes.len(), "worker-count-independent node count");
assert_eq!(parallel.edges.len(), serial.edges.len(), "worker-count-independent edge count");
// FULL graph equality: nodes (label + kommun) AND edges (src + dst + rel), in
// order. `PopGraph: PartialEq`, so this is a value-and-order-exact comparison.
assert_eq!(parallel, serial, "gatling-parallel graph == serial reference, node/edge for node/edge");
// And re-running is identical (deterministic in seed) — full graph again.
let again = sample_parallel(params);
assert_eq!(parallel, again, "same seed → same graph (nodes + edges, exactly)");
// The native cap really lets us generate a big sample (≫ the wasm GEN_CAP).
const _: () = assert!(GEN_CAP_NATIVE > GEN_CAP * 4, "native generates far more people than wasm");
let big = PopQuery { n_persons: SCALE_MAX, ..PopQuery::default() }.sample();
assert_eq!(big.count_label(PERSON), GEN_CAP_NATIVE as usize, "native sample fills the big cap");
}
#[test]
fn full_model_respects_render_cap_and_is_deterministic() {
let q = PopQuery::default();
let g = q.sample();
let a = full_model(&q, &g);
let b = full_model(&q, &g);
assert_eq!(a.nodes.len(), b.nodes.len(), "deterministic");
assert_eq!(a.edges.len(), b.edges.len());
assert!(a.nodes.len() <= RENDER_CAP, "render cap honoured: {}", a.nodes.len());
assert!(a.nodes.len() > 200, "but a real, populated graph: {}", a.nodes.len());
assert!(!a.edges.is_empty(), "ties present");
}
#[test]
fn disabling_a_label_drops_its_nodes() {
// Use a small (uncapped) sample so the label filter is the only thing that
// changes the count (at the full LOD cap the budget is filled either way —
// LOD by design; the label still vanishes from the picture, asserted below).
let small = SimParams { n_persons: 80, n_kommun: 14, seed: 3 };
let g = pop::simulate(small);
let mut q = PopQuery::default();
let with_m = full_model(&q, &g);
let with_vehicles = with_m.nodes.iter().filter(|n| n.label.starts_with("Vehicle")).count();
assert!(with_vehicles > 0, "the sample has Vehicle nodes to drop");
let with = with_m.nodes.len();
// Turn off Vehicle (index 10) — its nodes (and only those) must disappear.
q.labels[10] = false;
let m = full_model(&q, &g);
assert!(m.nodes.len() < with, "disabling Vehicle dropped nodes: {} < {with}", m.nodes.len());
assert!(m.nodes.iter().all(|n| !n.label.starts_with("Vehicle")), "no Vehicle nodes when off");
// Exactly the Vehicle nodes went away.
assert_eq!(with - m.nodes.len(), with_vehicles, "exactly the Vehicle nodes were removed");
}
#[test]
fn disabling_a_relation_drops_its_edges() {
let g = PopQuery::default().sample();
let mut q = PopQuery::default();
let with = full_model(&q, &g).edges.len();
// OWES is relations index 2.
let owes_idx = relation_names().iter().position(|r| *r == "OWES").unwrap();
q.relations[owes_idx] = false;
let without = full_model(&q, &g).edges.len();
assert!(without < with, "disabling OWES dropped edges: {without} < {with}");
}
#[test]
fn debt_model_is_the_owes_escalates_story() {
let g = PopQuery::default().sample();
let q = PopQuery::default();
let m = debt_model(&q, &g);
assert!(!m.nodes.is_empty(), "debt sub-graph has people in debt");
// Some edges are the OWES tie and some the ESCALATES_TO escalation.
let owes = m.edges.iter().filter(|e| e.label.as_deref() == Some("OWES")).count();
let esc = m.edges.iter().filter(|e| e.label.as_deref() == Some("ESCALATES_TO")).count();
assert!(owes > 0, "the debt graph has OWES ties");
assert!(esc > 0, "and at least one Debt→KfmCase escalation");
assert!(owes >= esc, "more people owe than escalate to enforcement");
}
#[test]
fn kommun_model_aggregates_to_one_node_per_kommun() {
let g = PopQuery::default().sample();
let q = PopQuery::default();
let m = kommun_model(&q, &g);
assert_eq!(m.nodes.len(), geo::CITIES.len(), "one node per kommun");
// Stockholm (the heaviest weight) must report the biggest population.
let sthlm = m.nodes.iter().find(|n| n.label.starts_with("Stockholm")).unwrap();
assert!(sthlm.label.contains('M') || sthlm.label.contains('k'), "kommun node sized by population: {}", sthlm.label);
assert!(!m.edges.is_empty(), "kommuner are tied into a country graph");
}
#[test]
fn color_modes_change_node_fills() {
let g = PopQuery::default().sample();
let by_label = full_model(&PopQuery { color: ColorMode::ByLabel, ..PopQuery::default() }, &g);
let by_kommun = full_model(&PopQuery { color: ColorMode::ByKommun, ..PopQuery::default() }, &g);
let by_heat = full_model(&PopQuery { color: ColorMode::ByDebtHeat, ..PopQuery::default() }, &g);
// The three colourings produce different fill sequences (real recolour).
let fills = |m: &GraphModel| m.nodes.iter().map(|n| n.fill).collect::<Vec<_>>();
assert_ne!(fills(&by_label), fills(&by_kommun), "by-label ≠ by-kommun");
assert_ne!(fills(&by_label), fills(&by_heat), "by-label ≠ debt-heat");
}
#[test]
fn debt_heat_scale_runs_cool_to_hot() {
let cool = debt_heat(0.0);
let hot = debt_heat(1.0);
assert!(cool.b > cool.r, "cool end is teal-ish (more blue than red)");
assert!(hot.r > hot.b, "hot end is red-ish (more red than blue)");
}
#[test]
fn schema_model_is_the_twelve_label_data_model() {
let m = schema_model();
assert_eq!(m.nodes.len(), 12, "12 labels");
assert!(m.nodes[PERSON].label.starts_with("Person"), "Person hub present");
assert!(m.nodes[5].label.contains("60M"), "TaxRecord sized 60M: {}", m.nodes[5].label);
// 11 hub edges + 4 cross edges = 15 (matches knut_popsim::schema()).
assert_eq!(m.edges.len(), 15, "hub + cross edges");
}
#[test]
fn graph3d_sample_builds_real_colored_cloud() {
let g = PopQuery::default().sample();
let q = PopQuery::default();
let (nodes, edges) = graph3d_nodes_edges(&q, &g, 200);
assert!(nodes.len() > 20 && nodes.len() <= 200, "a capped, real cloud: {}", nodes.len());
assert!(!edges.is_empty(), "ties present");
// Edge endpoints are in range.
for &(a, b) in &edges {
assert!(a < nodes.len() && b < nodes.len());
}
}
}