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

nordisk/knut

crates/knut-popsim/src/lib.rs

//! **knut-popsim** — the *canonical*, pure-Rust generator of the synthetic
//! Swedish population graph (the 11-million-person, 12-label graph DB the knut
//! demos load into FalkorDB / Iceberg).
//!
//! This crate holds **only the model**: the label set + counts ([`LABELS`]), the
//! deterministic geo placement ([`geo`]), and a generic in-memory graph
//! ([`PopGraph`]) produced by [`simulate`] / [`schema`]. There are **no
//! FalkorDB, Arrow, tonic, reqwest, tokio, or filesystem dependencies** — so it
//! compiles for `wasm32-unknown-unknown` and every consumer reuses one model:
//!
//! * `knut-popviz` keeps its Arrow → FalkorDB/Iceberg bulk-load path and just
//!   re-exports [`LABELS`] / [`geo`] from here, sharing the placement logic.
//! * the facett wasm demo can call [`simulate`] / [`schema`] directly and render
//!   the resulting [`PopGraph`] — no backend, no network.
//!
//! Determinism is a contract: [`simulate`] uses a seeded LCG (no `rand`), so the
//! same [`SimParams`] always yields byte-identical graphs.

pub mod geo;

/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps `nornir_testmatrix::functional_status` behind the
/// `testmatrix` feature (a compiled-out `#[inline]` no-op otherwise, with no
/// nornir dep, keeping the default build wasm-clean). [`simulate`] calls this so
/// `nornir test --features testmatrix` SEES the generator's health.
#[inline]
pub fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
    #[cfg(feature = "testmatrix")]
    nornir_testmatrix::functional_status(component, check, ok, detail);
    #[cfg(not(feature = "testmatrix"))]
    {
        let _ = (component, check, ok, detail);
    }
}

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

/// Base `ekonomiskt_bistand` (benefit-recipient) rate applied to the `Person`
/// population (before per-kommun hotspot multipliers). ≈ the real share of
/// Swedes receiving Försäkringskassan / social-assistance benefits.
pub const EKONOMISKT_BISTAND_RATE: f64 = 0.06;

/// The 12 node labels A–L with their **scale-1.0** counts (the real Swedish
/// register sizes the demo models). The single source of truth — knut-popviz
/// re-exports this constant rather than duplicating it.
pub const LABELS: [(&str, i64); 12] = [
    ("Person", 11_000_000),
    ("Address", 6_000_000),
    ("Company", 1_200_000),
    ("Debt", 5_000_000),
    ("Loan", 8_000_000),
    ("TaxRecord", 60_000_000),
    ("KfmCase", 2_000_000),
    ("PoliceRecord", 3_000_000),
    ("FkBenefit", 10_000_000),
    ("AfRecord", 3_000_000),
    ("Vehicle", 8_000_000),
    ("Property", 4_000_000),
];

/// The index of the `Person` label in [`LABELS`] (the hub every other label
/// hangs off of).
pub const PERSON: usize = 0;

/// Per-(non-Person)-label probability that a sampled `Person` is wired to an
/// entity of that label. Indexed parallel to [`LABELS`]; `LABELS[PERSON]`'s slot
/// is `1.0` (a person is always a person). These are realistic per-capita rates:
/// nearly everyone has an address and a tax record; far fewer have a KfmCase.
pub const PERSON_TIE_PROB: [f64; 12] = [
    1.00, // Person (self)
    0.95, // Address
    0.18, // Company
    0.30, // Debt
    0.45, // Loan
    0.99, // TaxRecord
    0.10, // KfmCase
    0.12, // PoliceRecord
    0.55, // FkBenefit
    0.20, // AfRecord
    0.60, // Vehicle
    0.35, // Property
];

