Skip to main content

oxirs_graphrag/gnn_encoder/
graphsage.rs

1//! GraphSAGE encoder with hand-rolled forward + backward passes.
2//!
3//! # Architecture
4//!
5//! Two GraphSAGE layers, each computing:
6//! ```text
7//! h_agg = MEAN({ h_u : u ∈ sample(N(v), K) })
8//! concat = CONCAT(h_v, h_agg)          // dim = 2 * in_dim
9//! h_v_new = ReLU( W @ concat + b )     // dim = out_dim
10//! ```
11//!
12//! Layer 1: `W1 ∈ ℝ^{hidden × 2·input}`, `b1 ∈ ℝ^{hidden}`
13//! Layer 2: `W2 ∈ ℝ^{output × 2·hidden}`, `b2 ∈ ℝ^{output}`
14//!
15//! # Training objective
16//!
17//! Unsupervised link-prediction with margin-ranking loss:
18//! ```text
19//! L = max(0, 1 − cos_sim(h_s, h_o+) + cos_sim(h_s, h_o−))
20//! ```
21//!
22//! Gradients are computed by hand-rolled chain rule and clipped to max-norm 1.0.
23
24use scirs2_core::ndarray_ext::Array2;
25use scirs2_core::random::rand_prelude::StdRng;
26use scirs2_core::random::{seeded_rng, CoreRandom};
27
28use super::aggregator::mean_aggregate;
29use super::sampler::sample_neighbours;
30
31/// Type alias for the three-tensor tuple returned by the cached forward pass:
32/// `(activations, pre_activations, neighbour_aggregates)`.
33type ForwardCache = (Vec<Vec<f64>>, Vec<Vec<f64>>, Vec<Vec<f64>>);
34
35// ─── Error ───────────────────────────────────────────────────────────────────
36
37/// Errors produced by the GraphSAGE encoder.
38#[derive(Debug, thiserror::Error)]
39pub enum GnnError {
40    #[error("dimension mismatch: {0}")]
41    DimMismatch(String),
42    #[error("empty graph: {0}")]
43    EmptyGraph(String),
44    #[error("invalid config: {0}")]
45    InvalidConfig(String),
46}
47
48pub type GnnResult<T> = Result<T, GnnError>;
49
50// ─── Public data structures ───────────────────────────────────────────────────
51
52/// Compact representation of a knowledge graph as adjacency lists.
53#[derive(Debug, Clone)]
54pub struct KgGraph {
55    /// Total number of nodes.
56    pub num_nodes: usize,
57    /// Directed edges as `(src, dst)` pairs.
58    pub edges: Vec<(usize, usize)>,
59    /// Input node features: shape `[num_nodes, feat_dim]`.
60    pub node_features: Array2<f64>,
61}
62
63/// Output entity embeddings produced by the encoder.
64#[derive(Debug, Clone)]
65pub struct EntityEmbeddings {
66    /// Embedding matrix: shape `[num_nodes, emb_dim]`.
67    pub embeddings: Array2<f64>,
68    /// Optional string identifiers for nodes (positionally aligned).
69    pub node_ids: Vec<String>,
70}
71
72/// Hyperparameter configuration for the GraphSAGE encoder.
73#[derive(Debug, Clone)]
74pub struct GraphSageConfig {
75    /// Dimension of the input node features.
76    pub input_dim: usize,
77    /// Hidden dimension after the first GraphSAGE layer.
78    pub hidden_dim: usize,
79    /// Output dimension after the second layer (= embedding dim).
80    pub output_dim: usize,
81    /// Number of layers (currently 2 is fully supported).
82    pub num_layers: usize,
83    /// Dropout probability (0.0 disables).
84    pub dropout: f64,
85    /// Maximum neighbours to sample per node per layer.
86    pub k_neighbors: usize,
87    /// SGD learning rate.
88    pub learning_rate: f64,
89}
90
91impl Default for GraphSageConfig {
92    fn default() -> Self {
93        Self {
94            input_dim: 64,
95            hidden_dim: 64,
96            output_dim: 64,
97            num_layers: 2,
98            dropout: 0.0,
99            k_neighbors: 10,
100            learning_rate: 0.01,
101        }
102    }
103}
104
105/// Training history produced by `GraphSageEncoder::train`.
106#[derive(Debug, Clone)]
107pub struct TrainingHistory {
108    /// Per-epoch mean link-prediction loss.
109    pub epoch_losses: Vec<f64>,
110    /// Loss in the final epoch.
111    pub final_loss: f64,
112}
113
114// ─── Internal weight storage ─────────────────────────────────────────────────
115
116/// Parameters for one GraphSAGE layer.
117///
118/// Projects `[2·in_dim] → [out_dim]` via `ReLU(W @ concat + b)`.
119#[derive(Debug, Clone)]
120struct SageLayer {
121    /// Weight matrix rows: shape `[out_dim][2 * in_dim]`.
122    w: Vec<Vec<f64>>,
123    /// Bias vector: shape `[out_dim]`.
124    b: Vec<f64>,
125    out_dim: usize,
126    in2_dim: usize, // = 2 * in_dim
127}
128
129impl SageLayer {
130    /// Xavier-uniform initialisation.
131    fn new_xavier(in_dim: usize, out_dim: usize, rng: &mut CoreRandom<StdRng>) -> Self {
132        let in2_dim = 2 * in_dim;
133        let fan_in = in2_dim;
134        let fan_out = out_dim;
135        let limit = (6.0_f64 / (fan_in + fan_out) as f64).sqrt();
136        let w: Vec<Vec<f64>> = (0..out_dim)
137            .map(|_| {
138                (0..in2_dim)
139                    .map(|_| {
140                        let u = rng.random_range(0.0_f64..1.0_f64);
141                        u * 2.0 * limit - limit
142                    })
143                    .collect()
144            })
145            .collect();
146        let b = vec![0.0_f64; out_dim];
147        Self {
148            w,
149            b,
150            out_dim,
151            in2_dim,
152        }
153    }
154
155    /// Forward: `ReLU(W @ [self_h ‖ agg_h] + b)`.
156    fn forward(&self, self_h: &[f64], agg_h: &[f64]) -> Vec<f64> {
157        debug_assert_eq!(self_h.len() + agg_h.len(), self.in2_dim);
158        let mut out = vec![0.0_f64; self.out_dim];
159        for (i, row) in self.w.iter().enumerate() {
160            let dot: f64 = row[..self_h.len()]
161                .iter()
162                .zip(self_h.iter())
163                .map(|(w, x)| w * x)
164                .sum::<f64>()
165                + row[self_h.len()..]
166                    .iter()
167                    .zip(agg_h.iter())
168                    .map(|(w, x)| w * x)
169                    .sum::<f64>();
170            out[i] = (dot + self.b[i]).max(0.0); // ReLU
171        }
172        out
173    }
174
175    /// Pre-activation (linear, no ReLU), used during backward pass.
176    fn pre_activation(&self, self_h: &[f64], agg_h: &[f64]) -> Vec<f64> {
177        debug_assert_eq!(self_h.len() + agg_h.len(), self.in2_dim);
178        let mut out = vec![0.0_f64; self.out_dim];
179        for (i, row) in self.w.iter().enumerate() {
180            let dot: f64 = row[..self_h.len()]
181                .iter()
182                .zip(self_h.iter())
183                .map(|(w, x)| w * x)
184                .sum::<f64>()
185                + row[self_h.len()..]
186                    .iter()
187                    .zip(agg_h.iter())
188                    .map(|(w, x)| w * x)
189                    .sum::<f64>();
190            out[i] = dot + self.b[i];
191        }
192        out
193    }
194}
195
196// ─── Encoder ─────────────────────────────────────────────────────────────────
197
198/// GraphSAGE encoder.
199pub struct GraphSageEncoder {
200    layer1: SageLayer,
201    layer2: SageLayer,
202    config: GraphSageConfig,
203    seed: u64,
204}
205
206impl GraphSageEncoder {
207    /// Create a new encoder with seed 42.
208    pub fn new(config: &GraphSageConfig) -> GnnResult<Self> {
209        Self::new_with_seed(config, 42)
210    }
211
212    /// Create a new encoder with an explicit seed for reproducibility.
213    pub fn new_with_seed(config: &GraphSageConfig, seed: u64) -> GnnResult<Self> {
214        if config.input_dim == 0 {
215            return Err(GnnError::InvalidConfig("input_dim must be > 0".into()));
216        }
217        if config.hidden_dim == 0 {
218            return Err(GnnError::InvalidConfig("hidden_dim must be > 0".into()));
219        }
220        if config.output_dim == 0 {
221            return Err(GnnError::InvalidConfig("output_dim must be > 0".into()));
222        }
223        let mut rng = seeded_rng(seed);
224        let layer1 = SageLayer::new_xavier(config.input_dim, config.hidden_dim, &mut rng);
225        let layer2 = SageLayer::new_xavier(config.hidden_dim, config.output_dim, &mut rng);
226        Ok(Self {
227            layer1,
228            layer2,
229            config: config.clone(),
230            seed,
231        })
232    }
233
234    // ─── Forward pass ─────────────────────────────────────────────────────
235
236    /// Encode all nodes in `graph` and return entity embeddings.
237    ///
238    /// A fresh RNG derived from the stored seed is created on each call, so
239    /// repeated calls on the same encoder produce identical results.
240    pub fn encode(&self, graph: &KgGraph) -> GnnResult<EntityEmbeddings> {
241        if graph.num_nodes == 0 {
242            return Err(GnnError::EmptyGraph("graph has no nodes".into()));
243        }
244        let feat_rows = graph.node_features.nrows();
245        if feat_rows != graph.num_nodes {
246            return Err(GnnError::DimMismatch(format!(
247                "node_features has {} rows but num_nodes = {}",
248                feat_rows, graph.num_nodes
249            )));
250        }
251        let feat_dim = graph.node_features.ncols();
252        if feat_dim != self.config.input_dim {
253            return Err(GnnError::DimMismatch(format!(
254                "node_features has {} cols but config.input_dim = {}",
255                feat_dim, self.config.input_dim
256            )));
257        }
258
259        let input_h: Vec<Vec<f64>> = (0..graph.num_nodes)
260            .map(|i| graph.node_features.row(i).to_vec())
261            .collect();
262
263        let mut rng1 = seeded_rng(self.seed.wrapping_add(1));
264        let h1 = self.sage_layer_forward(&self.layer1, &input_h, graph, &mut rng1);
265
266        let mut rng2 = seeded_rng(self.seed.wrapping_add(2));
267        let h2 = self.sage_layer_forward(&self.layer2, &h1, graph, &mut rng2);
268
269        let out_dim = self.config.output_dim;
270        let mut embeddings = Array2::zeros((graph.num_nodes, out_dim));
271        for (i, row) in h2.iter().enumerate() {
272            for (j, &v) in row.iter().enumerate() {
273                embeddings[[i, j]] = v;
274            }
275        }
276
277        Ok(EntityEmbeddings {
278            embeddings,
279            node_ids: (0..graph.num_nodes).map(|i| i.to_string()).collect(),
280        })
281    }
282
283    /// Run one GraphSAGE layer forward pass over all nodes.
284    fn sage_layer_forward(
285        &self,
286        layer: &SageLayer,
287        h_prev: &[Vec<f64>],
288        graph: &KgGraph,
289        rng: &mut CoreRandom<StdRng>,
290    ) -> Vec<Vec<f64>> {
291        let in_dim = if h_prev.is_empty() {
292            0
293        } else {
294            h_prev[0].len()
295        };
296        let zero_agg = vec![0.0_f64; in_dim];
297
298        (0..graph.num_nodes)
299            .map(|v| {
300                let neighbours = sample_neighbours(v, &graph.edges, self.config.k_neighbors, rng);
301                let agg = if neighbours.is_empty() {
302                    zero_agg.clone()
303                } else {
304                    let nb_embs: Vec<Vec<f64>> =
305                        neighbours.iter().map(|&u| h_prev[u].clone()).collect();
306                    mean_aggregate(&nb_embs)
307                };
308                layer.forward(&h_prev[v], &agg)
309            })
310            .collect()
311    }
312
313    // ─── Training ─────────────────────────────────────────────────────────
314
315    /// Train the encoder for `num_epochs` using link-prediction loss.
316    pub fn train(&mut self, graph: &KgGraph, num_epochs: usize) -> GnnResult<TrainingHistory> {
317        if graph.num_nodes < 2 {
318            return Err(GnnError::EmptyGraph(
319                "need at least 2 nodes for training".into(),
320            ));
321        }
322        if graph.edges.is_empty() {
323            return Err(GnnError::EmptyGraph(
324                "no edges to form positive pairs".into(),
325            ));
326        }
327
328        let mut epoch_losses = Vec::with_capacity(num_epochs);
329        let mut rng = seeded_rng(self.seed.wrapping_add(100));
330
331        for _ in 0..num_epochs {
332            // Forward pass, layer 1.
333            let input_h: Vec<Vec<f64>> = (0..graph.num_nodes)
334                .map(|i| graph.node_features.row(i).to_vec())
335                .collect();
336
337            let (h1, pre1, agg1) = self.forward_with_cache(
338                self.config.k_neighbors,
339                &self.layer1,
340                &input_h,
341                graph,
342                &mut rng,
343            );
344
345            // Forward pass, layer 2.
346            let (h2, pre2, agg2) = self.forward_with_cache(
347                self.config.k_neighbors,
348                &self.layer2,
349                &h1,
350                graph,
351                &mut rng,
352            );
353
354            // Sample positive pair.
355            let pos_idx = {
356                let n = graph.edges.len();
357                let u = rng.random_range(0.0_f64..1.0_f64);
358                (u * n as f64) as usize % n
359            };
360            let (s, o_pos) = graph.edges[pos_idx];
361
362            // Sample negative (not a neighbour of s).
363            let o_neg = self.sample_negative(s, graph, &mut rng);
364
365            let sim_pos = cosine_sim(&h2[s], &h2[o_pos]);
366            let sim_neg = cosine_sim(&h2[s], &h2[o_neg]);
367            let margin = 1.0_f64 - sim_pos + sim_neg;
368            let loss = margin.max(0.0);
369            epoch_losses.push(loss);
370
371            if loss <= 0.0 {
372                continue;
373            }
374
375            // Backward pass.
376            let (grad_s, grad_opos, grad_oneg) = cosine_sim_grads(&h2[s], &h2[o_pos], &h2[o_neg]);
377
378            let mut dl_dh2 = vec![vec![0.0_f64; self.config.output_dim]; graph.num_nodes];
379            add_vec(&mut dl_dh2[s], &grad_s);
380            add_vec(&mut dl_dh2[o_pos], &grad_opos);
381            add_vec(&mut dl_dh2[o_neg], &grad_oneg);
382
383            let (dw2, db2, dl_dh1) = backward_layer(
384                &self.layer2,
385                &dl_dh2,
386                &h1,
387                &pre2,
388                &agg2,
389                graph.num_nodes,
390                self.config.hidden_dim,
391            );
392
393            let (dw1, db1, _) = backward_layer(
394                &self.layer1,
395                &dl_dh1,
396                &input_h,
397                &pre1,
398                &agg1,
399                graph.num_nodes,
400                self.config.input_dim,
401            );
402
403            let lr = self.config.learning_rate;
404            apply_grad_2d(&mut self.layer2.w, &dw2, lr);
405            apply_grad_1d(&mut self.layer2.b, &db2, lr);
406            apply_grad_2d(&mut self.layer1.w, &dw1, lr);
407            apply_grad_1d(&mut self.layer1.b, &db1, lr);
408        }
409
410        let final_loss = epoch_losses.last().copied().unwrap_or(0.0);
411        Ok(TrainingHistory {
412            epoch_losses,
413            final_loss,
414        })
415    }
416
417    // ─── Internal helpers ─────────────────────────────────────────────────
418
419    /// Forward pass saving pre-activations and aggregations for backprop.
420    fn forward_with_cache(
421        &self,
422        k: usize,
423        layer: &SageLayer,
424        h_prev: &[Vec<f64>],
425        graph: &KgGraph,
426        rng: &mut CoreRandom<StdRng>,
427    ) -> ForwardCache {
428        let in_dim = if h_prev.is_empty() {
429            0
430        } else {
431            h_prev[0].len()
432        };
433        let zero_agg = vec![0.0_f64; in_dim];
434
435        let mut h_out = Vec::with_capacity(graph.num_nodes);
436        let mut pre_acts = Vec::with_capacity(graph.num_nodes);
437        let mut aggs = Vec::with_capacity(graph.num_nodes);
438
439        for v in 0..graph.num_nodes {
440            let neighbours = sample_neighbours(v, &graph.edges, k, rng);
441            let agg = if neighbours.is_empty() {
442                zero_agg.clone()
443            } else {
444                let nb_embs: Vec<Vec<f64>> =
445                    neighbours.iter().map(|&u| h_prev[u].clone()).collect();
446                mean_aggregate(&nb_embs)
447            };
448            let pre = layer.pre_activation(&h_prev[v], &agg);
449            let out: Vec<f64> = pre.iter().map(|&z| z.max(0.0)).collect();
450            pre_acts.push(pre);
451            aggs.push(agg);
452            h_out.push(out);
453        }
454        (h_out, pre_acts, aggs)
455    }
456
457    /// Sample a node that is not a neighbour of `src`.
458    fn sample_negative(&self, src: usize, graph: &KgGraph, rng: &mut CoreRandom<StdRng>) -> usize {
459        let neighbours: std::collections::HashSet<usize> = graph
460            .edges
461            .iter()
462            .filter_map(|&(s, d)| if s == src { Some(d) } else { None })
463            .collect();
464        for _ in 0..200 {
465            let u = rng.random_range(0.0_f64..1.0_f64);
466            let candidate = (u * graph.num_nodes as f64) as usize % graph.num_nodes;
467            if candidate != src && !neighbours.contains(&candidate) {
468                return candidate;
469            }
470        }
471        // Fallback: first node that is not src and not a neighbour.
472        for c in 0..graph.num_nodes {
473            if c != src && !neighbours.contains(&c) {
474                return c;
475            }
476        }
477        (src + 1) % graph.num_nodes
478    }
479
480    // ─── Test-helpers ─────────────────────────────────────────────────────
481
482    /// Return `(analytic_grad_of_W1[0][0], W1[0][0])` for a fixed triple
483    /// `(0 → 1)` as positive pair.  Intended for finite-difference checks.
484    pub fn compute_grad_and_param_for_test(&mut self, graph: &KgGraph) -> (f64, f64) {
485        let mut rng = seeded_rng(self.seed.wrapping_add(100));
486
487        let input_h: Vec<Vec<f64>> = (0..graph.num_nodes)
488            .map(|i| graph.node_features.row(i).to_vec())
489            .collect();
490        let (h1, pre1, agg1) = self.forward_with_cache(
491            self.config.k_neighbors,
492            &self.layer1,
493            &input_h,
494            graph,
495            &mut rng,
496        );
497        let (h2, pre2, agg2) =
498            self.forward_with_cache(self.config.k_neighbors, &self.layer2, &h1, graph, &mut rng);
499
500        let s = 0_usize;
501        let o_pos = 1_usize;
502        let o_neg = self.sample_negative(s, graph, &mut rng);
503
504        let margin = 1.0 - cosine_sim(&h2[s], &h2[o_pos]) + cosine_sim(&h2[s], &h2[o_neg]);
505        if margin <= 0.0 {
506            return (0.0, self.layer1.w[0][0]);
507        }
508
509        let (grad_s, grad_opos, grad_oneg) = cosine_sim_grads(&h2[s], &h2[o_pos], &h2[o_neg]);
510
511        let mut dl_dh2 = vec![vec![0.0_f64; self.config.output_dim]; graph.num_nodes];
512        add_vec(&mut dl_dh2[s], &grad_s);
513        add_vec(&mut dl_dh2[o_pos], &grad_opos);
514        add_vec(&mut dl_dh2[o_neg], &grad_oneg);
515
516        let (_dw2, _db2, dl_dh1) = backward_layer(
517            &self.layer2,
518            &dl_dh2,
519            &h1,
520            &pre2,
521            &agg2,
522            graph.num_nodes,
523            self.config.hidden_dim,
524        );
525
526        let (dw1, _db1, _) = backward_layer(
527            &self.layer1,
528            &dl_dh1,
529            &input_h,
530            &pre1,
531            &agg1,
532            graph.num_nodes,
533            self.config.input_dim,
534        );
535
536        (dw1[0][0], self.layer1.w[0][0])
537    }
538
539    /// Compute loss with `W1[0][0]` perturbed by `eps`, then restore.
540    /// Uses the same RNG seed as `compute_grad_and_param_for_test`.
541    pub fn compute_loss_with_perturb(&mut self, graph: &KgGraph, eps: f64) -> f64 {
542        self.layer1.w[0][0] += eps;
543
544        let mut rng = seeded_rng(self.seed.wrapping_add(100));
545        let input_h: Vec<Vec<f64>> = (0..graph.num_nodes)
546            .map(|i| graph.node_features.row(i).to_vec())
547            .collect();
548        let (h1, _, _) = self.forward_with_cache(
549            self.config.k_neighbors,
550            &self.layer1,
551            &input_h,
552            graph,
553            &mut rng,
554        );
555        let (h2, _, _) =
556            self.forward_with_cache(self.config.k_neighbors, &self.layer2, &h1, graph, &mut rng);
557
558        let o_neg = self.sample_negative(0, graph, &mut rng);
559        let sim_pos = cosine_sim(&h2[0], &h2[1]);
560        let sim_neg = cosine_sim(&h2[0], &h2[o_neg]);
561        let loss = (1.0 - sim_pos + sim_neg).max(0.0);
562
563        self.layer1.w[0][0] -= eps;
564        loss
565    }
566}
567
568// ─── Free functions ───────────────────────────────────────────────────────────
569
570/// Cosine similarity (returns 0 when either vector is near-zero).
571fn cosine_sim(a: &[f64], b: &[f64]) -> f64 {
572    let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
573    let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
574    let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
575    if na < 1e-12 || nb < 1e-12 {
576        0.0
577    } else {
578        dot / (na * nb)
579    }
580}
581
582/// Gradient of cosine similarity w.r.t. the first argument `a`.
583fn cos_grad_a(a: &[f64], b: &[f64]) -> Vec<f64> {
584    let dim = a.len();
585    let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
586    let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
587    let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
588    if na < 1e-12 || nb < 1e-12 {
589        return vec![0.0; dim];
590    }
591    let sim = dot / (na * nb);
592    a.iter()
593        .zip(b.iter())
594        .map(|(&ai, &bi)| bi / (na * nb) - ai * sim / (na * na))
595        .collect()
596}
597
598/// Gradient of cosine similarity w.r.t. the second argument `b`.
599fn cos_grad_b(a: &[f64], b: &[f64]) -> Vec<f64> {
600    cos_grad_a(b, a)
601}
602
603/// Compute embedding-space gradients for the margin-ranking loss.
604///
605/// Loss = max(0, 1 − sim(s, o+) + sim(s, o−)).
606/// Returns `(grad_s, grad_opos, grad_oneg)`.
607fn cosine_sim_grads(h_s: &[f64], h_opos: &[f64], h_oneg: &[f64]) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
608    let dim = h_s.len();
609
610    // dL/d_h_s = -d_sim_pos/d_h_s + d_sim_neg/d_h_s
611    let d_simpos_ds = cos_grad_a(h_s, h_opos);
612    let d_simneg_ds = cos_grad_a(h_s, h_oneg);
613    let mut grad_s = vec![0.0_f64; dim];
614    for i in 0..dim {
615        grad_s[i] = -d_simpos_ds[i] + d_simneg_ds[i];
616    }
617
618    // dL/d_h_opos = -d_sim_pos/d_h_opos
619    let grad_opos: Vec<f64> = cos_grad_b(h_s, h_opos).into_iter().map(|g| -g).collect();
620
621    // dL/d_h_oneg = +d_sim_neg/d_h_oneg
622    let grad_oneg = cos_grad_b(h_s, h_oneg);
623
624    (grad_s, grad_opos, grad_oneg)
625}
626
627/// Element-wise in-place addition.
628fn add_vec(dst: &mut [f64], src: &[f64]) {
629    for (d, &s) in dst.iter_mut().zip(src.iter()) {
630        *d += s;
631    }
632}
633
634/// Compute gradients for one GraphSAGE layer via chain rule.
635///
636/// Returns `(dW, db, dl_dh_prev)`.
637fn backward_layer(
638    layer: &SageLayer,
639    dl_dh: &[Vec<f64>],    // [num_nodes, out_dim]
640    h_prev: &[Vec<f64>],   // [num_nodes, in_dim]
641    pre_acts: &[Vec<f64>], // [num_nodes, out_dim]
642    aggs: &[Vec<f64>],     // [num_nodes, in_dim]
643    num_nodes: usize,
644    in_dim: usize,
645) -> (Vec<Vec<f64>>, Vec<f64>, Vec<Vec<f64>>) {
646    let out_dim = layer.out_dim;
647    let in2 = layer.in2_dim;
648
649    let mut dw = vec![vec![0.0_f64; in2]; out_dim];
650    let mut db = vec![0.0_f64; out_dim];
651    let mut dl_dh_prev = vec![vec![0.0_f64; in_dim]; num_nodes];
652
653    for v in 0..num_nodes {
654        // ReLU mask.
655        let d_pre: Vec<f64> = dl_dh[v]
656            .iter()
657            .zip(pre_acts[v].iter())
658            .map(|(&g, &z)| if z > 0.0 { g } else { 0.0 })
659            .collect();
660
661        let self_h = &h_prev[v];
662        let agg_h = &aggs[v];
663
664        for (i, &dp) in d_pre.iter().enumerate() {
665            for (j, &sh) in self_h.iter().enumerate() {
666                dw[i][j] += dp * sh;
667            }
668            for (j, &ah) in agg_h.iter().enumerate() {
669                dw[i][in_dim + j] += dp * ah;
670            }
671            db[i] += dp;
672        }
673
674        // Gradient into h_prev (through self-embedding part of concat).
675        for (j, dh) in dl_dh_prev[v].iter_mut().enumerate() {
676            for (i, &dp) in d_pre.iter().enumerate() {
677                *dh += layer.w[i][j] * dp;
678            }
679        }
680    }
681
682    (dw, db, dl_dh_prev)
683}
684
685/// Clip 2-D gradient to max-norm 1.0 and apply SGD update.
686fn apply_grad_2d(w: &mut [Vec<f64>], raw_grad: &[Vec<f64>], lr: f64) {
687    let norm_sq: f64 = raw_grad
688        .iter()
689        .flat_map(|row| row.iter())
690        .map(|&g| g * g)
691        .sum();
692    let norm = norm_sq.sqrt();
693    let scale = if norm > 1.0 { 1.0 / norm } else { 1.0 };
694    for (row, grow) in w.iter_mut().zip(raw_grad.iter()) {
695        for (wi, &gi) in row.iter_mut().zip(grow.iter()) {
696            *wi -= lr * gi * scale;
697        }
698    }
699}
700
701/// Clip 1-D gradient to max-norm 1.0 and apply SGD update.
702fn apply_grad_1d(b: &mut [f64], raw_grad: &[f64], lr: f64) {
703    let norm_sq: f64 = raw_grad.iter().map(|&g| g * g).sum();
704    let norm = norm_sq.sqrt();
705    let scale = if norm > 1.0 { 1.0 / norm } else { 1.0 };
706    for (bi, &gi) in b.iter_mut().zip(raw_grad.iter()) {
707        *bi -= lr * gi * scale;
708    }
709}
710
711// ─── Unit tests ───────────────────────────────────────────────────────────────
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use scirs2_core::ndarray_ext::Array2;
717
718    fn tiny_graph() -> KgGraph {
719        KgGraph {
720            num_nodes: 4,
721            edges: vec![(0, 1), (1, 2), (2, 3), (3, 0)],
722            node_features: Array2::zeros((4, 4)),
723        }
724    }
725
726    fn tiny_config() -> GraphSageConfig {
727        GraphSageConfig {
728            input_dim: 4,
729            hidden_dim: 4,
730            output_dim: 4,
731            num_layers: 2,
732            dropout: 0.0,
733            k_neighbors: 2,
734            learning_rate: 0.01,
735        }
736    }
737
738    #[test]
739    fn test_cosine_sim_identical() {
740        let v = vec![1.0, 2.0, 3.0];
741        let s = cosine_sim(&v, &v);
742        assert!((s - 1.0).abs() < 1e-10);
743    }
744
745    #[test]
746    fn test_cosine_sim_zero_vec() {
747        let a = vec![0.0, 0.0];
748        let b = vec![1.0, 0.0];
749        assert_eq!(cosine_sim(&a, &b), 0.0);
750    }
751
752    #[test]
753    fn test_forward_shape() {
754        let graph = tiny_graph();
755        let config = tiny_config();
756        let enc = GraphSageEncoder::new_with_seed(&config, 99).expect("construct");
757        let emb = enc.encode(&graph).expect("encode");
758        assert_eq!(emb.embeddings.nrows(), 4);
759        assert_eq!(emb.embeddings.ncols(), 4);
760    }
761
762    #[test]
763    fn test_reject_zero_dim() {
764        let mut cfg = tiny_config();
765        cfg.input_dim = 0;
766        assert!(GraphSageEncoder::new(&cfg).is_err());
767    }
768
769    #[test]
770    fn test_reject_feat_dim_mismatch() {
771        let config = tiny_config(); // input_dim = 4
772        let enc = GraphSageEncoder::new_with_seed(&config, 1).expect("construct");
773        let bad_graph = KgGraph {
774            num_nodes: 2,
775            edges: vec![(0, 1)],
776            node_features: Array2::zeros((2, 8)),
777        };
778        assert!(enc.encode(&bad_graph).is_err());
779    }
780}