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/tests/robot_popsim_graph.rs

//! **ROBOT — the simulated population graph, asserted on its EDGES.**
//!
//! `knut-popsim`'s in-crate tests check counts and determinism: the graph has
//! nodes, the same params give the same graph, `n_kommun` bounds the community
//! index. None of them looks at where an edge actually *goes*. An edge table
//! that pointed every relationship at node 0, or that wired `LIVES_AT` to a
//! `Vehicle`, or that dropped the kommun inheritance, passes all of them —
//! and the graph that reaches FalkorDB and the viewer is nonsense.
//!
//! Every assertion here is on **applied output**: the label of the node each
//! edge endpoint resolves to, the exact per-relationship edge counts against the
//! per-label node counts, the exact `(src_label, rel, dst_label)` triples of the
//! schema meta-graph, and the exact set of community indices in use.
//!
//! Nothing sits on an identity value:
//!
//! * `n_kommun` is **5**, not `1` (which cannot tell a working clamp from a dead
//!   one, since every node would be community 0) and not `14` (the default and
//!   the maximum, which cannot tell a working clamp from no clamp at all);
//! * the seed is not `0`;
//! * the tie probabilities are asserted to land **strictly between** 0 and
//!   `n_persons` for a middling label, so the graph is neither all-or-nothing;
//! * the non-vacuous anchor pairs a run that MUST produce edges with a run that
//!   MUST produce none, and asserts the two differ.
//!
//! Run: `cargo test -p knut-popsim --test robot_popsim_graph -- --nocapture`

use std::collections::{BTreeMap, BTreeSet};

use knut_popsim::{
    LABELS, PERSON, PERSON_REL, PERSON_TIE_PROB, PopGraph, SimParams, geo, label_index, schema,
    simulate,
};

/// The fixture. Off every identity: 5 of 14 communities, a non-zero seed, and
/// enough people that a probability of 0.10 still lands hundreds of edges.
fn params() -> SimParams {
    SimParams {
        n_persons: 3_000,
        n_kommun: 5,
        seed: 0x5EED_1234,
    }
}

fn label_name(g: &PopGraph, idx: u32) -> &'static str {
    LABELS[g.nodes[idx as usize].label as usize].0
}

/// **Every edge lands on the endpoints its relationship name promises.**
///
/// For each of the 11 `Person -> entity` relationships the simulator emits, the
/// `src` node must be a `Person`, the `dst` node must carry exactly the label
/// whose [`PERSON_REL`] slot names that relationship, and the two must share a
/// kommun (an entity lives in its owner's community). Asserted per edge, not in
/// aggregate — an aggregate count is satisfied by any permutation.
#[test]
fn robot_every_edge_connects_a_person_to_the_label_its_rel_names() {
    let g = simulate(params());

    // The fixture must actually have edges, or every loop below is vacuous.
    assert!(
        g.edges.len() > 1_000,
        "the fixture must produce a real edge set, got {}",
        g.edges.len()
    );

    // rel name → the label index it is declared to point at.
    let rel_target: BTreeMap<&str, usize> =
        (1..LABELS.len()).map(|li| (PERSON_REL[li], li)).collect();
    assert_eq!(rel_target.len(), 11, "11 distinct Person-> relationships");

    let mut per_rel: BTreeMap<&str, usize> = BTreeMap::new();
    let mut dst_seen: BTreeSet<u32> = BTreeSet::new();

    for (i, e) in g.edges.iter().enumerate() {
        let src = g.nodes[e.src as usize];
        let dst = g.nodes[e.dst as usize];

        assert_eq!(
            src.label as usize,
            PERSON,
            "edge #{i} ({}) must start at a Person, started at {}",
            e.rel,
            label_name(&g, e.src)
        );
        let want = *rel_target.get(e.rel).unwrap_or_else(|| {
            panic!(
                "edge #{i} carries relationship `{}`, which is not a declared Person-> rel",
                e.rel
            )
        });
        assert_eq!(
            dst.label as usize,
            want,
            "a `{}` edge must end on {}, ended on {}",
            e.rel,
            LABELS[want].0,
            label_name(&g, e.dst)
        );
        assert_eq!(
            dst.kommun, src.kommun,
            "edge #{i} ({}) — the entity must inherit its owner's kommun: {} vs {}",
            e.rel, dst.kommun, src.kommun
        );
        assert_ne!(e.src, e.dst, "edge #{i} is a self-loop");

        // Each entity node has exactly ONE owner — a duplicated dst would mean
        // the generator re-used a node index, which silently merges two people.
        assert!(
            dst_seen.insert(e.dst),
            "node {} is the destination of two edges — entity nodes are 1:1 with their edge",
            e.dst
        );
        *per_rel.entry(e.rel).or_default() += 1;
    }

    // ── APPLIED OUTPUT: per-rel edge count == per-label node count, exactly. ──
    for li in 1..LABELS.len() {
        let rel = PERSON_REL[li];
        let edges = per_rel.get(rel).copied().unwrap_or(0);
        let nodes = g.count_label(li);
        assert_eq!(
            edges, nodes,
            "every {} node is created together with its `{rel}` edge — {edges} edges vs {nodes} nodes",
            LABELS[li].0
        );
        assert!(edges > 0, "label {} produced no edges at all", LABELS[li].0);
    }
    assert_eq!(
        per_rel.len(),
        11,
        "all 11 relationships appear: {per_rel:?}"
    );

    eprintln!(
        "[robot-popsim] {} nodes / {} edges · per-rel {per_rel:?}",
        g.nodes.len(),
        g.edges.len()
    );
}