/// The relationship name a `Person` → label edge carries (parallel to
/// [`LABELS`]). Used by [`simulate`] and [`schema`] alike.
pub const PERSON_REL: [&str; 12] = [
    "IS",            // Person (self, unused)
    "LIVES_AT",      // Address
    "OWNS",          // Company
    "OWES",          // Debt
    "HOLDS",         // Loan
    "FILED",         // TaxRecord
    "SUBJECT_OF",    // KfmCase
    "FLAGGED_BY",    // PoliceRecord
    "RECEIVES",      // FkBenefit
    "REGISTERED_AT", // AfRecord
    "DRIVES",        // Vehicle
    "OWNS_PROPERTY", // Property
];

/// Look up a label's index in [`LABELS`] by name.
pub fn label_index(name: &str) -> Option<usize> {
    LABELS.iter().position(|(l, _)| *l == name)
}

/// A node in the generic population graph. `label` is an **index into
/// [`LABELS`]** (so it's `Copy`, stable, and serde-friendly without a string per
/// node); `kommun` is an index into [`geo::CITIES`] giving the node's community
/// (Swedish municipality). For non-geo labels the kommun is inherited from the
/// owning `Person` so an entity sits in the same community as its owner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PopNode {
    /// Index into [`LABELS`].
    pub label: u8,
    /// Index into [`geo::CITIES`] — the node's community / kommun.
    pub kommun: u16,
}

impl PopNode {
    /// The node's label name (from [`LABELS`]).
    pub fn label_name(&self) -> &'static str {
        LABELS[self.label as usize].0
    }
    /// The node's kommun name (from [`geo::CITIES`]).
    pub fn kommun_name(&self) -> &'static str {
        geo::CITIES[self.kommun as usize].kommun
    }
}

/// A directed edge in the generic population graph: `src` → `dst` are indices
/// into [`PopGraph::nodes`]; `rel` is the relationship type.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PopEdge {
    pub src: u32,
    pub dst: u32,
    /// Relationship type. Always one of the canonical names (see [`PERSON_REL`]
    /// and [`schema`]); on deserialize it is interned back to a `&'static str`
    /// via [`intern_rel`].
    pub rel: &'static str,
}

/// All relationship names this crate emits — the universe `rel` can take.
/// Deserialization interns an incoming string back to one of these statics.
pub const ALL_RELS: [&str; 15] = [
    "IS",
    "LIVES_AT",
    "OWNS",
    "OWES",
    "HOLDS",
    "FILED",
    "SUBJECT_OF",
    "FLAGGED_BY",
    "RECEIVES",
    "REGISTERED_AT",
    "DRIVES",
    "OWNS_PROPERTY",
    "LOCATED_AT",
    "OPERATES",
    "ESCALATES_TO",
];

/// Intern an arbitrary relationship string back to a `&'static str`. Falls back
/// to `"REL"` for unknown inputs (keeps deserialize total / never panics).
pub fn intern_rel(name: &str) -> &'static str {
    ALL_RELS
        .iter()
        .copied()
        .find(|r| *r == name)
        .unwrap_or("REL")
}

// Hand-written serde for `PopEdge`: the `rel: &'static str` field can't be
// derived (a derived `Deserialize` would require `'de: 'static`). Serializing
// emits `rel` as a plain string; deserializing interns it back to a known
// static — so the impl is `impl<'de> Deserialize<'de>` for *any* `'de` and
// `PopGraph`'s `Vec<PopEdge>` derives cleanly.
#[cfg(feature = "serde")]
impl Serialize for PopEdge {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        use serde::ser::SerializeStruct;
        let mut st = s.serialize_struct("PopEdge", 3)?;
        st.serialize_field("src", &self.src)?;
        st.serialize_field("dst", &self.dst)?;
        st.serialize_field("rel", self.rel)?;
        st.end()
    }
}

#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for PopEdge {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        #[derive(Deserialize)]
        struct Raw {
            src: u32,
            dst: u32,
            rel: String,
        }
        let r = Raw::deserialize(d)?;
        Ok(PopEdge {
            src: r.src,
            dst: r.dst,
            rel: intern_rel(&r.rel),
        })
    }
}

/// A generic, backend-agnostic in-memory population graph. **Not** tied to
/// facett or FalkorDB — any consumer converts it (to a facett `Scene`, to Arrow
/// batches, to Cypher `CREATE`s, …).
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PopGraph {
    pub nodes: Vec<PopNode>,
    pub edges: Vec<PopEdge>,
}

