Skip to main content

nodedb_graph/
params.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Graph algorithm enum and parameter bag.
4//!
5//! `GraphAlgorithm` identifies which algorithm to run.
6//! `AlgoParams` carries the union of all algorithm parameters — each
7//! algorithm validates and extracts what it needs.
8
9use serde::{Deserialize, Serialize};
10
11/// Supported graph algorithms.
12///
13/// Each variant maps to a standalone algorithm implementation under
14/// `src/engine/graph/algo/`. Used by `PhysicalPlan::GraphAlgo` to
15/// identify which algorithm to dispatch.
16#[derive(
17    Debug,
18    Clone,
19    Copy,
20    PartialEq,
21    Eq,
22    Hash,
23    Serialize,
24    Deserialize,
25    zerompk::ToMessagePack,
26    zerompk::FromMessagePack,
27)]
28#[msgpack(c_enum)]
29pub enum GraphAlgorithm {
30    /// PageRank — link analysis (power iteration).
31    PageRank,
32    /// Weakly Connected Components — union-find.
33    Wcc,
34    /// Community Detection — label propagation.
35    LabelPropagation,
36    /// Local Clustering Coefficient — per-node triangle density.
37    Lcc,
38    /// Single-Source Shortest Path — weighted Dijkstra.
39    Sssp,
40    /// Betweenness Centrality — Brandes' algorithm.
41    Betweenness,
42    /// Closeness Centrality — inverse distance sum.
43    Closeness,
44    /// Harmonic Centrality — inverse distance harmonic mean.
45    Harmonic,
46    /// Degree Centrality — normalized degree.
47    Degree,
48    /// Louvain Community Detection — modularity optimization.
49    Louvain,
50    /// Triangle Counting — global or per-node.
51    Triangles,
52    /// Graph Diameter / Eccentricity.
53    Diameter,
54    /// k-Core Decomposition — peeling algorithm.
55    KCore,
56}
57
58impl GraphAlgorithm {
59    /// Human-readable name for progress reporting and result column headers.
60    pub fn name(&self) -> &'static str {
61        match self {
62            Self::PageRank => "pagerank",
63            Self::Wcc => "wcc",
64            Self::LabelPropagation => "label_propagation",
65            Self::Lcc => "lcc",
66            Self::Sssp => "sssp",
67            Self::Betweenness => "betweenness",
68            Self::Closeness => "closeness",
69            Self::Harmonic => "harmonic",
70            Self::Degree => "degree",
71            Self::Louvain => "louvain",
72            Self::Triangles => "triangles",
73            Self::Diameter => "diameter",
74            Self::KCore => "kcore",
75        }
76    }
77
78    /// Whether this algorithm is iterative (emits progress per iteration).
79    pub fn is_iterative(&self) -> bool {
80        matches!(
81            self,
82            Self::PageRank | Self::LabelPropagation | Self::Louvain
83        )
84    }
85
86    /// Result column schema: `(column_name, column_type)`.
87    ///
88    /// Used by the Arrow result builder to construct RecordBatches and by
89    /// the DDL layer to advertise result columns.
90    pub fn result_schema(&self) -> &'static [(&'static str, AlgoColumnType)] {
91        use AlgoColumnType::*;
92        match self {
93            Self::PageRank => &[("node_id", Text), ("rank", Float64)],
94            Self::Wcc => &[("node_id", Text), ("component_id", Int64)],
95            Self::LabelPropagation => &[("node_id", Text), ("community_id", Int64)],
96            Self::Lcc => &[("node_id", Text), ("coefficient", Float64)],
97            Self::Sssp => &[("node_id", Text), ("distance", Float64)],
98            Self::Betweenness => &[("node_id", Text), ("centrality", Float64)],
99            Self::Closeness => &[("node_id", Text), ("centrality", Float64)],
100            Self::Harmonic => &[("node_id", Text), ("centrality", Float64)],
101            Self::Degree => &[("node_id", Text), ("centrality", Float64)],
102            Self::Louvain => &[
103                ("node_id", Text),
104                ("community_id", Int64),
105                ("modularity", Float64),
106            ],
107            Self::Triangles => &[("node_id", Text), ("triangles", Int64)],
108            Self::Diameter => &[("diameter", Int64), ("radius", Int64)],
109            Self::KCore => &[("node_id", Text), ("coreness", Int64)],
110        }
111    }
112}
113
114/// Column type for algorithm result schemas.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum AlgoColumnType {
117    Text,
118    Float64,
119    Int64,
120}
121
122/// Generic parameter bag for all graph algorithms.
123///
124/// Each algorithm validates and extracts the parameters it needs,
125/// ignoring the rest. Unknown parameters are silently ignored rather
126/// than rejected — this allows forward-compatible DDL extensions.
127#[derive(
128    Debug,
129    Clone,
130    Default,
131    PartialEq,
132    Serialize,
133    Deserialize,
134    zerompk::ToMessagePack,
135    zerompk::FromMessagePack,
136)]
137pub struct AlgoParams {
138    /// Target collection name.
139    pub collection: String,
140
141    /// Optional edge label filter — only edges with this label are traversed.
142    pub edge_label: Option<String>,
143
144    /// PageRank damping factor (default: 0.85).
145    pub damping: Option<f64>,
146
147    /// Maximum iterations for iterative algorithms (PageRank, LabelProp, Louvain).
148    pub max_iterations: Option<usize>,
149
150    /// Convergence tolerance for PageRank (default: 1e-7).
151    pub tolerance: Option<f64>,
152
153    /// Source node for SSSP.
154    pub source_node: Option<String>,
155
156    /// Sample size for approximate centrality (betweenness, closeness).
157    /// `None` = exact computation.
158    pub sample_size: Option<usize>,
159
160    /// Direction for degree centrality: "in", "out", "both".
161    pub direction: Option<String>,
162
163    /// Resolution parameter for Louvain (default: 1.0).
164    pub resolution: Option<f64>,
165
166    /// Mode for triangle counting / diameter: "global", "per_node", "exact", "approximate".
167    pub mode: Option<String>,
168
169    /// Per-node initial rank seed for Personalized PageRank.
170    ///
171    /// When `None`, PageRank initializes uniformly (1.0/n per node) — standard behavior.
172    /// When `Some(map)`, PPR initializes each node `id` to `map[id]` (normalized to sum=1.0);
173    /// nodes missing from the map start at 0.0. Bias toward seed nodes makes them and their
174    /// neighbors rank higher.
175    #[serde(default)]
176    pub personalization_vector: Option<std::collections::HashMap<String, f64>>,
177}
178
179impl AlgoParams {
180    /// PageRank damping factor, validated to (0.0, 1.0).
181    pub fn damping_factor(&self) -> f64 {
182        self.damping.unwrap_or(0.85).clamp(0.01, 0.99)
183    }
184
185    /// Max iterations with sensible default per algorithm.
186    ///
187    /// Defence-in-depth: the pgwire handler clamps `ITERATIONS` to
188    /// `MAX_ITERATIONS_CAP` before dispatch, but any alternate entry
189    /// point (native protocol, internal dispatch) also lands here, so
190    /// we enforce the ceiling at the engine boundary too.
191    pub fn iterations(&self, default: usize) -> usize {
192        const ITERATIONS_HARD_CAP: usize = 1_000;
193        self.max_iterations
194            .unwrap_or(default)
195            .clamp(1, ITERATIONS_HARD_CAP)
196    }
197
198    /// Convergence tolerance, validated to positive.
199    pub fn convergence_tolerance(&self) -> f64 {
200        let t = self.tolerance.unwrap_or(1e-7);
201        if t > 0.0 { t } else { 1e-7 }
202    }
203
204    /// Louvain resolution parameter, validated to positive.
205    pub fn louvain_resolution(&self) -> f64 {
206        let r = self.resolution.unwrap_or(1.0);
207        if r > 0.0 { r } else { 1.0 }
208    }
209
210    /// Personalization vector for Personalized PageRank.
211    ///
212    /// Returns `None` for standard uniform-init PageRank, or `Some(map)` where
213    /// map keys are node ids and values are unnormalized seed weights.
214    pub fn personalization_vector(&self) -> Option<&std::collections::HashMap<String, f64>> {
215        self.personalization_vector.as_ref()
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use sonic_rs;
223
224    #[test]
225    fn algorithm_names() {
226        assert_eq!(GraphAlgorithm::PageRank.name(), "pagerank");
227        assert_eq!(GraphAlgorithm::Wcc.name(), "wcc");
228        assert_eq!(GraphAlgorithm::KCore.name(), "kcore");
229    }
230
231    #[test]
232    fn iterative_algorithms() {
233        assert!(GraphAlgorithm::PageRank.is_iterative());
234        assert!(GraphAlgorithm::LabelPropagation.is_iterative());
235        assert!(GraphAlgorithm::Louvain.is_iterative());
236        assert!(!GraphAlgorithm::Wcc.is_iterative());
237        assert!(!GraphAlgorithm::Sssp.is_iterative());
238    }
239
240    #[test]
241    fn result_schema_columns() {
242        let schema = GraphAlgorithm::PageRank.result_schema();
243        assert_eq!(schema.len(), 2);
244        assert_eq!(schema[0], ("node_id", AlgoColumnType::Text));
245        assert_eq!(schema[1], ("rank", AlgoColumnType::Float64));
246    }
247
248    #[test]
249    fn louvain_schema_has_three_columns() {
250        let schema = GraphAlgorithm::Louvain.result_schema();
251        assert_eq!(schema.len(), 3);
252    }
253
254    #[test]
255    fn params_defaults() {
256        let p = AlgoParams::default();
257        assert_eq!(p.damping_factor(), 0.85);
258        assert_eq!(p.iterations(20), 20);
259        assert_eq!(p.convergence_tolerance(), 1e-7);
260        assert_eq!(p.louvain_resolution(), 1.0);
261    }
262
263    #[test]
264    fn params_clamping() {
265        let p = AlgoParams {
266            damping: Some(2.0),
267            tolerance: Some(-1.0),
268            resolution: Some(0.0),
269            ..Default::default()
270        };
271        assert_eq!(p.damping_factor(), 0.99);
272        assert_eq!(p.convergence_tolerance(), 1e-7);
273        assert_eq!(p.louvain_resolution(), 1.0);
274    }
275
276    #[test]
277    fn params_serde_roundtrip() {
278        let p = AlgoParams {
279            collection: "users".into(),
280            damping: Some(0.9),
281            max_iterations: Some(30),
282            source_node: Some("alice".into()),
283            ..Default::default()
284        };
285        let json = sonic_rs::to_string(&p).unwrap();
286        let p2: AlgoParams = sonic_rs::from_str(&json).unwrap();
287        assert_eq!(p2.collection, "users");
288        assert_eq!(p2.damping, Some(0.9));
289        assert_eq!(p2.max_iterations, Some(30));
290        assert_eq!(p2.source_node, Some("alice".into()));
291    }
292}