Skip to main content

ipfrs_tensorlogic/
graph_neural_network.rs

1//! Graph Neural Network (GNN) with message-passing, edge weighting, and multi-layer propagation.
2//!
3//! This module implements a full message-passing GNN where nodes aggregate neighbour
4//! features, transform them through stacked linear layers (with bias and activation),
5//! and iteratively refine node embeddings.
6//!
7//! # Quick example
8//!
9//! ```
10//! use ipfrs_tensorlogic::graph_neural_network::{
11//!     GraphNeuralNetwork, GnnConfig, GnnLayer, GnnActivation, GnnAggregation,
12//! };
13//!
14//! // One hidden layer: 2-dim input -> 4-dim output, ReLU
15//! let layer = GnnLayer {
16//!     weights: vec![
17//!         vec![0.5, -0.3],
18//!         vec![-0.1,  0.8],
19//!         vec![ 0.2,  0.4],
20//!         vec![-0.6,  0.1],
21//!     ],
22//!     bias: vec![0.0, 0.0, 0.0, 0.0],
23//!     activation: GnnActivation::Relu,
24//! };
25//!
26//! let config = GnnConfig {
27//!     layers: vec![layer],
28//!     aggregation: GnnAggregation::Mean,
29//!     num_iterations: 2,
30//! };
31//!
32//! let mut gnn = GraphNeuralNetwork::new(config);
33//! let a = gnn.add_node(vec![1.0, 0.0]);
34//! let b = gnn.add_node(vec![0.0, 1.0]);
35//! gnn.add_edge(a, b, 1.0).expect("example: should succeed in docs");
36//!
37//! let embeddings = gnn.forward();
38//! assert_eq!(embeddings.len(), 2);
39//! assert_eq!(embeddings[0].len(), 4);
40//! ```
41
42use std::fmt;
43
44// ── Activation enum ──────────────────────────────────────────────────────────
45
46/// Activation function applied element-wise in a [`GnnLayer`].
47#[derive(Debug, Clone, PartialEq)]
48pub enum GnnActivation {
49    /// Rectified Linear Unit: max(0, x)
50    Relu,
51    /// Sigmoid: 1 / (1 + exp(-x))
52    Sigmoid,
53    /// Hyperbolic tangent.
54    Tanh,
55    /// Pass-through: f(x) = x
56    Linear,
57}
58
59impl GnnActivation {
60    /// Apply the activation function to a scalar value.
61    #[inline]
62    pub fn apply(&self, x: f64) -> f64 {
63        match self {
64            GnnActivation::Relu => x.max(0.0),
65            GnnActivation::Sigmoid => 1.0 / (1.0 + (-x).exp()),
66            GnnActivation::Tanh => x.tanh(),
67            GnnActivation::Linear => x,
68        }
69    }
70}
71
72// ── Aggregation enum ─────────────────────────────────────────────────────────
73
74/// Neighbour feature aggregation strategy used during message passing.
75///
76/// Note: named `GnnAggregation` (not `AggregationMethod`) to avoid collision
77/// with the same-named type already exported from the `gradient` module.
78#[derive(Debug, Clone, PartialEq)]
79pub enum GnnAggregation {
80    /// Weighted sum of neighbour features (weight from edge).
81    Sum,
82    /// Weighted sum divided by total edge weight; falls back to own features if
83    /// the node is isolated.
84    Mean,
85    /// Element-wise maximum across neighbours (unweighted); falls back to own
86    /// features if the node is isolated.
87    Max,
88}
89
90// ── Node / Edge primitives ───────────────────────────────────────────────────
91
92/// Newtype wrapper for node indices.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94pub struct GnnNodeId(pub usize);
95
96impl fmt::Display for GnnNodeId {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(f, "GnnNodeId({})", self.0)
99    }
100}
101
102/// Feature vector associated with a single node.
103#[derive(Debug, Clone)]
104pub struct NodeFeatures {
105    /// Identifier of the node.
106    pub id: GnnNodeId,
107    /// Feature vector of dimension D (must be consistent across all nodes).
108    pub features: Vec<f64>,
109}
110
111/// Directed edge in the graph with an optional scalar weight.
112#[derive(Debug, Clone)]
113pub struct GnnEdge {
114    /// Source node.
115    pub from: GnnNodeId,
116    /// Target node.
117    pub to: GnnNodeId,
118    /// Edge weight used for weighted aggregation strategies.
119    pub weight: f64,
120}
121
122// ── Layer ────────────────────────────────────────────────────────────────────
123
124/// A single linear + bias + activation layer inside the GNN.
125///
126/// `weights` is a matrix of shape `[output_dim × input_dim]`; each inner
127/// `Vec<f64>` is one output row.
128#[derive(Debug, Clone)]
129pub struct GnnLayer {
130    /// Weight matrix: `weights[out_row][in_col]`.
131    pub weights: Vec<Vec<f64>>,
132    /// Bias vector of length `output_dim`.
133    pub bias: Vec<f64>,
134    /// Activation applied after the linear transform.
135    pub activation: GnnActivation,
136}
137
138// ── Config ───────────────────────────────────────────────────────────────────
139
140/// Full configuration for a [`GraphNeuralNetwork`].
141#[derive(Debug, Clone)]
142pub struct GnnConfig {
143    /// Ordered sequence of layers applied to each node's representation.
144    pub layers: Vec<GnnLayer>,
145    /// Neighbour aggregation strategy.
146    pub aggregation: GnnAggregation,
147    /// Number of message-passing iterations.
148    pub num_iterations: usize,
149}
150
151// ── Error ────────────────────────────────────────────────────────────────────
152
153/// Errors that can arise when working with a [`GraphNeuralNetwork`].
154#[derive(Debug, Clone, PartialEq)]
155pub enum GnnError {
156    /// A referenced node index does not exist.
157    NodeNotFound(usize),
158    /// Layer input/output dimension mismatch.
159    DimensionMismatch {
160        /// Zero-based layer index.
161        layer: usize,
162        /// Expected dimension.
163        expected: usize,
164        /// Actual dimension encountered.
165        got: usize,
166    },
167    /// The graph contains no nodes.
168    EmptyGraph,
169}
170
171impl fmt::Display for GnnError {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        match self {
174            GnnError::NodeNotFound(id) => write!(f, "node not found: {id}"),
175            GnnError::DimensionMismatch {
176                layer,
177                expected,
178                got,
179            } => {
180                write!(
181                    f,
182                    "dimension mismatch at layer {layer}: expected {expected}, got {got}"
183                )
184            }
185            GnnError::EmptyGraph => write!(f, "the graph is empty"),
186        }
187    }
188}
189
190impl std::error::Error for GnnError {}
191
192// ── Stats ────────────────────────────────────────────────────────────────────
193
194/// Snapshot of graph and model statistics.
195#[derive(Debug, Clone)]
196pub struct GnnStats {
197    /// Number of nodes currently in the graph.
198    pub num_nodes: usize,
199    /// Number of directed edges (2 per undirected edge).
200    pub num_edges: usize,
201    /// Average out-degree across all nodes.
202    pub avg_degree: f64,
203    /// Dimension of the raw node feature vectors.
204    pub feature_dim: usize,
205    /// Dimensionality of the final embedding (output of last layer, or feature
206    /// dim if there are no layers).
207    pub output_dim: usize,
208    /// Total number of message-passing rounds executed via [`GraphNeuralNetwork::forward`].
209    pub trained_iterations: u64,
210}
211
212// ── Main struct ──────────────────────────────────────────────────────────────
213
214/// Message-passing Graph Neural Network.
215///
216/// Supports dynamic node/edge addition/removal, configurable aggregation
217/// strategies, and multi-layer propagation.
218pub struct GraphNeuralNetwork {
219    /// GNN configuration (layers, aggregation, iterations).
220    pub config: GnnConfig,
221    /// Node feature vectors; index matches the logical node id.
222    pub nodes: Vec<NodeFeatures>,
223    /// Adjacency list: `adjacency[node_idx]` = list of `(neighbour_id, weight)`.
224    pub adjacency: Vec<Vec<(GnnNodeId, f64)>>,
225    /// Cumulative message-passing iterations executed so far.
226    pub trained_iterations: u64,
227}
228
229impl GraphNeuralNetwork {
230    // ── Construction ─────────────────────────────────────────────────────────
231
232    /// Create a new, empty graph neural network with the given configuration.
233    pub fn new(config: GnnConfig) -> Self {
234        Self {
235            config,
236            nodes: Vec::new(),
237            adjacency: Vec::new(),
238            trained_iterations: 0,
239        }
240    }
241
242    // ── Graph modification ────────────────────────────────────────────────────
243
244    /// Append a new node with the provided feature vector.
245    ///
246    /// Returns the [`GnnNodeId`] assigned to the new node.
247    pub fn add_node(&mut self, features: Vec<f64>) -> GnnNodeId {
248        let id = GnnNodeId(self.nodes.len());
249        self.nodes.push(NodeFeatures { id, features });
250        self.adjacency.push(Vec::new());
251        id
252    }
253
254    /// Add an undirected edge between `from` and `to` with the given `weight`.
255    ///
256    /// Both directions are stored so that neighbour aggregation works
257    /// transparently for undirected graphs.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`GnnError::NodeNotFound`] if either node id is out of bounds.
262    pub fn add_edge(
263        &mut self,
264        from: GnnNodeId,
265        to: GnnNodeId,
266        weight: f64,
267    ) -> Result<(), GnnError> {
268        let n = self.nodes.len();
269        if from.0 >= n {
270            return Err(GnnError::NodeNotFound(from.0));
271        }
272        if to.0 >= n {
273            return Err(GnnError::NodeNotFound(to.0));
274        }
275        self.adjacency[from.0].push((to, weight));
276        self.adjacency[to.0].push((from, weight));
277        Ok(())
278    }
279
280    /// Remove the node with the given id together with all edges incident to it.
281    ///
282    /// Returns `true` if the node existed, `false` otherwise.
283    ///
284    /// **Note**: Removing a node renumbers all nodes with higher indices; any
285    /// previously obtained [`GnnNodeId`]s for those nodes become stale.
286    pub fn remove_node(&mut self, id: GnnNodeId) -> bool {
287        let idx = id.0;
288        if idx >= self.nodes.len() {
289            return false;
290        }
291
292        // Remove the node and its adjacency list.
293        self.nodes.remove(idx);
294        self.adjacency.remove(idx);
295
296        // Fix-up remaining adjacency lists:
297        // 1. Remove any edge that pointed to the deleted node.
298        // 2. Decrement indices of nodes that were renumbered (index > idx).
299        for adj in self.adjacency.iter_mut() {
300            adj.retain(|(nb, _)| nb.0 != idx);
301            for (nb, _) in adj.iter_mut() {
302                if nb.0 > idx {
303                    nb.0 -= 1;
304                }
305            }
306        }
307
308        // Fix node ids stored inside NodeFeatures.
309        for (i, node) in self.nodes.iter_mut().enumerate() {
310            node.id = GnnNodeId(i);
311        }
312
313        true
314    }
315
316    // ── Inference ─────────────────────────────────────────────────────────────
317
318    /// Run `num_iterations` rounds of message passing and return the final node
319    /// embeddings.
320    ///
321    /// Each iteration:
322    /// 1. Every node aggregates its neighbours' current feature vectors using
323    ///    the configured [`GnnAggregation`].
324    /// 2. The aggregated vector is passed through every [`GnnLayer`] in order.
325    ///
326    /// Returns one embedding `Vec<f64>` per node in node-index order.
327    pub fn forward(&self) -> Vec<Vec<f64>> {
328        if self.nodes.is_empty() {
329            return Vec::new();
330        }
331
332        // Initialise embeddings from raw features.
333        let mut embeddings: Vec<Vec<f64>> = self.nodes.iter().map(|n| n.features.clone()).collect();
334
335        for _ in 0..self.config.num_iterations {
336            let prev = embeddings.clone();
337            for (node_idx, emb) in embeddings.iter_mut().enumerate() {
338                let agg = self.aggregate_neighbors(GnnNodeId(node_idx), &prev);
339                let mut h = agg;
340                for layer in &self.config.layers {
341                    h = Self::apply_layer(&h, layer);
342                }
343                *emb = h;
344            }
345        }
346
347        embeddings
348    }
349
350    /// Aggregate the feature vectors of `node_id`'s neighbours according to
351    /// the configured aggregation strategy.
352    ///
353    /// Falls back to the node's own features when it has no neighbours.
354    pub fn aggregate_neighbors(&self, node_id: GnnNodeId, features: &[Vec<f64>]) -> Vec<f64> {
355        let neighbours = &self.adjacency[node_id.0];
356        if neighbours.is_empty() {
357            return features[node_id.0].clone();
358        }
359
360        let feat_dim = features[node_id.0].len();
361        // Guard against empty feature vectors.
362        if feat_dim == 0 {
363            return Vec::new();
364        }
365
366        match &self.config.aggregation {
367            GnnAggregation::Sum => {
368                let mut acc = vec![0.0f64; feat_dim];
369                for &(nb_id, weight) in neighbours {
370                    let nb_feat = &features[nb_id.0];
371                    let effective_dim = nb_feat.len().min(feat_dim);
372                    for i in 0..effective_dim {
373                        acc[i] += weight * nb_feat[i];
374                    }
375                }
376                acc
377            }
378            GnnAggregation::Mean => {
379                let mut acc = vec![0.0f64; feat_dim];
380                let mut total_weight = 0.0f64;
381                for &(nb_id, weight) in neighbours {
382                    let nb_feat = &features[nb_id.0];
383                    let effective_dim = nb_feat.len().min(feat_dim);
384                    for i in 0..effective_dim {
385                        acc[i] += weight * nb_feat[i];
386                    }
387                    total_weight += weight;
388                }
389                if total_weight.abs() > f64::EPSILON {
390                    for v in acc.iter_mut() {
391                        *v /= total_weight;
392                    }
393                } else {
394                    // All weights are zero or no neighbours: return own features.
395                    return features[node_id.0].clone();
396                }
397                acc
398            }
399            GnnAggregation::Max => {
400                let mut acc = vec![f64::NEG_INFINITY; feat_dim];
401                for &(nb_id, _weight) in neighbours {
402                    let nb_feat = &features[nb_id.0];
403                    let effective_dim = nb_feat.len().min(feat_dim);
404                    for i in 0..effective_dim {
405                        if nb_feat[i] > acc[i] {
406                            acc[i] = nb_feat[i];
407                        }
408                    }
409                }
410                // Replace any remaining -inf (dimension not covered by any neighbour)
411                // with the node's own feature value.
412                let own = &features[node_id.0];
413                for (i, slot) in acc.iter_mut().enumerate() {
414                    if *slot == f64::NEG_INFINITY {
415                        *slot = *own.get(i).unwrap_or(&0.0);
416                    }
417                }
418                acc
419            }
420        }
421    }
422
423    /// Apply a single layer (linear transform + bias + activation) to `input`.
424    ///
425    /// `layer.weights` must be `[output_dim × input_dim]`. Rows shorter than
426    /// `input` are zero-padded; inputs longer than any row are silently ignored
427    /// for that row (safe defaults for heterogeneous graphs).
428    pub fn apply_layer(input: &[f64], layer: &GnnLayer) -> Vec<f64> {
429        layer
430            .weights
431            .iter()
432            .zip(layer.bias.iter())
433            .map(|(row, &b)| {
434                let dot: f64 = row.iter().zip(input.iter()).map(|(&w, &x)| w * x).sum();
435                layer.activation.apply(dot + b)
436            })
437            .collect()
438    }
439
440    // ── Query helpers ─────────────────────────────────────────────────────────
441
442    /// Compute the embedding for a single node.
443    ///
444    /// # Errors
445    ///
446    /// - [`GnnError::EmptyGraph`] if the graph has no nodes.
447    /// - [`GnnError::NodeNotFound`] if `id` is out of range.
448    pub fn node_embedding(&self, id: GnnNodeId) -> Result<Vec<f64>, GnnError> {
449        if self.nodes.is_empty() {
450            return Err(GnnError::EmptyGraph);
451        }
452        if id.0 >= self.nodes.len() {
453            return Err(GnnError::NodeNotFound(id.0));
454        }
455        let embeddings = self.forward();
456        Ok(embeddings[id.0].clone())
457    }
458
459    /// Compute the graph-level embedding as the element-wise mean of all node
460    /// embeddings.
461    ///
462    /// Returns an empty vector if the graph has no nodes or if the node
463    /// embeddings are empty.
464    pub fn graph_embedding(&self) -> Vec<f64> {
465        if self.nodes.is_empty() {
466            return Vec::new();
467        }
468        let embeddings = self.forward();
469        if embeddings.is_empty() {
470            return Vec::new();
471        }
472        let dim = embeddings[0].len();
473        if dim == 0 {
474            return Vec::new();
475        }
476        let n = embeddings.len() as f64;
477        let mut mean = vec![0.0f64; dim];
478        for emb in &embeddings {
479            for (i, &v) in emb.iter().enumerate() {
480                if i < dim {
481                    mean[i] += v;
482                }
483            }
484        }
485        for v in mean.iter_mut() {
486            *v /= n;
487        }
488        mean
489    }
490
491    // ── Graph metadata ────────────────────────────────────────────────────────
492
493    /// Number of nodes in the graph.
494    pub fn num_nodes(&self) -> usize {
495        self.nodes.len()
496    }
497
498    /// Number of directed edges (2 per undirected edge).
499    pub fn num_edges(&self) -> usize {
500        self.adjacency.iter().map(|adj| adj.len()).sum()
501    }
502
503    /// Return a statistics snapshot for the current graph state.
504    pub fn stats(&self) -> GnnStats {
505        let num_nodes = self.nodes.len();
506        let num_edges = self.num_edges();
507        let avg_degree = if num_nodes > 0 {
508            num_edges as f64 / num_nodes as f64
509        } else {
510            0.0
511        };
512        let feature_dim = self.nodes.first().map(|n| n.features.len()).unwrap_or(0);
513        let output_dim = self
514            .config
515            .layers
516            .last()
517            .map(|l| l.bias.len())
518            .unwrap_or(feature_dim);
519        GnnStats {
520            num_nodes,
521            num_edges,
522            avg_degree,
523            feature_dim,
524            output_dim,
525            trained_iterations: self.trained_iterations,
526        }
527    }
528}
529
530// ── PRNG helper (test support) ────────────────────────────────────────────────
531
532/// Xorshift64 pseudo-random number generator for test vector generation.
533///
534/// This is intentionally not used in production code paths.
535pub fn xorshift64(state: &mut u64) -> u64 {
536    let mut x = *state;
537    x ^= x << 13;
538    x ^= x >> 7;
539    x ^= x << 17;
540    *state = x;
541    x
542}
543
544// ── Unit tests ────────────────────────────────────────────────────────────────
545
546#[cfg(test)]
547mod tests {
548    use crate::graph_neural_network::{
549        xorshift64, GnnActivation, GnnAggregation, GnnConfig, GnnError, GnnLayer, GnnNodeId,
550        GnnStats, GraphNeuralNetwork, NodeFeatures,
551    };
552
553    // ── helpers ───────────────────────────────────────────────────────────────
554
555    fn identity_layer(dim: usize) -> GnnLayer {
556        let weights: Vec<Vec<f64>> = (0..dim)
557            .map(|i| {
558                let mut row = vec![0.0f64; dim];
559                row[i] = 1.0;
560                row
561            })
562            .collect();
563        GnnLayer {
564            weights,
565            bias: vec![0.0; dim],
566            activation: GnnActivation::Linear,
567        }
568    }
569
570    fn linear_config(dim: usize, iters: usize) -> GnnConfig {
571        GnnConfig {
572            layers: vec![identity_layer(dim)],
573            aggregation: GnnAggregation::Mean,
574            num_iterations: iters,
575        }
576    }
577
578    fn two_node_gnn() -> GraphNeuralNetwork {
579        let config = linear_config(2, 1);
580        let mut gnn = GraphNeuralNetwork::new(config);
581        gnn.add_node(vec![1.0, 0.0]);
582        gnn.add_node(vec![0.0, 1.0]);
583        gnn.add_edge(GnnNodeId(0), GnnNodeId(1), 1.0)
584            .expect("test: should succeed");
585        gnn
586    }
587
588    fn relu_layer(in_dim: usize, out_dim: usize) -> GnnLayer {
589        // Use xorshift64 to generate pseudo-random weights in [-0.5, 0.5].
590        let mut state: u64 = 0xDEAD_BEEF_1234_5678;
591        let weights: Vec<Vec<f64>> = (0..out_dim)
592            .map(|_| {
593                (0..in_dim)
594                    .map(|_| {
595                        let r = xorshift64(&mut state);
596                        (r as f64 / u64::MAX as f64) - 0.5
597                    })
598                    .collect()
599            })
600            .collect();
601        GnnLayer {
602            weights,
603            bias: vec![0.0; out_dim],
604            activation: GnnActivation::Relu,
605        }
606    }
607
608    // ── Test 1: new() creates an empty graph ──────────────────────────────────
609
610    #[test]
611    fn test_new_empty_graph() {
612        let config = GnnConfig {
613            layers: vec![],
614            aggregation: GnnAggregation::Mean,
615            num_iterations: 1,
616        };
617        let gnn = GraphNeuralNetwork::new(config);
618        assert_eq!(gnn.num_nodes(), 0);
619        assert_eq!(gnn.num_edges(), 0);
620    }
621
622    // ── Test 2: add_node increments count ────────────────────────────────────
623
624    #[test]
625    fn test_add_node_increments_count() {
626        let mut gnn = GraphNeuralNetwork::new(linear_config(3, 1));
627        let id0 = gnn.add_node(vec![1.0, 2.0, 3.0]);
628        let id1 = gnn.add_node(vec![4.0, 5.0, 6.0]);
629        assert_eq!(id0.0, 0);
630        assert_eq!(id1.0, 1);
631        assert_eq!(gnn.num_nodes(), 2);
632    }
633
634    // ── Test 3: add_edge validates source node ────────────────────────────────
635
636    #[test]
637    fn test_add_edge_invalid_source() {
638        let mut gnn = GraphNeuralNetwork::new(linear_config(2, 1));
639        gnn.add_node(vec![1.0, 0.0]);
640        let result = gnn.add_edge(GnnNodeId(5), GnnNodeId(0), 1.0);
641        assert_eq!(result, Err(GnnError::NodeNotFound(5)));
642    }
643
644    // ── Test 4: add_edge validates destination node ───────────────────────────
645
646    #[test]
647    fn test_add_edge_invalid_dest() {
648        let mut gnn = GraphNeuralNetwork::new(linear_config(2, 1));
649        gnn.add_node(vec![1.0, 0.0]);
650        let result = gnn.add_edge(GnnNodeId(0), GnnNodeId(99), 1.0);
651        assert_eq!(result, Err(GnnError::NodeNotFound(99)));
652    }
653
654    // ── Test 5: add_edge is undirected ────────────────────────────────────────
655
656    #[test]
657    fn test_add_edge_undirected() {
658        let gnn = two_node_gnn();
659        // Each undirected edge contributes 2 directed entries.
660        assert_eq!(gnn.num_edges(), 2);
661    }
662
663    // ── Test 6: remove_node returns false for missing node ────────────────────
664
665    #[test]
666    fn test_remove_node_missing() {
667        let mut gnn = two_node_gnn();
668        assert!(!gnn.remove_node(GnnNodeId(99)));
669    }
670
671    // ── Test 7: remove_node removes node and edges ───────────────────────────
672
673    #[test]
674    fn test_remove_node_cleans_edges() {
675        let mut gnn = two_node_gnn();
676        assert!(gnn.remove_node(GnnNodeId(0)));
677        assert_eq!(gnn.num_nodes(), 1);
678        // The remaining node's adjacency list should be empty.
679        assert_eq!(gnn.num_edges(), 0);
680    }
681
682    // ── Test 8: remove_node renumbers correctly ───────────────────────────────
683
684    #[test]
685    fn test_remove_node_renumbers() {
686        let mut gnn = GraphNeuralNetwork::new(linear_config(2, 1));
687        gnn.add_node(vec![1.0, 0.0]);
688        gnn.add_node(vec![0.0, 1.0]);
689        gnn.add_node(vec![1.0, 1.0]);
690        gnn.remove_node(GnnNodeId(0));
691        // Previous node 1 is now node 0, previous node 2 is now node 1.
692        assert_eq!(gnn.nodes[0].id, GnnNodeId(0));
693        assert_eq!(gnn.nodes[1].id, GnnNodeId(1));
694    }
695
696    // ── Test 9: forward on empty graph returns empty vec ─────────────────────
697
698    #[test]
699    fn test_forward_empty_graph() {
700        let gnn = GraphNeuralNetwork::new(linear_config(2, 1));
701        let out = gnn.forward();
702        assert!(out.is_empty());
703    }
704
705    // ── Test 10: forward with identity layer preserves features ──────────────
706
707    #[test]
708    fn test_forward_identity_layer_single_node() {
709        let mut gnn = GraphNeuralNetwork::new(linear_config(3, 2));
710        gnn.add_node(vec![1.0, 2.0, 3.0]);
711        let out = gnn.forward();
712        assert_eq!(out.len(), 1);
713        // Isolated node: aggregation returns own features, identity preserves them.
714        assert!((out[0][0] - 1.0).abs() < 1e-9);
715        assert!((out[0][1] - 2.0).abs() < 1e-9);
716        assert!((out[0][2] - 3.0).abs() < 1e-9);
717    }
718
719    // ── Test 11: forward output dimension matches layer output ────────────────
720
721    #[test]
722    fn test_forward_output_dimension() {
723        let layer = relu_layer(3, 5);
724        let config = GnnConfig {
725            layers: vec![layer],
726            aggregation: GnnAggregation::Sum,
727            num_iterations: 1,
728        };
729        let mut gnn = GraphNeuralNetwork::new(config);
730        gnn.add_node(vec![1.0, 2.0, 3.0]);
731        let out = gnn.forward();
732        assert_eq!(out[0].len(), 5);
733    }
734
735    // ── Test 12: aggregate_neighbors Mean with equal weights ──────────────────
736
737    #[test]
738    fn test_aggregate_mean_equal_weights() {
739        let gnn = two_node_gnn();
740        let features = vec![vec![2.0, 4.0], vec![6.0, 8.0]];
741        // Mean of node-1's features with weight 1 → [6, 8]
742        let agg = gnn.aggregate_neighbors(GnnNodeId(0), &features);
743        assert!((agg[0] - 6.0).abs() < 1e-9);
744        assert!((agg[1] - 8.0).abs() < 1e-9);
745    }
746
747    // ── Test 13: aggregate_neighbors Sum ─────────────────────────────────────
748
749    #[test]
750    fn test_aggregate_sum() {
751        let config = GnnConfig {
752            layers: vec![],
753            aggregation: GnnAggregation::Sum,
754            num_iterations: 1,
755        };
756        let mut gnn = GraphNeuralNetwork::new(config);
757        gnn.add_node(vec![1.0, 0.0]);
758        gnn.add_node(vec![3.0, 4.0]);
759        gnn.add_edge(GnnNodeId(0), GnnNodeId(1), 2.0)
760            .expect("test: should succeed");
761
762        let features = vec![vec![1.0, 0.0], vec![3.0, 4.0]];
763        let agg = gnn.aggregate_neighbors(GnnNodeId(0), &features);
764        // weight=2.0, nb features=[3,4] → [6, 8]
765        assert!((agg[0] - 6.0).abs() < 1e-9);
766        assert!((agg[1] - 8.0).abs() < 1e-9);
767    }
768
769    // ── Test 14: aggregate_neighbors Max ─────────────────────────────────────
770
771    #[test]
772    fn test_aggregate_max() {
773        let config = GnnConfig {
774            layers: vec![],
775            aggregation: GnnAggregation::Max,
776            num_iterations: 1,
777        };
778        let mut gnn = GraphNeuralNetwork::new(config);
779        gnn.add_node(vec![1.0, 5.0]);
780        gnn.add_node(vec![3.0, 2.0]);
781        gnn.add_node(vec![0.5, 7.0]);
782        gnn.add_edge(GnnNodeId(0), GnnNodeId(1), 1.0)
783            .expect("test: should succeed");
784        gnn.add_edge(GnnNodeId(0), GnnNodeId(2), 1.0)
785            .expect("test: should succeed");
786
787        let features = vec![vec![1.0, 5.0], vec![3.0, 2.0], vec![0.5, 7.0]];
788        let agg = gnn.aggregate_neighbors(GnnNodeId(0), &features);
789        // Element-wise max of [3,2] and [0.5,7] → [3, 7]
790        assert!((agg[0] - 3.0).abs() < 1e-9);
791        assert!((agg[1] - 7.0).abs() < 1e-9);
792    }
793
794    // ── Test 15: isolated node returns own features in Mean ───────────────────
795
796    #[test]
797    fn test_isolated_node_mean_returns_own() {
798        let config = GnnConfig {
799            layers: vec![],
800            aggregation: GnnAggregation::Mean,
801            num_iterations: 1,
802        };
803        let mut gnn = GraphNeuralNetwork::new(config);
804        gnn.add_node(vec![9.0, 8.0]);
805        let features = vec![vec![9.0, 8.0]];
806        let agg = gnn.aggregate_neighbors(GnnNodeId(0), &features);
807        assert!((agg[0] - 9.0).abs() < 1e-9);
808        assert!((agg[1] - 8.0).abs() < 1e-9);
809    }
810
811    // ── Test 16: apply_layer – linear (no activation) ────────────────────────
812
813    #[test]
814    fn test_apply_layer_linear() {
815        let layer = GnnLayer {
816            weights: vec![vec![1.0, 2.0], vec![3.0, 4.0]],
817            bias: vec![0.5, -0.5],
818            activation: GnnActivation::Linear,
819        };
820        let input = vec![1.0, 1.0];
821        let out = GraphNeuralNetwork::apply_layer(&input, &layer);
822        // Row 0: 1*1 + 2*1 + 0.5 = 3.5
823        // Row 1: 3*1 + 4*1 - 0.5 = 6.5
824        assert!((out[0] - 3.5).abs() < 1e-9);
825        assert!((out[1] - 6.5).abs() < 1e-9);
826    }
827
828    // ── Test 17: apply_layer – ReLU clips negatives ───────────────────────────
829
830    #[test]
831    fn test_apply_layer_relu() {
832        let layer = GnnLayer {
833            weights: vec![vec![-1.0, 0.0], vec![1.0, 0.0]],
834            bias: vec![0.0, 0.0],
835            activation: GnnActivation::Relu,
836        };
837        let input = vec![1.0, 0.0];
838        let out = GraphNeuralNetwork::apply_layer(&input, &layer);
839        assert!((out[0] - 0.0).abs() < 1e-9); // -1 → clipped to 0
840        assert!((out[1] - 1.0).abs() < 1e-9);
841    }
842
843    // ── Test 18: apply_layer – Sigmoid output approaches (0,1) ──────────────
844
845    #[test]
846    fn test_apply_layer_sigmoid_range() {
847        let layer = GnnLayer {
848            weights: vec![vec![1.0]],
849            bias: vec![0.0],
850            activation: GnnActivation::Sigmoid,
851        };
852        // Sigmoid of large negative value should be very close to 0 but ≥ 0.
853        let out_neg = GraphNeuralNetwork::apply_layer(&[-20.0], &layer);
854        assert!(out_neg[0] >= 0.0 && out_neg[0] < 0.01);
855
856        // Sigmoid of large positive value should be very close to 1 but ≤ 1.
857        let out_pos = GraphNeuralNetwork::apply_layer(&[20.0], &layer);
858        assert!(out_pos[0] > 0.99 && out_pos[0] <= 1.0);
859
860        // Check symmetry around 0.
861        let mid = GraphNeuralNetwork::apply_layer(&[0.0], &layer);
862        assert!((mid[0] - 0.5).abs() < 1e-9);
863    }
864
865    // ── Test 19: apply_layer – Tanh output approaches (-1,1) ─────────────────
866
867    #[test]
868    fn test_apply_layer_tanh_range() {
869        let layer = GnnLayer {
870            weights: vec![vec![1.0]],
871            bias: vec![0.0],
872            activation: GnnActivation::Tanh,
873        };
874        // Tanh of large negative should be very close to -1 but ≥ -1.
875        let out_neg = GraphNeuralNetwork::apply_layer(&[-20.0], &layer);
876        assert!(out_neg[0] >= -1.0 && out_neg[0] < -0.99);
877
878        let out_zero = GraphNeuralNetwork::apply_layer(&[0.0], &layer);
879        assert!((out_zero[0]).abs() < 1e-9);
880
881        // Tanh of large positive should be very close to 1 but ≤ 1.
882        let out_pos = GraphNeuralNetwork::apply_layer(&[20.0], &layer);
883        assert!(out_pos[0] > 0.99 && out_pos[0] <= 1.0);
884    }
885
886    // ── Test 20: node_embedding returns correct node ──────────────────────────
887
888    #[test]
889    fn test_node_embedding_valid() {
890        let gnn = two_node_gnn();
891        let emb = gnn.node_embedding(GnnNodeId(0));
892        assert!(emb.is_ok());
893        assert_eq!(emb.expect("test: should succeed").len(), 2);
894    }
895
896    // ── Test 21: node_embedding errors on missing node ────────────────────────
897
898    #[test]
899    fn test_node_embedding_not_found() {
900        let gnn = two_node_gnn();
901        let result = gnn.node_embedding(GnnNodeId(10));
902        assert_eq!(result, Err(GnnError::NodeNotFound(10)));
903    }
904
905    // ── Test 22: node_embedding errors on empty graph ─────────────────────────
906
907    #[test]
908    fn test_node_embedding_empty_graph() {
909        let gnn = GraphNeuralNetwork::new(linear_config(2, 1));
910        let result = gnn.node_embedding(GnnNodeId(0));
911        assert_eq!(result, Err(GnnError::EmptyGraph));
912    }
913
914    // ── Test 23: graph_embedding returns empty vec for empty graph ────────────
915
916    #[test]
917    fn test_graph_embedding_empty() {
918        let gnn = GraphNeuralNetwork::new(linear_config(2, 1));
919        let emb = gnn.graph_embedding();
920        assert!(emb.is_empty());
921    }
922
923    // ── Test 24: graph_embedding dimension matches layer output ───────────────
924
925    #[test]
926    fn test_graph_embedding_dimension() {
927        let gnn = two_node_gnn();
928        let emb = gnn.graph_embedding();
929        assert_eq!(emb.len(), 2);
930    }
931
932    // ── Test 25: graph_embedding is mean of node embeddings ───────────────────
933
934    #[test]
935    fn test_graph_embedding_is_mean() {
936        let config = GnnConfig {
937            layers: vec![],
938            aggregation: GnnAggregation::Mean,
939            num_iterations: 0,
940        };
941        let mut gnn = GraphNeuralNetwork::new(config);
942        gnn.add_node(vec![2.0, 4.0]);
943        gnn.add_node(vec![6.0, 8.0]);
944
945        // With 0 iterations the embeddings stay as raw features.
946        let emb = gnn.graph_embedding();
947        assert!((emb[0] - 4.0).abs() < 1e-9);
948        assert!((emb[1] - 6.0).abs() < 1e-9);
949    }
950
951    // ── Test 26: num_edges counts correctly ───────────────────────────────────
952
953    #[test]
954    fn test_num_edges() {
955        let mut gnn = GraphNeuralNetwork::new(linear_config(2, 1));
956        let a = gnn.add_node(vec![1.0, 0.0]);
957        let b = gnn.add_node(vec![0.0, 1.0]);
958        let c = gnn.add_node(vec![1.0, 1.0]);
959        gnn.add_edge(a, b, 1.0).expect("test: should succeed");
960        gnn.add_edge(b, c, 1.0).expect("test: should succeed");
961        // 2 undirected edges → 4 directed entries.
962        assert_eq!(gnn.num_edges(), 4);
963    }
964
965    // ── Test 27: stats reflects correct values ────────────────────────────────
966
967    #[test]
968    fn test_stats() {
969        let mut gnn = GraphNeuralNetwork::new(linear_config(2, 1));
970        let a = gnn.add_node(vec![1.0, 0.0]);
971        let b = gnn.add_node(vec![0.0, 1.0]);
972        gnn.add_edge(a, b, 1.0).expect("test: should succeed");
973        let s: GnnStats = gnn.stats();
974        assert_eq!(s.num_nodes, 2);
975        assert_eq!(s.num_edges, 2);
976        assert!((s.avg_degree - 1.0).abs() < 1e-9);
977        assert_eq!(s.feature_dim, 2);
978        assert_eq!(s.output_dim, 2);
979    }
980
981    // ── Test 28: GnnError::DimensionMismatch display ──────────────────────────
982
983    #[test]
984    fn test_gnn_error_display() {
985        let e = GnnError::DimensionMismatch {
986            layer: 0,
987            expected: 4,
988            got: 3,
989        };
990        let s = format!("{e}");
991        assert!(s.contains("dimension mismatch"));
992        assert!(s.contains("layer 0"));
993    }
994
995    // ── Test 29: GnnNodeId display ────────────────────────────────────────────
996
997    #[test]
998    fn test_gnn_node_id_display() {
999        let id = GnnNodeId(42);
1000        assert_eq!(format!("{id}"), "GnnNodeId(42)");
1001    }
1002
1003    // ── Test 30: multi-layer forward propagation ──────────────────────────────
1004
1005    #[test]
1006    fn test_multi_layer_forward() {
1007        // Two stacked layers: 2→4→2
1008        let l1 = relu_layer(2, 4);
1009        let l2 = GnnLayer {
1010            weights: vec![vec![0.25, 0.25, 0.25, 0.25], vec![0.25, 0.25, 0.25, 0.25]],
1011            bias: vec![0.0, 0.0],
1012            activation: GnnActivation::Linear,
1013        };
1014        let config = GnnConfig {
1015            layers: vec![l1, l2],
1016            aggregation: GnnAggregation::Mean,
1017            num_iterations: 2,
1018        };
1019        let mut gnn = GraphNeuralNetwork::new(config);
1020        let a = gnn.add_node(vec![1.0, 0.0]);
1021        let b = gnn.add_node(vec![0.0, 1.0]);
1022        gnn.add_edge(a, b, 1.0).expect("test: should succeed");
1023        let out = gnn.forward();
1024        assert_eq!(out.len(), 2);
1025        assert_eq!(out[0].len(), 2);
1026        assert_eq!(out[1].len(), 2);
1027    }
1028
1029    // ── Test 31: xorshift64 never returns zero ────────────────────────────────
1030
1031    #[test]
1032    fn test_xorshift64_nonzero() {
1033        let mut state = 1u64;
1034        for _ in 0..10_000 {
1035            let v = xorshift64(&mut state);
1036            assert_ne!(v, 0);
1037        }
1038    }
1039
1040    // ── Test 32: xorshift64 state advances ────────────────────────────────────
1041
1042    #[test]
1043    fn test_xorshift64_state_advances() {
1044        let mut state = 0xABCD_1234u64;
1045        let v1 = xorshift64(&mut state);
1046        let v2 = xorshift64(&mut state);
1047        assert_ne!(v1, v2);
1048    }
1049
1050    // ── Test 33: GnnActivation::apply covers all variants ────────────────────
1051
1052    #[test]
1053    fn test_activation_apply_all_variants() {
1054        assert!((GnnActivation::Relu.apply(-5.0) - 0.0).abs() < 1e-9);
1055        assert!((GnnActivation::Relu.apply(3.0) - 3.0).abs() < 1e-9);
1056
1057        let sig = GnnActivation::Sigmoid.apply(0.0);
1058        assert!((sig - 0.5).abs() < 1e-9);
1059
1060        let t = GnnActivation::Tanh.apply(0.0);
1061        assert!(t.abs() < 1e-9);
1062
1063        assert!((GnnActivation::Linear.apply(7.77) - 7.77).abs() < 1e-9);
1064    }
1065
1066    // ── Test 34: zero-weight Mean falls back to own features ─────────────────
1067
1068    #[test]
1069    fn test_mean_zero_weight_fallback() {
1070        let config = GnnConfig {
1071            layers: vec![],
1072            aggregation: GnnAggregation::Mean,
1073            num_iterations: 1,
1074        };
1075        let mut gnn = GraphNeuralNetwork::new(config);
1076        gnn.add_node(vec![3.0, 7.0]);
1077        gnn.add_node(vec![1.0, 2.0]);
1078        // Add edge with zero weight.
1079        gnn.add_edge(GnnNodeId(0), GnnNodeId(1), 0.0)
1080            .expect("test: should succeed");
1081
1082        let features = vec![vec![3.0, 7.0], vec![1.0, 2.0]];
1083        let agg = gnn.aggregate_neighbors(GnnNodeId(0), &features);
1084        // Total weight = 0 → fallback to own features [3, 7]
1085        assert!((agg[0] - 3.0).abs() < 1e-9);
1086        assert!((agg[1] - 7.0).abs() < 1e-9);
1087    }
1088
1089    // ── Test 35: NodeFeatures struct accessible ───────────────────────────────
1090
1091    #[test]
1092    fn test_node_features_accessible() {
1093        let nf = NodeFeatures {
1094            id: GnnNodeId(3),
1095            features: vec![0.1, 0.2, 0.3],
1096        };
1097        assert_eq!(nf.id.0, 3);
1098        assert!((nf.features[2] - 0.3).abs() < 1e-9);
1099    }
1100
1101    // ── Test 36: large graph with random features ─────────────────────────────
1102
1103    #[test]
1104    fn test_large_graph_forward() {
1105        let layer = relu_layer(4, 8);
1106        let config = GnnConfig {
1107            layers: vec![layer],
1108            aggregation: GnnAggregation::Sum,
1109            num_iterations: 3,
1110        };
1111        let mut gnn = GraphNeuralNetwork::new(config);
1112        let mut state: u64 = 0x1234_5678_9ABC_DEF0;
1113
1114        // 20 nodes with random 4-dim features.
1115        for _ in 0..20 {
1116            let features: Vec<f64> = (0..4)
1117                .map(|_| (xorshift64(&mut state) as f64 / u64::MAX as f64) * 2.0 - 1.0)
1118                .collect();
1119            gnn.add_node(features);
1120        }
1121
1122        // Ring topology.
1123        for i in 0..20 {
1124            gnn.add_edge(GnnNodeId(i), GnnNodeId((i + 1) % 20), 1.0)
1125                .expect("test: should succeed");
1126        }
1127
1128        let out = gnn.forward();
1129        assert_eq!(out.len(), 20);
1130        for emb in &out {
1131            assert_eq!(emb.len(), 8);
1132            // ReLU → all outputs ≥ 0
1133            for &v in emb {
1134                assert!(v >= 0.0);
1135            }
1136        }
1137    }
1138
1139    // ── Test 37: remove_node then forward still works ─────────────────────────
1140
1141    #[test]
1142    fn test_forward_after_remove_node() {
1143        let mut gnn = GraphNeuralNetwork::new(linear_config(2, 1));
1144        gnn.add_node(vec![1.0, 0.0]);
1145        gnn.add_node(vec![0.0, 1.0]);
1146        gnn.add_node(vec![0.5, 0.5]);
1147        gnn.add_edge(GnnNodeId(0), GnnNodeId(1), 1.0)
1148            .expect("test: should succeed");
1149        gnn.add_edge(GnnNodeId(1), GnnNodeId(2), 1.0)
1150            .expect("test: should succeed");
1151        gnn.remove_node(GnnNodeId(1));
1152        assert_eq!(gnn.num_nodes(), 2);
1153        // Both remaining nodes are now isolated.
1154        assert_eq!(gnn.num_edges(), 0);
1155        let out = gnn.forward();
1156        assert_eq!(out.len(), 2);
1157    }
1158
1159    // ── Test 38: graph with self-loop (both endpoints identical) ─────────────
1160
1161    #[test]
1162    fn test_self_loop_treated_as_edge() {
1163        let config = GnnConfig {
1164            layers: vec![],
1165            aggregation: GnnAggregation::Sum,
1166            num_iterations: 1,
1167        };
1168        let mut gnn = GraphNeuralNetwork::new(config);
1169        gnn.add_node(vec![2.0, 3.0]);
1170        // Self-loop: from == to; add_edge should succeed.
1171        let res = gnn.add_edge(GnnNodeId(0), GnnNodeId(0), 1.0);
1172        assert!(res.is_ok());
1173    }
1174
1175    // ── Test 39: trained_iterations starts at zero ────────────────────────────
1176
1177    #[test]
1178    fn test_trained_iterations_initial() {
1179        let gnn = GraphNeuralNetwork::new(linear_config(2, 1));
1180        assert_eq!(gnn.trained_iterations, 0);
1181        let s = gnn.stats();
1182        assert_eq!(s.trained_iterations, 0);
1183    }
1184
1185    // ── Test 40: GnnError::NodeNotFound display ───────────────────────────────
1186
1187    #[test]
1188    fn test_gnn_error_node_not_found_display() {
1189        let e = GnnError::NodeNotFound(7);
1190        let s = format!("{e}");
1191        assert!(s.contains("7"));
1192    }
1193
1194    // ── Test 41: GnnError::EmptyGraph display ─────────────────────────────────
1195
1196    #[test]
1197    fn test_gnn_error_empty_graph_display() {
1198        let e = GnnError::EmptyGraph;
1199        let s = format!("{e}");
1200        assert!(s.contains("empty"));
1201    }
1202
1203    // ── Test 42: GnnError implements std::error::Error ───────────────────────
1204
1205    #[test]
1206    fn test_gnn_error_is_std_error() {
1207        let e: Box<dyn std::error::Error> = Box::new(GnnError::EmptyGraph);
1208        assert!(!e.to_string().is_empty());
1209    }
1210
1211    // ── Test 43: zero iterations forward returns raw features ─────────────────
1212
1213    #[test]
1214    fn test_zero_iterations_returns_raw_features() {
1215        let config = GnnConfig {
1216            layers: vec![],
1217            aggregation: GnnAggregation::Mean,
1218            num_iterations: 0,
1219        };
1220        let mut gnn = GraphNeuralNetwork::new(config);
1221        gnn.add_node(vec![5.0, 6.0]);
1222        gnn.add_node(vec![7.0, 8.0]);
1223        gnn.add_edge(GnnNodeId(0), GnnNodeId(1), 1.0)
1224            .expect("test: should succeed");
1225        let out = gnn.forward();
1226        // With 0 iterations embeddings equal raw features.
1227        assert!((out[0][0] - 5.0).abs() < 1e-9);
1228        assert!((out[1][1] - 8.0).abs() < 1e-9);
1229    }
1230
1231    // ── Test 44: weighted edge affects Mean aggregation proportionally ─────────
1232
1233    #[test]
1234    fn test_weighted_edge_mean() {
1235        let config = GnnConfig {
1236            layers: vec![],
1237            aggregation: GnnAggregation::Mean,
1238            num_iterations: 1,
1239        };
1240        let mut gnn = GraphNeuralNetwork::new(config);
1241        gnn.add_node(vec![0.0]);
1242        gnn.add_node(vec![2.0]);
1243        gnn.add_node(vec![8.0]);
1244        // Node 0 has two neighbours: node 1 with weight 1, node 2 with weight 3.
1245        gnn.add_edge(GnnNodeId(0), GnnNodeId(1), 1.0)
1246            .expect("test: should succeed");
1247        gnn.add_edge(GnnNodeId(0), GnnNodeId(2), 3.0)
1248            .expect("test: should succeed");
1249
1250        let features = vec![vec![0.0], vec![2.0], vec![8.0]];
1251        let agg = gnn.aggregate_neighbors(GnnNodeId(0), &features);
1252        // Weighted mean: (1*2 + 3*8) / (1+3) = 26/4 = 6.5
1253        assert!((agg[0] - 6.5).abs() < 1e-9);
1254    }
1255
1256    // ── Test 45: graph_embedding single node equals its embedding ─────────────
1257
1258    #[test]
1259    fn test_graph_embedding_single_node() {
1260        let config = GnnConfig {
1261            layers: vec![],
1262            aggregation: GnnAggregation::Mean,
1263            num_iterations: 0,
1264        };
1265        let mut gnn = GraphNeuralNetwork::new(config);
1266        gnn.add_node(vec![3.0, 9.0]);
1267        let ge = gnn.graph_embedding();
1268        let ne = gnn
1269            .node_embedding(GnnNodeId(0))
1270            .expect("test: should succeed");
1271        for (g, n) in ge.iter().zip(ne.iter()) {
1272            assert!((g - n).abs() < 1e-9);
1273        }
1274    }
1275
1276    // ── Test 46: stats output_dim reflects last layer ─────────────────────────
1277
1278    #[test]
1279    fn test_stats_output_dim_last_layer() {
1280        let l1 = relu_layer(2, 16);
1281        let l2 = relu_layer(16, 4);
1282        let config = GnnConfig {
1283            layers: vec![l1, l2],
1284            aggregation: GnnAggregation::Mean,
1285            num_iterations: 1,
1286        };
1287        let mut gnn = GraphNeuralNetwork::new(config);
1288        gnn.add_node(vec![1.0, 2.0]);
1289        let s = gnn.stats();
1290        assert_eq!(s.output_dim, 4);
1291    }
1292}