Skip to main content

optirs_learned/
gnn_optimizer.rs

1//! Message-Passing Graph Neural Network Optimizer
2//!
3//! This module implements a learned optimizer whose update rule is computed by a
4//! message-passing graph neural network (GNN) with a gated recurrent (GRU) node
5//! update, in the spirit of Gated Graph Neural Networks (Li et al., 2016) applied
6//! to the "learning to optimize" setting (Andrychowicz et al., 2016).
7//!
8//! # Overview
9//!
10//! The optimizer treats the flat parameter vector as the nodes of a graph:
11//!
12//! 1. **Graph construction.** The parameter vector is partitioned into
13//!    `num_nodes` contiguous chunks (clamped to the parameter count). Each chunk
14//!    becomes a graph node that aggregates gradient statistics over its slice. The
15//!    inter-node adjacency is one of [`GraphTopology::Chain`],
16//!    [`GraphTopology::FullyConnected`], or [`GraphTopology::KNearest`].
17//! 2. **Node features.** For every node, per step, we recompute mean / abs-mean
18//!    gradient, gradient variance, the bias-corrected EMA of the gradient
19//!    (momentum), the bias-corrected EMA of the squared gradient (RMS), the sign
20//!    agreement of the chunk, and a running summary of the node's recent updates.
21//! 3. **Message passing.** For `num_message_rounds` rounds, every node maps its
22//!    persistent hidden state through a learned linear map plus nonlinearity to
23//!    produce a message, aggregates incoming neighbour messages (mean or sum), and
24//!    updates its hidden state through a real GRU cell (reset gate, update gate,
25//!    candidate, convex blend).
26//! 4. **Readout.** Each node's final hidden state is mapped through a learned
27//!    linear layer and a logistic to a strictly-positive per-node step scale in
28//!    `(0, 1)`. The parameter update applies
29//!    `params[chunk] -= base_lr * step_scale * effective_grad`, where
30//!    `effective_grad` is the bias-corrected EMA-smoothed gradient.
31//!
32//! The learned weights (feature encoder, message map, GRU weights
33//! `W_z, U_z, W_r, U_r, W_h, U_h`, and readout) are initialised deterministically
34//! from a seed via [`mod@scirs2_core::random`] and held fixed during [`AdvancedOptimizer::step`]
35//! (this is the genuine forward / inference optimizer; the persistent per-node
36//! EMAs and GRU hidden vectors evolve across steps). The architecture is laid out
37//! so that meta-training operates purely through the flat weight-vector surface
38//! in [`meta_training`]: see [`GnnOptimizer::weight_vector`],
39//! [`GnnOptimizer::set_weight_vector`] and [`GnnOptimizer::reset_state`], driven
40//! by [`crate::es_meta_training::EsMetaTrainer`].
41
42/// Meta-training of the learned weights by evolution strategies.
43///
44/// F75: everything below computes a real update from real learned weights, but
45/// nothing ever *trained* those weights — they stayed at their seeded draw
46/// forever. [`meta_training::GnnMetaTrainer`] is the entry point that changes
47/// that, and it operates through the public
48/// [`GnnOptimizer::weight_vector`] / [`GnnOptimizer::set_weight_vector`] /
49/// [`GnnOptimizer::reset_state`] surface it defines.
50pub mod meta_training;
51
52use scirs2_core::ndarray::{s, Array1, Array2};
53use scirs2_core::numeric::Float;
54use scirs2_core::random::{Random, Rng};
55use std::fmt::Debug;
56
57use crate::domain_optimizers::{clip_grad_norm, l2_norm, AdvancedOptimizer, OptimizerStateInfo};
58use crate::error::{OptimError, Result};
59
60/// Number of scalar features extracted per graph node, every step.
61///
62/// The features are, in order: mean gradient, abs-mean gradient, gradient
63/// variance, momentum (bias-corrected gradient EMA), RMS (sqrt of bias-corrected
64/// squared-gradient EMA), sign agreement, and the update-history summary.
65const NODE_FEATURE_DIM: usize = 7;
66
67/// Connectivity used to build the inter-node (parameter/layer) graph.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum GraphTopology {
70    /// Consecutive nodes are linked: node `n` neighbours `n-1` and `n+1`.
71    Chain,
72    /// Every node is linked to every other node.
73    FullyConnected,
74    /// Node `n` is linked to the `k` nearest nodes on each side in index space.
75    KNearest {
76        /// Number of neighbours on each side (`n-k..n` and `n+1..=n+k`).
77        k: usize,
78    },
79}
80
81/// How incoming neighbour messages are combined at a node.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum MessageAggregation {
84    /// Average of incoming messages (degree-normalised).
85    Mean,
86    /// Plain sum of incoming messages.
87    Sum,
88}
89
90/// Nonlinearity applied to a node's outgoing message.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum MessageActivation {
93    /// Hyperbolic tangent.
94    Tanh,
95    /// Rectified linear unit.
96    Relu,
97}
98
99/// Configuration for [`GnnOptimizer`].
100///
101/// The numeric hyper-parameters are stored as `f64` and converted to the
102/// optimizer's scalar type `T` when the optimizer is constructed.
103#[derive(Debug, Clone)]
104pub struct GnnOptimizerConfig {
105    /// Requested number of graph nodes (clamped to the parameter count at run time).
106    pub num_nodes: usize,
107    /// Dimension of the GRU hidden state and message space.
108    pub hidden_dim: usize,
109    /// Number of synchronous message-passing rounds per optimization step.
110    pub num_message_rounds: usize,
111    /// Inter-node graph connectivity.
112    pub topology: GraphTopology,
113    /// Neighbour message aggregation rule.
114    pub aggregation: MessageAggregation,
115    /// Message nonlinearity.
116    pub activation: MessageActivation,
117    /// Base learning rate applied before the learned per-node step scale.
118    pub base_lr: f64,
119    /// EMA decay for the gradient (momentum), often called `beta1`.
120    pub momentum_decay: f64,
121    /// EMA decay for the squared gradient (RMS), often called `beta2`.
122    pub rms_decay: f64,
123    /// EMA decay for the per-node update-history feature.
124    pub history_decay: f64,
125    /// Maximum gradient L2 norm; larger gradients are rescaled before use.
126    pub max_grad_norm: f64,
127    /// Seed for deterministic weight initialisation.
128    pub seed: u64,
129}
130
131impl Default for GnnOptimizerConfig {
132    fn default() -> Self {
133        Self {
134            num_nodes: 16,
135            hidden_dim: 8,
136            num_message_rounds: 2,
137            topology: GraphTopology::Chain,
138            aggregation: MessageAggregation::Mean,
139            activation: MessageActivation::Tanh,
140            base_lr: 0.01,
141            momentum_decay: 0.9,
142            rms_decay: 0.999,
143            history_decay: 0.9,
144            max_grad_norm: 10.0,
145            seed: 0x5EED_C0DE,
146        }
147    }
148}
149
150/// Logistic (sigmoid) activation for a scalar.
151fn sigmoid<T: Float>(x: T) -> T {
152    T::one() / (T::one() + (-x).exp())
153}
154
155/// Convert an `f64` into the optimizer scalar, falling back to zero on overflow.
156fn cast<T: Float>(value: f64) -> T {
157    T::from(value).unwrap_or_else(T::zero)
158}
159
160/// Glorot-uniform initialisation of a `rows x cols` weight matrix.
161fn init_matrix<T, R>(rng: &mut Random<R>, rows: usize, cols: usize) -> Array2<T>
162where
163    T: Float,
164    R: Rng,
165{
166    let limit = (6.0 / (rows as f64 + cols as f64)).sqrt();
167    Array2::from_shape_fn((rows, cols), |_| {
168        let value: f64 = rng.random_range(-limit..limit);
169        cast(value)
170    })
171}
172
173/// Glorot-uniform initialisation of a length-`len` readout vector.
174fn init_vector<T, R>(rng: &mut Random<R>, len: usize) -> Array1<T>
175where
176    T: Float,
177    R: Rng,
178{
179    let limit = (6.0 / (len as f64 + 1.0)).sqrt();
180    Array1::from_shape_fn(len, |_| {
181        let value: f64 = rng.random_range(-limit..limit);
182        cast(value)
183    })
184}
185
186/// A genuine Gated Recurrent Unit cell.
187///
188/// With input `a` and previous hidden `h` the cell computes
189/// `z = σ(W_z a + U_z h + b_z)` (update gate),
190/// `r = σ(W_r a + U_r h + b_r)` (reset gate),
191/// `h̃ = tanh(W_h a + U_h (r ⊙ h) + b_h)` (candidate), and returns the convex blend
192/// `(1 - z) ⊙ h + z ⊙ h̃`.
193#[derive(Debug, Clone)]
194struct GruCell<T: Float + Debug + Send + Sync + 'static> {
195    w_z: Array2<T>,
196    u_z: Array2<T>,
197    b_z: Array1<T>,
198    w_r: Array2<T>,
199    u_r: Array2<T>,
200    b_r: Array1<T>,
201    w_h: Array2<T>,
202    u_h: Array2<T>,
203    b_h: Array1<T>,
204}
205
206impl<T: Float + Debug + Send + Sync + 'static> GruCell<T> {
207    /// Deterministically initialise all gate weights for a hidden size of `hidden`.
208    ///
209    /// The input and hidden dimensions are both `hidden` (messages live in the
210    /// hidden space), so every gate weight is a square `hidden x hidden` matrix.
211    fn new<R: Rng>(rng: &mut Random<R>, hidden: usize) -> Self {
212        Self {
213            w_z: init_matrix(rng, hidden, hidden),
214            u_z: init_matrix(rng, hidden, hidden),
215            b_z: Array1::zeros(hidden),
216            w_r: init_matrix(rng, hidden, hidden),
217            u_r: init_matrix(rng, hidden, hidden),
218            b_r: Array1::zeros(hidden),
219            w_h: init_matrix(rng, hidden, hidden),
220            u_h: init_matrix(rng, hidden, hidden),
221            b_h: Array1::zeros(hidden),
222        }
223    }
224
225    /// Apply one GRU update, returning the new hidden state.
226    fn step(&self, input: &Array1<T>, hidden: &Array1<T>) -> Array1<T> {
227        let update_gate =
228            (self.w_z.dot(input) + self.u_z.dot(hidden) + &self.b_z).mapv(|x| sigmoid(x));
229        let reset_gate =
230            (self.w_r.dot(input) + self.u_r.dot(hidden) + &self.b_r).mapv(|x| sigmoid(x));
231        let reset_hidden = &reset_gate * hidden;
232        let candidate =
233            (self.w_h.dot(input) + self.u_h.dot(&reset_hidden) + &self.b_h).mapv(|x| x.tanh());
234        let keep = update_gate.mapv(|z| T::one() - z);
235        &keep * hidden + &update_gate * &candidate
236    }
237}
238
239/// The full set of learned GNN weights, held fixed during inference.
240#[derive(Debug, Clone)]
241struct GnnWeights<T: Float + Debug + Send + Sync + 'static> {
242    /// Node-feature encoder: `hidden x NODE_FEATURE_DIM`.
243    w_in: Array2<T>,
244    b_in: Array1<T>,
245    /// Message map applied to a node's hidden state: `hidden x hidden`.
246    w_msg: Array2<T>,
247    b_msg: Array1<T>,
248    /// GRU node-update cell.
249    gru: GruCell<T>,
250    /// Readout weight (length `hidden`) and bias producing a scalar logit.
251    w_out: Array1<T>,
252    b_out: T,
253}
254
255impl<T: Float + Debug + Send + Sync + 'static> GnnWeights<T> {
256    /// Deterministically initialise every learned weight.
257    fn new<R: Rng>(rng: &mut Random<R>, hidden: usize) -> Self {
258        Self {
259            w_in: init_matrix(rng, hidden, NODE_FEATURE_DIM),
260            b_in: Array1::zeros(hidden),
261            w_msg: init_matrix(rng, hidden, hidden),
262            b_msg: Array1::zeros(hidden),
263            gru: GruCell::new(rng, hidden),
264            w_out: init_vector(rng, hidden),
265            b_out: T::zero(),
266        }
267    }
268
269    /// Encode a node feature vector into the hidden space (tanh nonlinearity).
270    fn encode(&self, feature: &Array1<T>) -> Array1<T> {
271        (self.w_in.dot(feature) + &self.b_in).mapv(|x| x.tanh())
272    }
273
274    /// Compute a node's outgoing message from its hidden state.
275    fn message(&self, hidden: &Array1<T>, activation: MessageActivation) -> Array1<T> {
276        let linear = self.w_msg.dot(hidden) + &self.b_msg;
277        match activation {
278            MessageActivation::Tanh => linear.mapv(|x| x.tanh()),
279            MessageActivation::Relu => linear.mapv(|x| if x > T::zero() { x } else { T::zero() }),
280        }
281    }
282
283    /// Map a final hidden state to a strictly-positive step scale in `(0, 1)`.
284    fn readout(&self, hidden: &Array1<T>) -> T {
285        sigmoid(self.w_out.dot(hidden) + self.b_out)
286    }
287}
288
289/// Aggregate the messages of a node's neighbours.
290fn aggregate_messages<T>(
291    neighbors: &[usize],
292    messages: &[Array1<T>],
293    aggregation: MessageAggregation,
294) -> Array1<T>
295where
296    T: Float + Debug + Send + Sync + 'static,
297{
298    let dim = messages.first().map_or(0, Array1::len);
299    if neighbors.is_empty() {
300        return Array1::zeros(dim);
301    }
302    let sum = neighbors
303        .iter()
304        .map(|&j| &messages[j])
305        .fold(Array1::<T>::zeros(dim), |acc, message| acc + message);
306    match aggregation {
307        MessageAggregation::Sum => sum,
308        MessageAggregation::Mean => {
309            let count = cast::<T>(neighbors.len() as f64);
310            sum.mapv(|x| x / count)
311        }
312    }
313}
314
315/// Build the adjacency (neighbour index lists) for the chosen topology.
316fn build_adjacency(num_nodes: usize, topology: GraphTopology) -> Vec<Vec<usize>> {
317    let mut adjacency = vec![Vec::new(); num_nodes];
318    match topology {
319        GraphTopology::Chain => {
320            for (n, neighbors) in adjacency.iter_mut().enumerate() {
321                if n > 0 {
322                    neighbors.push(n - 1);
323                }
324                if n + 1 < num_nodes {
325                    neighbors.push(n + 1);
326                }
327            }
328        }
329        GraphTopology::FullyConnected => {
330            for (n, neighbors) in adjacency.iter_mut().enumerate() {
331                for j in 0..num_nodes {
332                    if j != n {
333                        neighbors.push(j);
334                    }
335                }
336            }
337        }
338        GraphTopology::KNearest { k } => {
339            for (n, neighbors) in adjacency.iter_mut().enumerate() {
340                for d in 1..=k {
341                    if n >= d {
342                        neighbors.push(n - d);
343                    }
344                    if n + d < num_nodes {
345                        neighbors.push(n + d);
346                    }
347                }
348            }
349        }
350    }
351    adjacency
352}
353
354/// Partition `param_len` indices into `num_nodes` contiguous, near-equal chunks.
355fn build_chunks(param_len: usize, num_nodes: usize) -> Vec<(usize, usize)> {
356    let base = param_len / num_nodes;
357    let remainder = param_len % num_nodes;
358    let mut bounds = Vec::with_capacity(num_nodes);
359    let mut start = 0;
360    for n in 0..num_nodes {
361        let size = base + usize::from(n < remainder);
362        bounds.push((start, start + size));
363        start += size;
364    }
365    bounds
366}
367
368/// A learned optimizer driven by a message-passing GNN with a GRU node update.
369///
370/// Implements [`AdvancedOptimizer`]. The learned weights are fixed; the per-node
371/// EMAs and GRU hidden vectors are persistent state that evolves across steps.
372#[derive(Debug, Clone)]
373pub struct GnnOptimizer<T: Float + Debug + Send + Sync + 'static> {
374    // --- configuration (fixed) ---
375    requested_num_nodes: usize,
376    hidden_dim: usize,
377    num_message_rounds: usize,
378    topology: GraphTopology,
379    aggregation: MessageAggregation,
380    activation: MessageActivation,
381    base_lr: T,
382    momentum_decay: T,
383    rms_decay: T,
384    history_decay: T,
385    max_grad_norm: T,
386    norm_ema_decay: T,
387    seed: u64,
388
389    // --- learned weights (fixed during step) ---
390    weights: GnnWeights<T>,
391
392    // --- graph layout (depends on the actual parameter length) ---
393    param_len: usize,
394    num_nodes: usize,
395    chunk_bounds: Vec<(usize, usize)>,
396    adjacency: Vec<Vec<usize>>,
397
398    // --- persistent optimization state ---
399    grad_ema: Array1<T>,
400    grad_sq_ema: Array1<T>,
401    node_hidden: Vec<Array1<T>>,
402    node_history: Array1<T>,
403
404    // --- bookkeeping ---
405    step_count: usize,
406    current_lr: T,
407    grad_norm_ema: T,
408}
409
410impl<T: Float + Debug + Send + Sync + 'static> GnnOptimizer<T> {
411    /// Construct a new optimizer from a configuration.
412    ///
413    /// The learned weights are initialised deterministically from `config.seed`.
414    /// Graph-dependent state is created lazily on the first [`AdvancedOptimizer::step`]
415    /// once the parameter length is known.
416    ///
417    /// # Errors
418    /// Returns [`OptimError::InvalidConfig`] if a hyper-parameter is out of range.
419    pub fn new(config: GnnOptimizerConfig) -> Result<Self> {
420        if config.hidden_dim == 0 {
421            return Err(OptimError::InvalidConfig(
422                "hidden_dim must be at least 1".to_string(),
423            ));
424        }
425        if config.num_message_rounds == 0 {
426            return Err(OptimError::InvalidConfig(
427                "num_message_rounds must be at least 1".to_string(),
428            ));
429        }
430        if config.num_nodes == 0 {
431            return Err(OptimError::InvalidConfig(
432                "num_nodes must be at least 1".to_string(),
433            ));
434        }
435        if !(config.base_lr.is_finite() && config.base_lr > 0.0) {
436            return Err(OptimError::InvalidConfig(
437                "base_lr must be a positive finite value".to_string(),
438            ));
439        }
440        if config.max_grad_norm <= 0.0 || !config.max_grad_norm.is_finite() {
441            return Err(OptimError::InvalidConfig(
442                "max_grad_norm must be a positive finite value".to_string(),
443            ));
444        }
445        for (name, value) in [
446            ("momentum_decay", config.momentum_decay),
447            ("rms_decay", config.rms_decay),
448            ("history_decay", config.history_decay),
449        ] {
450            if !(0.0..1.0).contains(&value) {
451                return Err(OptimError::InvalidConfig(format!(
452                    "{name} must lie in [0, 1), got {value}"
453                )));
454            }
455        }
456
457        let mut rng = Random::seed(config.seed);
458        let weights = GnnWeights::new(&mut rng, config.hidden_dim);
459
460        Ok(Self {
461            requested_num_nodes: config.num_nodes,
462            hidden_dim: config.hidden_dim,
463            num_message_rounds: config.num_message_rounds,
464            topology: config.topology,
465            aggregation: config.aggregation,
466            activation: config.activation,
467            base_lr: cast(config.base_lr),
468            momentum_decay: cast(config.momentum_decay),
469            rms_decay: cast(config.rms_decay),
470            history_decay: cast(config.history_decay),
471            max_grad_norm: cast(config.max_grad_norm),
472            norm_ema_decay: cast(0.99),
473            seed: config.seed,
474            weights,
475            param_len: 0,
476            num_nodes: 0,
477            chunk_bounds: Vec::new(),
478            adjacency: Vec::new(),
479            grad_ema: Array1::zeros(0),
480            grad_sq_ema: Array1::zeros(0),
481            node_hidden: Vec::new(),
482            node_history: Array1::zeros(0),
483            step_count: 0,
484            current_lr: cast(config.base_lr),
485            grad_norm_ema: T::zero(),
486        })
487    }
488
489    /// The actual number of graph nodes (after clamping to the parameter count).
490    ///
491    /// Returns `0` before the first step, when the parameter length is unknown.
492    pub fn num_nodes(&self) -> usize {
493        self.num_nodes
494    }
495
496    /// The GRU hidden / message dimension.
497    pub fn hidden_dim(&self) -> usize {
498        self.hidden_dim
499    }
500
501    /// The seed used for deterministic weight initialisation.
502    pub fn seed(&self) -> u64 {
503        self.seed
504    }
505
506    /// (Re)initialise all graph-dependent and persistent state for `param_len`.
507    fn initialize_for(&mut self, param_len: usize) {
508        let num_nodes = self.requested_num_nodes.min(param_len).max(1);
509        self.param_len = param_len;
510        self.num_nodes = num_nodes;
511        self.chunk_bounds = build_chunks(param_len, num_nodes);
512        self.adjacency = build_adjacency(num_nodes, self.topology);
513        self.grad_ema = Array1::zeros(param_len);
514        self.grad_sq_ema = Array1::zeros(param_len);
515        self.node_hidden = (0..num_nodes)
516            .map(|_| Array1::zeros(self.hidden_dim))
517            .collect();
518        self.node_history = Array1::zeros(num_nodes);
519        self.step_count = 0;
520        self.grad_norm_ema = T::zero();
521    }
522
523    /// Build the per-node encoded feature vectors from the current gradient and
524    /// the (already updated) bias-corrected EMAs.
525    fn encode_nodes(
526        &self,
527        grad: &Array1<T>,
528        m_hat: &Array1<T>,
529        v_hat: &Array1<T>,
530    ) -> Vec<Array1<T>> {
531        let mut encoded = Vec::with_capacity(self.num_nodes);
532        for (n, &(start, end)) in self.chunk_bounds.iter().enumerate() {
533            let size = cast::<T>((end - start) as f64);
534            let chunk = grad.slice(s![start..end]);
535
536            let mean_grad = chunk.sum() / size;
537            let abs_mean_grad = chunk.iter().fold(T::zero(), |a, &x| a + x.abs()) / size;
538            let variance = chunk.iter().fold(T::zero(), |a, &x| {
539                let d = x - mean_grad;
540                a + d * d
541            }) / size;
542
543            let momentum = m_hat.slice(s![start..end]).sum() / size;
544            let rms = v_hat
545                .slice(s![start..end])
546                .iter()
547                .fold(T::zero(), |a, &x| a + x.max(T::zero()).sqrt())
548                / size;
549
550            let mean_sign = mean_grad.signum();
551            let sign_agreement = chunk
552                .iter()
553                .fold(T::zero(), |a, &x| a + x.signum() * mean_sign)
554                / size;
555
556            let history = self.node_history[n];
557
558            let feature = Array1::from_vec(vec![
559                mean_grad,
560                abs_mean_grad,
561                variance,
562                momentum,
563                rms,
564                sign_agreement,
565                history,
566            ]);
567            encoded.push(self.weights.encode(&feature));
568        }
569        encoded
570    }
571
572    /// Run `rounds` synchronous message-passing rounds, updating `node_hidden`.
573    ///
574    /// Each round computes every node's message from the hidden snapshot, then
575    /// every node aggregates its neighbours' messages, injects its encoded node
576    /// feature, and applies the GRU update.
577    fn message_passing(
578        weights: &GnnWeights<T>,
579        adjacency: &[Vec<usize>],
580        encoded: &[Array1<T>],
581        node_hidden: &mut [Array1<T>],
582        rounds: usize,
583        aggregation: MessageAggregation,
584        activation: MessageActivation,
585    ) {
586        for _ in 0..rounds {
587            let messages: Vec<Array1<T>> = node_hidden
588                .iter()
589                .map(|hidden| weights.message(hidden, activation))
590                .collect();
591            for (n, neighbors) in adjacency.iter().enumerate() {
592                let aggregated = aggregate_messages(neighbors, &messages, aggregation);
593                let input = &aggregated + &encoded[n];
594                node_hidden[n] = weights.gru.step(&input, &node_hidden[n]);
595            }
596        }
597    }
598}
599
600impl<T: Float + Debug + Send + Sync + 'static> AdvancedOptimizer<T> for GnnOptimizer<T> {
601    fn step(&mut self, params: &Array1<T>, gradients: &Array1<T>) -> Result<Array1<T>> {
602        if params.len() != gradients.len() {
603            return Err(OptimError::InvalidConfig(format!(
604                "Parameter length {} != gradient length {}",
605                params.len(),
606                gradients.len()
607            )));
608        }
609        if params.is_empty() {
610            return Err(OptimError::InsufficientData(
611                "Empty parameter array".to_string(),
612            ));
613        }
614
615        if self.param_len != params.len() {
616            self.initialize_for(params.len());
617        }
618
619        // 1. Clip the gradient and track its EMA norm.
620        let grad = clip_grad_norm(gradients, self.max_grad_norm);
621        let grad_norm = l2_norm(&grad);
622        self.grad_norm_ema =
623            self.norm_ema_decay * self.grad_norm_ema + (T::one() - self.norm_ema_decay) * grad_norm;
624
625        // 2. Update the per-element gradient and squared-gradient EMAs.
626        let beta1 = self.momentum_decay;
627        let beta2 = self.rms_decay;
628        self.grad_ema = self.grad_ema.mapv(|m| m * beta1) + &grad.mapv(|g| g * (T::one() - beta1));
629        self.grad_sq_ema =
630            self.grad_sq_ema.mapv(|v| v * beta2) + &grad.mapv(|g| g * g * (T::one() - beta2));
631
632        // 3. Bias-correct the EMAs (Adam-style), giving the effective direction.
633        let step = self.step_count + 1;
634        let bias1 = T::one() - beta1.powi(step as i32);
635        let bias2 = T::one() - beta2.powi(step as i32);
636        let m_hat = self.grad_ema.mapv(|m| m / bias1);
637        let v_hat = self.grad_sq_ema.mapv(|v| v / bias2);
638
639        // 4. Encode node features, then run message passing + GRU updates.
640        let encoded = self.encode_nodes(&grad, &m_hat, &v_hat);
641        Self::message_passing(
642            &self.weights,
643            &self.adjacency,
644            &encoded,
645            &mut self.node_hidden,
646            self.num_message_rounds,
647            self.aggregation,
648            self.activation,
649        );
650
651        // 5. Readout: per-node strictly-positive step scale in (0, 1).
652        let step_scales: Vec<T> = self
653            .node_hidden
654            .iter()
655            .map(|hidden| self.weights.readout(hidden))
656            .collect();
657
658        // 6. Broadcast the per-node scale and apply the EMA-smoothed update.
659        let mut scale_vector = Array1::zeros(self.param_len);
660        for (n, &(start, end)) in self.chunk_bounds.iter().enumerate() {
661            scale_vector.slice_mut(s![start..end]).fill(step_scales[n]);
662        }
663        let base_lr = self.base_lr;
664        let update = (&scale_vector * &m_hat).mapv(|u| u * base_lr);
665        let new_params = params - &update;
666
667        // 7. Update the per-node update-history summary (EMA of the step scale).
668        let history_decay = self.history_decay;
669        for (history, &scale) in self.node_history.iter_mut().zip(step_scales.iter()) {
670            *history = history_decay * *history + (T::one() - history_decay) * scale;
671        }
672
673        // 8. Effective learning rate = base_lr * mean step scale; advance counter.
674        let scale_count = cast::<T>(step_scales.len() as f64);
675        let mean_scale = step_scales.iter().fold(T::zero(), |a, &s| a + s) / scale_count;
676        self.current_lr = base_lr * mean_scale;
677        self.step_count += 1;
678
679        Ok(new_params)
680    }
681
682    fn get_learning_rate(&self) -> T {
683        self.current_lr
684    }
685
686    fn set_learning_rate(&mut self, lr: T) {
687        self.base_lr = lr;
688        self.current_lr = lr;
689    }
690
691    fn name(&self) -> &str {
692        "GNNOptimizer"
693    }
694
695    fn get_state(&self) -> OptimizerStateInfo<T> {
696        OptimizerStateInfo {
697            step_count: self.step_count,
698            current_lr: self.current_lr,
699            grad_norm_ema: self.grad_norm_ema,
700        }
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use scirs2_core::ndarray::Array1;
708
709    fn make_optimizer(seed: u64) -> GnnOptimizer<f64> {
710        GnnOptimizer::new(GnnOptimizerConfig {
711            seed,
712            ..Default::default()
713        })
714        .expect("default configuration must be valid")
715    }
716
717    fn l2(a: &Array1<f64>) -> f64 {
718        a.iter().map(|x| x * x).sum::<f64>().sqrt()
719    }
720
721    #[test]
722    fn test_gnn_preserves_shape() {
723        for len in [1usize, 3, 7, 16, 40, 100] {
724            let mut opt = make_optimizer(7);
725            let params = Array1::from_shape_fn(len, |i| (i as f64) * 0.1 + 0.5);
726            let grads = Array1::from_shape_fn(len, |i| (i as f64) * 0.01 + 0.2);
727            let out = opt.step(&params, &grads).expect("step should succeed");
728            assert_eq!(out.len(), params.len(), "output length must match input");
729        }
730    }
731
732    #[test]
733    fn test_gnn_deterministic_same_seed() {
734        let params = Array1::from_vec(vec![1.0, -2.0, 3.0, -4.0, 5.0, 0.5, 0.25, 0.1]);
735        let grads = Array1::from_vec(vec![0.1, 0.2, -0.3, 0.4, -0.5, 0.05, 0.02, 0.3]);
736
737        let mut a = make_optimizer(123);
738        let mut b = make_optimizer(123);
739        let out_a = a.step(&params, &grads).expect("a step");
740        let out_b = b.step(&params, &grads).expect("b step");
741
742        for (x, y) in out_a.iter().zip(out_b.iter()) {
743            assert!(
744                (x - y).abs() < 1e-12,
745                "same seed must give identical output: {x} vs {y}"
746            );
747        }
748    }
749
750    #[test]
751    fn test_gnn_different_seed_differs() {
752        let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
753        let grads = Array1::from_vec(vec![0.3, 0.3, 0.3, 0.3, 0.3, 0.3]);
754
755        let mut a = make_optimizer(1);
756        let mut b = make_optimizer(2);
757        let out_a = a.step(&params, &grads).expect("a step");
758        let out_b = b.step(&params, &grads).expect("b step");
759
760        let diff: f64 = out_a
761            .iter()
762            .zip(out_b.iter())
763            .map(|(x, y)| (x - y).abs())
764            .sum();
765        assert!(
766            diff > 0.0,
767            "different seeds should produce different updates"
768        );
769    }
770
771    #[test]
772    fn test_gnn_convergence_quadratic() {
773        // f(x) = ||x||^2, grad = 2x. Repeated steps must strictly reduce ||x||.
774        let mut opt = make_optimizer(42);
775        let mut x = Array1::from_vec(vec![
776            3.0, -3.0, 2.5, -2.5, 4.0, -1.0, 1.5, -2.0, 3.5, -0.5, 2.0, -1.5, 1.0, -3.0, 2.0, -2.5,
777        ]);
778        let initial = l2(&x);
779        let mut prev = initial;
780
781        for iteration in 0..400 {
782            let grad = x.mapv(|v| 2.0 * v);
783            x = opt.step(&x, &grad).expect("step should succeed");
784            let norm = l2(&x);
785            assert!(
786                norm < prev,
787                "norm must strictly decrease at iteration {iteration}: {prev} -> {norm}"
788            );
789            prev = norm;
790        }
791
792        assert!(
793            prev < 0.8 * initial,
794            "norm should reduce substantially over many iterations: {initial} -> {prev}"
795        );
796    }
797
798    #[test]
799    fn test_gnn_all_topologies_run() {
800        let topologies = [
801            GraphTopology::Chain,
802            GraphTopology::FullyConnected,
803            GraphTopology::KNearest { k: 2 },
804        ];
805        for topology in topologies {
806            let cfg = GnnOptimizerConfig {
807                topology,
808                seed: 9,
809                ..Default::default()
810            };
811            let mut opt = GnnOptimizer::new(cfg).expect("config valid");
812            let params = Array1::from_shape_fn(20, |i| 1.0 + 0.1 * (i as f64));
813            let grads = Array1::from_shape_fn(20, |i| 0.2 - 0.01 * (i as f64));
814            let out = opt.step(&params, &grads).expect("step should succeed");
815            assert_eq!(out.len(), 20);
816            let moved: f64 = out
817                .iter()
818                .zip(params.iter())
819                .map(|(a, b)| (a - b).abs())
820                .sum();
821            assert!(
822                moved > 0.0,
823                "topology {topology:?} should update parameters"
824            );
825        }
826    }
827
828    #[test]
829    fn test_gnn_knearest_topology_converges() {
830        let cfg = GnnOptimizerConfig {
831            topology: GraphTopology::KNearest { k: 3 },
832            aggregation: MessageAggregation::Sum,
833            activation: MessageActivation::Relu,
834            seed: 77,
835            ..Default::default()
836        };
837        let mut opt = GnnOptimizer::new(cfg).expect("config valid");
838        let mut x = Array1::from_shape_fn(24, |i| 2.0 - 0.05 * (i as f64));
839        let initial = l2(&x);
840        for _ in 0..200 {
841            let grad = x.mapv(|v| 2.0 * v);
842            x = opt.step(&x, &grad).expect("step should succeed");
843        }
844        assert!(l2(&x) < initial, "k-nearest variant should reduce the norm");
845    }
846
847    #[test]
848    fn test_gnn_num_nodes_clamped() {
849        let cfg = GnnOptimizerConfig {
850            num_nodes: 1000,
851            seed: 5,
852            ..Default::default()
853        };
854        let mut opt = GnnOptimizer::new(cfg).expect("config valid");
855        let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
856        let grads = Array1::from_vec(vec![0.5, 0.5, 0.5, 0.5]);
857        let out = opt.step(&params, &grads).expect("step should succeed");
858        assert_eq!(out.len(), 4);
859        assert_eq!(
860            opt.num_nodes(),
861            4,
862            "num_nodes must clamp to the parameter count"
863        );
864    }
865
866    #[test]
867    fn test_gnn_single_parameter() {
868        let mut opt = make_optimizer(13);
869        let params = Array1::from_vec(vec![5.0]);
870        let grads = Array1::from_vec(vec![2.0 * 5.0]);
871        let out = opt.step(&params, &grads).expect("step should succeed");
872        assert_eq!(out.len(), 1);
873        assert_eq!(opt.num_nodes(), 1);
874        assert!(
875            out[0].abs() < params[0].abs(),
876            "single param should move toward 0"
877        );
878    }
879
880    #[test]
881    fn test_gnn_trait_methods() {
882        let mut opt = make_optimizer(11);
883        assert_eq!(opt.name(), "GNNOptimizer");
884
885        let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
886        let grads = Array1::from_vec(vec![0.1, 0.1, 0.1, 0.1, 0.1]);
887        let _ = opt.step(&params, &grads).expect("step should succeed");
888
889        let state = opt.get_state();
890        assert_eq!(state.step_count, 1);
891        assert!(state.grad_norm_ema > 0.0);
892        assert!(state.current_lr > 0.0);
893
894        opt.set_learning_rate(0.5);
895        assert!((opt.get_learning_rate() - 0.5).abs() < 1e-12);
896    }
897
898    #[test]
899    fn test_gnn_dimension_mismatch() {
900        let mut opt = make_optimizer(3);
901        let params = Array1::from_vec(vec![1.0, 2.0]);
902        let grads = Array1::from_vec(vec![0.1, 0.2, 0.3]);
903        assert!(opt.step(&params, &grads).is_err());
904    }
905
906    #[test]
907    fn test_gnn_empty_params_error() {
908        let mut opt = make_optimizer(3);
909        let params = Array1::<f64>::zeros(0);
910        let grads = Array1::<f64>::zeros(0);
911        assert!(opt.step(&params, &grads).is_err());
912    }
913
914    #[test]
915    fn test_gnn_invalid_config() {
916        assert!(GnnOptimizer::<f64>::new(GnnOptimizerConfig {
917            hidden_dim: 0,
918            ..Default::default()
919        })
920        .is_err());
921        assert!(GnnOptimizer::<f64>::new(GnnOptimizerConfig {
922            momentum_decay: 1.0,
923            ..Default::default()
924        })
925        .is_err());
926        assert!(GnnOptimizer::<f64>::new(GnnOptimizerConfig {
927            base_lr: 0.0,
928            ..Default::default()
929        })
930        .is_err());
931        assert!(GnnOptimizer::<f64>::new(GnnOptimizerConfig {
932            num_message_rounds: 0,
933            ..Default::default()
934        })
935        .is_err());
936    }
937
938    #[test]
939    fn test_gnn_persistent_hidden_state_evolves() {
940        // The GRU hidden vectors are persistent and must change across steps.
941        let mut opt = make_optimizer(7);
942        let params = Array1::from_shape_fn(10, |i| 0.5 + 0.1 * (i as f64));
943        let grads = Array1::from_shape_fn(10, |i| 0.3 - 0.02 * (i as f64));
944
945        let _ = opt.step(&params, &grads).expect("first step");
946        let hidden_after_first: Vec<f64> =
947            opt.node_hidden.iter().flat_map(|h| h.to_vec()).collect();
948
949        let _ = opt.step(&params, &grads).expect("second step");
950        let hidden_after_second: Vec<f64> =
951            opt.node_hidden.iter().flat_map(|h| h.to_vec()).collect();
952
953        let changed: f64 = hidden_after_first
954            .iter()
955            .zip(hidden_after_second.iter())
956            .map(|(a, b)| (a - b).abs())
957            .sum();
958        assert!(changed > 0.0, "GRU hidden state should evolve across steps");
959        assert_eq!(opt.get_state().step_count, 2);
960    }
961
962    #[test]
963    fn test_gnn_step_scales_in_unit_interval() {
964        // Every per-node readout must be a strictly-positive scale in (0, 1),
965        // which is what guarantees monotone descent on the quadratic.
966        let opt = make_optimizer(31);
967        let hidden = Array1::from_vec(vec![0.4; opt.hidden_dim()]);
968        let scale = opt.weights.readout(&hidden);
969        assert!(scale > 0.0 && scale < 1.0, "step scale must lie in (0, 1)");
970    }
971}