Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'

This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
7854 changed files with 3522914 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
//! Solver integration module — exposes ruvector-solver as SQL functions.
pub mod operators;
use ruvector_solver::types::CsrMatrix;
/// Convert a JSON edge list `[[src, dst], ...]` or `[[src, dst, weight], ...]`
/// into a CsrMatrix<f64> adjacency matrix.
pub fn edges_json_to_csr(json: &serde_json::Value) -> Result<CsrMatrix<f64>, String> {
let edges = json
.get("edges")
.and_then(|e| e.as_array())
.or_else(|| json.as_array())
.ok_or_else(|| {
"Expected JSON object with 'edges' array or a JSON array of edges".to_string()
})?;
if edges.is_empty() {
return Err("Edge list is empty".to_string());
}
// Collect edges and determine node count
let mut coo: Vec<(usize, usize, f64)> = Vec::with_capacity(edges.len() * 2);
let mut max_node: usize = 0;
for edge in edges {
let arr = edge
.as_array()
.ok_or_else(|| "Each edge must be an array".to_string())?;
if arr.len() < 2 {
return Err("Each edge must have at least [src, dst]".to_string());
}
let src = arr[0].as_u64().ok_or("Edge source must be integer")? as usize;
let dst = arr[1].as_u64().ok_or("Edge target must be integer")? as usize;
let weight = arr.get(2).and_then(|w| w.as_f64()).unwrap_or(1.0);
max_node = max_node.max(src).max(dst);
coo.push((src, dst, weight));
coo.push((dst, src, weight)); // undirected
}
let n = max_node + 1;
Ok(CsrMatrix::<f64>::from_coo(n, n, coo))
}
/// Convert a JSON sparse matrix representation to CsrMatrix<f64>.
/// Accepts format: `{"rows": N, "cols": M, "entries": [[r, c, val], ...]}`
/// or a flat array `[[r, c, val], ...]` (square matrix inferred).
pub fn matrix_json_to_csr(json: &serde_json::Value) -> Result<CsrMatrix<f64>, String> {
// Structured format with rows/cols
if let Some(entries) = json.get("entries").and_then(|e| e.as_array()) {
let rows = json
.get("rows")
.and_then(|r| r.as_u64())
.ok_or("Missing 'rows'")? as usize;
let cols = json
.get("cols")
.and_then(|c| c.as_u64())
.ok_or("Missing 'cols'")? as usize;
let coo: Vec<(usize, usize, f64)> = entries
.iter()
.filter_map(|e| {
let a = e.as_array()?;
Some((
a[0].as_u64()? as usize,
a[1].as_u64()? as usize,
a[2].as_f64()?,
))
})
.collect();
return Ok(CsrMatrix::<f64>::from_coo(rows, cols, coo));
}
// Flat array format
if let Some(entries) = json.as_array() {
let mut max_r = 0usize;
let mut max_c = 0usize;
let coo: Vec<(usize, usize, f64)> = entries
.iter()
.filter_map(|e| {
let a = e.as_array()?;
let r = a[0].as_u64()? as usize;
let c = a[1].as_u64()? as usize;
let v = a[2].as_f64()?;
Some((r, c, v))
})
.inspect(|(r, c, _)| {
max_r = max_r.max(*r);
max_c = max_c.max(*c);
})
.collect();
let n = max_r.max(max_c) + 1;
return Ok(CsrMatrix::<f64>::from_coo(n, n, coo));
}
Err("Invalid matrix JSON format".to_string())
}

View File

