1pub 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
60const NODE_FEATURE_DIM: usize = 7;
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum GraphTopology {
70 Chain,
72 FullyConnected,
74 KNearest {
76 k: usize,
78 },
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum MessageAggregation {
84 Mean,
86 Sum,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum MessageActivation {
93 Tanh,
95 Relu,
97}
98
99#[derive(Debug, Clone)]
104pub struct GnnOptimizerConfig {
105 pub num_nodes: usize,
107 pub hidden_dim: usize,
109 pub num_message_rounds: usize,
111 pub topology: GraphTopology,
113 pub aggregation: MessageAggregation,
115 pub activation: MessageActivation,
117 pub base_lr: f64,
119 pub momentum_decay: f64,
121 pub rms_decay: f64,
123 pub history_decay: f64,
125 pub max_grad_norm: f64,
127 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
150fn sigmoid<T: Float>(x: T) -> T {
152 T::one() / (T::one() + (-x).exp())
153}
154
155fn cast<T: Float>(value: f64) -> T {
157 T::from(value).unwrap_or_else(T::zero)
158}
159
160fn 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
173fn 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#[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 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 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#[derive(Debug, Clone)]
241struct GnnWeights<T: Float + Debug + Send + Sync + 'static> {
242 w_in: Array2<T>,
244 b_in: Array1<T>,
245 w_msg: Array2<T>,
247 b_msg: Array1<T>,
248 gru: GruCell<T>,
250 w_out: Array1<T>,
252 b_out: T,
253}
254
255impl<T: Float + Debug + Send + Sync + 'static> GnnWeights<T> {
256 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 fn encode(&self, feature: &Array1<T>) -> Array1<T> {
271 (self.w_in.dot(feature) + &self.b_in).mapv(|x| x.tanh())
272 }
273
274 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 fn readout(&self, hidden: &Array1<T>) -> T {
285 sigmoid(self.w_out.dot(hidden) + self.b_out)
286 }
287}
288
289fn 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
315fn 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
354fn 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#[derive(Debug, Clone)]
373pub struct GnnOptimizer<T: Float + Debug + Send + Sync + 'static> {
374 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 weights: GnnWeights<T>,
391
392 param_len: usize,
394 num_nodes: usize,
395 chunk_bounds: Vec<(usize, usize)>,
396 adjacency: Vec<Vec<usize>>,
397
398 grad_ema: Array1<T>,
400 grad_sq_ema: Array1<T>,
401 node_hidden: Vec<Array1<T>>,
402 node_history: Array1<T>,
403
404 step_count: usize,
406 current_lr: T,
407 grad_norm_ema: T,
408}
409
410impl<T: Float + Debug + Send + Sync + 'static> GnnOptimizer<T> {
411 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 pub fn num_nodes(&self) -> usize {
493 self.num_nodes
494 }
495
496 pub fn hidden_dim(&self) -> usize {
498 self.hidden_dim
499 }
500
501 pub fn seed(&self) -> u64 {
503 self.seed
504 }
505
506 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 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 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 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 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 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 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 let step_scales: Vec<T> = self
653 .node_hidden
654 .iter()
655 .map(|hidden| self.weights.readout(hidden))
656 .collect();
657
658 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 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 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(¶ms, &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(¶ms, &grads).expect("a step");
740 let out_b = b.step(¶ms, &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(¶ms, &grads).expect("a step");
758 let out_b = b.step(¶ms, &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 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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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 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(¶ms, &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(¶ms, &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 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}