/// **The non-vacuous anchor.** A run that MUST produce a graph, paired with a
/// run that MUST produce nothing, asserted to differ.
///
/// Plus the identity escape on the probability table: a middling label
/// (`KfmCase`, p = 0.10) must land **strictly between** zero and one-per-person.
/// A generator that ignored [`PERSON_TIE_PROB`] and wired every label to every
/// person would satisfy "edges exist"; so would one that fired only for p = 1.0.
#[test]
fn robot_zero_people_yield_no_graph_and_a_middling_probability_is_partial() {
    // ── must produce NONE ──
    let empty = simulate(SimParams {
        n_persons: 0,
        ..params()
    });
    assert_eq!(
        empty.nodes.len(),
        0,
        "no people → no nodes: {:?}",
        empty.nodes.len()
    );
    assert_eq!(empty.edges.len(), 0, "no people → no edges");

    // ── must produce OUTPUT ──
    let full = simulate(params());
    assert!(full.nodes.len() > 3_000 && full.edges.len() > 1_000);

    // ── and they differ. If this ever held, every other assertion is vacuous. ──
    assert_ne!(
        (empty.nodes.len(), empty.edges.len()),
        (full.nodes.len(), full.edges.len()),
        "the empty and populated runs must differ"
    );

    // ── identity escape on the tie probabilities ──
    let n = params().n_persons as usize;
    let kfm = label_index("KfmCase").expect("KfmCase is a label");
    assert!(
        (PERSON_TIE_PROB[kfm] - 0.10).abs() < 1e-9,
        "fixture assumes KfmCase p = 0.10, table says {}",
        PERSON_TIE_PROB[kfm]
    );
    let got = full.count_label(kfm);
    assert!(
        got > 0 && got < n,
        "a p=0.10 label must be PARTIAL — {got} of {n} people. 0 means the \
         probability table is ignored downward; {n} means it is ignored upward, \
         and both look like 'the graph has edges'."
    );
    // Within a wide band of the declared rate — loose enough not to be flaky,
    // tight enough that swapping the table for a constant fails.
    let share = got as f64 / n as f64;
    assert!(
        (0.06..0.15).contains(&share),
        "KfmCase share {share:.4} must track its declared p=0.10, not some other slot"
    );
    // A high-probability label must be markedly denser than the low one — the
    // ordering of the table is applied, not just its presence.
    let tax = label_index("TaxRecord").expect("TaxRecord is a label");
    assert!(
        full.count_label(tax) > got * 5,
        "TaxRecord (p=0.99) must dwarf KfmCase (p=0.10): {} vs {got}",
        full.count_label(tax)
    );
}