impl PopGraph {
    /// Count of nodes carrying the given label.
    pub fn count_label(&self, label: usize) -> usize {
        self.nodes
            .iter()
            .filter(|n| n.label as usize == label)
            .count()
    }
}

/// Parameters for [`simulate`]. Deterministic in all three: the same params
/// always yield byte-identical graphs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SimParams {
    /// How many `Person` nodes to sample.
    pub n_persons: u32,
    /// How many communities (kommuner) to spread them over. Clamped to
    /// `1..=CITIES.len()`.
    pub n_kommun: u16,
    /// PRNG seed — change it for a different (but still deterministic) graph.
    pub seed: u64,
}

impl Default for SimParams {
    fn default() -> Self {
        Self {
            n_persons: 1_000,
            n_kommun: geo::CITIES.len() as u16,
            seed: 0xC0FFEE,
        }
    }
}

/// A tiny deterministic LCG (Numerical Recipes constants) — no `rand`, no deps,
/// identical on every target including wasm.
#[inline]
fn lcg_next(state: &mut u64) -> u64 {
    *state = state
        .wrapping_mul(6364136223846793005)
        .wrapping_add(1442695040888963407);
    *state
}

/// Uniform `f64` in `[0,1)` from the LCG.
#[inline]
fn lcg_unit(state: &mut u64) -> f64 {
    (lcg_next(state) >> 11) as f64 / (1u64 << 53) as f64
}

/// Generate a population graph: `n_persons` people, each placed in one of
/// `n_kommun` communities and wired to entities by the realistic per-label
/// probabilities in [`PERSON_TIE_PROB`]. Entity nodes inherit their owner's
/// kommun. Fully deterministic in [`SimParams`].
pub fn simulate(params: SimParams) -> PopGraph {
    let n_kommun = (params.n_kommun as usize).clamp(1, geo::CITIES.len());
    let n = params.n_persons as usize;
    let mut g = PopGraph {
        nodes: Vec::with_capacity(n * 4),
        edges: Vec::with_capacity(n * 3),
    };
    let mut rng = params.seed ^ 0x9E37_79B9_7F4A_7C15;

    for p in 0..n {
        // Community: use the real city-weighting (so big kommuner dominate),
        // restricted to the first `n_kommun` communities.
        let kommun = (geo::city_index_for(p as i64) % n_kommun) as u16;
        let person_idx = g.nodes.len() as u32;
        g.nodes.push(PopNode {
            label: PERSON as u8,
            kommun,
        });

        // Wire this person to entities by per-label probability.
        for li in 1..LABELS.len() {
            if lcg_unit(&mut rng) < PERSON_TIE_PROB[li] {
                let entity_idx = g.nodes.len() as u32;
                g.nodes.push(PopNode {
                    label: li as u8,
                    kommun,
                });
                g.edges.push(PopEdge {
                    src: person_idx,
                    dst: entity_idx,
                    rel: PERSON_REL[li],
                });
            }
        }
    }
    // Introspection marker — the generator's health as one test-matrix row.
    // Gated so the detail string is never built in the release (no-op) build.
    #[cfg(feature = "testmatrix")]
    functional_status(
        "knut-popsim/simulate",
        "population_generated",
        !g.nodes.is_empty(),
        &format!("nodes={}, edges={}", g.nodes.len(), g.edges.len()),
    );
    g
}

