From 1a821ed7d5d33f2a81fb16471b8208d0da3def73 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Mon, 13 Jul 2026 04:41:54 -0400 Subject: [PATCH 01/39] Adding algorithms to GraphQL defined statically. Added GraphQL wrapper for NodeState (we use OutputTypedNodeState because there are no generic type parameters), similar to how PyOutputNodeState does it. Currently added pagerank --- raphtory-graphql/src/lib.rs | 38 ++++++++++ raphtory-graphql/src/model/algorithms/mod.rs | 74 +++++++++++++++++++ .../src/model/algorithms/pagerank.rs | 35 +++++++++ raphtory-graphql/src/model/graph/graph.rs | 6 ++ raphtory-graphql/src/model/graph/mod.rs | 1 + .../src/model/graph/node_state.rs | 40 ++++++++++ raphtory-graphql/src/model/mod.rs | 1 + 7 files changed, 195 insertions(+) create mode 100644 raphtory-graphql/src/model/algorithms/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/pagerank.rs create mode 100644 raphtory-graphql/src/model/graph/node_state.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 8d74e5b71a..a542c39602 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -582,6 +582,44 @@ mod graphql_test { graph } + #[tokio::test] + async fn test_algorithm_pagerank() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + count + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "pagerank": { + "count": 3 + } + } + } + }) + ); + } + #[tokio::test] async fn test_degree_filter_nodes_and_select_gql() { let graph: MaterializedGraph = degree_graph_with_add_node_and_add_edge().into(); diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs new file mode 100644 index 0000000000..a9fd616caf --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -0,0 +1,74 @@ +//! Statically defined graph algorithms exposed through `Graph.algorithm`. + +use crate::{ + model::{ + algorithms::pagerank::{GqlPagerank, GqlPagerankArgs}, + graph::node_state::GqlNodeState, + }, + rayon::blocking_compute, +}; +use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; +use raphtory::{db::api::view::DynamicGraph, errors::GraphError}; + +pub(crate) mod pagerank; + +/// A graph algorithm executable through the GraphQL API. +pub(crate) trait GqlExecutableAlgorithm: 'static { + /// The algorithm's arguments, assembled from the GraphQL field arguments + type Args: Send + 'static; + + /// The GraphQL-facing result, typically a GqlNodeState but can be different (e.g. scalars) + type Output: Send + 'static; + + /// Runs the algorithm on the given graph view + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result; +} + +/// The algorithms that can be run on a graph view. +#[derive(ResolvedObject, Clone)] +#[graphql(name = "Algorithms")] +pub(crate) struct GqlAlgorithms { + pub(crate) graph: DynamicGraph, +} + +impl From for GqlAlgorithms { + fn from(graph: DynamicGraph) -> Self { + Self { graph } + } +} + +impl GqlAlgorithms { + /// Runs algorithm `A` on the blocking thread pool. + async fn run(&self, args: A::Args) -> Result { + let graph = self.graph.clone(); + blocking_compute(move || A::execute(&graph, args)).await + } +} + +#[ResolvedObjectFields] +impl GqlAlgorithms { + /// Returns the PageRank centrality of every node in the graph. + async fn pagerank( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option< + usize, + >, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + #[graphql(desc = "Convergence tolerance. Defaults to 0.000001.")] tol: Option, + #[graphql(desc = "Probability that the spread continues. Defaults to 0.85.")] + damping_factor: Option, + #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] + weight: Option, + ) -> Result { + self.run::(GqlPagerankArgs { + iter_count, + threads, + tol, + damping_factor, + weight, + }) + .await + } +} diff --git a/raphtory-graphql/src/model/algorithms/pagerank.rs b/raphtory-graphql/src/model/algorithms/pagerank.rs new file mode 100644 index 0000000000..c1cd34a873 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/pagerank.rs @@ -0,0 +1,35 @@ +use crate::model::graph::node_state::GqlNodeState; +use raphtory::{ + algorithms::centrality::pagerank::page_rank, db::api::view::DynamicGraph, errors::GraphError, +}; +use raphtory_api::core::storage::arc_str::OptionAsStr; +use crate::model::algorithms::GqlExecutableAlgorithm; + +/// PageRank, see [`page_rank`]. +pub(crate) struct GqlPagerank; + +pub(crate) struct GqlPagerankArgs { + pub(crate) iter_count: Option, + pub(crate) threads: Option, + pub(crate) tol: Option, + pub(crate) damping_factor: Option, + pub(crate) weight: Option, +} + +impl GqlExecutableAlgorithm for GqlPagerank { + type Args = GqlPagerankArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = page_rank( + graph, + args.weight.as_str(), + args.iter_count, + args.threads, + args.tol, + true, + args.damping_factor, + ); + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index f6f3cd2a79..1be833b5f7 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -15,6 +15,7 @@ use crate::{ windowset::GqlGraphWindowSet, GqlAlignmentUnit, WindowDuration, }, + algorithms::GqlAlgorithms, plugins::graph_algorithm_plugin::GraphAlgorithmPlugin, schema::graph_schema::GraphSchema, }, @@ -641,6 +642,11 @@ impl GqlGraph { self.graph.clone().into() } + /// Access the algorithms that can be run on this graph view. + async fn algorithm(&self) -> GqlAlgorithms { + self.graph.clone().into() + } + /// Nodes that are neighbours of every node in `selectedNodes`. Returns the /// intersection of each selected node's neighbour set (undirected). diff --git a/raphtory-graphql/src/model/graph/mod.rs b/raphtory-graphql/src/model/graph/mod.rs index bb85fb6262..a11875ebe6 100644 --- a/raphtory-graphql/src/model/graph/mod.rs +++ b/raphtory-graphql/src/model/graph/mod.rs @@ -16,6 +16,7 @@ pub mod namespace; pub mod namespaced_item; pub(crate) mod node; pub(crate) mod node_id; +pub(crate) mod node_state; mod nodes; mod path_from_node; pub(crate) mod property; diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs new file mode 100644 index 0000000000..0c6c44a261 --- /dev/null +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -0,0 +1,40 @@ +use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; +use raphtory::{ + db::api::state::{NodeStateValue, OutputTypedNodeState, TypedNodeState}, + db::api::view::DynamicGraph, + prelude::NodeStateOps, +}; + +/// A mapping from the nodes of a graph to the computed values by an algorithm. +#[derive(ResolvedObject, Clone)] +#[graphql(name = "NodeState")] +pub(crate) struct GqlNodeState { + pub(crate) state: OutputTypedNodeState<'static, DynamicGraph>, +} + +impl From> for GqlNodeState +where + V: NodeStateValue + 'static, + T: Clone + Send + Sync + 'static, +{ + fn from(state: TypedNodeState<'static, V, DynamicGraph, T>) -> Self { + Self { + state: state.to_output_nodestate(), + } + } +} + +#[ResolvedObjectFields] +impl GqlNodeState { + // TODO: expose the remaining `NodeStateOps` surface, following + // `PyOutputNodeState` (raphtory/src/python/graph/node_state/output_node_state.rs): + // `get(node)`, `groupBy(cols)`, `topK(sortParams, k)`, `sortBy(sortParams)`, + // `values`, `items`, `nodes`, min/max/mean aggregates, ... + // Operations returning another node state should return `GqlNodeState` so + // queries can keep chaining. + + /// Returns the number of nodes with a value in this state. + async fn count(&self) -> usize { + self.state.len() + } +} diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 82e7788f30..d1d9b7cf2f 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -45,6 +45,7 @@ use raphtory::{ use std::sync::Arc; use tracing::warn; +pub(crate) mod algorithms; pub mod graph; pub mod plugins; pub(crate) mod schema; From 5b0d0581b0fb3be92521ea3d109dc4ba1af69ba0 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 15 Jul 2026 05:30:57 -0400 Subject: [PATCH 02/39] Added output for NodeStates in GraphQL. We query/return columns which contain all the values for that column. Each column is an output of the algorithm. We can then access the column's name and each output value. These values are row-aligned with the nodes of the node state. These nodes are queried and returned separately. --- .../src/model/graph/node_state.rs | 106 ++++++++++++++++-- 1 file changed, 95 insertions(+), 11 deletions(-) diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 0c6c44a261..9ecbafe4e2 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -1,11 +1,21 @@ -use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; +use crate::{ + model::graph::{node::GqlNode, nodes::GqlNodes, property::GqlPropertyOutputVal}, + rayon::blocking_compute, +}; +use dynamic_graphql::{ResolvedObject, ResolvedObjectFields, SimpleObject, Union}; use raphtory::{ - db::api::state::{NodeStateValue, OutputTypedNodeState, TypedNodeState}, - db::api::view::DynamicGraph, + db::api::{ + state::{NodeStateOutput, NodeStateValue, OutputTypedNodeState, TypedNodeState}, + view::{BoxableGraphView, DynamicGraph}, + }, prelude::NodeStateOps, }; +use std::sync::Arc; -/// A mapping from the nodes of a graph to the computed values by an algorithm. +/// A mapping from the nodes of a graph to the values computed for them by an algorithm. +/// +/// The output is columnar: every column of the underlying state is exposed as +/// a `NodeStateColumn` whose `values` are row-aligned with `nodes`. #[derive(ResolvedObject, Clone)] #[graphql(name = "NodeState")] pub(crate) struct GqlNodeState { @@ -24,17 +34,91 @@ where } } +/// A single cell of a node state column: either a plain property value, a +/// node, or a collection of nodes. +#[derive(Union, Clone)] +#[graphql(name = "NodeStateValue")] +pub(crate) enum GqlNodeStateValue { + Prop(GqlNodeStateProp), + Node(GqlNode), // TODO: test this + Nodes(GqlNodes), // TODO: test this +} + +/// A plain property value of a node state cell. +#[derive(SimpleObject, Clone)] +#[graphql(name = "NodeStateProp")] +pub(crate) struct GqlNodeStateProp { + /// The property value; null if the node has no value in this column. + value: Option, +} + +impl From>> for GqlNodeStateValue { + fn from(value: NodeStateOutput<'static, Arc>) -> Self { + match value { + NodeStateOutput::Prop(prop) => GqlNodeStateValue::Prop(GqlNodeStateProp { + value: prop.map(|p| GqlPropertyOutputVal(p.into())), + }), + NodeStateOutput::Node(node) => GqlNodeStateValue::Node(node.into()), + NodeStateOutput::Nodes(nodes) => GqlNodeStateValue::Nodes(GqlNodes::new(nodes)), + } + } +} + +/// One column of a node state: the values of a single output field of the +/// algorithm. Row-aligned with `NodeState.nodes`. +#[derive(SimpleObject, Clone)] +#[graphql(name = "NodeStateColumn")] +pub(crate) struct GqlNodeStateColumn { + /// Name of the column. + name: String, + /// The values of this column; `values[i]` belongs to `NodeState.nodes[i]`. + values: Vec, +} + +// TODO: add paging: `columns`/`nodes` currently dump every row. + +// TODO: NodeStateOps surface — expose the remaining operations +// follow `PyOutputNodeState` (raphtory/src/python/graph/node_state/output_node_state.rs): #[ResolvedObjectFields] impl GqlNodeState { - // TODO: expose the remaining `NodeStateOps` surface, following - // `PyOutputNodeState` (raphtory/src/python/graph/node_state/output_node_state.rs): - // `get(node)`, `groupBy(cols)`, `topK(sortParams, k)`, `sortBy(sortParams)`, - // `values`, `items`, `nodes`, min/max/mean aggregates, ... - // Operations returning another node state should return `GqlNodeState` so - // queries can keep chaining. - /// Returns the number of nodes with a value in this state. async fn count(&self) -> usize { self.state.len() } + + /// The nodes with a value in this state, in row order. Aligned with `values`. + async fn nodes(&self) -> GqlNodes { + GqlNodes::new(self.state.nodes()) + } + + /// The columns of the state, one per output field of the algorithm. + /// `values` are row-aligned with `nodes`. + async fn columns(&self) -> Vec { + let self_clone = self.clone(); + blocking_compute(move || { + let num_rows = self_clone.state.len(); + let mut columns: Vec<(String, Vec)> = self_clone + .state + .state + .values_ref() + .schema() + .fields() + .iter() + .map(|field| (field.name().clone(), Vec::with_capacity(num_rows))) + .collect(); + for row in self_clone.state.values_to_rows() { + let mut transformed = self_clone.state.convert(row); + for (name, values) in columns.iter_mut() { + if let Some(value) = transformed.swap_remove(name) { + values.push(value.into()); + } + } + } + columns + .into_iter() + .map(|(name, values)| GqlNodeStateColumn { name, values }) + .collect() + }) + .await + } } From 84d94f8167864312579969f884d9f81e1e86cdce Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 15 Jul 2026 05:36:59 -0400 Subject: [PATCH 03/39] Update pagerank test to match new output query. --- raphtory-graphql/src/lib.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index a542c39602..461009f9cd 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -598,6 +598,14 @@ mod graphql_test { algorithm { pagerank(iterCount: 20) { count + nodes { list { name } } + columns { + name + values { + __typename + ... on NodeStateProp { value } + } + } } } } @@ -606,13 +614,31 @@ mod graphql_test { let res = setup.schema.execute(Request::new(query)).await; assert_eq!(res.errors, vec![], "{:?}", res.errors); + // in a 3-cycle all nodes have the same rank of 1/3 assert_eq!( res.data.into_json().unwrap(), json!({ "graph": { "algorithm": { "pagerank": { - "count": 3 + "count": 3, + "nodes": { + "list": [ + { "name": "a" }, + { "name": "b" }, + { "name": "c" } + ] + }, + "columns": [ + { + "name": "pagerank_score", + "values": [ + { "__typename": "NodeStateProp", "value": 0.3333333333333333 }, + { "__typename": "NodeStateProp", "value": 0.3333333333333333 }, + { "__typename": "NodeStateProp", "value": 0.3333333333333333 } + ] + } + ] } } } From 5710bc474eb2dec38c058b2046db53896d032b76 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 15 Jul 2026 05:37:33 -0400 Subject: [PATCH 04/39] Rustfmt --- raphtory-graphql/src/model/algorithms/mod.rs | 4 +--- raphtory-graphql/src/model/algorithms/pagerank.rs | 3 +-- raphtory-graphql/src/model/graph/graph.rs | 2 +- raphtory-graphql/src/model/graph/node_state.rs | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index a9fd616caf..eff977414a 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -50,9 +50,7 @@ impl GqlAlgorithms { /// Returns the PageRank centrality of every node in the graph. async fn pagerank( &self, - #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option< - usize, - >, + #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option, #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< usize, >, diff --git a/raphtory-graphql/src/model/algorithms/pagerank.rs b/raphtory-graphql/src/model/algorithms/pagerank.rs index c1cd34a873..2dadcda415 100644 --- a/raphtory-graphql/src/model/algorithms/pagerank.rs +++ b/raphtory-graphql/src/model/algorithms/pagerank.rs @@ -1,9 +1,8 @@ -use crate::model::graph::node_state::GqlNodeState; +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; use raphtory::{ algorithms::centrality::pagerank::page_rank, db::api::view::DynamicGraph, errors::GraphError, }; use raphtory_api::core::storage::arc_str::OptionAsStr; -use crate::model::algorithms::GqlExecutableAlgorithm; /// PageRank, see [`page_rank`]. pub(crate) struct GqlPagerank; diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index 1be833b5f7..82e5c7d7cc 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -2,6 +2,7 @@ use crate::{ data::Data, graph::GraphWithVectors, model::{ + algorithms::GqlAlgorithms, graph::{ edge::GqlEdge, edges::GqlEdges, @@ -15,7 +16,6 @@ use crate::{ windowset::GqlGraphWindowSet, GqlAlignmentUnit, WindowDuration, }, - algorithms::GqlAlgorithms, plugins::graph_algorithm_plugin::GraphAlgorithmPlugin, schema::graph_schema::GraphSchema, }, diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 9ecbafe4e2..4a7a619b10 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -40,7 +40,7 @@ where #[graphql(name = "NodeStateValue")] pub(crate) enum GqlNodeStateValue { Prop(GqlNodeStateProp), - Node(GqlNode), // TODO: test this + Node(GqlNode), // TODO: test this Nodes(GqlNodes), // TODO: test this } From d3597ddeee7645498734f5a7b93a2fc78c42076c Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 15 Jul 2026 05:56:33 -0400 Subject: [PATCH 05/39] Add python test for graphql pagerank algorithm with the new nodestate/value output --- .../test_graphql/test_algorithms.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 python/tests/test_base_install/test_graphql/test_algorithms.py diff --git a/python/tests/test_base_install/test_graphql/test_algorithms.py b/python/tests/test_base_install/test_graphql/test_algorithms.py new file mode 100644 index 0000000000..49821e0106 --- /dev/null +++ b/python/tests/test_base_install/test_graphql/test_algorithms.py @@ -0,0 +1,52 @@ +from raphtory import Graph + +from utils import run_graphql_test + + +def init_graph(graph): + graph.add_edge(1, "a", "b") + graph.add_edge(2, "a", "c") + graph.add_edge(3, "b", "c") + return graph + + +def test_algorithm_pagerank(): + graph = init_graph(Graph()) + query = """{ + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + count + nodes { list { name } } + columns { + name + values { + __typename + ... on NodeStateProp { value } + } + } + } + } + } + }""" + expected_output = { + "graph": { + "algorithm": { + "pagerank": { + "count": 3, + "nodes": {"list": [{"name": "a"}, {"name": "b"}, {"name": "c"}]}, + "columns": [ + { + "name": "pagerank_score", + "values": [ + { "__typename": "NodeStateProp", "value": 0.197580035313204 }, + { "__typename": "NodeStateProp", "value": 0.28155081033755053 }, + { "__typename": "NodeStateProp", "value": 0.5208691543492454 }, + ], + } + ], + } + } + } + } + run_graphql_test(query, expected_output, graph) From 5a63273f6ea8c6172650363a426c6600a8cc3945 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 15 Jul 2026 18:47:12 -0400 Subject: [PATCH 06/39] cargo.lock and rustfmt changes --- raphtory-graphql/tests/open_telemetry.rs | 18 +++++++++------ raphtory-graphql/tests/open_telemetry_e2e.rs | 24 ++++++++++++-------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/raphtory-graphql/tests/open_telemetry.rs b/raphtory-graphql/tests/open_telemetry.rs index 8395a8d111..e02f283d8d 100644 --- a/raphtory-graphql/tests/open_telemetry.rs +++ b/raphtory-graphql/tests/open_telemetry.rs @@ -1,12 +1,16 @@ // OpenTelemetry Tests must be separated into their own binary to prevent polluting other tests since the span and log exporters are set globally. -use raphtory::db::api::storage::storage::Config; -use raphtory::prelude::{Graph, StableEncode}; -use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; -use raphtory_graphql::config::{ - app_config::AppConfigBuilder, - otlp_config::{TracingLevel, TracingProtocol, GLOBAL_EXPORTERS}, +use raphtory::{ + db::api::storage::storage::Config, + prelude::{Graph, StableEncode}, +}; +use raphtory_graphql::{ + client::raphtory_client::RaphtoryGraphQLClient, + config::{ + app_config::AppConfigBuilder, + otlp_config::{TracingLevel, TracingProtocol, GLOBAL_EXPORTERS}, + }, + server::{GraphServer, RunningGraphServer}, }; -use raphtory_graphql::server::{GraphServer, RunningGraphServer}; use std::collections::{HashMap, HashSet}; use tempfile::{tempdir, TempDir}; use url::Url; diff --git a/raphtory-graphql/tests/open_telemetry_e2e.rs b/raphtory-graphql/tests/open_telemetry_e2e.rs index d290f1bc11..947cacbbd6 100644 --- a/raphtory-graphql/tests/open_telemetry_e2e.rs +++ b/raphtory-graphql/tests/open_telemetry_e2e.rs @@ -1,14 +1,20 @@ use mock_collector::{MockServer, Protocol}; -use raphtory::db::api::storage::storage::Config; -use raphtory::prelude::{Graph, StableEncode}; -use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; -use raphtory_graphql::config::{ - app_config::AppConfigBuilder, - otlp_config::{TracingLevel, TracingProtocol}, +use raphtory::{ + db::api::storage::storage::Config, + prelude::{Graph, StableEncode}, +}; +use raphtory_graphql::{ + client::raphtory_client::RaphtoryGraphQLClient, + config::{ + app_config::AppConfigBuilder, + otlp_config::{TracingLevel, TracingProtocol}, + }, + server::GraphServer, +}; +use std::{ + collections::{HashMap, HashSet}, + time::Duration, }; -use raphtory_graphql::server::GraphServer; -use std::collections::{HashMap, HashSet}; -use std::time::Duration; use tempfile::tempdir; use url::Url; From 763914c09e80525dde1b7b974d5db898528e4b7b Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 15 Jul 2026 18:59:56 -0400 Subject: [PATCH 07/39] update python test --- python/tests/test_base_install/test_graphql/test_algorithms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/tests/test_base_install/test_graphql/test_algorithms.py b/python/tests/test_base_install/test_graphql/test_algorithms.py index 49821e0106..ef8e6118f5 100644 --- a/python/tests/test_base_install/test_graphql/test_algorithms.py +++ b/python/tests/test_base_install/test_graphql/test_algorithms.py @@ -3,7 +3,7 @@ from utils import run_graphql_test -def init_graph(graph): +def init_graph(graph: Graph): graph.add_edge(1, "a", "b") graph.add_edge(2, "a", "c") graph.add_edge(3, "b", "c") From adc53c244af4dbb1b459b56a4b749a7549c06c68 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Thu, 16 Jul 2026 03:56:45 -0400 Subject: [PATCH 08/39] Added get and sortById on GqlNodeState, along with a test for them --- raphtory-graphql/src/lib.rs | 65 +++++++++++++++++++ .../src/model/graph/node_state.rs | 53 ++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index b6b4c6c8ee..8f88fcceea 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -582,6 +582,71 @@ mod graphql_test { graph } + #[tokio::test] + async fn test_algorithm_node_state_ops() { + let graph = Graph::new(); + // insert out of id order so sortById is meaningful + graph.add_edge(1, "c", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "a", NO_PROPS, None).unwrap(); + graph.add_edge(3, "a", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + get(node: "b") { + name + value { + __typename + ... on NodeStateProp { value } + } + } + missing: get(node: "not-a-node") { name } + sortById { + nodes { list { name } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // in a 3-cycle all nodes have the same rank of 1/3 + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "pagerank": { + "get": [ + { + "name": "pagerank_score", + "value": { "__typename": "NodeStateProp", "value": 0.3333333333333333 } + } + ], + "missing": null, + "sortById": { + "nodes": { + "list": [ + { "name": "a" }, + { "name": "b" }, + { "name": "c" } + ] + } + } + } + } + } + }) + ); + } + #[tokio::test] async fn test_algorithm_pagerank() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 4a7a619b10..2af7bbbdcf 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -1,5 +1,7 @@ use crate::{ - model::graph::{node::GqlNode, nodes::GqlNodes, property::GqlPropertyOutputVal}, + model::graph::{ + node::GqlNode, node_id::GqlNodeId, nodes::GqlNodes, property::GqlPropertyOutputVal, + }, rayon::blocking_compute, }; use dynamic_graphql::{ResolvedObject, ResolvedObjectFields, SimpleObject, Union}; @@ -75,10 +77,24 @@ pub(crate) struct GqlNodeStateColumn { values: Vec, } +/// One column's value for a single node. +#[derive(SimpleObject, Clone)] +#[graphql(name = "NodeStateEntry")] +pub(crate) struct GqlNodeStateEntry { + /// Name of the column. + name: String, + /// The node's value in this column. + value: GqlNodeStateValue, +} + // TODO: add paging: `columns`/`nodes` currently dump every row. -// TODO: NodeStateOps surface — expose the remaining operations -// follow `PyOutputNodeState` (raphtory/src/python/graph/node_state/output_node_state.rs): +// TODO: still to be implemented, blocked on the datafusion feature gate (CVE): +// `sortBy` (`GenericNodeState::sort_by`), `topK` (`GenericNodeState::top_k`), +// `groupBy` (`TypedNodeState::get_groups`). +// +// Not exposed: `merge` (takes a second NodeState, which cannot be a query argument) +// and `to_parquet`/`from_parquet` (avoid server-side filesystem access). #[ResolvedObjectFields] impl GqlNodeState { /// Returns the number of nodes with a value in this state. @@ -91,6 +107,37 @@ impl GqlNodeState { GqlNodes::new(self.state.nodes()) } + /// Returns the values for a node, one entry per column; null if the node has no value in this NodeState. + async fn get( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + ) -> Option> { + let self_clone = self.clone(); + blocking_compute(move || { + let row = self_clone.state.get_by_node(node)?; + let transformed = self_clone.state.convert(row); + Some( + transformed + .into_iter() + .map(|(name, value)| GqlNodeStateEntry { + name, + value: value.into(), + }) + .collect(), + ) + }) + .await + } + + /// Returns a view of this state with the rows sorted by node id. + async fn sort_by_id(&self) -> GqlNodeState { + let self_clone = self.clone(); + blocking_compute(move || GqlNodeState { + state: self_clone.state.sort_by_id(), + }) + .await + } + /// The columns of the state, one per output field of the algorithm. /// `values` are row-aligned with `nodes`. async fn columns(&self) -> Vec { From 8f341d085c53e43113a72f0bbebfc8c46fa4c133 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Thu, 16 Jul 2026 04:34:56 -0400 Subject: [PATCH 09/39] First iteration of min/max/median for GraphQL. Still have some changes to make --- raphtory-graphql/src/lib.rs | 54 +++++++++ .../src/model/graph/node_state.rs | 111 +++++++++++++++++- 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 8f88fcceea..b14fd7603f 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,60 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_node_state_aggregates() { + let graph = Graph::new(); + // asymmetric graph so every node has a distinct pagerank + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + min(column: "pagerank_score") { node { name } value } + max(column: "pagerank_score") { node { name } value } + median(column: "pagerank_score") { node { name } value } + missing: min(column: "not_a_column") { value } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "pagerank": { + "min": { + "node": { "name": "a" }, + "value": 0.197580035313204 + }, + "max": { + "node": { "name": "c" }, + "value": 0.5208691543492454 + }, + "median": { + "node": { "name": "b" }, + "value": 0.28155081033755053 + }, + "missing": null + } + } + } + }) + ); + } + #[tokio::test] async fn test_algorithm_pagerank() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 2af7bbbdcf..3715fe67eb 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -6,13 +6,16 @@ use crate::{ }; use dynamic_graphql::{ResolvedObject, ResolvedObjectFields, SimpleObject, Union}; use raphtory::{ - db::api::{ - state::{NodeStateOutput, NodeStateValue, OutputTypedNodeState, TypedNodeState}, - view::{BoxableGraphView, DynamicGraph}, + db::{ + api::{ + state::{NodeStateOutput, NodeStateValue, OutputTypedNodeState, TypedNodeState}, + view::{BoxableGraphView, DynamicGraph}, + }, + graph::node::NodeView, }, - prelude::NodeStateOps, + prelude::{NodeStateOps, Prop}, }; -use std::sync::Arc; +use std::{cmp::Ordering, sync::Arc}; /// A mapping from the nodes of a graph to the values computed for them by an algorithm. /// @@ -87,6 +90,61 @@ pub(crate) struct GqlNodeStateEntry { value: GqlNodeStateValue, } +/// A `(node, value)` pair, e.g. the result of a column aggregate. +#[derive(SimpleObject, Clone)] +#[graphql(name = "NodeStateItem")] +pub(crate) struct GqlNodeStateItem { + /// The node. + node: GqlNode, + /// The node's value. + value: GqlPropertyOutputVal, +} + +impl GqlNodeState { + /// The `(node, value)` pairs of a plain-prop column, skipping empty cells. Useful for aggregate operations. + /// None if the column does not exist or contains nodes. + fn column_items(&self, column: &str) -> Option, Prop)>> { + if self.state.state.node_cols.contains_key(column) { + return None; + } + self.state.state.values_ref().schema().index_of(column).ok()?; + Some( + self.state + .iter() + .filter_map(|(node, mut row)| { + let value = row.swap_remove(column)??; + Some((node.cloned(), value.into())) + }) + .collect(), + ) + } + + /// Reduces a column to the item winning all `pick` comparisons. Basically executes a simple aggregate operation. + /// None if the column is empty or its dtype is not comparable. + fn reduce_column( + &self, + column: &str, + pick: impl Fn(Ordering) -> bool, + ) -> Option { + let mut items = self.column_items(column)?.into_iter(); + let mut acc = items.next()?; + // we only check this once because, in practice, the entire Arrow column has the same type, + // so we expect Props to have the same dtype. + if !acc.1.dtype().has_cmp() { + return None; + } + for item in items { + if !pick(acc.1.partial_cmp(&item.1)?) { + acc = item; + } + } + Some(GqlNodeStateItem { + node: acc.0.into(), + value: GqlPropertyOutputVal(acc.1), + }) + } +} + // TODO: add paging: `columns`/`nodes` currently dump every row. // TODO: still to be implemented, blocked on the datafusion feature gate (CVE): @@ -129,6 +187,49 @@ impl GqlNodeState { .await } + /// Minimum `(node, value)` of a column. Null if the column does not exist, is empty, + /// or its values are not comparable (e.g. contains nodes). + async fn min( + &self, + #[graphql(desc = "Column name.")] column: String, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || self_clone.reduce_column(&column, Ordering::is_le)).await + } + + /// Maximum `(node, value)` of a column. Null if the column does not exist, is empty, + /// or its values are not comparable (e.g. contains nodes). + async fn max( + &self, + #[graphql(desc = "Column name.")] column: String, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || self_clone.reduce_column(&column, Ordering::is_ge)).await + } + + /// Median `(node, value)` of a column (lower median on even lengths). Null if the column + /// does not exist, is empty, or its values are not comparable (e.g. contains nodes). + async fn median( + &self, + #[graphql(desc = "Column name.")] column: String, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || { + let mut items = self_clone.column_items(&column)?; + if !items.first()?.1.dtype().has_cmp() { + return None; + } + items.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal)); + let len = items.len(); + let (node, value) = items.swap_remove((len - 1) / 2); + Some(GqlNodeStateItem { + node: node.into(), + value: GqlPropertyOutputVal(value), + }) + }) + .await + } + /// Returns a view of this state with the rows sorted by node id. async fn sort_by_id(&self) -> GqlNodeState { let self_clone = self.clone(); From 65d6a1e7a20db4cd8a06799f07243d3294a5dd5a Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Fri, 17 Jul 2026 05:22:24 -0400 Subject: [PATCH 10/39] Update min, max, median functions to use the underlying rust implementations. Added a comparison function to use for these. Added sum and mean functions in GraphQL. They can't use the Rust implementations because of trait bounds on the functions, so we must re-implement them to handle number casting (and possible errors there). --- raphtory-graphql/src/lib.rs | 6 + .../src/model/graph/node_state.rs | 159 +++++++++++++----- 2 files changed, 123 insertions(+), 42 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index b14fd7603f..3cd141c01b 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -666,6 +666,9 @@ mod graphql_test { min(column: "pagerank_score") { node { name } value } max(column: "pagerank_score") { node { name } value } median(column: "pagerank_score") { node { name } value } + sum(column: "pagerank_score") + mean(column: "pagerank_score") + average(column: "pagerank_score") missing: min(column: "not_a_column") { value } } } @@ -693,6 +696,9 @@ mod graphql_test { "node": { "name": "b" }, "value": 0.28155081033755053 }, + "sum": 1.0, + "mean": 0.3333333333333333, + "average": 0.3333333333333333, "missing": null } } diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 3715fe67eb..4a0b20ae2c 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -8,13 +8,16 @@ use dynamic_graphql::{ResolvedObject, ResolvedObjectFields, SimpleObject, Union} use raphtory::{ db::{ api::{ - state::{NodeStateOutput, NodeStateValue, OutputTypedNodeState, TypedNodeState}, + state::{ + NodeStateOutput, NodeStateValue, OutputTypedNodeState, PropMap, TypedNodeState, + }, view::{BoxableGraphView, DynamicGraph}, }, graph::node::NodeView, }, prelude::{NodeStateOps, Prop}, }; +use raphtory_api::core::entities::properties::prop::PropUnwrap; use std::{cmp::Ordering, sync::Arc}; /// A mapping from the nodes of a graph to the values computed for them by an algorithm. @@ -100,47 +103,76 @@ pub(crate) struct GqlNodeStateItem { value: GqlPropertyOutputVal, } +/// Function for total order over rows by `column`'s value: empty cells always lose. +/// `nulls_last` puts them last (for min); `false` puts them first (for max) and incomparable pairs tie. +fn column_cmp<'a>( + column: &'a str, + nulls_last: bool, +) -> impl Fn(&PropMap, &PropMap) -> Ordering + Sync + 'a { + move |a, b| { + let a = a.get(column).and_then(|v| v.as_ref()); + let b = b.get(column).and_then(|v| v.as_ref()); + match (a, b) { + (Some(a), Some(b)) => a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal), + (None, None) => Ordering::Equal, + (None, Some(_)) => { + if nulls_last { + Ordering::Greater + } else { + Ordering::Less + } + } + (Some(_), None) => { + if nulls_last { + Ordering::Less + } else { + Ordering::Greater + } + } + } + } +} + impl GqlNodeState { - /// The `(node, value)` pairs of a plain-prop column, skipping empty cells. Useful for aggregate operations. + /// Iterator over the non-empty values of a plain-prop column. /// None if the column does not exist or contains nodes. - fn column_items(&self, column: &str) -> Option, Prop)>> { + fn column_value_iter<'a>(&'a self, column: &'a str) -> Option + 'a> { if self.state.state.node_cols.contains_key(column) { return None; } - self.state.state.values_ref().schema().index_of(column).ok()?; + self.state + .state + .values_ref() + .schema() + .index_of(column) + .ok()?; Some( self.state .iter() - .filter_map(|(node, mut row)| { - let value = row.swap_remove(column)??; - Some((node.cloned(), value.into())) - }) - .collect(), + .filter_map(move |(_, mut row)| Some(row.swap_remove(column)??.into())), ) } - /// Reduces a column to the item winning all `pick` comparisons. Basically executes a simple aggregate operation. - /// None if the column is empty or its dtype is not comparable. - fn reduce_column( + /// Checks that `column` is a plain-prop column with at least one non-empty, comparable value; + /// used as the guard for the `*_item_by` aggregates. None if not comparable. + fn check_comparable(&self, column: &str) -> Option<()> { + self.column_value_iter(column)? + .next() + .filter(|first| first.dtype().has_cmp()) + .map(|_| ()) + } + + /// Wraps a `(node, row)` item into the output item; None if the cell is empty or nonexistent. + fn item_from_row( &self, column: &str, - pick: impl Fn(Ordering) -> bool, + item: (NodeView<'_, &DynamicGraph>, PropMap), ) -> Option { - let mut items = self.column_items(column)?.into_iter(); - let mut acc = items.next()?; - // we only check this once because, in practice, the entire Arrow column has the same type, - // so we expect Props to have the same dtype. - if !acc.1.dtype().has_cmp() { - return None; - } - for item in items { - if !pick(acc.1.partial_cmp(&item.1)?) { - acc = item; - } - } + let (node, mut row) = item; + let value: Prop = row.swap_remove(column)??.into(); Some(GqlNodeStateItem { - node: acc.0.into(), - value: GqlPropertyOutputVal(acc.1), + node: node.cloned().into(), + value: GqlPropertyOutputVal(value), }) } } @@ -194,7 +226,12 @@ impl GqlNodeState { #[graphql(desc = "Column name.")] column: String, ) -> Option { let self_clone = self.clone(); - blocking_compute(move || self_clone.reduce_column(&column, Ordering::is_le)).await + blocking_compute(move || { + self_clone.check_comparable(&column)?; + let item = self_clone.state.min_item_by(column_cmp(&column, true))?; + self_clone.item_from_row(&column, item) + }) + .await } /// Maximum `(node, value)` of a column. Null if the column does not exist, is empty, @@ -204,28 +241,66 @@ impl GqlNodeState { #[graphql(desc = "Column name.")] column: String, ) -> Option { let self_clone = self.clone(); - blocking_compute(move || self_clone.reduce_column(&column, Ordering::is_ge)).await + blocking_compute(move || { + self_clone.check_comparable(&column)?; + let item = self_clone.state.max_item_by(column_cmp(&column, false))?; + self_clone.item_from_row(&column, item) + }) + .await } - /// Median `(node, value)` of a column (lower median on even lengths). Null if the column - /// does not exist, is empty, or its values are not comparable (e.g. contains nodes). - async fn median( + /// Sum of a column's values, skipping empty cells. Null if the column does not exist, is empty, + /// or is not additive (e.g. contains nodes). + async fn sum( &self, #[graphql(desc = "Column name.")] column: String, - ) -> Option { + ) -> Option { let self_clone = self.clone(); blocking_compute(move || { - let mut items = self_clone.column_items(&column)?; - if !items.first()?.1.dtype().has_cmp() { + let mut values = self_clone.column_value_iter(&column)?; + let mut acc = values.next()?; + if !acc.dtype().has_add() { return None; } - items.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal)); - let len = items.len(); - let (node, value) = items.swap_remove((len - 1) / 2); - Some(GqlNodeStateItem { - node: node.into(), - value: GqlPropertyOutputVal(value), - }) + for value in values { + acc = acc.add(value)?; + } + Some(GqlPropertyOutputVal(acc)) + }) + .await + } + + /// Mean of a column's values as a float, skipping empty cells. Null if the column does not exist, + /// is empty, or has any non-numeric value. + async fn mean( + &self, + #[graphql(desc = "Column name.")] column: String, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || { + let mut values = self_clone.column_value_iter(&column)?; + let mut sum = values.next()?.as_f64()?; + let mut count = 1usize; + for value in values { + sum += value.as_f64()?; + count += 1; + } + Some(GqlPropertyOutputVal(Prop::F64(sum / count as f64))) + }) + .await + } + + /// Median `(node, value)` of a column (upper median on even lengths). Null if the column + /// does not exist, is empty, or is not comparable (e.g. contains nodes). + async fn median( + &self, + #[graphql(desc = "Column name.")] column: String, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || { + self_clone.check_comparable(&column)?; + let item = self_clone.state.median_item_by(column_cmp(&column, true))?; + self_clone.item_from_row(&column, item) }) .await } From 7b2517d9873b5d41276ee37a78ee2682f01423a1 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Mon, 20 Jul 2026 04:59:03 -0400 Subject: [PATCH 11/39] Added GraphQL queries to return the NodeState's values by row (keyed by node) instead of by column. There is also a variant to return rows without column names --- raphtory-graphql/src/lib.rs | 86 ++++++++++- .../src/model/graph/node_state.rs | 135 ++++++++++++++---- 2 files changed, 194 insertions(+), 27 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 3cd141c01b..76b1c0da72 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,90 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_node_state_rows() { + let graph = Graph::new(); + // asymmetric graph so every node has a distinct pagerank + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + columnNames + rows { + node { name } + entries { + columnName + value { ... on NodeStateProp { value } } + } + } + headlessRows { + node { name } + values { ... on NodeStateProp { value } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "pagerank": { + "columnNames": ["pagerank_score"], + "rows": [ + { + "node": { "name": "a" }, + "entries": [ + { "columnName": "pagerank_score", "value": { "value": 0.197580035313204 } } + ] + }, + { + "node": { "name": "b" }, + "entries": [ + { "columnName": "pagerank_score", "value": { "value": 0.28155081033755053 } } + ] + }, + { + "node": { "name": "c" }, + "entries": [ + { "columnName": "pagerank_score", "value": { "value": 0.5208691543492454 } } + ] + } + ], + "headlessRows": [ + { + "node": { "name": "a" }, + "values": [ { "value": 0.197580035313204 } ] + }, + { + "node": { "name": "b" }, + "values": [ { "value": 0.28155081033755053 } ] + }, + { + "node": { "name": "c" }, + "values": [ { "value": 0.5208691543492454 } ] + } + ] + } + } + } + }) + ); + } + #[tokio::test] async fn test_algorithm_node_state_aggregates() { let graph = Graph::new(); @@ -668,7 +752,6 @@ mod graphql_test { median(column: "pagerank_score") { node { name } value } sum(column: "pagerank_score") mean(column: "pagerank_score") - average(column: "pagerank_score") missing: min(column: "not_a_column") { value } } } @@ -698,7 +781,6 @@ mod graphql_test { }, "sum": 1.0, "mean": 0.3333333333333333, - "average": 0.3333333333333333, "missing": null } } diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 4a0b20ae2c..9f12b8329f 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -22,12 +22,12 @@ use std::{cmp::Ordering, sync::Arc}; /// A mapping from the nodes of a graph to the values computed for them by an algorithm. /// -/// The output is columnar: every column of the underlying state is exposed as +/// The output is columnar: every column of the underlying node state is exposed as /// a `NodeStateColumn` whose `values` are row-aligned with `nodes`. #[derive(ResolvedObject, Clone)] #[graphql(name = "NodeState")] pub(crate) struct GqlNodeState { - pub(crate) state: OutputTypedNodeState<'static, DynamicGraph>, + pub(crate) node_state: OutputTypedNodeState<'static, DynamicGraph>, } impl From> for GqlNodeState @@ -35,9 +35,9 @@ where V: NodeStateValue + 'static, T: Clone + Send + Sync + 'static, { - fn from(state: TypedNodeState<'static, V, DynamicGraph, T>) -> Self { + fn from(node_state: TypedNodeState<'static, V, DynamicGraph, T>) -> Self { Self { - state: state.to_output_nodestate(), + node_state: node_state.to_output_nodestate(), } } } @@ -88,7 +88,7 @@ pub(crate) struct GqlNodeStateColumn { #[graphql(name = "NodeStateEntry")] pub(crate) struct GqlNodeStateEntry { /// Name of the column. - name: String, + column_name: String, /// The node's value in this column. value: GqlNodeStateValue, } @@ -103,6 +103,27 @@ pub(crate) struct GqlNodeStateItem { value: GqlPropertyOutputVal, } +/// A node's full row in the node state: one entry per column. +#[derive(SimpleObject, Clone)] +#[graphql(name = "NodeStateRow")] +pub(crate) struct GqlNodeStateRow { + /// The node this row belongs to. + node: GqlNode, + /// The row's values, one entry per column. + entries: Vec, +} + +/// A node's full row in the node state without the column names: `values[i]` +/// belongs to the column `NodeState.columnNames[i]`. +#[derive(SimpleObject, Clone)] +#[graphql(name = "NodeStateHeadlessRow")] +pub(crate) struct GqlNodeStateHeadlessRow { + /// The node this row belongs to. + node: GqlNode, + /// The row's values, in `columnNames` order. + values: Vec, +} + /// Function for total order over rows by `column`'s value: empty cells always lose. /// `nulls_last` puts them last (for min); `false` puts them first (for max) and incomparable pairs tie. fn column_cmp<'a>( @@ -137,17 +158,17 @@ impl GqlNodeState { /// Iterator over the non-empty values of a plain-prop column. /// None if the column does not exist or contains nodes. fn column_value_iter<'a>(&'a self, column: &'a str) -> Option + 'a> { - if self.state.state.node_cols.contains_key(column) { + if self.node_state.state.node_cols.contains_key(column) { return None; } - self.state + self.node_state .state .values_ref() .schema() .index_of(column) .ok()?; Some( - self.state + self.node_state .iter() .filter_map(move |(_, mut row)| Some(row.swap_remove(column)??.into())), ) @@ -187,14 +208,72 @@ impl GqlNodeState { // and `to_parquet`/`from_parquet` (avoid server-side filesystem access). #[ResolvedObjectFields] impl GqlNodeState { - /// Returns the number of nodes with a value in this state. + /// Returns the number of nodes with a value in this node state. async fn count(&self) -> usize { - self.state.len() + self.node_state.len() } - /// The nodes with a value in this state, in row order. Aligned with `values`. + /// The nodes with a value in this node state, in row order. Aligned with `values`. async fn nodes(&self) -> GqlNodes { - GqlNodes::new(self.state.nodes()) + GqlNodes::new(self.node_state.nodes()) + } + + /// The column names of this node state in order. + async fn column_names(&self) -> Vec { + self.node_state + .state + .values_ref() + .schema() + .fields() + .iter() + .map(|field| field.name().clone()) + .collect() + } + + /// All rows of the node state keyed by node, with one entry per column. + async fn rows(&self) -> Vec { + let self_clone = self.clone(); + blocking_compute(move || { + self_clone + .node_state + .iter() + .map(|(node, row)| GqlNodeStateRow { + node: node.cloned().into(), + entries: self_clone + .node_state + .convert(row) + .into_iter() + .map(|(name, value)| GqlNodeStateEntry { + column_name: name, + value: value.into(), + }) + .collect(), + }) + .collect() + }) + .await + } + + /// All rows of the node state keyed by node, without the column names: the `values` of each row are + /// in `columnNames` order. + async fn headless_rows(&self) -> Vec { + let self_clone = self.clone(); + blocking_compute(move || { + self_clone + .node_state + .iter() + .map(|(node, row)| GqlNodeStateHeadlessRow { + node: node.cloned().into(), + values: self_clone + .node_state + .convert(row) + .into_iter() + .map(|(_, value)| value.into()) + .collect(), + }) + .collect() + }) + .await } /// Returns the values for a node, one entry per column; null if the node has no value in this NodeState. @@ -204,13 +283,13 @@ impl GqlNodeState { ) -> Option> { let self_clone = self.clone(); blocking_compute(move || { - let row = self_clone.state.get_by_node(node)?; - let transformed = self_clone.state.convert(row); + let row = self_clone.node_state.get_by_node(node)?; + let transformed = self_clone.node_state.convert(row); Some( transformed .into_iter() .map(|(name, value)| GqlNodeStateEntry { - name, + column_name: name, value: value.into(), }) .collect(), @@ -228,7 +307,9 @@ impl GqlNodeState { let self_clone = self.clone(); blocking_compute(move || { self_clone.check_comparable(&column)?; - let item = self_clone.state.min_item_by(column_cmp(&column, true))?; + let item = self_clone + .node_state + .min_item_by(column_cmp(&column, true))?; self_clone.item_from_row(&column, item) }) .await @@ -243,7 +324,9 @@ impl GqlNodeState { let self_clone = self.clone(); blocking_compute(move || { self_clone.check_comparable(&column)?; - let item = self_clone.state.max_item_by(column_cmp(&column, false))?; + let item = self_clone + .node_state + .max_item_by(column_cmp(&column, false))?; self_clone.item_from_row(&column, item) }) .await @@ -299,29 +382,31 @@ impl GqlNodeState { let self_clone = self.clone(); blocking_compute(move || { self_clone.check_comparable(&column)?; - let item = self_clone.state.median_item_by(column_cmp(&column, true))?; + let item = self_clone + .node_state + .median_item_by(column_cmp(&column, true))?; self_clone.item_from_row(&column, item) }) .await } - /// Returns a view of this state with the rows sorted by node id. + /// Returns a view of this node state with the rows sorted by node id. async fn sort_by_id(&self) -> GqlNodeState { let self_clone = self.clone(); blocking_compute(move || GqlNodeState { - state: self_clone.state.sort_by_id(), + node_state: self_clone.node_state.sort_by_id(), }) .await } - /// The columns of the state, one per output field of the algorithm. + /// The columns of the node state, one per output field of the algorithm. /// `values` are row-aligned with `nodes`. async fn columns(&self) -> Vec { let self_clone = self.clone(); blocking_compute(move || { - let num_rows = self_clone.state.len(); + let num_rows = self_clone.node_state.len(); let mut columns: Vec<(String, Vec)> = self_clone - .state + .node_state .state .values_ref() .schema() @@ -329,8 +414,8 @@ impl GqlNodeState { .iter() .map(|field| (field.name().clone(), Vec::with_capacity(num_rows))) .collect(); - for row in self_clone.state.values_to_rows() { - let mut transformed = self_clone.state.convert(row); + for row in self_clone.node_state.values_to_rows() { + let mut transformed = self_clone.node_state.convert(row); for (name, values) in columns.iter_mut() { if let Some(value) = transformed.swap_remove(name) { values.push(value.into()); From e60f633b772ee3adea8a789d3d62601eabf0696a Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Mon, 20 Jul 2026 05:36:43 -0400 Subject: [PATCH 12/39] Rename field --- .../test_graphql/test_algorithms.py | 8 ++--- raphtory-graphql/src/lib.rs | 34 +++++++++---------- .../src/model/graph/node_state.rs | 4 +-- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/python/tests/test_base_install/test_graphql/test_algorithms.py b/python/tests/test_base_install/test_graphql/test_algorithms.py index ef8e6118f5..e18d3ea557 100644 --- a/python/tests/test_base_install/test_graphql/test_algorithms.py +++ b/python/tests/test_base_install/test_graphql/test_algorithms.py @@ -22,7 +22,7 @@ def test_algorithm_pagerank(): name values { __typename - ... on NodeStateProp { value } + ... on NodeStateProp { prop } } } } @@ -39,9 +39,9 @@ def test_algorithm_pagerank(): { "name": "pagerank_score", "values": [ - { "__typename": "NodeStateProp", "value": 0.197580035313204 }, - { "__typename": "NodeStateProp", "value": 0.28155081033755053 }, - { "__typename": "NodeStateProp", "value": 0.5208691543492454 }, + { "__typename": "NodeStateProp", "prop": 0.197580035313204 }, + { "__typename": "NodeStateProp", "prop": 0.28155081033755053 }, + { "__typename": "NodeStateProp", "prop": 0.5208691543492454 }, ], } ], diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 76b1c0da72..a08f15b96a 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -599,13 +599,13 @@ mod graphql_test { algorithm { pagerank(iterCount: 20) { get(node: "b") { - name + columnName value { __typename - ... on NodeStateProp { value } + ... on NodeStateProp { prop } } } - missing: get(node: "not-a-node") { name } + missing: get(node: "not-a-node") { columnName } sortById { nodes { list { name } } } @@ -626,8 +626,8 @@ mod graphql_test { "pagerank": { "get": [ { - "name": "pagerank_score", - "value": { "__typename": "NodeStateProp", "value": 0.3333333333333333 } + "columnName": "pagerank_score", + "value": { "__typename": "NodeStateProp", "prop": 0.3333333333333333 } } ], "missing": null, @@ -668,12 +668,12 @@ mod graphql_test { node { name } entries { columnName - value { ... on NodeStateProp { value } } + value { ... on NodeStateProp { prop } } } } headlessRows { node { name } - values { ... on NodeStateProp { value } } + values { ... on NodeStateProp { prop } } } } } @@ -694,34 +694,34 @@ mod graphql_test { { "node": { "name": "a" }, "entries": [ - { "columnName": "pagerank_score", "value": { "value": 0.197580035313204 } } + { "columnName": "pagerank_score", "value": { "prop": 0.197580035313204 } } ] }, { "node": { "name": "b" }, "entries": [ - { "columnName": "pagerank_score", "value": { "value": 0.28155081033755053 } } + { "columnName": "pagerank_score", "value": { "prop": 0.28155081033755053 } } ] }, { "node": { "name": "c" }, "entries": [ - { "columnName": "pagerank_score", "value": { "value": 0.5208691543492454 } } + { "columnName": "pagerank_score", "value": { "prop": 0.5208691543492454 } } ] } ], "headlessRows": [ { "node": { "name": "a" }, - "values": [ { "value": 0.197580035313204 } ] + "values": [ { "prop": 0.197580035313204 } ] }, { "node": { "name": "b" }, - "values": [ { "value": 0.28155081033755053 } ] + "values": [ { "prop": 0.28155081033755053 } ] }, { "node": { "name": "c" }, - "values": [ { "value": 0.5208691543492454 } ] + "values": [ { "prop": 0.5208691543492454 } ] } ] } @@ -810,7 +810,7 @@ mod graphql_test { name values { __typename - ... on NodeStateProp { value } + ... on NodeStateProp { prop } } } } @@ -840,9 +840,9 @@ mod graphql_test { { "name": "pagerank_score", "values": [ - { "__typename": "NodeStateProp", "value": 0.3333333333333333 }, - { "__typename": "NodeStateProp", "value": 0.3333333333333333 }, - { "__typename": "NodeStateProp", "value": 0.3333333333333333 } + { "__typename": "NodeStateProp", "prop": 0.3333333333333333 }, + { "__typename": "NodeStateProp", "prop": 0.3333333333333333 }, + { "__typename": "NodeStateProp", "prop": 0.3333333333333333 } ] } ] diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 9f12b8329f..30d92652bf 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -57,14 +57,14 @@ pub(crate) enum GqlNodeStateValue { #[graphql(name = "NodeStateProp")] pub(crate) struct GqlNodeStateProp { /// The property value; null if the node has no value in this column. - value: Option, + prop: Option, } impl From>> for GqlNodeStateValue { fn from(value: NodeStateOutput<'static, Arc>) -> Self { match value { NodeStateOutput::Prop(prop) => GqlNodeStateValue::Prop(GqlNodeStateProp { - value: prop.map(|p| GqlPropertyOutputVal(p.into())), + prop: prop.map(|p| GqlPropertyOutputVal(p.into())), }), NodeStateOutput::Node(node) => GqlNodeStateValue::Node(node.into()), NodeStateOutput::Nodes(nodes) => GqlNodeStateValue::Nodes(GqlNodes::new(nodes)), From 0e6c1369215f2ecb7b527de1209ecab8e5dbc7e9 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Tue, 21 Jul 2026 05:00:57 -0400 Subject: [PATCH 13/39] Adding single source shortest path to GraphQL algorithms. --- raphtory-graphql/src/lib.rs | 86 +++++++++++++++++++ raphtory-graphql/src/model/algorithms/mod.rs | 19 +++- .../algorithms/single_source_shortest_path.rs | 23 +++++ .../src/model/graph/node_state.rs | 4 +- 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index a08f15b96a..fdaa1c4f54 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,92 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_single_source_shortest_path() { + let graph = Graph::new(); + // simple chain a -> b -> c + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // The `path` column holds Nodes, not a Prop + let query = r#" + { + graph(path: "g") { + algorithm { + singleSourceShortestPath(source: "a") { + columnNames + rows { + node { id } + entries { + columnName + value { + __typename + ... on Nodes { list { id } } + } + } + } + min(column: "path") { value } + mean(column: "path") + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "singleSourceShortestPath": { + "columnNames": ["path"], + "rows": [ + { + "node": { "id": "a" }, + "entries": [{ + "columnName": "path", + "value": { + "__typename": "Nodes", + "list": [{ "id": "a" }] + } + }] + }, + { + "node": { "id": "b" }, + "entries": [{ + "columnName": "path", + "value": { + "__typename": "Nodes", + "list": [{ "id": "a" }, { "id": "b" }] + } + }] + }, + { + "node": { "id": "c" }, + "entries": [{ + "columnName": "path", + "value": { + "__typename": "Nodes", + "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] + } + }] + } + ], + // node-valued column: numeric aggregates return null + "min": null, + "mean": null + } + } + } + }) + ); + } + #[tokio::test] async fn test_algorithm_node_state_rows() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index eff977414a..abc58b2ce9 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -2,7 +2,12 @@ use crate::{ model::{ - algorithms::pagerank::{GqlPagerank, GqlPagerankArgs}, + algorithms::{ + pagerank::{GqlPagerank, GqlPagerankArgs}, + single_source_shortest_path::{ + GqlSingleSourceShortestPath, GqlSingleSourceShortestPathArgs, + }, + }, graph::node_state::GqlNodeState, }, rayon::blocking_compute, @@ -11,6 +16,7 @@ use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; use raphtory::{db::api::view::DynamicGraph, errors::GraphError}; pub(crate) mod pagerank; +pub(crate) mod single_source_shortest_path; /// A graph algorithm executable through the GraphQL API. pub(crate) trait GqlExecutableAlgorithm: 'static { @@ -69,4 +75,15 @@ impl GqlAlgorithms { }) .await } + + /// Returns the shortest (unweighted) path from `source` to every reachable node. + async fn single_source_shortest_path( + &self, + #[graphql(desc = "Source node id.")] source: String, + #[graphql(desc = "Optional maximum path length; stops the search once reached.")] + cutoff: Option, + ) -> Result { + self.run::(GqlSingleSourceShortestPathArgs { source, cutoff }) + .await + } } diff --git a/raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs b/raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs new file mode 100644 index 0000000000..4566e4a767 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs @@ -0,0 +1,23 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::pathing::single_source_shortest_path::single_source_shortest_path, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Single source shortest path (unweighted BFS), see [`single_source_shortest_path`]. +pub(crate) struct GqlSingleSourceShortestPath; + +pub(crate) struct GqlSingleSourceShortestPathArgs { + pub(crate) source: String, + pub(crate) cutoff: Option, +} + +impl GqlExecutableAlgorithm for GqlSingleSourceShortestPath { + type Args = GqlSingleSourceShortestPathArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = single_source_shortest_path(graph, args.source, args.cutoff); + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 30d92652bf..1d1e34dbf5 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -48,8 +48,8 @@ where #[graphql(name = "NodeStateValue")] pub(crate) enum GqlNodeStateValue { Prop(GqlNodeStateProp), - Node(GqlNode), // TODO: test this - Nodes(GqlNodes), // TODO: test this + Node(GqlNode), + Nodes(GqlNodes), } /// A plain property value of a node state cell. From 1f4e7fa120749c74814c387ca33d5cd6ee86230a Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Tue, 21 Jul 2026 05:18:43 -0400 Subject: [PATCH 14/39] Adding out components to GraphQL algorithms. --- raphtory-graphql/src/lib.rs | 67 +++++++++++++++++++ raphtory-graphql/src/model/algorithms/mod.rs | 13 ++++ .../src/model/algorithms/out_components.rs | 21 ++++++ 3 files changed, 101 insertions(+) create mode 100644 raphtory-graphql/src/model/algorithms/out_components.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index fdaa1c4f54..15125b09e6 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,73 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_out_components() { + let graph = Graph::new(); + // chain a -> b -> c + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // The `out_components` column holds Nodes + let query = r#" + { + graph(path: "g") { + algorithm { + outComponents { + nodes { list { id } } + columns { + name + values { + __typename + ... on Nodes { ids } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // component node order is not guaranteed (backed by a HashSet), so sort each set + let mut data = res.data.into_json().unwrap(); + for col in data["graph"]["algorithm"]["outComponents"]["columns"] + .as_array_mut() + .unwrap() + { + for value in col["values"].as_array_mut().unwrap() { + if let Some(ids) = value["ids"].as_array_mut() { + ids.sort_by_key(|id| id.as_str().unwrap().to_string()); + } + } + } + // values are row-aligned with nodes: a -> {b,c}, b -> {c}, c -> {} + assert_eq!( + data, + json!({ + "graph": { + "algorithm": { + "outComponents": { + "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] }, + "columns": [{ + "name": "out_components", + "values": [ + { "__typename": "Nodes", "ids": ["b", "c"] }, + { "__typename": "Nodes", "ids": ["c"] }, + { "__typename": "Nodes", "ids": [] } + ] + }] + } + } + } + }) + ); + } + #[tokio::test] async fn test_algorithm_single_source_shortest_path() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index abc58b2ce9..77f4994418 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -3,6 +3,7 @@ use crate::{ model::{ algorithms::{ + out_components::{GqlOutComponents, GqlOutComponentsArgs}, pagerank::{GqlPagerank, GqlPagerankArgs}, single_source_shortest_path::{ GqlSingleSourceShortestPath, GqlSingleSourceShortestPathArgs, @@ -15,6 +16,7 @@ use crate::{ use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; use raphtory::{db::api::view::DynamicGraph, errors::GraphError}; +pub(crate) mod out_components; pub(crate) mod pagerank; pub(crate) mod single_source_shortest_path; @@ -86,4 +88,15 @@ impl GqlAlgorithms { self.run::(GqlSingleSourceShortestPathArgs { source, cutoff }) .await } + + /// Returns the out component (all reachable nodes following out-edges) of every node. + async fn out_components( + &self, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlOutComponentsArgs { threads }) + .await + } } diff --git a/raphtory-graphql/src/model/algorithms/out_components.rs b/raphtory-graphql/src/model/algorithms/out_components.rs new file mode 100644 index 0000000000..e9f966ceb0 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/out_components.rs @@ -0,0 +1,21 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::components::out_components, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Out components, see [`out_components`]. +pub(crate) struct GqlOutComponents; + +pub(crate) struct GqlOutComponentsArgs { + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlOutComponents { + type Args = GqlOutComponentsArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = out_components(graph, args.threads); + Ok(state.into()) + } +} From 80689342ceb4af14a23c107aa42e574d0f80a088 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Tue, 21 Jul 2026 05:39:42 -0400 Subject: [PATCH 15/39] Adding in components and dijkstra to GraphQL algorithms. --- raphtory-graphql/src/lib.rs | 145 ++++++++++++++++++ .../src/model/algorithms/dijkstra.rs | 52 +++++++ .../src/model/algorithms/in_components.rs | 21 +++ raphtory-graphql/src/model/algorithms/mod.rs | 35 +++++ 4 files changed, 253 insertions(+) create mode 100644 raphtory-graphql/src/model/algorithms/dijkstra.rs create mode 100644 raphtory-graphql/src/model/algorithms/in_components.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 15125b09e6..471741aee6 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,151 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_in_components() { + let graph = Graph::new(); + // chain a -> b -> c + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // The `in_components` column holds Nodes + let query = r#" + { + graph(path: "g") { + algorithm { + inComponents { + rows { + node { id } + entries { + columnName + value { + __typename + ... on Nodes { ids } + } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // component node order is not guaranteed (backed by a HashSet), so sort each set + let mut data = res.data.into_json().unwrap(); + for row in data["graph"]["algorithm"]["inComponents"]["rows"] + .as_array_mut() + .unwrap() + { + for entry in row["entries"].as_array_mut().unwrap() { + if let Some(ids) = entry["value"]["ids"].as_array_mut() { + ids.sort_by_key(|id| id.as_str().unwrap().to_string()); + } + } + } + // in_components: a <- {}, b <- {a}, c <- {a,b} + assert_eq!( + data, + json!({ + "graph": { + "algorithm": { + "inComponents": { + "rows": [ + { + "node": { "id": "a" }, + "entries": [{ + "columnName": "in_components", + "value": { "__typename": "Nodes", "ids": [] } + }] + }, + { + "node": { "id": "b" }, + "entries": [{ + "columnName": "in_components", + "value": { "__typename": "Nodes", "ids": ["a"] } + }] + }, + { + "node": { "id": "c" }, + "entries": [{ + "columnName": "in_components", + "value": { "__typename": "Nodes", "ids": ["a", "b"] } + }] + } + ] + } + } + } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_dijkstra() { + let graph = Graph::new(); + // weighted chain a -> b -> c + graph + .add_edge(1, "a", "b", [("weight", 2.0)], None) + .unwrap(); + graph + .add_edge(2, "b", "c", [("weight", 3.0)], None) + .unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // Mixed columns: `distance` is a Prop, `path` is Nodes + let query = r#" + { + graph(path: "g") { + algorithm { + dijkstra(source: "a", targets: ["c"], weight: "weight", direction: OUT) { + nodes { list { id } } + columns { + name + values { + __typename + ... on NodeStateProp { prop } + ... on Nodes { ids } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // one row (target c): distance 2+3=5, path a -> b -> c + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "dijkstra": { + "nodes": { "list": [{ "id": "c" }] }, + "columns": [ + { + "name": "distance", + "values": [{ "__typename": "NodeStateProp", "prop": 5.0 }] + }, + { + "name": "path", + "values": [{ "__typename": "Nodes", "ids": ["a", "b", "c"] }] + } + ] + } + } + } + }) + ); + } + #[tokio::test] async fn test_algorithm_out_components() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/algorithms/dijkstra.rs b/raphtory-graphql/src/model/algorithms/dijkstra.rs new file mode 100644 index 0000000000..89c077fdb6 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/dijkstra.rs @@ -0,0 +1,52 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use dynamic_graphql::Enum; +use raphtory::{ + algorithms::pathing::dijkstra::dijkstra_single_source_shortest_paths, + db::api::view::DynamicGraph, errors::GraphError, +}; +use raphtory_api::core::Direction; + +/// Edge direction to follow during traversal. +#[derive(Enum, Copy, Clone)] +#[graphql(name = "Direction")] +pub(crate) enum GqlDirection { + Out, + In, + Both, +} + +impl From for Direction { + fn from(direction: GqlDirection) -> Self { + match direction { + GqlDirection::Out => Direction::OUT, + GqlDirection::In => Direction::IN, + GqlDirection::Both => Direction::BOTH, + } + } +} + +/// Weighted single source shortest paths (Dijkstra), see [`dijkstra_single_source_shortest_paths`]. +pub(crate) struct GqlDijkstra; + +pub(crate) struct GqlDijkstraArgs { + pub(crate) source: String, + pub(crate) targets: Vec, + pub(crate) weight: Option, + pub(crate) direction: GqlDirection, +} + +impl GqlExecutableAlgorithm for GqlDijkstra { + type Args = GqlDijkstraArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = dijkstra_single_source_shortest_paths( + graph, + args.source, + args.targets, + args.weight.as_deref(), + args.direction.into(), + )?; + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/in_components.rs b/raphtory-graphql/src/model/algorithms/in_components.rs new file mode 100644 index 0000000000..544d25be2b --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/in_components.rs @@ -0,0 +1,21 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::components::in_components, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// In components, see [`in_components`]. +pub(crate) struct GqlInComponents; + +pub(crate) struct GqlInComponentsArgs { + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlInComponents { + type Args = GqlInComponentsArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = in_components(graph, args.threads); + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 77f4994418..3b522db201 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -3,6 +3,8 @@ use crate::{ model::{ algorithms::{ + dijkstra::{GqlDijkstra, GqlDijkstraArgs, GqlDirection}, + in_components::{GqlInComponents, GqlInComponentsArgs}, out_components::{GqlOutComponents, GqlOutComponentsArgs}, pagerank::{GqlPagerank, GqlPagerankArgs}, single_source_shortest_path::{ @@ -16,6 +18,8 @@ use crate::{ use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; use raphtory::{db::api::view::DynamicGraph, errors::GraphError}; +pub(crate) mod dijkstra; +pub(crate) mod in_components; pub(crate) mod out_components; pub(crate) mod pagerank; pub(crate) mod single_source_shortest_path; @@ -89,6 +93,17 @@ impl GqlAlgorithms { .await } + /// Returns the in component (all nodes that can reach it following out-edges) of every node. + async fn in_components( + &self, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlInComponentsArgs { threads }) + .await + } + /// Returns the out component (all reachable nodes following out-edges) of every node. async fn out_components( &self, @@ -99,4 +114,24 @@ impl GqlAlgorithms { self.run::(GqlOutComponentsArgs { threads }) .await } + + /// Returns the weighted shortest path from `source` to each of `targets` (Dijkstra). + async fn dijkstra( + &self, + #[graphql(desc = "Source node id.")] source: String, + #[graphql(desc = "Target node ids.")] targets: Vec, + #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] + weight: Option, + #[graphql(desc = "Edge direction to follow. Defaults to BOTH.")] direction: Option< + GqlDirection, + >, + ) -> Result { + self.run::(GqlDijkstraArgs { + source, + targets, + weight, + direction: direction.unwrap_or(GqlDirection::Both), + }) + .await + } } From 19aa86c0e366722e69b59eb06d80217e09c4820c Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 22 Jul 2026 05:23:08 -0400 Subject: [PATCH 16/39] Added degree_centrality, betweenness_centrality, and hits algorithms in GraphQL --- raphtory-graphql/src/lib.rs | 147 ++++++++++++++++++ .../algorithms/betweenness_centrality.rs | 22 +++ .../src/model/algorithms/degree_centrality.rs | 19 +++ raphtory-graphql/src/model/algorithms/hits.rs | 21 +++ raphtory-graphql/src/model/algorithms/mod.rs | 42 +++++ 5 files changed, 251 insertions(+) create mode 100644 raphtory-graphql/src/model/algorithms/betweenness_centrality.rs create mode 100644 raphtory-graphql/src/model/algorithms/degree_centrality.rs create mode 100644 raphtory-graphql/src/model/algorithms/hits.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 471741aee6..d7dd223628 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,153 @@ mod graphql_test { ); } + fn centrality_test_graph() -> MaterializedGraph { + let graph = Graph::new(); + // path a -> b -> c -> d so nodes get distinct centrality scores + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); + graph.into() + } + + #[tokio::test] + async fn test_algorithm_degree_centrality() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", centrality_test_graph())], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + degreeCentrality { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // degree/max_degree: endpoints 0.5, middle nodes 1.0 + let entry = |id: &str, prop| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "degree_centrality", "value": { "prop": prop } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "degreeCentrality": { "rows": [ + entry("a", 0.5), + entry("b", 1.0), + entry("c", 1.0), + entry("d", 0.5), + ] } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_betweenness_centrality() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", centrality_test_graph())], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + betweennessCentrality { + nodes { list { id } } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // endpoints lie on no shortest path (0.0); middle nodes b,c each on one (1/3 normalized) + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "betweennessCentrality": { + "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }, { "id": "d" }] }, + "columns": [{ + "name": "betweenness_centrality", + "values": [ + { "prop": 0.0 }, + { "prop": 0.3333333333333333 }, + { "prop": 0.3333333333333333 }, + { "prop": 0.0 } + ] + }] + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_hits() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", centrality_test_graph())], tmp_dir.path()).await; + + // hits has two columns (hub_score, auth_score) + let query = r#" + { + graph(path: "g") { + algorithm { + hits(iterCount: 20) { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // f32 scores promoted to f64; source has no auth, sink has no hub + let s = 0.3333333432674408; + let row = |id: &str, hub, auth| { + json!({ + "node": { "id": id }, + "entries": [ + { "columnName": "hub_score", "value": { "prop": hub } }, + { "columnName": "auth_score", "value": { "prop": auth } } + ] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "hits": { "rows": [ + row("a", s, 0.0), + row("b", s, s), + row("c", s, s), + row("d", 0.0, s), + ] } } } + }) + ); + } + #[tokio::test] async fn test_algorithm_in_components() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/algorithms/betweenness_centrality.rs b/raphtory-graphql/src/model/algorithms/betweenness_centrality.rs new file mode 100644 index 0000000000..631e3237b1 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/betweenness_centrality.rs @@ -0,0 +1,22 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::centrality::betweenness::betweenness_centrality, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Betweenness centrality, see [`betweenness_centrality`]. +pub(crate) struct GqlBetweennessCentrality; + +pub(crate) struct GqlBetweennessCentralityArgs { + pub(crate) k: Option, + pub(crate) normalized: bool, +} + +impl GqlExecutableAlgorithm for GqlBetweennessCentrality { + type Args = GqlBetweennessCentralityArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + Ok(betweenness_centrality(graph, args.k, args.normalized).into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/degree_centrality.rs b/raphtory-graphql/src/model/algorithms/degree_centrality.rs new file mode 100644 index 0000000000..58042a9fb9 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/degree_centrality.rs @@ -0,0 +1,19 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::centrality::degree_centrality::degree_centrality, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Degree centrality, see [`degree_centrality`]. +pub(crate) struct GqlDegreeCentrality; + +pub(crate) struct GqlDegreeCentralityArgs; + +impl GqlExecutableAlgorithm for GqlDegreeCentrality { + type Args = GqlDegreeCentralityArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(degree_centrality(graph).into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/hits.rs b/raphtory-graphql/src/model/algorithms/hits.rs new file mode 100644 index 0000000000..16878bca6b --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/hits.rs @@ -0,0 +1,21 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::centrality::hits::hits, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// HITS (hub and authority scores), see [`hits`]. +pub(crate) struct GqlHits; + +pub(crate) struct GqlHitsArgs { + pub(crate) iter_count: usize, + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlHits { + type Args = GqlHitsArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + Ok(hits(graph, args.iter_count, args.threads).into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 3b522db201..32fa66f8f5 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -3,7 +3,10 @@ use crate::{ model::{ algorithms::{ + betweenness_centrality::{GqlBetweennessCentrality, GqlBetweennessCentralityArgs}, + degree_centrality::{GqlDegreeCentrality, GqlDegreeCentralityArgs}, dijkstra::{GqlDijkstra, GqlDijkstraArgs, GqlDirection}, + hits::{GqlHits, GqlHitsArgs}, in_components::{GqlInComponents, GqlInComponentsArgs}, out_components::{GqlOutComponents, GqlOutComponentsArgs}, pagerank::{GqlPagerank, GqlPagerankArgs}, @@ -18,7 +21,10 @@ use crate::{ use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; use raphtory::{db::api::view::DynamicGraph, errors::GraphError}; +pub(crate) mod betweenness_centrality; +pub(crate) mod degree_centrality; pub(crate) mod dijkstra; +pub(crate) mod hits; pub(crate) mod in_components; pub(crate) mod out_components; pub(crate) mod pagerank; @@ -82,6 +88,42 @@ impl GqlAlgorithms { .await } + /// Returns the degree centrality of every node. + async fn degree_centrality(&self) -> Result { + self.run::(GqlDegreeCentralityArgs) + .await + } + + /// Returns the betweenness centrality of every node. + async fn betweenness_centrality( + &self, + #[graphql(desc = "Number of nodes to sample. Defaults to all nodes.")] k: Option, + #[graphql(desc = "Whether to normalize the values. Defaults to true.")] normalized: Option< + bool, + >, + ) -> Result { + self.run::(GqlBetweennessCentralityArgs { + k, + normalized: normalized.unwrap_or(true), + }) + .await + } + + /// Returns the HITS hub and authority scores of every node. + async fn hits( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlHitsArgs { + iter_count: iter_count.unwrap_or(20), + threads, + }) + .await + } + /// Returns the shortest (unweighted) path from `source` to every reachable node. async fn single_source_shortest_path( &self, From dced393c9140e1078f5ffe2978f6f13ae241f4fa Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 22 Jul 2026 05:46:08 -0400 Subject: [PATCH 17/39] Added label_propagation and louvain algorithms in GraphQL --- raphtory-graphql/src/lib.rs | 110 ++++++++++++++++++ .../src/model/algorithms/label_propagation.rs | 23 ++++ .../src/model/algorithms/louvain.rs | 32 +++++ raphtory-graphql/src/model/algorithms/mod.rs | 39 +++++++ 4 files changed, 204 insertions(+) create mode 100644 raphtory-graphql/src/model/algorithms/label_propagation.rs create mode 100644 raphtory-graphql/src/model/algorithms/louvain.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index d7dd223628..2c641070d7 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,116 @@ mod graphql_test { ); } + fn community_test_graph() -> MaterializedGraph { + let graph = Graph::new(); + // two triangles joined by a single bridge edge (c -> d) + for (src, dst) in [ + ("a", "b"), + ("b", "c"), + ("c", "a"), + ("d", "e"), + ("e", "f"), + ("f", "d"), + ("c", "d"), + ] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); + } + graph.into() + } + + #[tokio::test] + async fn test_algorithm_louvain() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", community_test_graph())], tmp_dir.path()).await; + + // fixed rng_seed for deterministic output + let query = r#" + { + graph(path: "g") { + algorithm { + louvain(rngSeed: 42) { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // two triangles -> two communities: {a,b,c} and {d,e,f} + let entry = |id: &str, community| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "community_id", "value": { "prop": community } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "louvain": { "rows": [ + entry("a", 0), + entry("b", 0), + entry("c", 0), + entry("d", 1), + entry("e", 1), + entry("f", 1), + ] } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_label_propagation() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", community_test_graph())], tmp_dir.path()).await; + + // threads: 1 for deterministic output (multi-threaded label propagation output is non-deterministic) + let query = r#" + { + graph(path: "g") { + algorithm { + labelPropagation(threads: 1) { + nodes { list { id } } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // two triangles -> two communities; ids derive from node index + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "labelPropagation": { + "nodes": { "list": [ + { "id": "a" }, { "id": "b" }, { "id": "c" }, + { "id": "d" }, { "id": "e" }, { "id": "f" } + ] }, + "columns": [{ + "name": "community_id", + "values": [ + { "prop": 2 }, { "prop": 2 }, { "prop": 2 }, + { "prop": 600002 }, { "prop": 600002 }, { "prop": 600002 } + ] + }] + } } } + }) + ); + } + fn centrality_test_graph() -> MaterializedGraph { let graph = Graph::new(); // path a -> b -> c -> d so nodes get distinct centrality scores diff --git a/raphtory-graphql/src/model/algorithms/label_propagation.rs b/raphtory-graphql/src/model/algorithms/label_propagation.rs new file mode 100644 index 0000000000..bf483224e8 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/label_propagation.rs @@ -0,0 +1,23 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::community_detection::label_propagation::label_propagation, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Label propagation community detection, see [`label_propagation`]. +pub(crate) struct GqlLabelPropagation; + +pub(crate) struct GqlLabelPropagationArgs { + pub(crate) iter_count: usize, + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlLabelPropagation { + type Args = GqlLabelPropagationArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = label_propagation(graph, args.iter_count, None, args.threads); + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/louvain.rs b/raphtory-graphql/src/model/algorithms/louvain.rs new file mode 100644 index 0000000000..c329a0507e --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/louvain.rs @@ -0,0 +1,32 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::community_detection::{louvain::louvain, modularity::ModularityUnDir}, + db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Louvain community detection, see [`louvain`]. +pub(crate) struct GqlLouvain; + +pub(crate) struct GqlLouvainArgs { + pub(crate) resolution: f64, + pub(crate) weight_prop: Option, + pub(crate) tol: Option, + pub(crate) rng_seed: Option, +} + +impl GqlExecutableAlgorithm for GqlLouvain { + type Args = GqlLouvainArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = louvain::( + graph, + args.resolution, + args.weight_prop.as_deref(), + args.tol, + args.rng_seed, + ); + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 32fa66f8f5..3606cb1bf8 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -8,6 +8,8 @@ use crate::{ dijkstra::{GqlDijkstra, GqlDijkstraArgs, GqlDirection}, hits::{GqlHits, GqlHitsArgs}, in_components::{GqlInComponents, GqlInComponentsArgs}, + label_propagation::{GqlLabelPropagation, GqlLabelPropagationArgs}, + louvain::{GqlLouvain, GqlLouvainArgs}, out_components::{GqlOutComponents, GqlOutComponentsArgs}, pagerank::{GqlPagerank, GqlPagerankArgs}, single_source_shortest_path::{ @@ -26,6 +28,8 @@ pub(crate) mod degree_centrality; pub(crate) mod dijkstra; pub(crate) mod hits; pub(crate) mod in_components; +pub(crate) mod label_propagation; +pub(crate) mod louvain; pub(crate) mod out_components; pub(crate) mod pagerank; pub(crate) mod single_source_shortest_path; @@ -157,6 +161,41 @@ impl GqlAlgorithms { .await } + /// Returns the community of every node (Louvain). + async fn louvain( + &self, + #[graphql(desc = "Resolution parameter for modularity. Defaults to 1.0.")] + resolution: Option, + #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] + weight_prop: Option, + #[graphql(desc = "Convergence tolerance. Defaults to 1e-8.")] tol: Option, + #[graphql(desc = "Seed for the node-shuffling rng. If unset, seeded from the OS.")] + rng_seed: Option, + ) -> Result { + self.run::(GqlLouvainArgs { + resolution: resolution.unwrap_or(1.0), + weight_prop, + tol, + rng_seed, + }) + .await + } + + /// Returns the community of every node (label propagation). + async fn label_propagation( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlLabelPropagationArgs { + iter_count: iter_count.unwrap_or(20), + threads, + }) + .await + } + /// Returns the weighted shortest path from `source` to each of `targets` (Dijkstra). async fn dijkstra( &self, From 801b5cefc36e818ac4b3f9d03169cc9c3eb2287a Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 22 Jul 2026 05:57:29 -0400 Subject: [PATCH 18/39] Added strongly_connected_components and weakly_connected_components algorithms in GraphQL --- raphtory-graphql/src/lib.rs | 95 +++++++++++++++++++ raphtory-graphql/src/model/algorithms/mod.rs | 20 ++++ .../strongly_connected_components.rs | 19 ++++ .../algorithms/weakly_connected_components.rs | 19 ++++ 4 files changed, 153 insertions(+) create mode 100644 raphtory-graphql/src/model/algorithms/strongly_connected_components.rs create mode 100644 raphtory-graphql/src/model/algorithms/weakly_connected_components.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 2c641070d7..ba6994559f 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,101 @@ mod graphql_test { ); } + fn components_test_graph() -> MaterializedGraph { + let graph = Graph::new(); + // cycle a -> b -> c -> a (one SCC), plus d -> a (d reaches the cycle but not vice versa) + for (src, dst) in [("a", "b"), ("b", "c"), ("c", "a"), ("d", "a")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); + } + graph.into() + } + + #[tokio::test] + async fn test_algorithm_weakly_connected_components() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", components_test_graph())], tmp_dir.path()).await; + + // whole graph is weakly connected -> all nodes share one component + let query = r#" + { + graph(path: "g") { + algorithm { + weaklyConnectedComponents { + nodes { list { id } } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // all four nodes are weakly connected -> one component + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "weaklyConnectedComponents": { + "nodes": { "list": [ + { "id": "a" }, { "id": "b" }, { "id": "c" }, { "id": "d" } + ] }, + "columns": [{ + "name": "component_id", + "values": [{ "prop": 0 }, { "prop": 0 }, { "prop": 0 }, { "prop": 0 }] + }] + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_strongly_connected_components() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", components_test_graph())], tmp_dir.path()).await; + + // {a,b,c} form one SCC (the cycle); d is its own + let query = r#" + { + graph(path: "g") { + algorithm { + stronglyConnectedComponents { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let entry = |id: &str, component| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "component_id", "value": { "prop": component } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "stronglyConnectedComponents": { "rows": [ + entry("a", 0), + entry("b", 0), + entry("c", 0), + entry("d", 1), + ] } } } + }) + ); + } + fn community_test_graph() -> MaterializedGraph { let graph = Graph::new(); // two triangles joined by a single bridge edge (c -> d) diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 3606cb1bf8..99b7cf910f 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -15,6 +15,12 @@ use crate::{ single_source_shortest_path::{ GqlSingleSourceShortestPath, GqlSingleSourceShortestPathArgs, }, + strongly_connected_components::{ + GqlStronglyConnectedComponents, GqlStronglyConnectedComponentsArgs, + }, + weakly_connected_components::{ + GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, + }, }, graph::node_state::GqlNodeState, }, @@ -33,6 +39,8 @@ pub(crate) mod louvain; pub(crate) mod out_components; pub(crate) mod pagerank; pub(crate) mod single_source_shortest_path; +pub(crate) mod strongly_connected_components; +pub(crate) mod weakly_connected_components; /// A graph algorithm executable through the GraphQL API. pub(crate) trait GqlExecutableAlgorithm: 'static { @@ -161,6 +169,18 @@ impl GqlAlgorithms { .await } + /// Returns the weakly connected component id of every node. + async fn weakly_connected_components(&self) -> Result { + self.run::(GqlWeaklyConnectedComponentsArgs) + .await + } + + /// Returns the strongly connected component id of every node. + async fn strongly_connected_components(&self) -> Result { + self.run::(GqlStronglyConnectedComponentsArgs) + .await + } + /// Returns the community of every node (Louvain). async fn louvain( &self, diff --git a/raphtory-graphql/src/model/algorithms/strongly_connected_components.rs b/raphtory-graphql/src/model/algorithms/strongly_connected_components.rs new file mode 100644 index 0000000000..80867bf69b --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/strongly_connected_components.rs @@ -0,0 +1,19 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::components::strongly_connected_components, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Strongly connected components, see [`strongly_connected_components`]. +pub(crate) struct GqlStronglyConnectedComponents; + +pub(crate) struct GqlStronglyConnectedComponentsArgs; + +impl GqlExecutableAlgorithm for GqlStronglyConnectedComponents { + type Args = GqlStronglyConnectedComponentsArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(strongly_connected_components(graph).into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/weakly_connected_components.rs b/raphtory-graphql/src/model/algorithms/weakly_connected_components.rs new file mode 100644 index 0000000000..23ceefa0f2 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/weakly_connected_components.rs @@ -0,0 +1,19 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::components::weakly_connected_components, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Weakly connected components, see [`weakly_connected_components`]. +pub(crate) struct GqlWeaklyConnectedComponents; + +pub(crate) struct GqlWeaklyConnectedComponentsArgs; + +impl GqlExecutableAlgorithm for GqlWeaklyConnectedComponents { + type Args = GqlWeaklyConnectedComponentsArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(weakly_connected_components(graph).into()) + } +} From e01dc3c665531812dbb6f76823076f2013b6077a Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 22 Jul 2026 06:13:16 -0400 Subject: [PATCH 19/39] Added all_local_reciprocity, balance, and local_clustering_coefficient_batch algorithms in GraphQL --- raphtory-graphql/src/lib.rs | 136 ++++++++++++++++++ .../model/algorithms/all_local_reciprocity.rs | 19 +++ .../src/model/algorithms/balance.rs | 26 ++++ .../src/model/algorithms/dijkstra.rs | 22 +-- .../local_clustering_coefficient_batch.rs | 21 +++ raphtory-graphql/src/model/algorithms/mod.rs | 66 ++++++++- 6 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs create mode 100644 raphtory-graphql/src/model/algorithms/balance.rs create mode 100644 raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index ba6994559f..265b0470b9 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,142 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_all_local_reciprocity() { + let graph = Graph::new(); + // a<->b reciprocated, a->c not + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "a", NO_PROPS, None).unwrap(); + graph.add_edge(3, "a", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + allLocalReciprocity { + rows { + node { id } + entries { columnName value { ... on NodeStateProp { prop } } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // a: 2 of 3 edges reciprocated; b: fully reciprocated; c: none + let entry = |id: &str, reciprocity| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "reciprocity", "value": { "prop": reciprocity } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "allLocalReciprocity": { "rows": [ + entry("a", 0.6666666666666666), + entry("b", 1.0), + entry("c", 0.0), + ] } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_balance() { + let graph = Graph::new(); + graph + .add_edge(1, "a", "b", [("weight", 5.0)], None) + .unwrap(); + graph + .add_edge(2, "c", "a", [("weight", 3.0)], None) + .unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + balance(name: "weight", direction: BOTH) { + nodes { list { id } } + columns { name values { ... on NodeStateProp { prop } } } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // BOTH: a = in 3 - out 5 = -2, b = +5, c = -3 + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "balance": { + "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] }, + "columns": [{ + "name": "balance", + "values": [{ "prop": -2.0 }, { "prop": 5.0 }, { "prop": -3.0 }] + }] + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_local_clustering_coefficient_batch() { + let graph = Graph::new(); + // triangle a-b-c + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + localClusteringCoefficientBatch(nodes: ["a", "b"]) { + rows { + node { id } + entries { columnName value { ... on NodeStateProp { prop } } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // only the queried nodes are present; each is in a triangle -> coefficient 1.0 + let entry = |id: &str| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "lcc", "value": { "prop": 1.0 } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "localClusteringCoefficientBatch": { "rows": [ + entry("a"), + entry("b"), + ] } } } + }) + ); + } + fn components_test_graph() -> MaterializedGraph { let graph = Graph::new(); // cycle a -> b -> c -> a (one SCC), plus d -> a (d reaches the cycle but not vice versa) diff --git a/raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs b/raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs new file mode 100644 index 0000000000..6d8718366d --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs @@ -0,0 +1,19 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::metrics::reciprocity::all_local_reciprocity, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Local reciprocity of every node, see [`all_local_reciprocity`]. +pub(crate) struct GqlAllLocalReciprocity; + +pub(crate) struct GqlAllLocalReciprocityArgs; + +impl GqlExecutableAlgorithm for GqlAllLocalReciprocity { + type Args = GqlAllLocalReciprocityArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(all_local_reciprocity(graph).into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/balance.rs b/raphtory-graphql/src/model/algorithms/balance.rs new file mode 100644 index 0000000000..f69754bf7b --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/balance.rs @@ -0,0 +1,26 @@ +use crate::model::{ + algorithms::GqlExecutableAlgorithm, + graph::node_state::GqlNodeState, +}; +use raphtory::{ + algorithms::metrics::balance::balance, db::api::view::DynamicGraph, errors::GraphError, +}; +use crate::model::algorithms::GqlDirection; + +/// Net sum of edge weights per node, see [`balance`]. +pub(crate) struct GqlBalance; + +pub(crate) struct GqlBalanceArgs { + pub(crate) name: String, + pub(crate) direction: GqlDirection, +} + +impl GqlExecutableAlgorithm for GqlBalance { + type Args = GqlBalanceArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = balance(graph, args.name, args.direction.into())?; + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/dijkstra.rs b/raphtory-graphql/src/model/algorithms/dijkstra.rs index 89c077fdb6..868bcff687 100644 --- a/raphtory-graphql/src/model/algorithms/dijkstra.rs +++ b/raphtory-graphql/src/model/algorithms/dijkstra.rs @@ -1,29 +1,9 @@ use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; -use dynamic_graphql::Enum; use raphtory::{ algorithms::pathing::dijkstra::dijkstra_single_source_shortest_paths, db::api::view::DynamicGraph, errors::GraphError, }; -use raphtory_api::core::Direction; - -/// Edge direction to follow during traversal. -#[derive(Enum, Copy, Clone)] -#[graphql(name = "Direction")] -pub(crate) enum GqlDirection { - Out, - In, - Both, -} - -impl From for Direction { - fn from(direction: GqlDirection) -> Self { - match direction { - GqlDirection::Out => Direction::OUT, - GqlDirection::In => Direction::IN, - GqlDirection::Both => Direction::BOTH, - } - } -} +use crate::model::algorithms::GqlDirection; /// Weighted single source shortest paths (Dijkstra), see [`dijkstra_single_source_shortest_paths`]. pub(crate) struct GqlDijkstra; diff --git a/raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs b/raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs new file mode 100644 index 0000000000..d0d1ccb0ca --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs @@ -0,0 +1,21 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::metrics::clustering_coefficient::local_clustering_coefficient_batch::local_clustering_coefficient_batch, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Local clustering coefficient of the given nodes, see [`local_clustering_coefficient_batch`]. +pub(crate) struct GqlLocalClusteringCoefficientBatch; + +pub(crate) struct GqlLocalClusteringCoefficientBatchArgs { + pub(crate) nodes: Vec, +} + +impl GqlExecutableAlgorithm for GqlLocalClusteringCoefficientBatch { + type Args = GqlLocalClusteringCoefficientBatchArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + Ok(local_clustering_coefficient_batch(graph, args.nodes).into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 99b7cf910f..d96ff814e3 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -3,12 +3,17 @@ use crate::{ model::{ algorithms::{ + all_local_reciprocity::{GqlAllLocalReciprocity, GqlAllLocalReciprocityArgs}, + balance::{GqlBalance, GqlBalanceArgs}, betweenness_centrality::{GqlBetweennessCentrality, GqlBetweennessCentralityArgs}, degree_centrality::{GqlDegreeCentrality, GqlDegreeCentralityArgs}, - dijkstra::{GqlDijkstra, GqlDijkstraArgs, GqlDirection}, + dijkstra::{GqlDijkstra, GqlDijkstraArgs}, hits::{GqlHits, GqlHitsArgs}, in_components::{GqlInComponents, GqlInComponentsArgs}, label_propagation::{GqlLabelPropagation, GqlLabelPropagationArgs}, + local_clustering_coefficient_batch::{ + GqlLocalClusteringCoefficientBatch, GqlLocalClusteringCoefficientBatchArgs, + }, louvain::{GqlLouvain, GqlLouvainArgs}, out_components::{GqlOutComponents, GqlOutComponentsArgs}, pagerank::{GqlPagerank, GqlPagerankArgs}, @@ -26,15 +31,19 @@ use crate::{ }, rayon::blocking_compute, }; -use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; +use dynamic_graphql::{Enum, ResolvedObject, ResolvedObjectFields}; use raphtory::{db::api::view::DynamicGraph, errors::GraphError}; +use raphtory_api::core::Direction; +pub(crate) mod all_local_reciprocity; +pub(crate) mod balance; pub(crate) mod betweenness_centrality; pub(crate) mod degree_centrality; pub(crate) mod dijkstra; pub(crate) mod hits; pub(crate) mod in_components; pub(crate) mod label_propagation; +pub(crate) mod local_clustering_coefficient_batch; pub(crate) mod louvain; pub(crate) mod out_components; pub(crate) mod pagerank; @@ -67,6 +76,25 @@ impl From for GqlAlgorithms { } } +/// Edge direction to follow during traversal. +#[derive(Enum, Copy, Clone)] +#[graphql(name = "Direction")] +pub(crate) enum GqlDirection { + Out, + In, + Both, +} + +impl From for Direction { + fn from(direction: GqlDirection) -> Self { + match direction { + GqlDirection::Out => Direction::OUT, + GqlDirection::In => Direction::IN, + GqlDirection::Both => Direction::BOTH, + } + } +} + impl GqlAlgorithms { /// Runs algorithm `A` on the blocking thread pool. async fn run(&self, args: A::Args) -> Result { @@ -235,4 +263,38 @@ impl GqlAlgorithms { }) .await } + + /// Returns the local reciprocity of every node. + async fn all_local_reciprocity(&self) -> Result { + self.run::(GqlAllLocalReciprocityArgs) + .await + } + + /// Returns the net sum of edge weights (balance) of every node. + async fn balance( + &self, + #[graphql(desc = "Edge property to use as weight. Defaults to `weight`.")] name: Option< + String, + >, + #[graphql(desc = "Edge direction to consider. Defaults to BOTH.")] direction: Option< + GqlDirection, + >, + ) -> Result { + self.run::(GqlBalanceArgs { + name: name.unwrap_or_else(|| "weight".to_string()), + direction: direction.unwrap_or(GqlDirection::Both), + }) + .await + } + + /// Returns the local clustering coefficient of each of the given nodes. + async fn local_clustering_coefficient_batch( + &self, + #[graphql(desc = "Node ids to compute the coefficient for.")] nodes: Vec, + ) -> Result { + self.run::(GqlLocalClusteringCoefficientBatchArgs { + nodes, + }) + .await + } } From 43a18afb9d3ed7fc2761503d8b207d36a001d108 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Thu, 23 Jul 2026 05:04:28 -0400 Subject: [PATCH 20/39] Added cohesive_fruchterman_reingold, fast_rp, fruchterman_reingold_unbounded, temporal_three_node_motif, and temporally_reachable_nodes algorithms in GraphQL --- raphtory-graphql/src/lib.rs | 250 ++++++++++++++++++ .../src/model/algorithms/balance.rs | 3 +- .../cohesive_fruchterman_reingold.rs | 33 +++ .../src/model/algorithms/dijkstra.rs | 6 +- .../src/model/algorithms/fast_rp.rs | 32 +++ .../model/algorithms/fruchterman_reingold.rs | 33 +++ .../local_temporal_three_node_motifs.rs | 23 ++ raphtory-graphql/src/model/algorithms/mod.rs | 116 ++++++++ .../algorithms/temporally_reachable_nodes.rs | 33 +++ 9 files changed, 525 insertions(+), 4 deletions(-) create mode 100644 raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs create mode 100644 raphtory-graphql/src/model/algorithms/fast_rp.rs create mode 100644 raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs create mode 100644 raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs create mode 100644 raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 265b0470b9..cbb3a3a44e 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,256 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_fast_rp() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + fastRp(embeddingDim: 4, normalizationStrength: 1.0, iterWeights: [1.0, 1.0], seed: 42, threads: 1) { + columnNames + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // each embedding is a 4d vector (embeddingDim); values are deterministic given the seed + let row = |id: &str, embedding: [f64; 4]| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "embedding_state", "value": { "prop": embedding } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "fastRp": { + "columnNames": ["embedding_state"], + "rows": [ + row("a", [-0.9870555097143693, 0.3290185032381231, -1.6450925161906156, 0.0]), + row("b", [0.9870555097143693, 0.3290185032381231, -1.6450925161906156, -0.9870555097143693]), + row("c", [0.0, 1.3160740129524924, -0.6580370064762462, 0.9870555097143693]), + ] + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_temporally_reachable_nodes() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + temporallyReachableNodes(maxHops: 5, startTime: 0, seedNodes: ["a"], threads: 1) { + columnNames + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // each node is tainted by (time, source); tuples serialize as {"0": time, "1": source} + let row = |id: &str, taint: serde_json::Value| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "reachable_nodes", "value": { "prop": [taint] } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "temporallyReachableNodes": { + "columnNames": ["reachable_nodes"], + "rows": [ + row("a", json!({ "0": 0, "1": "start" })), + row("b", json!({ "0": 1, "1": "a" })), + row("c", json!({ "0": 2, "1": "b" })), + ] + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_fruchterman_reingold() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + fruchtermanReingold(iterCount: 1) { + columnNames + nodes { + list { id } + } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // layout positions are non-deterministic (random init, no seed), so assert on shape: + // two coordinate columns "0" (x) and "1" (y), each with one float per node. + let data = res.data.into_json().unwrap(); + let fr = &data["graph"]["algorithm"]["fruchtermanReingold"]; + assert_eq!(fr["columnNames"], json!(["0", "1"])); + assert_eq!( + fr["nodes"]["list"], + json!([{ "id": "a" }, { "id": "b" }, { "id": "c" }]) + ); + let columns = fr["columns"].as_array().unwrap(); + assert_eq!(columns.len(), 2); + for column in columns { + let values = column["values"].as_array().unwrap(); + assert_eq!(values.len(), 3); + assert!(values.iter().all(|v| v["prop"].is_number())); + } + } + + #[tokio::test] + async fn test_algorithm_cohesive_fruchterman_reingold() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + cohesiveFruchtermanReingold(iterCount: 1) { + columnNames + nodes { + list { id } + } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // layout positions are non-deterministic (random init, no seed), so assert on shape: + // two coordinate columns "0" (x) and "1" (y), each with one float per node. + let data = res.data.into_json().unwrap(); + let cfr = &data["graph"]["algorithm"]["cohesiveFruchtermanReingold"]; + assert_eq!(cfr["columnNames"], json!(["0", "1"])); + assert_eq!( + cfr["nodes"]["list"], + json!([{ "id": "a" }, { "id": "b" }, { "id": "c" }]) + ); + let columns = cfr["columns"].as_array().unwrap(); + assert_eq!(columns.len(), 2); + for column in columns { + let values = column["values"].as_array().unwrap(); + assert_eq!(values.len(), 3); + assert!(values.iter().all(|v| v["prop"].is_number())); + } + } + + #[tokio::test] + async fn test_algorithm_local_temporal_three_node_motifs() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + localTemporalThreeNodeMotifs(delta: 10) { + columnNames + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // each node gets a 40d motif-count vector; in this triangle each participates in motif 35 + let motif_counter = { + let mut v = vec![0; 40]; + v[35] = 1; + v + }; + let row = |id: &str| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "motif_counter", "value": { "prop": motif_counter } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "localTemporalThreeNodeMotifs": { + "columnNames": ["motif_counter"], + "rows": [row("a"), row("b"), row("c")] + } } } + }) + ); + } + #[tokio::test] async fn test_algorithm_all_local_reciprocity() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/algorithms/balance.rs b/raphtory-graphql/src/model/algorithms/balance.rs index f69754bf7b..fbe857bad5 100644 --- a/raphtory-graphql/src/model/algorithms/balance.rs +++ b/raphtory-graphql/src/model/algorithms/balance.rs @@ -1,11 +1,10 @@ use crate::model::{ - algorithms::GqlExecutableAlgorithm, + algorithms::{GqlDirection, GqlExecutableAlgorithm}, graph::node_state::GqlNodeState, }; use raphtory::{ algorithms::metrics::balance::balance, db::api::view::DynamicGraph, errors::GraphError, }; -use crate::model::algorithms::GqlDirection; /// Net sum of edge weights per node, see [`balance`]. pub(crate) struct GqlBalance; diff --git a/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs new file mode 100644 index 0000000000..91a71c0e78 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs @@ -0,0 +1,33 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::layout::cohesive_fruchterman_reingold::cohesive_fruchterman_reingold, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Cohesive Fruchterman-Reingold layout, see [`cohesive_fruchterman_reingold`]. +pub(crate) struct GqlCohesiveFruchtermanReingold; + +pub(crate) struct GqlCohesiveFruchtermanReingoldArgs { + pub(crate) iter_count: u64, + pub(crate) scale: f32, + pub(crate) node_start_size: f32, + pub(crate) cooloff_factor: f32, + pub(crate) dt: f32, +} + +impl GqlExecutableAlgorithm for GqlCohesiveFruchtermanReingold { + type Args = GqlCohesiveFruchtermanReingoldArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = cohesive_fruchterman_reingold( + graph, + args.iter_count, + args.scale, + args.node_start_size, + args.cooloff_factor, + args.dt, + ); + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/dijkstra.rs b/raphtory-graphql/src/model/algorithms/dijkstra.rs index 868bcff687..5d5287b293 100644 --- a/raphtory-graphql/src/model/algorithms/dijkstra.rs +++ b/raphtory-graphql/src/model/algorithms/dijkstra.rs @@ -1,9 +1,11 @@ -use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use crate::model::{ + algorithms::{GqlDirection, GqlExecutableAlgorithm}, + graph::node_state::GqlNodeState, +}; use raphtory::{ algorithms::pathing::dijkstra::dijkstra_single_source_shortest_paths, db::api::view::DynamicGraph, errors::GraphError, }; -use crate::model::algorithms::GqlDirection; /// Weighted single source shortest paths (Dijkstra), see [`dijkstra_single_source_shortest_paths`]. pub(crate) struct GqlDijkstra; diff --git a/raphtory-graphql/src/model/algorithms/fast_rp.rs b/raphtory-graphql/src/model/algorithms/fast_rp.rs new file mode 100644 index 0000000000..6575b92b30 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/fast_rp.rs @@ -0,0 +1,32 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::embeddings::fast_rp::fast_rp, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// FastRP node embeddings, see [`fast_rp`]. +pub(crate) struct GqlFastRp; + +pub(crate) struct GqlFastRpArgs { + pub(crate) embedding_dim: usize, + pub(crate) normalization_strength: f64, + pub(crate) iter_weights: Vec, + pub(crate) seed: Option, + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlFastRp { + type Args = GqlFastRpArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = fast_rp( + graph, + args.embedding_dim, + args.normalization_strength, + args.iter_weights, + args.seed, + args.threads, + ); + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs new file mode 100644 index 0000000000..c119009b6a --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs @@ -0,0 +1,33 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::layout::fruchterman_reingold::fruchterman_reingold_unbounded, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Fruchterman-Reingold layout, see [`fruchterman_reingold_unbounded`]. +pub(crate) struct GqlFruchtermanReingold; + +pub(crate) struct GqlFruchtermanReingoldArgs { + pub(crate) iter_count: u64, + pub(crate) scale: f32, + pub(crate) node_start_size: f32, + pub(crate) cooloff_factor: f32, + pub(crate) dt: f32, +} + +impl GqlExecutableAlgorithm for GqlFruchtermanReingold { + type Args = GqlFruchtermanReingoldArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = fruchterman_reingold_unbounded( + graph, + args.iter_count, + args.scale, + args.node_start_size, + args.cooloff_factor, + args.dt, + ); + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs b/raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs new file mode 100644 index 0000000000..0f6c1ab8d5 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs @@ -0,0 +1,23 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::motifs::local_temporal_three_node_motifs::temporal_three_node_motif, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Local temporal three-node motif counts, see [`temporal_three_node_motif`]. +pub(crate) struct GqlLocalTemporalThreeNodeMotifs; + +pub(crate) struct GqlLocalTemporalThreeNodeMotifsArgs { + pub(crate) delta: i64, + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlLocalTemporalThreeNodeMotifs { + type Args = GqlLocalTemporalThreeNodeMotifsArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = temporal_three_node_motif(graph, args.delta, args.threads); + Ok(state.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index d96ff814e3..594c1a97d7 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -6,14 +6,22 @@ use crate::{ all_local_reciprocity::{GqlAllLocalReciprocity, GqlAllLocalReciprocityArgs}, balance::{GqlBalance, GqlBalanceArgs}, betweenness_centrality::{GqlBetweennessCentrality, GqlBetweennessCentralityArgs}, + cohesive_fruchterman_reingold::{ + GqlCohesiveFruchtermanReingold, GqlCohesiveFruchtermanReingoldArgs, + }, degree_centrality::{GqlDegreeCentrality, GqlDegreeCentralityArgs}, dijkstra::{GqlDijkstra, GqlDijkstraArgs}, + fast_rp::{GqlFastRp, GqlFastRpArgs}, + fruchterman_reingold::{GqlFruchtermanReingold, GqlFruchtermanReingoldArgs}, hits::{GqlHits, GqlHitsArgs}, in_components::{GqlInComponents, GqlInComponentsArgs}, label_propagation::{GqlLabelPropagation, GqlLabelPropagationArgs}, local_clustering_coefficient_batch::{ GqlLocalClusteringCoefficientBatch, GqlLocalClusteringCoefficientBatchArgs, }, + local_temporal_three_node_motifs::{ + GqlLocalTemporalThreeNodeMotifs, GqlLocalTemporalThreeNodeMotifsArgs, + }, louvain::{GqlLouvain, GqlLouvainArgs}, out_components::{GqlOutComponents, GqlOutComponentsArgs}, pagerank::{GqlPagerank, GqlPagerankArgs}, @@ -23,6 +31,9 @@ use crate::{ strongly_connected_components::{ GqlStronglyConnectedComponents, GqlStronglyConnectedComponentsArgs, }, + temporally_reachable_nodes::{ + GqlTemporallyReachableNodes, GqlTemporallyReachableNodesArgs, + }, weakly_connected_components::{ GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, }, @@ -38,17 +49,22 @@ use raphtory_api::core::Direction; pub(crate) mod all_local_reciprocity; pub(crate) mod balance; pub(crate) mod betweenness_centrality; +pub(crate) mod cohesive_fruchterman_reingold; pub(crate) mod degree_centrality; pub(crate) mod dijkstra; +pub(crate) mod fast_rp; +pub(crate) mod fruchterman_reingold; pub(crate) mod hits; pub(crate) mod in_components; pub(crate) mod label_propagation; pub(crate) mod local_clustering_coefficient_batch; +pub(crate) mod local_temporal_three_node_motifs; pub(crate) mod louvain; pub(crate) mod out_components; pub(crate) mod pagerank; pub(crate) mod single_source_shortest_path; pub(crate) mod strongly_connected_components; +pub(crate) mod temporally_reachable_nodes; pub(crate) mod weakly_connected_components; /// A graph algorithm executable through the GraphQL API. @@ -297,4 +313,104 @@ impl GqlAlgorithms { }) .await } + + /// Returns the FastRP embedding of every node. + async fn fast_rp( + &self, + #[graphql(desc = "Dimension of the embedding.")] embedding_dim: usize, + #[graphql(desc = "Normalization strength applied to neighbour contributions.")] + normalization_strength: f64, + #[graphql(desc = "Weight of each iteration's contribution to the embedding.")] + iter_weights: Vec, + #[graphql(desc = "Seed for the rng. If unset, seeded from the OS.")] seed: Option, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlFastRpArgs { + embedding_dim, + normalization_strength, + iter_weights, + seed, + threads, + }) + .await + } + + /// Returns the nodes temporally reachable from `seedNodes` starting at `startTime`. + async fn temporally_reachable_nodes( + &self, + #[graphql(desc = "Maximum number of hops to traverse.")] max_hops: usize, + #[graphql(desc = "Time at which the traversal starts.")] start_time: i64, + #[graphql(desc = "Node ids to start from.")] seed_nodes: Vec, + #[graphql(desc = "Node ids that halt the traversal when reached.")] stop_nodes: Option< + Vec, + >, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlTemporallyReachableNodesArgs { + max_hops, + start_time, + seed_nodes, + stop_nodes, + threads, + }) + .await + } + + /// Returns the 2D layout position of every node (Fruchterman-Reingold). + async fn fruchterman_reingold( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 100.")] iter_count: Option, + #[graphql(desc = "Scale of the layout. Defaults to 1.0.")] scale: Option, + #[graphql(desc = "Initial node size. Defaults to 1.0.")] node_start_size: Option, + #[graphql(desc = "Cooloff factor. Defaults to 0.95.")] cooloff_factor: Option, + #[graphql(desc = "Time step. Defaults to 0.1.")] dt: Option, + ) -> Result { + self.run::(GqlFruchtermanReingoldArgs { + iter_count: iter_count.unwrap_or(100), + scale: scale.unwrap_or(1.0) as f32, + node_start_size: node_start_size.unwrap_or(1.0) as f32, + cooloff_factor: cooloff_factor.unwrap_or(0.95) as f32, + dt: dt.unwrap_or(0.1) as f32, + }) + .await + } + + /// Returns the 2D layout position of every node (cohesive Fruchterman-Reingold). + async fn cohesive_fruchterman_reingold( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 100.")] iter_count: Option, + #[graphql(desc = "Scale of the layout. Defaults to 1.0.")] scale: Option, + #[graphql(desc = "Initial node size. Defaults to 1.0.")] node_start_size: Option, + #[graphql(desc = "Cooloff factor. Defaults to 0.95.")] cooloff_factor: Option, + #[graphql(desc = "Time step. Defaults to 0.1.")] dt: Option, + ) -> Result { + self.run::(GqlCohesiveFruchtermanReingoldArgs { + iter_count: iter_count.unwrap_or(100), + scale: scale.unwrap_or(1.0) as f32, + node_start_size: node_start_size.unwrap_or(1.0) as f32, + cooloff_factor: cooloff_factor.unwrap_or(0.95) as f32, + dt: dt.unwrap_or(0.1) as f32, + }) + .await + } + + /// Returns the local temporal three-node motif counts of every node. + async fn local_temporal_three_node_motifs( + &self, + #[graphql(desc = "Maximum time difference between the first and last edge of a motif.")] + delta: i64, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlLocalTemporalThreeNodeMotifsArgs { + delta, + threads, + }) + .await + } } diff --git a/raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs b/raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs new file mode 100644 index 0000000000..3b0f174512 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs @@ -0,0 +1,33 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::pathing::temporal_reachability::temporally_reachable_nodes, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Temporally reachable nodes, see [`temporally_reachable_nodes`]. +pub(crate) struct GqlTemporallyReachableNodes; + +pub(crate) struct GqlTemporallyReachableNodesArgs { + pub(crate) max_hops: usize, + pub(crate) start_time: i64, + pub(crate) seed_nodes: Vec, + pub(crate) stop_nodes: Option>, + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlTemporallyReachableNodes { + type Args = GqlTemporallyReachableNodesArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let state = temporally_reachable_nodes( + graph, + args.threads, + args.max_hops, + args.start_time, + args.seed_nodes, + args.stop_nodes, + ); + Ok(state.into()) + } +} From be192b8c7e8112a9097d933f242f9f352103000b Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Thu, 23 Jul 2026 05:58:03 -0400 Subject: [PATCH 21/39] Adding in_component and out_component to GraphQL. They take a node as input as well as an optional node filter. We want to match python by making this a general filter, which doesn't currently exist in GraphQL. --- raphtory-graphql/src/lib.rs | 121 ++++++++++++++++++ .../src/model/algorithms/in_component.rs | 29 +++++ raphtory-graphql/src/model/algorithms/mod.rs | 51 +++++++- .../src/model/algorithms/out_component.rs | 29 +++++ 4 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 raphtory-graphql/src/model/algorithms/in_component.rs create mode 100644 raphtory-graphql/src/model/algorithms/out_component.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index cbb3a3a44e..6d953899a1 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -647,6 +647,127 @@ mod graphql_test { ); } + fn single_component_test_graph() -> MaterializedGraph { + let graph = Graph::new(); + // chain a -> b -> c -> d + for (src, dst) in [("a", "b"), ("b", "c"), ("c", "d")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); + } + graph.into() + } + + #[tokio::test] + async fn test_algorithm_out_component() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; + + // out component of a: nodes reachable following out-edges, keyed by distance + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a") { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // a reaches b (1), c (2), d (3); source itself is not included + let entry = |id: &str, distance| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "distance", "value": { "prop": distance } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { "rows": [ + entry("b", 1), + entry("c", 2), + entry("d", 3), + ] } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_in_component() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; + + // in component of d: nodes that can reach it, keyed by distance + let query = r#" + { + graph(path: "g") { + algorithm { + inComponent(node: "d") { + nodes { list { id } } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // a (3), b (2), c (1) can reach d; row order follows the key index (a, b, c) + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "inComponent": { + "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] }, + "columns": [{ + "name": "distance", + "values": [{ "prop": 3 }, { "prop": 2 }, { "prop": 1 }] + }] + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_out_component_filtered() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; + + // filter out node c; a can then only reach b (d becomes unreachable) + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } }) { + nodes { list { id } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // with c removed, a only reaches b (d is now unreachable) + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { + "nodes": { "list": [{ "id": "b" }] } + } } } + }) + ); + } + #[tokio::test] async fn test_algorithm_fast_rp() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/algorithms/in_component.rs b/raphtory-graphql/src/model/algorithms/in_component.rs new file mode 100644 index 0000000000..ec8f1faad0 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/in_component.rs @@ -0,0 +1,29 @@ +use crate::model::{ + algorithms::{filtered_view, GqlExecutableAlgorithm}, + graph::{filtering::GqlNodeFilter, node_id::GqlNodeId, node_state::GqlNodeState}, +}; +use raphtory::{ + algorithms::components::in_component, db::api::view::DynamicGraph, errors::GraphError, + prelude::*, +}; + +/// In component of a single node, see [`in_component`]. +pub(crate) struct GqlInComponent; + +pub(crate) struct GqlInComponentArgs { + pub(crate) node: GqlNodeId, + pub(crate) filter: Option, +} + +impl GqlExecutableAlgorithm for GqlInComponent { + type Args = GqlInComponentArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let view = filtered_view(graph, args.filter)?; + let node = view + .node(args.node.clone()) + .ok_or_else(|| GraphError::NodeMissingError(args.node.into()))?; + Ok(in_component(node).into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 594c1a97d7..a91e0391dd 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -14,6 +14,7 @@ use crate::{ fast_rp::{GqlFastRp, GqlFastRpArgs}, fruchterman_reingold::{GqlFruchtermanReingold, GqlFruchtermanReingoldArgs}, hits::{GqlHits, GqlHitsArgs}, + in_component::{GqlInComponent, GqlInComponentArgs}, in_components::{GqlInComponents, GqlInComponentsArgs}, label_propagation::{GqlLabelPropagation, GqlLabelPropagationArgs}, local_clustering_coefficient_batch::{ @@ -23,6 +24,7 @@ use crate::{ GqlLocalTemporalThreeNodeMotifs, GqlLocalTemporalThreeNodeMotifsArgs, }, louvain::{GqlLouvain, GqlLouvainArgs}, + out_component::{GqlOutComponent, GqlOutComponentArgs}, out_components::{GqlOutComponents, GqlOutComponentsArgs}, pagerank::{GqlPagerank, GqlPagerankArgs}, single_source_shortest_path::{ @@ -38,12 +40,18 @@ use crate::{ GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, }, }, - graph::node_state::GqlNodeState, + graph::{filtering::GqlNodeFilter, node_id::GqlNodeId, node_state::GqlNodeState}, }, rayon::blocking_compute, }; use dynamic_graphql::{Enum, ResolvedObject, ResolvedObjectFields}; -use raphtory::{db::api::view::DynamicGraph, errors::GraphError}; +use raphtory::{ + db::{ + api::view::{DynamicGraph, Filter, IntoDynamic}, + graph::views::filter::model::node_filter::CompositeNodeFilter, + }, + errors::GraphError, +}; use raphtory_api::core::Direction; pub(crate) mod all_local_reciprocity; @@ -55,11 +63,13 @@ pub(crate) mod dijkstra; pub(crate) mod fast_rp; pub(crate) mod fruchterman_reingold; pub(crate) mod hits; +pub(crate) mod in_component; pub(crate) mod in_components; pub(crate) mod label_propagation; pub(crate) mod local_clustering_coefficient_batch; pub(crate) mod local_temporal_three_node_motifs; pub(crate) mod louvain; +pub(crate) mod out_component; pub(crate) mod out_components; pub(crate) mod pagerank; pub(crate) mod single_source_shortest_path; @@ -111,6 +121,21 @@ impl From for Direction { } } +/// Applies an optional node filter, returning the filtered view (or the graph +/// unchanged if no filter is given). Mirrors `GqlGraph::filter_nodes`. +pub(crate) fn filtered_view( + graph: &DynamicGraph, + filter: Option, +) -> Result { + match filter { + Some(filter) => { + let filter: CompositeNodeFilter = filter.try_into()?; + Ok(graph.filter(filter)?.into_dynamic()) + } + None => Ok(graph.clone()), + } +} + impl GqlAlgorithms { /// Runs algorithm `A` on the blocking thread pool. async fn run(&self, args: A::Args) -> Result { @@ -213,6 +238,28 @@ impl GqlAlgorithms { .await } + /// Returns the in component of a single node (nodes that can reach it, with their distance). + async fn in_component( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + #[graphql(desc = "Optional node filter; the algorithm runs on the resulting view.")] + filter: Option, + ) -> Result { + self.run::(GqlInComponentArgs { node, filter }) + .await + } + + /// Returns the out component of a single node (nodes it can reach, with their distance). + async fn out_component( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + #[graphql(desc = "Optional node filter; the algorithm runs on the resulting view.")] + filter: Option, + ) -> Result { + self.run::(GqlOutComponentArgs { node, filter }) + .await + } + /// Returns the weakly connected component id of every node. async fn weakly_connected_components(&self) -> Result { self.run::(GqlWeaklyConnectedComponentsArgs) diff --git a/raphtory-graphql/src/model/algorithms/out_component.rs b/raphtory-graphql/src/model/algorithms/out_component.rs new file mode 100644 index 0000000000..7ca942ba96 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/out_component.rs @@ -0,0 +1,29 @@ +use crate::model::{ + algorithms::{filtered_view, GqlExecutableAlgorithm}, + graph::{filtering::GqlNodeFilter, node_id::GqlNodeId, node_state::GqlNodeState}, +}; +use raphtory::{ + algorithms::components::out_component, db::api::view::DynamicGraph, errors::GraphError, + prelude::*, +}; + +/// Out component of a single node, see [`out_component`]. +pub(crate) struct GqlOutComponent; + +pub(crate) struct GqlOutComponentArgs { + pub(crate) node: GqlNodeId, + pub(crate) filter: Option, +} + +impl GqlExecutableAlgorithm for GqlOutComponent { + type Args = GqlOutComponentArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let view = filtered_view(graph, args.filter)?; + let node = view + .node(args.node.clone()) + .ok_or_else(|| GraphError::NodeMissingError(args.node.into()))?; + Ok(out_component(node).into()) + } +} From 8e08fae4b8fb2a83368e40b15b7334c184b655db Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Fri, 24 Jul 2026 06:03:40 -0400 Subject: [PATCH 22/39] Added GqlViewFilter to GraphQL, which combines the three other filters (NodeFilter, EdgeFilter, GraphFilter). This allows a combination of all 3 filters to be passed as a single filter. Use them in in_component and out_component. --- raphtory-graphql/src/lib.rs | 200 +++++++++++++++++- .../src/model/algorithms/in_component.rs | 4 +- raphtory-graphql/src/model/algorithms/mod.rs | 46 ++-- .../src/model/algorithms/out_component.rs | 4 +- raphtory-graphql/src/model/graph/filtering.rs | 14 ++ 5 files changed, 244 insertions(+), 24 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 6d953899a1..2405ea54cb 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -659,7 +659,8 @@ mod graphql_test { #[tokio::test] async fn test_algorithm_out_component() { let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; + let setup = + setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; // out component of a: nodes reachable following out-edges, keyed by distance let query = r#" @@ -703,7 +704,8 @@ mod graphql_test { #[tokio::test] async fn test_algorithm_in_component() { let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; + let setup = + setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; // in component of d: nodes that can reach it, keyed by distance let query = r#" @@ -741,14 +743,15 @@ mod graphql_test { #[tokio::test] async fn test_algorithm_out_component_filtered() { let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; + let setup = + setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; - // filter out node c; a can then only reach b (d becomes unreachable) + // composite filter with a node filter removing c; a can then only reach b let query = r#" { graph(path: "g") { algorithm { - outComponent(node: "a", filter: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } }) { + outComponent(node: "a", filter: { nodes: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } }) { nodes { list { id } } } } @@ -768,6 +771,193 @@ mod graphql_test { ); } + fn star_test_graph() -> MaterializedGraph { + let graph = Graph::new(); + // star out of a: a -> b, a -> c, a -> d + for (src, dst) in [("a", "b"), ("a", "c"), ("a", "d")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); + } + graph.into() + } + + #[tokio::test] + async fn test_algorithm_out_component_node_filter_composed() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", star_test_graph())], tmp_dir.path()).await; + + // NodeFilter and: both clauses must apply. Dropping b AND c leaves only d + // in a's out component (dropping just one would leave two nodes). + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { nodes: { + and: [ + { node: { field: NODE_NAME, where: { ne: { str: "b" } } } }, + { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } + ] + } }) { + nodes { list { id } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { + "nodes": { "list": [{ "id": "d" }] } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_out_component_edge_filter_composed() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", star_test_graph())], tmp_dir.path()).await; + + // EdgeFilter and: both clauses must apply. Dropping edges to b AND to c + // leaves only a -> d, so a reaches only d. + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { edges: { + and: [ + { dst: { node: { field: NODE_NAME, where: { ne: { str: "b" } } } } }, + { dst: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } } + ] + } }) { + nodes { list { id } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { + "nodes": { "list": [{ "id": "d" }] } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_out_component_graph_filter_composed() { + let graph = Graph::new(); + // edges at increasing times so a graph-view window changes reachability + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // GraphFilter composes via nested `expr`: window [1,3) then a further before(2). + // Only the a -> b edge (t=1) remains, so a reaches only b. + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { graph: { + window: { start: 1, end: 3, expr: { before: { time: 2 } } } + } }) { + nodes { list { id } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { + "nodes": { "list": [{ "id": "b" }] } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_out_component_filter_equivalence() { + let tmp_dir = tempdir().unwrap(); + let setup = + setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; + + // filter passed as an algorithm argument + let as_argument = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { nodes: { + node: { field: NODE_NAME, where: { ne: { str: "c" } } } + } }) { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + // same filter applied to the graph view before calling the algorithm (no argument) + let pre_filtered = r#" + { + graph(path: "g") { + filterNodes(expr: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } }) { + algorithm { + outComponent(node: "a") { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + } + "#; + + let arg_res = setup.schema.execute(Request::new(as_argument)).await; + assert_eq!(arg_res.errors, vec![], "{:?}", arg_res.errors); + let pre_res = setup.schema.execute(Request::new(pre_filtered)).await; + assert_eq!(pre_res.errors, vec![], "{:?}", pre_res.errors); + + // both routes reach the same result (b -> unwrap the identical outComponent payload) + let arg_out = + arg_res.data.into_json().unwrap()["graph"]["algorithm"]["outComponent"].clone(); + let pre_out = pre_res.data.into_json().unwrap()["graph"]["filterNodes"]["algorithm"] + ["outComponent"] + .clone(); + assert_eq!(arg_out, pre_out); + assert_eq!( + arg_out, + json!({ + "rows": [{ + "node": { "id": "b" }, + "entries": [{ "columnName": "distance", "value": { "prop": 1 } }] + }] + }) + ); + } + #[tokio::test] async fn test_algorithm_fast_rp() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/algorithms/in_component.rs b/raphtory-graphql/src/model/algorithms/in_component.rs index ec8f1faad0..2fbb2ce390 100644 --- a/raphtory-graphql/src/model/algorithms/in_component.rs +++ b/raphtory-graphql/src/model/algorithms/in_component.rs @@ -1,6 +1,6 @@ use crate::model::{ algorithms::{filtered_view, GqlExecutableAlgorithm}, - graph::{filtering::GqlNodeFilter, node_id::GqlNodeId, node_state::GqlNodeState}, + graph::{filtering::GqlViewFilter, node_id::GqlNodeId, node_state::GqlNodeState}, }; use raphtory::{ algorithms::components::in_component, db::api::view::DynamicGraph, errors::GraphError, @@ -12,7 +12,7 @@ pub(crate) struct GqlInComponent; pub(crate) struct GqlInComponentArgs { pub(crate) node: GqlNodeId, - pub(crate) filter: Option, + pub(crate) filter: Option, } impl GqlExecutableAlgorithm for GqlInComponent { diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index a91e0391dd..8f7de530d1 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -40,7 +40,7 @@ use crate::{ GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, }, }, - graph::{filtering::GqlNodeFilter, node_id::GqlNodeId, node_state::GqlNodeState}, + graph::{filtering::GqlViewFilter, node_id::GqlNodeId, node_state::GqlNodeState}, }, rayon::blocking_compute, }; @@ -48,7 +48,9 @@ use dynamic_graphql::{Enum, ResolvedObject, ResolvedObjectFields}; use raphtory::{ db::{ api::view::{DynamicGraph, Filter, IntoDynamic}, - graph::views::filter::model::node_filter::CompositeNodeFilter, + graph::views::filter::model::{ + edge_filter::CompositeEdgeFilter, node_filter::CompositeNodeFilter, DynView, + }, }, errors::GraphError, }; @@ -121,19 +123,29 @@ impl From for Direction { } } -/// Applies an optional node filter, returning the filtered view (or the graph -/// unchanged if no filter is given). Mirrors `GqlGraph::filter_nodes`. +/// Applies an optional composite filter, returning the filtered view (or the +/// graph unchanged if no filter is given). pub(crate) fn filtered_view( graph: &DynamicGraph, - filter: Option, + filter: Option, ) -> Result { - match filter { - Some(filter) => { - let filter: CompositeNodeFilter = filter.try_into()?; - Ok(graph.filter(filter)?.into_dynamic()) - } - None => Ok(graph.clone()), + let Some(filter) = filter else { + return Ok(graph.clone()); + }; + let mut graph = graph.clone(); + if let Some(nodes) = filter.nodes { + let nodes: CompositeNodeFilter = nodes.try_into()?; + graph = graph.filter(nodes)?.into_dynamic(); + } + if let Some(edges) = filter.edges { + let edges: CompositeEdgeFilter = edges.try_into()?; + graph = graph.filter(edges)?.into_dynamic(); + } + if let Some(view) = filter.graph { + let view: DynView = view.try_into()?; + graph = graph.filter(view)?.into_dynamic(); } + Ok(graph) } impl GqlAlgorithms { @@ -242,8 +254,10 @@ impl GqlAlgorithms { async fn in_component( &self, #[graphql(desc = "Node id.")] node: GqlNodeId, - #[graphql(desc = "Optional node filter; the algorithm runs on the resulting view.")] - filter: Option, + #[graphql( + desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." + )] + filter: Option, ) -> Result { self.run::(GqlInComponentArgs { node, filter }) .await @@ -253,8 +267,10 @@ impl GqlAlgorithms { async fn out_component( &self, #[graphql(desc = "Node id.")] node: GqlNodeId, - #[graphql(desc = "Optional node filter; the algorithm runs on the resulting view.")] - filter: Option, + #[graphql( + desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." + )] + filter: Option, ) -> Result { self.run::(GqlOutComponentArgs { node, filter }) .await diff --git a/raphtory-graphql/src/model/algorithms/out_component.rs b/raphtory-graphql/src/model/algorithms/out_component.rs index 7ca942ba96..2490c25d56 100644 --- a/raphtory-graphql/src/model/algorithms/out_component.rs +++ b/raphtory-graphql/src/model/algorithms/out_component.rs @@ -1,6 +1,6 @@ use crate::model::{ algorithms::{filtered_view, GqlExecutableAlgorithm}, - graph::{filtering::GqlNodeFilter, node_id::GqlNodeId, node_state::GqlNodeState}, + graph::{filtering::GqlViewFilter, node_id::GqlNodeId, node_state::GqlNodeState}, }; use raphtory::{ algorithms::components::out_component, db::api::view::DynamicGraph, errors::GraphError, @@ -12,7 +12,7 @@ pub(crate) struct GqlOutComponent; pub(crate) struct GqlOutComponentArgs { pub(crate) node: GqlNodeId, - pub(crate) filter: Option, + pub(crate) filter: Option, } impl GqlExecutableAlgorithm for GqlOutComponent { diff --git a/raphtory-graphql/src/model/graph/filtering.rs b/raphtory-graphql/src/model/graph/filtering.rs index 2ab6872c9c..a6d503f7dc 100644 --- a/raphtory-graphql/src/model/graph/filtering.rs +++ b/raphtory-graphql/src/model/graph/filtering.rs @@ -591,6 +591,20 @@ pub enum GqlGraphFilter { Layers(GraphLayersExpr), } +/// A composite filter producing a graph view, bundling the graph-view, node, +/// and edge filters that are otherwise applied via the separate +/// `filter` / `filterNodes` / `filterEdges` resolvers. +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] +#[graphql(name = "ViewFilter")] +pub struct GqlViewFilter { + /// Graph-view filter (time windows, snapshots, layers). + pub graph: Option, + /// Node filter (field, property, metadata, degree; composes with and/or/not). + pub nodes: Option, + /// Edge filter (src/dst, property, layer; composes with and/or/not). + pub edges: Option, +} + /// Boolean expression over a built-in node field (ID, name, or type). /// /// This is used by `NodeFieldFilterNew.where_` when filtering a specific From 0cbd4d1e0da20d7543223d010a77a01c059f8d71 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Mon, 27 Jul 2026 05:40:18 -0400 Subject: [PATCH 23/39] Updating f32 algorithms to use f64 --- raphtory-graphql/src/lib.rs | 4 +- .../cohesive_fruchterman_reingold.rs | 8 ++-- .../model/algorithms/fruchterman_reingold.rs | 8 ++-- raphtory-graphql/src/model/algorithms/mod.rs | 16 ++++---- raphtory/src/algorithms/centrality/hits.rs | 22 +++++------ .../layout/cohesive_fruchterman_reingold.rs | 10 ++--- .../algorithms/layout/fruchterman_reingold.rs | 38 +++++++++---------- raphtory/src/algorithms/layout/mod.rs | 4 +- raphtory/src/python/packages/algorithms.rs | 16 ++++---- 9 files changed, 63 insertions(+), 63 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 2405ea54cb..abef0189ce 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1672,8 +1672,8 @@ mod graphql_test { let res = setup.schema.execute(Request::new(query)).await; assert_eq!(res.errors, vec![], "{:?}", res.errors); - // f32 scores promoted to f64; source has no auth, sink has no hub - let s = 0.3333333432674408; + // source has no auth, sink has no hub + let s = 0.3333333333333333; let row = |id: &str, hub, auth| { json!({ "node": { "id": id }, diff --git a/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs index 91a71c0e78..f4e0264b90 100644 --- a/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs +++ b/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs @@ -9,10 +9,10 @@ pub(crate) struct GqlCohesiveFruchtermanReingold; pub(crate) struct GqlCohesiveFruchtermanReingoldArgs { pub(crate) iter_count: u64, - pub(crate) scale: f32, - pub(crate) node_start_size: f32, - pub(crate) cooloff_factor: f32, - pub(crate) dt: f32, + pub(crate) scale: f64, + pub(crate) node_start_size: f64, + pub(crate) cooloff_factor: f64, + pub(crate) dt: f64, } impl GqlExecutableAlgorithm for GqlCohesiveFruchtermanReingold { diff --git a/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs index c119009b6a..2c5420dbb5 100644 --- a/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs +++ b/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs @@ -9,10 +9,10 @@ pub(crate) struct GqlFruchtermanReingold; pub(crate) struct GqlFruchtermanReingoldArgs { pub(crate) iter_count: u64, - pub(crate) scale: f32, - pub(crate) node_start_size: f32, - pub(crate) cooloff_factor: f32, - pub(crate) dt: f32, + pub(crate) scale: f64, + pub(crate) node_start_size: f64, + pub(crate) cooloff_factor: f64, + pub(crate) dt: f64, } impl GqlExecutableAlgorithm for GqlFruchtermanReingold { diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 8f7de530d1..776c3e92eb 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -434,10 +434,10 @@ impl GqlAlgorithms { ) -> Result { self.run::(GqlFruchtermanReingoldArgs { iter_count: iter_count.unwrap_or(100), - scale: scale.unwrap_or(1.0) as f32, - node_start_size: node_start_size.unwrap_or(1.0) as f32, - cooloff_factor: cooloff_factor.unwrap_or(0.95) as f32, - dt: dt.unwrap_or(0.1) as f32, + scale: scale.unwrap_or(1.0), + node_start_size: node_start_size.unwrap_or(1.0), + cooloff_factor: cooloff_factor.unwrap_or(0.95), + dt: dt.unwrap_or(0.1), }) .await } @@ -453,10 +453,10 @@ impl GqlAlgorithms { ) -> Result { self.run::(GqlCohesiveFruchtermanReingoldArgs { iter_count: iter_count.unwrap_or(100), - scale: scale.unwrap_or(1.0) as f32, - node_start_size: node_start_size.unwrap_or(1.0) as f32, - cooloff_factor: cooloff_factor.unwrap_or(0.95) as f32, - dt: dt.unwrap_or(0.1) as f32, + scale: scale.unwrap_or(1.0), + node_start_size: node_start_size.unwrap_or(1.0), + cooloff_factor: cooloff_factor.unwrap_or(0.95), + dt: dt.unwrap_or(0.1), }) .await } diff --git a/raphtory/src/algorithms/centrality/hits.rs b/raphtory/src/algorithms/centrality/hits.rs index bf5155132e..43d424a03a 100644 --- a/raphtory/src/algorithms/centrality/hits.rs +++ b/raphtory/src/algorithms/centrality/hits.rs @@ -21,15 +21,15 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] pub struct Hits { - pub hub_score: f32, - pub auth_score: f32, + pub hub_score: f64, + pub auth_score: f64, } impl Default for Hits { fn default() -> Self { Self { - hub_score: 1f32, - auth_score: 1f32, + hub_score: 1f64, + auth_score: 1f64, } } } @@ -57,14 +57,14 @@ pub fn hits( ) -> TypedNodeState<'static, Hits, G> { let mut ctx: Context = g.into(); - let recv_hub_score = sum::(2); - let recv_auth_score = sum::(3); + let recv_hub_score = sum::(2); + let recv_auth_score = sum::(3); - let total_hub_score = sum::(4); - let total_auth_score = sum::(5); + let total_hub_score = sum::(4); + let total_auth_score = sum::(5); - let max_diff_hub_score = max::(6); - let max_diff_auth_score = max::(7); + let max_diff_hub_score = max::(6); + let max_diff_auth_score = max::(7); ctx.agg(recv_hub_score); ctx.agg(recv_auth_score); @@ -123,7 +123,7 @@ pub fn hits( Step::Continue }); - let max_diff_hs = 0.01f32; + let max_diff_hs = 0.01f64; let max_diff_as = max_diff_hs; let step5 = Job::Check(Box::new(move |state| { diff --git a/raphtory/src/algorithms/layout/cohesive_fruchterman_reingold.rs b/raphtory/src/algorithms/layout/cohesive_fruchterman_reingold.rs index 28a7a5cb8e..c5a36adbc2 100644 --- a/raphtory/src/algorithms/layout/cohesive_fruchterman_reingold.rs +++ b/raphtory/src/algorithms/layout/cohesive_fruchterman_reingold.rs @@ -20,15 +20,15 @@ use crate::{ /// /// # Returns /// -/// An [AlgorithmResult] containing a mapping between vertices and a [Vec2] of coordinates. +/// An [AlgorithmResult] containing a mapping between vertices and a [DVec2] of coordinates. /// pub fn cohesive_fruchterman_reingold<'graph, G: GraphViewOps<'graph>>( g: &G, iter_count: u64, - scale: f32, - node_start_size: f32, - cooloff_factor: f32, - dt: f32, + scale: f64, + node_start_size: f64, + cooloff_factor: f64, + dt: f64, ) -> TypedNodeState<'graph, CoordinateState, G> { let virtual_graph = g.materialize().unwrap(); diff --git a/raphtory/src/algorithms/layout/fruchterman_reingold.rs b/raphtory/src/algorithms/layout/fruchterman_reingold.rs index 1ae04e8286..d14e9e6030 100644 --- a/raphtory/src/algorithms/layout/fruchterman_reingold.rs +++ b/raphtory/src/algorithms/layout/fruchterman_reingold.rs @@ -3,7 +3,7 @@ use crate::{ db::api::state::{GenericNodeState, TypedNodeState}, prelude::{GraphViewOps, NodeViewOps}, }; -use glam::Vec2; +use glam::DVec2; use quad_rand::RandomRange; use raphtory_api::core::entities::GID; use rayon::prelude::*; @@ -11,17 +11,17 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Serialize, Deserialize, Debug, Default)] pub struct CoordinateState { - coordinates: [f32; 2], + coordinates: [f64; 2], } /// Return the position of the nodes after running Fruchterman Reingold algorithm on the `graph` pub fn fruchterman_reingold_unbounded<'graph, G: GraphViewOps<'graph>>( g: &G, iter_count: u64, - scale: f32, - node_start_size: f32, - cooloff_factor: f32, - dt: f32, + scale: f64, + node_start_size: f64, + cooloff_factor: f64, + dt: f64, ) -> TypedNodeState<'graph, CoordinateState, G> { let mut positions = init_positions(g, node_start_size); let mut velocities = init_velocities(g); @@ -43,15 +43,15 @@ fn update_positions<'graph, G: GraphViewOps<'graph>>( old_positions: &NodeVectors, velocities: &mut NodeVectors, graph: &G, - scale: f32, - cooloff_factor: f32, - dt: f32, + scale: f64, + cooloff_factor: f64, + dt: f64, ) -> NodeVectors { let mut new_positions: NodeVectors = NodeVectors::default(); for (id, old_position) in old_positions { // force that will be applied to the node - let mut force = Vec2::ZERO; + let mut force = DVec2::ZERO; force += compute_repulsion(id, scale, old_positions); force += compute_attraction(id, scale, old_positions, graph); @@ -67,8 +67,8 @@ fn update_positions<'graph, G: GraphViewOps<'graph>>( new_positions } -fn compute_repulsion(id: &GID, scale: f32, old_positions: &NodeVectors) -> Vec2 { - let mut force = Vec2::ZERO; +fn compute_repulsion(id: &GID, scale: f64, old_positions: &NodeVectors) -> DVec2 { + let mut force = DVec2::ZERO; let position = old_positions.get(id).unwrap(); for (alt_id, alt_position) in old_positions { @@ -83,11 +83,11 @@ fn compute_repulsion(id: &GID, scale: f32, old_positions: &NodeVectors) -> Vec2 fn compute_attraction<'graph, G: GraphViewOps<'graph>>( id: &GID, - scale: f32, + scale: f64, old_positions: &NodeVectors, graph: &G, -) -> Vec2 { - let mut force = Vec2::ZERO; +) -> DVec2 { + let mut force = DVec2::ZERO; let node = graph.node(id).unwrap(); let position = old_positions.get(id).unwrap(); @@ -100,7 +100,7 @@ fn compute_attraction<'graph, G: GraphViewOps<'graph>>( force } -fn unit_vector(a: Vec2, b: Vec2) -> Vec2 { +fn unit_vector(a: DVec2, b: DVec2) -> DVec2 { (b - a).normalize_or_zero() } @@ -108,17 +108,17 @@ fn init_velocities<'graph, G: GraphViewOps<'graph>>(graph: &G) -> NodeVectors { graph .nodes() .iter() - .map(|node| (node.id(), Vec2::ZERO)) + .map(|node| (node.id(), DVec2::ZERO)) .collect() } -fn init_positions<'graph, G: GraphViewOps<'graph>>(graph: &G, node_start_size: f32) -> NodeVectors { +fn init_positions<'graph, G: GraphViewOps<'graph>>(graph: &G, node_start_size: f64) -> NodeVectors { let half_node_start_width = node_start_size / 2.0; graph .nodes() .iter() .map(|node| { - let position = Vec2::new( + let position = DVec2::new( RandomRange::gen_range(-half_node_start_width, half_node_start_width), RandomRange::gen_range(-half_node_start_width, half_node_start_width), ); diff --git a/raphtory/src/algorithms/layout/mod.rs b/raphtory/src/algorithms/layout/mod.rs index 2e3060c9cf..6d64106e78 100644 --- a/raphtory/src/algorithms/layout/mod.rs +++ b/raphtory/src/algorithms/layout/mod.rs @@ -1,8 +1,8 @@ -use glam::Vec2; +use glam::DVec2; use raphtory_api::core::entities::GID; use std::collections::HashMap; pub mod cohesive_fruchterman_reingold; pub mod fruchterman_reingold; -pub type NodeVectors = HashMap; +pub type NodeVectors = HashMap; diff --git a/raphtory/src/python/packages/algorithms.rs b/raphtory/src/python/packages/algorithms.rs index f15cb5ff8d..112357af64 100644 --- a/raphtory/src/python/packages/algorithms.rs +++ b/raphtory/src/python/packages/algorithms.rs @@ -916,10 +916,10 @@ pub fn louvain( pub fn fruchterman_reingold( graph: &PyGraphView, iterations: u64, - scale: f32, - node_start_size: f32, - cooloff_factor: f32, - dt: f32, + scale: f64, + node_start_size: f64, + cooloff_factor: f64, + dt: f64, ) -> OutputTypedNodeState<'static, DynamicGraph> { fruchterman_reingold_rs( &graph.graph, @@ -949,10 +949,10 @@ pub fn fruchterman_reingold( pub fn cohesive_fruchterman_reingold( graph: &PyGraphView, iter_count: u64, - scale: f32, - node_start_size: f32, - cooloff_factor: f32, - dt: f32, + scale: f64, + node_start_size: f64, + cooloff_factor: f64, + dt: f64, ) -> OutputTypedNodeState<'static, DynamicGraph> { cohesive_fruchterman_reingold_rs( &graph.graph, From 3d20e6b759c78c196481897614f1efa09605db3d Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Mon, 27 Jul 2026 06:13:40 -0400 Subject: [PATCH 24/39] Added average_degree, directed_graph_density, global_clustering_coefficient, global_reciprocity algorithms in GraphQL --- raphtory-graphql/src/lib.rs | 43 +++++++++++++++++++ .../src/model/algorithms/average_degree.rs | 18 ++++++++ .../algorithms/directed_graph_density.rs | 19 ++++++++ .../global_clustering_coefficient.rs | 19 ++++++++ .../model/algorithms/global_reciprocity.rs | 19 ++++++++ raphtory-graphql/src/model/algorithms/mod.rs | 33 ++++++++++++++ 6 files changed, 151 insertions(+) create mode 100644 raphtory-graphql/src/model/algorithms/average_degree.rs create mode 100644 raphtory-graphql/src/model/algorithms/directed_graph_density.rs create mode 100644 raphtory-graphql/src/model/algorithms/global_clustering_coefficient.rs create mode 100644 raphtory-graphql/src/model/algorithms/global_reciprocity.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index abef0189ce..c5e1cb4077 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1549,6 +1549,49 @@ mod graphql_test { ); } + fn scalar_metrics_test_graph() -> MaterializedGraph { + let graph = Graph::new(); + // a <-> b reciprocated, b -> c -> a forming a triangle with a-b, and c -> d as a pendant edge, + // so density/reciprocity/clustering/degree are all non-trivial + for (src, dst) in [("a", "b"), ("b", "a"), ("b", "c"), ("c", "a"), ("c", "d")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); + } + graph.into() + } + + #[tokio::test] + async fn test_algorithm_scalar_metrics() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + globalClusteringCoefficient + directedGraphDensity + globalReciprocity + averageDegree + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { + "globalClusteringCoefficient": 0.6, + "directedGraphDensity": 0.4166666666666667, + "globalReciprocity": 0.4, + "averageDegree": 2.0 + } } + }) + ); + } + fn centrality_test_graph() -> MaterializedGraph { let graph = Graph::new(); // path a -> b -> c -> d so nodes get distinct centrality scores diff --git a/raphtory-graphql/src/model/algorithms/average_degree.rs b/raphtory-graphql/src/model/algorithms/average_degree.rs new file mode 100644 index 0000000000..48cf289ce7 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/average_degree.rs @@ -0,0 +1,18 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::degree::average_degree, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Average node degree, see [`average_degree`]. +pub(crate) struct GqlAverageDegree; + +pub(crate) struct GqlAverageDegreeArgs; + +impl GqlExecutableAlgorithm for GqlAverageDegree { + type Args = GqlAverageDegreeArgs; + type Output = f64; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(average_degree(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/directed_graph_density.rs b/raphtory-graphql/src/model/algorithms/directed_graph_density.rs new file mode 100644 index 0000000000..338d1058ba --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/directed_graph_density.rs @@ -0,0 +1,19 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::directed_graph_density::directed_graph_density, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Directed graph density, see [`directed_graph_density`]. +pub(crate) struct GqlDirectedGraphDensity; + +pub(crate) struct GqlDirectedGraphDensityArgs; + +impl GqlExecutableAlgorithm for GqlDirectedGraphDensity { + type Args = GqlDirectedGraphDensityArgs; + type Output = f64; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(directed_graph_density(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/global_clustering_coefficient.rs b/raphtory-graphql/src/model/algorithms/global_clustering_coefficient.rs new file mode 100644 index 0000000000..b60a28cfc7 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/global_clustering_coefficient.rs @@ -0,0 +1,19 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::clustering_coefficient::global_clustering_coefficient::global_clustering_coefficient, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Global clustering coefficient, see [`global_clustering_coefficient`]. +pub(crate) struct GqlGlobalClusteringCoefficient; + +pub(crate) struct GqlGlobalClusteringCoefficientArgs; + +impl GqlExecutableAlgorithm for GqlGlobalClusteringCoefficient { + type Args = GqlGlobalClusteringCoefficientArgs; + type Output = f64; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(global_clustering_coefficient(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/global_reciprocity.rs b/raphtory-graphql/src/model/algorithms/global_reciprocity.rs new file mode 100644 index 0000000000..9e9ca0da29 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/global_reciprocity.rs @@ -0,0 +1,19 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::reciprocity::global_reciprocity, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Global reciprocity, see [`global_reciprocity`]. +pub(crate) struct GqlGlobalReciprocity; + +pub(crate) struct GqlGlobalReciprocityArgs; + +impl GqlExecutableAlgorithm for GqlGlobalReciprocity { + type Args = GqlGlobalReciprocityArgs; + type Output = f64; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(global_reciprocity(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 776c3e92eb..9ee6723e80 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -4,6 +4,7 @@ use crate::{ model::{ algorithms::{ all_local_reciprocity::{GqlAllLocalReciprocity, GqlAllLocalReciprocityArgs}, + average_degree::{GqlAverageDegree, GqlAverageDegreeArgs}, balance::{GqlBalance, GqlBalanceArgs}, betweenness_centrality::{GqlBetweennessCentrality, GqlBetweennessCentralityArgs}, cohesive_fruchterman_reingold::{ @@ -11,8 +12,13 @@ use crate::{ }, degree_centrality::{GqlDegreeCentrality, GqlDegreeCentralityArgs}, dijkstra::{GqlDijkstra, GqlDijkstraArgs}, + directed_graph_density::{GqlDirectedGraphDensity, GqlDirectedGraphDensityArgs}, fast_rp::{GqlFastRp, GqlFastRpArgs}, fruchterman_reingold::{GqlFruchtermanReingold, GqlFruchtermanReingoldArgs}, + global_clustering_coefficient::{ + GqlGlobalClusteringCoefficient, GqlGlobalClusteringCoefficientArgs, + }, + global_reciprocity::{GqlGlobalReciprocity, GqlGlobalReciprocityArgs}, hits::{GqlHits, GqlHitsArgs}, in_component::{GqlInComponent, GqlInComponentArgs}, in_components::{GqlInComponents, GqlInComponentsArgs}, @@ -57,13 +63,17 @@ use raphtory::{ use raphtory_api::core::Direction; pub(crate) mod all_local_reciprocity; +pub(crate) mod average_degree; pub(crate) mod balance; pub(crate) mod betweenness_centrality; pub(crate) mod cohesive_fruchterman_reingold; pub(crate) mod degree_centrality; pub(crate) mod dijkstra; +pub(crate) mod directed_graph_density; pub(crate) mod fast_rp; pub(crate) mod fruchterman_reingold; +pub(crate) mod global_clustering_coefficient; +pub(crate) mod global_reciprocity; pub(crate) mod hits; pub(crate) mod in_component; pub(crate) mod in_components; @@ -377,6 +387,29 @@ impl GqlAlgorithms { .await } + /// Returns the global clustering coefficient of the graph. + async fn global_clustering_coefficient(&self) -> Result { + self.run::(GqlGlobalClusteringCoefficientArgs) + .await + } + + /// Returns the directed graph density (fraction of possible directed edges present). + async fn directed_graph_density(&self) -> Result { + self.run::(GqlDirectedGraphDensityArgs) + .await + } + + /// Returns the global reciprocity of the graph. + async fn global_reciprocity(&self) -> Result { + self.run::(GqlGlobalReciprocityArgs) + .await + } + + /// Returns the average (undirected) degree of the graph's nodes. + async fn average_degree(&self) -> Result { + self.run::(GqlAverageDegreeArgs).await + } + /// Returns the FastRP embedding of every node. async fn fast_rp( &self, From 72ab6480cff33f99770ffe0f5c4907156afd4a00 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Mon, 27 Jul 2026 06:23:38 -0400 Subject: [PATCH 25/39] Added max_degree, max_in_degree, max_out_degree, min_degree, min_in_degree, min_out_degree, triangle_count, and triplet_count algorithms in GraphQL --- raphtory-graphql/src/lib.rs | 18 ++++- .../src/model/algorithms/max_degree.rs | 18 +++++ .../src/model/algorithms/max_in_degree.rs | 18 +++++ .../src/model/algorithms/max_out_degree.rs | 18 +++++ .../src/model/algorithms/min_degree.rs | 18 +++++ .../src/model/algorithms/min_in_degree.rs | 18 +++++ .../src/model/algorithms/min_out_degree.rs | 18 +++++ raphtory-graphql/src/model/algorithms/mod.rs | 68 +++++++++++++++++++ .../src/model/algorithms/triangle_count.rs | 21 ++++++ .../src/model/algorithms/triplet_count.rs | 21 ++++++ 10 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 raphtory-graphql/src/model/algorithms/max_degree.rs create mode 100644 raphtory-graphql/src/model/algorithms/max_in_degree.rs create mode 100644 raphtory-graphql/src/model/algorithms/max_out_degree.rs create mode 100644 raphtory-graphql/src/model/algorithms/min_degree.rs create mode 100644 raphtory-graphql/src/model/algorithms/min_in_degree.rs create mode 100644 raphtory-graphql/src/model/algorithms/min_out_degree.rs create mode 100644 raphtory-graphql/src/model/algorithms/triangle_count.rs create mode 100644 raphtory-graphql/src/model/algorithms/triplet_count.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index c5e1cb4077..ded181de2a 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1572,6 +1572,14 @@ mod graphql_test { directedGraphDensity globalReciprocity averageDegree + maxDegree + minDegree + maxOutDegree + maxInDegree + minOutDegree + minInDegree + tripletCount + triangleCount } } } @@ -1586,7 +1594,15 @@ mod graphql_test { "globalClusteringCoefficient": 0.6, "directedGraphDensity": 0.4166666666666667, "globalReciprocity": 0.4, - "averageDegree": 2.0 + "averageDegree": 2.0, + "maxDegree": 3, + "minDegree": 1, + "maxOutDegree": 2, + "maxInDegree": 2, + "minOutDegree": 0, + "minInDegree": 1, + "tripletCount": 5, + "triangleCount": 1 } } }) ); diff --git a/raphtory-graphql/src/model/algorithms/max_degree.rs b/raphtory-graphql/src/model/algorithms/max_degree.rs new file mode 100644 index 0000000000..acb497a6f9 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/max_degree.rs @@ -0,0 +1,18 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::degree::max_degree, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Maximum node degree, see [`max_degree`]. +pub(crate) struct GqlMaxDegree; + +pub(crate) struct GqlMaxDegreeArgs; + +impl GqlExecutableAlgorithm for GqlMaxDegree { + type Args = GqlMaxDegreeArgs; + type Output = usize; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(max_degree(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/max_in_degree.rs b/raphtory-graphql/src/model/algorithms/max_in_degree.rs new file mode 100644 index 0000000000..0425c55bf6 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/max_in_degree.rs @@ -0,0 +1,18 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::degree::max_in_degree, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Maximum node in-degree, see [`max_in_degree`]. +pub(crate) struct GqlMaxInDegree; + +pub(crate) struct GqlMaxInDegreeArgs; + +impl GqlExecutableAlgorithm for GqlMaxInDegree { + type Args = GqlMaxInDegreeArgs; + type Output = usize; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(max_in_degree(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/max_out_degree.rs b/raphtory-graphql/src/model/algorithms/max_out_degree.rs new file mode 100644 index 0000000000..da424c3f91 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/max_out_degree.rs @@ -0,0 +1,18 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::degree::max_out_degree, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Maximum node out-degree, see [`max_out_degree`]. +pub(crate) struct GqlMaxOutDegree; + +pub(crate) struct GqlMaxOutDegreeArgs; + +impl GqlExecutableAlgorithm for GqlMaxOutDegree { + type Args = GqlMaxOutDegreeArgs; + type Output = usize; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(max_out_degree(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/min_degree.rs b/raphtory-graphql/src/model/algorithms/min_degree.rs new file mode 100644 index 0000000000..3dbd0d9825 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/min_degree.rs @@ -0,0 +1,18 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::degree::min_degree, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Minimum node degree, see [`min_degree`]. +pub(crate) struct GqlMinDegree; + +pub(crate) struct GqlMinDegreeArgs; + +impl GqlExecutableAlgorithm for GqlMinDegree { + type Args = GqlMinDegreeArgs; + type Output = usize; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(min_degree(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/min_in_degree.rs b/raphtory-graphql/src/model/algorithms/min_in_degree.rs new file mode 100644 index 0000000000..a1ebe6a848 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/min_in_degree.rs @@ -0,0 +1,18 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::degree::min_in_degree, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Minimum node in-degree, see [`min_in_degree`]. +pub(crate) struct GqlMinInDegree; + +pub(crate) struct GqlMinInDegreeArgs; + +impl GqlExecutableAlgorithm for GqlMinInDegree { + type Args = GqlMinInDegreeArgs; + type Output = usize; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(min_in_degree(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/min_out_degree.rs b/raphtory-graphql/src/model/algorithms/min_out_degree.rs new file mode 100644 index 0000000000..60be29b6f5 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/min_out_degree.rs @@ -0,0 +1,18 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::metrics::degree::min_out_degree, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Minimum node out-degree, see [`min_out_degree`]. +pub(crate) struct GqlMinOutDegree; + +pub(crate) struct GqlMinOutDegreeArgs; + +impl GqlExecutableAlgorithm for GqlMinOutDegree { + type Args = GqlMinOutDegreeArgs; + type Output = usize; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(min_out_degree(graph)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 9ee6723e80..f6def07824 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -30,6 +30,12 @@ use crate::{ GqlLocalTemporalThreeNodeMotifs, GqlLocalTemporalThreeNodeMotifsArgs, }, louvain::{GqlLouvain, GqlLouvainArgs}, + max_degree::{GqlMaxDegree, GqlMaxDegreeArgs}, + max_in_degree::{GqlMaxInDegree, GqlMaxInDegreeArgs}, + max_out_degree::{GqlMaxOutDegree, GqlMaxOutDegreeArgs}, + min_degree::{GqlMinDegree, GqlMinDegreeArgs}, + min_in_degree::{GqlMinInDegree, GqlMinInDegreeArgs}, + min_out_degree::{GqlMinOutDegree, GqlMinOutDegreeArgs}, out_component::{GqlOutComponent, GqlOutComponentArgs}, out_components::{GqlOutComponents, GqlOutComponentsArgs}, pagerank::{GqlPagerank, GqlPagerankArgs}, @@ -42,6 +48,8 @@ use crate::{ temporally_reachable_nodes::{ GqlTemporallyReachableNodes, GqlTemporallyReachableNodesArgs, }, + triangle_count::{GqlTriangleCount, GqlTriangleCountArgs}, + triplet_count::{GqlTripletCount, GqlTripletCountArgs}, weakly_connected_components::{ GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, }, @@ -81,12 +89,20 @@ pub(crate) mod label_propagation; pub(crate) mod local_clustering_coefficient_batch; pub(crate) mod local_temporal_three_node_motifs; pub(crate) mod louvain; +pub(crate) mod max_degree; +pub(crate) mod max_in_degree; +pub(crate) mod max_out_degree; +pub(crate) mod min_degree; +pub(crate) mod min_in_degree; +pub(crate) mod min_out_degree; pub(crate) mod out_component; pub(crate) mod out_components; pub(crate) mod pagerank; pub(crate) mod single_source_shortest_path; pub(crate) mod strongly_connected_components; pub(crate) mod temporally_reachable_nodes; +pub(crate) mod triangle_count; +pub(crate) mod triplet_count; pub(crate) mod weakly_connected_components; /// A graph algorithm executable through the GraphQL API. @@ -410,6 +426,58 @@ impl GqlAlgorithms { self.run::(GqlAverageDegreeArgs).await } + /// Returns the maximum (undirected) degree of any node in the graph. + async fn max_degree(&self) -> Result { + self.run::(GqlMaxDegreeArgs).await + } + + /// Returns the minimum (undirected) degree of any node in the graph. + async fn min_degree(&self) -> Result { + self.run::(GqlMinDegreeArgs).await + } + + /// Returns the maximum out-degree of any node in the graph. + async fn max_out_degree(&self) -> Result { + self.run::(GqlMaxOutDegreeArgs).await + } + + /// Returns the maximum in-degree of any node in the graph. + async fn max_in_degree(&self) -> Result { + self.run::(GqlMaxInDegreeArgs).await + } + + /// Returns the minimum out-degree of any node in the graph. + async fn min_out_degree(&self) -> Result { + self.run::(GqlMinOutDegreeArgs).await + } + + /// Returns the minimum in-degree of any node in the graph. + async fn min_in_degree(&self) -> Result { + self.run::(GqlMinInDegreeArgs).await + } + + /// Returns the number of connected triplets (paths of length 2) in the graph. + async fn triplet_count( + &self, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlTripletCountArgs { threads }) + .await + } + + /// Returns the number of triangles in the graph. + async fn triangle_count( + &self, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlTriangleCountArgs { threads }) + .await + } + /// Returns the FastRP embedding of every node. async fn fast_rp( &self, diff --git a/raphtory-graphql/src/model/algorithms/triangle_count.rs b/raphtory-graphql/src/model/algorithms/triangle_count.rs new file mode 100644 index 0000000000..0be3fc8123 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/triangle_count.rs @@ -0,0 +1,21 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::motifs::triangle_count::triangle_count, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Triangle count, see [`triangle_count`]. +pub(crate) struct GqlTriangleCount; + +pub(crate) struct GqlTriangleCountArgs { + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlTriangleCount { + type Args = GqlTriangleCountArgs; + type Output = usize; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + Ok(triangle_count(graph, args.threads)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/triplet_count.rs b/raphtory-graphql/src/model/algorithms/triplet_count.rs new file mode 100644 index 0000000000..b3f396e05c --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/triplet_count.rs @@ -0,0 +1,21 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::motifs::triplet_count::triplet_count, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Triplet count, see [`triplet_count`]. +pub(crate) struct GqlTripletCount; + +pub(crate) struct GqlTripletCountArgs { + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlTripletCount { + type Args = GqlTripletCountArgs; + type Output = usize; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + Ok(triplet_count(graph, args.threads)) + } +} From a906520860e424ddab1e5309e820a881b1028d5d Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Tue, 28 Jul 2026 06:03:04 -0400 Subject: [PATCH 26/39] Added local_clustering_coefficient and local_triangle_count algorithms in GraphQL --- raphtory-graphql/src/lib.rs | 90 +++++++++++++++++++ .../local_clustering_coefficient.rs | 26 ++++++ .../model/algorithms/local_triangle_count.rs | 26 ++++++ raphtory-graphql/src/model/algorithms/mod.rs | 37 ++++++++ 4 files changed, 179 insertions(+) create mode 100644 raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs create mode 100644 raphtory-graphql/src/model/algorithms/local_triangle_count.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index ded181de2a..b7b24ca6fc 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1608,6 +1608,96 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_local_triangle_count() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; + + // a is in the a-b-c triangle; d is a pendant with degree 1 + // only a missing node yields null + let query = r#" + { + graph(path: "g") { + algorithm { + inTriangle: localTriangleCount(node: "a") + pendant: localTriangleCount(node: "d") + missing: localTriangleCount(node: "not-a-node") + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { + "inTriangle": 1, + "pendant": 0, + "missing": null + } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_local_triangle_count_filtered() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; + + // filtering out c breaks the a-b-c triangle, so a's local triangle count drops + let query = r#" + { + graph(path: "g") { + algorithm { + localTriangleCount(node: "a", filter: { nodes: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } }) + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ "graph": { "algorithm": { "localTriangleCount": 0 } } }) + ); + } + + #[tokio::test] + async fn test_algorithm_local_clustering_coefficient() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; + + // a is in the a-b-c triangle; d is a pendant with degree 1 + // only a missing node yields null + let query = r#" + { + graph(path: "g") { + algorithm { + inTriangle: localClusteringCoefficient(node: "a") + pendant: localClusteringCoefficient(node: "d") + missing: localClusteringCoefficient(node: "not-a-node") + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { + "inTriangle": 1.0, + "pendant": 0.0, + "missing": null + } } + }) + ); + } + fn centrality_test_graph() -> MaterializedGraph { let graph = Graph::new(); // path a -> b -> c -> d so nodes get distinct centrality scores diff --git a/raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs b/raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs new file mode 100644 index 0000000000..81b53b90b3 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs @@ -0,0 +1,26 @@ +use crate::model::{ + algorithms::{filtered_view, GqlExecutableAlgorithm}, + graph::{filtering::GqlViewFilter, node_id::GqlNodeId}, +}; +use raphtory::{ + algorithms::metrics::clustering_coefficient::local_clustering_coefficient::local_clustering_coefficient, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Local clustering coefficient of a single node, see [`local_clustering_coefficient`]. +pub(crate) struct GqlLocalClusteringCoefficient; + +pub(crate) struct GqlLocalClusteringCoefficientArgs { + pub(crate) node: GqlNodeId, + pub(crate) filter: Option, +} + +impl GqlExecutableAlgorithm for GqlLocalClusteringCoefficient { + type Args = GqlLocalClusteringCoefficientArgs; + type Output = Option; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let view = filtered_view(graph, args.filter)?; + Ok(local_clustering_coefficient(&view, args.node)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/local_triangle_count.rs b/raphtory-graphql/src/model/algorithms/local_triangle_count.rs new file mode 100644 index 0000000000..5adef1f931 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/local_triangle_count.rs @@ -0,0 +1,26 @@ +use crate::model::{ + algorithms::{filtered_view, GqlExecutableAlgorithm}, + graph::{filtering::GqlViewFilter, node_id::GqlNodeId}, +}; +use raphtory::{ + algorithms::motifs::local_triangle_count::local_triangle_count, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Local triangle count of a single node, see [`local_triangle_count`]. +pub(crate) struct GqlLocalTriangleCount; + +pub(crate) struct GqlLocalTriangleCountArgs { + pub(crate) node: GqlNodeId, + pub(crate) filter: Option, +} + +impl GqlExecutableAlgorithm for GqlLocalTriangleCount { + type Args = GqlLocalTriangleCountArgs; + type Output = Option; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let view = filtered_view(graph, args.filter)?; + Ok(local_triangle_count(&view, args.node)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index f6def07824..e0a47c82cc 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -23,12 +23,16 @@ use crate::{ in_component::{GqlInComponent, GqlInComponentArgs}, in_components::{GqlInComponents, GqlInComponentsArgs}, label_propagation::{GqlLabelPropagation, GqlLabelPropagationArgs}, + local_clustering_coefficient::{ + GqlLocalClusteringCoefficient, GqlLocalClusteringCoefficientArgs, + }, local_clustering_coefficient_batch::{ GqlLocalClusteringCoefficientBatch, GqlLocalClusteringCoefficientBatchArgs, }, local_temporal_three_node_motifs::{ GqlLocalTemporalThreeNodeMotifs, GqlLocalTemporalThreeNodeMotifsArgs, }, + local_triangle_count::{GqlLocalTriangleCount, GqlLocalTriangleCountArgs}, louvain::{GqlLouvain, GqlLouvainArgs}, max_degree::{GqlMaxDegree, GqlMaxDegreeArgs}, max_in_degree::{GqlMaxInDegree, GqlMaxInDegreeArgs}, @@ -86,8 +90,10 @@ pub(crate) mod hits; pub(crate) mod in_component; pub(crate) mod in_components; pub(crate) mod label_propagation; +pub(crate) mod local_clustering_coefficient; pub(crate) mod local_clustering_coefficient_batch; pub(crate) mod local_temporal_three_node_motifs; +pub(crate) mod local_triangle_count; pub(crate) mod louvain; pub(crate) mod max_degree; pub(crate) mod max_in_degree; @@ -302,6 +308,37 @@ impl GqlAlgorithms { .await } + /// Returns the local triangle count of a single node (0 if it has degree < 2), or null if + /// the node does not exist in the view. + async fn local_triangle_count( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + #[graphql( + desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." + )] + filter: Option, + ) -> Result, GraphError> { + self.run::(GqlLocalTriangleCountArgs { node, filter }) + .await + } + + /// Returns the local clustering coefficient of a single node (0 if it has degree < 2), or + /// null if the node does not exist in the view. + async fn local_clustering_coefficient( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + #[graphql( + desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." + )] + filter: Option, + ) -> Result, GraphError> { + self.run::(GqlLocalClusteringCoefficientArgs { + node, + filter, + }) + .await + } + /// Returns the weakly connected component id of every node. async fn weakly_connected_components(&self) -> Result { self.run::(GqlWeaklyConnectedComponentsArgs) From 014df509e866669b420f727db4992a34d63a309d Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 29 Jul 2026 05:05:31 -0400 Subject: [PATCH 27/39] Added global_temporal_three_node_motif, temporal_three_node_motif_multi, Matching, and max_weight_matching algorithms in GraphQL --- raphtory-graphql/src/lib.rs | 109 ++++++++++++++++++ .../global_temporal_three_node_motif.rs | 24 ++++ .../global_temporal_three_node_motif_multi.rs | 45 ++++++++ .../model/algorithms/max_weight_matching.rs | 29 +++++ raphtory-graphql/src/model/algorithms/mod.rs | 69 ++++++++++- raphtory-graphql/src/model/graph/matching.rs | 90 +++++++++++++++ raphtory-graphql/src/model/graph/mod.rs | 1 + 7 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs create mode 100644 raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif_multi.rs create mode 100644 raphtory-graphql/src/model/algorithms/max_weight_matching.rs create mode 100644 raphtory-graphql/src/model/graph/matching.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index b7b24ca6fc..ee73c413e5 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1549,6 +1549,115 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_global_temporal_three_node_motif() { + let graph = Graph::new(); + // a -> b -> c -> a, each edge at a distinct time, so triangle motifs are counted + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + single: globalTemporalThreeNodeMotif(delta: 10) + multi: globalTemporalThreeNodeMotifMulti(deltas: [10, 1]) { delta counts } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + let single = data["graph"]["algorithm"]["single"].as_array().unwrap(); + let multi = data["graph"]["algorithm"]["multi"].as_array().unwrap(); + + // 40 counts: 8 two-node + 24 star + 8 triangle + assert_eq!(single.len(), 40); + // one row per delta, and the first row is the same as the single-delta call + assert_eq!(multi.len(), 2); + assert!(multi + .iter() + .all(|row| row["counts"].as_array().unwrap().len() == 40)); + assert_eq!(multi[0]["delta"], 10); + assert_eq!(multi[1]["delta"], 1); + assert_eq!(multi[0]["counts"].as_array().unwrap(), single); + // delta 10 spans the whole triangle so it finds motifs delta 1 does not + assert!( + single.iter().any(|c| c.as_u64().unwrap() > 0), + "expected some motifs at delta 10, got {single:?}" + ); + assert_ne!(multi[0]["counts"], multi[1]["counts"]); + } + + #[tokio::test] + async fn test_algorithm_max_weight_matching() { + let graph = Graph::new(); + // path a-b-c-d: the max weight matching picks a-b (5) and c-d (4) over b-c (3) + graph + .add_edge(1, "a", "b", [("weight", 5.0)], None) + .unwrap(); + graph + .add_edge(1, "b", "c", [("weight", 3.0)], None) + .unwrap(); + graph + .add_edge(1, "c", "d", [("weight", 4.0)], None) + .unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + maxWeightMatching(weightProp: "weight") { + count + edges { list { src { id } dst { id } } } + dstOfA: dst(src: "a") { id } + srcOfD: src(dst: "d") { id } + hasAB: contains(src: "a", dst: "b") + hasBC: contains(src: "b", dst: "c") + edgeForA: edgeForSrc(src: "a") { src { id } dst { id } } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // the matching is backed by a HashMap, so edge order is not guaranteed + let mut data = res.data.into_json().unwrap(); + data["graph"]["algorithm"]["maxWeightMatching"]["edges"]["list"] + .as_array_mut() + .unwrap() + .sort_by_key(|edge| edge["src"]["id"].as_str().unwrap().to_string()); + // picks a-b and c-d (total weight 9) over the single b-c edge (weight 3) + assert_eq!( + data, + json!({ + "graph": { "algorithm": { "maxWeightMatching": { + "count": 2, + "edges": { "list": [ + { "src": { "id": "a" }, "dst": { "id": "b" } }, + { "src": { "id": "c" }, "dst": { "id": "d" } } + ] }, + "dstOfA": { "id": "b" }, + "srcOfD": { "id": "c" }, + "hasAB": true, + "hasBC": false, + "edgeForA": { "src": { "id": "a" }, "dst": { "id": "b" } } + } } } + }) + ); + } + fn scalar_metrics_test_graph() -> MaterializedGraph { let graph = Graph::new(); // a <-> b reciprocated, b -> c -> a forming a triangle with a-b, and c -> d as a pendant edge, diff --git a/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs b/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs new file mode 100644 index 0000000000..1744b2ca3d --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs @@ -0,0 +1,24 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use raphtory::{ + algorithms::motifs::global_temporal_three_node_motifs::global_temporal_three_node_motif, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// Global temporal three-node motif counts, see [`global_temporal_three_node_motif`]. +pub(crate) struct GqlGlobalTemporalThreeNodeMotif; + +pub(crate) struct GqlGlobalTemporalThreeNodeMotifArgs { + pub(crate) delta: i64, + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlGlobalTemporalThreeNodeMotif { + type Args = GqlGlobalTemporalThreeNodeMotifArgs; + /// The 40 motif counts, positionally ordered (see the core docs). + type Output = Vec; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let counts = global_temporal_three_node_motif(graph, args.delta, args.threads); + Ok(counts.to_vec()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif_multi.rs b/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif_multi.rs new file mode 100644 index 0000000000..1ba76bf816 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif_multi.rs @@ -0,0 +1,45 @@ +use crate::model::algorithms::GqlExecutableAlgorithm; +use dynamic_graphql::SimpleObject; +use raphtory::{ + algorithms::motifs::global_temporal_three_node_motifs::temporal_three_node_motif_multi, + db::api::view::DynamicGraph, errors::GraphError, +}; + +/// The motif counts for a single delta. Wraps the counts in an object because +/// the schema builder does not support nested lists of scalars. +#[derive(SimpleObject)] +#[graphql(name = "MotifCounts")] +pub(crate) struct GqlMotifCounts { + /// The delta these counts were computed for. + delta: i64, + /// The 40 motif counts, positionally ordered (see the core docs). + counts: Vec, +} + +/// Global temporal three-node motif counts for several deltas, see +/// [`temporal_three_node_motif_multi`]. +pub(crate) struct GqlGlobalTemporalThreeNodeMotifMulti; + +pub(crate) struct GqlGlobalTemporalThreeNodeMotifMultiArgs { + pub(crate) deltas: Vec, + pub(crate) threads: Option, +} + +impl GqlExecutableAlgorithm for GqlGlobalTemporalThreeNodeMotifMulti { + type Args = GqlGlobalTemporalThreeNodeMotifMultiArgs; + /// One entry per delta, in the order the deltas were given. + type Output = Vec; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let deltas = args.deltas.clone(); + let counts = temporal_three_node_motif_multi(graph, args.deltas, args.threads); + Ok(counts + .into_iter() + .zip(deltas) + .map(|(counts, delta)| GqlMotifCounts { + delta, + counts: counts.to_vec(), + }) + .collect()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/max_weight_matching.rs b/raphtory-graphql/src/model/algorithms/max_weight_matching.rs new file mode 100644 index 0000000000..a6d80d9f9a --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/max_weight_matching.rs @@ -0,0 +1,29 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::matching::GqlMatching}; +use raphtory::{ + algorithms::bipartite::max_weight_matching::max_weight_matching, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Maximum weight matching, see [`max_weight_matching`]. +pub(crate) struct GqlMaxWeightMatching; + +pub(crate) struct GqlMaxWeightMatchingArgs { + pub(crate) weight_prop: Option, + pub(crate) max_cardinality: bool, + pub(crate) verify_optimum: bool, +} + +impl GqlExecutableAlgorithm for GqlMaxWeightMatching { + type Args = GqlMaxWeightMatchingArgs; + type Output = GqlMatching; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let matching = max_weight_matching( + graph, + args.weight_prop.as_deref(), + args.max_cardinality, + args.verify_optimum, + ); + Ok(matching.into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index e0a47c82cc..3e4e944ea0 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -19,6 +19,13 @@ use crate::{ GqlGlobalClusteringCoefficient, GqlGlobalClusteringCoefficientArgs, }, global_reciprocity::{GqlGlobalReciprocity, GqlGlobalReciprocityArgs}, + global_temporal_three_node_motif::{ + GqlGlobalTemporalThreeNodeMotif, GqlGlobalTemporalThreeNodeMotifArgs, + }, + global_temporal_three_node_motif_multi::{ + GqlGlobalTemporalThreeNodeMotifMulti, GqlGlobalTemporalThreeNodeMotifMultiArgs, + GqlMotifCounts, + }, hits::{GqlHits, GqlHitsArgs}, in_component::{GqlInComponent, GqlInComponentArgs}, in_components::{GqlInComponents, GqlInComponentsArgs}, @@ -37,6 +44,7 @@ use crate::{ max_degree::{GqlMaxDegree, GqlMaxDegreeArgs}, max_in_degree::{GqlMaxInDegree, GqlMaxInDegreeArgs}, max_out_degree::{GqlMaxOutDegree, GqlMaxOutDegreeArgs}, + max_weight_matching::{GqlMaxWeightMatching, GqlMaxWeightMatchingArgs}, min_degree::{GqlMinDegree, GqlMinDegreeArgs}, min_in_degree::{GqlMinInDegree, GqlMinInDegreeArgs}, min_out_degree::{GqlMinOutDegree, GqlMinOutDegreeArgs}, @@ -58,7 +66,10 @@ use crate::{ GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, }, }, - graph::{filtering::GqlViewFilter, node_id::GqlNodeId, node_state::GqlNodeState}, + graph::{ + filtering::GqlViewFilter, matching::GqlMatching, node_id::GqlNodeId, + node_state::GqlNodeState, + }, }, rayon::blocking_compute, }; @@ -86,6 +97,8 @@ pub(crate) mod fast_rp; pub(crate) mod fruchterman_reingold; pub(crate) mod global_clustering_coefficient; pub(crate) mod global_reciprocity; +pub(crate) mod global_temporal_three_node_motif; +pub(crate) mod global_temporal_three_node_motif_multi; pub(crate) mod hits; pub(crate) mod in_component; pub(crate) mod in_components; @@ -98,6 +111,7 @@ pub(crate) mod louvain; pub(crate) mod max_degree; pub(crate) mod max_in_degree; pub(crate) mod max_out_degree; +pub(crate) mod max_weight_matching; pub(crate) mod min_degree; pub(crate) mod min_in_degree; pub(crate) mod min_out_degree; @@ -614,4 +628,57 @@ impl GqlAlgorithms { }) .await } + + /// Returns the graph-wide temporal three-node motif counts: 40 counts in a + /// fixed order (8 two-node, 24 star, then 8 triangle motifs). + async fn global_temporal_three_node_motif( + &self, + #[graphql(desc = "Maximum time difference between the first and last edge of a motif.")] + delta: i64, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result, GraphError> { + self.run::(GqlGlobalTemporalThreeNodeMotifArgs { + delta, + threads, + }) + .await + } + + /// Returns the graph-wide temporal three-node motif counts for each of + /// `deltas`, one row of 40 counts per delta, in the order given. + async fn global_temporal_three_node_motif_multi( + &self, + #[graphql(desc = "Maximum time differences to compute the motif counts for.")] deltas: Vec< + i64, + >, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result, GraphError> { + self.run::(GqlGlobalTemporalThreeNodeMotifMultiArgs { + deltas, + threads, + }) + .await + } + + /// Returns a maximum weight matching of the graph, treated as undirected. + async fn max_weight_matching( + &self, + #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] + weight_prop: Option, + #[graphql(desc = "Only consider maximum-cardinality matchings. Defaults to false.")] + max_cardinality: Option, + #[graphql(desc = "Verify that the matching found is optimum. Defaults to false.")] + verify_optimum: Option, + ) -> Result { + self.run::(GqlMaxWeightMatchingArgs { + weight_prop, + max_cardinality: max_cardinality.unwrap_or(false), + verify_optimum: verify_optimum.unwrap_or(false), + }) + .await + } } diff --git a/raphtory-graphql/src/model/graph/matching.rs b/raphtory-graphql/src/model/graph/matching.rs new file mode 100644 index 0000000000..4746808fcb --- /dev/null +++ b/raphtory-graphql/src/model/graph/matching.rs @@ -0,0 +1,90 @@ +use crate::{ + model::graph::{edge::GqlEdge, edges::GqlEdges, node::GqlNode, node_id::GqlNodeId}, + rayon::blocking_compute, +}; +use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; +use raphtory::{ + algorithms::bipartite::max_weight_matching::Matching, db::api::view::DynamicGraph, + prelude::NodeViewOps, +}; + +/// A matching of a graph: a set of edges no two of which share a node. +#[derive(ResolvedObject, Clone)] +#[graphql(name = "Matching")] +pub(crate) struct GqlMatching { + pub(crate) matching: Matching, +} + +impl From> for GqlMatching { + fn from(matching: Matching) -> Self { + Self { matching } + } +} + +#[ResolvedObjectFields] +impl GqlMatching { + /// Returns the number of edges in the matching. + async fn count(&self) -> usize { + self.matching.len() + } + + /// The edges in the matching. + async fn edges(&self) -> GqlEdges { + GqlEdges::new(self.matching.edges()) + } + + /// The node matched to `dst`, null if it is unmatched. + async fn src( + &self, + #[graphql(desc = "Destination node id.")] dst: GqlNodeId, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || self_clone.matching.src(dst).map(|n| n.cloned().into())).await + } + + /// The node matched to `src`, null if it is unmatched. + async fn dst(&self, #[graphql(desc = "Source node id.")] src: GqlNodeId) -> Option { + let self_clone = self.clone(); + blocking_compute(move || self_clone.matching.dst(src).map(|n| n.cloned().into())).await + } + + /// The matched edge for `src`, null if it is unmatched. + async fn edge_for_src( + &self, + #[graphql(desc = "Source node id.")] src: GqlNodeId, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || { + self_clone + .matching + .edge_for_src(src) + .map(|e| e.cloned().into()) + }) + .await + } + + /// The matched edge for `dst`, null if it is unmatched. + async fn edge_for_dst( + &self, + #[graphql(desc = "Destination node id.")] dst: GqlNodeId, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || { + self_clone + .matching + .edge_for_dst(dst) + .map(|e| e.cloned().into()) + }) + .await + } + + /// Whether the `src` to `dst` edge is part of the matching. + async fn contains( + &self, + #[graphql(desc = "Source node id.")] src: GqlNodeId, + #[graphql(desc = "Destination node id.")] dst: GqlNodeId, + ) -> bool { + let self_clone = self.clone(); + blocking_compute(move || self_clone.matching.contains(src, dst)).await + } +} diff --git a/raphtory-graphql/src/model/graph/mod.rs b/raphtory-graphql/src/model/graph/mod.rs index a11875ebe6..db2ec03c27 100644 --- a/raphtory-graphql/src/model/graph/mod.rs +++ b/raphtory-graphql/src/model/graph/mod.rs @@ -10,6 +10,7 @@ pub mod filtering; pub(crate) mod graph; pub(crate) mod history; pub(crate) mod index; +pub(crate) mod matching; pub mod meta_graph; pub(crate) mod mutable_graph; pub mod namespace; From bdb802c3eb729f0bf9ca4727ab121768ae89a05c Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Thu, 30 Jul 2026 05:46:00 -0400 Subject: [PATCH 28/39] Added alternating_mask and temporal_SEIR algorithms in GraphQL --- Cargo.lock | 1 + raphtory-graphql/Cargo.toml | 1 + raphtory-graphql/src/lib.rs | 132 ++++++++++++++++++ .../src/model/algorithms/alternating_mask.rs | 19 +++ raphtory-graphql/src/model/algorithms/mod.rs | 42 +++++- .../src/model/algorithms/temporal_seir.rs | 77 ++++++++++ raphtory/src/errors.rs | 4 + 7 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 raphtory-graphql/src/model/algorithms/alternating_mask.rs create mode 100644 raphtory-graphql/src/model/algorithms/temporal_seir.rs diff --git a/Cargo.lock b/Cargo.lock index f4bab2c0a0..d02b77a772 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6839,6 +6839,7 @@ dependencies = [ "pyo3", "pythonize", "quick_cache", + "rand 0.9.4", "raphtory", "raphtory-api", "raphtory-graphql", diff --git a/raphtory-graphql/Cargo.toml b/raphtory-graphql/Cargo.toml index 6c4b9ab4e8..ebbf05e0bc 100644 --- a/raphtory-graphql/Cargo.toml +++ b/raphtory-graphql/Cargo.toml @@ -24,6 +24,7 @@ jsonwebtoken = { workspace = true } spki = { workspace = true } thiserror = { workspace = true } itertools = { workspace = true } +rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } once_cell = { workspace = true } diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index ee73c413e5..2da8c2bbf1 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1658,6 +1658,138 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_alternating_mask() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + alternatingMask { + nodes { ids } + columns { name values { ... on NodeStateProp { prop } } } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // the mask alternates over the nodes in order + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "alternatingMask": { + "nodes": { "ids": ["a", "b", "c", "d"] }, + "columns": [{ + "name": "bool_col", + "values": [ + { "prop": false }, + { "prop": true }, + { "prop": false }, + { "prop": true } + ] + }] + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_temporal_seir() { + let graph = Graph::new(); + // a chain so the infection can spread forward in time + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // seeding an explicit node with certain infection spreads along the chain; + // rngSeed keeps the run reproducible + let query = r#" + { + graph(path: "g") { + algorithm { + temporalSeir( + seeds: { nodes: ["a"] } + infectionProb: 1.0 + initialInfection: 0 + rngSeed: 42 + ) { + nodes { ids } + columnNames + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "temporalSeir": { + "nodes": { "ids": ["a", "b", "c", "d"] }, + "columnNames": ["infected", "active", "recovered"] + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_temporal_seir_seed_variants() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // all three Seeds variants are accepted; number/probability pick nodes at random + let query = r#" + { + graph(path: "g") { + algorithm { + byNumber: temporalSeir( + seeds: { number: 2 } + infectionProb: 0.0 + initialInfection: 0 + rngSeed: 7 + ) { count } + byProbability: temporalSeir( + seeds: { probability: 0.5 } + infectionProb: 0.0 + initialInfection: 0 + rngSeed: 7 + ) { count } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // With no onward infection only the seeds appear. `number` samples that many + // nodes; `probability` currently seeds *every* node regardless of the value, + // because the core `IntoSeeds for Probability` impl ignores it. + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { + "byNumber": { "count": 2 }, + "byProbability": { "count": 4 } + } } + }) + ); + } + fn scalar_metrics_test_graph() -> MaterializedGraph { let graph = Graph::new(); // a <-> b reciprocated, b -> c -> a forming a triangle with a-b, and c -> d as a pendant edge, diff --git a/raphtory-graphql/src/model/algorithms/alternating_mask.rs b/raphtory-graphql/src/model/algorithms/alternating_mask.rs new file mode 100644 index 0000000000..db65074d20 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/alternating_mask.rs @@ -0,0 +1,19 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use raphtory::{ + algorithms::alternating_mask::alternating_mask, db::api::view::DynamicGraph, + errors::GraphError, +}; + +/// Alternating boolean mask over the nodes, see [`alternating_mask`]. +pub(crate) struct GqlAlternatingMask; + +pub(crate) struct GqlAlternatingMaskArgs; + +impl GqlExecutableAlgorithm for GqlAlternatingMask { + type Args = GqlAlternatingMaskArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, _args: Self::Args) -> Result { + Ok(alternating_mask(graph).into()) + } +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 3e4e944ea0..2e206a30f5 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -4,6 +4,7 @@ use crate::{ model::{ algorithms::{ all_local_reciprocity::{GqlAllLocalReciprocity, GqlAllLocalReciprocityArgs}, + alternating_mask::{GqlAlternatingMask, GqlAlternatingMaskArgs}, average_degree::{GqlAverageDegree, GqlAverageDegreeArgs}, balance::{GqlBalance, GqlBalanceArgs}, betweenness_centrality::{GqlBetweennessCentrality, GqlBetweennessCentralityArgs}, @@ -57,6 +58,7 @@ use crate::{ strongly_connected_components::{ GqlStronglyConnectedComponents, GqlStronglyConnectedComponentsArgs, }, + temporal_seir::{GqlSeeds, GqlTemporalSeir, GqlTemporalSeirArgs}, temporally_reachable_nodes::{ GqlTemporallyReachableNodes, GqlTemporallyReachableNodesArgs, }, @@ -68,7 +70,7 @@ use crate::{ }, graph::{ filtering::GqlViewFilter, matching::GqlMatching, node_id::GqlNodeId, - node_state::GqlNodeState, + node_state::GqlNodeState, timeindex::GqlTimeInput, }, }, rayon::blocking_compute, @@ -86,6 +88,7 @@ use raphtory::{ use raphtory_api::core::Direction; pub(crate) mod all_local_reciprocity; +pub(crate) mod alternating_mask; pub(crate) mod average_degree; pub(crate) mod balance; pub(crate) mod betweenness_centrality; @@ -120,6 +123,7 @@ pub(crate) mod out_components; pub(crate) mod pagerank; pub(crate) mod single_source_shortest_path; pub(crate) mod strongly_connected_components; +pub(crate) mod temporal_seir; pub(crate) mod temporally_reachable_nodes; pub(crate) mod triangle_count; pub(crate) mod triplet_count; @@ -664,6 +668,42 @@ impl GqlAlgorithms { .await } + /// Returns an alternating boolean mask over the nodes. + async fn alternating_mask(&self) -> Result { + self.run::(GqlAlternatingMaskArgs) + .await + } + + /// Simulates an SEIR epidemic, returning the infection, activation and + /// recovery times of every node that was infected. + async fn temporal_seir( + &self, + #[graphql(desc = "How the initially infected nodes are chosen.")] seeds: GqlSeeds, + #[graphql(desc = "Probability that an encounter between an active and a susceptible node infects it.")] + infection_prob: f64, + #[graphql(desc = "Time of the initial infection.")] initial_infection: GqlTimeInput, + #[graphql( + desc = "Rate at which infected nodes recover. If unset, nodes never recover." + )] + recovery_rate: Option, + #[graphql( + desc = "Rate at which infected nodes become infectious. If unset, they are infectious immediately." + )] + incubation_rate: Option, + #[graphql(desc = "Seed for the random number generator. If unset, seeded from the OS.")] + rng_seed: Option, + ) -> Result { + self.run::(GqlTemporalSeirArgs { + seeds, + infection_prob, + initial_infection, + recovery_rate, + incubation_rate, + rng_seed, + }) + .await + } + /// Returns a maximum weight matching of the graph, treated as undirected. async fn max_weight_matching( &self, diff --git a/raphtory-graphql/src/model/algorithms/temporal_seir.rs b/raphtory-graphql/src/model/algorithms/temporal_seir.rs new file mode 100644 index 0000000000..6993aafaa8 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/temporal_seir.rs @@ -0,0 +1,77 @@ +use crate::model::{ + algorithms::GqlExecutableAlgorithm, + graph::{node_id::GqlNodeId, node_state::GqlNodeState, timeindex::GqlTimeInput}, +}; +use dynamic_graphql::OneOfInput; +use raphtory::{ + algorithms::dynamics::temporal::epidemics::{ + temporal_SEIR, IntoSeeds, Number, Probability, SeedError, + }, + core::entities::VID, + db::api::view::{DynamicGraph, StaticGraphViewOps}, + errors::GraphError, +}; +use rand::{rngs::StdRng, Rng, SeedableRng}; +use raphtory_api::core::utils::time::IntoTime; + +/// How the initially infected nodes are chosen. +#[derive(OneOfInput, Clone)] +#[graphql(name = "Seeds")] +pub(crate) enum GqlSeeds { + /// Infect exactly these nodes. + Nodes(Vec), + /// Infect this many randomly chosen nodes. + Number(usize), + /// Infect this fraction of the nodes, chosen at random. + Probability(f64), +} + +impl IntoSeeds for GqlSeeds { + fn into_initial_list( + self, + graph: &G, + rng: &mut R, + ) -> Result, SeedError> { + match self { + GqlSeeds::Nodes(nodes) => nodes.into_initial_list(graph, rng), + GqlSeeds::Number(number) => Number(number).into_initial_list(graph, rng), + GqlSeeds::Probability(probability) => { + Probability::try_from(probability)?.into_initial_list(graph, rng) + } + } + } +} + +/// Temporal SEIR epidemic simulation, see [`temporal_SEIR`]. +pub(crate) struct GqlTemporalSeir; + +pub(crate) struct GqlTemporalSeirArgs { + pub(crate) seeds: GqlSeeds, + pub(crate) infection_prob: f64, + pub(crate) initial_infection: GqlTimeInput, + pub(crate) recovery_rate: Option, + pub(crate) incubation_rate: Option, + pub(crate) rng_seed: Option, +} + +impl GqlExecutableAlgorithm for GqlTemporalSeir { + type Args = GqlTemporalSeirArgs; + type Output = GqlNodeState; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let mut rng = match args.rng_seed { + Some(seed) => StdRng::seed_from_u64(seed), + None => StdRng::from_os_rng(), + }; + let state = temporal_SEIR( + graph, + args.recovery_rate, + args.incubation_rate, + args.infection_prob, + args.initial_infection.into_time(), + args.seeds, + &mut rng, + )?; + Ok(state.into()) + } +} diff --git a/raphtory/src/errors.rs b/raphtory/src/errors.rs index 959c5c7956..8da96abfec 100644 --- a/raphtory/src/errors.rs +++ b/raphtory/src/errors.rs @@ -35,6 +35,7 @@ use raphtory_api::core::storage::{graph_folder::GraphFolderError, timeindex::Tim use storage::{error::StorageError, resolver::mapping_resolver::InvalidNodeId}; #[cfg(feature = "io")] use zip::result::ZipError; +use crate::algorithms::dynamics::temporal::epidemics::SeedError; #[derive(thiserror::Error, Debug)] pub enum InvalidPathReason { @@ -238,6 +239,9 @@ pub enum GraphError { #[error("IO operation failed: {0}")] IOErrorMsg(String), + #[error("Invalid epidemic seeds: {0}")] + SeedError(#[from] SeedError), + #[cfg(feature = "vectors")] #[error("Heed error: {0}")] HeedError(#[from] heed::Error), From dd6a414585fa11e0fe9b5849a3ccedc4dabcf6e0 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Thu, 30 Jul 2026 06:13:04 -0400 Subject: [PATCH 29/39] Fixing temporal_SEIR probability infection and adding temporal_rich_club_coefficient algorithm by taking rolling window args in GraphQL. --- raphtory-graphql/src/lib.rs | 56 ++++++++++++++++--- .../src/model/algorithms/alternating_mask.rs | 3 +- raphtory-graphql/src/model/algorithms/mod.rs | 41 +++++++++++--- .../temporal_rich_club_coefficient.rs | 40 +++++++++++++ .../src/model/algorithms/temporal_seir.rs | 2 +- .../algorithms/dynamics/temporal/epidemics.rs | 9 ++- raphtory/src/errors.rs | 2 +- 7 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 2da8c2bbf1..cb876ec7ab 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1776,17 +1776,55 @@ mod graphql_test { let res = setup.schema.execute(Request::new(query)).await; assert_eq!(res.errors, vec![], "{:?}", res.errors); - // With no onward infection only the seeds appear. `number` samples that many - // nodes; `probability` currently seeds *every* node regardless of the value, - // because the core `IntoSeeds for Probability` impl ignores it. + // With no onward infection only the seeds appear. `number` samples exactly that + // many nodes; `probability` seeds each node independently + let data = res.data.into_json().unwrap(); + assert_eq!(data["graph"]["algorithm"]["byNumber"]["count"], 2); + let by_probability = data["graph"]["algorithm"]["byProbability"]["count"] + .as_u64() + .unwrap(); + assert!( + by_probability <= 4, + "expected at most every node to be seeded, got {by_probability}" + ); + } + + #[tokio::test] + async fn test_algorithm_temporal_rich_club_coefficient() { + let graph = Graph::new(); + // a triangle a-b-c repeated at every time step, so it persists across + // every snapshot, plus a pendant d that never joins the club + for t in 1..=4 { + for (src, dst) in [("a", "b"), ("b", "c"), ("c", "a")] { + graph.add_edge(t, src, dst, NO_PROPS, None).unwrap(); + } + } + graph.add_edge(1, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // one snapshot per time step; the triangle persists over every pair of them + let query = r#" + { + graph(path: "g") { + algorithm { + temporalRichClubCoefficient( + k: 2 + windowSize: 2 + rollingWindow: { epoch: 1 } + ) + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // the a-b-c triangle is fully connected and persists, so the coefficient is 1 assert_eq!( res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { - "byNumber": { "count": 2 }, - "byProbability": { "count": 4 } - } } - }) + json!({ "graph": { "algorithm": { "temporalRichClubCoefficient": 1.0 } } }) ); } diff --git a/raphtory-graphql/src/model/algorithms/alternating_mask.rs b/raphtory-graphql/src/model/algorithms/alternating_mask.rs index db65074d20..ca05dd401d 100644 --- a/raphtory-graphql/src/model/algorithms/alternating_mask.rs +++ b/raphtory-graphql/src/model/algorithms/alternating_mask.rs @@ -1,7 +1,6 @@ use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; use raphtory::{ - algorithms::alternating_mask::alternating_mask, db::api::view::DynamicGraph, - errors::GraphError, + algorithms::alternating_mask::alternating_mask, db::api::view::DynamicGraph, errors::GraphError, }; /// Alternating boolean mask over the nodes, see [`alternating_mask`]. diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 2e206a30f5..b6cbaa44ac 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -58,6 +58,9 @@ use crate::{ strongly_connected_components::{ GqlStronglyConnectedComponents, GqlStronglyConnectedComponentsArgs, }, + temporal_rich_club_coefficient::{ + GqlTemporalRichClubCoefficient, GqlTemporalRichClubCoefficientArgs, + }, temporal_seir::{GqlSeeds, GqlTemporalSeir, GqlTemporalSeirArgs}, temporally_reachable_nodes::{ GqlTemporallyReachableNodes, GqlTemporallyReachableNodesArgs, @@ -70,7 +73,7 @@ use crate::{ }, graph::{ filtering::GqlViewFilter, matching::GqlMatching, node_id::GqlNodeId, - node_state::GqlNodeState, timeindex::GqlTimeInput, + node_state::GqlNodeState, timeindex::GqlTimeInput, WindowDuration, }, }, rayon::blocking_compute, @@ -123,6 +126,7 @@ pub(crate) mod out_components; pub(crate) mod pagerank; pub(crate) mod single_source_shortest_path; pub(crate) mod strongly_connected_components; +pub(crate) mod temporal_rich_club_coefficient; pub(crate) mod temporal_seir; pub(crate) mod temporally_reachable_nodes; pub(crate) mod triangle_count; @@ -668,10 +672,33 @@ impl GqlAlgorithms { .await } + /// Returns the temporal rich club coefficient: the maximal density among the + /// nodes of degree at least `k` that persists over `windowSize` consecutive + /// snapshots. The snapshots are the rolling windows described by + /// `rollingWindow` / `rollingStep`. + async fn temporal_rich_club_coefficient( + &self, + #[graphql(desc = "Minimum degree a node must have to be in the rich club.")] k: usize, + #[graphql(desc = "Number of consecutive snapshots the edges must persist over.")] + window_size: usize, + #[graphql(desc = "Width of each snapshot.")] rolling_window: WindowDuration, + #[graphql( + desc = "Optional gap between the start of one snapshot and the next. Defaults to `rollingWindow`, i.e. non-overlapping snapshots." + )] + rolling_step: Option, + ) -> Result { + self.run::(GqlTemporalRichClubCoefficientArgs { + k, + window_size, + rolling_window, + rolling_step, + }) + .await + } + /// Returns an alternating boolean mask over the nodes. async fn alternating_mask(&self) -> Result { - self.run::(GqlAlternatingMaskArgs) - .await + self.run::(GqlAlternatingMaskArgs).await } /// Simulates an SEIR epidemic, returning the infection, activation and @@ -679,12 +706,12 @@ impl GqlAlgorithms { async fn temporal_seir( &self, #[graphql(desc = "How the initially infected nodes are chosen.")] seeds: GqlSeeds, - #[graphql(desc = "Probability that an encounter between an active and a susceptible node infects it.")] - infection_prob: f64, - #[graphql(desc = "Time of the initial infection.")] initial_infection: GqlTimeInput, #[graphql( - desc = "Rate at which infected nodes recover. If unset, nodes never recover." + desc = "Probability that an encounter between an active and a susceptible node infects it." )] + infection_prob: f64, + #[graphql(desc = "Time of the initial infection.")] initial_infection: GqlTimeInput, + #[graphql(desc = "Rate at which infected nodes recover. If unset, nodes never recover.")] recovery_rate: Option, #[graphql( desc = "Rate at which infected nodes become infectious. If unset, they are infectious immediately." diff --git a/raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs b/raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs new file mode 100644 index 0000000000..5c9429a041 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs @@ -0,0 +1,40 @@ +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::WindowDuration}; +use raphtory::{ + algorithms::motifs::temporal_rich_club_coefficient::temporal_rich_club_coefficient, + core::utils::time::TryIntoInterval, + db::api::view::{DynamicGraph, TimeOps}, + errors::GraphError, +}; + +/// Temporal rich club coefficient, see [`temporal_rich_club_coefficient`]. +pub(crate) struct GqlTemporalRichClubCoefficient; + +pub(crate) struct GqlTemporalRichClubCoefficientArgs { + pub(crate) k: usize, + pub(crate) window_size: usize, + pub(crate) rolling_window: WindowDuration, + pub(crate) rolling_step: Option, +} + +impl GqlExecutableAlgorithm for GqlTemporalRichClubCoefficient { + type Args = GqlTemporalRichClubCoefficientArgs; + type Output = f64; + + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result { + let rolling_window = args.rolling_window.try_into_interval()?; + let rolling_step = args + .rolling_step + .map(|step| step.try_into_interval()) + .transpose()?; + let views: Vec<_> = graph + .rolling(rolling_window, rolling_step)? + .into_iter() + .collect(); + Ok(temporal_rich_club_coefficient( + graph, + views, + args.k, + args.window_size, + )) + } +} diff --git a/raphtory-graphql/src/model/algorithms/temporal_seir.rs b/raphtory-graphql/src/model/algorithms/temporal_seir.rs index 6993aafaa8..d3d022a3b9 100644 --- a/raphtory-graphql/src/model/algorithms/temporal_seir.rs +++ b/raphtory-graphql/src/model/algorithms/temporal_seir.rs @@ -3,6 +3,7 @@ use crate::model::{ graph::{node_id::GqlNodeId, node_state::GqlNodeState, timeindex::GqlTimeInput}, }; use dynamic_graphql::OneOfInput; +use rand::{rngs::StdRng, Rng, SeedableRng}; use raphtory::{ algorithms::dynamics::temporal::epidemics::{ temporal_SEIR, IntoSeeds, Number, Probability, SeedError, @@ -11,7 +12,6 @@ use raphtory::{ db::api::view::{DynamicGraph, StaticGraphViewOps}, errors::GraphError, }; -use rand::{rngs::StdRng, Rng, SeedableRng}; use raphtory_api::core::utils::time::IntoTime; /// How the initially infected nodes are chosen. diff --git a/raphtory/src/algorithms/dynamics/temporal/epidemics.rs b/raphtory/src/algorithms/dynamics/temporal/epidemics.rs index 04f0170c98..ca98fd040e 100644 --- a/raphtory/src/algorithms/dynamics/temporal/epidemics.rs +++ b/raphtory/src/algorithms/dynamics/temporal/epidemics.rs @@ -99,12 +99,17 @@ impl, V: AsNodeRef + Debug> IntoSeeds for I { } impl IntoSeeds for Probability { + /// Seeds each node independently with this probability (Bernoulli sampling) fn into_initial_list( self, graph: &G, - _rng: &mut R, + rng: &mut R, ) -> Result, SeedError> { - Ok(graph.nodes().iter().map(|node| node.node).collect()) + Ok(graph + .nodes() + .iter() + .filter_map(|node| self.sample(rng).then_some(node.node)) + .collect()) } } diff --git a/raphtory/src/errors.rs b/raphtory/src/errors.rs index 8da96abfec..c7273ca984 100644 --- a/raphtory/src/errors.rs +++ b/raphtory/src/errors.rs @@ -31,11 +31,11 @@ use raphtory_api::core::utils::time::ParseTimeError; #[cfg(feature = "search")] use {tantivy, tantivy::query::QueryParserError}; +use crate::algorithms::dynamics::temporal::epidemics::SeedError; use raphtory_api::core::storage::{graph_folder::GraphFolderError, timeindex::TimeError}; use storage::{error::StorageError, resolver::mapping_resolver::InvalidNodeId}; #[cfg(feature = "io")] use zip::result::ZipError; -use crate::algorithms::dynamics::temporal::epidemics::SeedError; #[derive(thiserror::Error, Debug)] pub enum InvalidPathReason { From 11915a2b6c2e66c6146c62de2f7e26e8052da41a Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Thu, 30 Jul 2026 06:47:26 -0400 Subject: [PATCH 30/39] Update schema.graphql --- raphtory-graphql/schema.graphql | 825 +++++++++++++++++++++++++++++++- 1 file changed, 824 insertions(+), 1 deletion(-) diff --git a/raphtory-graphql/schema.graphql b/raphtory-graphql/schema.graphql index 69f59269d9..fa57a2ad11 100644 --- a/raphtory-graphql/schema.graphql +++ b/raphtory-graphql/schema.graphql @@ -1,3 +1,512 @@ +""" +The algorithms that can be run on a graph view. +""" +type Algorithms { + """ + Returns the PageRank centrality of every node in the graph. + """ + pagerank( + """ + Number of iterations to run. Defaults to 20. + """ + iterCount: Int, + """ + Number of threads to use. Defaults to all available. + """ + threads: Int, + """ + Convergence tolerance. Defaults to 0.000001. + """ + tol: Float, + """ + Probability that the spread continues. Defaults to 0.85. + """ + dampingFactor: Float, + """ + Edge property to use as weight. If unset, all edges have weight 1. + """ + weight: String + ): NodeState! + """ + Returns the degree centrality of every node. + """ + degreeCentrality: NodeState! + """ + Returns the betweenness centrality of every node. + """ + betweennessCentrality( + """ + Number of nodes to sample. Defaults to all nodes. + """ + k: Int, + """ + Whether to normalize the values. Defaults to true. + """ + normalized: Boolean + ): NodeState! + """ + Returns the HITS hub and authority scores of every node. + """ + hits( + """ + Number of iterations to run. Defaults to 20. + """ + iterCount: Int, + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): NodeState! + """ + Returns the shortest (unweighted) path from `source` to every reachable node. + """ + singleSourceShortestPath( + """ + Source node id. + """ + source: String!, + """ + Optional maximum path length; stops the search once reached. + """ + cutoff: Int + ): NodeState! + """ + Returns the in component (all nodes that can reach it following out-edges) of every node. + """ + inComponents( + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): NodeState! + """ + Returns the out component (all reachable nodes following out-edges) of every node. + """ + outComponents( + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): NodeState! + """ + Returns the in component of a single node (nodes that can reach it, with their distance). + """ + inComponent( + """ + Node id. + """ + node: NodeId!, + """ + Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view. + """ + filter: ViewFilter + ): NodeState! + """ + Returns the out component of a single node (nodes it can reach, with their distance). + """ + outComponent( + """ + Node id. + """ + node: NodeId!, + """ + Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view. + """ + filter: ViewFilter + ): NodeState! + """ + Returns the local triangle count of a single node (0 if it has degree < 2), or null if + the node does not exist in the view. + """ + localTriangleCount( + """ + Node id. + """ + node: NodeId!, + """ + Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view. + """ + filter: ViewFilter + ): Int + """ + Returns the local clustering coefficient of a single node (0 if it has degree < 2), or + null if the node does not exist in the view. + """ + localClusteringCoefficient( + """ + Node id. + """ + node: NodeId!, + """ + Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view. + """ + filter: ViewFilter + ): Float + """ + Returns the weakly connected component id of every node. + """ + weaklyConnectedComponents: NodeState! + """ + Returns the strongly connected component id of every node. + """ + stronglyConnectedComponents: NodeState! + """ + Returns the community of every node (Louvain). + """ + louvain( + """ + Resolution parameter for modularity. Defaults to 1.0. + """ + resolution: Float, + """ + Edge property to use as weight. If unset, all edges have weight 1. + """ + weightProp: String, + """ + Convergence tolerance. Defaults to 1e-8. + """ + tol: Float, + """ + Seed for the node-shuffling rng. If unset, seeded from the OS. + """ + rngSeed: Int + ): NodeState! + """ + Returns the community of every node (label propagation). + """ + labelPropagation( + """ + Number of iterations to run. Defaults to 20. + """ + iterCount: Int, + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): NodeState! + """ + Returns the weighted shortest path from `source` to each of `targets` (Dijkstra). + """ + dijkstra( + """ + Source node id. + """ + source: String!, + """ + Target node ids. + """ + targets: [String!]!, + """ + Edge property to use as weight. If unset, all edges have weight 1. + """ + weight: String, + """ + Edge direction to follow. Defaults to BOTH. + """ + direction: Direction + ): NodeState! + """ + Returns the local reciprocity of every node. + """ + allLocalReciprocity: NodeState! + """ + Returns the net sum of edge weights (balance) of every node. + """ + balance( + """ + Edge property to use as weight. Defaults to `weight`. + """ + name: String, + """ + Edge direction to consider. Defaults to BOTH. + """ + direction: Direction + ): NodeState! + """ + Returns the local clustering coefficient of each of the given nodes. + """ + localClusteringCoefficientBatch( + """ + Node ids to compute the coefficient for. + """ + nodes: [String!]! + ): NodeState! + """ + Returns the global clustering coefficient of the graph. + """ + globalClusteringCoefficient: Float! + """ + Returns the directed graph density (fraction of possible directed edges present). + """ + directedGraphDensity: Float! + """ + Returns the global reciprocity of the graph. + """ + globalReciprocity: Float! + """ + Returns the average (undirected) degree of the graph's nodes. + """ + averageDegree: Float! + """ + Returns the maximum (undirected) degree of any node in the graph. + """ + maxDegree: Int! + """ + Returns the minimum (undirected) degree of any node in the graph. + """ + minDegree: Int! + """ + Returns the maximum out-degree of any node in the graph. + """ + maxOutDegree: Int! + """ + Returns the maximum in-degree of any node in the graph. + """ + maxInDegree: Int! + """ + Returns the minimum out-degree of any node in the graph. + """ + minOutDegree: Int! + """ + Returns the minimum in-degree of any node in the graph. + """ + minInDegree: Int! + """ + Returns the number of connected triplets (paths of length 2) in the graph. + """ + tripletCount( + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): Int! + """ + Returns the number of triangles in the graph. + """ + triangleCount( + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): Int! + """ + Returns the FastRP embedding of every node. + """ + fastRp( + """ + Dimension of the embedding. + """ + embeddingDim: Int!, + """ + Normalization strength applied to neighbour contributions. + """ + normalizationStrength: Float!, + """ + Weight of each iteration's contribution to the embedding. + """ + iterWeights: [Float!]!, + """ + Seed for the rng. If unset, seeded from the OS. + """ + seed: Int, + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): NodeState! + """ + Returns the nodes temporally reachable from `seedNodes` starting at `startTime`. + """ + temporallyReachableNodes( + """ + Maximum number of hops to traverse. + """ + maxHops: Int!, + """ + Time at which the traversal starts. + """ + startTime: Int!, + """ + Node ids to start from. + """ + seedNodes: [String!]!, + """ + Node ids that halt the traversal when reached. + """ + stopNodes: [String!], + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): NodeState! + """ + Returns the 2D layout position of every node (Fruchterman-Reingold). + """ + fruchtermanReingold( + """ + Number of iterations to run. Defaults to 100. + """ + iterCount: Int, + """ + Scale of the layout. Defaults to 1.0. + """ + scale: Float, + """ + Initial node size. Defaults to 1.0. + """ + nodeStartSize: Float, + """ + Cooloff factor. Defaults to 0.95. + """ + cooloffFactor: Float, + """ + Time step. Defaults to 0.1. + """ + dt: Float + ): NodeState! + """ + Returns the 2D layout position of every node (cohesive Fruchterman-Reingold). + """ + cohesiveFruchtermanReingold( + """ + Number of iterations to run. Defaults to 100. + """ + iterCount: Int, + """ + Scale of the layout. Defaults to 1.0. + """ + scale: Float, + """ + Initial node size. Defaults to 1.0. + """ + nodeStartSize: Float, + """ + Cooloff factor. Defaults to 0.95. + """ + cooloffFactor: Float, + """ + Time step. Defaults to 0.1. + """ + dt: Float + ): NodeState! + """ + Returns the local temporal three-node motif counts of every node. + """ + localTemporalThreeNodeMotifs( + """ + Maximum time difference between the first and last edge of a motif. + """ + delta: Int!, + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): NodeState! + """ + Returns the graph-wide temporal three-node motif counts: 40 counts in a + fixed order (8 two-node, 24 star, then 8 triangle motifs). + """ + globalTemporalThreeNodeMotif( + """ + Maximum time difference between the first and last edge of a motif. + """ + delta: Int!, + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): [Int!]! + """ + Returns the graph-wide temporal three-node motif counts for each of + `deltas`, one row of 40 counts per delta, in the order given. + """ + globalTemporalThreeNodeMotifMulti( + """ + Maximum time differences to compute the motif counts for. + """ + deltas: [Int!]!, + """ + Number of threads to use. Defaults to all available. + """ + threads: Int + ): [MotifCounts!]! + """ + Returns the temporal rich club coefficient: the maximal density among the + nodes of degree at least `k` that persists over `windowSize` consecutive + snapshots. The snapshots are the rolling windows described by + `rollingWindow` / `rollingStep`. + """ + temporalRichClubCoefficient( + """ + Minimum degree a node must have to be in the rich club. + """ + k: Int!, + """ + Number of consecutive snapshots the edges must persist over. + """ + windowSize: Int!, + """ + Width of each snapshot. + """ + rollingWindow: WindowDuration!, + """ + Optional gap between the start of one snapshot and the next. Defaults to `rollingWindow`, i.e. non-overlapping snapshots. + """ + rollingStep: WindowDuration + ): Float! + """ + Returns an alternating boolean mask over the nodes. + """ + alternatingMask: NodeState! + """ + Simulates an SEIR epidemic, returning the infection, activation and + recovery times of every node that was infected. + """ + temporalSeir( + """ + How the initially infected nodes are chosen. + """ + seeds: Seeds!, + """ + Probability that an encounter between an active and a susceptible node infects it. + """ + infectionProb: Float!, + """ + Time of the initial infection. + """ + initialInfection: TimeInput!, + """ + Rate at which infected nodes recover. If unset, nodes never recover. + """ + recoveryRate: Float, + """ + Rate at which infected nodes become infectious. If unset, they are infectious immediately. + """ + incubationRate: Float, + """ + Seed for the random number generator. If unset, seeded from the OS. + """ + rngSeed: Int + ): NodeState! + """ + Returns a maximum weight matching of the graph, treated as undirected. + """ + maxWeightMatching( + """ + Edge property to use as weight. If unset, all edges have weight 1. + """ + weightProp: String, + """ + Only consider maximum-cardinality matchings. Defaults to false. + """ + maxCardinality: Boolean, + """ + Verify that the matching found is optimum. Defaults to false. + """ + verifyOptimum: Boolean + ): Matching! +} + """ Alignment unit used to align window boundaries. """ @@ -156,6 +665,15 @@ input DegreeFilterNew { where: PropCondition! } +""" +Edge direction to follow during traversal. +""" +enum Direction { + OUT + IN + BOTH +} + """ Document in a vector graph """ @@ -1764,6 +2282,10 @@ type Graph { """ algorithms: GraphAlgorithmPlugin! """ + Access the algorithms that can be run on this graph view. + """ + algorithm: Algorithms! + """ Nodes that are neighbours of every node in `selectedNodes`. Returns the intersection of each selected node's neighbour set (undirected). """ @@ -2561,6 +3083,69 @@ type LayerSchema { edges: [EdgeSchema!]! } +""" +A matching of a graph: a set of edges no two of which share a node. +""" +type Matching { + """ + Returns the number of edges in the matching. + """ + count: Int! + """ + The edges in the matching. + """ + edges: Edges! + """ + The node matched to `dst`, null if it is unmatched. + """ + src( + """ + Destination node id. + """ + dst: NodeId! + ): Node + """ + The node matched to `src`, null if it is unmatched. + """ + dst( + """ + Source node id. + """ + src: NodeId! + ): Node + """ + The matched edge for `src`, null if it is unmatched. + """ + edgeForSrc( + """ + Source node id. + """ + src: NodeId! + ): Edge + """ + The matched edge for `dst`, null if it is unmatched. + """ + edgeForDst( + """ + Destination node id. + """ + dst: NodeId! + ): Edge + """ + Whether the `src` to `dst` edge is part of the matching. + """ + contains( + """ + Source node id. + """ + src: NodeId!, + """ + Destination node id. + """ + dst: NodeId! + ): Boolean! +} + """ Lightweight summary of a stored graph — its name, path, counts, and filesystem timestamps — served without deserializing the full graph. @@ -2652,6 +3237,21 @@ type Metadata { ): [Property!]! } +""" +The motif counts for a single delta. Wraps the counts in an object because +the schema builder does not support nested lists of scalars. +""" +type MotifCounts { + """ + The delta these counts were computed for. + """ + delta: Int! + """ + The 40 motif counts, positionally ordered (see the core docs). + """ + counts: [Int!]! +} + type MutRoot { """ Returns a collection of mutation plugins. @@ -3894,6 +4494,192 @@ input NodeSortBy { property: String } +""" +A mapping from the nodes of a graph to the values computed for them by an algorithm. + +The output is columnar: every column of the underlying node state is exposed as +a `NodeStateColumn` whose `values` are row-aligned with `nodes`. +""" +type NodeState { + """ + Returns the number of nodes with a value in this node state. + """ + count: Int! + """ + The nodes with a value in this node state, in row order. Aligned with `values`. + """ + nodes: Nodes! + """ + The column names of this node state in order. + """ + columnNames: [String!]! + """ + All rows of the node state keyed by node, with one entry per column. + """ + rows: [NodeStateRow!]! + """ + All rows of the node state keyed by node, without the column names: the `values` of each row are + in `columnNames` order. + """ + headlessRows: [NodeStateHeadlessRow!]! + """ + Returns the values for a node, one entry per column; null if the node has no value in this NodeState. + """ + get( + """ + Node id. + """ + node: NodeId! + ): [NodeStateEntry!] + """ + Minimum `(node, value)` of a column. Null if the column does not exist, is empty, + or its values are not comparable (e.g. contains nodes). + """ + min( + """ + Column name. + """ + column: String! + ): NodeStateItem + """ + Maximum `(node, value)` of a column. Null if the column does not exist, is empty, + or its values are not comparable (e.g. contains nodes). + """ + max( + """ + Column name. + """ + column: String! + ): NodeStateItem + """ + Sum of a column's values, skipping empty cells. Null if the column does not exist, is empty, + or is not additive (e.g. contains nodes). + """ + sum( + """ + Column name. + """ + column: String! + ): PropertyOutput + """ + Mean of a column's values as a float, skipping empty cells. Null if the column does not exist, + is empty, or has any non-numeric value. + """ + mean( + """ + Column name. + """ + column: String! + ): PropertyOutput + """ + Median `(node, value)` of a column (upper median on even lengths). Null if the column + does not exist, is empty, or is not comparable (e.g. contains nodes). + """ + median( + """ + Column name. + """ + column: String! + ): NodeStateItem + """ + Returns a view of this node state with the rows sorted by node id. + """ + sortById: NodeState! + """ + The columns of the node state, one per output field of the algorithm. + `values` are row-aligned with `nodes`. + """ + columns: [NodeStateColumn!]! +} + +""" +One column of a node state: the values of a single output field of the +algorithm. Row-aligned with `NodeState.nodes`. +""" +type NodeStateColumn { + """ + Name of the column. + """ + name: String! + """ + The values of this column; `values[i]` belongs to `NodeState.nodes[i]`. + """ + values: [NodeStateValue!]! +} + +""" +One column's value for a single node. +""" +type NodeStateEntry { + """ + Name of the column. + """ + columnName: String! + """ + The node's value in this column. + """ + value: NodeStateValue! +} + +""" +A node's full row in the node state without the column names: `values[i]` +belongs to the column `NodeState.columnNames[i]`. +""" +type NodeStateHeadlessRow { + """ + The node this row belongs to. + """ + node: Node! + """ + The row's values, in `columnNames` order. + """ + values: [NodeStateValue!]! +} + +""" +A `(node, value)` pair, e.g. the result of a column aggregate. +""" +type NodeStateItem { + """ + The node. + """ + node: Node! + """ + The node's value. + """ + value: PropertyOutput! +} + +""" +A plain property value of a node state cell. +""" +type NodeStateProp { + """ + The property value; null if the node has no value in this column. + """ + prop: PropertyOutput +} + +""" +A node's full row in the node state: one entry per column. +""" +type NodeStateRow { + """ + The node this row belongs to. + """ + node: Node! + """ + The row's values, one entry per column. + """ + entries: [NodeStateEntry!]! +} + +""" +A single cell of a node state column: either a plain property value, a +node, or a collection of nodes. +""" +union NodeStateValue = NodeStateProp | Node | Nodes + """ Restricts node evaluation to a single time bound and applies a nested `NodeFilter`. @@ -5252,6 +6038,24 @@ type QueryRoot { version: String! } +""" +How the initially infected nodes are chosen. +""" +input Seeds @oneOf { + """ + Infect exactly these nodes. + """ + nodes: [NodeId!] + """ + Infect this many randomly chosen nodes. + """ + number: Int + """ + Infect this fraction of the nodes, chosen at random. + """ + probability: Float +} + type ShortestPathOutput { target: String! nodes: [String!]! @@ -5676,6 +6480,26 @@ input VectorisedGraphWindow { end: TimeInput! } +""" +A composite filter producing a graph view, bundling the graph-view, node, +and edge filters that are otherwise applied via the separate +`filter` / `filterNodes` / `filterEdges` resolvers. +""" +input ViewFilter { + """ + Graph-view filter (time windows, snapshots, layers). + """ + graph: GraphFilter + """ + Node filter (field, property, metadata, degree; composes with and/or/not). + """ + nodes: NodeFilter + """ + Edge filter (src/dst, property, layer; composes with and/or/not). + """ + edges: EdgeFilter +} + input Window { """ Window start time. @@ -5720,4 +6544,3 @@ schema { query: QueryRoot mutation: MutRoot } - From f77fa8ee594549210285305a9fbcffa189acef33 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Fri, 31 Jul 2026 04:45:14 -0400 Subject: [PATCH 31/39] Added top_k, bottom_k, sort_by_values, and group_by to GqlNodeState --- raphtory-graphql/src/lib.rs | 108 +++++++++++ .../src/model/graph/node_state.rs | 170 ++++++++++++++++-- 2 files changed, 264 insertions(+), 14 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index cb876ec7ab..a52d5d1698 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -2506,6 +2506,114 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_node_state_top_k_and_sorting() { + let graph = Graph::new(); + // asymmetric graph so every node has a distinct pagerank: + // a = 0.1976, b = 0.2816, c = 0.5209 + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + topTwo: topK(column: "pagerank_score", k: 2) { nodes { ids } } + bottomTwo: bottomK(column: "pagerank_score", k: 2) { nodes { ids } } + ascending: sortByValues(column: "pagerank_score") { nodes { ids } } + descending: sortByValues(column: "pagerank_score", reverse: true) { nodes { ids } } + missingColumn: topK(column: "nope", k: 2) { count } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "pagerank": { + // largest first, smallest first + "topTwo": { "nodes": { "ids": ["c", "b"] } }, + "bottomTwo": { "nodes": { "ids": ["a", "b"] } }, + "ascending": { "nodes": { "ids": ["a", "b", "c"] } }, + "descending": { "nodes": { "ids": ["c", "b", "a"] } }, + "missingColumn": null + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_node_state_group_by() { + let graph = Graph::new(); + // two connected components, so wcc gives two distinct component ids + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + weaklyConnectedComponents { + groupBy(column: "component_id") { + value + nodes { ids } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // group order and node order within a group are both unordered + let mut data = res.data.into_json().unwrap(); + let groups = data["graph"]["algorithm"]["weaklyConnectedComponents"]["groupBy"] + .as_array_mut() + .unwrap(); + for group in groups.iter_mut() { + group["nodes"]["ids"] + .as_array_mut() + .unwrap() + .sort_by_key(|id| id.as_str().unwrap().to_string()); + } + groups.sort_by_key(|group| group["nodes"]["ids"][0].as_str().unwrap().to_string()); + // a-b and c-d each form their own component + assert_eq!(groups.len(), 2); + assert_eq!(groups[0]["nodes"]["ids"], json!(["a", "b"])); + assert_eq!(groups[1]["nodes"]["ids"], json!(["c", "d"])); + assert_ne!(groups[0]["value"], groups[1]["value"]); + + // grouping a node-valued column is rejected + let query = r#" + { + graph(path: "g") { + algorithm { + outComponents { groupBy(column: "out_components") { value } } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ "graph": { "algorithm": { "outComponents": { "groupBy": null } } } }) + ); + } + #[tokio::test] async fn test_algorithm_node_state_aggregates() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 1d1e34dbf5..80f2214d53 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -18,7 +18,11 @@ use raphtory::{ prelude::{NodeStateOps, Prop}, }; use raphtory_api::core::entities::properties::prop::PropUnwrap; -use std::{cmp::Ordering, sync::Arc}; +use std::{ + cmp::Ordering, + hash::{Hash, Hasher}, + sync::Arc, +}; /// A mapping from the nodes of a graph to the values computed for them by an algorithm. /// @@ -113,6 +117,16 @@ pub(crate) struct GqlNodeStateRow { entries: Vec, } +/// The nodes sharing one value of a column, as returned by `groupBy`. +#[derive(SimpleObject, Clone)] +#[graphql(name = "NodeStateGroup")] +pub(crate) struct GqlNodeStateGroup { + /// The value shared by the nodes in this group; null if their cell is empty. + value: Option, + /// The nodes holding that value. + nodes: GqlNodes, +} + /// A node's full row in the node state without the column names: `values[i]` /// belongs to the column `NodeState.columnNames[i]`. #[derive(SimpleObject, Clone)] @@ -154,24 +168,58 @@ fn column_cmp<'a>( } } +/// A column value used as a `group_by` key. `Prop` is only `PartialEq`, so both +/// equality and hashing go through its debug representation, which distinguishes +/// variants as well as values. Computed once per row rather than per comparison. +/// Note that this groups `NaN` with itself, unlike `PartialEq` on floats. +#[derive(Clone, Debug)] +struct GroupKey { + value: Option, + repr: String, +} + +impl GroupKey { + fn new(value: Option) -> Self { + let repr = format!("{value:?}"); + Self { value, repr } + } +} + +impl PartialEq for GroupKey { + fn eq(&self, other: &Self) -> bool { + self.repr == other.repr + } +} + +impl Eq for GroupKey {} + +impl Hash for GroupKey { + fn hash(&self, state: &mut H) { + self.repr.hash(state); + } +} + impl GqlNodeState { + /// Whether `column` exists and holds plain property values rather than nodes. + fn is_prop_column(&self, column: &str) -> bool { + !self.node_state.state.node_cols.contains_key(column) + && self + .node_state + .state + .values_ref() + .schema() + .index_of(column) + .is_ok() + } + /// Iterator over the non-empty values of a plain-prop column. /// None if the column does not exist or contains nodes. fn column_value_iter<'a>(&'a self, column: &'a str) -> Option + 'a> { - if self.node_state.state.node_cols.contains_key(column) { - return None; - } - self.node_state - .state - .values_ref() - .schema() - .index_of(column) - .ok()?; - Some( + self.is_prop_column(column).then(|| { self.node_state .iter() - .filter_map(move |(_, mut row)| Some(row.swap_remove(column)??.into())), - ) + .filter_map(move |(_, mut row)| Some(row.swap_remove(column)??.into())) + }) } /// Checks that `column` is a plain-prop column with at least one non-empty, comparable value; @@ -198,7 +246,7 @@ impl GqlNodeState { } } -// TODO: add paging: `columns`/`nodes` currently dump every row. +// TODO: add paging: `columns`/`nodes`/`rows` currently dump every row. // TODO: still to be implemented, blocked on the datafusion feature gate (CVE): // `sortBy` (`GenericNodeState::sort_by`), `topK` (`GenericNodeState::top_k`), @@ -390,6 +438,100 @@ impl GqlNodeState { .await } + /// Returns the `k` rows with the largest values in a column. Empty cells rank + /// lowest, so they are only included if fewer than `k` rows have a value. + /// Null if the column does not exist, is empty, or is not comparable. + async fn top_k( + &self, + #[graphql(desc = "Column name.")] column: String, + #[graphql(desc = "Number of rows to return.")] k: usize, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || { + self_clone.check_comparable(&column)?; + Some(GqlNodeState { + node_state: self_clone + .node_state + .top_k_by(column_cmp(&column, false), k), + }) + }) + .await + } + + /// Returns the `k` rows with the smallest values in a column. Empty cells rank + /// highest, so they are only included if fewer than `k` rows have a value. + /// Null if the column does not exist, is empty, or is not comparable. + async fn bottom_k( + &self, + #[graphql(desc = "Column name.")] column: String, + #[graphql(desc = "Number of rows to return.")] k: usize, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || { + self_clone.check_comparable(&column)?; + Some(GqlNodeState { + node_state: self_clone + .node_state + .bottom_k_by(column_cmp(&column, true), k), + }) + }) + .await + } + + /// Returns a view of this node state with the rows sorted by a column's values, + /// ascending with empty cells last. `reverse` flips the whole ordering, putting + /// empty cells first. Null if the column does not exist, is empty, or is not + /// comparable. + async fn sort_by_values( + &self, + #[graphql(desc = "Column name.")] column: String, + #[graphql(desc = "Sort in descending order instead. Defaults to false.")] reverse: Option< + bool, + >, + ) -> Option { + let self_clone = self.clone(); + blocking_compute(move || { + self_clone.check_comparable(&column)?; + let cmp = column_cmp(&column, true); + let node_state = if reverse.unwrap_or(false) { + self_clone + .node_state + .sort_by_values_by(|a, b| cmp(a, b).reverse()) + } else { + self_clone.node_state.sort_by_values_by(cmp) + }; + Some(GqlNodeState { node_state }) + }) + .await + } + + /// Groups the nodes by their value in a column. Nodes with an empty cell form + /// their own group. Null if the column does not exist or contains nodes. + async fn group_by( + &self, + #[graphql(desc = "Column name.")] column: String, + ) -> Option> { + let self_clone = self.clone(); + blocking_compute(move || { + if !self_clone.is_prop_column(&column) { + return None; + } + let groups = self_clone.node_state.group_by(|mut row| { + GroupKey::new(row.swap_remove(&column).flatten().map(|value| value.0)) + }); + Some( + groups + .into_iter_groups() + .map(|(key, nodes)| GqlNodeStateGroup { + value: key.value.map(GqlPropertyOutputVal), + nodes: GqlNodes::new(nodes), + }) + .collect(), + ) + }) + .await + } + /// Returns a view of this node state with the rows sorted by node id. async fn sort_by_id(&self) -> GqlNodeState { let self_clone = self.clone(); From 8e21120dd6ee14d86a28e81045c40f05a26bebdd Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Fri, 31 Jul 2026 05:08:11 -0400 Subject: [PATCH 32/39] Update TODO and FIXME --- raphtory-graphql/src/model/graph/node_state.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 80f2214d53..59fae3a2ff 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -248,11 +248,7 @@ impl GqlNodeState { // TODO: add paging: `columns`/`nodes`/`rows` currently dump every row. -// TODO: still to be implemented, blocked on the datafusion feature gate (CVE): -// `sortBy` (`GenericNodeState::sort_by`), `topK` (`GenericNodeState::top_k`), -// `groupBy` (`TypedNodeState::get_groups`). -// -// Not exposed: `merge` (takes a second NodeState, which cannot be a query argument) +// FIXME: Not exposed: `merge` (takes a second NodeState, which cannot be a query argument) // and `to_parquet`/`from_parquet` (avoid server-side filesystem access). #[ResolvedObjectFields] impl GqlNodeState { From 29eac508a780b07f3e5337c7d34ef8247a470441 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Tue, 4 Aug 2026 02:35:25 -0400 Subject: [PATCH 33/39] Added paging for GqlNodeState --- raphtory-graphql/src/lib.rs | 103 ++++++++++++++++++ .../src/model/graph/node_state.rs | 55 +++++++++- 2 files changed, 154 insertions(+), 4 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index a52d5d1698..56ded41a02 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -2506,6 +2506,109 @@ mod graphql_test { ); } + #[tokio::test] + async fn test_algorithm_node_state_page() { + let graph = Graph::new(); + // a chain a -> b -> c -> d -> e, so the state has 5 rows + for (src, dst) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); + } + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // a page is itself a NodeState, so nodes/columns on it stay row-aligned + let query = r#" + { + graph(path: "g") { + algorithm { + degreeCentrality { + first: page(limit: 2) { + count + nodes { ids } + columns { name values { ... on NodeStateProp { prop } } } + } + second: page(limit: 2, pageIndex: 1) { nodes { ids } } + withOffset: page(limit: 2, offset: 1) { nodes { ids } } + lastPartial: page(limit: 2, pageIndex: 2) { nodes { ids } } + pastEnd: page(limit: 2, pageIndex: 99) { count nodes { ids } } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // the first page holds the first two nodes with their values still aligned, + // `offset` shifts by rows rather than pages, the final page is short rather + // than padded, and paging past the end is empty rather than an error + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "degreeCentrality": { + "first": { + "count": 2, + "nodes": { "ids": ["a", "b"] }, + "columns": [{ + "name": "degree_centrality", + "values": [{ "prop": 0.5 }, { "prop": 1.0 }] + }] + }, + "second": { "nodes": { "ids": ["c", "d"] } }, + "withOffset": { "nodes": { "ids": ["b", "c"] } }, + "lastPartial": { "nodes": { "ids": ["e"] } }, + "pastEnd": { "count": 0, "nodes": { "ids": [] } } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_node_state_page_composes() { + let graph = Graph::new(); + // asymmetric graph so every node has a distinct pagerank: + // a = 0.1976, b = 0.2816, c = 0.5209 + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // a page is a NodeState, so it chains with the other operations + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + sortedThenPaged: sortByValues(column: "pagerank_score", reverse: true) { + page(limit: 2) { nodes { ids } } + } + pagedThenAggregated: page(limit: 2) { + max(column: "pagerank_score") { node { id } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "pagerank": { + // top two by score, descending + "sortedThenPaged": { "page": { "nodes": { "ids": ["c", "b"] } } }, + // the aggregate only sees the page's rows (a, b), so b wins + "pagedThenAggregated": { "max": { "node": { "id": "b" } } } + } } } + }) + ); + } + #[tokio::test] async fn test_algorithm_node_state_top_k_and_sorting() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 59fae3a2ff..760a711d77 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -1,15 +1,18 @@ use crate::{ model::graph::{ - node::GqlNode, node_id::GqlNodeId, nodes::GqlNodes, property::GqlPropertyOutputVal, + collection::check_page_limit, node::GqlNode, node_id::GqlNodeId, nodes::GqlNodes, + property::GqlPropertyOutputVal, }, rayon::blocking_compute, }; -use dynamic_graphql::{ResolvedObject, ResolvedObjectFields, SimpleObject, Union}; +use async_graphql::Context; +use dynamic_graphql::{ResolvedObject, ResolvedObjectFields, Result, SimpleObject, Union}; use raphtory::{ db::{ api::{ state::{ - NodeStateOutput, NodeStateValue, OutputTypedNodeState, PropMap, TypedNodeState, + GenericNodeState, Index, NodeStateOutput, NodeStateValue, OutputTypedNodeState, + PropMap, TypedNodeState, }, view::{BoxableGraphView, DynamicGraph}, }, @@ -17,7 +20,7 @@ use raphtory::{ }, prelude::{NodeStateOps, Prop}, }; -use raphtory_api::core::entities::properties::prop::PropUnwrap; +use raphtory_api::core::entities::{properties::prop::PropUnwrap, VID}; use std::{ cmp::Ordering, hash::{Hash, Hasher}, @@ -200,6 +203,24 @@ impl Hash for GroupKey { } impl GqlNodeState { + /// A slice of `limit` rows starting at `start`, as a node state of its own. + fn slice(&self, start: usize, limit: usize) -> GqlNodeState { + let state = &self.node_state.state; + let start = start.min(self.node_state.len()); + let limit = limit.min(self.node_state.len() - start); + let values = state.values_ref().slice(start, limit); + let keys: Index = state.keys_ref().iter().skip(start).take(limit).collect(); + GqlNodeState { + node_state: GenericNodeState::new( + state.base_graph.clone(), + values, + keys, + Some(state.node_cols.clone()), + ) + .to_output_nodestate(), + } + } + /// Whether `column` exists and holds plain property values rather than nodes. fn is_prop_column(&self, column: &str) -> bool { !self.node_state.state.node_cols.contains_key(column) @@ -528,6 +549,32 @@ impl GqlNodeState { .await } + /// Returns one page of this node state as a node state of its own, so that + /// `nodes` / `rows` / `columns` on it stay row-aligned with each other. + /// Pages past the end are empty rather than an error. + /// + /// For example, if page(limit: 5, offset: 1, page_index: 2) is called, a page with 5 items, + /// offset by 11 items (2 pages of 5 + 1), will be returned. + async fn page( + &self, + ctx: &Context<'_>, + #[graphql(desc = "Maximum number of rows to return on this page.")] limit: usize, + #[graphql(desc = "Extra rows to skip on top of `pageIndex` paging (default 0).")] + offset: Option, + #[graphql( + desc = "Zero-based page number; multiplies `limit` to determine where to start (default 0)." + )] + page_index: Option, + ) -> Result { + check_page_limit(ctx, limit)?; + let self_clone = self.clone(); + Ok(blocking_compute(move || { + let start = page_index.unwrap_or(0) * limit + offset.unwrap_or(0); + self_clone.slice(start, limit) + }) + .await) + } + /// Returns a view of this node state with the rows sorted by node id. async fn sort_by_id(&self) -> GqlNodeState { let self_clone = self.clone(); From 30d04790fb8a9807ac0dd9962794d5369ea83ccf Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Tue, 4 Aug 2026 18:19:26 -0400 Subject: [PATCH 34/39] Moving algorithm, node state, and filtering tests out of lib.rs and into respective files --- raphtory-graphql/src/lib.rs | 715 +----------------- .../src/model/algorithms/in_component.rs | 50 ++ .../src/model/algorithms/out_component.rs | 56 ++ raphtory-graphql/src/model/graph/filtering.rs | 229 ++++++ .../src/model/graph/node_state.rs | 427 +++++++++++ 5 files changed, 764 insertions(+), 713 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 09f4e9ed4e..a0a5a41c64 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -430,72 +430,7 @@ mod graphql_test { graph } - #[tokio::test] - async fn test_algorithm_node_state_ops() { - let graph = Graph::new(); - // insert out of id order so sortById is meaningful - graph.add_edge(1, "c", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "a", NO_PROPS, None).unwrap(); - graph.add_edge(3, "a", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - pagerank(iterCount: 20) { - get(node: "b") { - columnName - value { - __typename - ... on NodeStateProp { prop } - } - } - missing: get(node: "not-a-node") { columnName } - sortById { - nodes { list { name } } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // in a 3-cycle all nodes have the same rank of 1/3 - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { - "algorithm": { - "pagerank": { - "get": [ - { - "columnName": "pagerank_score", - "value": { "__typename": "NodeStateProp", "prop": 0.3333333333333333 } - } - ], - "missing": null, - "sortById": { - "nodes": { - "list": [ - { "name": "a" }, - { "name": "b" }, - { "name": "c" } - ] - } - } - } - } - } - }) - ); - } - - fn single_component_test_graph() -> MaterializedGraph { + pub(crate) fn single_component_test_graph() -> MaterializedGraph { let graph = Graph::new(); // chain a -> b -> c -> d for (src, dst) in [("a", "b"), ("b", "c"), ("c", "d")] { @@ -504,122 +439,7 @@ mod graphql_test { graph.into() } - #[tokio::test] - async fn test_algorithm_out_component() { - let tmp_dir = tempdir().unwrap(); - let setup = - setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; - - // out component of a: nodes reachable following out-edges, keyed by distance - let query = r#" - { - graph(path: "g") { - algorithm { - outComponent(node: "a") { - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // a reaches b (1), c (2), d (3); source itself is not included - let entry = |id: &str, distance| { - json!({ - "node": { "id": id }, - "entries": [{ "columnName": "distance", "value": { "prop": distance } }] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "outComponent": { "rows": [ - entry("b", 1), - entry("c", 2), - entry("d", 3), - ] } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_in_component() { - let tmp_dir = tempdir().unwrap(); - let setup = - setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; - - // in component of d: nodes that can reach it, keyed by distance - let query = r#" - { - graph(path: "g") { - algorithm { - inComponent(node: "d") { - nodes { list { id } } - columns { - name - values { ... on NodeStateProp { prop } } - } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // a (3), b (2), c (1) can reach d; row order follows the key index (a, b, c) - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "inComponent": { - "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] }, - "columns": [{ - "name": "distance", - "values": [{ "prop": 3 }, { "prop": 2 }, { "prop": 1 }] - }] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_out_component_filtered() { - let tmp_dir = tempdir().unwrap(); - let setup = - setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; - - // composite filter with a node filter removing c; a can then only reach b - let query = r#" - { - graph(path: "g") { - algorithm { - outComponent(node: "a", filter: { nodes: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } }) { - nodes { list { id } } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // with c removed, a only reaches b (d is now unreachable) - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "outComponent": { - "nodes": { "list": [{ "id": "b" }] } - } } } - }) - ); - } - - fn star_test_graph() -> MaterializedGraph { + pub(crate) fn star_test_graph() -> MaterializedGraph { let graph = Graph::new(); // star out of a: a -> b, a -> c, a -> d for (src, dst) in [("a", "b"), ("a", "c"), ("a", "d")] { @@ -628,184 +448,6 @@ mod graphql_test { graph.into() } - #[tokio::test] - async fn test_algorithm_out_component_node_filter_composed() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", star_test_graph())], tmp_dir.path()).await; - - // NodeFilter and: both clauses must apply. Dropping b AND c leaves only d - // in a's out component (dropping just one would leave two nodes). - let query = r#" - { - graph(path: "g") { - algorithm { - outComponent(node: "a", filter: { nodes: { - and: [ - { node: { field: NODE_NAME, where: { ne: { str: "b" } } } }, - { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } - ] - } }) { - nodes { list { id } } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "outComponent": { - "nodes": { "list": [{ "id": "d" }] } - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_out_component_edge_filter_composed() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", star_test_graph())], tmp_dir.path()).await; - - // EdgeFilter and: both clauses must apply. Dropping edges to b AND to c - // leaves only a -> d, so a reaches only d. - let query = r#" - { - graph(path: "g") { - algorithm { - outComponent(node: "a", filter: { edges: { - and: [ - { dst: { node: { field: NODE_NAME, where: { ne: { str: "b" } } } } }, - { dst: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } } - ] - } }) { - nodes { list { id } } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "outComponent": { - "nodes": { "list": [{ "id": "d" }] } - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_out_component_graph_filter_composed() { - let graph = Graph::new(); - // edges at increasing times so a graph-view window changes reachability - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // GraphFilter composes via nested `expr`: window [1,3) then a further before(2). - // Only the a -> b edge (t=1) remains, so a reaches only b. - let query = r#" - { - graph(path: "g") { - algorithm { - outComponent(node: "a", filter: { graph: { - window: { start: 1, end: 3, expr: { before: { time: 2 } } } - } }) { - nodes { list { id } } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "outComponent": { - "nodes": { "list": [{ "id": "b" }] } - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_out_component_filter_equivalence() { - let tmp_dir = tempdir().unwrap(); - let setup = - setup_with_graphs(&[("g", single_component_test_graph())], tmp_dir.path()).await; - - // filter passed as an algorithm argument - let as_argument = r#" - { - graph(path: "g") { - algorithm { - outComponent(node: "a", filter: { nodes: { - node: { field: NODE_NAME, where: { ne: { str: "c" } } } - } }) { - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - "#; - // same filter applied to the graph view before calling the algorithm (no argument) - let pre_filtered = r#" - { - graph(path: "g") { - filterNodes(expr: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } }) { - algorithm { - outComponent(node: "a") { - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - } - "#; - - let arg_res = setup.schema.execute(Request::new(as_argument)).await; - assert_eq!(arg_res.errors, vec![], "{:?}", arg_res.errors); - let pre_res = setup.schema.execute(Request::new(pre_filtered)).await; - assert_eq!(pre_res.errors, vec![], "{:?}", pre_res.errors); - - // both routes reach the same result (b -> unwrap the identical outComponent payload) - let arg_out = - arg_res.data.into_json().unwrap()["graph"]["algorithm"]["outComponent"].clone(); - let pre_out = pre_res.data.into_json().unwrap()["graph"]["filterNodes"]["algorithm"] - ["outComponent"] - .clone(); - assert_eq!(arg_out, pre_out); - assert_eq!( - arg_out, - json!({ - "rows": [{ - "node": { "id": "b" }, - "entries": [{ "columnName": "distance", "value": { "prop": 1 } }] - }] - }) - ); - } - #[tokio::test] async fn test_algorithm_fast_rp() { let graph = Graph::new(); @@ -2270,359 +1912,6 @@ mod graphql_test { ); } - #[tokio::test] - async fn test_algorithm_node_state_rows() { - let graph = Graph::new(); - // asymmetric graph so every node has a distinct pagerank - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - pagerank(iterCount: 20) { - columnNames - rows { - node { name } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - headlessRows { - node { name } - values { ... on NodeStateProp { prop } } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { - "algorithm": { - "pagerank": { - "columnNames": ["pagerank_score"], - "rows": [ - { - "node": { "name": "a" }, - "entries": [ - { "columnName": "pagerank_score", "value": { "prop": 0.197580035313204 } } - ] - }, - { - "node": { "name": "b" }, - "entries": [ - { "columnName": "pagerank_score", "value": { "prop": 0.28155081033755053 } } - ] - }, - { - "node": { "name": "c" }, - "entries": [ - { "columnName": "pagerank_score", "value": { "prop": 0.5208691543492454 } } - ] - } - ], - "headlessRows": [ - { - "node": { "name": "a" }, - "values": [ { "prop": 0.197580035313204 } ] - }, - { - "node": { "name": "b" }, - "values": [ { "prop": 0.28155081033755053 } ] - }, - { - "node": { "name": "c" }, - "values": [ { "prop": 0.5208691543492454 } ] - } - ] - } - } - } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_node_state_page() { - let graph = Graph::new(); - // a chain a -> b -> c -> d -> e, so the state has 5 rows - for (src, dst) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")] { - graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); - } - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // a page is itself a NodeState, so nodes/columns on it stay row-aligned - let query = r#" - { - graph(path: "g") { - algorithm { - degreeCentrality { - first: page(limit: 2) { - count - nodes { ids } - columns { name values { ... on NodeStateProp { prop } } } - } - second: page(limit: 2, pageIndex: 1) { nodes { ids } } - withOffset: page(limit: 2, offset: 1) { nodes { ids } } - lastPartial: page(limit: 2, pageIndex: 2) { nodes { ids } } - pastEnd: page(limit: 2, pageIndex: 99) { count nodes { ids } } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // the first page holds the first two nodes with their values still aligned, - // `offset` shifts by rows rather than pages, the final page is short rather - // than padded, and paging past the end is empty rather than an error - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "degreeCentrality": { - "first": { - "count": 2, - "nodes": { "ids": ["a", "b"] }, - "columns": [{ - "name": "degree_centrality", - "values": [{ "prop": 0.5 }, { "prop": 1.0 }] - }] - }, - "second": { "nodes": { "ids": ["c", "d"] } }, - "withOffset": { "nodes": { "ids": ["b", "c"] } }, - "lastPartial": { "nodes": { "ids": ["e"] } }, - "pastEnd": { "count": 0, "nodes": { "ids": [] } } - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_node_state_page_composes() { - let graph = Graph::new(); - // asymmetric graph so every node has a distinct pagerank: - // a = 0.1976, b = 0.2816, c = 0.5209 - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // a page is a NodeState, so it chains with the other operations - let query = r#" - { - graph(path: "g") { - algorithm { - pagerank(iterCount: 20) { - sortedThenPaged: sortByValues(column: "pagerank_score", reverse: true) { - page(limit: 2) { nodes { ids } } - } - pagedThenAggregated: page(limit: 2) { - max(column: "pagerank_score") { node { id } } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "pagerank": { - // top two by score, descending - "sortedThenPaged": { "page": { "nodes": { "ids": ["c", "b"] } } }, - // the aggregate only sees the page's rows (a, b), so b wins - "pagedThenAggregated": { "max": { "node": { "id": "b" } } } - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_node_state_top_k_and_sorting() { - let graph = Graph::new(); - // asymmetric graph so every node has a distinct pagerank: - // a = 0.1976, b = 0.2816, c = 0.5209 - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - pagerank(iterCount: 20) { - topTwo: topK(column: "pagerank_score", k: 2) { nodes { ids } } - bottomTwo: bottomK(column: "pagerank_score", k: 2) { nodes { ids } } - ascending: sortByValues(column: "pagerank_score") { nodes { ids } } - descending: sortByValues(column: "pagerank_score", reverse: true) { nodes { ids } } - missingColumn: topK(column: "nope", k: 2) { count } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "pagerank": { - // largest first, smallest first - "topTwo": { "nodes": { "ids": ["c", "b"] } }, - "bottomTwo": { "nodes": { "ids": ["a", "b"] } }, - "ascending": { "nodes": { "ids": ["a", "b", "c"] } }, - "descending": { "nodes": { "ids": ["c", "b", "a"] } }, - "missingColumn": null - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_node_state_group_by() { - let graph = Graph::new(); - // two connected components, so wcc gives two distinct component ids - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "c", "d", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - weaklyConnectedComponents { - groupBy(column: "component_id") { - value - nodes { ids } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // group order and node order within a group are both unordered - let mut data = res.data.into_json().unwrap(); - let groups = data["graph"]["algorithm"]["weaklyConnectedComponents"]["groupBy"] - .as_array_mut() - .unwrap(); - for group in groups.iter_mut() { - group["nodes"]["ids"] - .as_array_mut() - .unwrap() - .sort_by_key(|id| id.as_str().unwrap().to_string()); - } - groups.sort_by_key(|group| group["nodes"]["ids"][0].as_str().unwrap().to_string()); - // a-b and c-d each form their own component - assert_eq!(groups.len(), 2); - assert_eq!(groups[0]["nodes"]["ids"], json!(["a", "b"])); - assert_eq!(groups[1]["nodes"]["ids"], json!(["c", "d"])); - assert_ne!(groups[0]["value"], groups[1]["value"]); - - // grouping a node-valued column is rejected - let query = r#" - { - graph(path: "g") { - algorithm { - outComponents { groupBy(column: "out_components") { value } } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ "graph": { "algorithm": { "outComponents": { "groupBy": null } } } }) - ); - } - - #[tokio::test] - async fn test_algorithm_node_state_aggregates() { - let graph = Graph::new(); - // asymmetric graph so every node has a distinct pagerank - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - pagerank(iterCount: 20) { - min(column: "pagerank_score") { node { name } value } - max(column: "pagerank_score") { node { name } value } - median(column: "pagerank_score") { node { name } value } - sum(column: "pagerank_score") - mean(column: "pagerank_score") - missing: min(column: "not_a_column") { value } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { - "algorithm": { - "pagerank": { - "min": { - "node": { "name": "a" }, - "value": 0.197580035313204 - }, - "max": { - "node": { "name": "c" }, - "value": 0.5208691543492454 - }, - "median": { - "node": { "name": "b" }, - "value": 0.28155081033755053 - }, - "sum": 1.0, - "mean": 0.3333333333333333, - "missing": null - } - } - } - }) - ); - } - #[tokio::test] async fn test_algorithm_pagerank() { let graph = Graph::new(); diff --git a/raphtory-graphql/src/model/algorithms/in_component.rs b/raphtory-graphql/src/model/algorithms/in_component.rs index 2fbb2ce390..c6f2ac3b10 100644 --- a/raphtory-graphql/src/model/algorithms/in_component.rs +++ b/raphtory-graphql/src/model/algorithms/in_component.rs @@ -27,3 +27,53 @@ impl GqlExecutableAlgorithm for GqlInComponent { Ok(in_component(node).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_in_component() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::single_component_test_graph())], + tmp_dir.path(), + ) + .await; + + // in component of d: nodes that can reach it, keyed by distance + let query = r#" + { + graph(path: "g") { + algorithm { + inComponent(node: "d") { + nodes { list { id } } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // a (3), b (2), c (1) can reach d; row order follows the key index (a, b, c) + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "inComponent": { + "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] }, + "columns": [{ + "name": "distance", + "values": [{ "prop": 3 }, { "prop": 2 }, { "prop": 1 }] + }] + } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/out_component.rs b/raphtory-graphql/src/model/algorithms/out_component.rs index 2490c25d56..6366fd7c95 100644 --- a/raphtory-graphql/src/model/algorithms/out_component.rs +++ b/raphtory-graphql/src/model/algorithms/out_component.rs @@ -27,3 +27,59 @@ impl GqlExecutableAlgorithm for GqlOutComponent { Ok(out_component(node).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_out_component() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::single_component_test_graph())], + tmp_dir.path(), + ) + .await; + + // out component of a: nodes reachable following out-edges, keyed by distance + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a") { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // a reaches b (1), c (2), d (3); source itself is not included + let entry = |id: &str, distance| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "distance", "value": { "prop": distance } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { "rows": [ + entry("b", 1), + entry("c", 2), + entry("d", 3), + ] } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/graph/filtering.rs b/raphtory-graphql/src/model/graph/filtering.rs index 0091b138c7..0c3dcdaffa 100644 --- a/raphtory-graphql/src/model/graph/filtering.rs +++ b/raphtory-graphql/src/model/graph/filtering.rs @@ -1920,3 +1920,232 @@ pub struct GraphAccessFilter { #[serde(default, skip_serializing_if = "Option::is_none")] pub hidden_metadata: Option, } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_out_component_filtered() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::single_component_test_graph())], + tmp_dir.path(), + ) + .await; + + // composite filter with a node filter removing c; a can then only reach b + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { nodes: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } }) { + nodes { list { id } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // with c removed, a only reaches b (d is now unreachable) + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { + "nodes": { "list": [{ "id": "b" }] } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_out_component_node_filter_composed() { + let tmp_dir = tempdir().unwrap(); + let setup = + setup_with_graphs(&[("g", graphql_test::star_test_graph())], tmp_dir.path()).await; + + // NodeFilter and: both clauses must apply. Dropping b AND c leaves only d + // in a's out component (dropping just one would leave two nodes). + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { nodes: { + and: [ + { node: { field: NODE_NAME, where: { ne: { str: "b" } } } }, + { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } + ] + } }) { + nodes { list { id } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { + "nodes": { "list": [{ "id": "d" }] } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_out_component_edge_filter_composed() { + let tmp_dir = tempdir().unwrap(); + let setup = + setup_with_graphs(&[("g", graphql_test::star_test_graph())], tmp_dir.path()).await; + + // EdgeFilter and: both clauses must apply. Dropping edges to b AND to c + // leaves only a -> d, so a reaches only d. + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { edges: { + and: [ + { dst: { node: { field: NODE_NAME, where: { ne: { str: "b" } } } } }, + { dst: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } } + ] + } }) { + nodes { list { id } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { + "nodes": { "list": [{ "id": "d" }] } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_out_component_graph_filter_composed() { + let graph = Graph::new(); + // edges at increasing times so a graph-view window changes reachability + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // GraphFilter composes via nested `expr`: window [1,3) then a further before(2). + // Only the a -> b edge (t=1) remains, so a reaches only b. + let query = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { graph: { + window: { start: 1, end: 3, expr: { before: { time: 2 } } } + } }) { + nodes { list { id } } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "outComponent": { + "nodes": { "list": [{ "id": "b" }] } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_out_component_filter_equivalence() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::single_component_test_graph())], + tmp_dir.path(), + ) + .await; + + // filter passed as an algorithm argument + let as_argument = r#" + { + graph(path: "g") { + algorithm { + outComponent(node: "a", filter: { nodes: { + node: { field: NODE_NAME, where: { ne: { str: "c" } } } + } }) { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + // same filter applied to the graph view before calling the algorithm (no argument) + let pre_filtered = r#" + { + graph(path: "g") { + filterNodes(expr: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } }) { + algorithm { + outComponent(node: "a") { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + } + "#; + + let arg_res = setup.schema.execute(Request::new(as_argument)).await; + assert_eq!(arg_res.errors, vec![], "{:?}", arg_res.errors); + let pre_res = setup.schema.execute(Request::new(pre_filtered)).await; + assert_eq!(pre_res.errors, vec![], "{:?}", pre_res.errors); + + // both routes reach the same result (b -> unwrap the identical outComponent payload) + let arg_out = + arg_res.data.into_json().unwrap()["graph"]["algorithm"]["outComponent"].clone(); + let pre_out = pre_res.data.into_json().unwrap()["graph"]["filterNodes"]["algorithm"] + ["outComponent"] + .clone(); + assert_eq!(arg_out, pre_out); + assert_eq!( + arg_out, + json!({ + "rows": [{ + "node": { "id": "b" }, + "entries": [{ "columnName": "distance", "value": { "prop": 1 } }] + }] + }) + ); + } +} diff --git a/raphtory-graphql/src/model/graph/node_state.rs b/raphtory-graphql/src/model/graph/node_state.rs index 760a711d77..6e29c48191 100644 --- a/raphtory-graphql/src/model/graph/node_state.rs +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -615,3 +615,430 @@ impl GqlNodeState { .await } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use dynamic_graphql::Request; + use raphtory::{db::api::view::MaterializedGraph, prelude::*}; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_node_state_ops() { + let graph = Graph::new(); + // insert out of id order so sortById is meaningful + graph.add_edge(1, "c", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "a", NO_PROPS, None).unwrap(); + graph.add_edge(3, "a", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + get(node: "b") { + columnName + value { + __typename + ... on NodeStateProp { prop } + } + } + missing: get(node: "not-a-node") { columnName } + sortById { + nodes { list { name } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // in a 3-cycle all nodes have the same rank of 1/3 + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "pagerank": { + "get": [ + { + "columnName": "pagerank_score", + "value": { "__typename": "NodeStateProp", "prop": 0.3333333333333333 } + } + ], + "missing": null, + "sortById": { + "nodes": { + "list": [ + { "name": "a" }, + { "name": "b" }, + { "name": "c" } + ] + } + } + } + } + } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_node_state_rows() { + let graph = Graph::new(); + // asymmetric graph so every node has a distinct pagerank + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + columnNames + rows { + node { name } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + headlessRows { + node { name } + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "pagerank": { + "columnNames": ["pagerank_score"], + "rows": [ + { + "node": { "name": "a" }, + "entries": [ + { "columnName": "pagerank_score", "value": { "prop": 0.197580035313204 } } + ] + }, + { + "node": { "name": "b" }, + "entries": [ + { "columnName": "pagerank_score", "value": { "prop": 0.28155081033755053 } } + ] + }, + { + "node": { "name": "c" }, + "entries": [ + { "columnName": "pagerank_score", "value": { "prop": 0.5208691543492454 } } + ] + } + ], + "headlessRows": [ + { + "node": { "name": "a" }, + "values": [ { "prop": 0.197580035313204 } ] + }, + { + "node": { "name": "b" }, + "values": [ { "prop": 0.28155081033755053 } ] + }, + { + "node": { "name": "c" }, + "values": [ { "prop": 0.5208691543492454 } ] + } + ] + } + } + } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_node_state_page() { + let graph = Graph::new(); + // a chain a -> b -> c -> d -> e, so the state has 5 rows + for (src, dst) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); + } + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // a page is itself a NodeState, so nodes/columns on it stay row-aligned + let query = r#" + { + graph(path: "g") { + algorithm { + degreeCentrality { + first: page(limit: 2) { + count + nodes { ids } + columns { name values { ... on NodeStateProp { prop } } } + } + second: page(limit: 2, pageIndex: 1) { nodes { ids } } + withOffset: page(limit: 2, offset: 1) { nodes { ids } } + lastPartial: page(limit: 2, pageIndex: 2) { nodes { ids } } + pastEnd: page(limit: 2, pageIndex: 99) { count nodes { ids } } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // the first page holds the first two nodes with their values still aligned, + // `offset` shifts by rows rather than pages, the final page is short rather + // than padded, and paging past the end is empty rather than an error + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "degreeCentrality": { + "first": { + "count": 2, + "nodes": { "ids": ["a", "b"] }, + "columns": [{ + "name": "degree_centrality", + "values": [{ "prop": 0.5 }, { "prop": 1.0 }] + }] + }, + "second": { "nodes": { "ids": ["c", "d"] } }, + "withOffset": { "nodes": { "ids": ["b", "c"] } }, + "lastPartial": { "nodes": { "ids": ["e"] } }, + "pastEnd": { "count": 0, "nodes": { "ids": [] } } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_node_state_page_composes() { + let graph = Graph::new(); + // asymmetric graph so every node has a distinct pagerank: + // a = 0.1976, b = 0.2816, c = 0.5209 + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // a page is a NodeState, so it chains with the other operations + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + sortedThenPaged: sortByValues(column: "pagerank_score", reverse: true) { + page(limit: 2) { nodes { ids } } + } + pagedThenAggregated: page(limit: 2) { + max(column: "pagerank_score") { node { id } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "pagerank": { + // top two by score, descending + "sortedThenPaged": { "page": { "nodes": { "ids": ["c", "b"] } } }, + // the aggregate only sees the page's rows (a, b), so b wins + "pagedThenAggregated": { "max": { "node": { "id": "b" } } } + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_node_state_top_k_and_sorting() { + let graph = Graph::new(); + // asymmetric graph so every node has a distinct pagerank: + // a = 0.1976, b = 0.2816, c = 0.5209 + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + topTwo: topK(column: "pagerank_score", k: 2) { nodes { ids } } + bottomTwo: bottomK(column: "pagerank_score", k: 2) { nodes { ids } } + ascending: sortByValues(column: "pagerank_score") { nodes { ids } } + descending: sortByValues(column: "pagerank_score", reverse: true) { nodes { ids } } + missingColumn: topK(column: "nope", k: 2) { count } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "pagerank": { + // largest first, smallest first + "topTwo": { "nodes": { "ids": ["c", "b"] } }, + "bottomTwo": { "nodes": { "ids": ["a", "b"] } }, + "ascending": { "nodes": { "ids": ["a", "b", "c"] } }, + "descending": { "nodes": { "ids": ["c", "b", "a"] } }, + "missingColumn": null + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_node_state_group_by() { + let graph = Graph::new(); + // two connected components, so wcc gives two distinct component ids + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + weaklyConnectedComponents { + groupBy(column: "component_id") { + value + nodes { ids } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // group order and node order within a group are both unordered + let mut data = res.data.into_json().unwrap(); + let groups = data["graph"]["algorithm"]["weaklyConnectedComponents"]["groupBy"] + .as_array_mut() + .unwrap(); + for group in groups.iter_mut() { + group["nodes"]["ids"] + .as_array_mut() + .unwrap() + .sort_by_key(|id| id.as_str().unwrap().to_string()); + } + groups.sort_by_key(|group| group["nodes"]["ids"][0].as_str().unwrap().to_string()); + // a-b and c-d each form their own component + assert_eq!(groups.len(), 2); + assert_eq!(groups[0]["nodes"]["ids"], json!(["a", "b"])); + assert_eq!(groups[1]["nodes"]["ids"], json!(["c", "d"])); + assert_ne!(groups[0]["value"], groups[1]["value"]); + + // grouping a node-valued column is rejected + let query = r#" + { + graph(path: "g") { + algorithm { + outComponents { groupBy(column: "out_components") { value } } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ "graph": { "algorithm": { "outComponents": { "groupBy": null } } } }) + ); + } + + #[tokio::test] + async fn test_algorithm_node_state_aggregates() { + let graph = Graph::new(); + // asymmetric graph so every node has a distinct pagerank + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "a", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + min(column: "pagerank_score") { node { name } value } + max(column: "pagerank_score") { node { name } value } + median(column: "pagerank_score") { node { name } value } + sum(column: "pagerank_score") + mean(column: "pagerank_score") + missing: min(column: "not_a_column") { value } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "pagerank": { + "min": { + "node": { "name": "a" }, + "value": 0.197580035313204 + }, + "max": { + "node": { "name": "c" }, + "value": 0.5208691543492454 + }, + "median": { + "node": { "name": "b" }, + "value": 0.28155081033755053 + }, + "sum": 1.0, + "mean": 0.3333333333333333, + "missing": null + } + } + } + }) + ); + } +} From de13847ceb2700cd0fb2b0dc940ea0c937c2a89b Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 5 Aug 2026 03:15:53 -0400 Subject: [PATCH 35/39] Move tests from lib.rs to the individual algorithm's rust file --- raphtory-graphql/src/lib.rs | 1546 +---------------- .../model/algorithms/all_local_reciprocity.rs | 59 + .../src/model/algorithms/alternating_mask.rs | 52 + .../src/model/algorithms/balance.rs | 55 + .../algorithms/betweenness_centrality.rs | 55 + .../cohesive_fruchterman_reingold.rs | 59 + .../src/model/algorithms/degree_centrality.rs | 66 +- .../src/model/algorithms/dijkstra.rs | 74 + .../src/model/algorithms/fast_rp.rs | 64 + .../model/algorithms/fruchterman_reingold.rs | 59 + .../global_temporal_three_node_motif.rs | 57 + raphtory-graphql/src/model/algorithms/hits.rs | 62 + .../src/model/algorithms/in_components.rs | 95 + .../src/model/algorithms/label_propagation.rs | 57 + .../local_clustering_coefficient.rs | 45 + .../local_clustering_coefficient_batch.rs | 58 + .../local_temporal_three_node_motifs.rs | 65 + .../model/algorithms/local_triangle_count.rs | 73 + .../src/model/algorithms/louvain.rs | 60 + .../model/algorithms/max_weight_matching.rs | 75 + .../src/model/algorithms/out_components.rs | 79 + .../src/model/algorithms/pagerank.rs | 76 + .../algorithms/single_source_shortest_path.rs | 98 ++ .../strongly_connected_components.rs | 57 + .../temporal_rich_club_coefficient.rs | 51 + .../src/model/algorithms/temporal_seir.rs | 103 ++ .../algorithms/temporally_reachable_nodes.rs | 63 + .../algorithms/weakly_connected_components.rs | 53 + 28 files changed, 1825 insertions(+), 1491 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index a0a5a41c64..9cfd769356 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -448,1495 +448,72 @@ mod graphql_test { graph.into() } - #[tokio::test] - async fn test_algorithm_fast_rp() { - let graph = Graph::new(); - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - fastRp(embeddingDim: 4, normalizationStrength: 1.0, iterWeights: [1.0, 1.0], seed: 42, threads: 1) { - columnNames - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // each embedding is a 4d vector (embeddingDim); values are deterministic given the seed - let row = |id: &str, embedding: [f64; 4]| { - json!({ - "node": { "id": id }, - "entries": [{ "columnName": "embedding_state", "value": { "prop": embedding } }] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "fastRp": { - "columnNames": ["embedding_state"], - "rows": [ - row("a", [-0.9870555097143693, 0.3290185032381231, -1.6450925161906156, 0.0]), - row("b", [0.9870555097143693, 0.3290185032381231, -1.6450925161906156, -0.9870555097143693]), - row("c", [0.0, 1.3160740129524924, -0.6580370064762462, 0.9870555097143693]), - ] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_temporally_reachable_nodes() { - let graph = Graph::new(); - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - temporallyReachableNodes(maxHops: 5, startTime: 0, seedNodes: ["a"], threads: 1) { - columnNames - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // each node is tainted by (time, source); tuples serialize as {"0": time, "1": source} - let row = |id: &str, taint: serde_json::Value| { - json!({ - "node": { "id": id }, - "entries": [{ "columnName": "reachable_nodes", "value": { "prop": [taint] } }] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "temporallyReachableNodes": { - "columnNames": ["reachable_nodes"], - "rows": [ - row("a", json!({ "0": 0, "1": "start" })), - row("b", json!({ "0": 1, "1": "a" })), - row("c", json!({ "0": 2, "1": "b" })), - ] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_fruchterman_reingold() { - let graph = Graph::new(); - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - fruchtermanReingold(iterCount: 1) { - columnNames - nodes { - list { id } - } - columns { - name - values { ... on NodeStateProp { prop } } - } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // layout positions are non-deterministic (random init, no seed), so assert on shape: - // two coordinate columns "0" (x) and "1" (y), each with one float per node. - let data = res.data.into_json().unwrap(); - let fr = &data["graph"]["algorithm"]["fruchtermanReingold"]; - assert_eq!(fr["columnNames"], json!(["0", "1"])); - assert_eq!( - fr["nodes"]["list"], - json!([{ "id": "a" }, { "id": "b" }, { "id": "c" }]) - ); - let columns = fr["columns"].as_array().unwrap(); - assert_eq!(columns.len(), 2); - for column in columns { - let values = column["values"].as_array().unwrap(); - assert_eq!(values.len(), 3); - assert!(values.iter().all(|v| v["prop"].is_number())); - } - } - - #[tokio::test] - async fn test_algorithm_cohesive_fruchterman_reingold() { - let graph = Graph::new(); - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - cohesiveFruchtermanReingold(iterCount: 1) { - columnNames - nodes { - list { id } - } - columns { - name - values { ... on NodeStateProp { prop } } - } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // layout positions are non-deterministic (random init, no seed), so assert on shape: - // two coordinate columns "0" (x) and "1" (y), each with one float per node. - let data = res.data.into_json().unwrap(); - let cfr = &data["graph"]["algorithm"]["cohesiveFruchtermanReingold"]; - assert_eq!(cfr["columnNames"], json!(["0", "1"])); - assert_eq!( - cfr["nodes"]["list"], - json!([{ "id": "a" }, { "id": "b" }, { "id": "c" }]) - ); - let columns = cfr["columns"].as_array().unwrap(); - assert_eq!(columns.len(), 2); - for column in columns { - let values = column["values"].as_array().unwrap(); - assert_eq!(values.len(), 3); - assert!(values.iter().all(|v| v["prop"].is_number())); - } - } - - #[tokio::test] - async fn test_algorithm_local_temporal_three_node_motifs() { - let graph = Graph::new(); - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - localTemporalThreeNodeMotifs(delta: 10) { - columnNames - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - "#; - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // each node gets a 40d motif-count vector; in this triangle each participates in motif 35 - let motif_counter = { - let mut v = vec![0; 40]; - v[35] = 1; - v - }; - let row = |id: &str| { - json!({ - "node": { "id": id }, - "entries": [{ "columnName": "motif_counter", "value": { "prop": motif_counter } }] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "localTemporalThreeNodeMotifs": { - "columnNames": ["motif_counter"], - "rows": [row("a"), row("b"), row("c")] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_all_local_reciprocity() { - let graph = Graph::new(); - // a<->b reciprocated, a->c not - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "a", NO_PROPS, None).unwrap(); - graph.add_edge(3, "a", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - allLocalReciprocity { - rows { - node { id } - entries { columnName value { ... on NodeStateProp { prop } } } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // a: 2 of 3 edges reciprocated; b: fully reciprocated; c: none - let entry = |id: &str, reciprocity| { - json!({ - "node": { "id": id }, - "entries": [{ "columnName": "reciprocity", "value": { "prop": reciprocity } }] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "allLocalReciprocity": { "rows": [ - entry("a", 0.6666666666666666), - entry("b", 1.0), - entry("c", 0.0), - ] } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_balance() { - let graph = Graph::new(); - graph - .add_edge(1, "a", "b", [("weight", 5.0)], None) - .unwrap(); - graph - .add_edge(2, "c", "a", [("weight", 3.0)], None) - .unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - balance(name: "weight", direction: BOTH) { - nodes { list { id } } - columns { name values { ... on NodeStateProp { prop } } } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // BOTH: a = in 3 - out 5 = -2, b = +5, c = -3 - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "balance": { - "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] }, - "columns": [{ - "name": "balance", - "values": [{ "prop": -2.0 }, { "prop": 5.0 }, { "prop": -3.0 }] - }] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_local_clustering_coefficient_batch() { - let graph = Graph::new(); - // triangle a-b-c - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - localClusteringCoefficientBatch(nodes: ["a", "b"]) { - rows { - node { id } - entries { columnName value { ... on NodeStateProp { prop } } } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // only the queried nodes are present; each is in a triangle -> coefficient 1.0 - let entry = |id: &str| { - json!({ - "node": { "id": id }, - "entries": [{ "columnName": "lcc", "value": { "prop": 1.0 } }] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "localClusteringCoefficientBatch": { "rows": [ - entry("a"), - entry("b"), - ] } } } - }) - ); - } - - fn components_test_graph() -> MaterializedGraph { - let graph = Graph::new(); - // cycle a -> b -> c -> a (one SCC), plus d -> a (d reaches the cycle but not vice versa) - for (src, dst) in [("a", "b"), ("b", "c"), ("c", "a"), ("d", "a")] { - graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); - } - graph.into() - } - - #[tokio::test] - async fn test_algorithm_weakly_connected_components() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", components_test_graph())], tmp_dir.path()).await; - - // whole graph is weakly connected -> all nodes share one component - let query = r#" - { - graph(path: "g") { - algorithm { - weaklyConnectedComponents { - nodes { list { id } } - columns { - name - values { ... on NodeStateProp { prop } } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // all four nodes are weakly connected -> one component - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "weaklyConnectedComponents": { - "nodes": { "list": [ - { "id": "a" }, { "id": "b" }, { "id": "c" }, { "id": "d" } - ] }, - "columns": [{ - "name": "component_id", - "values": [{ "prop": 0 }, { "prop": 0 }, { "prop": 0 }, { "prop": 0 }] - }] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_strongly_connected_components() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", components_test_graph())], tmp_dir.path()).await; - - // {a,b,c} form one SCC (the cycle); d is its own - let query = r#" - { - graph(path: "g") { - algorithm { - stronglyConnectedComponents { - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - let entry = |id: &str, component| { - json!({ - "node": { "id": id }, - "entries": [{ "columnName": "component_id", "value": { "prop": component } }] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "stronglyConnectedComponents": { "rows": [ - entry("a", 0), - entry("b", 0), - entry("c", 0), - entry("d", 1), - ] } } } - }) - ); - } - - fn community_test_graph() -> MaterializedGraph { - let graph = Graph::new(); - // two triangles joined by a single bridge edge (c -> d) - for (src, dst) in [ - ("a", "b"), - ("b", "c"), - ("c", "a"), - ("d", "e"), - ("e", "f"), - ("f", "d"), - ("c", "d"), - ] { - graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); - } - graph.into() - } - - #[tokio::test] - async fn test_algorithm_louvain() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", community_test_graph())], tmp_dir.path()).await; - - // fixed rng_seed for deterministic output - let query = r#" - { - graph(path: "g") { - algorithm { - louvain(rngSeed: 42) { - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // two triangles -> two communities: {a,b,c} and {d,e,f} - let entry = |id: &str, community| { - json!({ - "node": { "id": id }, - "entries": [{ "columnName": "community_id", "value": { "prop": community } }] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "louvain": { "rows": [ - entry("a", 0), - entry("b", 0), - entry("c", 0), - entry("d", 1), - entry("e", 1), - entry("f", 1), - ] } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_label_propagation() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", community_test_graph())], tmp_dir.path()).await; - - // threads: 1 for deterministic output (multi-threaded label propagation output is non-deterministic) - let query = r#" - { - graph(path: "g") { - algorithm { - labelPropagation(threads: 1) { - nodes { list { id } } - columns { - name - values { ... on NodeStateProp { prop } } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // two triangles -> two communities; ids derive from node index - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "labelPropagation": { - "nodes": { "list": [ - { "id": "a" }, { "id": "b" }, { "id": "c" }, - { "id": "d" }, { "id": "e" }, { "id": "f" } - ] }, - "columns": [{ - "name": "community_id", - "values": [ - { "prop": 2 }, { "prop": 2 }, { "prop": 2 }, - { "prop": 600002 }, { "prop": 600002 }, { "prop": 600002 } - ] - }] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_global_temporal_three_node_motif() { - let graph = Graph::new(); - // a -> b -> c -> a, each edge at a distinct time, so triangle motifs are counted - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - single: globalTemporalThreeNodeMotif(delta: 10) - multi: globalTemporalThreeNodeMotifMulti(deltas: [10, 1]) { delta counts } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - let data = res.data.into_json().unwrap(); - let single = data["graph"]["algorithm"]["single"].as_array().unwrap(); - let multi = data["graph"]["algorithm"]["multi"].as_array().unwrap(); - - // 40 counts: 8 two-node + 24 star + 8 triangle - assert_eq!(single.len(), 40); - // one row per delta, and the first row is the same as the single-delta call - assert_eq!(multi.len(), 2); - assert!(multi - .iter() - .all(|row| row["counts"].as_array().unwrap().len() == 40)); - assert_eq!(multi[0]["delta"], 10); - assert_eq!(multi[1]["delta"], 1); - assert_eq!(multi[0]["counts"].as_array().unwrap(), single); - // delta 10 spans the whole triangle so it finds motifs delta 1 does not - assert!( - single.iter().any(|c| c.as_u64().unwrap() > 0), - "expected some motifs at delta 10, got {single:?}" - ); - assert_ne!(multi[0]["counts"], multi[1]["counts"]); - } - - #[tokio::test] - async fn test_algorithm_max_weight_matching() { - let graph = Graph::new(); - // path a-b-c-d: the max weight matching picks a-b (5) and c-d (4) over b-c (3) - graph - .add_edge(1, "a", "b", [("weight", 5.0)], None) - .unwrap(); - graph - .add_edge(1, "b", "c", [("weight", 3.0)], None) - .unwrap(); - graph - .add_edge(1, "c", "d", [("weight", 4.0)], None) - .unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - maxWeightMatching(weightProp: "weight") { - count - edges { list { src { id } dst { id } } } - dstOfA: dst(src: "a") { id } - srcOfD: src(dst: "d") { id } - hasAB: contains(src: "a", dst: "b") - hasBC: contains(src: "b", dst: "c") - edgeForA: edgeForSrc(src: "a") { src { id } dst { id } } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // the matching is backed by a HashMap, so edge order is not guaranteed - let mut data = res.data.into_json().unwrap(); - data["graph"]["algorithm"]["maxWeightMatching"]["edges"]["list"] - .as_array_mut() - .unwrap() - .sort_by_key(|edge| edge["src"]["id"].as_str().unwrap().to_string()); - // picks a-b and c-d (total weight 9) over the single b-c edge (weight 3) - assert_eq!( - data, - json!({ - "graph": { "algorithm": { "maxWeightMatching": { - "count": 2, - "edges": { "list": [ - { "src": { "id": "a" }, "dst": { "id": "b" } }, - { "src": { "id": "c" }, "dst": { "id": "d" } } - ] }, - "dstOfA": { "id": "b" }, - "srcOfD": { "id": "c" }, - "hasAB": true, - "hasBC": false, - "edgeForA": { "src": { "id": "a" }, "dst": { "id": "b" } } - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_alternating_mask() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - alternatingMask { - nodes { ids } - columns { name values { ... on NodeStateProp { prop } } } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // the mask alternates over the nodes in order - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "alternatingMask": { - "nodes": { "ids": ["a", "b", "c", "d"] }, - "columns": [{ - "name": "bool_col", - "values": [ - { "prop": false }, - { "prop": true }, - { "prop": false }, - { "prop": true } - ] - }] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_temporal_seir() { - let graph = Graph::new(); - // a chain so the infection can spread forward in time - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // seeding an explicit node with certain infection spreads along the chain; - // rngSeed keeps the run reproducible - let query = r#" - { - graph(path: "g") { - algorithm { - temporalSeir( - seeds: { nodes: ["a"] } - infectionProb: 1.0 - initialInfection: 0 - rngSeed: 42 - ) { - nodes { ids } - columnNames - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "temporalSeir": { - "nodes": { "ids": ["a", "b", "c", "d"] }, - "columnNames": ["infected", "active", "recovered"] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_temporal_seir_seed_variants() { - let graph = Graph::new(); - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // all three Seeds variants are accepted; number/probability pick nodes at random - let query = r#" - { - graph(path: "g") { - algorithm { - byNumber: temporalSeir( - seeds: { number: 2 } - infectionProb: 0.0 - initialInfection: 0 - rngSeed: 7 - ) { count } - byProbability: temporalSeir( - seeds: { probability: 0.5 } - infectionProb: 0.0 - initialInfection: 0 - rngSeed: 7 - ) { count } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // With no onward infection only the seeds appear. `number` samples exactly that - // many nodes; `probability` seeds each node independently - let data = res.data.into_json().unwrap(); - assert_eq!(data["graph"]["algorithm"]["byNumber"]["count"], 2); - let by_probability = data["graph"]["algorithm"]["byProbability"]["count"] - .as_u64() - .unwrap(); - assert!( - by_probability <= 4, - "expected at most every node to be seeded, got {by_probability}" - ); - } - - #[tokio::test] - async fn test_algorithm_temporal_rich_club_coefficient() { - let graph = Graph::new(); - // a triangle a-b-c repeated at every time step, so it persists across - // every snapshot, plus a pendant d that never joins the club - for t in 1..=4 { - for (src, dst) in [("a", "b"), ("b", "c"), ("c", "a")] { - graph.add_edge(t, src, dst, NO_PROPS, None).unwrap(); - } - } - graph.add_edge(1, "c", "d", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // one snapshot per time step; the triangle persists over every pair of them - let query = r#" - { - graph(path: "g") { - algorithm { - temporalRichClubCoefficient( - k: 2 - windowSize: 2 - rollingWindow: { epoch: 1 } - ) - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // the a-b-c triangle is fully connected and persists, so the coefficient is 1 - assert_eq!( - res.data.into_json().unwrap(), - json!({ "graph": { "algorithm": { "temporalRichClubCoefficient": 1.0 } } }) - ); - } - - fn scalar_metrics_test_graph() -> MaterializedGraph { - let graph = Graph::new(); - // a <-> b reciprocated, b -> c -> a forming a triangle with a-b, and c -> d as a pendant edge, - // so density/reciprocity/clustering/degree are all non-trivial - for (src, dst) in [("a", "b"), ("b", "a"), ("b", "c"), ("c", "a"), ("c", "d")] { - graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); - } - graph.into() - } - - #[tokio::test] - async fn test_algorithm_scalar_metrics() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - globalClusteringCoefficient - directedGraphDensity - globalReciprocity - averageDegree - maxDegree - minDegree - maxOutDegree - maxInDegree - minOutDegree - minInDegree - tripletCount - triangleCount - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { - "globalClusteringCoefficient": 0.6, - "directedGraphDensity": 0.4166666666666667, - "globalReciprocity": 0.4, - "averageDegree": 2.0, - "maxDegree": 3, - "minDegree": 1, - "maxOutDegree": 2, - "maxInDegree": 2, - "minOutDegree": 0, - "minInDegree": 1, - "tripletCount": 5, - "triangleCount": 1 - } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_local_triangle_count() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; - - // a is in the a-b-c triangle; d is a pendant with degree 1 - // only a missing node yields null - let query = r#" - { - graph(path: "g") { - algorithm { - inTriangle: localTriangleCount(node: "a") - pendant: localTriangleCount(node: "d") - missing: localTriangleCount(node: "not-a-node") - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { - "inTriangle": 1, - "pendant": 0, - "missing": null - } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_local_triangle_count_filtered() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; - - // filtering out c breaks the a-b-c triangle, so a's local triangle count drops - let query = r#" - { - graph(path: "g") { - algorithm { - localTriangleCount(node: "a", filter: { nodes: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } }) - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ "graph": { "algorithm": { "localTriangleCount": 0 } } }) - ); - } - - #[tokio::test] - async fn test_algorithm_local_clustering_coefficient() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; - - // a is in the a-b-c triangle; d is a pendant with degree 1 - // only a missing node yields null - let query = r#" - { - graph(path: "g") { - algorithm { - inTriangle: localClusteringCoefficient(node: "a") - pendant: localClusteringCoefficient(node: "d") - missing: localClusteringCoefficient(node: "not-a-node") - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { - "inTriangle": 1.0, - "pendant": 0.0, - "missing": null - } } - }) - ); - } - - fn centrality_test_graph() -> MaterializedGraph { - let graph = Graph::new(); - // path a -> b -> c -> d so nodes get distinct centrality scores - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); - graph.into() - } - - #[tokio::test] - async fn test_algorithm_degree_centrality() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", centrality_test_graph())], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - degreeCentrality { - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // degree/max_degree: endpoints 0.5, middle nodes 1.0 - let entry = |id: &str, prop| { - json!({ - "node": { "id": id }, - "entries": [{ "columnName": "degree_centrality", "value": { "prop": prop } }] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "degreeCentrality": { "rows": [ - entry("a", 0.5), - entry("b", 1.0), - entry("c", 1.0), - entry("d", 0.5), - ] } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_betweenness_centrality() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", centrality_test_graph())], tmp_dir.path()).await; - - let query = r#" - { - graph(path: "g") { - algorithm { - betweennessCentrality { - nodes { list { id } } - columns { - name - values { ... on NodeStateProp { prop } } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // endpoints lie on no shortest path (0.0); middle nodes b,c each on one (1/3 normalized) - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "betweennessCentrality": { - "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }, { "id": "d" }] }, - "columns": [{ - "name": "betweenness_centrality", - "values": [ - { "prop": 0.0 }, - { "prop": 0.3333333333333333 }, - { "prop": 0.3333333333333333 }, - { "prop": 0.0 } - ] - }] - } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_hits() { - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", centrality_test_graph())], tmp_dir.path()).await; - - // hits has two columns (hub_score, auth_score) - let query = r#" - { - graph(path: "g") { - algorithm { - hits(iterCount: 20) { - rows { - node { id } - entries { - columnName - value { ... on NodeStateProp { prop } } - } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // source has no auth, sink has no hub - let s = 0.3333333333333333; - let row = |id: &str, hub, auth| { - json!({ - "node": { "id": id }, - "entries": [ - { "columnName": "hub_score", "value": { "prop": hub } }, - { "columnName": "auth_score", "value": { "prop": auth } } - ] - }) - }; - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { "algorithm": { "hits": { "rows": [ - row("a", s, 0.0), - row("b", s, s), - row("c", s, s), - row("d", 0.0, s), - ] } } } - }) - ); - } - - #[tokio::test] - async fn test_algorithm_in_components() { + pub(crate) fn components_test_graph() -> MaterializedGraph { let graph = Graph::new(); - // chain a -> b -> c - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // The `in_components` column holds Nodes - let query = r#" - { - graph(path: "g") { - algorithm { - inComponents { - rows { - node { id } - entries { - columnName - value { - __typename - ... on Nodes { ids } - } - } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // component node order is not guaranteed (backed by a HashSet), so sort each set - let mut data = res.data.into_json().unwrap(); - for row in data["graph"]["algorithm"]["inComponents"]["rows"] - .as_array_mut() - .unwrap() - { - for entry in row["entries"].as_array_mut().unwrap() { - if let Some(ids) = entry["value"]["ids"].as_array_mut() { - ids.sort_by_key(|id| id.as_str().unwrap().to_string()); - } - } + // cycle a -> b -> c -> a (one SCC), plus d -> a (d reaches the cycle but not vice versa) + for (src, dst) in [("a", "b"), ("b", "c"), ("c", "a"), ("d", "a")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); } - // in_components: a <- {}, b <- {a}, c <- {a,b} - assert_eq!( - data, - json!({ - "graph": { - "algorithm": { - "inComponents": { - "rows": [ - { - "node": { "id": "a" }, - "entries": [{ - "columnName": "in_components", - "value": { "__typename": "Nodes", "ids": [] } - }] - }, - { - "node": { "id": "b" }, - "entries": [{ - "columnName": "in_components", - "value": { "__typename": "Nodes", "ids": ["a"] } - }] - }, - { - "node": { "id": "c" }, - "entries": [{ - "columnName": "in_components", - "value": { "__typename": "Nodes", "ids": ["a", "b"] } - }] - } - ] - } - } - } - }) - ); + graph.into() } - #[tokio::test] - async fn test_algorithm_dijkstra() { + pub(crate) fn community_test_graph() -> MaterializedGraph { let graph = Graph::new(); - // weighted chain a -> b -> c - graph - .add_edge(1, "a", "b", [("weight", 2.0)], None) - .unwrap(); - graph - .add_edge(2, "b", "c", [("weight", 3.0)], None) - .unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // Mixed columns: `distance` is a Prop, `path` is Nodes - let query = r#" - { - graph(path: "g") { - algorithm { - dijkstra(source: "a", targets: ["c"], weight: "weight", direction: OUT) { - nodes { list { id } } - columns { - name - values { - __typename - ... on NodeStateProp { prop } - ... on Nodes { ids } - } - } - } - } - } + // two triangles joined by a single bridge edge (c -> d) + for (src, dst) in [ + ("a", "b"), + ("b", "c"), + ("c", "a"), + ("d", "e"), + ("e", "f"), + ("f", "d"), + ("c", "d"), + ] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // one row (target c): distance 2+3=5, path a -> b -> c - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { - "algorithm": { - "dijkstra": { - "nodes": { "list": [{ "id": "c" }] }, - "columns": [ - { - "name": "distance", - "values": [{ "__typename": "NodeStateProp", "prop": 5.0 }] - }, - { - "name": "path", - "values": [{ "__typename": "Nodes", "ids": ["a", "b", "c"] }] - } - ] - } - } - } - }) - ); + graph.into() } - #[tokio::test] - async fn test_algorithm_out_components() { + pub(crate) fn scalar_metrics_test_graph() -> MaterializedGraph { let graph = Graph::new(); - // chain a -> b -> c - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // The `out_components` column holds Nodes - let query = r#" - { - graph(path: "g") { - algorithm { - outComponents { - nodes { list { id } } - columns { - name - values { - __typename - ... on Nodes { ids } - } - } - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - // component node order is not guaranteed (backed by a HashSet), so sort each set - let mut data = res.data.into_json().unwrap(); - for col in data["graph"]["algorithm"]["outComponents"]["columns"] - .as_array_mut() - .unwrap() - { - for value in col["values"].as_array_mut().unwrap() { - if let Some(ids) = value["ids"].as_array_mut() { - ids.sort_by_key(|id| id.as_str().unwrap().to_string()); - } - } + // a <-> b reciprocated, b -> c -> a forming a triangle with a-b, and c -> d as a pendant edge, + // so density/reciprocity/clustering/degree are all non-trivial + for (src, dst) in [("a", "b"), ("b", "a"), ("b", "c"), ("c", "a"), ("c", "d")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); } - // values are row-aligned with nodes: a -> {b,c}, b -> {c}, c -> {} - assert_eq!( - data, - json!({ - "graph": { - "algorithm": { - "outComponents": { - "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] }, - "columns": [{ - "name": "out_components", - "values": [ - { "__typename": "Nodes", "ids": ["b", "c"] }, - { "__typename": "Nodes", "ids": ["c"] }, - { "__typename": "Nodes", "ids": [] } - ] - }] - } - } - } - }) - ); + graph.into() } - #[tokio::test] - async fn test_algorithm_single_source_shortest_path() { + pub(crate) fn centrality_test_graph() -> MaterializedGraph { let graph = Graph::new(); - // simple chain a -> b -> c + // path a -> b -> c -> d so nodes get distinct centrality scores graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); - let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; - - // The `path` column holds Nodes, not a Prop - let query = r#" - { - graph(path: "g") { - algorithm { - singleSourceShortestPath(source: "a") { - columnNames - rows { - node { id } - entries { - columnName - value { - __typename - ... on Nodes { list { id } } - } - } - } - min(column: "path") { value } - mean(column: "path") - } - } - } - } - "#; - - let res = setup.schema.execute(Request::new(query)).await; - assert_eq!(res.errors, vec![], "{:?}", res.errors); - assert_eq!( - res.data.into_json().unwrap(), - json!({ - "graph": { - "algorithm": { - "singleSourceShortestPath": { - "columnNames": ["path"], - "rows": [ - { - "node": { "id": "a" }, - "entries": [{ - "columnName": "path", - "value": { - "__typename": "Nodes", - "list": [{ "id": "a" }] - } - }] - }, - { - "node": { "id": "b" }, - "entries": [{ - "columnName": "path", - "value": { - "__typename": "Nodes", - "list": [{ "id": "a" }, { "id": "b" }] - } - }] - }, - { - "node": { "id": "c" }, - "entries": [{ - "columnName": "path", - "value": { - "__typename": "Nodes", - "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] - } - }] - } - ], - // node-valued column: numeric aggregates return null - "min": null, - "mean": null - } - } - } - }) - ); + graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); + graph.into() } #[tokio::test] - async fn test_algorithm_pagerank() { - let graph = Graph::new(); - graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); - graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); - graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); - let graph: MaterializedGraph = graph.into(); + async fn test_algorithm_scalar_metrics() { let tmp_dir = tempdir().unwrap(); - let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + let setup = setup_with_graphs(&[("g", scalar_metrics_test_graph())], tmp_dir.path()).await; let query = r#" { graph(path: "g") { algorithm { - pagerank(iterCount: 20) { - count - nodes { list { name } } - columns { - name - values { - __typename - ... on NodeStateProp { prop } - } - } - } + globalClusteringCoefficient + directedGraphDensity + globalReciprocity + averageDegree + maxDegree + minDegree + maxOutDegree + maxInDegree + minOutDegree + minInDegree + tripletCount + triangleCount } } } @@ -1944,34 +521,23 @@ mod graphql_test { let res = setup.schema.execute(Request::new(query)).await; assert_eq!(res.errors, vec![], "{:?}", res.errors); - // in a 3-cycle all nodes have the same rank of 1/3 assert_eq!( res.data.into_json().unwrap(), json!({ - "graph": { - "algorithm": { - "pagerank": { - "count": 3, - "nodes": { - "list": [ - { "name": "a" }, - { "name": "b" }, - { "name": "c" } - ] - }, - "columns": [ - { - "name": "pagerank_score", - "values": [ - { "__typename": "NodeStateProp", "prop": 0.3333333333333333 }, - { "__typename": "NodeStateProp", "prop": 0.3333333333333333 }, - { "__typename": "NodeStateProp", "prop": 0.3333333333333333 } - ] - } - ] - } - } - } + "graph": { "algorithm": { + "globalClusteringCoefficient": 0.6, + "directedGraphDensity": 0.4166666666666667, + "globalReciprocity": 0.4, + "averageDegree": 2.0, + "maxDegree": 3, + "minDegree": 1, + "maxOutDegree": 2, + "maxInDegree": 2, + "minOutDegree": 0, + "minInDegree": 1, + "tripletCount": 5, + "triangleCount": 1 + } } }) ); } diff --git a/raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs b/raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs index 6d8718366d..af65385e3f 100644 --- a/raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs +++ b/raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs @@ -17,3 +17,62 @@ impl GqlExecutableAlgorithm for GqlAllLocalReciprocity { Ok(all_local_reciprocity(graph).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_all_local_reciprocity() { + let graph = Graph::new(); + // a<->b reciprocated, a->c not + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "a", NO_PROPS, None).unwrap(); + graph.add_edge(3, "a", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + allLocalReciprocity { + rows { + node { id } + entries { columnName value { ... on NodeStateProp { prop } } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // a: 2 of 3 edges reciprocated; b: fully reciprocated; c: none + let entry = |id: &str, reciprocity| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "reciprocity", "value": { "prop": reciprocity } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "allLocalReciprocity": { "rows": [ + entry("a", 0.6666666666666666), + entry("b", 1.0), + entry("c", 0.0), + ] } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/alternating_mask.rs b/raphtory-graphql/src/model/algorithms/alternating_mask.rs index ca05dd401d..40b7453d4f 100644 --- a/raphtory-graphql/src/model/algorithms/alternating_mask.rs +++ b/raphtory-graphql/src/model/algorithms/alternating_mask.rs @@ -16,3 +16,55 @@ impl GqlExecutableAlgorithm for GqlAlternatingMask { Ok(alternating_mask(graph).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_alternating_mask() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::scalar_metrics_test_graph())], + tmp_dir.path(), + ) + .await; + + let query = r#" + { + graph(path: "g") { + algorithm { + alternatingMask { + nodes { ids } + columns { name values { ... on NodeStateProp { prop } } } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // the mask alternates over the nodes in order + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "alternatingMask": { + "nodes": { "ids": ["a", "b", "c", "d"] }, + "columns": [{ + "name": "bool_col", + "values": [ + { "prop": false }, + { "prop": true }, + { "prop": false }, + { "prop": true } + ] + }] + } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/balance.rs b/raphtory-graphql/src/model/algorithms/balance.rs index fbe857bad5..3d1eece16c 100644 --- a/raphtory-graphql/src/model/algorithms/balance.rs +++ b/raphtory-graphql/src/model/algorithms/balance.rs @@ -23,3 +23,58 @@ impl GqlExecutableAlgorithm for GqlBalance { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_balance() { + let graph = Graph::new(); + graph + .add_edge(1, "a", "b", [("weight", 5.0)], None) + .unwrap(); + graph + .add_edge(2, "c", "a", [("weight", 3.0)], None) + .unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + balance(name: "weight", direction: BOTH) { + nodes { list { id } } + columns { name values { ... on NodeStateProp { prop } } } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // BOTH: a = in 3 - out 5 = -2, b = +5, c = -3 + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "balance": { + "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] }, + "columns": [{ + "name": "balance", + "values": [{ "prop": -2.0 }, { "prop": 5.0 }, { "prop": -3.0 }] + }] + } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/betweenness_centrality.rs b/raphtory-graphql/src/model/algorithms/betweenness_centrality.rs index 631e3237b1..0c18bd341f 100644 --- a/raphtory-graphql/src/model/algorithms/betweenness_centrality.rs +++ b/raphtory-graphql/src/model/algorithms/betweenness_centrality.rs @@ -20,3 +20,58 @@ impl GqlExecutableAlgorithm for GqlBetweennessCentrality { Ok(betweenness_centrality(graph, args.k, args.normalized).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_betweenness_centrality() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::centrality_test_graph())], + tmp_dir.path(), + ) + .await; + + let query = r#" + { + graph(path: "g") { + algorithm { + betweennessCentrality { + nodes { list { id } } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // endpoints lie on no shortest path (0.0); middle nodes b,c each on one (1/3 normalized) + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "betweennessCentrality": { + "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }, { "id": "d" }] }, + "columns": [{ + "name": "betweenness_centrality", + "values": [ + { "prop": 0.0 }, + { "prop": 0.3333333333333333 }, + { "prop": 0.3333333333333333 }, + { "prop": 0.0 } + ] + }] + } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs index f4e0264b90..98c61b76a6 100644 --- a/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs +++ b/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs @@ -31,3 +31,62 @@ impl GqlExecutableAlgorithm for GqlCohesiveFruchtermanReingold { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_cohesive_fruchterman_reingold() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + cohesiveFruchtermanReingold(iterCount: 1) { + columnNames + nodes { + list { id } + } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // layout positions are non-deterministic (random init, no seed), so assert on shape: + // two coordinate columns "0" (x) and "1" (y), each with one float per node. + let data = res.data.into_json().unwrap(); + let cfr = &data["graph"]["algorithm"]["cohesiveFruchtermanReingold"]; + assert_eq!(cfr["columnNames"], json!(["0", "1"])); + assert_eq!( + cfr["nodes"]["list"], + json!([{ "id": "a" }, { "id": "b" }, { "id": "c" }]) + ); + let columns = cfr["columns"].as_array().unwrap(); + assert_eq!(columns.len(), 2); + for column in columns { + let values = column["values"].as_array().unwrap(); + assert_eq!(values.len(), 3); + assert!(values.iter().all(|v| v["prop"].is_number())); + } + } +} diff --git a/raphtory-graphql/src/model/algorithms/degree_centrality.rs b/raphtory-graphql/src/model/algorithms/degree_centrality.rs index 58042a9fb9..7215c770a5 100644 --- a/raphtory-graphql/src/model/algorithms/degree_centrality.rs +++ b/raphtory-graphql/src/model/algorithms/degree_centrality.rs @@ -1,8 +1,15 @@ -use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; +use crate::{ + graphql_test, + model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}, + test_support::setup_with_graphs, +}; +use async_graphql::Request; use raphtory::{ algorithms::centrality::degree_centrality::degree_centrality, db::api::view::DynamicGraph, errors::GraphError, }; +use serde_json::json; +use tempfile::tempdir; /// Degree centrality, see [`degree_centrality`]. pub(crate) struct GqlDegreeCentrality; @@ -17,3 +24,60 @@ impl GqlExecutableAlgorithm for GqlDegreeCentrality { Ok(degree_centrality(graph).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_degree_centrality() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::centrality_test_graph())], + tmp_dir.path(), + ) + .await; + + let query = r#" + { + graph(path: "g") { + algorithm { + degreeCentrality { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // degree/max_degree: endpoints 0.5, middle nodes 1.0 + let entry = |id: &str, prop| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "degree_centrality", "value": { "prop": prop } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "degreeCentrality": { "rows": [ + entry("a", 0.5), + entry("b", 1.0), + entry("c", 1.0), + entry("d", 0.5), + ] } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/dijkstra.rs b/raphtory-graphql/src/model/algorithms/dijkstra.rs index 5d5287b293..d0b4f5cf6d 100644 --- a/raphtory-graphql/src/model/algorithms/dijkstra.rs +++ b/raphtory-graphql/src/model/algorithms/dijkstra.rs @@ -32,3 +32,77 @@ impl GqlExecutableAlgorithm for GqlDijkstra { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_dijkstra() { + let graph = Graph::new(); + // weighted chain a -> b -> c + graph + .add_edge(1, "a", "b", [("weight", 2.0)], None) + .unwrap(); + graph + .add_edge(2, "b", "c", [("weight", 3.0)], None) + .unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // Mixed columns: `distance` is a Prop, `path` is Nodes + let query = r#" + { + graph(path: "g") { + algorithm { + dijkstra(source: "a", targets: ["c"], weight: "weight", direction: OUT) { + nodes { list { id } } + columns { + name + values { + __typename + ... on NodeStateProp { prop } + ... on Nodes { ids } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // one row (target c): distance 2+3=5, path a -> b -> c + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "dijkstra": { + "nodes": { "list": [{ "id": "c" }] }, + "columns": [ + { + "name": "distance", + "values": [{ "__typename": "NodeStateProp", "prop": 5.0 }] + }, + { + "name": "path", + "values": [{ "__typename": "Nodes", "ids": ["a", "b", "c"] }] + } + ] + } + } + } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/fast_rp.rs b/raphtory-graphql/src/model/algorithms/fast_rp.rs index 6575b92b30..4020bf7008 100644 --- a/raphtory-graphql/src/model/algorithms/fast_rp.rs +++ b/raphtory-graphql/src/model/algorithms/fast_rp.rs @@ -30,3 +30,67 @@ impl GqlExecutableAlgorithm for GqlFastRp { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_fast_rp() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + fastRp(embeddingDim: 4, normalizationStrength: 1.0, iterWeights: [1.0, 1.0], seed: 42, threads: 1) { + columnNames + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // each embedding is a 4d vector (embeddingDim); values are deterministic given the seed + let row = |id: &str, embedding: [f64; 4]| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "embedding_state", "value": { "prop": embedding } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "fastRp": { + "columnNames": ["embedding_state"], + "rows": [ + row("a", [-0.9870555097143693, 0.3290185032381231, -1.6450925161906156, 0.0]), + row("b", [0.9870555097143693, 0.3290185032381231, -1.6450925161906156, -0.9870555097143693]), + row("c", [0.0, 1.3160740129524924, -0.6580370064762462, 0.9870555097143693]), + ] + } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs index 2c5420dbb5..f4d9d56c3e 100644 --- a/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs +++ b/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs @@ -31,3 +31,62 @@ impl GqlExecutableAlgorithm for GqlFruchtermanReingold { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_fruchterman_reingold() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + fruchtermanReingold(iterCount: 1) { + columnNames + nodes { + list { id } + } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // layout positions are non-deterministic (random init, no seed), so assert on shape: + // two coordinate columns "0" (x) and "1" (y), each with one float per node. + let data = res.data.into_json().unwrap(); + let fr = &data["graph"]["algorithm"]["fruchtermanReingold"]; + assert_eq!(fr["columnNames"], json!(["0", "1"])); + assert_eq!( + fr["nodes"]["list"], + json!([{ "id": "a" }, { "id": "b" }, { "id": "c" }]) + ); + let columns = fr["columns"].as_array().unwrap(); + assert_eq!(columns.len(), 2); + for column in columns { + let values = column["values"].as_array().unwrap(); + assert_eq!(values.len(), 3); + assert!(values.iter().all(|v| v["prop"].is_number())); + } + } +} diff --git a/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs b/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs index 1744b2ca3d..c6ef95b8d2 100644 --- a/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs +++ b/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs @@ -22,3 +22,60 @@ impl GqlExecutableAlgorithm for GqlGlobalTemporalThreeNodeMotif { Ok(counts.to_vec()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_global_temporal_three_node_motif() { + let graph = Graph::new(); + // a -> b -> c -> a, each edge at a distinct time, so triangle motifs are counted + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + single: globalTemporalThreeNodeMotif(delta: 10) + multi: globalTemporalThreeNodeMotifMulti(deltas: [10, 1]) { delta counts } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let data = res.data.into_json().unwrap(); + let single = data["graph"]["algorithm"]["single"].as_array().unwrap(); + let multi = data["graph"]["algorithm"]["multi"].as_array().unwrap(); + + // 40 counts: 8 two-node + 24 star + 8 triangle + assert_eq!(single.len(), 40); + // one row per delta, and the first row is the same as the single-delta call + assert_eq!(multi.len(), 2); + assert!(multi + .iter() + .all(|row| row["counts"].as_array().unwrap().len() == 40)); + assert_eq!(multi[0]["delta"], 10); + assert_eq!(multi[1]["delta"], 1); + assert_eq!(multi[0]["counts"].as_array().unwrap(), single); + // delta 10 spans the whole triangle so it finds motifs delta 1 does not + assert!( + single.iter().any(|c| c.as_u64().unwrap() > 0), + "expected some motifs at delta 10, got {single:?}" + ); + assert_ne!(multi[0]["counts"], multi[1]["counts"]); + } +} diff --git a/raphtory-graphql/src/model/algorithms/hits.rs b/raphtory-graphql/src/model/algorithms/hits.rs index 16878bca6b..707f95b7cb 100644 --- a/raphtory-graphql/src/model/algorithms/hits.rs +++ b/raphtory-graphql/src/model/algorithms/hits.rs @@ -19,3 +19,65 @@ impl GqlExecutableAlgorithm for GqlHits { Ok(hits(graph, args.iter_count, args.threads).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_hits() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::centrality_test_graph())], + tmp_dir.path(), + ) + .await; + + // hits has two columns (hub_score, auth_score) + let query = r#" + { + graph(path: "g") { + algorithm { + hits(iterCount: 20) { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // source has no auth, sink has no hub + let s = 0.3333333333333333; + let row = |id: &str, hub, auth| { + json!({ + "node": { "id": id }, + "entries": [ + { "columnName": "hub_score", "value": { "prop": hub } }, + { "columnName": "auth_score", "value": { "prop": auth } } + ] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "hits": { "rows": [ + row("a", s, 0.0), + row("b", s, s), + row("c", s, s), + row("d", 0.0, s), + ] } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/in_components.rs b/raphtory-graphql/src/model/algorithms/in_components.rs index 544d25be2b..4f35b1f051 100644 --- a/raphtory-graphql/src/model/algorithms/in_components.rs +++ b/raphtory-graphql/src/model/algorithms/in_components.rs @@ -19,3 +19,98 @@ impl GqlExecutableAlgorithm for GqlInComponents { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_in_components() { + let graph = Graph::new(); + // chain a -> b -> c + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // The `in_components` column holds Nodes + let query = r#" + { + graph(path: "g") { + algorithm { + inComponents { + rows { + node { id } + entries { + columnName + value { + __typename + ... on Nodes { ids } + } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // component node order is not guaranteed (backed by a HashSet), so sort each set + let mut data = res.data.into_json().unwrap(); + for row in data["graph"]["algorithm"]["inComponents"]["rows"] + .as_array_mut() + .unwrap() + { + for entry in row["entries"].as_array_mut().unwrap() { + if let Some(ids) = entry["value"]["ids"].as_array_mut() { + ids.sort_by_key(|id| id.as_str().unwrap().to_string()); + } + } + } + // in_components: a <- {}, b <- {a}, c <- {a,b} + assert_eq!( + data, + json!({ + "graph": { + "algorithm": { + "inComponents": { + "rows": [ + { + "node": { "id": "a" }, + "entries": [{ + "columnName": "in_components", + "value": { "__typename": "Nodes", "ids": [] } + }] + }, + { + "node": { "id": "b" }, + "entries": [{ + "columnName": "in_components", + "value": { "__typename": "Nodes", "ids": ["a"] } + }] + }, + { + "node": { "id": "c" }, + "entries": [{ + "columnName": "in_components", + "value": { "__typename": "Nodes", "ids": ["a", "b"] } + }] + } + ] + } + } + } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/label_propagation.rs b/raphtory-graphql/src/model/algorithms/label_propagation.rs index bf483224e8..43d60a6d69 100644 --- a/raphtory-graphql/src/model/algorithms/label_propagation.rs +++ b/raphtory-graphql/src/model/algorithms/label_propagation.rs @@ -21,3 +21,60 @@ impl GqlExecutableAlgorithm for GqlLabelPropagation { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_label_propagation() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::community_test_graph())], + tmp_dir.path(), + ) + .await; + + // threads: 1 for deterministic output (multi-threaded label propagation output is non-deterministic) + let query = r#" + { + graph(path: "g") { + algorithm { + labelPropagation(threads: 1) { + nodes { list { id } } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // two triangles -> two communities; ids derive from node index + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "labelPropagation": { + "nodes": { "list": [ + { "id": "a" }, { "id": "b" }, { "id": "c" }, + { "id": "d" }, { "id": "e" }, { "id": "f" } + ] }, + "columns": [{ + "name": "community_id", + "values": [ + { "prop": 2 }, { "prop": 2 }, { "prop": 2 }, + { "prop": 600002 }, { "prop": 600002 }, { "prop": 600002 } + ] + }] + } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs b/raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs index 81b53b90b3..feeab3311a 100644 --- a/raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs +++ b/raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs @@ -24,3 +24,48 @@ impl GqlExecutableAlgorithm for GqlLocalClusteringCoefficient { Ok(local_clustering_coefficient(&view, args.node)) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_local_clustering_coefficient() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::scalar_metrics_test_graph())], + tmp_dir.path(), + ) + .await; + + // a is in the a-b-c triangle; d is a pendant with degree 1 + // only a missing node yields null + let query = r#" + { + graph(path: "g") { + algorithm { + inTriangle: localClusteringCoefficient(node: "a") + pendant: localClusteringCoefficient(node: "d") + missing: localClusteringCoefficient(node: "not-a-node") + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { + "inTriangle": 1.0, + "pendant": 0.0, + "missing": null + } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs b/raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs index d0d1ccb0ca..899d7b7ce1 100644 --- a/raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs +++ b/raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs @@ -19,3 +19,61 @@ impl GqlExecutableAlgorithm for GqlLocalClusteringCoefficientBatch { Ok(local_clustering_coefficient_batch(graph, args.nodes).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_local_clustering_coefficient_batch() { + let graph = Graph::new(); + // triangle a-b-c + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + localClusteringCoefficientBatch(nodes: ["a", "b"]) { + rows { + node { id } + entries { columnName value { ... on NodeStateProp { prop } } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // only the queried nodes are present; each is in a triangle -> coefficient 1.0 + let entry = |id: &str| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "lcc", "value": { "prop": 1.0 } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "localClusteringCoefficientBatch": { "rows": [ + entry("a"), + entry("b"), + ] } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs b/raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs index 0f6c1ab8d5..d668277df6 100644 --- a/raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs +++ b/raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs @@ -21,3 +21,68 @@ impl GqlExecutableAlgorithm for GqlLocalTemporalThreeNodeMotifs { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_local_temporal_three_node_motifs() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + localTemporalThreeNodeMotifs(delta: 10) { + columnNames + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // each node gets a 40d motif-count vector; in this triangle each participates in motif 35 + let motif_counter = { + let mut v = vec![0; 40]; + v[35] = 1; + v + }; + let row = |id: &str| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "motif_counter", "value": { "prop": motif_counter } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "localTemporalThreeNodeMotifs": { + "columnNames": ["motif_counter"], + "rows": [row("a"), row("b"), row("c")] + } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/local_triangle_count.rs b/raphtory-graphql/src/model/algorithms/local_triangle_count.rs index 5adef1f931..db14f0b569 100644 --- a/raphtory-graphql/src/model/algorithms/local_triangle_count.rs +++ b/raphtory-graphql/src/model/algorithms/local_triangle_count.rs @@ -24,3 +24,76 @@ impl GqlExecutableAlgorithm for GqlLocalTriangleCount { Ok(local_triangle_count(&view, args.node)) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_local_triangle_count() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::scalar_metrics_test_graph())], + tmp_dir.path(), + ) + .await; + + // a is in the a-b-c triangle; d is a pendant with degree 1 + // only a missing node yields null + let query = r#" + { + graph(path: "g") { + algorithm { + inTriangle: localTriangleCount(node: "a") + pendant: localTriangleCount(node: "d") + missing: localTriangleCount(node: "not-a-node") + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { + "inTriangle": 1, + "pendant": 0, + "missing": null + } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_local_triangle_count_filtered() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::scalar_metrics_test_graph())], + tmp_dir.path(), + ) + .await; + + // filtering out c breaks the a-b-c triangle, so a's local triangle count drops + let query = r#" + { + graph(path: "g") { + algorithm { + localTriangleCount(node: "a", filter: { nodes: { node: { field: NODE_NAME, where: { ne: { str: "c" } } } } }) + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ "graph": { "algorithm": { "localTriangleCount": 0 } } }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/louvain.rs b/raphtory-graphql/src/model/algorithms/louvain.rs index c329a0507e..f8bbb61fec 100644 --- a/raphtory-graphql/src/model/algorithms/louvain.rs +++ b/raphtory-graphql/src/model/algorithms/louvain.rs @@ -30,3 +30,63 @@ impl GqlExecutableAlgorithm for GqlLouvain { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_louvain() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::community_test_graph())], + tmp_dir.path(), + ) + .await; + + // fixed rng_seed for deterministic output + let query = r#" + { + graph(path: "g") { + algorithm { + louvain(rngSeed: 42) { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // two triangles -> two communities: {a,b,c} and {d,e,f} + let entry = |id: &str, community| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "community_id", "value": { "prop": community } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "louvain": { "rows": [ + entry("a", 0), + entry("b", 0), + entry("c", 0), + entry("d", 1), + entry("e", 1), + entry("f", 1), + ] } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/max_weight_matching.rs b/raphtory-graphql/src/model/algorithms/max_weight_matching.rs index a6d80d9f9a..506a2438ca 100644 --- a/raphtory-graphql/src/model/algorithms/max_weight_matching.rs +++ b/raphtory-graphql/src/model/algorithms/max_weight_matching.rs @@ -27,3 +27,78 @@ impl GqlExecutableAlgorithm for GqlMaxWeightMatching { Ok(matching.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_max_weight_matching() { + let graph = Graph::new(); + // path a-b-c-d: the max weight matching picks a-b (5) and c-d (4) over b-c (3) + graph + .add_edge(1, "a", "b", [("weight", 5.0)], None) + .unwrap(); + graph + .add_edge(1, "b", "c", [("weight", 3.0)], None) + .unwrap(); + graph + .add_edge(1, "c", "d", [("weight", 4.0)], None) + .unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + maxWeightMatching(weightProp: "weight") { + count + edges { list { src { id } dst { id } } } + dstOfA: dst(src: "a") { id } + srcOfD: src(dst: "d") { id } + hasAB: contains(src: "a", dst: "b") + hasBC: contains(src: "b", dst: "c") + edgeForA: edgeForSrc(src: "a") { src { id } dst { id } } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // the matching is backed by a HashMap, so edge order is not guaranteed + let mut data = res.data.into_json().unwrap(); + data["graph"]["algorithm"]["maxWeightMatching"]["edges"]["list"] + .as_array_mut() + .unwrap() + .sort_by_key(|edge| edge["src"]["id"].as_str().unwrap().to_string()); + // picks a-b and c-d (total weight 9) over the single b-c edge (weight 3) + assert_eq!( + data, + json!({ + "graph": { "algorithm": { "maxWeightMatching": { + "count": 2, + "edges": { "list": [ + { "src": { "id": "a" }, "dst": { "id": "b" } }, + { "src": { "id": "c" }, "dst": { "id": "d" } } + ] }, + "dstOfA": { "id": "b" }, + "srcOfD": { "id": "c" }, + "hasAB": true, + "hasBC": false, + "edgeForA": { "src": { "id": "a" }, "dst": { "id": "b" } } + } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/out_components.rs b/raphtory-graphql/src/model/algorithms/out_components.rs index e9f966ceb0..199e009c2b 100644 --- a/raphtory-graphql/src/model/algorithms/out_components.rs +++ b/raphtory-graphql/src/model/algorithms/out_components.rs @@ -19,3 +19,82 @@ impl GqlExecutableAlgorithm for GqlOutComponents { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_out_components() { + let graph = Graph::new(); + // chain a -> b -> c + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // The `out_components` column holds Nodes + let query = r#" + { + graph(path: "g") { + algorithm { + outComponents { + nodes { list { id } } + columns { + name + values { + __typename + ... on Nodes { ids } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // component node order is not guaranteed (backed by a HashSet), so sort each set + let mut data = res.data.into_json().unwrap(); + for col in data["graph"]["algorithm"]["outComponents"]["columns"] + .as_array_mut() + .unwrap() + { + for value in col["values"].as_array_mut().unwrap() { + if let Some(ids) = value["ids"].as_array_mut() { + ids.sort_by_key(|id| id.as_str().unwrap().to_string()); + } + } + } + // values are row-aligned with nodes: a -> {b,c}, b -> {c}, c -> {} + assert_eq!( + data, + json!({ + "graph": { + "algorithm": { + "outComponents": { + "nodes": { "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] }, + "columns": [{ + "name": "out_components", + "values": [ + { "__typename": "Nodes", "ids": ["b", "c"] }, + { "__typename": "Nodes", "ids": ["c"] }, + { "__typename": "Nodes", "ids": [] } + ] + }] + } + } + } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/pagerank.rs b/raphtory-graphql/src/model/algorithms/pagerank.rs index 2dadcda415..8cc80d043b 100644 --- a/raphtory-graphql/src/model/algorithms/pagerank.rs +++ b/raphtory-graphql/src/model/algorithms/pagerank.rs @@ -32,3 +32,79 @@ impl GqlExecutableAlgorithm for GqlPagerank { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_pagerank() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "a", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + pagerank(iterCount: 20) { + count + nodes { list { name } } + columns { + name + values { + __typename + ... on NodeStateProp { prop } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // in a 3-cycle all nodes have the same rank of 1/3 + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "pagerank": { + "count": 3, + "nodes": { + "list": [ + { "name": "a" }, + { "name": "b" }, + { "name": "c" } + ] + }, + "columns": [ + { + "name": "pagerank_score", + "values": [ + { "__typename": "NodeStateProp", "prop": 0.3333333333333333 }, + { "__typename": "NodeStateProp", "prop": 0.3333333333333333 }, + { "__typename": "NodeStateProp", "prop": 0.3333333333333333 } + ] + } + ] + } + } + } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs b/raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs index 4566e4a767..8b31ea5c08 100644 --- a/raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs +++ b/raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs @@ -21,3 +21,101 @@ impl GqlExecutableAlgorithm for GqlSingleSourceShortestPath { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_single_source_shortest_path() { + let graph = Graph::new(); + // simple chain a -> b -> c + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // The `path` column holds Nodes, not a Prop + let query = r#" + { + graph(path: "g") { + algorithm { + singleSourceShortestPath(source: "a") { + columnNames + rows { + node { id } + entries { + columnName + value { + __typename + ... on Nodes { list { id } } + } + } + } + min(column: "path") { value } + mean(column: "path") + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { + "algorithm": { + "singleSourceShortestPath": { + "columnNames": ["path"], + "rows": [ + { + "node": { "id": "a" }, + "entries": [{ + "columnName": "path", + "value": { + "__typename": "Nodes", + "list": [{ "id": "a" }] + } + }] + }, + { + "node": { "id": "b" }, + "entries": [{ + "columnName": "path", + "value": { + "__typename": "Nodes", + "list": [{ "id": "a" }, { "id": "b" }] + } + }] + }, + { + "node": { "id": "c" }, + "entries": [{ + "columnName": "path", + "value": { + "__typename": "Nodes", + "list": [{ "id": "a" }, { "id": "b" }, { "id": "c" }] + } + }] + } + ], + // node-valued column: numeric aggregates return null + "min": null, + "mean": null + } + } + } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/strongly_connected_components.rs b/raphtory-graphql/src/model/algorithms/strongly_connected_components.rs index 80867bf69b..759f849881 100644 --- a/raphtory-graphql/src/model/algorithms/strongly_connected_components.rs +++ b/raphtory-graphql/src/model/algorithms/strongly_connected_components.rs @@ -17,3 +17,60 @@ impl GqlExecutableAlgorithm for GqlStronglyConnectedComponents { Ok(strongly_connected_components(graph).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_strongly_connected_components() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::components_test_graph())], + tmp_dir.path(), + ) + .await; + + // {a,b,c} form one SCC (the cycle); d is its own + let query = r#" + { + graph(path: "g") { + algorithm { + stronglyConnectedComponents { + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + let entry = |id: &str, component| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "component_id", "value": { "prop": component } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "stronglyConnectedComponents": { "rows": [ + entry("a", 0), + entry("b", 0), + entry("c", 0), + entry("d", 1), + ] } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs b/raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs index 5c9429a041..01cc1d148f 100644 --- a/raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs +++ b/raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs @@ -38,3 +38,54 @@ impl GqlExecutableAlgorithm for GqlTemporalRichClubCoefficient { )) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_temporal_rich_club_coefficient() { + let graph = Graph::new(); + // a triangle a-b-c repeated at every time step, so it persists across + // every snapshot, plus a pendant d that never joins the club + for t in 1..=4 { + for (src, dst) in [("a", "b"), ("b", "c"), ("c", "a")] { + graph.add_edge(t, src, dst, NO_PROPS, None).unwrap(); + } + } + graph.add_edge(1, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // one snapshot per time step; the triangle persists over every pair of them + let query = r#" + { + graph(path: "g") { + algorithm { + temporalRichClubCoefficient( + k: 2 + windowSize: 2 + rollingWindow: { epoch: 1 } + ) + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // the a-b-c triangle is fully connected and persists, so the coefficient is 1 + assert_eq!( + res.data.into_json().unwrap(), + json!({ "graph": { "algorithm": { "temporalRichClubCoefficient": 1.0 } } }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/temporal_seir.rs b/raphtory-graphql/src/model/algorithms/temporal_seir.rs index d3d022a3b9..49067a5d84 100644 --- a/raphtory-graphql/src/model/algorithms/temporal_seir.rs +++ b/raphtory-graphql/src/model/algorithms/temporal_seir.rs @@ -75,3 +75,106 @@ impl GqlExecutableAlgorithm for GqlTemporalSeir { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_temporal_seir() { + let graph = Graph::new(); + // a chain so the infection can spread forward in time + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // seeding an explicit node with certain infection spreads along the chain; + // rngSeed keeps the run reproducible + let query = r#" + { + graph(path: "g") { + algorithm { + temporalSeir( + seeds: { nodes: ["a"] } + infectionProb: 1.0 + initialInfection: 0 + rngSeed: 42 + ) { + nodes { ids } + columnNames + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "temporalSeir": { + "nodes": { "ids": ["a", "b", "c", "d"] }, + "columnNames": ["infected", "active", "recovered"] + } } } + }) + ); + } + + #[tokio::test] + async fn test_algorithm_temporal_seir_seed_variants() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + graph.add_edge(3, "c", "d", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + // all three Seeds variants are accepted; number/probability pick nodes at random + let query = r#" + { + graph(path: "g") { + algorithm { + byNumber: temporalSeir( + seeds: { number: 2 } + infectionProb: 0.0 + initialInfection: 0 + rngSeed: 7 + ) { count } + byProbability: temporalSeir( + seeds: { probability: 0.5 } + infectionProb: 0.0 + initialInfection: 0 + rngSeed: 7 + ) { count } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // With no onward infection only the seeds appear. `number` samples exactly that + // many nodes; `probability` seeds each node independently + let data = res.data.into_json().unwrap(); + assert_eq!(data["graph"]["algorithm"]["byNumber"]["count"], 2); + let by_probability = data["graph"]["algorithm"]["byProbability"]["count"] + .as_u64() + .unwrap(); + assert!( + by_probability <= 4, + "expected at most every node to be seeded, got {by_probability}" + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs b/raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs index 3b0f174512..4bfff47ee7 100644 --- a/raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs +++ b/raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs @@ -31,3 +31,66 @@ impl GqlExecutableAlgorithm for GqlTemporallyReachableNodes { Ok(state.into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::test_support::setup_with_graphs; + use async_graphql::Request; + use raphtory::{ + db::api::view::MaterializedGraph, + prelude::{AdditionOps, Graph, NO_PROPS}, + }; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_temporally_reachable_nodes() { + let graph = Graph::new(); + graph.add_edge(1, "a", "b", NO_PROPS, None).unwrap(); + graph.add_edge(2, "b", "c", NO_PROPS, None).unwrap(); + let graph: MaterializedGraph = graph.into(); + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs(&[("g", graph)], tmp_dir.path()).await; + + let query = r#" + { + graph(path: "g") { + algorithm { + temporallyReachableNodes(maxHops: 5, startTime: 0, seedNodes: ["a"], threads: 1) { + columnNames + rows { + node { id } + entries { + columnName + value { ... on NodeStateProp { prop } } + } + } + } + } + } + } + "#; + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // each node is tainted by (time, source); tuples serialize as {"0": time, "1": source} + let row = |id: &str, taint: serde_json::Value| { + json!({ + "node": { "id": id }, + "entries": [{ "columnName": "reachable_nodes", "value": { "prop": [taint] } }] + }) + }; + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "temporallyReachableNodes": { + "columnNames": ["reachable_nodes"], + "rows": [ + row("a", json!({ "0": 0, "1": "start" })), + row("b", json!({ "0": 1, "1": "a" })), + row("c", json!({ "0": 2, "1": "b" })), + ] + } } } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/algorithms/weakly_connected_components.rs b/raphtory-graphql/src/model/algorithms/weakly_connected_components.rs index 23ceefa0f2..c21f6e2966 100644 --- a/raphtory-graphql/src/model/algorithms/weakly_connected_components.rs +++ b/raphtory-graphql/src/model/algorithms/weakly_connected_components.rs @@ -17,3 +17,56 @@ impl GqlExecutableAlgorithm for GqlWeaklyConnectedComponents { Ok(weakly_connected_components(graph).into()) } } + +#[cfg(test)] +mod graphql_test { + use crate::{graphql_test, test_support::setup_with_graphs}; + use async_graphql::Request; + use serde_json::json; + use tempfile::tempdir; + + #[tokio::test] + async fn test_algorithm_weakly_connected_components() { + let tmp_dir = tempdir().unwrap(); + let setup = setup_with_graphs( + &[("g", graphql_test::components_test_graph())], + tmp_dir.path(), + ) + .await; + + // whole graph is weakly connected -> all nodes share one component + let query = r#" + { + graph(path: "g") { + algorithm { + weaklyConnectedComponents { + nodes { list { id } } + columns { + name + values { ... on NodeStateProp { prop } } + } + } + } + } + } + "#; + + let res = setup.schema.execute(Request::new(query)).await; + assert_eq!(res.errors, vec![], "{:?}", res.errors); + // all four nodes are weakly connected -> one component + assert_eq!( + res.data.into_json().unwrap(), + json!({ + "graph": { "algorithm": { "weaklyConnectedComponents": { + "nodes": { "list": [ + { "id": "a" }, { "id": "b" }, { "id": "c" }, { "id": "d" } + ] }, + "columns": [{ + "name": "component_id", + "values": [{ "prop": 0 }, { "prop": 0 }, { "prop": 0 }, { "prop": 0 }] + }] + } } } + }) + ); + } +} From 02b114310dcd79863f195815cb7212d2ae5b927d Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 5 Aug 2026 03:48:59 -0400 Subject: [PATCH 36/39] Change directory structure of algorithms in GraphQL to match that of core rust --- .../src/model/algorithms/{ => bipartite}/max_weight_matching.rs | 0 .../model/algorithms/{ => centrality}/betweenness_centrality.rs | 0 .../src/model/algorithms/{ => centrality}/degree_centrality.rs | 0 raphtory-graphql/src/model/algorithms/{ => centrality}/hits.rs | 0 .../src/model/algorithms/{ => centrality}/pagerank.rs | 0 .../algorithms/{ => community_detection}/label_propagation.rs | 0 .../src/model/algorithms/{ => community_detection}/louvain.rs | 0 .../src/model/algorithms/{ => components}/in_component.rs | 0 .../src/model/algorithms/{ => components}/in_components.rs | 0 .../src/model/algorithms/{ => components}/out_component.rs | 0 .../src/model/algorithms/{ => components}/out_components.rs | 0 .../algorithms/{ => components}/strongly_connected_components.rs | 0 .../algorithms/{ => components}/weakly_connected_components.rs | 0 .../src/model/algorithms/{ => dynamics/temporal}/temporal_seir.rs | 0 raphtory-graphql/src/model/algorithms/{ => embeddings}/fast_rp.rs | 0 .../algorithms/{ => layout}/cohesive_fruchterman_reingold.rs | 0 .../src/model/algorithms/{ => layout}/fruchterman_reingold.rs | 0 .../src/model/algorithms/{ => metrics}/all_local_reciprocity.rs | 0 .../src/model/algorithms/{ => metrics}/average_degree.rs | 0 raphtory-graphql/src/model/algorithms/{ => metrics}/balance.rs | 0 .../clustering_coefficient}/global_clustering_coefficient.rs | 0 .../clustering_coefficient}/local_clustering_coefficient.rs | 0 .../clustering_coefficient}/local_clustering_coefficient_batch.rs | 0 .../src/model/algorithms/{ => metrics}/directed_graph_density.rs | 0 .../src/model/algorithms/{ => metrics}/global_reciprocity.rs | 0 raphtory-graphql/src/model/algorithms/{ => metrics}/max_degree.rs | 0 .../src/model/algorithms/{ => metrics}/max_in_degree.rs | 0 .../src/model/algorithms/{ => metrics}/max_out_degree.rs | 0 raphtory-graphql/src/model/algorithms/{ => metrics}/min_degree.rs | 0 .../src/model/algorithms/{ => metrics}/min_in_degree.rs | 0 .../src/model/algorithms/{ => metrics}/min_out_degree.rs | 0 .../algorithms/{ => motifs}/global_temporal_three_node_motif.rs | 0 .../{ => motifs}/global_temporal_three_node_motif_multi.rs | 0 .../algorithms/{ => motifs}/local_temporal_three_node_motifs.rs | 0 .../src/model/algorithms/{ => motifs}/local_triangle_count.rs | 0 .../algorithms/{ => motifs}/temporal_rich_club_coefficient.rs | 0 .../src/model/algorithms/{ => motifs}/triangle_count.rs | 0 .../src/model/algorithms/{ => motifs}/triplet_count.rs | 0 raphtory-graphql/src/model/algorithms/{ => pathing}/dijkstra.rs | 0 .../model/algorithms/{ => pathing}/single_source_shortest_path.rs | 0 .../model/algorithms/{ => pathing}/temporally_reachable_nodes.rs | 0 41 files changed, 0 insertions(+), 0 deletions(-) rename raphtory-graphql/src/model/algorithms/{ => bipartite}/max_weight_matching.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => centrality}/betweenness_centrality.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => centrality}/degree_centrality.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => centrality}/hits.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => centrality}/pagerank.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => community_detection}/label_propagation.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => community_detection}/louvain.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => components}/in_component.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => components}/in_components.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => components}/out_component.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => components}/out_components.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => components}/strongly_connected_components.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => components}/weakly_connected_components.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => dynamics/temporal}/temporal_seir.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => embeddings}/fast_rp.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => layout}/cohesive_fruchterman_reingold.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => layout}/fruchterman_reingold.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/all_local_reciprocity.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/average_degree.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/balance.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics/clustering_coefficient}/global_clustering_coefficient.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics/clustering_coefficient}/local_clustering_coefficient.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics/clustering_coefficient}/local_clustering_coefficient_batch.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/directed_graph_density.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/global_reciprocity.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/max_degree.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/max_in_degree.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/max_out_degree.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/min_degree.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/min_in_degree.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => metrics}/min_out_degree.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => motifs}/global_temporal_three_node_motif.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => motifs}/global_temporal_three_node_motif_multi.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => motifs}/local_temporal_three_node_motifs.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => motifs}/local_triangle_count.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => motifs}/temporal_rich_club_coefficient.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => motifs}/triangle_count.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => motifs}/triplet_count.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => pathing}/dijkstra.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => pathing}/single_source_shortest_path.rs (100%) rename raphtory-graphql/src/model/algorithms/{ => pathing}/temporally_reachable_nodes.rs (100%) diff --git a/raphtory-graphql/src/model/algorithms/max_weight_matching.rs b/raphtory-graphql/src/model/algorithms/bipartite/max_weight_matching.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/max_weight_matching.rs rename to raphtory-graphql/src/model/algorithms/bipartite/max_weight_matching.rs diff --git a/raphtory-graphql/src/model/algorithms/betweenness_centrality.rs b/raphtory-graphql/src/model/algorithms/centrality/betweenness_centrality.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/betweenness_centrality.rs rename to raphtory-graphql/src/model/algorithms/centrality/betweenness_centrality.rs diff --git a/raphtory-graphql/src/model/algorithms/degree_centrality.rs b/raphtory-graphql/src/model/algorithms/centrality/degree_centrality.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/degree_centrality.rs rename to raphtory-graphql/src/model/algorithms/centrality/degree_centrality.rs diff --git a/raphtory-graphql/src/model/algorithms/hits.rs b/raphtory-graphql/src/model/algorithms/centrality/hits.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/hits.rs rename to raphtory-graphql/src/model/algorithms/centrality/hits.rs diff --git a/raphtory-graphql/src/model/algorithms/pagerank.rs b/raphtory-graphql/src/model/algorithms/centrality/pagerank.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/pagerank.rs rename to raphtory-graphql/src/model/algorithms/centrality/pagerank.rs diff --git a/raphtory-graphql/src/model/algorithms/label_propagation.rs b/raphtory-graphql/src/model/algorithms/community_detection/label_propagation.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/label_propagation.rs rename to raphtory-graphql/src/model/algorithms/community_detection/label_propagation.rs diff --git a/raphtory-graphql/src/model/algorithms/louvain.rs b/raphtory-graphql/src/model/algorithms/community_detection/louvain.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/louvain.rs rename to raphtory-graphql/src/model/algorithms/community_detection/louvain.rs diff --git a/raphtory-graphql/src/model/algorithms/in_component.rs b/raphtory-graphql/src/model/algorithms/components/in_component.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/in_component.rs rename to raphtory-graphql/src/model/algorithms/components/in_component.rs diff --git a/raphtory-graphql/src/model/algorithms/in_components.rs b/raphtory-graphql/src/model/algorithms/components/in_components.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/in_components.rs rename to raphtory-graphql/src/model/algorithms/components/in_components.rs diff --git a/raphtory-graphql/src/model/algorithms/out_component.rs b/raphtory-graphql/src/model/algorithms/components/out_component.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/out_component.rs rename to raphtory-graphql/src/model/algorithms/components/out_component.rs diff --git a/raphtory-graphql/src/model/algorithms/out_components.rs b/raphtory-graphql/src/model/algorithms/components/out_components.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/out_components.rs rename to raphtory-graphql/src/model/algorithms/components/out_components.rs diff --git a/raphtory-graphql/src/model/algorithms/strongly_connected_components.rs b/raphtory-graphql/src/model/algorithms/components/strongly_connected_components.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/strongly_connected_components.rs rename to raphtory-graphql/src/model/algorithms/components/strongly_connected_components.rs diff --git a/raphtory-graphql/src/model/algorithms/weakly_connected_components.rs b/raphtory-graphql/src/model/algorithms/components/weakly_connected_components.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/weakly_connected_components.rs rename to raphtory-graphql/src/model/algorithms/components/weakly_connected_components.rs diff --git a/raphtory-graphql/src/model/algorithms/temporal_seir.rs b/raphtory-graphql/src/model/algorithms/dynamics/temporal/temporal_seir.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/temporal_seir.rs rename to raphtory-graphql/src/model/algorithms/dynamics/temporal/temporal_seir.rs diff --git a/raphtory-graphql/src/model/algorithms/fast_rp.rs b/raphtory-graphql/src/model/algorithms/embeddings/fast_rp.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/fast_rp.rs rename to raphtory-graphql/src/model/algorithms/embeddings/fast_rp.rs diff --git a/raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/layout/cohesive_fruchterman_reingold.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/cohesive_fruchterman_reingold.rs rename to raphtory-graphql/src/model/algorithms/layout/cohesive_fruchterman_reingold.rs diff --git a/raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/layout/fruchterman_reingold.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/fruchterman_reingold.rs rename to raphtory-graphql/src/model/algorithms/layout/fruchterman_reingold.rs diff --git a/raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs b/raphtory-graphql/src/model/algorithms/metrics/all_local_reciprocity.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/all_local_reciprocity.rs rename to raphtory-graphql/src/model/algorithms/metrics/all_local_reciprocity.rs diff --git a/raphtory-graphql/src/model/algorithms/average_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/average_degree.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/average_degree.rs rename to raphtory-graphql/src/model/algorithms/metrics/average_degree.rs diff --git a/raphtory-graphql/src/model/algorithms/balance.rs b/raphtory-graphql/src/model/algorithms/metrics/balance.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/balance.rs rename to raphtory-graphql/src/model/algorithms/metrics/balance.rs diff --git a/raphtory-graphql/src/model/algorithms/global_clustering_coefficient.rs b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/global_clustering_coefficient.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/global_clustering_coefficient.rs rename to raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/global_clustering_coefficient.rs diff --git a/raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/local_clustering_coefficient.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/local_clustering_coefficient.rs rename to raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/local_clustering_coefficient.rs diff --git a/raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/local_clustering_coefficient_batch.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/local_clustering_coefficient_batch.rs rename to raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/local_clustering_coefficient_batch.rs diff --git a/raphtory-graphql/src/model/algorithms/directed_graph_density.rs b/raphtory-graphql/src/model/algorithms/metrics/directed_graph_density.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/directed_graph_density.rs rename to raphtory-graphql/src/model/algorithms/metrics/directed_graph_density.rs diff --git a/raphtory-graphql/src/model/algorithms/global_reciprocity.rs b/raphtory-graphql/src/model/algorithms/metrics/global_reciprocity.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/global_reciprocity.rs rename to raphtory-graphql/src/model/algorithms/metrics/global_reciprocity.rs diff --git a/raphtory-graphql/src/model/algorithms/max_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/max_degree.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/max_degree.rs rename to raphtory-graphql/src/model/algorithms/metrics/max_degree.rs diff --git a/raphtory-graphql/src/model/algorithms/max_in_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/max_in_degree.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/max_in_degree.rs rename to raphtory-graphql/src/model/algorithms/metrics/max_in_degree.rs diff --git a/raphtory-graphql/src/model/algorithms/max_out_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/max_out_degree.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/max_out_degree.rs rename to raphtory-graphql/src/model/algorithms/metrics/max_out_degree.rs diff --git a/raphtory-graphql/src/model/algorithms/min_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/min_degree.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/min_degree.rs rename to raphtory-graphql/src/model/algorithms/metrics/min_degree.rs diff --git a/raphtory-graphql/src/model/algorithms/min_in_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/min_in_degree.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/min_in_degree.rs rename to raphtory-graphql/src/model/algorithms/metrics/min_in_degree.rs diff --git a/raphtory-graphql/src/model/algorithms/min_out_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/min_out_degree.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/min_out_degree.rs rename to raphtory-graphql/src/model/algorithms/metrics/min_out_degree.rs diff --git a/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs b/raphtory-graphql/src/model/algorithms/motifs/global_temporal_three_node_motif.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif.rs rename to raphtory-graphql/src/model/algorithms/motifs/global_temporal_three_node_motif.rs diff --git a/raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif_multi.rs b/raphtory-graphql/src/model/algorithms/motifs/global_temporal_three_node_motif_multi.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/global_temporal_three_node_motif_multi.rs rename to raphtory-graphql/src/model/algorithms/motifs/global_temporal_three_node_motif_multi.rs diff --git a/raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs b/raphtory-graphql/src/model/algorithms/motifs/local_temporal_three_node_motifs.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/local_temporal_three_node_motifs.rs rename to raphtory-graphql/src/model/algorithms/motifs/local_temporal_three_node_motifs.rs diff --git a/raphtory-graphql/src/model/algorithms/local_triangle_count.rs b/raphtory-graphql/src/model/algorithms/motifs/local_triangle_count.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/local_triangle_count.rs rename to raphtory-graphql/src/model/algorithms/motifs/local_triangle_count.rs diff --git a/raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs b/raphtory-graphql/src/model/algorithms/motifs/temporal_rich_club_coefficient.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/temporal_rich_club_coefficient.rs rename to raphtory-graphql/src/model/algorithms/motifs/temporal_rich_club_coefficient.rs diff --git a/raphtory-graphql/src/model/algorithms/triangle_count.rs b/raphtory-graphql/src/model/algorithms/motifs/triangle_count.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/triangle_count.rs rename to raphtory-graphql/src/model/algorithms/motifs/triangle_count.rs diff --git a/raphtory-graphql/src/model/algorithms/triplet_count.rs b/raphtory-graphql/src/model/algorithms/motifs/triplet_count.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/triplet_count.rs rename to raphtory-graphql/src/model/algorithms/motifs/triplet_count.rs diff --git a/raphtory-graphql/src/model/algorithms/dijkstra.rs b/raphtory-graphql/src/model/algorithms/pathing/dijkstra.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/dijkstra.rs rename to raphtory-graphql/src/model/algorithms/pathing/dijkstra.rs diff --git a/raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs b/raphtory-graphql/src/model/algorithms/pathing/single_source_shortest_path.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/single_source_shortest_path.rs rename to raphtory-graphql/src/model/algorithms/pathing/single_source_shortest_path.rs diff --git a/raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs b/raphtory-graphql/src/model/algorithms/pathing/temporally_reachable_nodes.rs similarity index 100% rename from raphtory-graphql/src/model/algorithms/temporally_reachable_nodes.rs rename to raphtory-graphql/src/model/algorithms/pathing/temporally_reachable_nodes.rs From aeb15a25a02fffe00ecb388e4b8734292624a3e7 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 5 Aug 2026 04:22:17 -0400 Subject: [PATCH 37/39] Fixing new directory structures. Mainly adding mod.rs files to subdirectories --- .../src/model/algorithms/bipartite/mod.rs | 1 + .../centrality/degree_centrality.rs | 9 +- .../src/model/algorithms/centrality/mod.rs | 4 + .../algorithms/community_detection/mod.rs | 2 + .../src/model/algorithms/components/mod.rs | 6 + .../src/model/algorithms/dynamics/mod.rs | 1 + .../model/algorithms/dynamics/temporal/mod.rs | 1 + .../src/model/algorithms/embeddings/mod.rs | 1 + .../src/model/algorithms/layout/mod.rs | 2 + .../metrics/clustering_coefficient/mod.rs | 3 + .../src/model/algorithms/metrics/mod.rs | 12 ++ raphtory-graphql/src/model/algorithms/mod.rs | 185 ++++++++---------- .../src/model/algorithms/motifs/mod.rs | 7 + .../src/model/algorithms/pathing/mod.rs | 3 + 14 files changed, 129 insertions(+), 108 deletions(-) create mode 100644 raphtory-graphql/src/model/algorithms/bipartite/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/centrality/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/community_detection/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/components/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/dynamics/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/dynamics/temporal/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/embeddings/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/layout/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/metrics/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/motifs/mod.rs create mode 100644 raphtory-graphql/src/model/algorithms/pathing/mod.rs diff --git a/raphtory-graphql/src/model/algorithms/bipartite/mod.rs b/raphtory-graphql/src/model/algorithms/bipartite/mod.rs new file mode 100644 index 0000000000..b7c5eb8805 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/bipartite/mod.rs @@ -0,0 +1 @@ +pub(crate) mod max_weight_matching; diff --git a/raphtory-graphql/src/model/algorithms/centrality/degree_centrality.rs b/raphtory-graphql/src/model/algorithms/centrality/degree_centrality.rs index 7215c770a5..e88ab1a3d0 100644 --- a/raphtory-graphql/src/model/algorithms/centrality/degree_centrality.rs +++ b/raphtory-graphql/src/model/algorithms/centrality/degree_centrality.rs @@ -1,15 +1,8 @@ -use crate::{ - graphql_test, - model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}, - test_support::setup_with_graphs, -}; -use async_graphql::Request; +use crate::model::{algorithms::GqlExecutableAlgorithm, graph::node_state::GqlNodeState}; use raphtory::{ algorithms::centrality::degree_centrality::degree_centrality, db::api::view::DynamicGraph, errors::GraphError, }; -use serde_json::json; -use tempfile::tempdir; /// Degree centrality, see [`degree_centrality`]. pub(crate) struct GqlDegreeCentrality; diff --git a/raphtory-graphql/src/model/algorithms/centrality/mod.rs b/raphtory-graphql/src/model/algorithms/centrality/mod.rs new file mode 100644 index 0000000000..c904de0cee --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/centrality/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod betweenness_centrality; +pub(crate) mod degree_centrality; +pub(crate) mod hits; +pub(crate) mod pagerank; diff --git a/raphtory-graphql/src/model/algorithms/community_detection/mod.rs b/raphtory-graphql/src/model/algorithms/community_detection/mod.rs new file mode 100644 index 0000000000..ad2fe6b279 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/community_detection/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod label_propagation; +pub(crate) mod louvain; diff --git a/raphtory-graphql/src/model/algorithms/components/mod.rs b/raphtory-graphql/src/model/algorithms/components/mod.rs new file mode 100644 index 0000000000..dc2465b20f --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/components/mod.rs @@ -0,0 +1,6 @@ +pub(crate) mod in_component; +pub(crate) mod in_components; +pub(crate) mod out_component; +pub(crate) mod out_components; +pub(crate) mod strongly_connected_components; +pub(crate) mod weakly_connected_components; diff --git a/raphtory-graphql/src/model/algorithms/dynamics/mod.rs b/raphtory-graphql/src/model/algorithms/dynamics/mod.rs new file mode 100644 index 0000000000..977f3f0ec9 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/dynamics/mod.rs @@ -0,0 +1 @@ +pub(crate) mod temporal; diff --git a/raphtory-graphql/src/model/algorithms/dynamics/temporal/mod.rs b/raphtory-graphql/src/model/algorithms/dynamics/temporal/mod.rs new file mode 100644 index 0000000000..2e03fdd151 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/dynamics/temporal/mod.rs @@ -0,0 +1 @@ +pub(crate) mod temporal_seir; diff --git a/raphtory-graphql/src/model/algorithms/embeddings/mod.rs b/raphtory-graphql/src/model/algorithms/embeddings/mod.rs new file mode 100644 index 0000000000..493821993a --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/embeddings/mod.rs @@ -0,0 +1 @@ +pub(crate) mod fast_rp; diff --git a/raphtory-graphql/src/model/algorithms/layout/mod.rs b/raphtory-graphql/src/model/algorithms/layout/mod.rs new file mode 100644 index 0000000000..6a25bf5d8f --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/layout/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod cohesive_fruchterman_reingold; +pub(crate) mod fruchterman_reingold; diff --git a/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/mod.rs b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/mod.rs new file mode 100644 index 0000000000..5d28dd238f --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod global_clustering_coefficient; +pub(crate) mod local_clustering_coefficient; +pub(crate) mod local_clustering_coefficient_batch; diff --git a/raphtory-graphql/src/model/algorithms/metrics/mod.rs b/raphtory-graphql/src/model/algorithms/metrics/mod.rs new file mode 100644 index 0000000000..59e61127c5 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/mod.rs @@ -0,0 +1,12 @@ +pub(crate) mod all_local_reciprocity; +pub(crate) mod average_degree; +pub(crate) mod balance; +pub(crate) mod clustering_coefficient; +pub(crate) mod directed_graph_density; +pub(crate) mod global_reciprocity; +pub(crate) mod max_degree; +pub(crate) mod max_in_degree; +pub(crate) mod max_out_degree; +pub(crate) mod min_degree; +pub(crate) mod min_in_degree; +pub(crate) mod min_out_degree; diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index b6cbaa44ac..495728cd6c 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -3,72 +3,88 @@ use crate::{ model::{ algorithms::{ - all_local_reciprocity::{GqlAllLocalReciprocity, GqlAllLocalReciprocityArgs}, alternating_mask::{GqlAlternatingMask, GqlAlternatingMaskArgs}, - average_degree::{GqlAverageDegree, GqlAverageDegreeArgs}, - balance::{GqlBalance, GqlBalanceArgs}, - betweenness_centrality::{GqlBetweennessCentrality, GqlBetweennessCentralityArgs}, - cohesive_fruchterman_reingold::{ - GqlCohesiveFruchtermanReingold, GqlCohesiveFruchtermanReingoldArgs, + bipartite::max_weight_matching::{GqlMaxWeightMatching, GqlMaxWeightMatchingArgs}, + centrality::{ + betweenness_centrality::{GqlBetweennessCentrality, GqlBetweennessCentralityArgs}, + degree_centrality::{GqlDegreeCentrality, GqlDegreeCentralityArgs}, + hits::{GqlHits, GqlHitsArgs}, + pagerank::{GqlPagerank, GqlPagerankArgs}, }, - degree_centrality::{GqlDegreeCentrality, GqlDegreeCentralityArgs}, - dijkstra::{GqlDijkstra, GqlDijkstraArgs}, - directed_graph_density::{GqlDirectedGraphDensity, GqlDirectedGraphDensityArgs}, - fast_rp::{GqlFastRp, GqlFastRpArgs}, - fruchterman_reingold::{GqlFruchtermanReingold, GqlFruchtermanReingoldArgs}, - global_clustering_coefficient::{ - GqlGlobalClusteringCoefficient, GqlGlobalClusteringCoefficientArgs, + community_detection::{ + label_propagation::{GqlLabelPropagation, GqlLabelPropagationArgs}, + louvain::{GqlLouvain, GqlLouvainArgs}, }, - global_reciprocity::{GqlGlobalReciprocity, GqlGlobalReciprocityArgs}, - global_temporal_three_node_motif::{ - GqlGlobalTemporalThreeNodeMotif, GqlGlobalTemporalThreeNodeMotifArgs, + components::{ + in_component::{GqlInComponent, GqlInComponentArgs}, + in_components::{GqlInComponents, GqlInComponentsArgs}, + out_component::{GqlOutComponent, GqlOutComponentArgs}, + out_components::{GqlOutComponents, GqlOutComponentsArgs}, + strongly_connected_components::{ + GqlStronglyConnectedComponents, GqlStronglyConnectedComponentsArgs, + }, + weakly_connected_components::{ + GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, + }, }, - global_temporal_three_node_motif_multi::{ - GqlGlobalTemporalThreeNodeMotifMulti, GqlGlobalTemporalThreeNodeMotifMultiArgs, - GqlMotifCounts, + dynamics::temporal::temporal_seir::{GqlSeeds, GqlTemporalSeir, GqlTemporalSeirArgs}, + embeddings::fast_rp::{GqlFastRp, GqlFastRpArgs}, + layout::{ + cohesive_fruchterman_reingold::{ + GqlCohesiveFruchtermanReingold, GqlCohesiveFruchtermanReingoldArgs, + }, + fruchterman_reingold::{GqlFruchtermanReingold, GqlFruchtermanReingoldArgs}, }, - hits::{GqlHits, GqlHitsArgs}, - in_component::{GqlInComponent, GqlInComponentArgs}, - in_components::{GqlInComponents, GqlInComponentsArgs}, - label_propagation::{GqlLabelPropagation, GqlLabelPropagationArgs}, - local_clustering_coefficient::{ - GqlLocalClusteringCoefficient, GqlLocalClusteringCoefficientArgs, + metrics::{ + all_local_reciprocity::{GqlAllLocalReciprocity, GqlAllLocalReciprocityArgs}, + average_degree::{GqlAverageDegree, GqlAverageDegreeArgs}, + balance::{GqlBalance, GqlBalanceArgs}, + clustering_coefficient::{ + global_clustering_coefficient::{ + GqlGlobalClusteringCoefficient, GqlGlobalClusteringCoefficientArgs, + }, + local_clustering_coefficient::{ + GqlLocalClusteringCoefficient, GqlLocalClusteringCoefficientArgs, + }, + local_clustering_coefficient_batch::{ + GqlLocalClusteringCoefficientBatch, GqlLocalClusteringCoefficientBatchArgs, + }, + }, + directed_graph_density::{GqlDirectedGraphDensity, GqlDirectedGraphDensityArgs}, + global_reciprocity::{GqlGlobalReciprocity, GqlGlobalReciprocityArgs}, + max_degree::{GqlMaxDegree, GqlMaxDegreeArgs}, + max_in_degree::{GqlMaxInDegree, GqlMaxInDegreeArgs}, + max_out_degree::{GqlMaxOutDegree, GqlMaxOutDegreeArgs}, + min_degree::{GqlMinDegree, GqlMinDegreeArgs}, + min_in_degree::{GqlMinInDegree, GqlMinInDegreeArgs}, + min_out_degree::{GqlMinOutDegree, GqlMinOutDegreeArgs}, }, - local_clustering_coefficient_batch::{ - GqlLocalClusteringCoefficientBatch, GqlLocalClusteringCoefficientBatchArgs, + motifs::{ + global_temporal_three_node_motif::{ + GqlGlobalTemporalThreeNodeMotif, GqlGlobalTemporalThreeNodeMotifArgs, + }, + global_temporal_three_node_motif_multi::{ + GqlGlobalTemporalThreeNodeMotifMulti, GqlGlobalTemporalThreeNodeMotifMultiArgs, + GqlMotifCounts, + }, + local_temporal_three_node_motifs::{ + GqlLocalTemporalThreeNodeMotifs, GqlLocalTemporalThreeNodeMotifsArgs, + }, + local_triangle_count::{GqlLocalTriangleCount, GqlLocalTriangleCountArgs}, + temporal_rich_club_coefficient::{ + GqlTemporalRichClubCoefficient, GqlTemporalRichClubCoefficientArgs, + }, + triangle_count::{GqlTriangleCount, GqlTriangleCountArgs}, + triplet_count::{GqlTripletCount, GqlTripletCountArgs}, }, - local_temporal_three_node_motifs::{ - GqlLocalTemporalThreeNodeMotifs, GqlLocalTemporalThreeNodeMotifsArgs, - }, - local_triangle_count::{GqlLocalTriangleCount, GqlLocalTriangleCountArgs}, - louvain::{GqlLouvain, GqlLouvainArgs}, - max_degree::{GqlMaxDegree, GqlMaxDegreeArgs}, - max_in_degree::{GqlMaxInDegree, GqlMaxInDegreeArgs}, - max_out_degree::{GqlMaxOutDegree, GqlMaxOutDegreeArgs}, - max_weight_matching::{GqlMaxWeightMatching, GqlMaxWeightMatchingArgs}, - min_degree::{GqlMinDegree, GqlMinDegreeArgs}, - min_in_degree::{GqlMinInDegree, GqlMinInDegreeArgs}, - min_out_degree::{GqlMinOutDegree, GqlMinOutDegreeArgs}, - out_component::{GqlOutComponent, GqlOutComponentArgs}, - out_components::{GqlOutComponents, GqlOutComponentsArgs}, - pagerank::{GqlPagerank, GqlPagerankArgs}, - single_source_shortest_path::{ - GqlSingleSourceShortestPath, GqlSingleSourceShortestPathArgs, - }, - strongly_connected_components::{ - GqlStronglyConnectedComponents, GqlStronglyConnectedComponentsArgs, - }, - temporal_rich_club_coefficient::{ - GqlTemporalRichClubCoefficient, GqlTemporalRichClubCoefficientArgs, - }, - temporal_seir::{GqlSeeds, GqlTemporalSeir, GqlTemporalSeirArgs}, - temporally_reachable_nodes::{ - GqlTemporallyReachableNodes, GqlTemporallyReachableNodesArgs, - }, - triangle_count::{GqlTriangleCount, GqlTriangleCountArgs}, - triplet_count::{GqlTripletCount, GqlTripletCountArgs}, - weakly_connected_components::{ - GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, + pathing::{ + dijkstra::{GqlDijkstra, GqlDijkstraArgs}, + single_source_shortest_path::{ + GqlSingleSourceShortestPath, GqlSingleSourceShortestPathArgs, + }, + temporally_reachable_nodes::{ + GqlTemporallyReachableNodes, GqlTemporallyReachableNodesArgs, + }, }, }, graph::{ @@ -90,48 +106,17 @@ use raphtory::{ }; use raphtory_api::core::Direction; -pub(crate) mod all_local_reciprocity; pub(crate) mod alternating_mask; -pub(crate) mod average_degree; -pub(crate) mod balance; -pub(crate) mod betweenness_centrality; -pub(crate) mod cohesive_fruchterman_reingold; -pub(crate) mod degree_centrality; -pub(crate) mod dijkstra; -pub(crate) mod directed_graph_density; -pub(crate) mod fast_rp; -pub(crate) mod fruchterman_reingold; -pub(crate) mod global_clustering_coefficient; -pub(crate) mod global_reciprocity; -pub(crate) mod global_temporal_three_node_motif; -pub(crate) mod global_temporal_three_node_motif_multi; -pub(crate) mod hits; -pub(crate) mod in_component; -pub(crate) mod in_components; -pub(crate) mod label_propagation; -pub(crate) mod local_clustering_coefficient; -pub(crate) mod local_clustering_coefficient_batch; -pub(crate) mod local_temporal_three_node_motifs; -pub(crate) mod local_triangle_count; -pub(crate) mod louvain; -pub(crate) mod max_degree; -pub(crate) mod max_in_degree; -pub(crate) mod max_out_degree; -pub(crate) mod max_weight_matching; -pub(crate) mod min_degree; -pub(crate) mod min_in_degree; -pub(crate) mod min_out_degree; -pub(crate) mod out_component; -pub(crate) mod out_components; -pub(crate) mod pagerank; -pub(crate) mod single_source_shortest_path; -pub(crate) mod strongly_connected_components; -pub(crate) mod temporal_rich_club_coefficient; -pub(crate) mod temporal_seir; -pub(crate) mod temporally_reachable_nodes; -pub(crate) mod triangle_count; -pub(crate) mod triplet_count; -pub(crate) mod weakly_connected_components; +pub(crate) mod bipartite; +pub(crate) mod centrality; +pub(crate) mod community_detection; +pub(crate) mod components; +pub(crate) mod dynamics; +pub(crate) mod embeddings; +pub(crate) mod layout; +pub(crate) mod metrics; +pub(crate) mod motifs; +pub(crate) mod pathing; /// A graph algorithm executable through the GraphQL API. pub(crate) trait GqlExecutableAlgorithm: 'static { diff --git a/raphtory-graphql/src/model/algorithms/motifs/mod.rs b/raphtory-graphql/src/model/algorithms/motifs/mod.rs new file mode 100644 index 0000000000..754655cce6 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/motifs/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod global_temporal_three_node_motif; +pub(crate) mod global_temporal_three_node_motif_multi; +pub(crate) mod local_temporal_three_node_motifs; +pub(crate) mod local_triangle_count; +pub(crate) mod temporal_rich_club_coefficient; +pub(crate) mod triangle_count; +pub(crate) mod triplet_count; diff --git a/raphtory-graphql/src/model/algorithms/pathing/mod.rs b/raphtory-graphql/src/model/algorithms/pathing/mod.rs new file mode 100644 index 0000000000..b809a25a90 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/pathing/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod dijkstra; +pub(crate) mod single_source_shortest_path; +pub(crate) mod temporally_reachable_nodes; From bbd791c9cb8e84fcd7e125a9f9ffbdf48bbd61a5 Mon Sep 17 00:00:00 2001 From: arienandalibi Date: Wed, 5 Aug 2026 04:42:30 -0400 Subject: [PATCH 38/39] Splitting GraphQL's src/model/algorithms.mod.rs into 3 files, keeping mod.rs short and structural. --- .../src/model/algorithms/executable.rs | 41 + .../src/model/algorithms/inputs.rs | 59 ++ raphtory-graphql/src/model/algorithms/mod.rs | 727 +----------------- .../src/model/algorithms/resolvers.rs | 637 +++++++++++++++ 4 files changed, 742 insertions(+), 722 deletions(-) create mode 100644 raphtory-graphql/src/model/algorithms/executable.rs create mode 100644 raphtory-graphql/src/model/algorithms/inputs.rs create mode 100644 raphtory-graphql/src/model/algorithms/resolvers.rs diff --git a/raphtory-graphql/src/model/algorithms/executable.rs b/raphtory-graphql/src/model/algorithms/executable.rs new file mode 100644 index 0000000000..4c3055e529 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/executable.rs @@ -0,0 +1,41 @@ +//! The machinery that turns an algorithm into a GraphQL resolver. + +use crate::rayon::blocking_compute; +use dynamic_graphql::ResolvedObject; +use raphtory::{db::api::view::DynamicGraph, errors::GraphError}; + +/// A graph algorithm executable through the GraphQL API. +pub(crate) trait GqlExecutableAlgorithm: 'static { + /// The algorithm's arguments, assembled from the GraphQL field arguments + type Args: Send + 'static; + + /// The GraphQL-facing result, typically a GqlNodeState but can be different (e.g. scalars) + type Output: Send + 'static; + + /// Runs the algorithm on the given graph view + fn execute(graph: &DynamicGraph, args: Self::Args) -> Result; +} + +/// The algorithms that can be run on a graph view. +#[derive(ResolvedObject, Clone)] +#[graphql(name = "Algorithms")] +pub(crate) struct GqlAlgorithms { + pub(crate) graph: DynamicGraph, +} + +impl From for GqlAlgorithms { + fn from(graph: DynamicGraph) -> Self { + Self { graph } + } +} + +impl GqlAlgorithms { + /// Runs algorithm `A` on the blocking thread pool. + pub(crate) async fn run( + &self, + args: A::Args, + ) -> Result { + let graph = self.graph.clone(); + blocking_compute(move || A::execute(&graph, args)).await + } +} diff --git a/raphtory-graphql/src/model/algorithms/inputs.rs b/raphtory-graphql/src/model/algorithms/inputs.rs new file mode 100644 index 0000000000..661145812c --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/inputs.rs @@ -0,0 +1,59 @@ +//! Shared GraphQL argument types for algorithms, and their conversion into the +//! core types the algorithms take. + +use crate::model::graph::filtering::GqlViewFilter; +use dynamic_graphql::Enum; +use raphtory::{ + db::{ + api::view::{DynamicGraph, Filter, IntoDynamic}, + graph::views::filter::model::{ + edge_filter::CompositeEdgeFilter, node_filter::CompositeNodeFilter, DynView, + }, + }, + errors::GraphError, +}; +use raphtory_api::core::Direction; + +/// Edge direction to follow during traversal. +#[derive(Enum, Copy, Clone)] +#[graphql(name = "Direction")] +pub(crate) enum GqlDirection { + Out, + In, + Both, +} + +impl From for Direction { + fn from(direction: GqlDirection) -> Self { + match direction { + GqlDirection::Out => Direction::OUT, + GqlDirection::In => Direction::IN, + GqlDirection::Both => Direction::BOTH, + } + } +} + +/// Applies an optional composite filter, returning the filtered view (or the +/// graph unchanged if no filter is given). +pub(crate) fn filtered_view( + graph: &DynamicGraph, + filter: Option, +) -> Result { + let Some(filter) = filter else { + return Ok(graph.clone()); + }; + let mut graph = graph.clone(); + if let Some(nodes) = filter.nodes { + let nodes: CompositeNodeFilter = nodes.try_into()?; + graph = graph.filter(nodes)?.into_dynamic(); + } + if let Some(edges) = filter.edges { + let edges: CompositeEdgeFilter = edges.try_into()?; + graph = graph.filter(edges)?.into_dynamic(); + } + if let Some(view) = filter.graph { + let view: DynView = view.try_into()?; + graph = graph.filter(view)?.into_dynamic(); + } + Ok(graph) +} diff --git a/raphtory-graphql/src/model/algorithms/mod.rs b/raphtory-graphql/src/model/algorithms/mod.rs index 495728cd6c..5394d13372 100644 --- a/raphtory-graphql/src/model/algorithms/mod.rs +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -1,111 +1,5 @@ //! Statically defined graph algorithms exposed through `Graph.algorithm`. -use crate::{ - model::{ - algorithms::{ - alternating_mask::{GqlAlternatingMask, GqlAlternatingMaskArgs}, - bipartite::max_weight_matching::{GqlMaxWeightMatching, GqlMaxWeightMatchingArgs}, - centrality::{ - betweenness_centrality::{GqlBetweennessCentrality, GqlBetweennessCentralityArgs}, - degree_centrality::{GqlDegreeCentrality, GqlDegreeCentralityArgs}, - hits::{GqlHits, GqlHitsArgs}, - pagerank::{GqlPagerank, GqlPagerankArgs}, - }, - community_detection::{ - label_propagation::{GqlLabelPropagation, GqlLabelPropagationArgs}, - louvain::{GqlLouvain, GqlLouvainArgs}, - }, - components::{ - in_component::{GqlInComponent, GqlInComponentArgs}, - in_components::{GqlInComponents, GqlInComponentsArgs}, - out_component::{GqlOutComponent, GqlOutComponentArgs}, - out_components::{GqlOutComponents, GqlOutComponentsArgs}, - strongly_connected_components::{ - GqlStronglyConnectedComponents, GqlStronglyConnectedComponentsArgs, - }, - weakly_connected_components::{ - GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, - }, - }, - dynamics::temporal::temporal_seir::{GqlSeeds, GqlTemporalSeir, GqlTemporalSeirArgs}, - embeddings::fast_rp::{GqlFastRp, GqlFastRpArgs}, - layout::{ - cohesive_fruchterman_reingold::{ - GqlCohesiveFruchtermanReingold, GqlCohesiveFruchtermanReingoldArgs, - }, - fruchterman_reingold::{GqlFruchtermanReingold, GqlFruchtermanReingoldArgs}, - }, - metrics::{ - all_local_reciprocity::{GqlAllLocalReciprocity, GqlAllLocalReciprocityArgs}, - average_degree::{GqlAverageDegree, GqlAverageDegreeArgs}, - balance::{GqlBalance, GqlBalanceArgs}, - clustering_coefficient::{ - global_clustering_coefficient::{ - GqlGlobalClusteringCoefficient, GqlGlobalClusteringCoefficientArgs, - }, - local_clustering_coefficient::{ - GqlLocalClusteringCoefficient, GqlLocalClusteringCoefficientArgs, - }, - local_clustering_coefficient_batch::{ - GqlLocalClusteringCoefficientBatch, GqlLocalClusteringCoefficientBatchArgs, - }, - }, - directed_graph_density::{GqlDirectedGraphDensity, GqlDirectedGraphDensityArgs}, - global_reciprocity::{GqlGlobalReciprocity, GqlGlobalReciprocityArgs}, - max_degree::{GqlMaxDegree, GqlMaxDegreeArgs}, - max_in_degree::{GqlMaxInDegree, GqlMaxInDegreeArgs}, - max_out_degree::{GqlMaxOutDegree, GqlMaxOutDegreeArgs}, - min_degree::{GqlMinDegree, GqlMinDegreeArgs}, - min_in_degree::{GqlMinInDegree, GqlMinInDegreeArgs}, - min_out_degree::{GqlMinOutDegree, GqlMinOutDegreeArgs}, - }, - motifs::{ - global_temporal_three_node_motif::{ - GqlGlobalTemporalThreeNodeMotif, GqlGlobalTemporalThreeNodeMotifArgs, - }, - global_temporal_three_node_motif_multi::{ - GqlGlobalTemporalThreeNodeMotifMulti, GqlGlobalTemporalThreeNodeMotifMultiArgs, - GqlMotifCounts, - }, - local_temporal_three_node_motifs::{ - GqlLocalTemporalThreeNodeMotifs, GqlLocalTemporalThreeNodeMotifsArgs, - }, - local_triangle_count::{GqlLocalTriangleCount, GqlLocalTriangleCountArgs}, - temporal_rich_club_coefficient::{ - GqlTemporalRichClubCoefficient, GqlTemporalRichClubCoefficientArgs, - }, - triangle_count::{GqlTriangleCount, GqlTriangleCountArgs}, - triplet_count::{GqlTripletCount, GqlTripletCountArgs}, - }, - pathing::{ - dijkstra::{GqlDijkstra, GqlDijkstraArgs}, - single_source_shortest_path::{ - GqlSingleSourceShortestPath, GqlSingleSourceShortestPathArgs, - }, - temporally_reachable_nodes::{ - GqlTemporallyReachableNodes, GqlTemporallyReachableNodesArgs, - }, - }, - }, - graph::{ - filtering::GqlViewFilter, matching::GqlMatching, node_id::GqlNodeId, - node_state::GqlNodeState, timeindex::GqlTimeInput, WindowDuration, - }, - }, - rayon::blocking_compute, -}; -use dynamic_graphql::{Enum, ResolvedObject, ResolvedObjectFields}; -use raphtory::{ - db::{ - api::view::{DynamicGraph, Filter, IntoDynamic}, - graph::views::filter::model::{ - edge_filter::CompositeEdgeFilter, node_filter::CompositeNodeFilter, DynView, - }, - }, - errors::GraphError, -}; -use raphtory_api::core::Direction; - pub(crate) mod alternating_mask; pub(crate) mod bipartite; pub(crate) mod centrality; @@ -113,624 +7,13 @@ pub(crate) mod community_detection; pub(crate) mod components; pub(crate) mod dynamics; pub(crate) mod embeddings; +pub(crate) mod executable; +pub(crate) mod inputs; pub(crate) mod layout; pub(crate) mod metrics; pub(crate) mod motifs; pub(crate) mod pathing; +pub(crate) mod resolvers; -/// A graph algorithm executable through the GraphQL API. -pub(crate) trait GqlExecutableAlgorithm: 'static { - /// The algorithm's arguments, assembled from the GraphQL field arguments - type Args: Send + 'static; - - /// The GraphQL-facing result, typically a GqlNodeState but can be different (e.g. scalars) - type Output: Send + 'static; - - /// Runs the algorithm on the given graph view - fn execute(graph: &DynamicGraph, args: Self::Args) -> Result; -} - -/// The algorithms that can be run on a graph view. -#[derive(ResolvedObject, Clone)] -#[graphql(name = "Algorithms")] -pub(crate) struct GqlAlgorithms { - pub(crate) graph: DynamicGraph, -} - -impl From for GqlAlgorithms { - fn from(graph: DynamicGraph) -> Self { - Self { graph } - } -} - -/// Edge direction to follow during traversal. -#[derive(Enum, Copy, Clone)] -#[graphql(name = "Direction")] -pub(crate) enum GqlDirection { - Out, - In, - Both, -} - -impl From for Direction { - fn from(direction: GqlDirection) -> Self { - match direction { - GqlDirection::Out => Direction::OUT, - GqlDirection::In => Direction::IN, - GqlDirection::Both => Direction::BOTH, - } - } -} - -/// Applies an optional composite filter, returning the filtered view (or the -/// graph unchanged if no filter is given). -pub(crate) fn filtered_view( - graph: &DynamicGraph, - filter: Option, -) -> Result { - let Some(filter) = filter else { - return Ok(graph.clone()); - }; - let mut graph = graph.clone(); - if let Some(nodes) = filter.nodes { - let nodes: CompositeNodeFilter = nodes.try_into()?; - graph = graph.filter(nodes)?.into_dynamic(); - } - if let Some(edges) = filter.edges { - let edges: CompositeEdgeFilter = edges.try_into()?; - graph = graph.filter(edges)?.into_dynamic(); - } - if let Some(view) = filter.graph { - let view: DynView = view.try_into()?; - graph = graph.filter(view)?.into_dynamic(); - } - Ok(graph) -} - -impl GqlAlgorithms { - /// Runs algorithm `A` on the blocking thread pool. - async fn run(&self, args: A::Args) -> Result { - let graph = self.graph.clone(); - blocking_compute(move || A::execute(&graph, args)).await - } -} - -#[ResolvedObjectFields] -impl GqlAlgorithms { - /// Returns the PageRank centrality of every node in the graph. - async fn pagerank( - &self, - #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - #[graphql(desc = "Convergence tolerance. Defaults to 0.000001.")] tol: Option, - #[graphql(desc = "Probability that the spread continues. Defaults to 0.85.")] - damping_factor: Option, - #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] - weight: Option, - ) -> Result { - self.run::(GqlPagerankArgs { - iter_count, - threads, - tol, - damping_factor, - weight, - }) - .await - } - - /// Returns the degree centrality of every node. - async fn degree_centrality(&self) -> Result { - self.run::(GqlDegreeCentralityArgs) - .await - } - - /// Returns the betweenness centrality of every node. - async fn betweenness_centrality( - &self, - #[graphql(desc = "Number of nodes to sample. Defaults to all nodes.")] k: Option, - #[graphql(desc = "Whether to normalize the values. Defaults to true.")] normalized: Option< - bool, - >, - ) -> Result { - self.run::(GqlBetweennessCentralityArgs { - k, - normalized: normalized.unwrap_or(true), - }) - .await - } - - /// Returns the HITS hub and authority scores of every node. - async fn hits( - &self, - #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result { - self.run::(GqlHitsArgs { - iter_count: iter_count.unwrap_or(20), - threads, - }) - .await - } - - /// Returns the shortest (unweighted) path from `source` to every reachable node. - async fn single_source_shortest_path( - &self, - #[graphql(desc = "Source node id.")] source: String, - #[graphql(desc = "Optional maximum path length; stops the search once reached.")] - cutoff: Option, - ) -> Result { - self.run::(GqlSingleSourceShortestPathArgs { source, cutoff }) - .await - } - - /// Returns the in component (all nodes that can reach it following out-edges) of every node. - async fn in_components( - &self, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result { - self.run::(GqlInComponentsArgs { threads }) - .await - } - - /// Returns the out component (all reachable nodes following out-edges) of every node. - async fn out_components( - &self, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result { - self.run::(GqlOutComponentsArgs { threads }) - .await - } - - /// Returns the in component of a single node (nodes that can reach it, with their distance). - async fn in_component( - &self, - #[graphql(desc = "Node id.")] node: GqlNodeId, - #[graphql( - desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." - )] - filter: Option, - ) -> Result { - self.run::(GqlInComponentArgs { node, filter }) - .await - } - - /// Returns the out component of a single node (nodes it can reach, with their distance). - async fn out_component( - &self, - #[graphql(desc = "Node id.")] node: GqlNodeId, - #[graphql( - desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." - )] - filter: Option, - ) -> Result { - self.run::(GqlOutComponentArgs { node, filter }) - .await - } - - /// Returns the local triangle count of a single node (0 if it has degree < 2), or null if - /// the node does not exist in the view. - async fn local_triangle_count( - &self, - #[graphql(desc = "Node id.")] node: GqlNodeId, - #[graphql( - desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." - )] - filter: Option, - ) -> Result, GraphError> { - self.run::(GqlLocalTriangleCountArgs { node, filter }) - .await - } - - /// Returns the local clustering coefficient of a single node (0 if it has degree < 2), or - /// null if the node does not exist in the view. - async fn local_clustering_coefficient( - &self, - #[graphql(desc = "Node id.")] node: GqlNodeId, - #[graphql( - desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." - )] - filter: Option, - ) -> Result, GraphError> { - self.run::(GqlLocalClusteringCoefficientArgs { - node, - filter, - }) - .await - } - - /// Returns the weakly connected component id of every node. - async fn weakly_connected_components(&self) -> Result { - self.run::(GqlWeaklyConnectedComponentsArgs) - .await - } - - /// Returns the strongly connected component id of every node. - async fn strongly_connected_components(&self) -> Result { - self.run::(GqlStronglyConnectedComponentsArgs) - .await - } - - /// Returns the community of every node (Louvain). - async fn louvain( - &self, - #[graphql(desc = "Resolution parameter for modularity. Defaults to 1.0.")] - resolution: Option, - #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] - weight_prop: Option, - #[graphql(desc = "Convergence tolerance. Defaults to 1e-8.")] tol: Option, - #[graphql(desc = "Seed for the node-shuffling rng. If unset, seeded from the OS.")] - rng_seed: Option, - ) -> Result { - self.run::(GqlLouvainArgs { - resolution: resolution.unwrap_or(1.0), - weight_prop, - tol, - rng_seed, - }) - .await - } - - /// Returns the community of every node (label propagation). - async fn label_propagation( - &self, - #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result { - self.run::(GqlLabelPropagationArgs { - iter_count: iter_count.unwrap_or(20), - threads, - }) - .await - } - - /// Returns the weighted shortest path from `source` to each of `targets` (Dijkstra). - async fn dijkstra( - &self, - #[graphql(desc = "Source node id.")] source: String, - #[graphql(desc = "Target node ids.")] targets: Vec, - #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] - weight: Option, - #[graphql(desc = "Edge direction to follow. Defaults to BOTH.")] direction: Option< - GqlDirection, - >, - ) -> Result { - self.run::(GqlDijkstraArgs { - source, - targets, - weight, - direction: direction.unwrap_or(GqlDirection::Both), - }) - .await - } - - /// Returns the local reciprocity of every node. - async fn all_local_reciprocity(&self) -> Result { - self.run::(GqlAllLocalReciprocityArgs) - .await - } - - /// Returns the net sum of edge weights (balance) of every node. - async fn balance( - &self, - #[graphql(desc = "Edge property to use as weight. Defaults to `weight`.")] name: Option< - String, - >, - #[graphql(desc = "Edge direction to consider. Defaults to BOTH.")] direction: Option< - GqlDirection, - >, - ) -> Result { - self.run::(GqlBalanceArgs { - name: name.unwrap_or_else(|| "weight".to_string()), - direction: direction.unwrap_or(GqlDirection::Both), - }) - .await - } - - /// Returns the local clustering coefficient of each of the given nodes. - async fn local_clustering_coefficient_batch( - &self, - #[graphql(desc = "Node ids to compute the coefficient for.")] nodes: Vec, - ) -> Result { - self.run::(GqlLocalClusteringCoefficientBatchArgs { - nodes, - }) - .await - } - - /// Returns the global clustering coefficient of the graph. - async fn global_clustering_coefficient(&self) -> Result { - self.run::(GqlGlobalClusteringCoefficientArgs) - .await - } - - /// Returns the directed graph density (fraction of possible directed edges present). - async fn directed_graph_density(&self) -> Result { - self.run::(GqlDirectedGraphDensityArgs) - .await - } - - /// Returns the global reciprocity of the graph. - async fn global_reciprocity(&self) -> Result { - self.run::(GqlGlobalReciprocityArgs) - .await - } - - /// Returns the average (undirected) degree of the graph's nodes. - async fn average_degree(&self) -> Result { - self.run::(GqlAverageDegreeArgs).await - } - - /// Returns the maximum (undirected) degree of any node in the graph. - async fn max_degree(&self) -> Result { - self.run::(GqlMaxDegreeArgs).await - } - - /// Returns the minimum (undirected) degree of any node in the graph. - async fn min_degree(&self) -> Result { - self.run::(GqlMinDegreeArgs).await - } - - /// Returns the maximum out-degree of any node in the graph. - async fn max_out_degree(&self) -> Result { - self.run::(GqlMaxOutDegreeArgs).await - } - - /// Returns the maximum in-degree of any node in the graph. - async fn max_in_degree(&self) -> Result { - self.run::(GqlMaxInDegreeArgs).await - } - - /// Returns the minimum out-degree of any node in the graph. - async fn min_out_degree(&self) -> Result { - self.run::(GqlMinOutDegreeArgs).await - } - - /// Returns the minimum in-degree of any node in the graph. - async fn min_in_degree(&self) -> Result { - self.run::(GqlMinInDegreeArgs).await - } - - /// Returns the number of connected triplets (paths of length 2) in the graph. - async fn triplet_count( - &self, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result { - self.run::(GqlTripletCountArgs { threads }) - .await - } - - /// Returns the number of triangles in the graph. - async fn triangle_count( - &self, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result { - self.run::(GqlTriangleCountArgs { threads }) - .await - } - - /// Returns the FastRP embedding of every node. - async fn fast_rp( - &self, - #[graphql(desc = "Dimension of the embedding.")] embedding_dim: usize, - #[graphql(desc = "Normalization strength applied to neighbour contributions.")] - normalization_strength: f64, - #[graphql(desc = "Weight of each iteration's contribution to the embedding.")] - iter_weights: Vec, - #[graphql(desc = "Seed for the rng. If unset, seeded from the OS.")] seed: Option, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result { - self.run::(GqlFastRpArgs { - embedding_dim, - normalization_strength, - iter_weights, - seed, - threads, - }) - .await - } - - /// Returns the nodes temporally reachable from `seedNodes` starting at `startTime`. - async fn temporally_reachable_nodes( - &self, - #[graphql(desc = "Maximum number of hops to traverse.")] max_hops: usize, - #[graphql(desc = "Time at which the traversal starts.")] start_time: i64, - #[graphql(desc = "Node ids to start from.")] seed_nodes: Vec, - #[graphql(desc = "Node ids that halt the traversal when reached.")] stop_nodes: Option< - Vec, - >, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result { - self.run::(GqlTemporallyReachableNodesArgs { - max_hops, - start_time, - seed_nodes, - stop_nodes, - threads, - }) - .await - } - - /// Returns the 2D layout position of every node (Fruchterman-Reingold). - async fn fruchterman_reingold( - &self, - #[graphql(desc = "Number of iterations to run. Defaults to 100.")] iter_count: Option, - #[graphql(desc = "Scale of the layout. Defaults to 1.0.")] scale: Option, - #[graphql(desc = "Initial node size. Defaults to 1.0.")] node_start_size: Option, - #[graphql(desc = "Cooloff factor. Defaults to 0.95.")] cooloff_factor: Option, - #[graphql(desc = "Time step. Defaults to 0.1.")] dt: Option, - ) -> Result { - self.run::(GqlFruchtermanReingoldArgs { - iter_count: iter_count.unwrap_or(100), - scale: scale.unwrap_or(1.0), - node_start_size: node_start_size.unwrap_or(1.0), - cooloff_factor: cooloff_factor.unwrap_or(0.95), - dt: dt.unwrap_or(0.1), - }) - .await - } - - /// Returns the 2D layout position of every node (cohesive Fruchterman-Reingold). - async fn cohesive_fruchterman_reingold( - &self, - #[graphql(desc = "Number of iterations to run. Defaults to 100.")] iter_count: Option, - #[graphql(desc = "Scale of the layout. Defaults to 1.0.")] scale: Option, - #[graphql(desc = "Initial node size. Defaults to 1.0.")] node_start_size: Option, - #[graphql(desc = "Cooloff factor. Defaults to 0.95.")] cooloff_factor: Option, - #[graphql(desc = "Time step. Defaults to 0.1.")] dt: Option, - ) -> Result { - self.run::(GqlCohesiveFruchtermanReingoldArgs { - iter_count: iter_count.unwrap_or(100), - scale: scale.unwrap_or(1.0), - node_start_size: node_start_size.unwrap_or(1.0), - cooloff_factor: cooloff_factor.unwrap_or(0.95), - dt: dt.unwrap_or(0.1), - }) - .await - } - - /// Returns the local temporal three-node motif counts of every node. - async fn local_temporal_three_node_motifs( - &self, - #[graphql(desc = "Maximum time difference between the first and last edge of a motif.")] - delta: i64, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result { - self.run::(GqlLocalTemporalThreeNodeMotifsArgs { - delta, - threads, - }) - .await - } - - /// Returns the graph-wide temporal three-node motif counts: 40 counts in a - /// fixed order (8 two-node, 24 star, then 8 triangle motifs). - async fn global_temporal_three_node_motif( - &self, - #[graphql(desc = "Maximum time difference between the first and last edge of a motif.")] - delta: i64, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result, GraphError> { - self.run::(GqlGlobalTemporalThreeNodeMotifArgs { - delta, - threads, - }) - .await - } - - /// Returns the graph-wide temporal three-node motif counts for each of - /// `deltas`, one row of 40 counts per delta, in the order given. - async fn global_temporal_three_node_motif_multi( - &self, - #[graphql(desc = "Maximum time differences to compute the motif counts for.")] deltas: Vec< - i64, - >, - #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< - usize, - >, - ) -> Result, GraphError> { - self.run::(GqlGlobalTemporalThreeNodeMotifMultiArgs { - deltas, - threads, - }) - .await - } - - /// Returns the temporal rich club coefficient: the maximal density among the - /// nodes of degree at least `k` that persists over `windowSize` consecutive - /// snapshots. The snapshots are the rolling windows described by - /// `rollingWindow` / `rollingStep`. - async fn temporal_rich_club_coefficient( - &self, - #[graphql(desc = "Minimum degree a node must have to be in the rich club.")] k: usize, - #[graphql(desc = "Number of consecutive snapshots the edges must persist over.")] - window_size: usize, - #[graphql(desc = "Width of each snapshot.")] rolling_window: WindowDuration, - #[graphql( - desc = "Optional gap between the start of one snapshot and the next. Defaults to `rollingWindow`, i.e. non-overlapping snapshots." - )] - rolling_step: Option, - ) -> Result { - self.run::(GqlTemporalRichClubCoefficientArgs { - k, - window_size, - rolling_window, - rolling_step, - }) - .await - } - - /// Returns an alternating boolean mask over the nodes. - async fn alternating_mask(&self) -> Result { - self.run::(GqlAlternatingMaskArgs).await - } - - /// Simulates an SEIR epidemic, returning the infection, activation and - /// recovery times of every node that was infected. - async fn temporal_seir( - &self, - #[graphql(desc = "How the initially infected nodes are chosen.")] seeds: GqlSeeds, - #[graphql( - desc = "Probability that an encounter between an active and a susceptible node infects it." - )] - infection_prob: f64, - #[graphql(desc = "Time of the initial infection.")] initial_infection: GqlTimeInput, - #[graphql(desc = "Rate at which infected nodes recover. If unset, nodes never recover.")] - recovery_rate: Option, - #[graphql( - desc = "Rate at which infected nodes become infectious. If unset, they are infectious immediately." - )] - incubation_rate: Option, - #[graphql(desc = "Seed for the random number generator. If unset, seeded from the OS.")] - rng_seed: Option, - ) -> Result { - self.run::(GqlTemporalSeirArgs { - seeds, - infection_prob, - initial_infection, - recovery_rate, - incubation_rate, - rng_seed, - }) - .await - } - - /// Returns a maximum weight matching of the graph, treated as undirected. - async fn max_weight_matching( - &self, - #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] - weight_prop: Option, - #[graphql(desc = "Only consider maximum-cardinality matchings. Defaults to false.")] - max_cardinality: Option, - #[graphql(desc = "Verify that the matching found is optimum. Defaults to false.")] - verify_optimum: Option, - ) -> Result { - self.run::(GqlMaxWeightMatchingArgs { - weight_prop, - max_cardinality: max_cardinality.unwrap_or(false), - verify_optimum: verify_optimum.unwrap_or(false), - }) - .await - } -} +pub(crate) use executable::{GqlAlgorithms, GqlExecutableAlgorithm}; +pub(crate) use inputs::{filtered_view, GqlDirection}; diff --git a/raphtory-graphql/src/model/algorithms/resolvers.rs b/raphtory-graphql/src/model/algorithms/resolvers.rs new file mode 100644 index 0000000000..fb4a0fa7e8 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/resolvers.rs @@ -0,0 +1,637 @@ +//! The `Graph.algorithm` field resolvers: one per algorithm exposed through the GraphQL API. + +use crate::model::{ + algorithms::{ + alternating_mask::{GqlAlternatingMask, GqlAlternatingMaskArgs}, + bipartite::max_weight_matching::{GqlMaxWeightMatching, GqlMaxWeightMatchingArgs}, + centrality::{ + betweenness_centrality::{GqlBetweennessCentrality, GqlBetweennessCentralityArgs}, + degree_centrality::{GqlDegreeCentrality, GqlDegreeCentralityArgs}, + hits::{GqlHits, GqlHitsArgs}, + pagerank::{GqlPagerank, GqlPagerankArgs}, + }, + community_detection::{ + label_propagation::{GqlLabelPropagation, GqlLabelPropagationArgs}, + louvain::{GqlLouvain, GqlLouvainArgs}, + }, + components::{ + in_component::{GqlInComponent, GqlInComponentArgs}, + in_components::{GqlInComponents, GqlInComponentsArgs}, + out_component::{GqlOutComponent, GqlOutComponentArgs}, + out_components::{GqlOutComponents, GqlOutComponentsArgs}, + strongly_connected_components::{ + GqlStronglyConnectedComponents, GqlStronglyConnectedComponentsArgs, + }, + weakly_connected_components::{ + GqlWeaklyConnectedComponents, GqlWeaklyConnectedComponentsArgs, + }, + }, + dynamics::temporal::temporal_seir::{GqlSeeds, GqlTemporalSeir, GqlTemporalSeirArgs}, + embeddings::fast_rp::{GqlFastRp, GqlFastRpArgs}, + executable::GqlAlgorithms, + inputs::GqlDirection, + layout::{ + cohesive_fruchterman_reingold::{ + GqlCohesiveFruchtermanReingold, GqlCohesiveFruchtermanReingoldArgs, + }, + fruchterman_reingold::{GqlFruchtermanReingold, GqlFruchtermanReingoldArgs}, + }, + metrics::{ + all_local_reciprocity::{GqlAllLocalReciprocity, GqlAllLocalReciprocityArgs}, + average_degree::{GqlAverageDegree, GqlAverageDegreeArgs}, + balance::{GqlBalance, GqlBalanceArgs}, + clustering_coefficient::{ + global_clustering_coefficient::{ + GqlGlobalClusteringCoefficient, GqlGlobalClusteringCoefficientArgs, + }, + local_clustering_coefficient::{ + GqlLocalClusteringCoefficient, GqlLocalClusteringCoefficientArgs, + }, + local_clustering_coefficient_batch::{ + GqlLocalClusteringCoefficientBatch, GqlLocalClusteringCoefficientBatchArgs, + }, + }, + directed_graph_density::{GqlDirectedGraphDensity, GqlDirectedGraphDensityArgs}, + global_reciprocity::{GqlGlobalReciprocity, GqlGlobalReciprocityArgs}, + max_degree::{GqlMaxDegree, GqlMaxDegreeArgs}, + max_in_degree::{GqlMaxInDegree, GqlMaxInDegreeArgs}, + max_out_degree::{GqlMaxOutDegree, GqlMaxOutDegreeArgs}, + min_degree::{GqlMinDegree, GqlMinDegreeArgs}, + min_in_degree::{GqlMinInDegree, GqlMinInDegreeArgs}, + min_out_degree::{GqlMinOutDegree, GqlMinOutDegreeArgs}, + }, + motifs::{ + global_temporal_three_node_motif::{ + GqlGlobalTemporalThreeNodeMotif, GqlGlobalTemporalThreeNodeMotifArgs, + }, + global_temporal_three_node_motif_multi::{ + GqlGlobalTemporalThreeNodeMotifMulti, GqlGlobalTemporalThreeNodeMotifMultiArgs, + GqlMotifCounts, + }, + local_temporal_three_node_motifs::{ + GqlLocalTemporalThreeNodeMotifs, GqlLocalTemporalThreeNodeMotifsArgs, + }, + local_triangle_count::{GqlLocalTriangleCount, GqlLocalTriangleCountArgs}, + temporal_rich_club_coefficient::{ + GqlTemporalRichClubCoefficient, GqlTemporalRichClubCoefficientArgs, + }, + triangle_count::{GqlTriangleCount, GqlTriangleCountArgs}, + triplet_count::{GqlTripletCount, GqlTripletCountArgs}, + }, + pathing::{ + dijkstra::{GqlDijkstra, GqlDijkstraArgs}, + single_source_shortest_path::{ + GqlSingleSourceShortestPath, GqlSingleSourceShortestPathArgs, + }, + temporally_reachable_nodes::{ + GqlTemporallyReachableNodes, GqlTemporallyReachableNodesArgs, + }, + }, + }, + graph::{ + filtering::GqlViewFilter, matching::GqlMatching, node_id::GqlNodeId, + node_state::GqlNodeState, timeindex::GqlTimeInput, WindowDuration, + }, +}; +use dynamic_graphql::ResolvedObjectFields; +use raphtory::errors::GraphError; + +#[ResolvedObjectFields] +impl GqlAlgorithms { + /// Returns the PageRank centrality of every node in the graph. + async fn pagerank( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + #[graphql(desc = "Convergence tolerance. Defaults to 0.000001.")] tol: Option, + #[graphql(desc = "Probability that the spread continues. Defaults to 0.85.")] + damping_factor: Option, + #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] + weight: Option, + ) -> Result { + self.run::(GqlPagerankArgs { + iter_count, + threads, + tol, + damping_factor, + weight, + }) + .await + } + + /// Returns the degree centrality of every node. + async fn degree_centrality(&self) -> Result { + self.run::(GqlDegreeCentralityArgs) + .await + } + + /// Returns the betweenness centrality of every node. + async fn betweenness_centrality( + &self, + #[graphql(desc = "Number of nodes to sample. Defaults to all nodes.")] k: Option, + #[graphql(desc = "Whether to normalize the values. Defaults to true.")] normalized: Option< + bool, + >, + ) -> Result { + self.run::(GqlBetweennessCentralityArgs { + k, + normalized: normalized.unwrap_or(true), + }) + .await + } + + /// Returns the HITS hub and authority scores of every node. + async fn hits( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlHitsArgs { + iter_count: iter_count.unwrap_or(20), + threads, + }) + .await + } + + /// Returns the shortest (unweighted) path from `source` to every reachable node. + async fn single_source_shortest_path( + &self, + #[graphql(desc = "Source node id.")] source: String, + #[graphql(desc = "Optional maximum path length; stops the search once reached.")] + cutoff: Option, + ) -> Result { + self.run::(GqlSingleSourceShortestPathArgs { source, cutoff }) + .await + } + + /// Returns the in component (all nodes that can reach it following out-edges) of every node. + async fn in_components( + &self, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlInComponentsArgs { threads }) + .await + } + + /// Returns the out component (all reachable nodes following out-edges) of every node. + async fn out_components( + &self, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlOutComponentsArgs { threads }) + .await + } + + /// Returns the in component of a single node (nodes that can reach it, with their distance). + async fn in_component( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + #[graphql( + desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." + )] + filter: Option, + ) -> Result { + self.run::(GqlInComponentArgs { node, filter }) + .await + } + + /// Returns the out component of a single node (nodes it can reach, with their distance). + async fn out_component( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + #[graphql( + desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." + )] + filter: Option, + ) -> Result { + self.run::(GqlOutComponentArgs { node, filter }) + .await + } + + /// Returns the local triangle count of a single node (0 if it has degree < 2), or null if + /// the node does not exist in the view. + async fn local_triangle_count( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + #[graphql( + desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." + )] + filter: Option, + ) -> Result, GraphError> { + self.run::(GqlLocalTriangleCountArgs { node, filter }) + .await + } + + /// Returns the local clustering coefficient of a single node (0 if it has degree < 2), or + /// null if the node does not exist in the view. + async fn local_clustering_coefficient( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + #[graphql( + desc = "Optional composite filter (node, edge, and graph-view); the algorithm runs on the resulting view." + )] + filter: Option, + ) -> Result, GraphError> { + self.run::(GqlLocalClusteringCoefficientArgs { + node, + filter, + }) + .await + } + + /// Returns the weakly connected component id of every node. + async fn weakly_connected_components(&self) -> Result { + self.run::(GqlWeaklyConnectedComponentsArgs) + .await + } + + /// Returns the strongly connected component id of every node. + async fn strongly_connected_components(&self) -> Result { + self.run::(GqlStronglyConnectedComponentsArgs) + .await + } + + /// Returns the community of every node (Louvain). + async fn louvain( + &self, + #[graphql(desc = "Resolution parameter for modularity. Defaults to 1.0.")] + resolution: Option, + #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] + weight_prop: Option, + #[graphql(desc = "Convergence tolerance. Defaults to 1e-8.")] tol: Option, + #[graphql(desc = "Seed for the node-shuffling rng. If unset, seeded from the OS.")] + rng_seed: Option, + ) -> Result { + self.run::(GqlLouvainArgs { + resolution: resolution.unwrap_or(1.0), + weight_prop, + tol, + rng_seed, + }) + .await + } + + /// Returns the community of every node (label propagation). + async fn label_propagation( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 20.")] iter_count: Option, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlLabelPropagationArgs { + iter_count: iter_count.unwrap_or(20), + threads, + }) + .await + } + + /// Returns the weighted shortest path from `source` to each of `targets` (Dijkstra). + async fn dijkstra( + &self, + #[graphql(desc = "Source node id.")] source: String, + #[graphql(desc = "Target node ids.")] targets: Vec, + #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] + weight: Option, + #[graphql(desc = "Edge direction to follow. Defaults to BOTH.")] direction: Option< + GqlDirection, + >, + ) -> Result { + self.run::(GqlDijkstraArgs { + source, + targets, + weight, + direction: direction.unwrap_or(GqlDirection::Both), + }) + .await + } + + /// Returns the local reciprocity of every node. + async fn all_local_reciprocity(&self) -> Result { + self.run::(GqlAllLocalReciprocityArgs) + .await + } + + /// Returns the net sum of edge weights (balance) of every node. + async fn balance( + &self, + #[graphql(desc = "Edge property to use as weight. Defaults to `weight`.")] name: Option< + String, + >, + #[graphql(desc = "Edge direction to consider. Defaults to BOTH.")] direction: Option< + GqlDirection, + >, + ) -> Result { + self.run::(GqlBalanceArgs { + name: name.unwrap_or_else(|| "weight".to_string()), + direction: direction.unwrap_or(GqlDirection::Both), + }) + .await + } + + /// Returns the local clustering coefficient of each of the given nodes. + async fn local_clustering_coefficient_batch( + &self, + #[graphql(desc = "Node ids to compute the coefficient for.")] nodes: Vec, + ) -> Result { + self.run::(GqlLocalClusteringCoefficientBatchArgs { + nodes, + }) + .await + } + + /// Returns the global clustering coefficient of the graph. + async fn global_clustering_coefficient(&self) -> Result { + self.run::(GqlGlobalClusteringCoefficientArgs) + .await + } + + /// Returns the directed graph density (fraction of possible directed edges present). + async fn directed_graph_density(&self) -> Result { + self.run::(GqlDirectedGraphDensityArgs) + .await + } + + /// Returns the global reciprocity of the graph. + async fn global_reciprocity(&self) -> Result { + self.run::(GqlGlobalReciprocityArgs) + .await + } + + /// Returns the average (undirected) degree of the graph's nodes. + async fn average_degree(&self) -> Result { + self.run::(GqlAverageDegreeArgs).await + } + + /// Returns the maximum (undirected) degree of any node in the graph. + async fn max_degree(&self) -> Result { + self.run::(GqlMaxDegreeArgs).await + } + + /// Returns the minimum (undirected) degree of any node in the graph. + async fn min_degree(&self) -> Result { + self.run::(GqlMinDegreeArgs).await + } + + /// Returns the maximum out-degree of any node in the graph. + async fn max_out_degree(&self) -> Result { + self.run::(GqlMaxOutDegreeArgs).await + } + + /// Returns the maximum in-degree of any node in the graph. + async fn max_in_degree(&self) -> Result { + self.run::(GqlMaxInDegreeArgs).await + } + + /// Returns the minimum out-degree of any node in the graph. + async fn min_out_degree(&self) -> Result { + self.run::(GqlMinOutDegreeArgs).await + } + + /// Returns the minimum in-degree of any node in the graph. + async fn min_in_degree(&self) -> Result { + self.run::(GqlMinInDegreeArgs).await + } + + /// Returns the number of connected triplets (paths of length 2) in the graph. + async fn triplet_count( + &self, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlTripletCountArgs { threads }) + .await + } + + /// Returns the number of triangles in the graph. + async fn triangle_count( + &self, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlTriangleCountArgs { threads }) + .await + } + + /// Returns the FastRP embedding of every node. + async fn fast_rp( + &self, + #[graphql(desc = "Dimension of the embedding.")] embedding_dim: usize, + #[graphql(desc = "Normalization strength applied to neighbour contributions.")] + normalization_strength: f64, + #[graphql(desc = "Weight of each iteration's contribution to the embedding.")] + iter_weights: Vec, + #[graphql(desc = "Seed for the rng. If unset, seeded from the OS.")] seed: Option, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlFastRpArgs { + embedding_dim, + normalization_strength, + iter_weights, + seed, + threads, + }) + .await + } + + /// Returns the nodes temporally reachable from `seedNodes` starting at `startTime`. + async fn temporally_reachable_nodes( + &self, + #[graphql(desc = "Maximum number of hops to traverse.")] max_hops: usize, + #[graphql(desc = "Time at which the traversal starts.")] start_time: i64, + #[graphql(desc = "Node ids to start from.")] seed_nodes: Vec, + #[graphql(desc = "Node ids that halt the traversal when reached.")] stop_nodes: Option< + Vec, + >, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlTemporallyReachableNodesArgs { + max_hops, + start_time, + seed_nodes, + stop_nodes, + threads, + }) + .await + } + + /// Returns the 2D layout position of every node (Fruchterman-Reingold). + async fn fruchterman_reingold( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 100.")] iter_count: Option, + #[graphql(desc = "Scale of the layout. Defaults to 1.0.")] scale: Option, + #[graphql(desc = "Initial node size. Defaults to 1.0.")] node_start_size: Option, + #[graphql(desc = "Cooloff factor. Defaults to 0.95.")] cooloff_factor: Option, + #[graphql(desc = "Time step. Defaults to 0.1.")] dt: Option, + ) -> Result { + self.run::(GqlFruchtermanReingoldArgs { + iter_count: iter_count.unwrap_or(100), + scale: scale.unwrap_or(1.0), + node_start_size: node_start_size.unwrap_or(1.0), + cooloff_factor: cooloff_factor.unwrap_or(0.95), + dt: dt.unwrap_or(0.1), + }) + .await + } + + /// Returns the 2D layout position of every node (cohesive Fruchterman-Reingold). + async fn cohesive_fruchterman_reingold( + &self, + #[graphql(desc = "Number of iterations to run. Defaults to 100.")] iter_count: Option, + #[graphql(desc = "Scale of the layout. Defaults to 1.0.")] scale: Option, + #[graphql(desc = "Initial node size. Defaults to 1.0.")] node_start_size: Option, + #[graphql(desc = "Cooloff factor. Defaults to 0.95.")] cooloff_factor: Option, + #[graphql(desc = "Time step. Defaults to 0.1.")] dt: Option, + ) -> Result { + self.run::(GqlCohesiveFruchtermanReingoldArgs { + iter_count: iter_count.unwrap_or(100), + scale: scale.unwrap_or(1.0), + node_start_size: node_start_size.unwrap_or(1.0), + cooloff_factor: cooloff_factor.unwrap_or(0.95), + dt: dt.unwrap_or(0.1), + }) + .await + } + + /// Returns the local temporal three-node motif counts of every node. + async fn local_temporal_three_node_motifs( + &self, + #[graphql(desc = "Maximum time difference between the first and last edge of a motif.")] + delta: i64, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result { + self.run::(GqlLocalTemporalThreeNodeMotifsArgs { + delta, + threads, + }) + .await + } + + /// Returns the graph-wide temporal three-node motif counts: 40 counts in a + /// fixed order (8 two-node, 24 star, then 8 triangle motifs). + async fn global_temporal_three_node_motif( + &self, + #[graphql(desc = "Maximum time difference between the first and last edge of a motif.")] + delta: i64, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result, GraphError> { + self.run::(GqlGlobalTemporalThreeNodeMotifArgs { + delta, + threads, + }) + .await + } + + /// Returns the graph-wide temporal three-node motif counts for each of + /// `deltas`, one row of 40 counts per delta, in the order given. + async fn global_temporal_three_node_motif_multi( + &self, + #[graphql(desc = "Maximum time differences to compute the motif counts for.")] deltas: Vec< + i64, + >, + #[graphql(desc = "Number of threads to use. Defaults to all available.")] threads: Option< + usize, + >, + ) -> Result, GraphError> { + self.run::(GqlGlobalTemporalThreeNodeMotifMultiArgs { + deltas, + threads, + }) + .await + } + + /// Returns the temporal rich club coefficient: the maximal density among the + /// nodes of degree at least `k` that persists over `windowSize` consecutive + /// snapshots. The snapshots are the rolling windows described by + /// `rollingWindow` / `rollingStep`. + async fn temporal_rich_club_coefficient( + &self, + #[graphql(desc = "Minimum degree a node must have to be in the rich club.")] k: usize, + #[graphql(desc = "Number of consecutive snapshots the edges must persist over.")] + window_size: usize, + #[graphql(desc = "Width of each snapshot.")] rolling_window: WindowDuration, + #[graphql( + desc = "Optional gap between the start of one snapshot and the next. Defaults to `rollingWindow`, i.e. non-overlapping snapshots." + )] + rolling_step: Option, + ) -> Result { + self.run::(GqlTemporalRichClubCoefficientArgs { + k, + window_size, + rolling_window, + rolling_step, + }) + .await + } + + /// Returns an alternating boolean mask over the nodes. + async fn alternating_mask(&self) -> Result { + self.run::(GqlAlternatingMaskArgs).await + } + + /// Simulates an SEIR epidemic, returning the infection, activation and + /// recovery times of every node that was infected. + async fn temporal_seir( + &self, + #[graphql(desc = "How the initially infected nodes are chosen.")] seeds: GqlSeeds, + #[graphql( + desc = "Probability that an encounter between an active and a susceptible node infects it." + )] + infection_prob: f64, + #[graphql(desc = "Time of the initial infection.")] initial_infection: GqlTimeInput, + #[graphql(desc = "Rate at which infected nodes recover. If unset, nodes never recover.")] + recovery_rate: Option, + #[graphql( + desc = "Rate at which infected nodes become infectious. If unset, they are infectious immediately." + )] + incubation_rate: Option, + #[graphql(desc = "Seed for the random number generator. If unset, seeded from the OS.")] + rng_seed: Option, + ) -> Result { + self.run::(GqlTemporalSeirArgs { + seeds, + infection_prob, + initial_infection, + recovery_rate, + incubation_rate, + rng_seed, + }) + .await + } + + /// Returns a maximum weight matching of the graph, treated as undirected. + async fn max_weight_matching( + &self, + #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] + weight_prop: Option, + #[graphql(desc = "Only consider maximum-cardinality matchings. Defaults to false.")] + max_cardinality: Option, + #[graphql(desc = "Verify that the matching found is optimum. Defaults to false.")] + verify_optimum: Option, + ) -> Result { + self.run::(GqlMaxWeightMatchingArgs { + weight_prop, + max_cardinality: max_cardinality.unwrap_or(false), + verify_optimum: verify_optimum.unwrap_or(false), + }) + .await + } +} From 6aa20a077e2ef70cd58f916b2ee5e13c41eef2b0 Mon Sep 17 00:00:00 2001 From: Ben Steer Date: Wed, 5 Aug 2026 10:38:50 +0100 Subject: [PATCH 39/39] Rename test_algorithms.py to test_algorithms_graphql.py --- .../{test_algorithms.py => test_algorithms_graphql.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename python/tests/test_base_install/test_graphql/{test_algorithms.py => test_algorithms_graphql.py} (100%) diff --git a/python/tests/test_base_install/test_graphql/test_algorithms.py b/python/tests/test_base_install/test_graphql/test_algorithms_graphql.py similarity index 100% rename from python/tests/test_base_install/test_graphql/test_algorithms.py rename to python/tests/test_base_install/test_graphql/test_algorithms_graphql.py