facett-demo/src/sim.rs
//! **The live, ticking, in-memory SIMULATION** — the demo's heartbeat.
//!
//! `facett-demo` used to be a gallery of *static* fakes: every facet built once
//! from a frozen dataset and never moved. [`Sim`] makes it **alive**. A frame
//! counter on [`DemoState`](crate::DemoState) advances a small in-memory
//! population each ~[`TICK_EVERY`] frames (≈0.75 s); on every tick the population
//! grows, its debt → enforcement chain evolves, and three downstream facets are
//! fed fresh data IN PLACE:
//!
//! * the **warehouse** tab gains rows (the per-kommun population table grows),
//! * the **icebergview** tab gains a **new snapshot** (time-travel accrues — each
//! tick is one more commit you can scrub back to),
//! * the **Sverige · Population** + graph tabs re-take the latest [`PopGraph`] so
//! the linked panes animate as the population swells.
//!
//! It is **self-contained**: NO skade, NO catalog, NO network — the whole thing is
//! [`knut_popsim::simulate`] (pure, deterministic, wasm-clean) plus a couple of
//! Arrow `RecordBatch` builders. The tick is throttled so the gallery reads as a
//! *calm* living system, not a frantic one. Determinism is kept: timestamps are
//! derived from the tick index (`BASE_TS + tick * TICK_MS`), never wall-clock, so a
//! headless test gets byte-stable snapshots.
use std::sync::Arc;
use arrow_array::{Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use knut_popsim::{LABELS, PopGraph, SimParams, geo, label_index, simulate};
use facett::table::PreviewTable;
use facett::icebergview::{Snapshot, SnapshotMeta};
/// Repaint frames between ticks (~0.75 s at 60 fps). Calm, not frantic — the
/// gallery should *breathe*, not strobe.
pub const TICK_EVERY: u64 = 45;
/// Persons generated on the **first** tick — a small, snappy starting population.
pub const START_PERSONS: u32 = 600;
/// Persons added to the population on **each** subsequent tick (the growth rate).
pub const GROWTH_PER_TICK: u32 = 140;
/// The population ceiling — once reached the sim keeps ticking (new snapshots /
/// rows still accrue, the picture still re-renders) but the person count holds, so
/// it never blows memory in a long-running demo.
pub const MAX_PERSONS: u32 = 8_000;
/// Synthetic base timestamp (epoch ms) every snapshot is derived from. NOT
/// wall-clock — derived from the tick index so the stream is deterministic.
pub const BASE_TS: i64 = 1_700_000_000_000;
/// Milliseconds attributed to one tick (the inter-snapshot spacing the picker shows).
pub const TICK_MS: i64 = 750;
/// The warehouse / iceberg table name the sim feeds.
pub const TABLE: &str = "population";
/// The number of kommuner (communities) the population spreads over.
const N_KOMMUN: u16 = 14;
/// PRNG seed base — each tick advances the population deterministically off this.
const SEED: u64 = 0x5A1700;
/// The live, ticking, in-memory simulation that feeds the warehouse + icebergview +
/// population/graph facets. Owned by [`DemoState`](crate::DemoState); its
/// [`maybe_tick`](Sim::maybe_tick) is called once per frame from `draw_app`.
pub struct Sim {
/// Frames seen since construction (the throttle counter).
frames: u64,
/// Ticks performed (also the next snapshot id).
ticks: u64,
/// Current generated population size (grows each tick up to [`MAX_PERSONS`]).
n_persons: u32,
/// The latest ticking population graph (fed to the pop/graph tabs).
pop: PopGraph,
/// Every accrued Iceberg snapshot (one per tick, newest grows the list).
snapshots: Vec<Snapshot>,
/// The growing per-kommun preview table (the warehouse view's rows).
table: PreviewTable,
}
impl Default for Sim {
fn default() -> Self {
Self::new()
}
}
impl Sim {
/// Build the sim at tick 0 — generate the first population, the first snapshot,
/// and the first warehouse table so the facets are non-empty BEFORE any frame
/// ticks (the deck coverage gate renders them with real data immediately).
pub fn new() -> Self {
let n_persons = START_PERSONS;
let pop = simulate(SimParams { n_persons, n_kommun: N_KOMMUN, seed: SEED });
let table = population_table(&pop);
let snapshots = vec![snapshot_at(0, &pop)];
Self { frames: 0, ticks: 0, n_persons, pop, snapshots, table }
}
/// Advance the frame counter; **tick** (grow + accrue) every [`TICK_EVERY`]
/// frames. Returns `true` on a frame where a tick fired, so the caller can push
/// the fresh data into the deck's facets + request a repaint.
pub fn maybe_tick(&mut self) -> bool {
self.frames += 1;
if self.frames.is_multiple_of(TICK_EVERY) {
self.tick();
true
} else {
false
}
}
/// Force one tick (the headless / inject-assert path — no frame throttle). Grows
/// the population, re-simulates, appends exactly one snapshot, and grows the
/// warehouse table. Deterministic (timestamps derive from the tick index).
pub fn tick(&mut self) {
self.ticks += 1;
self.n_persons = (self.n_persons + GROWTH_PER_TICK).min(MAX_PERSONS);
// Advance the population: re-simulate at the grown size. The seed is folded
// with the tick so the graph evolves (new people, new debts/enforcement),
// not just scales a fixed pattern — deterministic per tick.
self.pop = simulate(SimParams {
n_persons: self.n_persons,
n_kommun: N_KOMMUN,
seed: SEED ^ self.ticks,
});
self.table = population_table(&self.pop);
self.snapshots.push(snapshot_at(self.ticks, &self.pop));
}
/// The number of ticks performed (0 immediately after [`new`](Self::new)).
pub fn ticks(&self) -> u64 {
self.ticks
}
/// The current generated population size.
pub fn n_persons(&self) -> u32 {
self.n_persons
}
/// The latest ticking population graph (handed to the pop/graph tabs).
pub fn pop(&self) -> &PopGraph {
&self.pop
}
/// The growing warehouse preview table (rows = one per kommun).
pub fn table(&self) -> &PreviewTable {
&self.table
}
/// The number of warehouse rows the current table carries.
pub fn table_rows(&self) -> usize {
self.table.rows.len()
}
/// The number of accrued snapshots (1 at tick 0, +1 per tick).
pub fn snapshot_count(&self) -> usize {
self.snapshots.len()
}
/// **Take** the accrued snapshots as a fresh `Vec<Snapshot>` to feed into an
/// [`IcebergView`](facett_icebergview::IcebergView). The snapshots are rebuilt
/// from the per-tick history (not moved out), so the sim keeps its own copy and
/// can keep growing the list — the caller gets an independent, complete set.
pub fn iceberg_snapshots(&self) -> Vec<Snapshot> {
// Each historical snapshot is re-derivable from its tick's deterministic
// population, but we keep the materialized batches around so we don't
// re-simulate the whole history each frame. Clone the cheap RecordBatch
// handles (Arrow batches are Arc-backed, so this is a refcount bump).
self.snapshots
.iter()
.map(|s| Snapshot::new(s.meta.clone(), s.batches.clone()))
.collect()
}
/// The sim's observable state (folded into the demo's `state_json` so a headless
/// test + an agent see the live stream as data: tick count, population size, the
/// accrued snapshot count, and the warehouse row count).
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"ticks": self.ticks,
"frames": self.frames,
"tick_every": TICK_EVERY,
"n_persons": self.n_persons,
"max_persons": MAX_PERSONS,
"table": TABLE,
"warehouse_rows": self.table.rows.len(),
"snapshots": self.snapshots.len(),
"pop_nodes": self.pop.nodes.len(),
"pop_edges": self.pop.edges.len(),
"self_contained": true,
"source": "knut_popsim::simulate() — in-memory, no skade/catalog/network",
})
}
}
/// Build the per-kommun **population** preview table from a [`PopGraph`]: one row
/// per kommun with `persons`, `debts` (OWES count, the enforcement pipeline's
/// inflow), `enforcement` (KfmCase count), and `vehicles` — numeric columns so the
/// warehouse view offers its 📊 bar chart. Sorted by population descending.
fn population_table(g: &PopGraph) -> PreviewTable {
let n_k = geo::CITIES.len();
let mut persons = vec![0i64; n_k];
let mut debts = vec![0i64; n_k];
let mut enforce = vec![0i64; n_k];
let mut vehicles = vec![0i64; n_k];
let person_l = 0u8; // PERSON is label 0
let debt_l = label_index("Debt").unwrap_or(0) as u8;
let kfm_l = label_index("KfmCase").unwrap_or(0) as u8;
let veh_l = label_index("Vehicle").unwrap_or(0) as u8;
for node in &g.nodes {
let k = node.kommun as usize;
if k >= n_k {
continue;
}
if node.label == person_l {
persons[k] += 1;
} else if node.label == debt_l {
debts[k] += 1;
} else if node.label == kfm_l {
enforce[k] += 1;
} else if node.label == veh_l {
vehicles[k] += 1;
}
}
let mut rows: Vec<Vec<String>> = (0..n_k)
.filter(|&k| persons[k] > 0)
.map(|k| {
vec![
geo::CITIES[k].kommun.to_string(),
persons[k].to_string(),
debts[k].to_string(),
enforce[k].to_string(),
vehicles[k].to_string(),
]
})
.collect();
rows.sort_by(|a, b| {
let pa: i64 = a[1].parse().unwrap_or(0);
let pb: i64 = b[1].parse().unwrap_or(0);
pb.cmp(&pa)
});
PreviewTable::new(
vec![
"kommun".into(),
"persons".into(),
"debts".into(),
"enforcement".into(),
"vehicles".into(),
],
rows,
)
}
/// Build one Iceberg [`Snapshot`] for tick `tick` from the current population: a
/// per-kommun Arrow `RecordBatch` (the same shape the warehouse table shows), with
/// the deterministic, tick-derived timestamp.
fn snapshot_at(tick: u64, g: &PopGraph) -> Snapshot {
let batch = population_batch(g);
let rows = batch.num_rows() as u64;
let ts = BASE_TS + tick as i64 * TICK_MS;
Snapshot::new(SnapshotMeta::new(tick as i64, ts, rows), vec![batch])
}
/// The per-kommun population as an Arrow `RecordBatch` (one row per kommun with a
/// population, the columns matching [`population_table`]).
fn population_batch(g: &PopGraph) -> RecordBatch {
let n_k = geo::CITIES.len();
let mut persons = vec![0i64; n_k];
let mut debts = vec![0i64; n_k];
let mut enforce = vec![0i64; n_k];
let debt_l = label_index("Debt").unwrap_or(0) as u8;
let kfm_l = label_index("KfmCase").unwrap_or(0) as u8;
for node in &g.nodes {
let k = node.kommun as usize;
if k >= n_k {
continue;
}
if node.label == 0 {
persons[k] += 1;
} else if node.label == debt_l {
debts[k] += 1;
} else if node.label == kfm_l {
enforce[k] += 1;
}
}
let mut idx: Vec<usize> = (0..n_k).filter(|&k| persons[k] > 0).collect();
idx.sort_by(|&a, &b| persons[b].cmp(&persons[a]));
let kommun: Vec<&str> = idx.iter().map(|&k| geo::CITIES[k].kommun).collect();
let persons_c: Vec<i64> = idx.iter().map(|&k| persons[k]).collect();
let debts_c: Vec<i64> = idx.iter().map(|&k| debts[k]).collect();
let enforce_c: Vec<i64> = idx.iter().map(|&k| enforce[k]).collect();
let _ = LABELS; // (keep the label register in scope for readers of this builder)
RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("kommun", DataType::Utf8, false),
Field::new("persons", DataType::Int64, false),
Field::new("debts", DataType::Int64, false),
Field::new("enforcement", DataType::Int64, false),
])),
vec![
Arc::new(StringArray::from(kommun)),
Arc::new(Int64Array::from(persons_c)),
Arc::new(Int64Array::from(debts_c)),
Arc::new(Int64Array::from(enforce_c)),
],
)
.expect("population batch columns are equal length")
}
#[cfg(test)]
mod tests {
use super::*;
/// INJECT-ASSERT (LAW 6): one `tick()` grows the population count, appends EXACTLY
/// one snapshot, and grows (re-derives) the warehouse table — the living-stream
/// contract, asserted as state values, not "didn't panic".
#[test]
fn tick_grows_population_appends_one_snapshot_and_grows_table() {
let mut sim = Sim::new();
// Tick 0 already seeded one snapshot + a non-empty table.
assert_eq!(sim.snapshot_count(), 1, "tick 0 seeds the first snapshot");
assert!(sim.table_rows() > 0, "tick 0 seeds a non-empty warehouse table");
let persons0 = sim.n_persons();
let snaps0 = sim.snapshot_count();
sim.tick();
assert_eq!(sim.ticks(), 1, "one tick performed");
assert!(sim.n_persons() > persons0, "the population grew ({} > {persons0})", sim.n_persons());
assert_eq!(sim.snapshot_count(), snaps0 + 1, "exactly one snapshot appended per tick");
// The grown population fans out to more nodes/edges than the seed.
assert!(sim.pop().nodes.len() > persons0 as usize, "more nodes than the seed person count");
}
/// INJECT-ASSERT: after K ticks the accrued snapshot set is K+1 (tick 0 seed +
/// one per tick), each snapshot carries a deterministic tick-derived timestamp
/// (strictly increasing), and the population keeps growing toward the cap.
#[test]
fn k_ticks_accrue_k_plus_one_snapshots_with_monotonic_timestamps() {
let mut sim = Sim::new();
const K: u64 = 6;
for _ in 0..K {
sim.tick();
}
assert_eq!(sim.ticks(), K);
let snaps = sim.iceberg_snapshots();
assert_eq!(snaps.len() as u64, K + 1, "K ticks + the tick-0 seed = K+1 snapshots");
// Timestamps derive from the tick index (deterministic, monotonic).
let mut ts: Vec<i64> = snaps.iter().map(|s| s.meta.timestamp_ms).collect();
ts.sort();
for w in ts.windows(2) {
assert!(w[1] > w[0], "snapshot timestamps strictly increase ({} !> {})", w[1], w[0]);
}
// The latest snapshot id is the latest tick.
assert!(snaps.iter().any(|s| s.meta.id == K as i64), "the K-th tick's snapshot is present");
}
/// INJECT-ASSERT: the per-kommun population table has the chartable numeric
/// columns the warehouse view needs (persons/debts/enforcement/vehicles), and a
/// later tick re-derives MORE total persons than tick 0.
#[test]
fn population_table_is_numeric_and_grows() {
let mut sim = Sim::new();
let total0: i64 = sim.table().rows.iter().map(|r| r[1].parse::<i64>().unwrap_or(0)).sum();
assert_eq!(sim.table().columns.len(), 5, "kommun + 4 numeric columns");
// persons (col 1), debts (2), enforcement (3), vehicles (4) are numeric.
assert_eq!(sim.table().numeric_columns(), vec![1, 2, 3, 4]);
for _ in 0..4 {
sim.tick();
}
let total1: i64 = sim.table().rows.iter().map(|r| r[1].parse::<i64>().unwrap_or(0)).sum();
assert!(total1 > total0, "the population grew over ticks ({total1} > {total0})");
}
/// Determinism: two sims ticked the same number of times produce byte-identical
/// state (timestamps derived from the tick index, seed folded with the tick).
#[test]
fn ticks_are_deterministic() {
let mut a = Sim::new();
let mut b = Sim::new();
for _ in 0..5 {
a.tick();
b.tick();
}
assert_eq!(a.state_json(), b.state_json(), "same tick count → identical observable state");
assert_eq!(a.pop(), b.pop(), "same tick count → byte-identical population graph");
}
/// The throttle: `maybe_tick` only ticks on the [`TICK_EVERY`]-th frame.
#[test]
fn maybe_tick_throttles_to_tick_every_frames() {
let mut sim = Sim::new();
let mut ticks = 0;
for _ in 0..TICK_EVERY {
if sim.maybe_tick() {
ticks += 1;
}
}
assert_eq!(ticks, 1, "exactly one tick fired across TICK_EVERY frames");
assert_eq!(sim.ticks(), 1);
}
}