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