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

nordisk/facett

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());
        }
    }
}