Skip to main content

optirs_learned/gnn_optimizer/
meta_training.rs

1//! [`crate::es_meta_training::MetaTrainable`] implementation for [`GnnOptimizer`] (finding F75).
2//!
3//! The generic evolution-strategies loop lives in [`crate::es_meta_training`],
4//! which also explains why ES rather than backpropagation. This file supplies the
5//! three architecture-specific pieces the loop needs: flatten the GNN's learned
6//! weights into one vector, load them back, and clear the persistent per-rollout
7//! state.
8//!
9//! Before this existed, `GnnOptimizer`'s weights were drawn once from the
10//! configured seed and never changed again, so every "learned" update came out of
11//! a randomly-initialised network.
12
13use scirs2_core::ndarray::Array1;
14use scirs2_core::numeric::Float;
15use std::fmt::Debug;
16
17use super::{GnnOptimizer, GnnWeights, GruCell};
18use crate::error::Result;
19use crate::es_meta_training::{
20    expect_fully_consumed, pull_matrix, pull_scalar, pull_vector, push_matrix, push_scalar,
21    push_vector, MetaTrainable,
22};
23
24/// Convenience alias: the ES trainer, named for the architecture it is usually
25/// pointed at here. It is not GNN-specific — see [`crate::es_meta_training`].
26pub use crate::es_meta_training::EsMetaTrainer as GnnMetaTrainer;
27pub use crate::es_meta_training::{MetaTrainingConfig, MetaTrainingReport, SelectionMetric};
28
29impl<T: Float + Debug + Send + Sync + 'static> GruCell<T> {
30    /// Append the cell's nine parameter blocks to `out` in a fixed order.
31    fn extend_flat(&self, out: &mut Vec<f64>) {
32        push_matrix(&self.w_z, out);
33        push_matrix(&self.u_z, out);
34        push_vector(&self.b_z, out);
35        push_matrix(&self.w_r, out);
36        push_matrix(&self.u_r, out);
37        push_vector(&self.b_r, out);
38        push_matrix(&self.w_h, out);
39        push_matrix(&self.u_h, out);
40        push_vector(&self.b_h, out);
41    }
42
43    /// Load the cell's nine parameter blocks from `flat`, in the same order.
44    fn load_flat(&mut self, flat: &[f64], cursor: &mut usize) -> Result<()> {
45        pull_matrix(&mut self.w_z, flat, cursor)?;
46        pull_matrix(&mut self.u_z, flat, cursor)?;
47        pull_vector(&mut self.b_z, flat, cursor)?;
48        pull_matrix(&mut self.w_r, flat, cursor)?;
49        pull_matrix(&mut self.u_r, flat, cursor)?;
50        pull_vector(&mut self.b_r, flat, cursor)?;
51        pull_matrix(&mut self.w_h, flat, cursor)?;
52        pull_matrix(&mut self.u_h, flat, cursor)?;
53        pull_vector(&mut self.b_h, flat, cursor)?;
54        Ok(())
55    }
56}
57
58impl<T: Float + Debug + Send + Sync + 'static> GnnWeights<T> {
59    /// Flatten every learned weight into one vector, in a stable order.
60    fn to_flat(&self) -> Vec<f64> {
61        let mut out = Vec::new();
62        push_matrix(&self.w_in, &mut out);
63        push_vector(&self.b_in, &mut out);
64        push_matrix(&self.w_msg, &mut out);
65        push_vector(&self.b_msg, &mut out);
66        self.gru.extend_flat(&mut out);
67        push_vector(&self.w_out, &mut out);
68        push_scalar(self.b_out, &mut out);
69        out
70    }
71
72    /// Load every learned weight from a vector produced by [`Self::to_flat`].
73    fn load_flat(&mut self, flat: &[f64]) -> Result<()> {
74        let mut cursor = 0usize;
75        pull_matrix(&mut self.w_in, flat, &mut cursor)?;
76        pull_vector(&mut self.b_in, flat, &mut cursor)?;
77        pull_matrix(&mut self.w_msg, flat, &mut cursor)?;
78        pull_vector(&mut self.b_msg, flat, &mut cursor)?;
79        self.gru.load_flat(flat, &mut cursor)?;
80        pull_vector(&mut self.w_out, flat, &mut cursor)?;
81        pull_scalar(&mut self.b_out, flat, &mut cursor)?;
82        expect_fully_consumed(flat, cursor)
83    }
84}
85
86impl<T: Float + Debug + Send + Sync + 'static> GnnOptimizer<T> {
87    /// Every learned weight, flattened in a stable order.
88    ///
89    /// The layout is an implementation detail, but it is *stable across calls on
90    /// the same architecture*, which is what a meta-training loop needs:
91    /// `set_weight_vector(&weight_vector())` is the identity.
92    pub fn weight_vector(&self) -> Vec<f64> {
93        self.weights.to_flat()
94    }
95
96    /// Number of learned weights.
97    pub fn weight_count(&self) -> usize {
98        self.weights.to_flat().len()
99    }
100
101    /// Overwrite every learned weight from a vector laid out like
102    /// [`Self::weight_vector`]'s output.
103    ///
104    /// # Errors
105    /// Returns [`crate::error::OptimError::InvalidConfig`] if `flat` is not
106    /// exactly the right length for this architecture — a silent partial load
107    /// would leave the network in a state no caller could reason about.
108    pub fn set_weight_vector(&mut self, flat: &[f64]) -> Result<()> {
109        self.weights.load_flat(flat)
110    }
111
112    /// Clear the persistent optimization state (per-node EMAs, GRU hidden
113    /// vectors, step count, graph layout) while keeping the learned weights.
114    ///
115    /// Meta-training evaluates the same weights on several tasks, and a rollout
116    /// must not inherit momentum from the previous one. The graph layout is
117    /// discarded too because it is derived from the parameter length, which
118    /// differs between tasks; it is rebuilt lazily on the next step.
119    pub fn reset_state(&mut self) {
120        self.param_len = 0;
121        self.num_nodes = 0;
122        self.chunk_bounds.clear();
123        self.adjacency.clear();
124        self.grad_ema = Array1::zeros(0);
125        self.grad_sq_ema = Array1::zeros(0);
126        self.node_hidden.clear();
127        self.node_history = Array1::zeros(0);
128        self.step_count = 0;
129        self.grad_norm_ema = T::zero();
130        self.current_lr = self.base_lr;
131    }
132}
133
134impl<T: Float + Debug + Send + Sync + 'static> MetaTrainable<T> for GnnOptimizer<T> {
135    fn weight_vector(&self) -> Vec<f64> {
136        GnnOptimizer::weight_vector(self)
137    }
138
139    fn set_weight_vector(&mut self, flat: &[f64]) -> Result<()> {
140        GnnOptimizer::set_weight_vector(self, flat)
141    }
142
143    fn reset_state(&mut self) {
144        GnnOptimizer::reset_state(self)
145    }
146
147    fn weight_count(&self) -> usize {
148        GnnOptimizer::weight_count(self)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::domain_objectives::{MetaObjective, QuadraticObjective};
156    use crate::domain_optimizers::AdvancedOptimizer;
157    use crate::gnn_optimizer::{GnnOptimizerConfig, GraphTopology};
158
159    fn optimizer(seed: u64) -> GnnOptimizer<f64> {
160        GnnOptimizer::new(GnnOptimizerConfig {
161            num_nodes: 4,
162            hidden_dim: 6,
163            num_message_rounds: 2,
164            topology: GraphTopology::Chain,
165            base_lr: 0.2,
166            seed,
167            ..Default::default()
168        })
169        .expect("optimizer")
170    }
171
172    fn training_tasks() -> Vec<QuadraticObjective<f64>> {
173        vec![
174            QuadraticObjective::isotropic(8, 1.0, 0.7).expect("task"),
175            QuadraticObjective::from_curvature(vec![1.0, 4.0, 1.0, 4.0, 1.0, 4.0, 1.0, 4.0], 0.5)
176                .expect("task"),
177        ]
178    }
179
180    #[test]
181    fn weight_vector_round_trips() {
182        let mut opt = optimizer(11);
183        let flat = opt.weight_vector();
184        assert!(!flat.is_empty());
185        assert_eq!(flat.len(), opt.weight_count());
186        assert!(
187            flat.iter().any(|v| v.abs() > 1e-9),
188            "the weight vector is all zeros, so it is not reading real weights"
189        );
190
191        opt.set_weight_vector(&flat).expect("round trip");
192        assert_eq!(opt.weight_vector(), flat);
193
194        // Wrong lengths must be refused, not partially applied.
195        assert!(opt.set_weight_vector(&flat[..flat.len() - 1]).is_err());
196        let mut too_long = flat.clone();
197        too_long.push(0.0);
198        assert!(opt.set_weight_vector(&too_long).is_err());
199    }
200
201    /// Changing the weights must change what the optimizer does — otherwise
202    /// "meta-training" could not possibly matter.
203    #[test]
204    fn the_weights_actually_drive_the_update() {
205        let mut opt = optimizer(3);
206        let params = Array1::from_vec(vec![1.0; 8]);
207        let gradient = Array1::from_vec(vec![0.4, -0.3, 0.2, 0.9, -0.1, 0.5, -0.7, 0.15]);
208
209        let before = opt.step(&params, &gradient).expect("step");
210
211        let mut flat = opt.weight_vector();
212        // Push the readout bias hard negative: the step scale is a logistic of
213        // the readout, so this must shrink every update.
214        let last = flat.len() - 1;
215        flat[last] = -8.0;
216        let mut shrunk = optimizer(3);
217        shrunk.set_weight_vector(&flat).expect("set");
218        let after = shrunk.step(&params, &gradient).expect("step");
219
220        let moved_before = (&before - &params).mapv(f64::abs).sum();
221        let moved_after = (&after - &params).mapv(f64::abs).sum();
222        assert!(
223            moved_after < moved_before * 0.5,
224            "a strongly negative readout bias must shrink the update: \
225             {moved_before} -> {moved_after}"
226        );
227    }
228
229    #[test]
230    fn meta_loss_is_a_real_measurement() {
231        let tasks = training_tasks();
232        let refs: Vec<&dyn MetaObjective<f64>> =
233            tasks.iter().map(|t| t as &dyn MetaObjective<f64>).collect();
234        let trainer = GnnMetaTrainer::new(MetaTrainingConfig::default()).expect("trainer");
235
236        let a = trainer.meta_loss(&optimizer(1), &refs).expect("loss a");
237        let b = trainer.meta_loss(&optimizer(2), &refs).expect("loss b");
238        assert!(a.is_finite() && b.is_finite());
239        assert!(
240            a > 0.0 && b > 0.0,
241            "normalised loss curves must be positive"
242        );
243        assert!(
244            (a - b).abs() > 1e-9,
245            "two different weight draws scored identically ({a} vs {b}), so meta_loss is not \
246             reading the weights"
247        );
248
249        // Measuring must not mutate the optimizer it measures.
250        let opt = optimizer(1);
251        let snapshot = opt.weight_vector();
252        let repeat = trainer.meta_loss(&opt, &refs).expect("repeat");
253        assert_eq!(opt.weight_vector(), snapshot);
254        assert!((repeat - a).abs() < 1e-12, "meta_loss is not deterministic");
255    }
256
257    #[test]
258    fn meta_loss_rejects_an_empty_task_set() {
259        let trainer = GnnMetaTrainer::new(MetaTrainingConfig::default()).expect("trainer");
260        let empty: [&dyn MetaObjective<f64>; 0] = [];
261        assert!(trainer.meta_loss(&optimizer(1), &empty).is_err());
262        assert!(trainer.train(&mut optimizer(1), &empty).is_err());
263    }
264
265    /// `reset_state` must clear momentum, so the first step after a reset is the
266    /// same as the first step of a fresh optimizer.
267    #[test]
268    fn reset_state_clears_the_persistent_state() {
269        let params = Array1::from_vec(vec![1.0; 8]);
270        let gradient = Array1::from_vec(vec![0.5; 8]);
271
272        let mut fresh = optimizer(21);
273        let baseline = fresh.step(&params, &gradient).expect("step");
274
275        let mut used = optimizer(21);
276        for _ in 0..5 {
277            used.step(&params, &gradient).expect("warm-up step");
278        }
279        let warm = used.step(&params, &gradient).expect("step");
280        assert!(
281            (&warm - &baseline).mapv(f64::abs).sum() > 1e-12,
282            "five warm-up steps left no trace, so there is no state to reset"
283        );
284
285        used.reset_state();
286        let after_reset = used.step(&params, &gradient).expect("step");
287        assert!(
288            (&after_reset - &baseline).mapv(f64::abs).sum() < 1e-12,
289            "reset_state did not restore the fresh-optimizer behaviour"
290        );
291        // The weights must survive the reset.
292        assert_eq!(used.weight_vector(), fresh.weight_vector());
293    }
294
295    /// `reset_state` is public and clears the graph layout, the chunk bounds and
296    /// the gradient EMAs to zero length. Every *other* public accessor must stay
297    /// callable in between, with no intervening `step` to rebuild them — a caller
298    /// can write `reset_state(); get_state();` and must not get a panic or a
299    /// division by zero.
300    #[test]
301    fn every_accessor_stays_safe_after_a_bare_reset() {
302        let mut opt = optimizer(31);
303        // Warm it up first, so the reset really has state to clear.
304        let params = Array1::from_vec(vec![1.0; 8]);
305        let gradient = Array1::from_vec(vec![0.25; 8]);
306        for _ in 0..3 {
307            opt.step(&params, &gradient).expect("warm-up");
308        }
309
310        opt.reset_state();
311
312        // No `step` in between: these are the reachable read paths.
313        let state = opt.get_state();
314        assert_eq!(state.step_count, 0);
315        assert!(state.current_lr.is_finite());
316        assert!(state.grad_norm_ema.is_finite());
317        assert_eq!(opt.get_learning_rate(), state.current_lr);
318        // Documented to be 0 before the first step.
319        assert_eq!(opt.num_nodes(), 0);
320        assert!(opt.hidden_dim() > 0);
321        assert_eq!(opt.seed(), 31);
322        assert_eq!(opt.name(), "GNNOptimizer");
323        assert!(!opt.weight_vector().is_empty());
324        assert!(opt.weight_count() > 0);
325        // A second reset must be idempotent, not a panic.
326        opt.reset_state();
327        assert_eq!(opt.num_nodes(), 0);
328        // And the optimizer must still be usable afterwards.
329        assert!(opt.step(&params, &gradient).is_ok());
330        assert!(opt.num_nodes() > 0);
331    }
332}