use std::collections::HashMap;
use rusqlite::Connection;
use crate::error::Result;
use crate::graph::store::{list_node_ids, read_edges};
use crate::graph::types::GraphEdge;
const CSR_EDGE_CAP_PER_NODE: usize = 64;
const CSR_EDGE_CAP_MIN: usize = 2000;
pub struct CsrGraph {
node_count: usize,
edge_count: usize,
out_offsets: Vec<u32>,
out_targets: Vec<u32>,
out_weights: Vec<f64>,
in_offsets: Vec<u32>,
in_sources: Vec<u32>,
in_weights: Vec<f64>,
out_weight_sum: Vec<f64>,
id_to_idx: HashMap<String, u32>,
idx_to_id: Vec<String>,
}
impl CsrGraph {
pub fn from_edges(node_ids: &[String], edges: &[GraphEdge]) -> Self {
let mut id_to_idx: HashMap<String, u32> = HashMap::with_capacity(node_ids.len());
let mut idx_to_id: Vec<String> = Vec::with_capacity(node_ids.len());
for id in node_ids {
if id_to_idx.contains_key(id) {
continue;
}
id_to_idx.insert(id.clone(), idx_to_id.len() as u32);
idx_to_id.push(id.clone());
}
let node_count = idx_to_id.len();
let mut resolved: Vec<(u32, u32, f64)> = Vec::with_capacity(edges.len());
for e in edges {
let (Some(&src), Some(&dst)) = (id_to_idx.get(&e.source), id_to_idx.get(&e.target))
else {
continue;
};
resolved.push((src, dst, e.weight));
}
resolved.sort_unstable_by_key(|&(s, _, _)| s);
let edge_count = resolved.len();
let mut out_offsets = vec![0u32; node_count + 1];
let mut out_weight_sum = vec![0.0f64; node_count];
for &(src, _, w) in &resolved {
out_offsets[src as usize + 1] += 1;
out_weight_sum[src as usize] += w;
}
for i in 1..=node_count {
out_offsets[i] += out_offsets[i - 1];
}
let mut out_targets = Vec::with_capacity(edge_count);
let mut out_weights = Vec::with_capacity(edge_count);
for &(_, dst, w) in &resolved {
out_targets.push(dst);
out_weights.push(w);
}
let mut by_target: Vec<(u32, u32, f64)> =
resolved.iter().map(|&(s, t, w)| (t, s, w)).collect();
by_target.sort_unstable_by_key(|&(t, _, _)| t);
let mut in_offsets = vec![0u32; node_count + 1];
for &(tgt, _, _) in &by_target {
in_offsets[tgt as usize + 1] += 1;
}
for i in 1..=node_count {
in_offsets[i] += in_offsets[i - 1];
}
let mut in_sources = Vec::with_capacity(edge_count);
let mut in_weights = Vec::with_capacity(edge_count);
for &(_, src, w) in &by_target {
in_sources.push(src);
in_weights.push(w);
}
Self {
node_count,
edge_count,
out_offsets,
out_targets,
out_weights,
in_offsets,
in_sources,
in_weights,
out_weight_sum,
id_to_idx,
idx_to_id,
}
}
pub fn build_csr(conn: &Connection) -> Result<Self> {
let node_ids = list_node_ids(conn)?;
let cap = node_ids
.len()
.saturating_mul(CSR_EDGE_CAP_PER_NODE)
.max(CSR_EDGE_CAP_MIN);
let edges = read_edges(conn, cap)?;
Ok(Self::from_edges(&node_ids, &edges))
}
pub fn node_count(&self) -> usize {
self.node_count
}
pub fn edge_count(&self) -> usize {
self.edge_count
}
pub fn node_index(&self, id: &str) -> Option<u32> {
self.id_to_idx.get(id).copied()
}
pub fn node_id(&self, idx: u32) -> &str {
&self.idx_to_id[idx as usize]
}
pub fn out_neighbors(&self, src: u32) -> impl Iterator<Item = (u32, f64)> + '_ {
let start = self.out_offsets[src as usize] as usize;
let end = self.out_offsets[src as usize + 1] as usize;
self.out_targets[start..end]
.iter()
.zip(self.out_weights[start..end].iter())
.map(|(&t, &w)| (t, w))
}
pub fn in_neighbors(&self, dst: u32) -> impl Iterator<Item = (u32, f64)> + '_ {
let start = self.in_offsets[dst as usize] as usize;
let end = self.in_offsets[dst as usize + 1] as usize;
self.in_sources[start..end]
.iter()
.zip(self.in_weights[start..end].iter())
.map(|(&s, &w)| (s, w))
}
pub fn out_degree(&self, idx: u32) -> u32 {
self.out_offsets[idx as usize + 1] - self.out_offsets[idx as usize]
}
pub fn out_weight_sum(&self, idx: u32) -> f64 {
self.out_weight_sum[idx as usize]
}
pub fn is_dangling(&self, idx: u32) -> bool {
self.out_weight_sum[idx as usize] <= 0.0
}
pub fn node_indices(&self) -> std::ops::Range<u32> {
0..self.node_count as u32
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::schema::init_graph_schema;
use crate::graph::store::{append_edge, upsert_node};
use crate::graph::types::GraphNode;
use rusqlite::Connection;
fn mem_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
init_graph_schema(&conn).unwrap();
conn
}
fn edge(id: &str, s: &str, t: &str, w: f64) -> GraphEdge {
GraphEdge {
id: id.into(),
source: s.into(),
target: t.into(),
relation: "related".into(),
weight: w,
ts: "2026-01-01T00:00:00Z".into(),
}
}
fn node(id: &str) -> GraphNode {
GraphNode {
id: id.into(),
node_type: "concept".into(),
title: id.into(),
body: String::new(),
tags: vec![],
projects: vec![],
agents: vec![],
created: "2026-01-01T00:00:00Z".into(),
updated: "2026-01-01T00:00:00Z".into(),
importance: 0.5,
access_count: 0,
accessed_at: String::new(),
}
}
fn diamond() -> (Vec<String>, Vec<GraphEdge>) {
let nodes: Vec<String> = ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect();
let edges = vec![
edge("e1", "A", "B", 1.0),
edge("e2", "A", "C", 1.0),
edge("e3", "B", "D", 1.0),
edge("e4", "C", "D", 1.0),
];
(nodes, edges)
}
#[test]
fn diamond_out_neighbors_and_sink() {
let (nodes, edges) = diamond();
let g = CsrGraph::from_edges(&nodes, &edges);
let a = g.node_index("A").unwrap();
let b = g.node_index("B").unwrap();
let c = g.node_index("C").unwrap();
let d = g.node_index("D").unwrap();
let mut nbrs: Vec<u32> = g.out_neighbors(a).map(|(t, _)| t).collect();
nbrs.sort_unstable();
assert_eq!(nbrs, vec![b, c]);
assert_eq!(g.out_degree(d), 0);
assert!(g.is_dangling(d));
assert!(!g.is_dangling(a));
}
#[test]
fn reverse_csr_in_neighbors() {
let (nodes, edges) = diamond();
let g = CsrGraph::from_edges(&nodes, &edges);
let b = g.node_index("B").unwrap();
let c = g.node_index("C").unwrap();
let d = g.node_index("D").unwrap();
let mut srcs: Vec<u32> = g.in_neighbors(d).map(|(s, _)| s).collect();
srcs.sort_unstable();
assert_eq!(srcs, vec![b, c]);
}
#[test]
fn out_weight_sum_accumulates_parallel_edges() {
let nodes: Vec<String> = ["A", "B"].iter().map(|s| s.to_string()).collect();
let edges = vec![
edge("e1", "A", "B", 0.3),
edge("e2", "A", "B", 0.7), ];
let g = CsrGraph::from_edges(&nodes, &edges);
let a = g.node_index("A").unwrap();
assert!((g.out_weight_sum(a) - 1.0).abs() < 1e-12);
assert_eq!(g.out_degree(a), 2);
}
#[test]
fn orphan_edges_dropped() {
let nodes: Vec<String> = ["A", "B"].iter().map(|s| s.to_string()).collect();
let edges = vec![
edge("e1", "A", "B", 1.0),
edge("e2", "A", "Z", 1.0), edge("e3", "X", "B", 1.0), ];
let g = CsrGraph::from_edges(&nodes, &edges);
assert_eq!(g.edge_count(), 1);
}
#[test]
fn isolated_node_is_dangling() {
let nodes: Vec<String> = ["A", "B", "E"].iter().map(|s| s.to_string()).collect();
let edges = vec![edge("e1", "A", "B", 1.0)];
let g = CsrGraph::from_edges(&nodes, &edges);
let e = g.node_index("E").unwrap();
assert!(g.is_dangling(e));
assert_eq!(g.node_count(), 3);
assert_eq!(g.edge_count(), 1);
}
#[test]
fn build_csr_from_sqlite() {
let conn = mem_db();
for id in ["A", "B", "C", "D"] {
upsert_node(&conn, &node(id)).unwrap();
}
append_edge(&conn, &edge("e1", "A", "B", 1.0)).unwrap();
append_edge(&conn, &edge("e2", "B", "C", 1.0)).unwrap();
let g = CsrGraph::build_csr(&conn).unwrap();
assert_eq!(g.node_count(), 4);
assert_eq!(g.edge_count(), 2);
let a = g.node_index("A").unwrap();
assert_eq!(g.out_neighbors(a).count(), 1);
assert!(g.is_dangling(g.node_index("D").unwrap()));
}
#[test]
fn self_loop_kept() {
let nodes: Vec<String> = ["A"].iter().map(|s| s.to_string()).collect();
let edges = vec![edge("e1", "A", "A", 1.0)];
let g = CsrGraph::from_edges(&nodes, &edges);
let a = g.node_index("A").unwrap();
assert_eq!(g.out_degree(a), 1);
assert_eq!(g.out_neighbors(a).next().unwrap().0, a);
}
#[test]
fn node_id_roundtrip() {
let (nodes, edges) = diamond();
let g = CsrGraph::from_edges(&nodes, &edges);
for id in &["A", "B", "C", "D"] {
let idx = g.node_index(id).unwrap();
assert_eq!(g.node_id(idx), *id);
}
assert!(g.node_index("missing").is_none());
}
}