facett-graphnav/src/table.rs
//! **The data-laboratory table view** — the navigator's graph as two Arrow
//! [`RecordBatch`]es a [`facett_grid::Grid`](https://docs.rs/facett-grid) mounts:
//!
//! - the **node table** — `id`, `label`, `caption`, undirected `degree`, plus the
//! computed centrality/partition columns (`pagerank`, `betweenness`, `community`);
//! - the **edge table** — `src`, `dst`, `type`, `weight`.
//!
//! Row order matches `graph.nodes` / `graph.edges`, so a grid row ⇄ a canvas element
//! is a plain index — the seam the two-way selection sync keys on. Pure + Arrow-only
//! (no egui), so it is headless-testable and any grid/table facet can ingest it.
use std::sync::Arc;
use arrow_array::{Float64Array, Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use crate::appearance::{Metric, Partition};
use crate::model::PropGraph;
/// The node table's columns, without computing a row. [`node_batch`] builds its batch
/// on this schema; a caller that only needs the column NAMES (a state readout) reads
/// them here — building the batch computes betweenness, which is O(V·E): 9.5 s at
/// 10 000 nodes (MEASURED 2026-09-28, oden), per read.
#[must_use]
pub fn node_schema() -> Schema {
Schema::new(vec![
Field::new("id", DataType::Utf8, false),
Field::new("label", DataType::Utf8, false),
Field::new("caption", DataType::Utf8, false),
Field::new("degree", DataType::Int64, false),
Field::new("pagerank", DataType::Float64, false),
Field::new("betweenness", DataType::Float64, false),
Field::new("community", DataType::Int64, false),
])
}
/// The edge table's columns, without computing a row (see [`node_schema`]).
#[must_use]
pub fn edge_schema() -> Schema {
Schema::new(vec![
Field::new("src", DataType::Utf8, false),
Field::new("dst", DataType::Utf8, false),
Field::new("type", DataType::Utf8, false),
Field::new("weight", DataType::Float64, false),
])
}
/// Build the **node** [`RecordBatch`]: `id, label, caption, degree, pagerank,
/// betweenness, community`. One row per node, in `graph.nodes` order.
#[must_use]
pub fn node_batch(graph: &PropGraph) -> RecordBatch {
let n = graph.nodes.len();
let ids: Vec<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect();
let labels: Vec<&str> = graph.nodes.iter().map(|n| n.label.as_str()).collect();
let captions: Vec<&str> = graph.nodes.iter().map(|n| n.caption()).collect();
// Raw (un-normalised) degree so the column reads as a plain count.
let edges = graph.edge_indices();
let g = facett_graphview::analysis::Adjacency::from_edges(n, &edges);
let degree: Vec<i64> = (0..n).map(|i| g.degree(i) as i64).collect();
let pagerank: Vec<f64> = Metric::PageRank.values(graph).into_iter().map(f64::from).collect();
let betweenness: Vec<f64> = Metric::Betweenness.values(graph).into_iter().map(f64::from).collect();
let (community, _) = Partition::Louvain.assign(graph);
let community: Vec<i64> = community.into_iter().map(|c| c as i64).collect();
RecordBatch::try_new(
Arc::new(node_schema()),
vec![
Arc::new(StringArray::from(ids)),
Arc::new(StringArray::from(labels)),
Arc::new(StringArray::from(captions)),
Arc::new(Int64Array::from(degree)),
Arc::new(Float64Array::from(pagerank)),
Arc::new(Float64Array::from(betweenness)),
Arc::new(Int64Array::from(community)),
],
)
.expect("node batch columns are all length n")
}
/// Build the **edge** [`RecordBatch`]: `src, dst, type, weight`. One row per edge,
/// in `graph.edges` order. `weight` is the edge's `weight` property (default `1.0`).
#[must_use]
pub fn edge_batch(graph: &PropGraph) -> RecordBatch {
let src: Vec<&str> = graph.edges.iter().map(|e| e.from.as_str()).collect();
let dst: Vec<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
let etype: Vec<&str> = graph.edges.iter().map(|e| e.etype.as_str()).collect();
let weight: Vec<f64> = graph
.edges
.iter()
.map(|e| e.props.get("weight").and_then(|w| w.parse::<f64>().ok()).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(1.0))
.collect();
RecordBatch::try_new(
Arc::new(edge_schema()),
vec![
Arc::new(StringArray::from(src)),
Arc::new(StringArray::from(dst)),
Arc::new(StringArray::from(etype)),
Arc::new(Float64Array::from(weight)),
],
)
.expect("edge batch columns are all length m")
}
#[cfg(test)]
mod tests {
use super::*;
use arrow_array::Array;
#[test]
fn node_batch_has_a_row_per_node_and_metric_columns() {
let g = PropGraph::demo();
let b = node_batch(&g);
assert_eq!(b.num_rows(), g.nodes.len());
assert_eq!(b.num_columns(), 7);
let ids = b.column(0).as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(ids.value(0), "p1");
// The degree column is a real count: the bridge node p3 has degree 3.
let deg = b.column(3).as_any().downcast_ref::<Int64Array>().unwrap();
let p3 = g.index_of("p3").unwrap();
assert_eq!(deg.value(p3), 3);
}
#[test]
fn edge_batch_has_a_row_per_edge() {
let g = PropGraph::demo();
let b = edge_batch(&g);
assert_eq!(b.num_rows(), g.edges.len());
assert_eq!(b.num_columns(), 4);
let w = b.column(3).as_any().downcast_ref::<Float64Array>().unwrap();
assert!((w.value(0) - 1.0).abs() < 1e-9, "default weight is 1.0");
}
}