/// The 12-label **meta-graph** (schema): one node per label carrying its
/// scale-1.0 count (folded into `kommun` as `0` — schema nodes aren't geo),
/// `Person` as the hub with an edge to every other label, plus a few realistic
/// cross-edges:
///
/// * `Address` ↔ `Property` (a property sits at an address)
/// * `Company` ↔ `Vehicle`  (companies own fleet vehicles)
/// * `Debt`    → `KfmCase`   (unpaid debt escalates to enforcement)
/// * `Company` → `Address`   (companies have a registered address)
///
/// Node order is exactly [`LABELS`] order, so `nodes[i]` is `LABELS[i]`.
pub fn schema() -> PopGraph {
    let nodes: Vec<PopNode> = (0..LABELS.len())
        .map(|i| PopNode {
            label: i as u8,
            kommun: 0,
        })
        .collect();
    let mut edges: Vec<PopEdge> = Vec::new();

    // Person hub → every other label.
    for li in 1..LABELS.len() {
        edges.push(PopEdge {
            src: PERSON as u32,
            dst: li as u32,
            rel: PERSON_REL[li],
        });
    }

    // Cross-edges (look up by name so the wiring stays readable + order-robust).
    let idx = |name: &str| label_index(name).expect("known label") as u32;
    edges.push(PopEdge {
        src: idx("Property"),
        dst: idx("Address"),
        rel: "LOCATED_AT",
    });
    edges.push(PopEdge {
        src: idx("Company"),
        dst: idx("Vehicle"),
        rel: "OPERATES",
    });
    edges.push(PopEdge {
        src: idx("Debt"),
        dst: idx("KfmCase"),
        rel: "ESCALATES_TO",
    });
    edges.push(PopEdge {
        src: idx("Company"),
        dst: idx("Address"),
        rel: "REGISTERED_AT",
    });

    PopGraph { nodes, edges }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tiny() -> SimParams {
        SimParams {
            n_persons: 500,
            n_kommun: 14,
            seed: 1,
        }
    }

    #[test]
    fn labels_are_the_twelve_register() {
        assert_eq!(LABELS.len(), 12);
        assert_eq!(LABELS[PERSON], ("Person", 11_000_000));
        assert_eq!(LABELS[5], ("TaxRecord", 60_000_000));
        // probability + rel tables are aligned to LABELS.
        assert_eq!(PERSON_TIE_PROB.len(), 12);
        assert_eq!(PERSON_REL.len(), 12);
    }

    #[test]
    fn simulate_is_deterministic() {
        let a = simulate(tiny());
        let b = simulate(tiny());
        assert_eq!(a, b, "same seed → byte-identical graph");
        assert!(
            !a.nodes.is_empty() && !a.edges.is_empty(),
            "produced a real graph"
        );
        // exactly n_persons Person nodes.
        assert_eq!(a.count_label(PERSON), 500);
        // every edge references valid nodes and starts at a Person.
        for e in &a.edges {
            assert!((e.src as usize) < a.nodes.len() && (e.dst as usize) < a.nodes.len());
            assert_eq!(
                a.nodes[e.src as usize].label as usize, PERSON,
                "edges start at a Person"
            );
        }
    }

    #[test]
    fn different_seed_yields_different_graph() {
        let a = simulate(SimParams { seed: 1, ..tiny() });
        let b = simulate(SimParams { seed: 2, ..tiny() });
        assert_ne!(a, b, "a different seed changes the graph");
        // both still have exactly the requested persons.
        assert_eq!(a.count_label(PERSON), 500);
        assert_eq!(b.count_label(PERSON), 500);
    }

    #[test]
    fn sampling_scales_with_n_persons() {
        let small = simulate(SimParams {
            n_persons: 200,
            ..tiny()
        });
        let big = simulate(SimParams {
            n_persons: 2_000,
            ..tiny()
        });
        assert_eq!(small.count_label(PERSON), 200);
        assert_eq!(big.count_label(PERSON), 2_000);
        // ~10x persons → roughly ~10x total nodes (each person spawns entities).
        assert!(
            big.nodes.len() > small.nodes.len() * 7,
            "node count scales with n_persons ({} vs {})",
            big.nodes.len(),
            small.nodes.len()
        );
    }

    #[test]
    fn tie_probabilities_are_respected() {
        // Address (0.95) must vastly out-number KfmCase (0.10) across the sample.
        let g = simulate(SimParams {
            n_persons: 5_000,
            n_kommun: 14,
            seed: 7,
        });
        let addr = g.count_label(label_index("Address").unwrap());
        let kfm = g.count_label(label_index("KfmCase").unwrap());
        assert!(
            addr > kfm * 4,
            "Address ({addr}) ≫ KfmCase ({kfm}) per the tie rates"
        );
        // Address ties land in [0.85, 1.0] of persons (0.95 ± sampling noise).
        let frac = addr as f64 / 5_000.0;
        assert!(
            (0.85..=1.0).contains(&frac),
            "Address tie frac {frac} ≈ 0.95"
        );
    }

    #[test]
    fn n_kommun_bounds_the_communities() {
        let g = simulate(SimParams {
            n_persons: 2_000,
            n_kommun: 3,
            seed: 4,
        });
        let max_kommun = g.nodes.iter().map(|n| n.kommun).max().unwrap();
        assert!(
            max_kommun < 3,
            "communities capped to n_kommun=3, saw {max_kommun}"
        );
    }

    #[test]
    fn schema_has_twelve_labels_and_person_ties() {
        let s = schema();
        assert_eq!(s.nodes.len(), 12, "one node per label");
        for (i, n) in s.nodes.iter().enumerate() {
            assert_eq!(n.label as usize, i, "node order mirrors LABELS");
        }
        // Person hub → every other label = 11 hub edges.
        let hub_edges = s.edges.iter().filter(|e| e.src as usize == PERSON).count();
        assert_eq!(hub_edges, 11, "Person ties to every other label");
        // The named cross-edges are present.
        let has = |from: &str, to: &str| {
            let (f, t) = (
                label_index(from).unwrap() as u32,
                label_index(to).unwrap() as u32,
            );
            s.edges.iter().any(|e| e.src == f && e.dst == t)
        };
        assert!(has("Property", "Address"), "Property↔Address cross-edge");
        assert!(has("Company", "Vehicle"), "Company↔Vehicle cross-edge");
        assert!(has("Debt", "KfmCase"), "Debt→KfmCase cross-edge");
        assert!(has("Company", "Address"), "Company→Address cross-edge");
        // total = 11 hub + 4 cross.
        assert_eq!(s.edges.len(), 15);
    }

    #[cfg(feature = "serde")]
    #[test]
    fn popgraph_serde_roundtrips() {
        let g = simulate(SimParams {
            n_persons: 50,
            n_kommun: 5,
            seed: 9,
        });
        let json = serde_json::to_string(&g).expect("serialize");
        let back: PopGraph = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(
            g, back,
            "PopGraph survives a JSON roundtrip (rel interned back)"
        );
        // rel really came back as one of the canonical statics, not "REL".
        assert!(back.edges.iter().all(|e| ALL_RELS.contains(&e.rel)));
    }

    #[test]
    fn intern_rel_maps_known_and_falls_back() {
        // Canonical rels intern back to the exact static pointer.
        assert_eq!(intern_rel("LIVES_AT"), "LIVES_AT");
        assert!(ALL_RELS.contains(&intern_rel("OWNS")));
        // Unknown / empty inputs never panic and collapse to the "REL" fallback.
        assert_eq!(intern_rel(""), "REL");
        assert_eq!(intern_rel("NONSENSE"), "REL");
    }

    #[cfg(feature = "serde")]
    #[test]
    fn intern_rel_falls_back_to_rel_literal_for_unknown() {
        // Deserializing a PopEdge carrying a foreign rel must stay total: the
        // hand-written Deserialize interns the unknown string to "REL" rather
        // than erroring, so any Vec<PopEdge> round-trips.
        let json = r#"{"src":0,"dst":1,"rel":"NONSENSE"}"#;
        let e: PopEdge = serde_json::from_str(json).expect("deserialize is total");
        assert_eq!(e.rel, "REL");
        assert_eq!(e.src, 0);
        assert_eq!(e.dst, 1);
    }

    #[test]
    fn node_helpers_resolve_names() {
        let g = simulate(tiny());
        let person = g.nodes[0];
        assert_eq!(person.label_name(), "Person");
        assert!(!person.kommun_name().is_empty());
    }
}