facett-graphview/src/falkor.rs
//! **FalkorDB browser** — the egui-free view-model behind a graph-DB browse pane.
//!
//! A `Cypher query → nodes/edges → laid-out [`GraphModel`]` state machine, with NO
//! egui and NO transport baked in. The redis/RESP wire (FalkorDB speaks the Redis
//! `GRAPH.QUERY` command over a `MultiplexedConnection`) lives at the **consumer**
//! (korp / dwarves): it implements the [`CypherRunner`] seam and hands this
//! view-model an already-parsed [`CypherResult`]. That keeps this crate pure-Rust,
//! dependency-light, and headless-testable — a [`CannedRunner`] stands in for a live
//! DB so [`FalkorBrowser::run_query`] round-trips a query with no server.
//!
//! # Why a view-model and not a Facet
//! The interactive pane is [`crate::decorated_view::DecoratedGraphView`] (the egui
//! surface that paints a [`GraphModel`]). This browser is the *data half*: it turns a
//! Cypher string into that model (force-directed via [`crate::gpu_layout::relax_cpu`],
//! reused — no second layout engine) and publishes a `state_json` a headless driver
//! asserts on (node/edge counts, the round-tripped query, the label breakdown). A host
//! wires the two together: `browser.model()` → `DecoratedGraphView::with_model`.
//!
//! # Contract (FC-1/FC-3/FC-8)
//! Pure messages ([`Msg::SetQuery`]/[`Clear`](Msg::Clear)/[`Select`](Msg::Select)/
//! [`SetEndpoint`](Msg::SetEndpoint)) mutate only in-memory state through
//! [`update`](FalkorBrowser::update). The one I/O step — running the query — is the
//! explicit [`run_query`](FalkorBrowser::run_query) call that takes a
//! [`CypherRunner`], so the side effect is a typed argument, never hidden in `update`.
use serde::{Deserialize, Serialize};
use crate::gpu_layout::{relax_cpu, GraphCsr, LayoutParams};
use crate::model::{Color, GraphEdge, GraphModel, GraphNode, Pos};
/// Where a FalkorDB graph lives: host, Redis port, and the graph key
/// (`GRAPH.QUERY <graph> <cypher>`). Defaults to the korp demo endpoint
/// (`localhost:6379`, graph `korp_demo`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FalkorEndpoint {
pub host: String,
pub port: u16,
/// The FalkorDB graph key the query targets.
pub graph: String,
}
impl Default for FalkorEndpoint {
fn default() -> Self {
Self { host: "localhost".into(), port: 6379, graph: "korp_demo".into() }
}
}
impl FalkorEndpoint {
pub fn new(host: impl Into<String>, port: u16, graph: impl Into<String>) -> Self {
Self { host: host.into(), port, graph: graph.into() }
}
/// A `host:port/graph` display string.
pub fn display(&self) -> String {
format!("{}:{}/{}", self.host, self.port, self.graph)
}
}
/// One node in a Cypher result — the FalkorDB node record shape: an internal `id`,
/// zero-or-more `labels`, and an optional human `caption` (a `name`/`title`/`id`
/// property the caller picked out). The engine never invents captions; it falls back
/// to `#id` when `caption` is `None`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CypherNode {
pub id: i64,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub caption: Option<String>,
}
impl CypherNode {
pub fn new(id: i64, label: impl Into<String>, caption: impl Into<String>) -> Self {
Self { id, labels: vec![label.into()], caption: Some(caption.into()) }
}
/// The node's primary label (its first), or `""` when unlabelled.
pub fn primary_label(&self) -> &str {
self.labels.first().map(String::as_str).unwrap_or("")
}
/// The display caption: the explicit caption, else `#<id>`.
pub fn display_caption(&self) -> String {
self.caption.clone().unwrap_or_else(|| format!("#{}", self.id))
}
}
/// One relation in a Cypher result: endpoints by node `id` + a relationship `type`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CypherEdge {
pub src: i64,
pub dst: i64,
#[serde(default)]
pub rel_type: String,
}
impl CypherEdge {
pub fn new(src: i64, dst: i64, rel_type: impl Into<String>) -> Self {
Self { src, dst, rel_type: rel_type.into() }
}
}
/// A parsed Cypher response: the nodes + relations a `GRAPH.QUERY` returned. This is
/// the seam type — a [`CypherRunner`] parses the FalkorDB reply into this; the
/// view-model never touches the wire.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct CypherResult {
pub nodes: Vec<CypherNode>,
pub edges: Vec<CypherEdge>,
}
impl CypherResult {
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub fn edge_count(&self) -> usize {
self.edges.len()
}
}
/// **The pluggable executor seam.** A consumer (korp/dwarves) implements this over a
/// live FalkorDB (`redis::Client` → `GRAPH.QUERY`), parsing the reply into a
/// [`CypherResult`]. The view-model depends only on this trait, so it builds and
/// tests with zero network deps. Object-safe (`&mut dyn CypherRunner`).
pub trait CypherRunner {
/// Run `query` against `graph`, returning the parsed result or a human error
/// string (surfaced as [`BrowserStatus::Error`]).
fn run(&mut self, graph: &str, query: &str) -> Result<CypherResult, String>;
}
/// A headless stand-in for a live DB: returns a fixed [`CypherResult`] (or a canned
/// error) and records the last query it saw, so a robot test can prove the query
/// round-tripped through the view-model to the runner with no server running.
#[derive(Clone, Debug, Default)]
pub struct CannedRunner {
result: CypherResult,
error: Option<String>,
/// The last `(graph, query)` this runner was handed — the round-trip witness.
pub last: Option<(String, String)>,
}
impl CannedRunner {
/// A runner that always returns `result`.
pub fn ok(result: CypherResult) -> Self {
Self { result, error: None, last: None }
}
/// A runner that always fails with `msg` (the fail-loud path).
pub fn failing(msg: impl Into<String>) -> Self {
Self { result: CypherResult::default(), error: Some(msg.into()), last: None }
}
}
impl CypherRunner for CannedRunner {
fn run(&mut self, graph: &str, query: &str) -> Result<CypherResult, String> {
self.last = Some((graph.to_string(), query.to_string()));
match &self.error {
Some(e) => Err(e.clone()),
None => Ok(self.result.clone()),
}
}
}
/// The outcome of the last query — the observable status (FC-1).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BrowserStatus {
/// No query run yet.
#[default]
Idle,
/// The last query succeeded and returned this many nodes/edges.
Ok { nodes: usize, edges: usize },
/// The last query failed — the error surfaced fail-loud (never a silent empty graph).
Error { message: String },
}
/// A robot-/host-addressable control message. The pure ones mutate only in-memory
/// state through [`FalkorBrowser::update`]; running the query is the separate
/// [`FalkorBrowser::run_query`] call (it needs a [`CypherRunner`], so the I/O is a
/// typed argument, not a hidden effect).
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Replace the editable Cypher text.
SetQuery(String),
/// Point at a different graph/endpoint.
SetEndpoint(FalkorEndpoint),
/// Select a node by its id string (`"#<id>"` space), or `None` to clear.
Select(Option<String>),
/// Drop the current result graph + status (back to Idle), keep the query text.
Clear,
}
/// **The FalkorDB browser view-model.** Holds the endpoint, the editable query, the
/// last-run status, the laid-out result [`GraphModel`], a bounded query history, and
/// the selected node id. Egui-free; a host paints [`model`](Self::model) through
/// [`crate::decorated_view::DecoratedGraphView`].
#[derive(Clone, Debug)]
pub struct FalkorBrowser {
endpoint: FalkorEndpoint,
query: String,
status: BrowserStatus,
model: GraphModel,
/// Distinct labels seen in the current result (categorical-colour order).
labels: Vec<String>,
selected: Option<String>,
history: Vec<String>,
/// Force-layout iteration budget for `run_query` (deterministic).
iters: usize,
/// Which lane laid the current result out, and the GPU job while it runs.
layout: LayoutSlot,
}
/// **The largest answer the browser lays out on the CPU** — [`relax_cpu`] is the
/// all-pairs force layout, O(n²) per step (140 steps): 1 000 nodes is ~1.4·10⁸ pair
/// visits, 100 000 would be ~1.4·10¹². Above it the layout is the GPU's (a host-lent
/// device, [`crate::gpu_layout::install_host_device`]), and with no device the state
/// says `no GPU` and the nodes stay on the seed spiral — there is no CPU lane behind it.
/// With a device EVERY size goes to the GPU (the 2026-09-28 GPU ruling).
pub const CPU_LAYOUT_MAX: usize = 1_000;
/// Which lane laid the current result out, plus the in-flight GPU job. `Clone` yields
/// the readouts without the job (a job is VRAM buffers and a map callback; a cloned
/// browser keeps the placeholder positions it was cloned with).
#[derive(Default)]
struct LayoutSlot {
/// `"cpu"`, `"gpu-job"`, `"no GPU"` or `""` (nothing laid out yet).
lane: &'static str,
/// The repulsion kernel of the GPU job (`"all-pairs"` / `"grid"`), `""` for none.
kernel: &'static str,
/// Why the GPU lane was not (or no longer) used — `""` when it was.
note: &'static str,
/// Layout units → world units for the job's positions.
world_scale: f32,
#[cfg(feature = "gpu")]
job: Option<Box<crate::gpu_layout::LayoutJob>>,
}
impl Clone for LayoutSlot {
fn clone(&self) -> Self {
Self { lane: self.lane, kernel: self.kernel, note: self.note, world_scale: self.world_scale, ..Self::default() }
}
}
impl std::fmt::Debug for LayoutSlot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LayoutSlot").field("lane", &self.lane).field("kernel", &self.kernel).field("note", &self.note).finish()
}
}
/// World-space scale applied to the (unit-ish) relaxed layout so chips don't overlap.
const LAYOUT_SCALE: f32 = 90.0;
/// Rest length fed to the force layout — a few `ideal_len` apart before scaling.
const LAYOUT_IDEAL: f32 = 3.0;
/// A small categorical palette (dark-friendly, 8 distinct hues) that colours nodes by
/// their primary label. Local to the browser: there is no shared POD-`Color`
/// categorical palette to reuse yet, and this is presentation only.
const LABEL_PALETTE: [Color; 8] = [
Color::rgb(0x5b, 0x8f, 0xf9), // blue
Color::rgb(0x61, 0xcd, 0xbb), // teal
Color::rgb(0xf6, 0xc8, 0x5f), // amber
Color::rgb(0xef, 0x86, 0x6b), // coral
Color::rgb(0xa0, 0x6f, 0xf5), // violet
Color::rgb(0x9d, 0xc3, 0x5e), // green
Color::rgb(0xe0, 0x6b, 0xa8), // magenta
Color::rgb(0x7a, 0x9a, 0xa8), // slate
];
/// The default starter query — every node + relationship, capped (the "show me the
/// graph" button a browser opens on).
pub const DEFAULT_QUERY: &str = "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100";
impl Default for FalkorBrowser {
fn default() -> Self {
Self {
endpoint: FalkorEndpoint::default(),
query: DEFAULT_QUERY.to_string(),
status: BrowserStatus::Idle,
model: GraphModel::default(),
labels: Vec::new(),
selected: None,
history: Vec::new(),
iters: 140,
layout: LayoutSlot::default(),
}
}
}
impl FalkorBrowser {
/// A fresh browser pointed at `endpoint`, seeded with the default query.
pub fn new(endpoint: FalkorEndpoint) -> Self {
Self { endpoint, ..Self::default() }
}
/// **Discovery-contract `local()`** — a browser whose *already-run* result is a
/// small canned constellation (3 repos → crates → a dep edge), so the pane paints a
/// real laid-out graph with no DB. Mirrors korp's `korp_demo` shape.
pub fn local() -> Self {
let mut b = FalkorBrowser {
query: "MATCH (r:Repo)-[:HAS]->(c:Crate) RETURN r, c".into(),
..Default::default()
};
let mut runner = CannedRunner::ok(demo_result());
// Drive the real path so `local()` and a live run produce byte-identical state.
b.run_query(&mut runner);
b
}
/// The endpoint (host/port/graph).
pub fn endpoint(&self) -> &FalkorEndpoint {
&self.endpoint
}
/// The current editable Cypher text.
pub fn query(&self) -> &str {
&self.query
}
/// The last-run status.
pub fn status(&self) -> &BrowserStatus {
&self.status
}
/// The laid-out result graph (paint this through `DecoratedGraphView`).
pub fn model(&self) -> &GraphModel {
&self.model
}
/// Distinct labels in the current result, in categorical-colour order.
pub fn labels(&self) -> &[String] {
&self.labels
}
/// The bounded query history (most-recent last).
pub fn history(&self) -> &[String] {
&self.history
}
/// The selected node id, if any.
pub fn selected(&self) -> Option<&str> {
self.selected.as_deref()
}
/// **FC-8 pure mutation** — apply one non-I/O [`Msg`]. Returns nothing to run: the
/// only side effect in this view-model is [`run_query`](Self::run_query), which is
/// its own call.
pub fn update(&mut self, msg: Msg) {
match msg {
Msg::SetQuery(q) => self.query = q,
Msg::SetEndpoint(e) => self.endpoint = e,
Msg::Select(id) => self.selected = id,
Msg::Clear => {
self.layout = LayoutSlot::default();
self.model = GraphModel::default();
self.labels.clear();
self.selected = None;
self.status = BrowserStatus::Idle;
}
}
}
/// **The one I/O step.** Run the current [`query`](Self::query) against the endpoint
/// via `runner`, then map the [`CypherResult`] into a force-laid-out
/// [`GraphModel`]. On success the status becomes [`BrowserStatus::Ok`] with the
/// counts and the query is pushed to history; on failure the graph is left cleared
/// and the error surfaces fail-loud in [`BrowserStatus::Error`] — never a silent
/// empty graph. Returns a reference to the new status.
pub fn run_query(&mut self, runner: &mut dyn CypherRunner) -> &BrowserStatus {
let query = self.query.clone();
match runner.run(&self.endpoint.graph, &query) {
Ok(result) => {
let (model, labels) = self.lay_out(&result);
let (nodes, edges) = (model.nodes.len(), model.edges.len());
self.model = model;
self.labels = labels;
// Selection may no longer resolve; keep it only if the id still exists.
if let Some(sel) = &self.selected && !self.model.nodes.iter().any(|n| &n.id == sel) {
self.selected = None;
}
self.push_history(query);
self.status = BrowserStatus::Ok { nodes, edges };
}
Err(message) => {
self.model = GraphModel::default();
self.labels.clear();
self.selected = None;
self.layout = LayoutSlot::default();
self.status = BrowserStatus::Error { message };
}
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-graphview::FalkorBrowser::run_query",
"cypher_round_trip",
matches!(self.status, BrowserStatus::Ok { .. }),
&format!(
"graph={} status={:?} nodes={} edges={}",
self.endpoint.graph,
self.status,
self.model.nodes.len(),
self.model.edges.len(),
),
);
&self.status
}
/// Map `result` and lay it out on the lane its size and the host allow (see
/// [`CPU_LAYOUT_MAX`]). A GPU job leaves the seed spiral in place; [`poll_layout`]
/// (Self::poll_layout) swaps the settled positions in.
fn lay_out(&mut self, result: &CypherResult) -> (GraphModel, Vec<String>) {
self.layout = LayoutSlot::default();
let (mut model, labels, dense) = map_result(result);
let n = model.nodes.len();
if n == 0 {
return (model, labels);
}
#[cfg(feature = "gpu")]
if crate::gpu_layout::host_device_installed() {
use crate::gpu_layout::{with_host_device, LayoutJob, LayoutState};
// Small: the CPU lane's own params + seed, positions back in its units —
// the GPU twin of `relax_cpu` (the same look). Large: the navigator's
// proven 100k lane (default params, the grid kernel above GRID_MIN_NODES),
// fitted to the box and scaled to the world the CPU lane would have spanned.
let (params, seed, normalize, world_scale) = if n <= CPU_LAYOUT_MAX {
let p = LayoutParams { ideal_len: LAYOUT_IDEAL, ..Default::default() };
(p, LayoutState::seed(n, p.ideal_len * (n as f32).sqrt()), false, LAYOUT_SCALE)
} else {
let p = LayoutParams::default();
(p, LayoutState::seed(n, p.ideal_len * (n as f32).sqrt()), true, large_world_scale(n))
};
let csr = GraphCsr::from_edges(n, &dense, None);
let iters = self.iters;
if let Some(job) = with_host_device(|d, _| LayoutJob::start_with(d, csr, params, &seed, iters, normalize)) {
let mut placeholder = seed.pos;
if normalize {
facett_core::barnes_hut::normalize_box(&mut placeholder);
}
place(&mut model, &placeholder, world_scale);
self.layout.lane = "gpu-job";
self.layout.kernel = job.kernel();
self.layout.world_scale = world_scale;
self.layout.job = Some(Box::new(job));
return (model, labels);
}
}
if n <= CPU_LAYOUT_MAX {
let csr = GraphCsr::from_edges(n, &dense, None);
let params = LayoutParams { ideal_len: LAYOUT_IDEAL, ..Default::default() };
place(&mut model, &relax_cpu(&csr, ¶ms, self.iters).pos, LAYOUT_SCALE);
self.layout.lane = "cpu";
} else {
let mut spiral = crate::gpu_layout::LayoutState::seed(n, (n as f32).sqrt()).pos;
facett_core::barnes_hut::normalize_box(&mut spiral);
place(&mut model, &spiral, large_world_scale(n));
self.layout.lane = "no GPU";
self.layout.note = "no GPU: above CPU_LAYOUT_MAX nodes the force layout runs on the GPU only, and no host device is installed — the nodes stay on the seed spiral";
}
(model, labels)
}
/// **Advance the GPU layout job** by one bounded slice (never waits on the GPU; call
/// once per frame while [`layout_pending`](Self::layout_pending)). `true` when the
/// settled positions landed in [`model`](Self::model) THIS call — the host re-seeds
/// its view then (one model change, one upload). A job that fails says why in
/// [`layout_note`](Self::layout_note); at or below [`CPU_LAYOUT_MAX`] the existing CPU
/// lane then lays the graph out, above it the seed spiral stays.
pub fn poll_layout(&mut self) -> bool {
#[cfg(feature = "gpu")]
{
use crate::gpu_layout::{with_host_device, JobState};
let Some(job) = self.layout.job.as_mut() else { return false };
let state = with_host_device(|d, q| job.poll(d, q))
.unwrap_or(JobState::Failed("the host device was cleared while the layout ran"));
match state {
JobState::Pending => false,
JobState::Ready(pos) => {
self.layout.job = None;
place(&mut self.model, &pos, self.layout.world_scale);
true
}
JobState::Failed(why) => {
self.layout.job = None;
self.layout.note = why;
let n = self.model.nodes.len();
if n <= CPU_LAYOUT_MAX {
let dense = dense_edges_of(&self.model);
let csr = GraphCsr::from_edges(n, &dense, None);
let params = LayoutParams { ideal_len: LAYOUT_IDEAL, ..Default::default() };
place(&mut self.model, &relax_cpu(&csr, ¶ms, self.iters).pos, LAYOUT_SCALE);
self.layout.lane = "cpu";
true
} else {
self.layout.lane = "no GPU";
false
}
}
}
}
#[cfg(not(feature = "gpu"))]
{
false
}
}
/// A GPU layout job is in flight (the host keeps repainting and polling).
#[must_use]
pub fn layout_pending(&self) -> bool {
#[cfg(feature = "gpu")]
{
self.layout.job.is_some()
}
#[cfg(not(feature = "gpu"))]
{
false
}
}
/// Which lane laid the current result out: `"cpu"`, `"gpu-job"` (pending or
/// settled — see [`layout_pending`](Self::layout_pending)), `"no GPU"`, or `""`.
#[must_use]
pub fn layout_lane(&self) -> &'static str {
self.layout.lane
}
/// The GPU job's repulsion kernel: `"all-pairs"` below
/// `gpu_layout::GRID_MIN_NODES`, `"grid"` above, `""` with no job.
#[must_use]
pub fn layout_kernel(&self) -> &'static str {
self.layout.kernel
}
/// Why the GPU lane was not used, or failed — `""` when it ran.
#[must_use]
pub fn layout_note(&self) -> &'static str {
self.layout.note
}
/// The layout readout as JSON (the `layout` key of [`state_json`](Self::state_json)).
#[must_use]
pub fn layout_json(&self) -> serde_json::Value {
serde_json::json!({
"lane": self.layout_lane(),
"pending": self.layout_pending(),
"kernel": self.layout_kernel(),
"note": self.layout_note(),
"cpu_max": CPU_LAYOUT_MAX,
})
}
fn push_history(&mut self, query: String) {
if self.history.last() == Some(&query) {
return;
}
self.history.push(query);
// Keep the last 50 — a browser history, not an unbounded leak.
let over = self.history.len().saturating_sub(50);
if over > 0 {
self.history.drain(0..over);
}
}
/// **FC-3** — the complete observable state as JSON: the endpoint, the round-tripped
/// query, the status + node/edge counts, the label breakdown, the selection, and a
/// compact node/edge dump. This is what a headless robot asserts on.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"endpoint": self.endpoint,
"graph": self.endpoint.graph,
"query": self.query,
"status": self.status,
"node_count": self.model.nodes.len(),
"edge_count": self.model.edges.len(),
"labels": self.labels,
"label_count": self.labels.len(),
"selected": self.selected,
"history_len": self.history.len(),
"layout": self.layout_json(),
"nodes": self.model.nodes.iter().map(|n| serde_json::json!({
"id": n.id,
"label": n.label,
"x": n.pos.x,
"y": n.pos.y,
})).collect::<Vec<_>>(),
"edges": self.model.edges.iter().map(|e| serde_json::json!({
"from": e.from,
"to": e.to,
"rel": e.label,
})).collect::<Vec<_>>(),
})
}
}
/// The canned demo result behind [`FalkorBrowser::local`] — a tiny constellation:
/// two repos, three crates, a cross-repo dependency.
fn demo_result() -> CypherResult {
CypherResult {
nodes: vec![
CypherNode::new(1, "Repo", "facett"),
CypherNode::new(2, "Repo", "nornir"),
CypherNode::new(3, "Crate", "facett-core"),
CypherNode::new(4, "Crate", "facett-graphview"),
CypherNode::new(5, "Crate", "nornir-viz"),
],
edges: vec![
CypherEdge::new(1, 3, "HAS"),
CypherEdge::new(1, 4, "HAS"),
CypherEdge::new(2, 5, "HAS"),
CypherEdge::new(4, 3, "DEPENDS_ON"),
CypherEdge::new(5, 4, "DEPENDS_ON"),
],
}
}
/// The world half-extent per layout unit for an answer above [`CPU_LAYOUT_MAX`]: the
/// GPU positions come back fitted to `[-0.9, 0.9]²`, and this spreads them over the
/// disc the CPU lane's spacing (`LAYOUT_IDEAL` × `LAYOUT_SCALE` per node) would cover.
fn large_world_scale(n: usize) -> f32 {
LAYOUT_IDEAL * LAYOUT_SCALE * (n as f32).sqrt() * 0.5 / 0.9
}
/// Write layout positions (`pos[i]` for node `i`, layout units) into `model` at `scale`.
fn place(model: &mut GraphModel, pos: &[[f32; 2]], scale: f32) {
for (node, p) in model.nodes.iter_mut().zip(pos) {
node.pos = Pos::new(p[0] * scale, p[1] * scale);
}
}
/// The model's edges over dense node indices (the CSR the layout takes).
#[cfg(feature = "gpu")]
fn dense_edges_of(model: &GraphModel) -> Vec<(u32, u32)> {
let index: std::collections::HashMap<&str, u32> =
model.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i as u32)).collect();
model.edges.iter().filter_map(|e| Some((*index.get(e.from.as_str())?, *index.get(e.to.as_str())?))).collect()
}
/// Map a [`CypherResult`] into an UNPLACED [`GraphModel`] (every node at the origin),
/// the distinct-label order and the edges over dense indices — the layout lane places
/// it. Nodes are coloured categorically by primary label; edges keep their
/// relationship type as a mid-edge label. Pure + deterministic in `result`.
fn map_result(result: &CypherResult) -> (GraphModel, Vec<String>, Vec<(u32, u32)>) {
let n = result.nodes.len();
if n == 0 {
return (GraphModel::default(), Vec::new(), Vec::new());
}
// id → dense index (node ids are FalkorDB internal ids, not contiguous).
let mut index: std::collections::HashMap<i64, u32> = std::collections::HashMap::with_capacity(n);
for (i, node) in result.nodes.iter().enumerate() {
index.insert(node.id, i as u32);
}
// Distinct labels in first-seen order → categorical colour slot.
let mut labels: Vec<String> = Vec::new();
let mut slot_of: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for node in &result.nodes {
let l = node.primary_label();
if !l.is_empty() && !slot_of.contains_key(l) {
slot_of.insert(l, labels.len());
labels.push(l.to_string());
}
}
let colour_of = |label: &str| -> Color {
match slot_of.get(label) {
Some(slot) => LABEL_PALETTE[slot % LABEL_PALETTE.len()],
None => Color::GRAY,
}
};
// Edges over dense indices (drop dangling endpoints the result didn't include).
let dense_edges: Vec<(u32, u32)> = result
.edges
.iter()
.filter_map(|e| Some((*index.get(&e.src)?, *index.get(&e.dst)?)))
.collect();
let nodes = result
.nodes
.iter()
.map(|node| GraphNode {
id: node.id.to_string(),
label: node.display_caption(),
fill: colour_of(node.primary_label()),
stroke: Color::WHITE,
pos: Pos::new(0.0, 0.0),
})
.collect();
let edges = result
.edges
.iter()
.filter(|e| index.contains_key(&e.src) && index.contains_key(&e.dst))
.map(|e| GraphEdge {
from: e.src.to_string(),
to: e.dst.to_string(),
color: Color::rgb(150, 160, 180),
dashed: false,
label: (!e.rel_type.is_empty()).then(|| e.rel_type.clone()),
})
.collect();
(GraphModel { nodes, edges }, labels, dense_edges)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_default_is_korp_demo() {
let e = FalkorEndpoint::default();
assert_eq!(e.port, 6379);
assert_eq!(e.graph, "korp_demo");
assert_eq!(e.display(), "localhost:6379/korp_demo");
}
/// The headline robot property: a Cypher query round-trips through the view-model to
/// the runner, and the parsed result becomes a laid-out graph the `state_json`
/// reports. RED-when-broken if the query is dropped or the mapping miscounts.
#[test]
fn cypher_query_round_trips_into_state_json() {
let mut b = FalkorBrowser::new(FalkorEndpoint::new("db.local", 6380, "warehouse"));
let q = "MATCH (r:Repo)-[:HAS]->(c:Crate) RETURN r, c";
b.update(Msg::SetQuery(q.into()));
let mut runner = CannedRunner::ok(demo_result());
let status = b.run_query(&mut runner).clone();
// The runner saw the exact (graph, query) the view-model holds — the round trip.
assert_eq!(runner.last.as_ref().unwrap().0, "warehouse", "graph key routed");
assert_eq!(runner.last.as_ref().unwrap().1, q, "query text routed verbatim");
// The status + state_json report the mapped node/edge counts (5 nodes, 5 edges).
assert_eq!(status, BrowserStatus::Ok { nodes: 5, edges: 5 });
let j = b.state_json();
assert_eq!(j["query"], q, "the query round-trips into state_json");
assert_eq!(j["node_count"], 5);
assert_eq!(j["edge_count"], 5);
assert_eq!(j["graph"], "warehouse");
// Two distinct labels (Repo, Crate) → two categorical colour slots.
assert_eq!(j["label_count"], 2);
assert_eq!(j["status"]["kind"], "ok");
// Positions were assigned (the force layout ran, not all-origin).
let spread = j["nodes"].as_array().unwrap().iter().any(|n| {
n["x"].as_f64().unwrap().abs() > 1.0 || n["y"].as_f64().unwrap().abs() > 1.0
});
assert!(spread, "the force layout placed nodes off the origin");
}
#[test]
fn local_paints_a_canned_constellation_with_no_db() {
let b = FalkorBrowser::local();
let j = b.state_json();
assert_eq!(j["node_count"], 5, "the demo constellation");
assert_eq!(j["edge_count"], 5);
assert_eq!(j["status"]["kind"], "ok");
// Node captions came through (names, not #id).
let labels: Vec<&str> =
j["nodes"].as_array().unwrap().iter().map(|n| n["label"].as_str().unwrap()).collect();
assert!(labels.contains(&"facett-graphview"), "captions map through: {labels:?}");
}
/// A failing runner surfaces the error fail-loud and leaves NO silent partial graph.
#[test]
fn query_error_surfaces_and_clears_the_graph() {
let mut b = FalkorBrowser::local(); // starts with a populated graph
assert!(!b.model().nodes.is_empty());
let mut runner = CannedRunner::failing("connection refused (:6379)");
b.run_query(&mut runner);
assert!(matches!(b.status(), BrowserStatus::Error { .. }), "fail-loud, not empty-ok");
let j = b.state_json();
assert_eq!(j["status"]["kind"], "error");
assert_eq!(j["status"]["message"], "connection refused (:6379)");
assert_eq!(j["node_count"], 0, "the stale graph is cleared, never left partial");
}
#[test]
fn clear_resets_to_idle_keeping_query() {
let mut b = FalkorBrowser::local();
let q = b.query().to_string();
b.update(Msg::Clear);
assert_eq!(b.status(), &BrowserStatus::Idle);
assert_eq!(b.model().nodes.len(), 0);
assert_eq!(b.query(), q, "Clear keeps the editable query text");
}
#[test]
fn selection_is_dropped_when_it_no_longer_resolves() {
let mut b = FalkorBrowser::local();
b.update(Msg::Select(Some("3".into()))); // facett-core, id 3 — exists
assert_eq!(b.selected(), Some("3"));
// Re-run with a result that omits node 3 → the selection must not dangle.
let smaller = CypherResult { nodes: vec![CypherNode::new(1, "Repo", "facett")], edges: vec![] };
let mut runner = CannedRunner::ok(smaller);
b.run_query(&mut runner);
assert_eq!(b.selected(), None, "a selection that no longer resolves is cleared");
assert_eq!(b.state_json()["node_count"], 1);
}
#[test]
fn history_dedups_consecutive_and_bounds() {
let mut b = FalkorBrowser::default();
let mut runner = CannedRunner::ok(demo_result());
b.update(Msg::SetQuery("MATCH (n) RETURN n".into()));
b.run_query(&mut runner);
b.run_query(&mut runner); // same query twice → one history entry
assert_eq!(b.history().len(), 1, "consecutive duplicate queries fold");
b.update(Msg::SetQuery("MATCH (n:Repo) RETURN n".into()));
b.run_query(&mut runner);
assert_eq!(b.history().len(), 2);
}
#[test]
fn empty_result_lays_out_cleanly() {
let mut b = FalkorBrowser::default();
let mut runner = CannedRunner::ok(CypherResult::default());
b.run_query(&mut runner);
assert_eq!(b.status(), &BrowserStatus::Ok { nodes: 0, edges: 0 });
assert_eq!(b.state_json()["node_count"], 0);
}
/// The result → GraphModel mapping feeds straight into the interactive pane.
#[test]
fn model_feeds_the_decorated_view() {
use crate::decorated_view::DecoratedGraphView;
use facett_core::Facet;
let b = FalkorBrowser::local();
let view = DecoratedGraphView::new("FalkorDB").with_model(b.model().clone());
assert_eq!(view.state_json()["node_count"], 5, "the browser model paints in the pane");
assert_eq!(view.state_json()["edge_count"], 5);
}
}