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