Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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