/// **`n_kommun` is a real projection, not a pass-through.**
///
/// With `n_kommun = 5` the community index must take **exactly** the values
/// `{0,1,2,3,4}` — all five used, none beyond. Then the same seed at
/// `n_kommun = 14` must produce a **different** assignment, which is what a dead
/// clamp (or a clamp that always collapsed to 0) could not do.
#[test]
fn robot_n_kommun_bounds_and_actually_changes_the_assignment() {
    let five = simulate(params());
    let used: BTreeSet<u16> = five.nodes.iter().map(|n| n.kommun).collect();
    assert_eq!(
        used,
        BTreeSet::from([0, 1, 2, 3, 4]),
        "n_kommun = 5 must use exactly communities 0..4, got {used:?}"
    );

    let all = simulate(SimParams {
        n_kommun: geo::CITIES.len() as u16,
        ..params()
    });
    let used_all: BTreeSet<u16> = all.nodes.iter().map(|n| n.kommun).collect();
    assert!(
        used_all.len() > used.len(),
        "14 communities must reach more than 5 did: {} vs {}",
        used_all.len(),
        used.len()
    );
    assert!(
        used_all.contains(&13),
        "the last community must be reachable at n_kommun = 14: {used_all:?}"
    );

    // The two graphs have the same NODE COUNT but different kommun vectors — so
    // a test comparing only counts would see nothing here.
    let k5: Vec<u16> = five.nodes.iter().map(|n| n.kommun).collect();
    let k14: Vec<u16> = all.nodes.iter().map(|n| n.kommun).collect();
    assert_ne!(k5, k14, "changing n_kommun must change where people live");

    // Over-large `n_kommun` clamps to the city count rather than escaping it.
    let over = simulate(SimParams {
        n_kommun: 999,
        ..params()
    });
    let max = over.nodes.iter().map(|n| n.kommun).max().expect("nodes");
    assert!(
        (max as usize) < geo::CITIES.len(),
        "community {max} is outside CITIES ({})",
        geo::CITIES.len()
    );
}

/// **The schema meta-graph, edge for edge.**
///
/// `schema()` is what the viewer draws as the 12-label overview. Its in-crate
/// test checks `edges.len()`; this asserts the exact `(src, rel, dst)` triples by
/// LABEL NAME, so a re-ordering of [`LABELS`] (which would silently repoint every
/// `src`/`dst` index) fails here instead of shipping a wrong picture.
#[test]
fn robot_schema_meta_graph_has_exactly_these_named_edges() {
    let g = schema();
    assert_eq!(g.nodes.len(), 12, "one node per label");

    let mut got: Vec<(String, String, String)> = g
        .edges
        .iter()
        .map(|e| {
            (
                LABELS[g.nodes[e.src as usize].label as usize].0.to_string(),
                e.rel.to_string(),
                LABELS[g.nodes[e.dst as usize].label as usize].0.to_string(),
            )
        })
        .collect();
    got.sort();

    let mut want: Vec<(String, String, String)> = [
        ("Person", "LIVES_AT", "Address"),
        ("Person", "OWNS", "Company"),
        ("Person", "OWES", "Debt"),
        ("Person", "HOLDS", "Loan"),
        ("Person", "FILED", "TaxRecord"),
        ("Person", "SUBJECT_OF", "KfmCase"),
        ("Person", "FLAGGED_BY", "PoliceRecord"),
        ("Person", "RECEIVES", "FkBenefit"),
        ("Person", "REGISTERED_AT", "AfRecord"),
        ("Person", "DRIVES", "Vehicle"),
        ("Person", "OWNS_PROPERTY", "Property"),
        // the four cross-edges
        ("Property", "LOCATED_AT", "Address"),
        ("Company", "OPERATES", "Vehicle"),
        ("Debt", "ESCALATES_TO", "KfmCase"),
        ("Company", "REGISTERED_AT", "Address"),
    ]
    .iter()
    .map(|(a, r, b)| (a.to_string(), r.to_string(), b.to_string()))
    .collect();
    want.sort();

    assert_eq!(got, want, "the schema meta-graph's exact edge list");

    // The cross-edges are what make this a graph rather than a star. If they
    // vanished the picture would still have 11 edges and still look plausible.
    let non_person: Vec<&(String, String, String)> =
        got.iter().filter(|(s, _, _)| s != "Person").collect();
    assert_eq!(
        non_person.len(),
        4,
        "four non-Person cross-edges must survive: {non_person:?}"
    );
}

/// **The seed is a real input.** Two seeds over identical everything-else must
/// give different edge sets — asserted on the edge list, not on a count, because
/// the counts are close by construction.
#[test]
fn robot_seed_changes_the_edges_not_merely_the_totals() {
    let a = simulate(params());
    let b = simulate(SimParams {
        seed: 0xFACE_0FF,
        ..params()
    });

    let ea: Vec<(u32, u32, &str)> = a.edges.iter().map(|e| (e.src, e.dst, e.rel)).collect();
    let eb: Vec<(u32, u32, &str)> = b.edges.iter().map(|e| (e.src, e.dst, e.rel)).collect();
    assert_ne!(ea, eb, "a different seed must produce a different edge set");

    // …but the same seed must reproduce it byte for byte.
    let a2 = simulate(params());
    let ea2: Vec<(u32, u32, &str)> = a2.edges.iter().map(|e| (e.src, e.dst, e.rel)).collect();
    assert_eq!(ea, ea2, "the same seed must reproduce the same edges");
}