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

nordisk/facett

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