diff --git a/Cargo.lock b/Cargo.lock index 8f41fd8af8..6b2b9be2d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6531,6 +6531,7 @@ dependencies = [ "pyo3", "pythonize", "quick_cache", + "rand 0.9.4", "raphtory", "raphtory-api", "raphtory-storage", diff --git a/python/tests/test_base_install/test_graphql/test_algorithms_graphql.py b/python/tests/test_base_install/test_graphql/test_algorithms_graphql.py new file mode 100644 index 0000000000..e18d3ea557 --- /dev/null +++ b/python/tests/test_base_install/test_graphql/test_algorithms_graphql.py @@ -0,0 +1,52 @@ +from raphtory import Graph + +from utils import run_graphql_test + + +def init_graph(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 { prop } + } + } + } + } + } + }""" + expected_output = { + "graph": { + "algorithm": { + "pagerank": { + "count": 3, + "nodes": {"list": [{"name": "a"}, {"name": "b"}, {"name": "c"}]}, + "columns": [ + { + "name": "pagerank_score", + "values": [ + { "__typename": "NodeStateProp", "prop": 0.197580035313204 }, + { "__typename": "NodeStateProp", "prop": 0.28155081033755053 }, + { "__typename": "NodeStateProp", "prop": 0.5208691543492454 }, + ], + } + ], + } + } + } + } + run_graphql_test(query, expected_output, graph) diff --git a/raphtory-graphql/Cargo.toml b/raphtory-graphql/Cargo.toml index d2cc35f6ad..9b51372de0 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/schema.graphql b/raphtory-graphql/schema.graphql index 77451b5703..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. diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 0541e0cf96..9cfd769356 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -430,6 +430,118 @@ mod graphql_test { graph } + 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")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); + } + graph.into() + } + + 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")] { + graph.add_edge(1, src, dst, NO_PROPS, None).unwrap(); + } + graph.into() + } + + pub(crate) 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() + } + + pub(crate) 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() + } + + pub(crate) 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() + } + + pub(crate) 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_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_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/alternating_mask.rs b/raphtory-graphql/src/model/algorithms/alternating_mask.rs new file mode 100644 index 0000000000..40b7453d4f --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/alternating_mask.rs @@ -0,0 +1,70 @@ +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()) + } +} + +#[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/bipartite/max_weight_matching.rs b/raphtory-graphql/src/model/algorithms/bipartite/max_weight_matching.rs new file mode 100644 index 0000000000..506a2438ca --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/bipartite/max_weight_matching.rs @@ -0,0 +1,104 @@ +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()) + } +} + +#[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/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/betweenness_centrality.rs b/raphtory-graphql/src/model/algorithms/centrality/betweenness_centrality.rs new file mode 100644 index 0000000000..0c18bd341f --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/centrality/betweenness_centrality.rs @@ -0,0 +1,77 @@ +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()) + } +} + +#[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/centrality/degree_centrality.rs b/raphtory-graphql/src/model/algorithms/centrality/degree_centrality.rs new file mode 100644 index 0000000000..e88ab1a3d0 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/centrality/degree_centrality.rs @@ -0,0 +1,76 @@ +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()) + } +} + +#[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/centrality/hits.rs b/raphtory-graphql/src/model/algorithms/centrality/hits.rs new file mode 100644 index 0000000000..707f95b7cb --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/centrality/hits.rs @@ -0,0 +1,83 @@ +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()) + } +} + +#[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/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/centrality/pagerank.rs b/raphtory-graphql/src/model/algorithms/centrality/pagerank.rs new file mode 100644 index 0000000000..8cc80d043b --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/centrality/pagerank.rs @@ -0,0 +1,110 @@ +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; + +/// 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()) + } +} + +#[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/community_detection/label_propagation.rs b/raphtory-graphql/src/model/algorithms/community_detection/label_propagation.rs new file mode 100644 index 0000000000..43d60a6d69 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/community_detection/label_propagation.rs @@ -0,0 +1,80 @@ +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()) + } +} + +#[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/community_detection/louvain.rs b/raphtory-graphql/src/model/algorithms/community_detection/louvain.rs new file mode 100644 index 0000000000..f8bbb61fec --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/community_detection/louvain.rs @@ -0,0 +1,92 @@ +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()) + } +} + +#[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/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/in_component.rs b/raphtory-graphql/src/model/algorithms/components/in_component.rs new file mode 100644 index 0000000000..c6f2ac3b10 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/components/in_component.rs @@ -0,0 +1,79 @@ +use crate::model::{ + algorithms::{filtered_view, GqlExecutableAlgorithm}, + graph::{filtering::GqlViewFilter, 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()) + } +} + +#[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/components/in_components.rs b/raphtory-graphql/src/model/algorithms/components/in_components.rs new file mode 100644 index 0000000000..4f35b1f051 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/components/in_components.rs @@ -0,0 +1,116 @@ +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()) + } +} + +#[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/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/components/out_component.rs b/raphtory-graphql/src/model/algorithms/components/out_component.rs new file mode 100644 index 0000000000..6366fd7c95 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/components/out_component.rs @@ -0,0 +1,85 @@ +use crate::model::{ + algorithms::{filtered_view, GqlExecutableAlgorithm}, + graph::{filtering::GqlViewFilter, 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()) + } +} + +#[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/algorithms/components/out_components.rs b/raphtory-graphql/src/model/algorithms/components/out_components.rs new file mode 100644 index 0000000000..199e009c2b --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/components/out_components.rs @@ -0,0 +1,100 @@ +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()) + } +} + +#[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/components/strongly_connected_components.rs b/raphtory-graphql/src/model/algorithms/components/strongly_connected_components.rs new file mode 100644 index 0000000000..759f849881 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/components/strongly_connected_components.rs @@ -0,0 +1,76 @@ +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()) + } +} + +#[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/components/weakly_connected_components.rs b/raphtory-graphql/src/model/algorithms/components/weakly_connected_components.rs new file mode 100644 index 0000000000..c21f6e2966 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/components/weakly_connected_components.rs @@ -0,0 +1,72 @@ +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()) + } +} + +#[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 }] + }] + } } } + }) + ); + } +} 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/dynamics/temporal/temporal_seir.rs b/raphtory-graphql/src/model/algorithms/dynamics/temporal/temporal_seir.rs new file mode 100644 index 0000000000..49067a5d84 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/dynamics/temporal/temporal_seir.rs @@ -0,0 +1,180 @@ +use crate::model::{ + algorithms::GqlExecutableAlgorithm, + 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, + }, + core::entities::VID, + db::api::view::{DynamicGraph, StaticGraphViewOps}, + errors::GraphError, +}; +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()) + } +} + +#[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/embeddings/fast_rp.rs b/raphtory-graphql/src/model/algorithms/embeddings/fast_rp.rs new file mode 100644 index 0000000000..4020bf7008 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/embeddings/fast_rp.rs @@ -0,0 +1,96 @@ +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()) + } +} + +#[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/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/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/layout/cohesive_fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/layout/cohesive_fruchterman_reingold.rs new file mode 100644 index 0000000000..98c61b76a6 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/layout/cohesive_fruchterman_reingold.rs @@ -0,0 +1,92 @@ +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: f64, + pub(crate) node_start_size: f64, + pub(crate) cooloff_factor: f64, + pub(crate) dt: f64, +} + +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()) + } +} + +#[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/layout/fruchterman_reingold.rs b/raphtory-graphql/src/model/algorithms/layout/fruchterman_reingold.rs new file mode 100644 index 0000000000..f4d9d56c3e --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/layout/fruchterman_reingold.rs @@ -0,0 +1,92 @@ +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: f64, + pub(crate) node_start_size: f64, + pub(crate) cooloff_factor: f64, + pub(crate) dt: f64, +} + +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()) + } +} + +#[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/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/all_local_reciprocity.rs b/raphtory-graphql/src/model/algorithms/metrics/all_local_reciprocity.rs new file mode 100644 index 0000000000..af65385e3f --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/all_local_reciprocity.rs @@ -0,0 +1,78 @@ +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()) + } +} + +#[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/metrics/average_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/average_degree.rs new file mode 100644 index 0000000000..48cf289ce7 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/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/metrics/balance.rs b/raphtory-graphql/src/model/algorithms/metrics/balance.rs new file mode 100644 index 0000000000..3d1eece16c --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/balance.rs @@ -0,0 +1,80 @@ +use crate::model::{ + algorithms::{GqlDirection, GqlExecutableAlgorithm}, + graph::node_state::GqlNodeState, +}; +use raphtory::{ + algorithms::metrics::balance::balance, db::api::view::DynamicGraph, errors::GraphError, +}; + +/// 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()) + } +} + +#[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/metrics/clustering_coefficient/global_clustering_coefficient.rs b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/global_clustering_coefficient.rs new file mode 100644 index 0000000000..b60a28cfc7 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/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/metrics/clustering_coefficient/local_clustering_coefficient.rs b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/local_clustering_coefficient.rs new file mode 100644 index 0000000000..feeab3311a --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/local_clustering_coefficient.rs @@ -0,0 +1,71 @@ +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)) + } +} + +#[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/metrics/clustering_coefficient/local_clustering_coefficient_batch.rs b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/local_clustering_coefficient_batch.rs new file mode 100644 index 0000000000..899d7b7ce1 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/clustering_coefficient/local_clustering_coefficient_batch.rs @@ -0,0 +1,79 @@ +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()) + } +} + +#[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/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/directed_graph_density.rs b/raphtory-graphql/src/model/algorithms/metrics/directed_graph_density.rs new file mode 100644 index 0000000000..338d1058ba --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/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/metrics/global_reciprocity.rs b/raphtory-graphql/src/model/algorithms/metrics/global_reciprocity.rs new file mode 100644 index 0000000000..9e9ca0da29 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/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/metrics/max_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/max_degree.rs new file mode 100644 index 0000000000..acb497a6f9 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/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/metrics/max_in_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/max_in_degree.rs new file mode 100644 index 0000000000..0425c55bf6 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/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/metrics/max_out_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/max_out_degree.rs new file mode 100644 index 0000000000..da424c3f91 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/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/metrics/min_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/min_degree.rs new file mode 100644 index 0000000000..3dbd0d9825 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/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/metrics/min_in_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/min_in_degree.rs new file mode 100644 index 0000000000..a1ebe6a848 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/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/metrics/min_out_degree.rs b/raphtory-graphql/src/model/algorithms/metrics/min_out_degree.rs new file mode 100644 index 0000000000..60be29b6f5 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/metrics/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/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 new file mode 100644 index 0000000000..5394d13372 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/mod.rs @@ -0,0 +1,19 @@ +//! Statically defined graph algorithms exposed through `Graph.algorithm`. + +pub(crate) mod alternating_mask; +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 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; + +pub(crate) use executable::{GqlAlgorithms, GqlExecutableAlgorithm}; +pub(crate) use inputs::{filtered_view, GqlDirection}; diff --git a/raphtory-graphql/src/model/algorithms/motifs/global_temporal_three_node_motif.rs b/raphtory-graphql/src/model/algorithms/motifs/global_temporal_three_node_motif.rs new file mode 100644 index 0000000000..c6ef95b8d2 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/motifs/global_temporal_three_node_motif.rs @@ -0,0 +1,81 @@ +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()) + } +} + +#[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/motifs/global_temporal_three_node_motif_multi.rs b/raphtory-graphql/src/model/algorithms/motifs/global_temporal_three_node_motif_multi.rs new file mode 100644 index 0000000000..1ba76bf816 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/motifs/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/motifs/local_temporal_three_node_motifs.rs b/raphtory-graphql/src/model/algorithms/motifs/local_temporal_three_node_motifs.rs new file mode 100644 index 0000000000..d668277df6 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/motifs/local_temporal_three_node_motifs.rs @@ -0,0 +1,88 @@ +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()) + } +} + +#[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/motifs/local_triangle_count.rs b/raphtory-graphql/src/model/algorithms/motifs/local_triangle_count.rs new file mode 100644 index 0000000000..db14f0b569 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/motifs/local_triangle_count.rs @@ -0,0 +1,99 @@ +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)) + } +} + +#[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/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/motifs/temporal_rich_club_coefficient.rs b/raphtory-graphql/src/model/algorithms/motifs/temporal_rich_club_coefficient.rs new file mode 100644 index 0000000000..01cc1d148f --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/motifs/temporal_rich_club_coefficient.rs @@ -0,0 +1,91 @@ +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, + )) + } +} + +#[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/motifs/triangle_count.rs b/raphtory-graphql/src/model/algorithms/motifs/triangle_count.rs new file mode 100644 index 0000000000..0be3fc8123 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/motifs/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/motifs/triplet_count.rs b/raphtory-graphql/src/model/algorithms/motifs/triplet_count.rs new file mode 100644 index 0000000000..b3f396e05c --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/motifs/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)) + } +} diff --git a/raphtory-graphql/src/model/algorithms/pathing/dijkstra.rs b/raphtory-graphql/src/model/algorithms/pathing/dijkstra.rs new file mode 100644 index 0000000000..d0b4f5cf6d --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/pathing/dijkstra.rs @@ -0,0 +1,108 @@ +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, +}; + +/// 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()) + } +} + +#[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/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; diff --git a/raphtory-graphql/src/model/algorithms/pathing/single_source_shortest_path.rs b/raphtory-graphql/src/model/algorithms/pathing/single_source_shortest_path.rs new file mode 100644 index 0000000000..8b31ea5c08 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/pathing/single_source_shortest_path.rs @@ -0,0 +1,121 @@ +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()) + } +} + +#[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/pathing/temporally_reachable_nodes.rs b/raphtory-graphql/src/model/algorithms/pathing/temporally_reachable_nodes.rs new file mode 100644 index 0000000000..4bfff47ee7 --- /dev/null +++ b/raphtory-graphql/src/model/algorithms/pathing/temporally_reachable_nodes.rs @@ -0,0 +1,96 @@ +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()) + } +} + +#[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/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 + } +} diff --git a/raphtory-graphql/src/model/graph/filtering.rs b/raphtory-graphql/src/model/graph/filtering.rs index 51e4b212ed..0c3dcdaffa 100644 --- a/raphtory-graphql/src/model/graph/filtering.rs +++ b/raphtory-graphql/src/model/graph/filtering.rs @@ -585,6 +585,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 @@ -1906,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/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index 83bfb2419d..318f44b232 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, @@ -630,6 +631,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). async fn shared_neighbours( 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 7e416b89fb..dfd1595a73 100644 --- a/raphtory-graphql/src/model/graph/mod.rs +++ b/raphtory-graphql/src/model/graph/mod.rs @@ -9,12 +9,14 @@ mod edges; pub mod filtering; pub(crate) mod graph; pub(crate) mod history; +pub(crate) mod matching; pub mod meta_graph; pub(crate) mod mutable_graph; 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..6e29c48191 --- /dev/null +++ b/raphtory-graphql/src/model/graph/node_state.rs @@ -0,0 +1,1044 @@ +use crate::{ + model::graph::{ + collection::check_page_limit, node::GqlNode, node_id::GqlNodeId, nodes::GqlNodes, + property::GqlPropertyOutputVal, + }, + rayon::blocking_compute, +}; +use async_graphql::Context; +use dynamic_graphql::{ResolvedObject, ResolvedObjectFields, Result, SimpleObject, Union}; +use raphtory::{ + db::{ + api::{ + state::{ + GenericNodeState, Index, NodeStateOutput, NodeStateValue, OutputTypedNodeState, + PropMap, TypedNodeState, + }, + view::{BoxableGraphView, DynamicGraph}, + }, + graph::node::NodeView, + }, + prelude::{NodeStateOps, Prop}, +}; +use raphtory_api::core::entities::{properties::prop::PropUnwrap, VID}; +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. +/// +/// 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) node_state: OutputTypedNodeState<'static, DynamicGraph>, +} + +impl From> for GqlNodeState +where + V: NodeStateValue + 'static, + T: Clone + Send + Sync + 'static, +{ + fn from(node_state: TypedNodeState<'static, V, DynamicGraph, T>) -> Self { + Self { + node_state: node_state.to_output_nodestate(), + } + } +} + +/// 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), + Nodes(GqlNodes), +} + +/// 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. + prop: Option, +} + +impl From>> for GqlNodeStateValue { + fn from(value: NodeStateOutput<'static, Arc>) -> Self { + match value { + NodeStateOutput::Prop(prop) => GqlNodeStateValue::Prop(GqlNodeStateProp { + prop: 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, +} + +/// One column's value for a single node. +#[derive(SimpleObject, Clone)] +#[graphql(name = "NodeStateEntry")] +pub(crate) struct GqlNodeStateEntry { + /// Name of the column. + column_name: String, + /// The node's value in this column. + 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, +} + +/// 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, +} + +/// 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)] +#[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>( + 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 + } + } + } + } +} + +/// 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 { + /// 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) + && 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> { + self.is_prop_column(column).then(|| { + self.node_state + .iter() + .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; + /// 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, + item: (NodeView<'_, &DynamicGraph>, PropMap), + ) -> Option { + let (node, mut row) = item; + let value: Prop = row.swap_remove(column)??.into(); + Some(GqlNodeStateItem { + node: node.cloned().into(), + value: GqlPropertyOutputVal(value), + }) + } +} + +// TODO: add paging: `columns`/`nodes`/`rows` currently dump every row. + +// 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 { + /// Returns the number of nodes with a value in this node state. + async fn count(&self) -> usize { + self.node_state.len() + } + + /// The nodes with a value in this node state, in row order. Aligned with `values`. + async fn nodes(&self) -> GqlNodes { + 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. + async fn get( + &self, + #[graphql(desc = "Node id.")] node: GqlNodeId, + ) -> Option> { + let self_clone = self.clone(); + blocking_compute(move || { + 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 { + column_name: name, + value: value.into(), + }) + .collect(), + ) + }) + .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.check_comparable(&column)?; + let item = self_clone + .node_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, + /// 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.check_comparable(&column)?; + let item = self_clone + .node_state + .max_item_by(column_cmp(&column, false))?; + self_clone.item_from_row(&column, item) + }) + .await + } + + /// 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 { + let self_clone = self.clone(); + blocking_compute(move || { + let mut values = self_clone.column_value_iter(&column)?; + let mut acc = values.next()?; + if !acc.dtype().has_add() { + return None; + } + 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 + .node_state + .median_item_by(column_cmp(&column, true))?; + self_clone.item_from_row(&column, item) + }) + .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 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(); + blocking_compute(move || GqlNodeState { + node_state: self_clone.node_state.sort_by_id(), + }) + .await + } + + /// 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.node_state.len(); + let mut columns: Vec<(String, Vec)> = self_clone + .node_state + .state + .values_ref() + .schema() + .fields() + .iter() + .map(|field| (field.name().clone(), Vec::with_capacity(num_rows))) + .collect(); + 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()); + } + } + } + columns + .into_iter() + .map(|(name, values)| GqlNodeStateColumn { name, values }) + .collect() + }) + .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 + } + } + } + }) + ); + } +} diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index bc37d9b13e..4d1a2f9889 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -44,6 +44,7 @@ use tracing::warn; #[cfg(feature = "vectors")] use crate::model::graph::vectorised_graph::{GqlVectorisedGraph, VectorQuery}; +pub(crate) mod algorithms; pub mod graph; pub mod plugins; pub(crate) mod schema; 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/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/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/errors.rs b/raphtory/src/errors.rs index 9f5f4ffc7e..82cc38c6c6 100644 --- a/raphtory/src/errors.rs +++ b/raphtory/src/errors.rs @@ -31,6 +31,7 @@ use storage::{error::StorageError, resolver::mapping_resolver::InvalidNodeId}; #[cfg(feature = "python")] use pyo3::PyErr; +use crate::algorithms::dynamics::temporal::epidemics::SeedError; #[cfg(feature = "io")] use zip::result::ZipError; @@ -239,6 +240,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), 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,