facett-korp/src/graph.rs
//! **facett-korp · GraphPane** — the korp **Graph** tab's analyst control surface
//! as a facett-Elm component (the Palantir entity-link moves L-3/L-4).
//!
//! korp's Graph tab welded its query bar into the 4000-line `main.rs`, so the
//! two analyst moves an investigator reaches for first —
//! **neighbour expansion** (click a node → pull its 1-hop links, grow the scene)
//! and **shortest-path finding** (how do these two entities connect?) — had no
//! drivable surface. This pane extracts that control surface: a Cypher query box,
//! a node picker + *Expand neighbours* action, and a from/to picker + *Find path*
//! action. Each action emits a [`GraphEffect`] the korp host turns into a real
//! FalkorDB Cypher query built by `korp::graph::{neighbours_cypher, path_cypher}`
//! (the PURE builders that already exist and are unit-tested) and run on a worker
//! thread that grows / rebuilds the scene.
//!
//! ## The Elm seam (why there is no `korp` dependency)
//!
//! korp *depends on this crate* to render its Graph controls, so this crate must
//! NOT depend on `korp` (cycle). It stays a pure leaf: it holds only the current
//! scene's node ids/labels (the host feeds them in via [`GraphPane::sync_scene`])
//! and emits **effects as data** — the actual Cypher building + DB round-trip is a
//! host concern. It implements [`facett_core::Elm`]; [`facett_core::impl_facet_via_elm!`]
//! bridges it to [`facett_core::Facet`]/[`facett_core::Panel`], so the headless
//! robot harness drives it through `update_json` and asserts `state_json`.
use egui::{Color32, Ui};
use facett_core::Elm;
use serde::{Deserialize, Serialize};
/// One node in the current scene — the host-fed roster the analyst picks from
/// when choosing which node to expand or which two nodes to path between. `id` is
/// the FalkorDB `id()` (an `i64`), so the emitted effect carries it verbatim.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphNodeRef {
pub id: i64,
pub label: String,
}
impl GraphNodeRef {
pub fn new(id: i64, label: impl Into<String>) -> Self {
Self { id, label: label.into() }
}
/// A short display caption: `label #id` (or just `#id` when unlabelled).
#[must_use]
pub fn caption(&self) -> String {
if self.label.is_empty() { format!("#{}", self.id) } else { format!("{} #{}", self.label, self.id) }
}
}
/// One ranked node in the **centrality** readout (Palantir gap **L-5**) — the
/// host-fed *who-matters* row the pane shows. The scores are computed host-side by
/// `korp::graph::GraphData::centrality()` (degree + Brandes betweenness over the
/// undirected scene); this crate only renders them (it holds no graph algorithm —
/// pure leaf).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CentralityRow {
pub id: i64,
pub label: String,
/// Distinct-neighbour degree.
pub degree: usize,
/// Brandes betweenness (undirected, unweighted).
pub betweenness: f64,
/// **Louvain community id** (0-based) — which denser-than-average cluster this
/// node belongs to. Shown as a `c{n}` tag so an analyst sees the grouping.
pub community: usize,
}
impl CentralityRow {
pub fn new(
id: i64,
label: impl Into<String>,
degree: usize,
betweenness: f64,
community: usize,
) -> Self {
Self { id, label: label.into(), degree, betweenness, community }
}
/// `label #id` (or `#id` unlabelled) — the caption the readout row shows.
#[must_use]
pub fn caption(&self) -> String {
if self.label.is_empty() { format!("#{}", self.id) } else { format!("{} #{}", self.label, self.id) }
}
}
/// The **timeline cross-filter status** (Palantir gap **G-9** — linked brushing):
/// when a timeline brush is active AND the scene is ontology-keyed, the host crops
/// the rendered graph to the brushed objects and reports how many of the scene's
/// nodes survived here (host-fed via [`GraphPane::sync_crossfilter`]).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CrossFilter {
/// Whether a timeline brush is currently cropping the graph.
pub active: bool,
/// Nodes shown after the crop.
pub shown: usize,
/// Nodes in the full (uncropped) scene.
pub total: usize,
/// Distinct object ids in the brush window (the payload size).
pub subjects: usize,
}
/// The complete observable state of the Graph-controls pane (FC-1/FC-3).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphModel {
pub title: String,
/// The free Cypher the *Run query* button submits.
pub cypher: String,
/// The current scene's nodes (host-fed via [`GraphPane::sync_scene`]).
pub nodes: Vec<GraphNodeRef>,
/// Live scene size (host-fed) — surfaced so a robot can assert the scene GREW
/// after an expand.
pub node_count: usize,
pub edge_count: usize,
/// Index into `nodes` of the node to expand, if any.
pub expand_sel: Option<usize>,
/// The 1-hop neighbour row cap for an expand.
pub limit: usize,
/// Indices into `nodes` of the path endpoints, if chosen.
pub path_from: Option<usize>,
pub path_to: Option<usize>,
/// Max hops for the bounded shortest path (floored at 1).
pub hops: usize,
/// **Centrality readout (L-5)** — the top nodes by degree/betweenness the host
/// computed from the current scene (host-fed via [`GraphPane::sync_centrality`],
/// highest first). Empty until a scene with edges has been scored.
pub centrality: Vec<CentralityRow>,
/// Number of connected components in the current scene (host-fed).
pub components: usize,
/// **Timeline cross-filter (G-9)** — the linked-brushing crop status (host-fed).
pub crossfilter: CrossFilter,
/// Number of **Louvain communities** in the current scene (host-fed) — always
/// `≥ components`.
pub communities: usize,
/// The community partition's **modularity** `Q` (host-fed) — higher = stronger
/// community structure.
pub modularity: f64,
/// Human note describing the last action (the queued query / expand / path).
pub last_action: Option<String>,
}
/// Every input the pane accepts (FC-2). Externally-tagged serde so the robot /
/// `update_json` seam sends `{"set_cypher":"…"}` / `{"select_expand":2}` and the
/// bare strings `"run_query"` / `"expand"` / `"find_path"` / `"clear"`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GraphMsg {
SetCypher(String),
RunQuery,
SelectExpand(usize),
Expand,
SelectFrom(usize),
SelectTo(usize),
SetHops(usize),
FindPath,
Clear,
}
/// Side work as data (FC-8): the korp host turns each into a real FalkorDB query.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GraphEffect {
/// Run this Cypher and REPLACE the scene.
RunQuery(String),
/// **Neighbour expansion (L-3)** — grow the scene with this node's 1-hop links.
/// The host builds `korp::graph::neighbours_cypher(id, limit)`.
ExpandNeighbours { id: i64, limit: usize },
/// **Shortest path (L-4)** — the bounded path between two entities. The host
/// builds `korp::graph::path_cypher(from, to, hops)`.
FindPath { from: i64, to: i64, hops: usize },
}
/// The korp Graph-controls pane.
#[derive(Clone, Debug, PartialEq)]
pub struct GraphPane {
model: GraphModel,
}
/// The default Run-query query — **Person-anchored + bounded + ORDER BY** so the
/// result is a legible, Person-centric (blue-dominant) wireframe instead of a dense
/// dominant-label hairball sampled from millions of arbitrary edges, and is
/// deterministic so a re-run doesn't reshuffle the scene. Uses the `src`/`dst`/`rel`/`sl`/
/// `dl` convention korp's `graph::parse` consumes, PLUS the optional `sk`/`dk`
/// natural-key columns (`coalesce` of a node's `mmsi`/`icao24`/`org_nr`) that let
/// `parse` resolve each typed node to its ontology `ObjectId` (I-8) — so a timeline
/// brush can cross-filter the graph (G-9). A node with none of those properties
/// yields `null` and simply stays unkeyed.
pub const DEFAULT_CYPHER: &str = "MATCH (a:Person)-[r]->(b) \
WITH a, r, b ORDER BY id(a), id(b) LIMIT 300 \
RETURN id(a) AS src, id(b) AS dst, \
type(r) AS rel, labels(a)[0] AS sl, labels(b)[0] AS dl, \
coalesce(a.mmsi, a.icao24, a.org_nr) AS sk, coalesce(b.mmsi, b.icao24, b.org_nr) AS dk";
impl Default for GraphPane {
fn default() -> Self {
Self::new("Graph")
}
}
impl GraphPane {
pub fn new(title: impl Into<String>) -> Self {
Self {
model: GraphModel {
title: title.into(),
cypher: DEFAULT_CYPHER.to_string(),
nodes: Vec::new(),
node_count: 0,
edge_count: 0,
expand_sel: None,
limit: 100,
path_from: None,
path_to: None,
hops: 4,
centrality: Vec::new(),
components: 0,
crossfilter: CrossFilter::default(),
communities: 0,
modularity: 0.0,
last_action: None,
},
}
}
/// Host feedback: replace the roster + scene size the analyst picks from. Called
/// each frame from korp's `draw_graph` with the live `GraphData`. Keeps any
/// selection whose index still resolves; drops one that fell out of range.
pub fn sync_scene(&mut self, nodes: Vec<GraphNodeRef>, edge_count: usize) {
self.model.node_count = nodes.len();
self.model.edge_count = edge_count;
let n = nodes.len();
self.model.nodes = nodes;
let clamp = |sel: &mut Option<usize>| {
if let Some(i) = *sel && i >= n {
*sel = None;
}
};
clamp(&mut self.model.expand_sel);
clamp(&mut self.model.path_from);
clamp(&mut self.model.path_to);
}
/// **Host feedback: the Cypher the host is about to RUN, into the box the analyst
/// reads** — the one writer for "the query text follows the opened graph".
///
/// # The defect this closes (korp lane GQ, the owner 2026-09-03)
///
/// The owner: *"SQL does not change when sweden is chosen."* Three Cypher texts lived
/// in korp's 🕸 view and only one was VISIBLE — this pane's `model.cypher`, seeded
/// [`DEFAULT_CYPHER`] (Person-anchored) at construction and written by nothing but
/// [`GraphMsg::SetCypher`], i.e. the analyst typing. Opening a listed graph computed
/// the right query, ran it correctly, and pushed it nowhere: `grep -r SetCypher` in
/// korp came back EMPTY. So the box showed a `:Person` query forever, whatever graph
/// was open, and ▶ Run then fired it at a road or collector graph — 0 rows, scene
/// wiped. Not cosmetic.
///
/// It is a `sync_*` setter beside [`sync_scene`](Self::sync_scene) rather than a
/// `Msg`, for the reason every other one here is: a host FACT is not an analyst
/// action. Routing it through `update(SetCypher(..))` would put "the host opened a
/// graph" and "a person typed" through the same door, and an undo/redo or an audit
/// that separates the two could never tell them apart.
pub fn sync_cypher(&mut self, cypher: impl Into<String>) {
self.model.cypher = cypher.into();
}
/// Host feedback: replace the **centrality readout** (L-5) from the scores the
/// host computed with `korp::graph::GraphData::centrality()`. Called each frame
/// from korp's `draw_graph` with the top-ranked nodes (highest first), the
/// scene's connected-component count, and the **Louvain** community count +
/// modularity. Pure display state — no effect, no msg.
pub fn sync_centrality(
&mut self,
top: Vec<CentralityRow>,
components: usize,
communities: usize,
modularity: f64,
) {
self.model.centrality = top;
self.model.components = components;
self.model.communities = communities;
self.model.modularity = modularity;
}
/// Host feedback: the **timeline cross-filter (G-9)** status. Called from korp's
/// `draw_graph`: when a timeline brush is active and the scene is ontology-keyed,
/// the host renders a scene cropped to the brushed objects and reports the crop
/// here (`shown` of `total` nodes, `subjects` in the window). Pure display state.
pub fn sync_crossfilter(&mut self, active: bool, shown: usize, total: usize, subjects: usize) {
self.model.crossfilter = CrossFilter { active, shown, total, subjects };
}
pub fn model(&self) -> &GraphModel {
&self.model
}
/// Index of the first node whose id equals `id`, if present.
pub fn index_of(&self, id: i64) -> Option<usize> {
self.model.nodes.iter().position(|n| n.id == id)
}
fn id_at(&self, sel: Option<usize>) -> Option<i64> {
sel.and_then(|i| self.model.nodes.get(i)).map(|n| n.id)
}
}
impl Elm for GraphPane {
type Model = GraphModel;
type Msg = GraphMsg;
type Effect = GraphEffect;
fn title(&self) -> &str {
&self.model.title
}
fn state(&self) -> &Self::Model {
&self.model
}
fn update(&mut self, msg: Self::Msg) -> Vec<Self::Effect> {
match msg {
GraphMsg::SetCypher(s) => {
self.model.cypher = s;
Vec::new()
}
GraphMsg::RunQuery => {
let q = self.model.cypher.trim().to_string();
if q.is_empty() {
self.model.last_action = Some("empty query".into());
Vec::new()
} else {
self.model.last_action = Some("queued query".into());
vec![GraphEffect::RunQuery(q)]
}
}
GraphMsg::SelectExpand(i) => {
if i < self.model.nodes.len() {
self.model.expand_sel = Some(i);
}
Vec::new()
}
GraphMsg::Expand => match self.id_at(self.model.expand_sel) {
Some(id) => {
self.model.last_action = Some(format!("expand neighbours of #{id}"));
vec![GraphEffect::ExpandNeighbours { id, limit: self.model.limit }]
}
None => {
self.model.last_action = Some("select a node to expand".into());
Vec::new()
}
},
GraphMsg::SelectFrom(i) => {
if i < self.model.nodes.len() {
self.model.path_from = Some(i);
}
Vec::new()
}
GraphMsg::SelectTo(i) => {
if i < self.model.nodes.len() {
self.model.path_to = Some(i);
}
Vec::new()
}
GraphMsg::SetHops(h) => {
self.model.hops = h.max(1);
Vec::new()
}
GraphMsg::FindPath => match (self.id_at(self.model.path_from), self.id_at(self.model.path_to)) {
(Some(from), Some(to)) if from != to => {
self.model.last_action = Some(format!("shortest path #{from} → #{to}"));
vec![GraphEffect::FindPath { from, to, hops: self.model.hops }]
}
(Some(_), Some(_)) => {
self.model.last_action = Some("from and to must differ".into());
Vec::new()
}
_ => {
self.model.last_action = Some("pick a from and a to node".into());
Vec::new()
}
},
GraphMsg::Clear => {
self.model.last_action = None;
Vec::new()
}
}
}
fn view(&self, ui: &mut Ui) -> Vec<Self::Msg> {
let mut msgs = Vec::new();
ui.heading("korp · Graph controls");
ui.label(format!("{} nodes · {} edges in scene", self.model.node_count, self.model.edge_count)); // plain numbers
// ── timeline cross-filter status (G-9 linked brushing) ──
if self.model.crossfilter.active {
let cf = &self.model.crossfilter;
ui.colored_label(
Color32::from_rgb(230, 200, 90),
format!(
"⏱ timeline cross-filter: showing {} of {} nodes ({} subjects in window)",
cf.shown, cf.total, cf.subjects
),
);
}
// ── free Cypher query (replace scene) ──
let mut cypher = self.model.cypher.clone();
if ui
.add(egui::TextEdit::multiline(&mut cypher).desired_rows(2).code_editor())
.changed()
{
msgs.push(GraphMsg::SetCypher(cypher));
}
if ui.button("▶ Run query").clicked() {
msgs.push(GraphMsg::RunQuery);
}
ui.separator();
// ── neighbour expansion (L-3) ──
ui.strong("Neighbour expand");
ui.horizontal(|ui| {
let sel_caption = self
.model
.expand_sel
.and_then(|i| self.model.nodes.get(i))
.map(GraphNodeRef::caption)
.unwrap_or_else(|| "— pick a node —".into());
egui::ComboBox::from_id_salt("korp_graph_expand").selected_text(sel_caption).show_ui(ui, |ui| {
for (i, node) in self.model.nodes.iter().enumerate() {
if ui.selectable_label(self.model.expand_sel == Some(i), node.caption()).clicked() {
msgs.push(GraphMsg::SelectExpand(i));
}
}
});
if ui.add_enabled(self.model.expand_sel.is_some(), egui::Button::new("⊕ Expand neighbours")).clicked() {
msgs.push(GraphMsg::Expand);
}
});
ui.separator();
// ── shortest path (L-4) ──
ui.strong("Shortest path");
ui.horizontal(|ui| {
let cap = |sel: Option<usize>| {
sel.and_then(|i| self.model.nodes.get(i)).map(GraphNodeRef::caption).unwrap_or_else(|| "—".into())
};
ui.label("from");
egui::ComboBox::from_id_salt("korp_graph_from").selected_text(cap(self.model.path_from)).show_ui(ui, |ui| {
for (i, node) in self.model.nodes.iter().enumerate() {
if ui.selectable_label(self.model.path_from == Some(i), node.caption()).clicked() {
msgs.push(GraphMsg::SelectFrom(i));
}
}
});
ui.label("to");
egui::ComboBox::from_id_salt("korp_graph_to").selected_text(cap(self.model.path_to)).show_ui(ui, |ui| {
for (i, node) in self.model.nodes.iter().enumerate() {
if ui.selectable_label(self.model.path_to == Some(i), node.caption()).clicked() {
msgs.push(GraphMsg::SelectTo(i));
}
}
});
let mut hops = self.model.hops as u32;
if ui.add(egui::DragValue::new(&mut hops).range(1..=12).prefix("≤ hops ")).changed() {
msgs.push(GraphMsg::SetHops(hops as usize));
}
if ui.button("⇄ Find path").clicked() {
msgs.push(GraphMsg::FindPath);
}
});
// ── centrality readout (L-5) ──
if !self.model.centrality.is_empty() {
ui.separator();
ui.strong("Centrality (L-5)");
ui.label(format!(
"{} connected components · {} communities (Q {:.2})",
self.model.components, self.model.communities, self.model.modularity
)); // plain numbers
let max_bet =
self.model.centrality.iter().map(|c| c.betweenness).fold(0.0_f64, f64::max).max(1.0);
// BOUNDED. This list used to render every ranked node straight into the
// pane's vertical flow — no scroll, no cap — so a scene with a few dozen
// brokers grew the readout until it pushed the GRAPH CANVAS off the bottom
// of the screen. The canvas is the point of the pane; a readout about it
// must never be able to evict it.
//
// The list is the thing that scrolls, so the pane's height stops depending
// on how many nodes the analysis happened to rank.
egui::ScrollArea::vertical()
.id_salt("korp_centrality_rows")
.auto_shrink([false, false])
.show(ui, |ui| {
for row in &self.model.centrality {
ui.horizontal(|ui| {
// a proportional betweenness bar so the broker stands out.
let frac = (row.betweenness / max_bet).clamp(0.0, 1.0) as f32;
let (rect, _) =
ui.allocate_exact_size(egui::vec2(60.0, 10.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(40, 50, 66));
let mut fill = rect;
fill.set_width(rect.width() * frac);
ui.painter().rect_filled(fill, 2.0, Color32::from_rgb(110, 180, 240));
ui.label(format!(
"{} deg {} · btw {:.1} · c{}",
row.caption(),
row.degree,
row.betweenness,
row.community
));
});
}
});
}
if let Some(a) = &self.model.last_action {
ui.separator();
ui.colored_label(Color32::from_rgb(120, 200, 120), a);
}
msgs
}
}
facett_core::impl_facet_via_elm!(GraphPane, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"kind": "korp-graph",
"title": self.model.title,
"cypher": self.model.cypher,
"node_count": self.model.node_count,
"edge_count": self.model.edge_count,
"expand_sel": self.model.expand_sel,
"expand_id": self.id_at(self.model.expand_sel),
"limit": self.model.limit,
"path_from": self.id_at(self.model.path_from),
"path_to": self.id_at(self.model.path_to),
"hops": self.model.hops,
// The centrality readout (L-5) — a robot asserts the ranked who-matters
// list + the component count here (host-fed from GraphData::centrality).
"components": self.model.components,
"communities": self.model.communities,
"modularity": (self.model.modularity * 1000.0).round() / 1000.0,
// G-9 timeline cross-filter status (linked brushing).
"crossfilter": {
"active": self.model.crossfilter.active,
"shown": self.model.crossfilter.shown,
"total": self.model.crossfilter.total,
"subjects": self.model.crossfilter.subjects,
},
"centrality": self.model.centrality.iter().map(|c| serde_json::json!({
"id": c.id,
"label": c.label,
"degree": c.degree,
"betweenness": (c.betweenness * 100.0).round() / 100.0,
"community": c.community,
})).collect::<Vec<_>>(),
"top_central_id": self.model.centrality.first().map(|c| c.id),
"last_action": self.model.last_action,
})
}
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<GraphMsg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
});
#[cfg(test)]
mod tests {
use super::*;
use facett_core::Panel;
fn seeded() -> GraphPane {
let mut p = GraphPane::new("Graph");
// A tiny scene: three entities the analyst can pick from.
p.sync_scene(
vec![
GraphNodeRef::new(1, "Person"),
GraphNodeRef::new(2, "Company"),
GraphNodeRef::new(3, "Vessel"),
],
2,
);
p
}
#[test]
fn fresh_pane_opens_with_the_default_query_and_no_action() {
let p = GraphPane::default();
assert_eq!(p.model().cypher, DEFAULT_CYPHER);
assert!(p.model().last_action.is_none());
assert_eq!(p.model().hops, 4);
assert_eq!(p.model().limit, 100);
}
/// **RED-when-broken: the host can move the query box, and it is not the same door
/// the analyst types through** (korp lane GQ, the owner 2026-09-03).
///
/// Two halves, and the second is the point. `sync_cypher` must actually replace the
/// text — a no-op setter would leave korp's "SQL does not change when sweden is
/// chosen" exactly where it was — and it must NOT record an analyst action, because
/// a host fact travelling the `Msg` door is indistinguishable from a person typing
/// to anything downstream that separates the two.
#[test]
fn the_host_can_set_the_cypher_without_it_reading_as_an_analyst_action() {
let mut p = GraphPane::default();
assert_eq!(p.model().cypher, DEFAULT_CYPHER, "it starts on the default");
p.sync_cypher("MATCH (n:Road)-[r]->(m) RETURN n, r, m LIMIT 1882");
assert_eq!(
p.model().cypher,
"MATCH (n:Road)-[r]->(m) RETURN n, r, m LIMIT 1882",
"the box follows the host — this is the whole defect",
);
assert!(
p.model().last_action.is_none(),
"a host sync is not an analyst action: {:?}",
p.model().last_action,
);
// …and ▶ Run then runs what the box SHOWS, which is what made the old state
// dangerous: the visible text and the executed text were two different strings.
let fx = p.update(GraphMsg::RunQuery);
assert_eq!(
fx,
vec![GraphEffect::RunQuery("MATCH (n:Road)-[r]->(m) RETURN n, r, m LIMIT 1882".into())],
);
}
#[test]
fn run_query_emits_run_effect_with_the_current_cypher() {
let mut p = seeded();
p.update(GraphMsg::SetCypher("MATCH (n) RETURN n".into()));
let fx = p.update(GraphMsg::RunQuery);
assert_eq!(fx, vec![GraphEffect::RunQuery("MATCH (n) RETURN n".into())]);
assert_eq!(p.model().last_action.as_deref(), Some("queued query"));
}
/// RED-when-broken: an empty query must NOT fire a DB round-trip.
#[test]
fn empty_query_is_a_clean_noop() {
let mut p = seeded();
p.update(GraphMsg::SetCypher(" ".into()));
assert!(p.update(GraphMsg::RunQuery).is_empty());
assert_eq!(p.model().last_action.as_deref(), Some("empty query"));
}
#[test]
fn expand_emits_neighbour_effect_for_the_selected_node_id() {
let mut p = seeded();
p.update(GraphMsg::SelectExpand(1)); // the Company, id=2
let fx = p.update(GraphMsg::Expand);
assert_eq!(fx, vec![GraphEffect::ExpandNeighbours { id: 2, limit: 100 }]);
assert_eq!(p.model().last_action.as_deref(), Some("expand neighbours of #2"));
}
/// RED-when-broken: expanding with nothing selected emits no effect.
#[test]
fn expand_without_selection_is_a_clean_noop() {
let mut p = seeded();
assert!(p.update(GraphMsg::Expand).is_empty());
assert_eq!(p.model().last_action.as_deref(), Some("select a node to expand"));
}
#[test]
fn find_path_emits_effect_with_both_endpoint_ids_and_hops() {
let mut p = seeded();
p.update(GraphMsg::SelectFrom(0)); // id=1
p.update(GraphMsg::SelectTo(2)); // id=3
p.update(GraphMsg::SetHops(6));
let fx = p.update(GraphMsg::FindPath);
assert_eq!(fx, vec![GraphEffect::FindPath { from: 1, to: 3, hops: 6 }]);
assert_eq!(p.model().last_action.as_deref(), Some("shortest path #1 → #3"));
}
/// RED-when-broken: path from a node to itself (or with an endpoint missing)
/// emits no effect — a degenerate path is refused.
#[test]
fn find_path_refuses_same_or_missing_endpoints() {
let mut p = seeded();
assert!(p.update(GraphMsg::FindPath).is_empty(), "no endpoints picked");
assert_eq!(p.model().last_action.as_deref(), Some("pick a from and a to node"));
p.update(GraphMsg::SelectFrom(0));
p.update(GraphMsg::SelectTo(0)); // same node
assert!(p.update(GraphMsg::FindPath).is_empty(), "from == to is degenerate");
assert_eq!(p.model().last_action.as_deref(), Some("from and to must differ"));
}
#[test]
fn set_hops_floors_at_one() {
let mut p = seeded();
p.update(GraphMsg::SetHops(0));
assert_eq!(p.model().hops, 1);
}
/// **Centrality readout (L-5).** The host feeds the top-ranked nodes + the
/// component count; the pane echoes them into `state_json` so a robot asserts the
/// broker ranking without pixels. RED-when-broken: `top_central_id` must be the
/// highest-ranked (first) row, and the component count must round-trip.
#[test]
fn sync_centrality_feeds_the_ranked_readout() {
let mut p = seeded();
// no readout until the host scores a scene.
assert!(p.model().centrality.is_empty());
assert_eq!(Panel::state_json(&p)["top_central_id"], serde_json::Value::Null);
// host feeds the P5-path scores: node #3 is the top broker (btw 4.0),
// all in one community (c0) with a modularity Q.
p.sync_centrality(
vec![
CentralityRow::new(3, "N3", 2, 4.0, 0),
CentralityRow::new(2, "N2", 2, 3.0, 0),
CentralityRow::new(4, "N4", 2, 3.0, 1),
],
1,
2,
0.25,
);
let st = Panel::state_json(&p);
assert_eq!(st["components"], 1);
assert_eq!(st["communities"], 2);
assert_eq!(st["modularity"], 0.25);
assert_eq!(st["top_central_id"], 3, "the middle broker ranks first");
assert_eq!(st["centrality"][0]["betweenness"], 4.0);
assert_eq!(st["centrality"][0]["community"], 0);
assert_eq!(st["centrality"].as_array().unwrap().len(), 3);
// a fresh scene with no edges clears the readout back to empty.
p.sync_centrality(Vec::new(), 0, 0, 0.0);
assert!(p.model().centrality.is_empty());
assert_eq!(Panel::state_json(&p)["top_central_id"], serde_json::Value::Null);
}
/// **Timeline cross-filter readout (G-9).** The host reports the crop status; the
/// pane echoes it into `state_json` so a robot asserts a brush narrowed the graph
/// without pixels. RED-when-broken: `active`/`shown`/`total` must round-trip, and
/// an inactive cross-filter must report `active:false`.
#[test]
fn sync_crossfilter_feeds_the_linked_brushing_status() {
let mut p = seeded();
// no cross-filter by default.
assert_eq!(Panel::state_json(&p)["crossfilter"]["active"], false);
// host crops the scene to 2 of 5 nodes from a 3-subject brush window.
p.sync_crossfilter(true, 2, 5, 3);
let st = Panel::state_json(&p);
assert_eq!(st["crossfilter"]["active"], true);
assert_eq!(st["crossfilter"]["shown"], 2);
assert_eq!(st["crossfilter"]["total"], 5);
assert_eq!(st["crossfilter"]["subjects"], 3);
// clearing the brush turns it off again.
p.sync_crossfilter(false, 5, 5, 0);
assert_eq!(Panel::state_json(&p)["crossfilter"]["active"], false);
}
/// The centrality readout is display-only: feeding it emits NO effect and does
/// not disturb the query/expand/path controls (RED-when-broken against a readout
/// that leaks into the Elm update loop).
#[test]
fn sync_centrality_is_pure_display_and_leaves_controls_untouched() {
let mut p = seeded();
p.update(GraphMsg::SelectExpand(1)); // id=2 armed
p.sync_centrality(vec![CentralityRow::new(2, "Company", 3, 1.5, 0)], 1, 1, 0.0);
// the armed expand selection survived the readout feed.
assert_eq!(Panel::state_json(&p)["expand_id"], 2);
let fx = p.update(GraphMsg::Expand);
assert_eq!(fx, vec![GraphEffect::ExpandNeighbours { id: 2, limit: 100 }]);
}
/// Emit the test-matrix cell so `nornir test --features testmatrix` SEES the
/// L-5 centrality readout is covered. No-op otherwise.
#[test]
fn testmatrix_cell_centrality_readout() {
let mut p = seeded();
p.sync_centrality(
vec![CentralityRow::new(3, "N3", 2, 4.0, 0), CentralityRow::new(2, "N2", 2, 3.0, 1)],
2,
2,
0.33,
);
let st = Panel::state_json(&p);
let ok = st["top_central_id"] == 3 && st["components"] == 2 && st["communities"] == 2;
facett_core::testmatrix::emit(
"facett-korp/graph",
"centrality_readout_l5",
ok,
"sync_centrality feeds ranked brokers + component + Louvain community count into state_json (L-5)",
);
assert!(ok);
}
/// A shrinking scene drops a now-out-of-range selection so a stale index can
/// never resolve to the wrong node.
#[test]
fn sync_scene_clamps_stale_selections() {
let mut p = seeded();
p.update(GraphMsg::SelectExpand(2)); // id=3
assert_eq!(p.model().expand_sel, Some(2));
p.sync_scene(vec![GraphNodeRef::new(1, "Person")], 0); // scene shrank to 1 node
assert_eq!(p.model().expand_sel, None, "the out-of-range selection was dropped");
assert_eq!(p.model().node_count, 1);
}
/// The **robot/remote-control seam driven IN CODE** — the exact `update_json`
/// a headless robot / test-matrix / bencher sends — select + expand, then
/// assert `state_json`. No pixels, self-driving.
#[test]
fn robot_json_drive_select_and_expand() {
let mut p = seeded();
Panel::update_json(&mut p, r#"{"select_expand":1}"#);
Panel::update_json(&mut p, "\"expand\"");
let st = Panel::state_json(&p);
assert_eq!(st["kind"], "korp-graph");
assert_eq!(st["expand_id"], 2);
assert_eq!(st["last_action"], "expand neighbours of #2");
// malformed / unknown messages are ignored, not a panic.
Panel::update_json(&mut p, "not json [[[");
Panel::update_json(&mut p, r#"{"nonsense":true}"#);
assert_eq!(Panel::state_json(&p)["expand_id"], 2);
}
#[test]
fn robot_json_drive_find_path() {
let mut p = seeded();
Panel::update_json(&mut p, r#"{"select_from":0}"#);
Panel::update_json(&mut p, r#"{"select_to":1}"#);
Panel::update_json(&mut p, r#"{"set_hops":3}"#);
Panel::update_json(&mut p, "\"find_path\"");
let st = Panel::state_json(&p);
assert_eq!(st["path_from"], 1);
assert_eq!(st["path_to"], 2);
assert_eq!(st["hops"], 3);
assert_eq!(st["last_action"], "shortest path #1 → #2");
}
/// Emit the test-matrix cells so `nornir test --features testmatrix` SEES the
/// L-3 (expand) + L-4 (path) analyst moves are covered. No-op otherwise.
#[test]
fn testmatrix_cell_expand_and_path_drive() {
let mut p = seeded();
Panel::update_json(&mut p, r#"{"select_expand":0}"#);
Panel::update_json(&mut p, "\"expand\"");
let ok_expand = Panel::state_json(&p)["expand_id"] == 1;
facett_core::testmatrix::emit(
"facett-korp/graph",
"expand_neighbours_l3_drive",
ok_expand,
"select→expand via update_json pins the node id in state_json (L-3)",
);
assert!(ok_expand);
let mut p = seeded();
Panel::update_json(&mut p, r#"{"select_from":0}"#);
Panel::update_json(&mut p, r#"{"select_to":2}"#);
Panel::update_json(&mut p, "\"find_path\"");
let ok_path = Panel::state_json(&p)["last_action"] == "shortest path #1 → #3";
facett_core::testmatrix::emit(
"facett-korp/graph",
"shortest_path_l4_drive",
ok_path,
"from/to→find_path via update_json emits the bounded path action (L-4)",
);
assert!(ok_path);
}
}