From ee585f9efab1b870b89ff18ee0f89b0224ec0d4c Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Thu, 1 Oct 2026 21:37:37 -0700 Subject: [PATCH] Leiden treemap, insights pane, and Arrow loader hardening - Treemap view over Leiden communities (in-process icebug Leiden, same family as GDS_LEIDEN) with squarified layout after disktree, PageRank-weighted tiles; header toggle switches graph/treemap - Collapsible right insights pane: top 10 nodes by PageRank plus the Leiden community summary; rows focus/select into the graph - backend: graphr_leiden_full, gds_leiden_communities readout, tests/gds_leiden.rs port of the extension's gds_leiden.test - loader: tiered CSR -> columnar Arrow -> row-wise id scans so a silent-empty fast path can never show 0 nodes; pin arrow to 55 to match lbug (arrow56 alias for the icebug arrays); tests/loader.rs plus an arrow-pin unit test locking the batch decode shape --- Cargo.lock | 1 + Cargo.toml | 8 +- README.md | 23 +- src/backend.rs | 327 +++++++++++++++++-- src/clusters.rs | 431 +++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 19 +- src/ui.rs | 751 ++++++++++++++++++++++++++++++++++++++++++-- tests/gds_leiden.rs | 193 ++++++++++++ tests/loader.rs | 79 +++++ 10 files changed, 1771 insertions(+), 62 deletions(-) create mode 100644 src/clusters.rs create mode 100644 tests/gds_leiden.rs create mode 100644 tests/loader.rs diff --git a/Cargo.lock b/Cargo.lock index cf76beb..498f5b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1005,6 +1005,7 @@ name = "bugscope" version = "0.1.0" dependencies = [ "anyhow", + "arrow 55.2.0", "arrow 56.2.1", "gpui", "icebug", diff --git a/Cargo.toml b/Cargo.toml index e9e7186..966eaa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,13 @@ icebug-analytics = ["dep:icebug"] gpui = "0.2" lbug = { version = "0.21.1", features = ["arrow"] } icebug = { version = "=13.2.0", optional = true } -arrow = { version = "56", default-features = false, features = ["ffi"] } +# Pinned to 55 to match lbug's `arrow` (0.21.x uses arrow 55): lbug hands us +# Arrow batches built with its own arrow, so our decode types must be the +# same crate version or every `downcast_ref` silently yields nothing. +arrow = { version = "55", default-features = false, features = ["ffi"] } +# icebug 13.2.0 instead needs arrow 56 (`arrow-array`/`arrow-buffer` 56.x), +# so the in-process analytics arrays use this alias. +arrow56 = { package = "arrow", version = "56", default-features = false, features = ["ffi"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" diff --git a/README.md b/README.md index c975aa5..34be7ea 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ This is the native port of [bugscope-tauri](https://github.com/LadybugDB/bugscop - **Search + Focus** - Substring search over node properties; click a match to focus its 1-hop neighborhood. - **Schema View** - Toggle to see node tables instead of the edge graph. - **Live Layout** - Force simulation (repulsion + springs + damping) runs at 30 Hz and settles when the layout goes quiet; pause/resume any time. +- **Leiden Treemap** - Header toggle switches the canvas between the graph and a squarified treemap of Leiden communities (in-process icebug Leiden, same family as `GDS_LEIDEN`); tile area follows PageRank weight. Double-click drills into a neighborhood. +- **Insights Pane** - Collapsible right pane with the top 10 nodes by PageRank plus the Leiden community summary; clicking a row focuses (PageRank) or selects (community) it. ## Usage @@ -57,6 +59,13 @@ Set `BUGSCOPE_ALGO_EXTENSION` to a local `.lbug_extension` file to override the downloaded one (e.g. a build from [LadybugDB/extensions](https://github.com/LadybugDB/extensions/tree/main/algo)). +The treemap and insights pane run the same algorithms in-process over +whatever is displayed (full scan, Cypher result, or neighborhood), with no +`PROJECT_GRAPH` step: `backend::graphr_leiden_full` (Leiden + modularity) +and `backend::graphr_page_rank`. `backend::gds_leiden_communities` reads +`CALL GDS_LEIDEN(...)` rows back for an existing projection when you want +the extension's own output. + From source: ```bash @@ -65,26 +74,34 @@ bash scripts/download-liblbug.sh # prebuilt shared liblbug (dlopen needs sha bash scripts/vendor_arrow.sh # stage libarrow/libomp next to libnetworkit (the algo # extension references @rpath/libarrow, resolved via our rpaths) cargo test --test gds_page_rank # GDS_PAGE_RANK vs the expected ranks (skips when offline) +cargo test --test gds_leiden # GDS_LEIDEN two-cliques structure + local Leiden cross-check ``` The `icebug-analytics` cargo feature (on by default; `--no-default-features` to skip) links the icebug Rust crate for in-process analytics such as `backend::graphr_page_rank`. +Bulk loading is tiered CSR → columnar Arrow → row-wise ids, so a silent-empty +fast path can never hide the graph. Our `arrow` is pinned to 55 to match lbug +(0.21.x builds its batches with arrow 55 — decoding them with another major +silently yields zero rows); icebug 13.2.0 needs arrow 56, which lives behind +the `arrow56` alias for the analytics arrays only. + ## Project structure | Path | Contents | |---|---| | `src/main.rs` | App entry: window setup, titlebar, autofocus | -| `src/backend.rs` | Direct LadybugDB backend — `open_connection`, `scan_for_databases`, `collect_edge_graph`, `collect_schema_graph`, `search_nodes`, `neighborhood` | +| `src/backend.rs` | Direct LadybugDB backend — `open_connection`, `scan_for_databases`, `collect_edge_graph`, `collect_schema_graph`, `search_nodes`, `neighborhood`, in-process PageRank/Leiden (`graphr_page_rank`, `graphr_leiden_full`) + `GDS_LEIDEN` readout (`gds_leiden_communities`) | +| `src/clusters.rs` | Squarified treemap layout over Leiden communities (disktree-inspired `squarify` + header bands + hit-testing), pure geometry with unit tests | | `src/model.rs` | `GraphModel` (force layout tick, palette, picking) + `Camera` (world↔screen, cursor-anchored zoom, fit) | -| `src/ui.rs` | `RootView` — sidebar, query box, canvas rendering, interaction | +| `src/ui.rs` | `RootView` — sidebar, insights pane, query box, canvas graph + treemap views, interaction | | `src/theme.rs` | Vendored disktree theme (`tokyo_night` dark / `flexoki_light` light), selected via `window.appearance()` like disktree follows the system setting | The backend runs the same Cypher as the Tauri commands (`MATCH (a)-[r]->(b) RETURN a, r, b`, isolated-node scan, `CALL SHOW_TABLES`, node sampling) with direct `lbug` calls and no IPC boundary. ## Deliberately out of scope for v1 -Summary-space PageRank sidecars, Leiden cluster levels and LLM cluster naming, Voronoi overlay, the lever panel, Arrow IPC transport — all were web-renderer or sidecar concerns in bugscope-tauri. The native port loads the edge graph directly and lays it out live. Cluster/color extensions can build on `GraphModel` without an IPC boundary. +Summary-space PageRank sidecars, LLM cluster naming, Voronoi overlay, the lever panel, Arrow IPC transport — all were web-renderer or sidecar concerns in bugscope-tauri. The native port loads the edge graph directly and lays it out live. The sunburst view is skipped for now (arc-heavy painting in GPUI needs more scaffolding than the treemap's rects). ### Troubleshooting diff --git a/src/backend.rs b/src/backend.rs index 49d9f4c..3f9487a 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -215,9 +215,14 @@ pub fn ensure_algo_extension(conn: &Connection) -> bool { /// (NetworKit) library — no extension needed. Returns `(node_id, score)` /// sorted by score descending. Mirrors the GDS_PAGE_RANK ranking so the /// extension result can be cross-checked (see `tests/gds_page_rank.rs`). +/// +/// Without the `icebug-analytics` feature this is unavailable and returns +/// an empty ranking (callers show an empty Top PageRank list). #[cfg(feature = "icebug-analytics")] pub fn graphr_page_rank(data: &GraphData) -> Vec<(String, f64)> { - use arrow::array::UInt64Array; + // icebug 13.2.0 speaks arrow 56: build with the alias, not the + // lbug-pinned `arrow` (55). + use arrow56::array::UInt64Array; let index: HashMap<&str, u64> = data .nodes .iter() @@ -305,6 +310,11 @@ type NodeId = (u64, u64); /// Decode an `INTERNAL_ID` Arrow struct column (`{offset, table}`) into /// `(table_id, offset)` pairs, skipping nulls. +/// +/// Our `arrow` is pinned to 55 to match lbug, so these downcasts hit the +/// same types lbug builds its batches with. A 55/56 skew here silently +/// decodes zero rows (observed: 6285 edges → 0), which is why the loader +/// verifies every tier instead of trusting an empty success. fn decode_internal_id(col: &arrow::array::StructArray) -> Vec { use arrow::array::Array; let offsets: Vec = col @@ -325,8 +335,8 @@ fn decode_internal_id(col: &arrow::array::StructArray) -> Vec { } /// Columnar edge scan via Arrow memory: `RETURN id(a), id(b), label(r)`. -/// No `Node`/`Rel` objects are materialized per row, so this scales far past -/// what the row-wise `RETURN a, r, b` path can hold. +/// No `Node`/`Rel` objects are materialized per row, so bulk loads from +/// larger DBs stay fast. Decoding relies on the arrow-55 pin (see above). fn collect_edges_arrow( conn: &Connection, limit: usize, @@ -376,13 +386,58 @@ fn collect_edges_arrow( Ok(()) } +/// Row-wise edge scan: `RETURN id(a), id(b), label(r)`. +/// Only internal ids and the rel label cross each row — no `Node`/`Rel` +/// objects are materialized — so this stays cheap up to the edge limit. +/// +/// NOTE: this deliberately avoids decoding `query_as_arrow` batches: it is +/// the version-proof last resort behind the pinned-Arrow fast paths. +/// Row values cross the boundary as plain `Value`s. +fn collect_edges_rows( + conn: &Connection, + limit: usize, + links: &mut Vec, + node_ids: &mut HashSet, +) -> Result<()> { + let mut result = conn + .query(&format!( + "MATCH (a)-[r]->(b) RETURN id(a), id(b), label(r) LIMIT {limit}" + )) + .context("edge scan failed")?; + let mut seen = HashSet::new(); + for row in &mut result { + if row.len() < 3 { + continue; + } + let (Value::InternalID(src), Value::InternalID(dst), Value::String(label)) = + (&row[0], &row[1], &row[2]) + else { + continue; + }; + let (st, so) = (src.table_id, src.offset); + let (dt, doff) = (dst.table_id, dst.offset); + let source = format!("{st}:{so}"); + let target = format!("{dt}:{doff}"); + node_ids.insert((st, so)); + node_ids.insert((dt, doff)); + if seen.insert((source.clone(), target.clone(), label.clone())) { + links.push(GraphLink { + source, + target, + label: label.clone(), + }); + } + } + Ok(()) +} + /// Native CSR data path: per rel table, `RETURN a.rowid, r.rowid, b.rowid` /// via `query_as_arrow` carries CSR metadata and `QueryResult::csr()` hands -/// back zero-copy `indptr`/`indices` Arrow arrays — the full adjacency list +/// back zero-copy `indptr`/`indices` arrays — the full adjacency list /// without materializing a single row. Src/dst table ids come from one cheap /// normal `LIMIT 1` query per rel table (metadata only). Any failure -/// (no CSR metadata, e.g. older storage versions) is an `Err` so the caller -/// can fall back to the columnar scan. +/// (no/empty CSR metadata, e.g. older storage versions) is an `Err` so the +/// caller can fall back to the row-wise scan. fn collect_edges_csr( conn: &Connection, rel_tables: &[String], @@ -441,6 +496,13 @@ fn collect_edges_csr( } } } + // Some storage versions report Ok with empty CSR arrays instead of an + // error. Zero links from a non-empty rel catalog is indistinguishable + // from a broken fast path, so decline it and let the caller verify via + // the row-wise scan (which also finds nothing on a truly empty db). + if links.is_empty() && !rel_tables.is_empty() { + anyhow::bail!("empty CSR for {} rel tables", rel_tables.len()); + } Ok((links, node_ids)) } @@ -448,9 +510,11 @@ fn collect_edges_csr( /// /// Topology comes from native CSR via Arrow memory (`query_as_arrow` with a /// `RETURN a.rowid, r.rowid, b.rowid` projection + `QueryResult::csr()`), -/// one CSR per rel table — no rows are materialized for edges. If CSR -/// metadata is unavailable the columnar Arrow scan is the fallback; schema -/// (`SHOW_TABLES`) and node properties always use normal row queries. +/// one CSR per rel table — no rows are materialized for edges. Missing or +/// empty CSR metadata falls back to the columnar Arrow id scan, then to the +/// row-wise id scan; schema (`SHOW_TABLES`) and node properties always use +/// normal row queries. Tiers decline on error *or* empty success, so a +/// silent-empty fast path can never hide the graph. pub fn collect_edge_graph(conn: &Connection, limit: usize) -> Result { let catalog = table_catalog(conn).unwrap_or_default(); let rel_tables: Vec = catalog @@ -466,33 +530,38 @@ pub fn collect_edge_graph(conn: &Connection, limit: usize) -> Result let mut links = Vec::new(); let mut node_ids: HashSet = HashSet::new(); - // CSR first for data; fall back to the columnar Arrow scan. + // CSR first for data, then columnar Arrow, then row-wise. Each tier + // declines on error or empty success (an empty win from a non-empty + // catalog is indistinguishable from a broken fast path). match collect_edges_csr(conn, &rel_tables, limit) { Ok((l, ids)) => { links = l; node_ids = ids; } Err(e) => { - eprintln!( - "bugscope: CSR edge scan unavailable ({e}); falling back to columnar Arrow scan" - ); - collect_edges_arrow(conn, limit, &mut links, &mut node_ids)? + eprintln!("bugscope: CSR edge scan unavailable ({e}); trying Arrow scan"); + let mut arrow_links = Vec::new(); + let mut arrow_ids = HashSet::new(); + let arrow_ok = collect_edges_arrow(conn, limit, &mut arrow_links, &mut arrow_ids) + .is_ok() + && !arrow_links.is_empty(); + if arrow_ok { + links = arrow_links; + node_ids = arrow_ids; + } else { + eprintln!("bugscope: Arrow scan unavailable/empty; falling back to row-wise scan"); + collect_edges_rows(conn, limit, &mut links, &mut node_ids)? + } } } - // Isolated nodes via Arrow ids — schema/details stay on normal queries. - if let Ok(mut iso) = conn.query_as_arrow( - &format!("MATCH (n) WHERE NOT (n)--() RETURN id(n) AS nid LIMIT {limit}"), - 65_536, - ) { - if let Ok(batches) = iso.iter_arrow(65_536) { - for batch in batches { - if let Some(col) = batch - .column_by_name("nid") - .and_then(|c| c.as_any().downcast_ref::()) - { - node_ids.extend(decode_internal_id(col)); - } + // Isolated nodes by internal id — schema/details stay on normal queries. + if let Ok(mut iso) = conn.query(&format!( + "MATCH (n) WHERE NOT (n)--() RETURN id(n) LIMIT {limit}" + )) { + for row in &mut iso { + if let Some(Value::InternalID(id)) = row.first() { + node_ids.insert((id.table_id, id.offset)); } } } @@ -769,3 +838,207 @@ pub fn neighborhood(full: &GraphData, focus: &str) -> GraphData { .collect(); GraphData { nodes, links } } + +/// Fallback when `icebug-analytics` is off: no ranking available. +#[cfg(not(feature = "icebug-analytics"))] +pub fn graphr_page_rank(data: &GraphData) -> Vec<(String, f64)> { + let _ = data; + Vec::new() +} + +/// Leiden community detection over an already-collected graph via the linked +/// icebug (NetworKit) library — the same algorithm family as the +/// `GDS_LEIDEN()` table function in the algo extension (see +/// `tests/gds_leiden.rs`, ported from the extension's `gds_leiden.test`). +/// Runs in-process, so it works on any displayed graph — full edge scan, +/// Cypher result, or 1-hop neighborhood — without a `PROJECT_GRAPH` step. +/// +/// Returns `(assignment, modularity, community_count)` where `assignment[i]` +/// is the community of `data.nodes[i]`. Community ids are arbitrary; only +/// equality matters. +#[cfg(feature = "icebug-analytics")] +pub fn graphr_leiden_full(data: &GraphData) -> (Vec, f64, u64) { + // icebug 13.2.0 speaks arrow 56: build with the alias, not the + // lbug-pinned `arrow` (55). + use arrow56::array::UInt64Array; + let n = data.nodes.len(); + if n == 0 { + return (Vec::new(), 0.0, 0); + } + if data.links.is_empty() { + return (vec![0; n], 0.0, 1); + } + let index: HashMap<&str, u64> = data + .nodes + .iter() + .enumerate() + .map(|(i, nd)| (nd.id.as_str(), i as u64)) + .collect(); + // Leiden works on undirected topology: union both directions. + let mut adj: Vec> = vec![Vec::new(); n]; + for l in &data.links { + if let (Some(&s), Some(&t)) = (index.get(l.source.as_str()), index.get(l.target.as_str())) { + if s != t { + adj[s as usize].push(t); + adj[t as usize].push(s); + } + } + } + let mut indptr = Vec::with_capacity(n + 1); + let mut indices = Vec::new(); + for mut neighbors in adj { + neighbors.sort_unstable(); + neighbors.dedup(); + indptr.push(indices.len() as u64); + indices.extend(neighbors); + } + indptr.push(indices.len() as u64); + let Ok(graph) = icebug::GraphR::from_csr( + n as u64, + false, + UInt64Array::from(indices), + UInt64Array::from(indptr), + ) else { + return (vec![0; n], 0.0, 1); + }; + // Deterministic (randomize=false) so the treemap is stable across loads. + let Ok(mut leiden) = icebug::Leiden::new(&graph, 32, false, 1.0) else { + return (vec![0; n], 0.0, 1); + }; + if leiden.run().is_err() { + return (vec![0; n], 0.0, 1); + } + let (membership, count) = match leiden.partition() { + Ok(p) => (p.membership, p.count), + Err(_) => (vec![0; n], 1), + }; + let modularity = leiden.modularity().unwrap_or(0.0); + let mut assignment = vec![0u64; n]; + for (i, c) in membership.into_iter().enumerate().take(n) { + assignment[i] = c; + } + (assignment, modularity, count) +} + +/// Leiden community assignment per node, in `data.nodes` order. +/// Convenience wrapper over [`graphr_leiden_full`] when modularity is unneeded. +#[cfg(feature = "icebug-analytics")] +pub fn graphr_leiden(data: &GraphData) -> Vec { + graphr_leiden_full(data).0 +} + +/// Fallback when `icebug-analytics` is off: everything in one community. +#[cfg(not(feature = "icebug-analytics"))] +pub fn graphr_leiden_full(data: &GraphData) -> (Vec, f64, u64) { + let n = data.nodes.len(); + (vec![0; n], 0.0, if n == 0 { 0 } else { 1 }) +} + +/// Fallback when `icebug-analytics` is off. +#[cfg(not(feature = "icebug-analytics"))] +pub fn graphr_leiden(data: &GraphData) -> Vec { + graphr_leiden_full(data).0 +} + +/// Read back the communities of an existing `PROJECT_GRAPH` projection via +/// the algo extension's `GDS_LEIDEN()` table function: +/// +/// ```cypher +/// CALL PROJECT_GRAPH('G', ['N'], ['E']); +/// CALL GDS_LEIDEN('G') RETURN node, community_id; +/// ``` +/// +/// Returns `node_id ("table:offset") -> community_id`, mirroring the +/// `gds_leiden.test` assertions (structure, not exact ids: Leiden +/// randomizes, so only community *equality* is meaningful). +pub fn gds_leiden_communities( + conn: &Connection, + graph: &str, + gamma: Option, +) -> Result> { + let call = match gamma { + Some(g) => format!("CALL GDS_LEIDEN('{graph}', gamma := {g})"), + None => format!("CALL GDS_LEIDEN('{graph}')"), + }; + let mut result = conn + .query(&format!("{call} RETURN node, community_id")) + .with_context(|| format!("GDS_LEIDEN call on '{graph}' failed"))?; + let mut out = HashMap::new(); + for row in &mut result { + if row.len() < 2 { + continue; + } + let Some(node) = graph_node_from_value(&row[0]) else { + continue; + }; + let community: u64 = match &row[1] { + Value::Int8(v) => (*v).max(0) as u64, + Value::Int16(v) => (*v).max(0) as u64, + Value::Int32(v) => (*v).max(0) as u64, + Value::Int64(v) => (*v).max(0) as u64, + Value::Int128(v) => (*v).max(0) as u64, + Value::UInt8(v) => *v as u64, + Value::UInt16(v) => *v as u64, + Value::UInt32(v) => *v as u64, + Value::UInt64(v) => *v, + _ => continue, + }; + out.insert(node.id, community); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn open_ephemeral() -> (tempfile::TempDir, Database) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("arrow_pin.lbdb"); + let db = Database::new(path.to_str().unwrap(), SystemConfig::default()).unwrap(); + (dir, db) + } + + /// Locks the arrow-55 pin: lbug's `id()` batches must decode to the exact + /// `(table, offset)` pairs through our types. If lbug ever moves to a new + /// arrow major (or renames the struct fields), this fails loudly instead + /// of the loader silently showing 0 nodes. + #[test] + fn arrow_internal_id_decode_matches_lbug_batches() { + let (_dir, db) = open_ephemeral(); + let conn = Connection::new(&db).unwrap(); + conn.query("CREATE NODE TABLE N(id INT64 PRIMARY KEY)") + .unwrap(); + conn.query("CREATE REL TABLE E(FROM N TO N)").unwrap(); + conn.query("CREATE (a:N{id:0}), (b:N{id:1}), (c:N{id:2})") + .unwrap(); + conn.query("MATCH (x:N{id:0}), (y:N{id:1}) CREATE (x)-[:E]->(y)") + .unwrap(); + conn.query("MATCH (x:N{id:1}), (y:N{id:2}) CREATE (x)-[:E]->(y)") + .unwrap(); + + // End-to-end through the loader's Arrow tier. + let mut links = Vec::new(); + let mut node_ids = HashSet::new(); + collect_edges_arrow(&conn, 10_000, &mut links, &mut node_ids).unwrap(); + assert_eq!(links.len(), 2, "both edges decode: {links:?}"); + assert_eq!(node_ids.len(), 3, "all endpoints decode: {node_ids:?}"); + + // And the raw batch shape the decoder relies on. + let mut result = conn + .query_as_arrow("MATCH (a)-[r]->(b) RETURN id(a) AS src LIMIT 1", 64) + .unwrap(); + let batches = result.iter_arrow(64).unwrap(); + let mut saw = 0; + for batch in batches { + let col = batch + .column_by_name("src") + .and_then(|c| c.as_any().downcast_ref::()) + .expect("src decodes as StructArray with our pinned arrow"); + let ids = decode_internal_id(col); + assert_eq!(ids.len(), batch.num_rows()); + saw += ids.len(); + } + assert!(saw > 0); + } +} diff --git a/src/clusters.rs b/src/clusters.rs new file mode 100644 index 0000000..c8b7850 --- /dev/null +++ b/src/clusters.rs @@ -0,0 +1,431 @@ +//! Squarified treemap over Leiden communities. +//! +//! Layout follows the approach in +//! (`disktree-core/src/treemap.rs`): Bruls, Huizing and van Wijk's squarified +//! algorithm — grow a row of tiles while the worst aspect ratio keeps +//! improving, then start a new row in the remaining space. That is what yields +//! a legible mosaic instead of the slivers a naive slice-and-dice gives. +//! +//! Two levels are laid out: communities fill the viewport, and each +//! community's members fill its body below a header band that keeps the +//! community's name (so a parent's label never sits on top of a child). +//! Painting lives in `ui.rs`; everything here is pure geometry and is unit +//! tested below. + +/// An axis-aligned rectangle in canvas pixels. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl Rect { + pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self { + Self { x, y, w, h } + } + + pub fn right(&self) -> f32 { + self.x + self.w + } + + pub fn bottom(&self) -> f32 { + self.y + self.h + } + + pub fn area(&self) -> f32 { + self.w.max(0.0) * self.h.max(0.0) + } + + pub fn contains(&self, x: f32, y: f32) -> bool { + x >= self.x && x < self.right() && y >= self.y && y < self.bottom() + } + + /// Shrink on every side, never past empty. The gaps between tiles are + /// what separate them, so no borders are needed while painting. + #[must_use] + pub fn inset(&self, padding: f32) -> Self { + let w = (self.w - 2.0 * padding).max(0.0); + let h = (self.h - 2.0 * padding).max(0.0); + Self::new(self.x + padding, self.y + padding, w, h) + } +} + +/// One Leiden community, ready for layout. +#[derive(Clone, Debug)] +pub struct Community { + /// Position in the layout (communities are pre-sorted, largest first); + /// doubles as the palette slot, like disktree's category hue. + pub slot: usize, + /// Arbitrary Leiden community id (for status text, not layout). + pub id: u64, + /// Member node indices into the displayed graph, heaviest first. + pub members: Vec, + /// Node weights parallel to `members` (PageRank scores, or 1.0). + pub weights: Vec, + /// Sum of member weights — the community's share of the viewport. + pub weight: f64, +} + +/// Group per-node community assignments into communities, largest first. +/// +/// `weights` is an optional per-node weight (PageRank); `None` weighs every +/// node equally. Members inside each community come out heaviest first. +pub fn build_communities( + node_count: usize, + assignment: &[u64], + weights: Option<&[f64]>, +) -> Vec { + // Renumber arbitrary Leiden ids in first-seen order, then accumulate. + let mut renumber: std::collections::HashMap = std::collections::HashMap::new(); + let mut members: Vec> = Vec::new(); + for i in 0..node_count { + let cid = assignment.get(i).copied().unwrap_or(0); + let pos = *renumber.entry(cid).or_insert_with(|| { + members.push(Vec::new()); + members.len() - 1 + }); + members[pos].push(i); + } + let mut out: Vec = members + .into_iter() + .enumerate() + .map(|(pos, mut mem)| { + mem.sort_by(|&a, &b| { + let wa = weights.and_then(|w| w.get(a).copied()).unwrap_or(1.0); + let wb = weights.and_then(|w| w.get(b).copied()).unwrap_or(1.0); + wb.total_cmp(&wa) + }); + let ws: Vec = mem + .iter() + .map(|&m| { + let w = weights.and_then(|w| w.get(m).copied()).unwrap_or(1.0); + if w > 0.0 && w.is_finite() { + w + } else { + 0.0 + } + }) + .collect(); + let total: f64 = ws.iter().sum(); + // Keep the original Leiden id for status text: recover it from + // the first member's assignment. + let id = mem + .first() + .and_then(|&m| assignment.get(m).copied()) + .unwrap_or(pos as u64); + Community { + slot: 0, // filled in after sorting + id, + members: mem, + weights: ws, + weight: total, + } + }) + .collect(); + // Largest community first: stable palette slots + biggest tile top-left. + out.sort_by(|a, b| b.weight.total_cmp(&a.weight)); + for (slot, c) in out.iter_mut().enumerate() { + c.slot = slot; + } + out +} + +/// One rectangle of the mosaic. +#[derive(Clone, Debug)] +pub struct Tile { + /// Position of the community in the layout's community list. + pub community: usize, + /// `Some(node_idx)` for a member cell, `None` for a community header band. + pub node: Option, + pub rect: Rect, + /// Whether this rect is the header band (community label) rather than a cell. + pub header: bool, +} + +/// Lay out `communities` inside `area`: one rect per community, subdivided +/// into one cell per member below a header band. +/// +/// `min_tile` drops cells too small to read or hit; `header_h` is the band +/// height a community keeps for its name when its body stays usable. +pub fn layout_treemap( + communities: &[Community], + area: Rect, + min_tile: f32, + header_h: f32, +) -> Vec { + let mut tiles = Vec::new(); + if communities.is_empty() || area.w <= 0.0 || area.h <= 0.0 { + return tiles; + } + let values: Vec = communities.iter().map(|c| c.weight.max(0.0)).collect(); + // Communities with no weight still deserve a sliver so singletons stay + // reachable; squarify skips non-positive values, so lift zeros slightly. + let values: Vec = values + .iter() + .map(|&v| if v > 0.0 { v } else { 1e-6 }) + .collect(); + for (pos, raw) in squarify(&values, area).iter().enumerate() { + let rect = raw.inset(2.0); + if rect.w < min_tile || rect.h < min_tile { + continue; + } + let community = &communities[pos]; + // A community that fits only its header stays whole: a name drawn + // over its own single cell is worse than one level less of detail. + let header = header_band(rect, header_h, min_tile); + tiles.push(Tile { + community: pos, + node: None, + rect: header.unwrap_or(rect), + header: header.is_some(), + }); + let Some(band) = header else { continue }; + let body = Rect::new(rect.x, band.bottom(), rect.w, rect.bottom() - band.bottom()); + if body.w <= 0.0 || body.h <= 0.0 { + continue; + } + let member_values: Vec = community + .weights + .iter() + .map(|&w| if w > 0.0 { w } else { 1e-6 }) + .collect(); + for (k, cell) in squarify(&member_values, body).iter().enumerate() { + let cell = cell.inset(1.0); + if cell.w < min_tile || cell.h < min_tile { + continue; + } + tiles.push(Tile { + community: pos, + node: Some(community.members[k]), + rect: cell, + header: false, + }); + } + } + tiles +} + +/// The band a community keeps for its name, or `None` when the tile is too +/// small to leave its members a usable body below it. +fn header_band(rect: Rect, height: f32, min_tile: f32) -> Option { + if rect.w < 44.0 || rect.h - height < min_tile * 3.0 { + return None; + } + Some(Rect::new(rect.x, rect.y, rect.w, height)) +} + +/// Split `area` into one rectangle per value, proportional to it. +/// +/// Returned in the order of `values`, not in layout order. After Bruls, +/// Huizing and van Wijk ("Squarified Treemaps"), following disktree's port. +pub fn squarify(values: &[f64], area: Rect) -> Vec { + let mut rects = vec![Rect::default(); values.len()]; + let total: f64 = values.iter().filter(|v| **v > 0.0).sum(); + if total <= 0.0 || area.w <= 0.0 || area.h <= 0.0 { + return rects; + } + + let mut order: Vec = (0..values.len()).filter(|i| values[*i] > 0.0).collect(); + order.sort_by(|a, b| values[*b].total_cmp(&values[*a])); + + let scale = f64::from(area.w) * f64::from(area.h) / total; + let areas: Vec = order.iter().map(|i| values[*i] * scale).collect(); + + let mut free = area; + let mut start = 0; + while start < areas.len() { + let side = f64::from(free.w.min(free.h)); + let mut end = start + 1; + let mut row_sum = areas[start]; + let mut row_worst = worst_ratio(&areas[start..end], row_sum, side); + + while end < areas.len() { + let candidate_sum = row_sum + areas[end]; + let candidate_worst = worst_ratio(&areas[start..=end], candidate_sum, side); + if candidate_worst > row_worst { + break; + } + row_sum = candidate_sum; + row_worst = candidate_worst; + end += 1; + } + + if free.w >= free.h { + // A vertical strip on the left; tiles stack top to bottom. + let strip_w = ((row_sum / f64::from(free.h)) as f32).min(free.w); + let mut y = free.y; + for index in start..end { + let height = if strip_w > 0.0 { + (areas[index] / f64::from(strip_w)) as f32 + } else { + 0.0 + }; + let height = height.min(free.bottom() - y).max(0.0); + rects[order[index]] = Rect::new(free.x, y, strip_w, height); + y += height; + } + free.x += strip_w; + free.w -= strip_w; + } else { + // A horizontal strip along the top; tiles run left to right. + let strip_h = ((row_sum / f64::from(free.w)) as f32).min(free.h); + let mut x = free.x; + for index in start..end { + let width = if strip_h > 0.0 { + (areas[index] / f64::from(strip_h)) as f32 + } else { + 0.0 + }; + let width = width.min(free.right() - x).max(0.0); + rects[order[index]] = Rect::new(x, free.y, width, strip_h); + x += width; + } + free.y += strip_h; + free.h -= strip_h; + } + start = end; + } + rects +} + +/// Worst (largest) aspect ratio in a row of `areas` laid along `side`. +fn worst_ratio(areas: &[f64], row_sum: f64, side: f64) -> f64 { + if row_sum <= 0.0 || side <= 0.0 { + return f64::INFINITY; + } + let thickness = row_sum / side; + areas.iter().fold(0.0_f64, |worst, area| { + if *area <= 0.0 || thickness <= 0.0 { + return worst; + } + let other = area / thickness; + worst.max((thickness / other).max(other / thickness)) + }) +} + +/// The deepest tile containing a point. +/// +/// Member cells are emitted after their community header and are inset inside +/// the community rect, so the last match in reverse order is the most +/// specific one. +pub fn hit(tiles: &[Tile], x: f32, y: f32) -> Option<&Tile> { + tiles.iter().rev().find(|tile| tile.rect.contains(x, y)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn area() -> Rect { + Rect::new(0.0, 0.0, 800.0, 500.0) + } + + fn two_cliques() -> Vec { + // Two 4-cliques, as in the GDS_LEIDEN two-cliques test. + build_communities(8, &[0, 0, 0, 0, 1, 1, 1, 1], None) + } + + #[test] + fn squarify_fills_the_area() { + let values = [40.0, 30.0, 20.0, 5.0, 3.0, 2.0]; + let rects = squarify(&values, area()); + let covered: f32 = rects.iter().map(Rect::area).sum(); + assert!( + (covered - area().area()).abs() < 1.0, + "covered {covered} of {}", + area().area() + ); + for rect in &rects { + assert!(rect.x >= -0.01 && rect.y >= -0.01); + assert!(rect.right() <= area().right() + 0.01); + assert!(rect.bottom() <= area().bottom() + 0.01); + } + } + + #[test] + fn squarify_keeps_aspect_ratios_reasonable() { + let values = [6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0]; + let rects = squarify(&values, Rect::new(0.0, 0.0, 600.0, 400.0)); + for rect in &rects { + let ratio = (rect.w / rect.h).max(rect.h / rect.w); + assert!(ratio <= 4.0, "aspect ratio {ratio} in {rect:?}"); + } + } + + #[test] + fn squarify_survives_degenerate_input() { + assert_eq!(squarify(&[], area()).len(), 0); + assert!(squarify(&[0.0, 0.0], area()) + .iter() + .all(|r| r.area().abs() < f32::EPSILON)); + assert!( + squarify(&[1.0], Rect::new(0.0, 0.0, 0.0, 10.0))[0] + .area() + .abs() + < f32::EPSILON + ); + } + + #[test] + fn communities_sort_largest_first_with_stable_slots() { + let cs = build_communities(5, &[7, 7, 7, 3, 3], None); + assert_eq!(cs.len(), 2); + assert_eq!(cs[0].members.len(), 3); + assert_eq!(cs[1].members.len(), 2); + assert_eq!((cs[0].slot, cs[1].slot), (0, 1)); + } + + #[test] + fn layout_nests_cells_inside_their_community() { + let cs = two_cliques(); + let tiles = layout_treemap(&cs, area(), 5.0, 20.0); + // 2 headers + 8 member cells. + assert_eq!(tiles.iter().filter(|t| t.header).count(), 2); + assert_eq!(tiles.iter().filter(|t| !t.header).count(), 8); + for cell in tiles.iter().filter(|t| !t.header) { + let header = tiles + .iter() + .find(|t| t.header && t.community == cell.community) + .expect("community header"); + // Cells start below their community's header band. + assert!( + cell.rect.y >= header.rect.bottom() - f32::EPSILON, + "{cell:?} overlaps its header" + ); + } + } + + #[test] + fn hit_prefers_the_member_cell_over_the_header() { + let cs = two_cliques(); + let tiles = layout_treemap(&cs, area(), 5.0, 20.0); + let cell = tiles.iter().find(|t| !t.header).expect("a cell"); + let cx = cell.rect.x + cell.rect.w / 2.0; + let cy = cell.rect.y + cell.rect.h / 2.0; + let found = hit(&tiles, cx, cy).expect("a hit"); + assert_eq!(found.node, cell.node); + assert!(hit(&tiles, -50.0, -50.0).is_none()); + } + + #[test] + fn pagerank_weights_scale_community_share() { + let weights = [10.0, 10.0, 10.0, 10.0, 1.0, 1.0, 1.0, 1.0]; + let cs = build_communities(8, &[0, 0, 0, 0, 1, 1, 1, 1], Some(&weights)); + assert_eq!(cs.len(), 2); + // Heavy community sorts first and owns 10x the weight. + assert!((cs[0].weight - 40.0).abs() < 1e-9); + assert!((cs[1].weight - 4.0).abs() < 1e-9); + let tiles = layout_treemap(&cs, area(), 5.0, 20.0); + let area_of = |community: usize| { + tiles + .iter() + .filter(|t| t.community == community) + .map(|t| t.rect.area()) + .sum::() + }; + // Header bands are shared per community, so compare loosely. + assert!(area_of(0) > area_of(1) * 2.0); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5de1a89..5491571 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod backend; pub mod cli; +pub mod clusters; pub mod model; pub mod theme; pub mod ui; diff --git a/src/main.rs b/src/main.rs index ae68c5d..f982bc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use bugscope::cli::parse_cli; -use bugscope::ui::RootView; +use bugscope::ui::{RootView, ViewMode}; use gpui::*; use std::cell::RefCell; use std::rc::Rc; @@ -13,6 +13,9 @@ actions!( ToggleLayout, ToggleTheme, ToggleSidebar, + ToggleInsights, + ViewGraph, + ViewTreemap, ResetView, OpenPreferences, Quit @@ -49,6 +52,13 @@ fn main() { cx.on_action(|_: &ToggleLayout, cx| with_view(cx, |v, cx| v.toggle_layout(cx))); cx.on_action(|_: &ToggleTheme, cx| with_view(cx, |v, cx| v.toggle_theme(cx))); cx.on_action(|_: &ToggleSidebar, cx| with_view(cx, |v, cx| v.toggle_sidebar(cx))); + cx.on_action(|_: &ToggleInsights, cx| with_view(cx, |v, cx| v.toggle_right_pane(cx))); + cx.on_action(|_: &ViewGraph, cx| { + with_view(cx, |v, cx| v.set_view_mode(ViewMode::Graph, cx)) + }); + cx.on_action(|_: &ViewTreemap, cx| { + with_view(cx, |v, cx| v.set_view_mode(ViewMode::Treemap, cx)) + }); cx.on_action(|_: &ResetView, cx| with_view(cx, |v, cx| v.reset_view(cx))); cx.on_action(|_: &OpenPreferences, cx| with_view(cx, |v, cx| v.toggle_preferences(cx))); cx.on_action(|_: &Quit, cx| cx.quit()); @@ -56,6 +66,9 @@ fn main() { KeyBinding::new("cmd-o", OpenFile, None), KeyBinding::new("cmd-r", ReloadGraph, None), KeyBinding::new("cmd-b", ToggleSidebar, None), + KeyBinding::new("cmd-i", ToggleInsights, None), + KeyBinding::new("cmd-1", ViewGraph, None), + KeyBinding::new("cmd-2", ViewTreemap, None), KeyBinding::new("cmd-,", OpenPreferences, None), ]); cx.set_menus(vec![ @@ -82,6 +95,10 @@ fn main() { MenuItem::action("Pause/Resume Layout", ToggleLayout), MenuItem::action("Toggle Light/Dark Theme", ToggleTheme), MenuItem::action("Toggle Sidebar", ToggleSidebar), + MenuItem::action("Toggle Insights Pane", ToggleInsights), + MenuItem::separator(), + MenuItem::action("Graph View", ViewGraph), + MenuItem::action("Treemap (Leiden) View", ViewTreemap), MenuItem::separator(), MenuItem::action("Reset View", ResetView), ], diff --git a/src/ui.rs b/src/ui.rs index 6e76be2..adcedeb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,6 +3,7 @@ use crate::backend::{self, DatabaseInfo, GraphData, GraphNode}; use crate::cli::CliOptions; +use crate::clusters; use crate::model::{node_size, Camera, GraphModel, Vec2}; use crate::theme::{edge_color, highlight, node_color, Theme}; use gpui::*; @@ -38,6 +39,39 @@ struct SnapNode { name: String, } +/// Center-pane view: force-directed graph or Leiden treemap. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ViewMode { + #[default] + Graph, + Treemap, +} + +/// One row of the Top PageRank list in the insights pane. +#[derive(Clone)] +struct TopRank { + id: String, + name: String, + label: String, + score: f64, +} + +/// Everything the treemap needs for one frame: the Leiden communities +/// (largest first, shared with hit-testing via the same `Rc`), truncated +/// names per displayed node, and the hover/selection to outline. +#[derive(Clone)] +struct TreeSnapshot { + tree: Rc>, + names: Rc>, + theme: Theme, + hover_node: Option, + selected_node: Option, + selected_community: Option, + label_font: String, + label_font_size: f32, + summary: String, +} + pub struct RootView { databases: Vec, selected_db: Option, @@ -45,6 +79,26 @@ pub struct RootView { full: GraphData, model: GraphModel, camera: Camera, + /// Center-pane view, switched by the header toggle. + view_mode: ViewMode, + /// Currently displayed graph, in model order: full scan, Cypher result, + /// or 1-hop neighborhood. Analytics (Leiden/PageRank) run over this. + shown: GraphData, + /// Leiden community per displayed node + modularity + status line. + communities: Vec, + cluster_modularity: f64, + cluster_status: String, + /// PageRank score per displayed node, and the top 10 with names. + scores: Vec, + top_ranks: Vec, + /// Leiden communities largest-first with truncated names, shared by + /// paint + hit-testing so both always agree. + tree: Rc>, + tree_names: Rc>, + /// Displayed-node index under the treemap cursor. + treemap_hover: Option, + /// Collapsible right insights pane (Top PageRank + Leiden summary). + right_open: bool, status: String, query: String, /// Byte offset of the text cursor inside `query` (always a char boundary). @@ -122,6 +176,17 @@ impl RootView { full: GraphData::default(), model: GraphModel::default(), camera: Camera::default(), + view_mode: ViewMode::Graph, + shown: GraphData::default(), + communities: Vec::new(), + cluster_modularity: 0.0, + cluster_status: "No graph loaded.".to_string(), + scores: Vec::new(), + top_ranks: Vec::new(), + tree: Rc::new(Vec::new()), + tree_names: Rc::new(Vec::new()), + treemap_hover: None, + right_open: true, status: "Pick a database to begin.".to_string(), query: String::new(), query_cursor: 0, @@ -217,6 +282,8 @@ impl RootView { self.focused = None; self.selected = None; self.running = !self.schema_mode; + self.shown = data; + self.refresh_analytics(); self.set_status(msg, cx); } Err(e) => self.set_status(format!("Load failed: {e:#}"), cx), @@ -274,6 +341,134 @@ impl RootView { cx.notify(); } + /// Header toggle + menu actions for the center-pane view. + pub fn set_view_mode(&mut self, mode: ViewMode, cx: &mut Context) { + self.view_mode = mode; + cx.notify(); + } + + pub fn toggle_right_pane(&mut self, cx: &mut Context) { + self.right_open = !self.right_open; + cx.notify(); + } + + /// Recompute Leiden communities + PageRank over the displayed graph. + /// Runs synchronously: loaders cap the graph at 10–20k nodes, where + /// both algorithms finish in well under a second. + fn refresh_analytics(&mut self) { + let n = self.shown.nodes.len(); + if n == 0 { + self.communities.clear(); + self.scores.clear(); + self.top_ranks.clear(); + self.cluster_modularity = 0.0; + self.cluster_status = "No graph loaded.".to_string(); + self.tree = Rc::new(Vec::new()); + self.tree_names = Rc::new(Vec::new()); + self.treemap_hover = None; + return; + } + let (assignment, modularity, _) = backend::graphr_leiden_full(&self.shown); + self.communities = assignment; + self.cluster_modularity = modularity; + let mut distinct = self.communities.clone(); + distinct.sort_unstable(); + distinct.dedup(); + self.cluster_status = format!( + "{} Leiden communities · Q={:.2}", + distinct.len(), + modularity + ); + let ranks = backend::graphr_page_rank(&self.shown); + let score_of: std::collections::HashMap<&str, f64> = + ranks.iter().map(|(id, s)| (id.as_str(), *s)).collect(); + self.scores = self + .shown + .nodes + .iter() + .map(|nd| score_of.get(nd.id.as_str()).copied().unwrap_or(0.0)) + .collect(); + let by_id: std::collections::HashMap<&str, &GraphNode> = self + .shown + .nodes + .iter() + .map(|nd| (nd.id.as_str(), nd)) + .collect(); + self.top_ranks = ranks + .into_iter() + .take(10) + .filter_map(|(id, score)| { + by_id.get(id.as_str()).map(|nd| TopRank { + id: id.clone(), + name: nd.name.clone(), + label: nd.label.clone(), + score, + }) + }) + .collect(); + let tree = clusters::build_communities(n, &self.communities, Some(&self.scores)); + let names = self + .shown + .nodes + .iter() + .map(|nd| truncate_label(&nd.name)) + .collect(); + self.tree = Rc::new(tree); + self.tree_names = Rc::new(names); + if self.treemap_hover.map(|i| i >= n).unwrap_or(false) { + self.treemap_hover = None; + } + if self.selected.map(|i| i >= n).unwrap_or(false) { + self.selected = None; + } + } + + /// Squarified tiles for a viewport, shared by paint + hit-testing. + fn tree_tiles(vw: f32, vh: f32, tree: &[clusters::Community]) -> Vec { + clusters::layout_treemap( + tree, + clusters::Rect::new(0.0, 0.0, vw.max(50.0), vh.max(50.0)), + 5.0, + 20.0, + ) + } + + /// Deepest treemap tile under a canvas-local point. + fn pick_treemap(&self, lx: f32, ly: f32) -> Option { + let (vw, vh) = self.canvas_size(); + Self::tree_tiles(vw, vh, &self.tree) + .into_iter() + .rev() + .find(|t| t.rect.contains(lx, ly)) + } + + /// Displayed-node index shown for a tile: the member cell itself, or + /// the heaviest member when the community header was hit. + fn treemap_tile_node(&self, tile: &clusters::Tile) -> Option { + tile.node.or_else(|| { + self.tree + .get(tile.community) + .and_then(|c| c.members.first().copied()) + }) + } + + fn tree_snapshot(&self, theme: Theme) -> TreeSnapshot { + let selected_community = self + .selected + .and_then(|i| self.tree.iter().position(|c| c.members.contains(&i))); + TreeSnapshot { + tree: self.tree.clone(), + names: self.tree_names.clone(), + theme, + hover_node: self.treemap_hover, + selected_node: self.selected, + selected_community, + label_font: self.label_font.clone(), + label_font_size: self.label_font_size, + summary: self.cluster_status.clone(), + } + } + pub fn toggle_preferences(&mut self, cx: &mut Context) { self.show_preferences = !self.show_preferences; cx.notify(); @@ -428,14 +623,10 @@ impl RootView { self.model.settle(); self.frame_initial(None); self.running = !data.nodes.is_empty() && !self.schema_mode; - self.set_status( - format!( - "Cypher: {} nodes, {} edges", - data.nodes.len(), - data.links.len() - ), - cx, - ); + let (nn, ne) = (data.nodes.len(), data.links.len()); + self.shown = data; + self.refresh_analytics(); + self.set_status(format!("Cypher: {nn} nodes, {ne} edges"), cx); } } Err(e) => self.set_status(format!("Query failed: {e:#}"), cx), @@ -453,6 +644,8 @@ impl RootView { self.model.settle(); self.frame_initial(Some(node_id)); self.running = true; + self.shown = view; + self.refresh_analytics(); self.set_status(format!("Neighborhood of {node_id}"), cx); } @@ -555,7 +748,7 @@ impl RootView { } } - fn render_header(&mut self, theme: Theme, cx: &mut Context) -> impl IntoElement { + fn render_header(&mut self, theme: Theme, cx: &mut Context) -> Div { let db_name = self .selected_db .and_then(|id| self.databases.get(id)) @@ -565,6 +758,35 @@ impl RootView { // The theme button flips between the two palettes; the glyph shows // what you get when you click (sun on dark, moon on light). let theme_button = if theme.dark { "☀" } else { "☾" }; + // View toggle: graph ↔ Leiden treemap. The active view reads as a + // filled pill; the other as a quiet button. + let mut toggle = div().flex().flex_row().gap_1(); + for (label, mode) in [("Graph", ViewMode::Graph), ("Treemap", ViewMode::Treemap)] { + let active = self.view_mode == mode; + toggle = toggle.child( + div() + .px_2() + .py_1() + .rounded_md() + .text_sm() + .bg(if active { + theme.accent + } else { + theme.selection + }) + .text_color(if active { + theme.on_accent + } else { + theme.foreground + }) + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |view, _, _, cx| view.set_view_mode(mode, cx)), + ) + .child(label.to_string()), + ); + } div() .flex() .flex_row() @@ -577,25 +799,33 @@ impl RootView { .child(div().font_weight(FontWeight::BOLD).child("Bugscope")) .child(div().text_sm().child(format!("{db_name} · {status}"))) .child( - div().flex_1().flex().flex_row().justify_end().child( - div() - .px_2() - .py_1() - .rounded_md() - .bg(theme.selection) - .cursor_pointer() - .on_mouse_down( - MouseButton::Left, - cx.listener(|view, _, window, cx| { - let current = view - .dark_override - .unwrap_or_else(|| Theme::current(window).dark); - view.dark_override = Some(!current); - cx.notify(); - }), - ) - .child(theme_button), - ), + div() + .flex_1() + .flex() + .flex_row() + .justify_end() + .items_center() + .gap_2() + .child(toggle) + .child( + div() + .px_2() + .py_1() + .rounded_md() + .bg(theme.selection) + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(|view, _, window, cx| { + let current = view + .dark_override + .unwrap_or_else(|| Theme::current(window).dark); + view.dark_override = Some(!current); + cx.notify(); + }), + ) + .child(theme_button), + ), ) } @@ -748,7 +978,7 @@ impl RootView { (f32::from(s.width), f32::from(s.height)) } - fn render_graph_canvas(&mut self, theme: Theme, cx: &mut Context) -> impl IntoElement { + fn render_graph_canvas(&mut self, theme: Theme, cx: &mut Context) -> Div { let snap = self.snapshot(theme); let origin_cell = self.canvas_origin.clone(); let size_cell = self.canvas_size.clone(); @@ -848,6 +1078,213 @@ impl RootView { cx.notify(); })) } + + /// Divider gutter between the canvas and the insights pane, mirroring + /// the left sidebar divider. Always rendered so the pane can be + /// reopened when hidden. + fn render_right_divider(&mut self, theme: Theme, cx: &mut Context) -> Div { + // Chevron points the way the bar will go. + let glyph = if self.right_open { "»" } else { "«" }; + let line = || div().flex_1().w(px(1.)).bg(theme.border); + div() + .flex() + .flex_col() + .items_center() + .w(px(18.)) + .h_full() + .py_1() + .child(line()) + .child( + div() + .px_1() + .text_xs() + .rounded_md() + .bg(theme.surface) + .border_1() + .border_color(theme.border) + .text_color(theme.secondary) + .cursor_pointer() + .hover(|s| s.bg(theme.selection)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|view, _, _, cx| view.toggle_right_pane(cx)), + ) + .child(glyph), + ) + .child(line()) + } + + /// Collapsible right pane: top 10 nodes by PageRank over the displayed + /// graph, plus the Leiden community summary. Clicking a row focuses the + /// node's 1-hop neighborhood; clicking a community selects its heaviest + /// member and jumps to the treemap. + fn render_insights(&mut self, theme: Theme, cx: &mut Context) -> impl IntoElement { + let mut col = div() + .flex() + .flex_col() + .gap_1() + .p_2() + .w(px(260.)) + .h_full() + .id("insights") + .overflow_y_scroll() + .bg(theme.surface) + .text_color(theme.foreground) + .text_sm() + .child(div().font_weight(FontWeight::BOLD).child("Insights")) + .child( + div() + .text_xs() + .text_color(theme.secondary) + .child(self.cluster_status.clone()), + ) + .child( + div() + .pt_1() + .font_weight(FontWeight::BOLD) + .child("Top PageRank"), + ); + if self.top_ranks.is_empty() { + col = col.child( + div() + .text_xs() + .text_color(theme.secondary) + .child("Load a graph to rank nodes."), + ); + } + for (rank, top) in self.top_ranks.clone().into_iter().enumerate() { + let id = top.id.clone(); + let row = format!("#{} {} · {}", rank + 1, top.name, top.label); + let score = format!("{:.4}", top.score); + col = col.child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .px_2() + .py(px(2.)) + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(theme.selection)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |view, _, _, cx| { + view.focus_node(&id.clone(), cx); + }), + ) + .child(div().flex_1().child(row)) + .child(div().text_xs().text_color(theme.secondary).child(score)), + ); + } + col = col.child( + div() + .pt_2() + .font_weight(FontWeight::BOLD) + .child("Leiden communities"), + ); + if self.tree.is_empty() { + col = col.child( + div() + .text_xs() + .text_color(theme.secondary) + .child("No communities yet."), + ); + } + for (pos, comm) in self.tree.iter().enumerate() { + let first = comm.members.first().copied(); + let label = format!("C{} · {} nodes", pos + 1, comm.members.len()); + col = col.child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .px_2() + .py(px(2.)) + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(theme.selection)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |view, _, _, cx| { + if let Some(m) = first { + view.selected = Some(m); + view.set_view_mode(ViewMode::Treemap, cx); + } + }), + ) + .child( + div() + .w(px(10.)) + .h(px(10.)) + .rounded_sm() + .bg(node_color(&theme, pos)), + ) + .child(div().flex_1().child(label)), + ); + } + col + } + + fn render_treemap_canvas(&mut self, theme: Theme, cx: &mut Context) -> Div { + let snap = self.tree_snapshot(theme); + let origin_cell = self.canvas_origin.clone(); + let size_cell = self.canvas_size.clone(); + div() + .flex_1() + .h_full() + .bg(theme.inset) + .overflow_hidden() + .child( + canvas( + move |bounds, _window, _cx| { + origin_cell.set(bounds.origin); + size_cell.set(bounds.size); + (snap, bounds) + }, + move |_bounds, (snap, bounds), window, cx| { + paint_treemap(&snap, bounds, window, cx); + }, + ) + .flex_1() + .h_full(), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|view, ev: &MouseDownEvent, _, cx| { + let (lx, ly) = view.to_local(ev.position); + let Some(tile) = view.pick_treemap(lx, ly) else { + view.treemap_hover = None; + cx.notify(); + return; + }; + let node = view.treemap_tile_node(&tile); + if ev.click_count >= 2 { + if let Some(i) = node { + if let Some(nd) = view.shown.nodes.get(i) { + let id = nd.id.clone(); + view.focus_node(&id, cx); + } + } + return; + } + view.selected = node; + view.treemap_hover = node; + cx.notify(); + }), + ) + .on_mouse_move(cx.listener(|view, ev: &MouseMoveEvent, _, cx| { + let (lx, ly) = view.to_local(ev.position); + let h = view + .pick_treemap(lx, ly) + .and_then(|t| view.treemap_tile_node(&t)); + if h != view.treemap_hover { + view.treemap_hover = h; + cx.notify(); + } + })) + } } impl Render for RootView { @@ -908,6 +1345,15 @@ impl Render for RootView { } else { None }; + let center = match self.view_mode { + ViewMode::Graph => self.render_graph_canvas(theme, cx), + ViewMode::Treemap => self.render_treemap_canvas(theme, cx), + }; + let insights = if self.right_open { + Some(self.render_insights(theme, cx)) + } else { + None + }; div() .flex() .flex_col() @@ -924,7 +1370,9 @@ impl Render for RootView { .min_h_0() .children(sidebar) .child(self.render_divider(theme, cx)) - .child(self.render_graph_canvas(theme, cx)), + .child(center) + .child(self.render_right_divider(theme, cx)) + .children(insights), ) // Compact query bar at the bottom: `/foo` searches, anything // else runs as Cypher. Enter submits; Escape clears. @@ -1516,3 +1964,246 @@ fn paint_labels(snap: &Snapshot, bounds: Bounds, window: &mut Window, cx ); } } + +/// Treemap of the Leiden communities, disktree-style: tiles are painted, not +/// composed from elements (thousands of rects would spend the frame in +/// layout). Each community gets a header band with its name over member cells +/// tinted by community hue; the selected node's community reads as an amber +/// ring on every one of its cells, like the graph view's hot edges. +fn paint_treemap(snap: &TreeSnapshot, bounds: Bounds, window: &mut Window, cx: &mut App) { + let vw = f32::from(bounds.size.width); + let vh = f32::from(bounds.size.height); + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + let font_size = px(snap.label_font_size); + + let tiles = RootView::tree_tiles(vw, vh, &snap.tree); + if tiles.is_empty() { + // Empty state: centered hint instead of a blank canvas. + let text: SharedString = if snap.tree.is_empty() { + snap.summary.clone().into() + } else { + "Treemap tiles too small — load a smaller graph or resize.".into() + }; + let run = TextRun { + len: text.len(), + font: font(&snap.label_font), + color: snap.theme.secondary, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window + .text_system() + .shape_line(text, font_size, &[run], None); + let w = f32::from(shaped.width); + let h = f32::from(shaped.ascent + shaped.descent); + let _ = shaped.paint( + point(px(ox + (vw - w) / 2.0), px(oy + (vh - h) / 2.0)), + px(14.), + window, + cx, + ); + return; + } + + // Fills first; outlines after every fill so member cells never cover + // their community's selection ring (same ordering trick as disktree). + let mut outlines: Vec<(u8, Bounds, f32, Hsla)> = Vec::new(); + for tile in &tiles { + let fill = node_color(&snap.theme, tile.community); + let quad_bounds = Bounds::new( + point(px(ox + tile.rect.x), px(oy + tile.rect.y)), + size(px(tile.rect.w), px(tile.rect.h)), + ); + if tile.header { + window.paint_quad(PaintQuad { + bounds: quad_bounds, + corner_radii: Corners::all(px(3.)), + background: snap.theme.surface.into(), + border_widths: Edges::default(), + border_color: transparent_black(), + border_style: BorderStyle::Solid, + }); + // Community strip: a thin slab of the hue across the top, so the + // first level of structure reads before any detail. + let strip = Bounds::new( + quad_bounds.origin, + size( + quad_bounds.size.width, + px(2.0f32.min(f32::from(quad_bounds.size.height))), + ), + ); + window.paint_quad(PaintQuad { + bounds: strip, + corner_radii: Corners::default(), + background: fill.into(), + border_widths: Edges::default(), + border_color: transparent_black(), + border_style: BorderStyle::Solid, + }); + } else { + window.paint_quad(PaintQuad { + bounds: quad_bounds, + corner_radii: Corners::all(px(3.)), + background: fill.into(), + border_widths: Edges::default(), + border_color: transparent_black(), + border_style: BorderStyle::Solid, + }); + } + let node_here = tile.node.filter(|n| Some(*n) == snap.hover_node); + let ring = Bounds::new( + point(px(ox + tile.rect.x), px(oy + tile.rect.y)), + size(px(tile.rect.w), px(tile.rect.h)), + ); + let outline = if tile.node.is_some() && tile.node == snap.selected_node { + Some((3u8, ring, 2.0, snap.theme.bright)) + } else if node_here.is_some() { + Some((2, ring, 1.0, snap.theme.bright)) + } else if Some(tile.community) == snap.selected_community { + Some((1, ring, 1.5, highlight(&snap.theme))) + } else { + None + }; + if let Some((rank, ring, width, color)) = outline { + outlines.push((rank, ring, width, color)); + } + } + outlines.sort_by_key(|(rank, ..)| *rank); + for (_, ring, width, color) in outlines { + window.paint_quad(PaintQuad { + bounds: ring, + corner_radii: Corners::all(px(3.)), + background: transparent_black().into(), + border_widths: Edges::all(px(width)), + border_color: color, + border_style: BorderStyle::Solid, + }); + } + + // Labels: every header names its community; member cells label when the + // text fits, largest first so the cap keeps informative names. + let mut cells: Vec<&clusters::Tile> = tiles + .iter() + .filter(|t| !t.header && t.node.is_some()) + .collect(); + cells.sort_by(|a, b| { + b.rect + .area() + .partial_cmp(&a.rect.area()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for tile in &tiles { + if !tile.header { + continue; + } + let count = snap + .tree + .get(tile.community) + .map(|c| c.members.len()) + .unwrap_or(0); + let label = format!("C{} · {count} nodes", tile.community + 1); + paint_tree_text( + snap, + &label, + ox + tile.rect.x + 6.0, + oy + tile.rect.y + 3.0, + tile.rect.w - 12.0, + snap.theme.bright, + window, + cx, + ); + } + for tile in cells.into_iter().take(150) { + if tile.rect.w < 56.0 || tile.rect.h < 18.0 { + continue; + } + let Some(i) = tile.node else { continue }; + let Some(name) = snap.names.get(i) else { + continue; + }; + paint_tree_text( + snap, + name, + ox + tile.rect.x + 5.0, + oy + tile.rect.y + 3.0, + tile.rect.w - 10.0, + snap.theme.bright, + window, + cx, + ); + } + + // Summary pill, bottom-left over the mosaic. + let text: SharedString = snap.summary.clone().into(); + let run = TextRun { + len: text.len(), + font: font(&snap.label_font), + color: snap.theme.secondary, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window + .text_system() + .shape_line(text, font_size, &[run], None); + let w = f32::from(shaped.width); + let h = f32::from(shaped.ascent + shaped.descent); + let pad_x = 6.0; + let pad_y = 3.0; + window.paint_quad(PaintQuad { + bounds: Bounds::new( + point(px(ox + 8.0), px(oy + vh - h - pad_y * 2.0 - 8.0)), + size(px(w + pad_x * 2.0), px(h + pad_y * 2.0)), + ), + corner_radii: Corners::all(px(4.)), + background: snap.theme.inset.opacity(0.88).into(), + border_widths: Edges::default(), + border_color: transparent_black(), + border_style: BorderStyle::Solid, + }); + let _ = shaped.paint( + point( + px(ox + 8.0 + pad_x), + px(oy + vh - h - pad_y * 2.0 - 8.0 + pad_y), + ), + px(14.), + window, + cx, + ); +} + +/// One treemap label, painted only when the shaped text fits its tile — +/// nothing may bleed into the neighbour. +#[allow(clippy::too_many_arguments)] +fn paint_tree_text( + snap: &TreeSnapshot, + label: &str, + x: f32, + y: f32, + max_w: f32, + color: Hsla, + window: &mut Window, + cx: &mut App, +) { + if max_w < 20.0 { + return; + } + let text: SharedString = label.to_string().into(); + let run = TextRun { + len: text.len(), + font: font(&snap.label_font), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window + .text_system() + .shape_line(text, px(snap.label_font_size), &[run], None); + if f32::from(shaped.width) > max_w { + return; + } + let _ = shaped.paint(point(px(x), px(y)), px(14.), window, cx); +} diff --git a/tests/gds_leiden.rs b/tests/gds_leiden.rs new file mode 100644 index 0000000..8483b7f --- /dev/null +++ b/tests/gds_leiden.rs @@ -0,0 +1,193 @@ +//! GDS_LEIDEN coverage, ported from +//! https://github.com/LadybugDB/extensions/blob/bf74ab654af6bd40a0e3208ac4e8bcf5cb45a7a0/algo/test/test_files/gds_leiden.test +//! +//! Requires the bundled algo extension (`extensions/algo/libalgo.lbug_extension`, +//! built by `scripts/build_algo_extension.sh`, or `BUGSCOPE_ALGO_EXTENSION`). +//! The GDS assertions skip gracefully when the extension is absent so +//! `cargo test` stays green on machines without the native bundle. The local +//! icebug Leiden cross-check always runs (no extension needed). + +use bugscope::backend::{self, GraphData}; +use lbug::{Database, SystemConfig, Value}; + +fn open_ephemeral() -> (tempfile::TempDir, Database) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("leiden.lbdb"); + let db = Database::new(path.to_str().unwrap(), SystemConfig::default()).unwrap(); + (dir, db) +} + +fn exec(conn: &lbug::Connection, q: &str) { + conn.query(q) + .unwrap_or_else(|e| panic!("query failed: {q}\n{e}")); +} + +/// `CALL GDS_LEIDEN(graph) RETURN COUNT(DISTINCT community_id)`, with an +/// optional `WITH node, community_id WHERE ...` cohesion filter. +fn distinct_communities(conn: &lbug::Connection, graph: &str, filter: &str) -> i64 { + let where_clause = if filter.is_empty() { + String::new() + } else { + format!("WITH node, community_id WHERE {filter} ") + }; + let mut result = conn + .query(&format!( + "CALL GDS_LEIDEN('{graph}') {where_clause}RETURN COUNT(DISTINCT community_id)" + )) + .expect("GDS_LEIDEN call failed"); + let row = result.next().expect("GDS_LEIDEN returned no rows"); + if row.len() != 1 { + panic!("unexpected GDS_LEIDEN count row: {row:?}"); + } + match &row[0] { + Value::Int64(v) => *v, + Value::UInt64(v) => *v as i64, + other => panic!("unexpected GDS_LEIDEN count row: {other:?}"), + } +} + +/// Two 4-cliques joined by a single bridge edge — the `GDSLeidenTwoCliques` +/// case from `gds_leiden.test`. +fn build_two_cliques(conn: &lbug::Connection) { + exec(conn, "CREATE NODE TABLE Node(id INT64 PRIMARY KEY)"); + exec(conn, "CREATE REL TABLE Edge(FROM Node to Node)"); + exec( + conn, + "CREATE (u0:Node {id: 0}), (u1:Node {id: 1}), (u2:Node {id: 2}), (u3:Node {id: 3}), + (u4:Node {id: 4}), (u5:Node {id: 5}), (u6:Node {id: 6}), (u7:Node {id: 7})", + ); + let edges = [ + (0, 1), + (0, 2), + (1, 2), + (0, 3), + (1, 3), + (2, 3), + (4, 5), + (4, 6), + (5, 6), + (4, 7), + (5, 7), + (6, 7), + (3, 4), // the bridge + ]; + for (s, t) in edges { + exec( + conn, + &format!("MATCH (a:Node {{id:{s}}}), (b:Node {{id:{t}}}) CREATE (a)-[:Edge]->(b)"), + ); + } + exec(conn, "CALL PROJECT_GRAPH('Graph', ['Node'], ['Edge'])"); +} + +#[test] +fn gds_leiden_two_cliques() { + let (_dir, db) = open_ephemeral(); + let conn = lbug::Connection::new(&db).unwrap(); + if !backend::install_algo_extension(&conn) { + eprintln!("SKIP gds_leiden_two_cliques: cannot INSTALL algo (offline?)"); + return; + } + assert!( + backend::ensure_algo_extension(&conn), + "algo installed but LOAD algo failed" + ); + build_two_cliques(&conn); + + // Leiden community IDs are arbitrary and the algorithm randomizes, so + // assert structure, not exact IDs: two communities, one per clique. + assert_eq!(distinct_communities(&conn, "Graph", ""), 2); + assert_eq!( + distinct_communities(&conn, "Graph", "node.id IN [0, 1, 2, 3]"), + 1 + ); + assert_eq!( + distinct_communities(&conn, "Graph", "node.id IN [4, 5, 6, 7]"), + 1 + ); + + // The Rust helper reads the same rows into id -> community. + let map = backend::gds_leiden_communities(&conn, "Graph", None).unwrap(); + assert_eq!(map.len(), 8); + let comm = |id: i64| { + let key = map + .keys() + .find(|k| k.ends_with(&format!(":{id}"))) + .unwrap_or_else(|| panic!("node id {id} missing from {map:?}")); + map[key] + }; + assert!(comm(0) == comm(1) && comm(1) == comm(2) && comm(2) == comm(3)); + assert!(comm(4) == comm(5) && comm(5) == comm(6) && comm(6) == comm(7)); + assert_ne!(comm(0), comm(4)); + + // Gamma must be positive; unknown params rejected. + let err = conn + .query("CALL GDS_LEIDEN('Graph', gamma := -1.0) RETURN COUNT(DISTINCT community_id)") + .unwrap_err() + .to_string(); + assert!( + err.contains("Gamma must be positive"), + "unexpected error: {err}" + ); + let err = conn + .query("CALL GDS_LEIDEN('Graph', bogus := 1) RETURN COUNT(DISTINCT community_id)") + .unwrap_err() + .to_string(); + assert!( + err.contains("Unknown optional parameter"), + "unexpected error: {err}" + ); +} + +/// In-process icebug Leiden on the same two-clique shape: needs no extension +/// and must agree on structure. This is what the treemap visualizes. +#[test] +fn local_leiden_two_cliques() { + let nodes: Vec = (0..8) + .map(|i| bugscope::backend::GraphNode { + id: format!("0:{i}"), + name: format!("u{i}"), + label: "Node".to_string(), + properties: std::collections::HashMap::new(), + }) + .collect(); + let edges = [ + (0, 1), + (0, 2), + (1, 2), + (0, 3), + (1, 3), + (2, 3), + (4, 5), + (4, 6), + (5, 6), + (4, 7), + (5, 7), + (6, 7), + (3, 4), + ]; + let links = edges + .iter() + .map(|(s, t)| bugscope::backend::GraphLink { + source: format!("0:{s}"), + target: format!("0:{t}"), + label: "Edge".to_string(), + }) + .collect(); + let data = GraphData { nodes, links }; + let (assignment, modularity, count) = backend::graphr_leiden_full(&data); + assert_eq!(assignment.len(), 8); + assert_eq!(count, 2, "two cliques, two communities: {assignment:?}"); + assert!( + assignment[0] == assignment[1] + && assignment[1] == assignment[2] + && assignment[2] == assignment[3] + ); + assert!( + assignment[4] == assignment[5] + && assignment[5] == assignment[6] + && assignment[6] == assignment[7] + ); + assert_ne!(assignment[0], assignment[4]); + assert!(modularity > 0.3, "dense cliques score well: {modularity}"); +} diff --git a/tests/loader.rs b/tests/loader.rs new file mode 100644 index 0000000..26f32d2 --- /dev/null +++ b/tests/loader.rs @@ -0,0 +1,79 @@ +//! Loader regression test: `collect_edge_graph` must return every node and +//! edge of a small database, including isolated nodes. +//! +//! Regression coverage for a real-world failure where the topology scan +//! silently decoded zero rows (Arrow-array version skew between lbug and +//! this crate, plus empty CSR metadata), so the UI showed "0 nodes" for a +//! database holding thousands of them. Whatever fast path / fallback runs, +//! the loader's contract is the full edge-bounded graph. + +use bugscope::backend::{self, GraphData}; +use lbug::{Database, SystemConfig}; +use std::collections::HashSet; + +fn open_ephemeral() -> (tempfile::TempDir, Database) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("loader.lbdb"); + let db = Database::new(path.to_str().unwrap(), SystemConfig::default()).unwrap(); + (dir, db) +} + +fn exec(conn: &lbug::Connection, q: &str) { + conn.query(q) + .unwrap_or_else(|e| panic!("query failed: {q}\n{e}")); +} + +#[test] +fn collect_edge_graph_returns_everything() { + let (_dir, db) = open_ephemeral(); + let conn = lbug::Connection::new(&db).unwrap(); + exec(&conn, "CREATE NODE TABLE N(id INT64 PRIMARY KEY)"); + exec(&conn, "CREATE REL TABLE E(FROM N TO N)"); + // A 4-star (hub 0 <- leaves 1,2,3) plus an isolated node 4. + exec( + &conn, + "CREATE (a:N{id:0}), (b:N{id:1}), (c:N{id:2}), (d:N{id:3}), (e:N{id:4})", + ); + for leaf in [1, 2, 3] { + exec( + &conn, + &format!("MATCH (x:N{{id:{leaf}}}), (y:N{{id:0}}) CREATE (x)-[:E]->(y)"), + ); + } + + let data: GraphData = backend::collect_edge_graph(&conn, 10_000).unwrap(); + assert_eq!(data.nodes.len(), 5, "all nodes incl. isolated: {data:?}"); + assert_eq!(data.links.len(), 3, "all edges: {data:?}"); + + // Every endpoint resolves to a loaded node; labels survive the trip. + let ids: HashSet<&str> = data.nodes.iter().map(|n| n.id.as_str()).collect(); + assert_eq!(ids.len(), 5); + for link in &data.links { + assert!( + ids.contains(link.source.as_str()), + "dangling source {}", + link.source + ); + assert!( + ids.contains(link.target.as_str()), + "dangling target {}", + link.target + ); + assert_eq!(link.label, "E"); + } + assert!( + data.nodes.iter().all(|n| n.label == "N"), + "node labels: {data:?}" + ); +} + +#[test] +fn collect_edge_graph_empty_db_is_empty() { + let (_dir, db) = open_ephemeral(); + let conn = lbug::Connection::new(&db).unwrap(); + exec(&conn, "CREATE NODE TABLE N(id INT64 PRIMARY KEY)"); + exec(&conn, "CREATE REL TABLE E(FROM N TO N)"); + let data: GraphData = backend::collect_edge_graph(&conn, 10_000).unwrap(); + assert!(data.nodes.is_empty()); + assert!(data.links.is_empty()); +}