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