@@ -0,0 +1,506 @@
//! PostgreSQL operator functions for solver integration.
use pgrx::prelude::*;
use pgrx::JsonB;
use ruvector_solver::forward_push::ForwardPushSolver;
use ruvector_solver::traits::{SolverEngine, SublinearPageRank};
use ruvector_solver::types::{ComputeBudget, CsrMatrix};
use super::{edges_json_to_csr, matrix_json_to_csr};
/// Compute PageRank on an edge list using Forward Push.
#[pg_extern(immutable, parallel_safe)]
pub fn ruvector_pagerank(
edges_json: JsonB,
alpha: default!(f32, 0.85),
epsilon: default!(f32, 1e-6),
) -> JsonB {
let csr = match edges_json_to_csr(&edges_json.0) {
Ok(m) => m,
Err(e) => {
pgrx::error!("PageRank: {}", e);
}
};
let n = csr.rows;
let solver = ForwardPushSolver::new(alpha as f64, epsilon as f64);
// Compute PPR from each node and accumulate
let mut scores = vec![0.0f64; n];
for source in 0..n {
match solver.ppr(&csr, source, alpha as f64, epsilon as f64) {
Ok(ppr) => {
for (node, val) in ppr {
if node < n {
scores[node] += val;
}
}
}
Err(_) => {} // skip failed nodes
}
}
// Normalize
let total: f64 = scores.iter().sum();
if total > 0.0 {
for s in &mut scores {
*s /= total;
}
}
let result: Vec<serde_json::Value> = scores
.iter()
.enumerate()
.map(|(i, &s)| serde_json::json!({"node": i, "rank": s}))
.collect();
JsonB(serde_json::json!(result))
}
/// Compute Personalized PageRank from a single source.
#[pg_extern(immutable, parallel_safe)]
pub fn ruvector_pagerank_personalized(
edges_json: JsonB,
source: i32,
alpha: default!(f32, 0.85),
epsilon: default!(f32, 1e-6),
) -> JsonB {
let csr = match edges_json_to_csr(&edges_json.0) {
Ok(m) => m,
Err(e) => pgrx::error!("PPR: {}", e),
};
let solver = ForwardPushSolver::new(alpha as f64, epsilon as f64);
match solver.ppr(&csr, source as usize, alpha as f64, epsilon as f64) {
Ok(ppr) => {
let result: Vec<serde_json::Value> = ppr
.iter()
.map(|&(node, val)| serde_json::json!({"node": node, "rank": val}))
.collect();
JsonB(serde_json::json!(result))
}
Err(e) => pgrx::error!("PPR failed: {}", e),
}
}
/// Compute multi-seed Personalized PageRank.
#[pg_extern(immutable, parallel_safe)]
pub fn ruvector_pagerank_multi_seed(
edges_json: JsonB,
seeds_json: JsonB,
alpha: default!(f32, 0.85),
epsilon: default!(f32, 1e-6),
) -> JsonB {
let csr = match edges_json_to_csr(&edges_json.0) {
Ok(m) => m,
Err(e) => pgrx::error!("Multi-seed PPR: {}", e),
};
let seeds: Vec<(usize, f64)> = match seeds_json.0.as_array() {
Some(arr) => arr
.iter()
.filter_map(|v| {
let a = v.as_array()?;
Some((a[0].as_u64()? as usize, a[1].as_f64().unwrap_or(1.0)))
})
.collect(),
None => pgrx::error!("Seeds must be array of [node, weight] pairs"),
};
let solver = ForwardPushSolver::new(alpha as f64, epsilon as f64);
match solver.ppr_multi_seed(&csr, &seeds, alpha as f64, epsilon as f64) {
Ok(ppr) => {
let result: Vec<serde_json::Value> = ppr
.iter()
.map(|&(node, val)| serde_json::json!({"node": node, "rank": val}))
.collect();
JsonB(serde_json::json!(result))
}
Err(e) => pgrx::error!("Multi-seed PPR failed: {}", e),
}
}
/// Solve a sparse linear system Ax=b.
#[pg_extern(immutable, parallel_safe)]
pub fn ruvector_solve_sparse(
matrix_json: JsonB,
rhs: Vec<f32>,
method: default!(&str, "'neumann'"),
) -> JsonB {
let csr = match matrix_json_to_csr(&matrix_json.0) {
Ok(m) => m,
Err(e) => pgrx::error!("Sparse solve: {}", e),
};
let rhs_f64: Vec<f64> = rhs.iter().map(|&x| x as f64).collect();
let budget = ComputeBudget::default();
// Select solver based on method
let result = match method.to_lowercase().as_str() {
"cg" | "conjugate_gradient" => {
let solver = ruvector_solver::cg::ConjugateGradientSolver::new(1e-6, 1000, true);
solver.solve(&csr, &rhs_f64, &budget)
}
_ => {
// Default to Neumann — use trait method explicitly for f64 interface
let solver = ruvector_solver::neumann::NeumannSolver::new(1e-6, 1000);
SolverEngine::solve(&solver, &csr, &rhs_f64, &budget)
}
};
match result {
Ok(res) => JsonB(serde_json::json!({
"solution": res.solution,
"iterations": res.iterations,
"residual_norm": res.residual_norm,
"algorithm": format!("{:?}", res.algorithm),
"wall_time_ms": res.wall_time.as_millis(),
})),
Err(e) => pgrx::error!("Solver failed: {}", e),
}
}
/// Solve a graph Laplacian system Lx=b.
#[pg_extern(immutable, parallel_safe)]
pub fn ruvector_solve_laplacian(laplacian_json: JsonB, rhs: Vec<f32>) -> JsonB {
let csr = match matrix_json_to_csr(&laplacian_json.0) {
Ok(m) => m,
Err(e) => pgrx::error!("Laplacian solve: {}", e),
};
let rhs_f64: Vec<f64> = rhs.iter().map(|&x| x as f64).collect();
let budget = ComputeBudget::default();
let solver = ruvector_solver::cg::ConjugateGradientSolver::new(1e-6, 1000, true);
match solver.solve(&csr, &rhs_f64, &budget) {
Ok(res) => JsonB(serde_json::json!({
"solution": res.solution,
"iterations": res.iterations,
"residual_norm": res.residual_norm,
"algorithm": format!("{:?}", res.algorithm),
})),
Err(e) => pgrx::error!("Laplacian solve failed: {}", e),
}
}
/// Compute effective resistance between two nodes.
#[pg_extern(immutable, parallel_safe)]
pub fn ruvector_effective_resistance(laplacian_json: JsonB, source: i32, target: i32) -> f32 {
let csr = match matrix_json_to_csr(&laplacian_json.0) {
Ok(m) => m,
Err(e) => pgrx::error!("Effective resistance: {}", e),
};
let n = csr.rows;
let budget = ComputeBudget::default();
// Solve L * x = e_s - e_t
let mut rhs = vec![0.0f64; n];
if (source as usize) < n {
rhs[source as usize] = 1.0;
}
if (target as usize) < n {
rhs[target as usize] = -1.0;
}
let solver = ruvector_solver::cg::ConjugateGradientSolver::new(1e-8, 2000, true);
match solver.solve(&csr, &rhs, &budget) {
Ok(res) => {
let s = source as usize;
let t = target as usize;
let x_s = if s < res.solution.len() {
res.solution[s] as f64
} else {
0.0
};
let x_t = if t < res.solution.len() {
res.solution[t] as f64
} else {
0.0
};
(x_s - x_t) as f32
}
Err(e) => pgrx::error!("Effective resistance failed: {}", e),
}
}
/// Run PageRank on an existing property graph stored via ruvector graph module.
#[cfg(feature = "graph")]
#[pg_extern]
pub fn ruvector_graph_pagerank(
graph_name: &str,
alpha: default!(f32, 0.85),
epsilon: default!(f32, 1e-6),
) -> TableIterator<'static, (name!(node_id, i64), name!(rank, f64))> {
let graph = match crate::graph::get_graph(graph_name) {
Some(g) => g,
None => pgrx::error!("Graph '{}' not found", graph_name),
};
// Extract edges and nodes
let all_nodes = graph.nodes.all_nodes();
let all_edges = graph.edges.all_edges();
if all_nodes.is_empty() {
return TableIterator::new(std::iter::empty());
}
// Build node id mapping
let mut node_ids: Vec<u64> = all_nodes.iter().map(|n| n.id).collect();
node_ids.sort();
let node_idx: std::collections::HashMap<u64, usize> = node_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
let n = node_ids.len();
let mut coo = Vec::new();
for edge in &all_edges {
if let (Some(&si), Some(&di)) = (node_idx.get(&edge.source), node_idx.get(&edge.target)) {
coo.push((si, di, 1.0f64));
coo.push((di, si, 1.0f64));
}
}
let csr = CsrMatrix::<f64>::from_coo(n, n, coo);
let solver = ForwardPushSolver::new(alpha as f64, epsilon as f64);
let mut scores = vec![0.0f64; n];
for source in 0..n {
if let Ok(ppr) = solver.ppr(&csr, source, alpha as f64, epsilon as f64) {
for (node, val) in ppr {
if node < n {
scores[node] += val;
}
}
}
}
let total: f64 = scores.iter().sum();
if total > 0.0 {
for s in &mut scores {
*s /= total;
}
}
let results: Vec<(i64, f64)> = node_ids
.iter()
.enumerate()
.map(|(i, &id)| (id as i64, scores[i]))
.collect();
TableIterator::new(results.into_iter())
}
/// List available solver algorithms.
#[pg_extern]
pub fn ruvector_solver_info() -> TableIterator<
'static,
(
name!(algorithm, String),
name!(description, String),
name!(complexity, String),
),
> {
let algos = vec![
(
"neumann",
"Jacobi-preconditioned Neumann series",
"O(nnz * log(1/eps))",
),
(
"cg",
"Conjugate Gradient for SPD systems",
"O(n * sqrt(kappa))",
),
(
"forward-push",
"Andersen-Chung-Lang PageRank",
"O(1/epsilon)",
),
(
"backward-push",
"Backward Push for target PPR",
"O(1/epsilon)",
),
(
"hybrid-random-walk",
"Push + Monte Carlo sampling",
"O(sqrt(n/epsilon))",
),
(
"bmssp",
"Block MSS preconditioned solver",
"O(n * nnz_per_row)",
),
(
"true-solver",
"Topology-aware batch solver",
"O(batch * nnz)",
),
];
TableIterator::new(
algos
.into_iter()
.map(|(a, d, c)| (a.to_string(), d.to_string(), c.to_string())),
)
}
/// Analyze matrix sparsity profile.
#[pg_extern(immutable, parallel_safe)]
pub fn ruvector_matrix_analyze(matrix_json: JsonB) -> JsonB {
let csr = match matrix_json_to_csr(&matrix_json.0) {
Ok(m) => m,
Err(e) => pgrx::error!("Matrix analyze: {}", e),
};
let nnz = csr.nnz();
let density = if csr.rows > 0 && csr.cols > 0 {
nnz as f64 / (csr.rows as f64 * csr.cols as f64)
} else {
0.0
};
let mut max_nnz_per_row = 0usize;
let mut min_nnz_per_row = usize::MAX;
for i in 0..csr.rows {
let row_nnz = csr.row_degree(i);
max_nnz_per_row = max_nnz_per_row.max(row_nnz);
min_nnz_per_row = min_nnz_per_row.min(row_nnz);
}
if csr.rows == 0 {
min_nnz_per_row = 0;
}
let avg_nnz_per_row = if csr.rows > 0 {
nnz as f64 / csr.rows as f64
} else {
0.0
};
JsonB(serde_json::json!({
"rows": csr.rows,
"cols": csr.cols,
"nnz": nnz,
"density": density,
"avg_nnz_per_row": avg_nnz_per_row,
"max_nnz_per_row": max_nnz_per_row,
"min_nnz_per_row": min_nnz_per_row,
}))
}
/// Solve using Conjugate Gradient directly.
#[pg_extern(immutable, parallel_safe)]
pub fn ruvector_conjugate_gradient(
matrix_json: JsonB,
rhs: Vec<f32>,
tol: default!(f32, 1e-6),
max_iter: default!(i32, 1000),
) -> JsonB {
let csr = match matrix_json_to_csr(&matrix_json.0) {
Ok(m) => m,
Err(e) => pgrx::error!("CG solve: {}", e),
};
let rhs_f64: Vec<f64> = rhs.iter().map(|&x| x as f64).collect();
let budget = ComputeBudget {
tolerance: tol as f64,
max_iterations: max_iter as usize,
..Default::default()
};
let solver =
ruvector_solver::cg::ConjugateGradientSolver::new(tol as f64, max_iter as usize, true);
match solver.solve(&csr, &rhs_f64, &budget) {
Ok(res) => JsonB(serde_json::json!({
"solution": res.solution,
"iterations": res.iterations,
"residual_norm": res.residual_norm,
"converged": res.residual_norm < tol as f64,
"wall_time_ms": res.wall_time.as_millis(),
})),
Err(e) => pgrx::error!("CG solve failed: {}", e),
}
}
/// Compute node centrality using solver-based methods.
#[cfg(feature = "graph")]
#[pg_extern]
pub fn ruvector_graph_centrality(
graph_name: &str,
method: default!(&str, "'pagerank'"),
) -> TableIterator<'static, (name!(node_id, i64), name!(centrality, f64))> {
let graph = match crate::graph::get_graph(graph_name) {
Some(g) => g,
None => pgrx::error!("Graph '{}' not found", graph_name),
};
let all_nodes = graph.nodes.all_nodes();
let all_edges = graph.edges.all_edges();
if all_nodes.is_empty() {
return TableIterator::new(std::iter::empty());
}
let mut node_ids: Vec<u64> = all_nodes.iter().map(|n| n.id).collect();
node_ids.sort();
let node_idx: std::collections::HashMap<u64, usize> = node_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
let n = node_ids.len();
let mut coo = Vec::new();
for edge in &all_edges {
if let (Some(&si), Some(&di)) = (node_idx.get(&edge.source), node_idx.get(&edge.target)) {
coo.push((si, di, 1.0f64));
coo.push((di, si, 1.0f64));
}
}
let csr = CsrMatrix::<f64>::from_coo(n, n, coo);
let scores = match method.to_lowercase().as_str() {
"degree" => {
// Degree centrality
(0..n).map(|i| csr.row_degree(i) as f64).collect::<Vec<_>>()
}
_ => {
// Default: PageRank centrality
let solver = ForwardPushSolver::new(0.85, 1e-6);
let mut scores = vec![0.0f64; n];
for source in 0..n {
if let Ok(ppr) = solver.ppr(&csr, source, 0.85, 1e-6) {
for (node, val) in ppr {
if node < n {
scores[node] += val;
}
}
}
}
let total: f64 = scores.iter().sum();
if total > 0.0 {
for s in &mut scores {
*s /= total;
}
}
scores
}
};
let results: Vec<(i64, f64)> = node_ids
.iter()
.enumerate()
.map(|(i, &id)| (id as i64, scores[i]))
.collect();
TableIterator::new(results.into_iter())
}