Skip to main content

optirs_learned/
ntm_optimizer.rs

1//! Neural Turing Machine (NTM) Memory-Augmented Learned Optimizer
2//!
3//! This module implements a learned optimizer whose update rule is produced by a
4//! Neural Turing Machine: a feedforward controller coupled to an external memory
5//! matrix through a differentiable read/write head that uses the full content- and
6//! location-based addressing mechanism of Graves, Wayne & Danihelka (2014),
7//! *Neural Turing Machines* (arXiv:1410.5401), applied to the "learning to
8//! optimize" setting (Andrychowicz et al., 2016).
9//!
10//! # Overview
11//!
12//! The optimizer treats the flat parameter vector as a temporal stream. Each
13//! optimization [`AdvancedOptimizer::step`] partitions the parameters into
14//! `num_slots` contiguous chunks (clamped to the parameter count). Every chunk is
15//! processed in turn as one controller time-step, and the external memory plus the
16//! addressing weights and read vector persist across both chunks and steps, giving
17//! the optimizer a genuine recurrent memory.
18//!
19//! For each chunk the optimizer:
20//!
21//! 1. **Features.** Computes aggregate gradient statistics over the chunk (mean,
22//!    abs-mean, variance, bias-corrected momentum, bias-corrected RMS and sign
23//!    agreement) and concatenates them with the previous read vector to form the
24//!    controller input.
25//! 2. **Controller.** Maps that input through a real feedforward layer
26//!    (`tanh(W_in x + b_in)`) and emits every head parameter through dedicated
27//!    projection heads: the content key `k`, key strength `β = softplus(·)`,
28//!    interpolation gate `g = σ(·)`, shift distribution `s = softmax(·)`,
29//!    sharpening `γ = 1 + softplus(·)`, erase vector `e = σ(·)` and add vector
30//!    `a = tanh(·)`.
31//! 3. **Addressing.** Runs the complete pipeline — content addressing
32//!    (`w_c = softmax(β · cosine(k, M))`), interpolation
33//!    (`w_g = g·w_c + (1-g)·w_prev`), circular convolutional shift
34//!    (`w_s[i] = Σ_d w_g[(i-d) mod N] · s_d`) and sharpening
35//!    (`w[i] = w_s[i]^γ / Σ_j w_s[j]^γ`).
36//! 4. **Read / write.** Reads `r = Σ_i w[i]·M[i]` from memory, then writes with the
37//!    erase/add rule `M[i] ← M[i]∘(1 - w[i]·e) + w[i]·a`.
38//! 5. **Update.** Combines the controller hidden state and the read vector into a
39//!    strictly-positive per-chunk step scale in `(0, 1)`, and applies
40//!    `params[chunk] -= base_lr · scale · m̂[chunk]`, where `m̂` is the
41//!    bias-corrected EMA-smoothed gradient (the descent direction).
42//!
43//! The controller weights and the initial memory are initialised deterministically
44//! from a seed via [`mod@scirs2_core::random`] and held fixed during
45//! [`AdvancedOptimizer::step`] (this is the genuine inference optimizer; the memory,
46//! addressing weights, read vector and gradient EMAs are persistent state that
47//! evolves across steps). Meta-training of the controller weights is available
48//! through [`meta_training`], which supplies the
49//! [`crate::es_meta_training::MetaTrainable`] implementation for
50//! [`crate::es_meta_training::EsMetaTrainer`] — see
51//! [`NtmOptimizer::weight_vector`] / [`NtmOptimizer::set_weight_vector`] /
52//! [`NtmOptimizer::reset_state`].
53
54/// Meta-training of the learned controller weights by evolution strategies.
55///
56/// F75: everything below computes a real update from real learned weights, but
57/// nothing ever *trained* those weights — they stayed at their seeded draw
58/// forever. [`meta_training`] supplies the [`crate::es_meta_training::MetaTrainable`]
59/// implementation that makes [`crate::es_meta_training::EsMetaTrainer`] able to
60/// train them.
61pub mod meta_training;
62
63use scirs2_core::ndarray::{s, Array1, Array2};
64use scirs2_core::numeric::Float;
65use scirs2_core::random::{Random, Rng};
66use std::fmt::Debug;
67
68use crate::domain_optimizers::{clip_grad_norm, l2_norm, AdvancedOptimizer, OptimizerStateInfo};
69use crate::error::{OptimError, Result};
70
71/// Number of scalar gradient features extracted per chunk, every step.
72///
73/// In order: mean gradient, abs-mean gradient, gradient variance, momentum
74/// (bias-corrected gradient EMA), RMS (sqrt of bias-corrected squared-gradient
75/// EMA) and sign agreement.
76const CHUNK_FEATURE_DIM: usize = 6;
77
78/// Configuration for [`NtmOptimizer`].
79///
80/// Numeric hyper-parameters are stored as `f64` and converted to the optimizer's
81/// scalar type `T` when the optimizer is constructed.
82#[derive(Debug, Clone)]
83pub struct NtmOptimizerConfig {
84    /// Requested number of parameter chunks (clamped to the parameter count).
85    pub num_slots: usize,
86    /// Number of memory locations (rows of the memory matrix `M`).
87    pub num_locations: usize,
88    /// Width of each memory location (columns of `M`); also the read/key width.
89    pub mem_width: usize,
90    /// Controller hidden-layer dimension.
91    pub hidden_dim: usize,
92    /// Maximum convolutional shift magnitude; allowed shifts are
93    /// `-shift_range ..= shift_range`, giving `2·shift_range + 1` entries.
94    pub shift_range: usize,
95    /// Half-width of the uniform interval used to initialise the memory matrix.
96    pub mem_init_scale: f64,
97    /// Base learning rate applied before the learned per-chunk step scale.
98    pub base_lr: f64,
99    /// EMA decay for the gradient (momentum), often called `beta1`.
100    pub momentum_decay: f64,
101    /// EMA decay for the squared gradient (RMS), often called `beta2`.
102    pub rms_decay: f64,
103    /// Maximum gradient L2 norm; larger gradients are rescaled before use.
104    pub max_grad_norm: f64,
105    /// Seed for deterministic weight and memory initialisation.
106    pub seed: u64,
107}
108
109impl Default for NtmOptimizerConfig {
110    fn default() -> Self {
111        Self {
112            num_slots: 8,
113            num_locations: 16,
114            mem_width: 8,
115            hidden_dim: 16,
116            shift_range: 1,
117            mem_init_scale: 0.1,
118            base_lr: 0.01,
119            momentum_decay: 0.9,
120            rms_decay: 0.999,
121            max_grad_norm: 10.0,
122            seed: 0x47A5_E500,
123        }
124    }
125}
126
127/// Convert an `f64` into the optimizer scalar, falling back to zero on overflow.
128fn cast<T: Float>(value: f64) -> T {
129    T::from(value).unwrap_or_else(T::zero)
130}
131
132/// Numerically stable logistic (sigmoid) activation.
133fn sigmoid<T: Float>(x: T) -> T {
134    if x >= T::zero() {
135        T::one() / (T::one() + (-x).exp())
136    } else {
137        let ex = x.exp();
138        ex / (T::one() + ex)
139    }
140}
141
142/// Numerically stable softplus, `ln(1 + e^x) ≥ 0`.
143fn softplus<T: Float>(x: T) -> T {
144    x.max(T::zero()) + (T::one() + (-x.abs()).exp()).ln()
145}
146
147/// Numerically stable softmax over a 1-D logit vector.
148fn softmax<T: Float>(logits: &Array1<T>) -> Array1<T> {
149    let max = logits
150        .iter()
151        .fold(T::neg_infinity(), |m, &x| if x > m { x } else { m });
152    let max = if max.is_finite() { max } else { T::zero() };
153    let exps = logits.mapv(|x| (x - max).exp());
154    let sum = exps.iter().fold(T::zero(), |a, &x| a + x);
155    let denom = if sum > T::zero() { sum } else { T::one() };
156    exps.mapv(|x| x / denom)
157}
158
159/// Glorot-uniform initialisation of a `rows x cols` weight matrix.
160fn init_matrix<T, R>(rng: &mut Random<R>, rows: usize, cols: usize) -> Array2<T>
161where
162    T: Float,
163    R: Rng,
164{
165    let limit = (6.0 / (rows as f64 + cols as f64)).sqrt();
166    Array2::from_shape_fn((rows, cols), |_| {
167        let value: f64 = rng.random_range(-limit..limit);
168        cast(value)
169    })
170}
171
172/// Glorot-uniform initialisation of a length-`len` vector.
173fn init_vector<T, R>(rng: &mut Random<R>, len: usize) -> Array1<T>
174where
175    T: Float,
176    R: Rng,
177{
178    let limit = (6.0 / (len as f64 + 1.0)).sqrt();
179    Array1::from_shape_fn(len, |_| {
180        let value: f64 = rng.random_range(-limit..limit);
181        cast(value)
182    })
183}
184
185/// Uniform initialisation of a `rows x cols` matrix in `(-scale, scale)`.
186fn init_matrix_uniform<T, R>(rng: &mut Random<R>, rows: usize, cols: usize, scale: f64) -> Array2<T>
187where
188    T: Float,
189    R: Rng,
190{
191    let limit = scale.abs().max(f64::MIN_POSITIVE);
192    Array2::from_shape_fn((rows, cols), |_| {
193        let value: f64 = rng.random_range(-limit..limit);
194        cast(value)
195    })
196}
197
198/// The full set of head parameters emitted by the controller for one time-step.
199#[derive(Debug, Clone)]
200struct HeadParams<T: Float + Debug + Send + Sync + 'static> {
201    /// Content key (length `mem_width`).
202    key: Array1<T>,
203    /// Key strength `β ≥ 0`.
204    beta: T,
205    /// Interpolation gate `g ∈ [0, 1]`.
206    gate: T,
207    /// Shift distribution over the allowed shifts (length `2·shift_range + 1`).
208    shift: Array1<T>,
209    /// Sharpening exponent `γ ≥ 1`.
210    gamma: T,
211    /// Erase vector `e ∈ [0, 1]^width`.
212    erase: Array1<T>,
213    /// Add vector `a` (length `mem_width`).
214    add: Array1<T>,
215}
216
217/// A genuine feedforward NTM controller with one hidden layer.
218///
219/// The controller maps a feature/read input through `tanh(W_in x + b_in)` and then
220/// emits every addressing-head parameter and an output-head step scale through
221/// dedicated linear projections with the appropriate activations. All weights are
222/// fixed during inference; they are exposed only through forward methods so that a
223/// meta-training loop could optimise them without altering this forward path.
224#[derive(Debug, Clone)]
225struct NtmController<T: Float + Debug + Send + Sync + 'static> {
226    w_in: Array2<T>,
227    b_in: Array1<T>,
228    w_key: Array2<T>,
229    b_key: Array1<T>,
230    w_beta: Array1<T>,
231    b_beta: T,
232    w_gate: Array1<T>,
233    b_gate: T,
234    w_shift: Array2<T>,
235    b_shift: Array1<T>,
236    w_gamma: Array1<T>,
237    b_gamma: T,
238    w_erase: Array2<T>,
239    b_erase: Array1<T>,
240    w_add: Array2<T>,
241    b_add: Array1<T>,
242    w_out: Array1<T>,
243    v_out: Array1<T>,
244    b_out: T,
245}
246
247impl<T: Float + Debug + Send + Sync + 'static> NtmController<T> {
248    /// Deterministically initialise every controller weight.
249    fn new<R: Rng>(
250        rng: &mut Random<R>,
251        input_dim: usize,
252        hidden: usize,
253        mem_width: usize,
254        num_shifts: usize,
255    ) -> Self {
256        Self {
257            w_in: init_matrix(rng, hidden, input_dim),
258            b_in: Array1::zeros(hidden),
259            w_key: init_matrix(rng, mem_width, hidden),
260            b_key: Array1::zeros(mem_width),
261            w_beta: init_vector(rng, hidden),
262            b_beta: T::zero(),
263            w_gate: init_vector(rng, hidden),
264            b_gate: T::zero(),
265            w_shift: init_matrix(rng, num_shifts, hidden),
266            b_shift: Array1::zeros(num_shifts),
267            w_gamma: init_vector(rng, hidden),
268            b_gamma: T::zero(),
269            w_erase: init_matrix(rng, mem_width, hidden),
270            b_erase: Array1::zeros(mem_width),
271            w_add: init_matrix(rng, mem_width, hidden),
272            b_add: Array1::zeros(mem_width),
273            w_out: init_vector(rng, hidden),
274            v_out: init_vector(rng, mem_width),
275            b_out: T::zero(),
276        }
277    }
278
279    /// Compute the hidden state `tanh(W_in x + b_in)`.
280    fn hidden_state(&self, input: &Array1<T>) -> Array1<T> {
281        (self.w_in.dot(input) + &self.b_in).mapv(|x| x.tanh())
282    }
283
284    /// Emit every addressing-head parameter from the hidden state.
285    fn heads(&self, hidden: &Array1<T>) -> HeadParams<T> {
286        let key = self.w_key.dot(hidden) + &self.b_key;
287        let beta = softplus(self.w_beta.dot(hidden) + self.b_beta);
288        let gate = sigmoid(self.w_gate.dot(hidden) + self.b_gate);
289        let shift = softmax(&(self.w_shift.dot(hidden) + &self.b_shift));
290        let gamma = T::one() + softplus(self.w_gamma.dot(hidden) + self.b_gamma);
291        let erase = (self.w_erase.dot(hidden) + &self.b_erase).mapv(sigmoid);
292        let add = (self.w_add.dot(hidden) + &self.b_add).mapv(|x| x.tanh());
293        HeadParams {
294            key,
295            beta,
296            gate,
297            shift,
298            gamma,
299            erase,
300            add,
301        }
302    }
303
304    /// Map the hidden state and the memory read into a step scale in `(0, 1)`.
305    fn output_scale(&self, hidden: &Array1<T>, read: &Array1<T>) -> T {
306        sigmoid(self.w_out.dot(hidden) + self.v_out.dot(read) + self.b_out)
307    }
308}
309
310/// Content-based addressing: `w_c = softmax(β · cosine_similarity(k, M[i]))`.
311fn content_addressing<T>(memory: &Array2<T>, key: &Array1<T>, beta: T) -> Array1<T>
312where
313    T: Float + Debug + Send + Sync + 'static,
314{
315    let eps = cast::<T>(1e-8);
316    let key_norm = key.iter().fold(T::zero(), |acc, &x| acc + x * x).sqrt();
317    let mut sims = Array1::<T>::zeros(memory.nrows());
318    for (slot, row) in sims.iter_mut().zip(memory.outer_iter()) {
319        let mut dot = T::zero();
320        let mut row_sq = T::zero();
321        for (&kv, &rv) in key.iter().zip(row.iter()) {
322            dot = dot + kv * rv;
323            row_sq = row_sq + rv * rv;
324        }
325        let row_norm = row_sq.sqrt();
326        let cosine = dot / (key_norm * row_norm + eps);
327        *slot = beta * cosine;
328    }
329    softmax(&sims)
330}
331
332/// Interpolation: `w_g = g · w_c + (1 - g) · w_prev`.
333fn interpolate<T>(content: &Array1<T>, prev: &Array1<T>, gate: T) -> Array1<T>
334where
335    T: Float + Debug + Send + Sync + 'static,
336{
337    let one_minus = T::one() - gate;
338    content.mapv(|x| x * gate) + &prev.mapv(|x| x * one_minus)
339}
340
341/// Circular convolutional shift: `w_s[i] = Σ_d w_g[(i - d) mod N] · s_d`.
342fn circular_shift<T>(gated: &Array1<T>, shift: &Array1<T>, offsets: &[isize]) -> Array1<T>
343where
344    T: Float + Debug + Send + Sync + 'static,
345{
346    let n = gated.len();
347    let n_isize = n as isize;
348    let mut shifted = Array1::<T>::zeros(n);
349    for (i, slot) in shifted.iter_mut().enumerate() {
350        let mut acc = T::zero();
351        for (idx, &offset) in offsets.iter().enumerate() {
352            let src = (((i as isize - offset) % n_isize) + n_isize) % n_isize;
353            acc = acc + gated[src as usize] * shift[idx];
354        }
355        *slot = acc;
356    }
357    shifted
358}
359
360/// Sharpening: `w[i] = w_s[i]^γ / Σ_j w_s[j]^γ`.
361fn sharpen<T>(shifted: &Array1<T>, gamma: T) -> Array1<T>
362where
363    T: Float + Debug + Send + Sync + 'static,
364{
365    let powered = shifted.mapv(|x| {
366        if x > T::zero() {
367            x.powf(gamma)
368        } else {
369            T::zero()
370        }
371    });
372    let sum = powered.iter().fold(T::zero(), |a, &x| a + x);
373    let denom = if sum > T::zero() { sum } else { T::one() };
374    powered.mapv(|x| x / denom)
375}
376
377/// Run the full addressing pipeline, returning the final (sharpened) weights.
378fn address<T>(
379    memory: &Array2<T>,
380    heads: &HeadParams<T>,
381    prev_weights: &Array1<T>,
382    offsets: &[isize],
383) -> Array1<T>
384where
385    T: Float + Debug + Send + Sync + 'static,
386{
387    let content = content_addressing(memory, &heads.key, heads.beta);
388    let gated = interpolate(&content, prev_weights, heads.gate);
389    let shifted = circular_shift(&gated, &heads.shift, offsets);
390    sharpen(&shifted, heads.gamma)
391}
392
393/// Read from memory: `r = Σ_i w[i] · M[i]`.
394fn read_memory<T>(memory: &Array2<T>, weights: &Array1<T>) -> Array1<T>
395where
396    T: Float + Debug + Send + Sync + 'static,
397{
398    weights
399        .iter()
400        .zip(memory.outer_iter())
401        .fold(Array1::<T>::zeros(memory.ncols()), |acc, (&weight, row)| {
402            acc + &row.mapv(|val| val * weight)
403        })
404}
405
406/// Write to memory: `M[i] ← M[i] ∘ (1 - w[i]·e) + w[i]·a`.
407fn write_memory<T>(memory: &mut Array2<T>, weights: &Array1<T>, erase: &Array1<T>, add: &Array1<T>)
408where
409    T: Float + Debug + Send + Sync + 'static,
410{
411    for (i, mut row) in memory.outer_iter_mut().enumerate() {
412        let weight = weights[i];
413        for (j, slot) in row.iter_mut().enumerate() {
414            *slot = *slot * (T::one() - weight * erase[j]) + weight * add[j];
415        }
416    }
417}
418
419/// Compute the aggregate gradient feature vector for one chunk.
420fn chunk_features<T>(
421    grad: &Array1<T>,
422    m_hat: &Array1<T>,
423    v_hat: &Array1<T>,
424    start: usize,
425    end: usize,
426) -> Array1<T>
427where
428    T: Float + Debug + Send + Sync + 'static,
429{
430    let size = cast::<T>((end - start) as f64);
431    let chunk = grad.slice(s![start..end]);
432
433    let mean_grad = chunk.sum() / size;
434    let abs_mean_grad = chunk.iter().fold(T::zero(), |a, &x| a + x.abs()) / size;
435    let variance = chunk.iter().fold(T::zero(), |a, &x| {
436        let d = x - mean_grad;
437        a + d * d
438    }) / size;
439
440    let momentum = m_hat.slice(s![start..end]).sum() / size;
441    let rms = v_hat
442        .slice(s![start..end])
443        .iter()
444        .fold(T::zero(), |a, &x| a + x.max(T::zero()).sqrt())
445        / size;
446
447    let mean_sign = mean_grad.signum();
448    let sign_agreement = chunk
449        .iter()
450        .fold(T::zero(), |a, &x| a + x.signum() * mean_sign)
451        / size;
452
453    Array1::from_vec(vec![
454        mean_grad,
455        abs_mean_grad,
456        variance,
457        momentum,
458        rms,
459        sign_agreement,
460    ])
461}
462
463/// Concatenate two 1-D arrays into a new one.
464fn concat<T>(head: &Array1<T>, tail: &Array1<T>) -> Array1<T>
465where
466    T: Float + Debug + Send + Sync + 'static,
467{
468    let mut values = Vec::with_capacity(head.len() + tail.len());
469    values.extend(head.iter().copied());
470    values.extend(tail.iter().copied());
471    Array1::from_vec(values)
472}
473
474/// Partition `param_len` indices into `num_slots` contiguous, near-equal chunks.
475fn build_chunks(param_len: usize, num_slots: usize) -> Vec<(usize, usize)> {
476    let base = param_len / num_slots;
477    let remainder = param_len % num_slots;
478    let mut bounds = Vec::with_capacity(num_slots);
479    let mut start = 0;
480    for n in 0..num_slots {
481        let size = base + usize::from(n < remainder);
482        bounds.push((start, start + size));
483        start += size;
484    }
485    bounds
486}
487
488/// Build the ordered list of allowed integer shift offsets `-range ..= range`.
489fn build_shift_offsets(shift_range: usize) -> Vec<isize> {
490    let range = shift_range as isize;
491    (-range..=range).collect()
492}
493
494/// A memory-augmented learned optimizer driven by a Neural Turing Machine.
495///
496/// Implements [`AdvancedOptimizer`]. The controller weights and the initial memory
497/// are fixed; the memory matrix, the previous addressing weights, the previous read
498/// vector and the gradient EMAs are persistent state that evolves across steps.
499#[derive(Debug, Clone)]
500pub struct NtmOptimizer<T: Float + Debug + Send + Sync + 'static> {
501    // --- configuration (fixed) ---
502    requested_num_slots: usize,
503    num_locations: usize,
504    mem_width: usize,
505    hidden_dim: usize,
506    shift_range: usize,
507    base_lr: T,
508    momentum_decay: T,
509    rms_decay: T,
510    max_grad_norm: T,
511    norm_ema_decay: T,
512    seed: u64,
513    shift_offsets: Vec<isize>,
514
515    // --- learned controller (fixed during step) ---
516    controller: NtmController<T>,
517
518    // --- the seeded initial memory, kept so `reset_state` can restore it ---
519    initial_memory: Array2<T>,
520
521    // --- persistent memory + addressing state ---
522    memory: Array2<T>,
523    prev_weights: Array1<T>,
524    prev_read: Array1<T>,
525    last_weights: Array1<T>,
526
527    // --- parameter layout (depends on the actual parameter length) ---
528    param_len: usize,
529    num_slots: usize,
530    chunk_bounds: Vec<(usize, usize)>,
531
532    // --- persistent optimization state ---
533    grad_ema: Array1<T>,
534    grad_sq_ema: Array1<T>,
535
536    // --- bookkeeping ---
537    step_count: usize,
538    current_lr: T,
539    grad_norm_ema: T,
540}
541
542impl<T: Float + Debug + Send + Sync + 'static> NtmOptimizer<T> {
543    /// Construct a new optimizer from a configuration.
544    ///
545    /// The controller weights and the initial memory are initialised
546    /// deterministically from `config.seed`. Parameter-dependent state is created
547    /// lazily on the first [`AdvancedOptimizer::step`] once the parameter length is
548    /// known.
549    ///
550    /// # Errors
551    /// Returns [`OptimError::InvalidConfig`] if a hyper-parameter is out of range.
552    pub fn new(config: NtmOptimizerConfig) -> Result<Self> {
553        if config.num_slots == 0 {
554            return Err(OptimError::InvalidConfig(
555                "num_slots must be at least 1".to_string(),
556            ));
557        }
558        if config.num_locations == 0 {
559            return Err(OptimError::InvalidConfig(
560                "num_locations must be at least 1".to_string(),
561            ));
562        }
563        if config.mem_width == 0 {
564            return Err(OptimError::InvalidConfig(
565                "mem_width must be at least 1".to_string(),
566            ));
567        }
568        if config.hidden_dim == 0 {
569            return Err(OptimError::InvalidConfig(
570                "hidden_dim must be at least 1".to_string(),
571            ));
572        }
573        if config.shift_range == 0 {
574            return Err(OptimError::InvalidConfig(
575                "shift_range must be at least 1".to_string(),
576            ));
577        }
578        if !(config.mem_init_scale.is_finite() && config.mem_init_scale > 0.0) {
579            return Err(OptimError::InvalidConfig(
580                "mem_init_scale must be a positive finite value".to_string(),
581            ));
582        }
583        if !(config.base_lr.is_finite() && config.base_lr > 0.0) {
584            return Err(OptimError::InvalidConfig(
585                "base_lr must be a positive finite value".to_string(),
586            ));
587        }
588        if !(config.max_grad_norm.is_finite() && config.max_grad_norm > 0.0) {
589            return Err(OptimError::InvalidConfig(
590                "max_grad_norm must be a positive finite value".to_string(),
591            ));
592        }
593        for (name, value) in [
594            ("momentum_decay", config.momentum_decay),
595            ("rms_decay", config.rms_decay),
596        ] {
597            if !(0.0..1.0).contains(&value) {
598                return Err(OptimError::InvalidConfig(format!(
599                    "{name} must lie in [0, 1), got {value}"
600                )));
601            }
602        }
603
604        let num_shifts = 2 * config.shift_range + 1;
605        let input_dim = CHUNK_FEATURE_DIM + config.mem_width;
606
607        let mut rng = Random::seed(config.seed);
608        let controller = NtmController::new(
609            &mut rng,
610            input_dim,
611            config.hidden_dim,
612            config.mem_width,
613            num_shifts,
614        );
615        let memory = init_matrix_uniform(
616            &mut rng,
617            config.num_locations,
618            config.mem_width,
619            config.mem_init_scale,
620        );
621
622        let uniform = cast::<T>(1.0 / config.num_locations as f64);
623        let prev_weights = Array1::from_elem(config.num_locations, uniform);
624
625        Ok(Self {
626            requested_num_slots: config.num_slots,
627            num_locations: config.num_locations,
628            mem_width: config.mem_width,
629            hidden_dim: config.hidden_dim,
630            shift_range: config.shift_range,
631            base_lr: cast(config.base_lr),
632            momentum_decay: cast(config.momentum_decay),
633            rms_decay: cast(config.rms_decay),
634            max_grad_norm: cast(config.max_grad_norm),
635            norm_ema_decay: cast(0.99),
636            seed: config.seed,
637            shift_offsets: build_shift_offsets(config.shift_range),
638            controller,
639            initial_memory: memory.clone(),
640            memory,
641            prev_weights: prev_weights.clone(),
642            prev_read: Array1::zeros(config.mem_width),
643            last_weights: prev_weights,
644            param_len: 0,
645            num_slots: 0,
646            chunk_bounds: Vec::new(),
647            grad_ema: Array1::zeros(0),
648            grad_sq_ema: Array1::zeros(0),
649            step_count: 0,
650            current_lr: cast(config.base_lr),
651            grad_norm_ema: T::zero(),
652        })
653    }
654
655    /// The actual number of parameter chunks (after clamping to the parameter
656    /// count). Returns `0` before the first step.
657    pub fn num_slots(&self) -> usize {
658        self.num_slots
659    }
660
661    /// The number of memory locations (rows of `M`).
662    pub fn num_locations(&self) -> usize {
663        self.num_locations
664    }
665
666    /// The memory width (columns of `M`).
667    pub fn mem_width(&self) -> usize {
668        self.mem_width
669    }
670
671    /// The controller hidden dimension.
672    pub fn hidden_dim(&self) -> usize {
673        self.hidden_dim
674    }
675
676    /// The shift range (allowed shifts are `-range ..= range`).
677    pub fn shift_range(&self) -> usize {
678        self.shift_range
679    }
680
681    /// The seed used for deterministic initialisation.
682    pub fn seed(&self) -> u64 {
683        self.seed
684    }
685
686    /// A read-only view of the external memory matrix `M`.
687    pub fn memory(&self) -> &Array2<T> {
688        &self.memory
689    }
690
691    /// The most recent (sharpened) addressing weight distribution.
692    pub fn last_address_weights(&self) -> &Array1<T> {
693        &self.last_weights
694    }
695
696    /// (Re)initialise parameter-dependent layout and EMAs for `param_len`.
697    fn initialize_for(&mut self, param_len: usize) {
698        let num_slots = self.requested_num_slots.min(param_len).max(1);
699        self.param_len = param_len;
700        self.num_slots = num_slots;
701        self.chunk_bounds = build_chunks(param_len, num_slots);
702        self.grad_ema = Array1::zeros(param_len);
703        self.grad_sq_ema = Array1::zeros(param_len);
704        self.step_count = 0;
705        self.grad_norm_ema = T::zero();
706    }
707}
708
709impl<T: Float + Debug + Send + Sync + 'static> AdvancedOptimizer<T> for NtmOptimizer<T> {
710    fn step(&mut self, params: &Array1<T>, gradients: &Array1<T>) -> Result<Array1<T>> {
711        if params.len() != gradients.len() {
712            return Err(OptimError::InvalidConfig(format!(
713                "Parameter length {} != gradient length {}",
714                params.len(),
715                gradients.len()
716            )));
717        }
718        if params.is_empty() {
719            return Err(OptimError::InsufficientData(
720                "Empty parameter array".to_string(),
721            ));
722        }
723
724        if self.param_len != params.len() {
725            self.initialize_for(params.len());
726        }
727
728        // 1. Clip the gradient and track its EMA norm.
729        let grad = clip_grad_norm(gradients, self.max_grad_norm);
730        let grad_norm = l2_norm(&grad);
731        self.grad_norm_ema =
732            self.norm_ema_decay * self.grad_norm_ema + (T::one() - self.norm_ema_decay) * grad_norm;
733
734        // 2. Update the per-element gradient and squared-gradient EMAs.
735        let beta1 = self.momentum_decay;
736        let beta2 = self.rms_decay;
737        self.grad_ema = self.grad_ema.mapv(|m| m * beta1) + &grad.mapv(|g| g * (T::one() - beta1));
738        self.grad_sq_ema =
739            self.grad_sq_ema.mapv(|v| v * beta2) + &grad.mapv(|g| g * g * (T::one() - beta2));
740
741        // 3. Bias-correct the EMAs (Adam-style), giving the effective direction.
742        let step = self.step_count + 1;
743        let bias1 = T::one() - beta1.powi(step as i32);
744        let bias2 = T::one() - beta2.powi(step as i32);
745        let m_hat = self.grad_ema.mapv(|m| m / bias1);
746        let v_hat = self.grad_sq_ema.mapv(|v| v / bias2);
747
748        // 4. Process every chunk as one controller time-step, threading the
749        //    persistent memory, addressing weights and read vector through.
750        let base_lr = self.base_lr;
751        let mut scale_vector = Array1::<T>::zeros(self.param_len);
752        let mut scale_sum = T::zero();
753
754        for c in 0..self.num_slots {
755            let (start, end) = self.chunk_bounds[c];
756
757            let features = chunk_features(&grad, &m_hat, &v_hat, start, end);
758            let input = concat(&features, &self.prev_read);
759
760            let hidden = self.controller.hidden_state(&input);
761            let heads = self.controller.heads(&hidden);
762
763            let weights = address(
764                &self.memory,
765                &heads,
766                &self.prev_weights,
767                &self.shift_offsets,
768            );
769            let read = read_memory(&self.memory, &weights);
770            let scale = self.controller.output_scale(&hidden, &read);
771            write_memory(&mut self.memory, &weights, &heads.erase, &heads.add);
772
773            self.prev_weights = weights.clone();
774            self.last_weights = weights;
775            self.prev_read = read;
776
777            scale_vector.slice_mut(s![start..end]).fill(scale);
778            scale_sum = scale_sum + scale;
779        }
780
781        // 5. Apply the per-chunk-scaled, EMA-smoothed update.
782        let update = (&scale_vector * &m_hat).mapv(|u| u * base_lr);
783        let new_params = params - &update;
784
785        // 6. Effective learning rate = base_lr · mean chunk scale; advance counter.
786        let mean_scale = scale_sum / cast::<T>(self.num_slots as f64);
787        self.current_lr = base_lr * mean_scale;
788        self.step_count += 1;
789
790        Ok(new_params)
791    }
792
793    fn get_learning_rate(&self) -> T {
794        self.current_lr
795    }
796
797    fn set_learning_rate(&mut self, lr: T) {
798        self.base_lr = lr;
799        self.current_lr = lr;
800    }
801
802    fn name(&self) -> &str {
803        "NtmOptimizer"
804    }
805
806    fn get_state(&self) -> OptimizerStateInfo<T> {
807        OptimizerStateInfo {
808            step_count: self.step_count,
809            current_lr: self.current_lr,
810            grad_norm_ema: self.grad_norm_ema,
811        }
812    }
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818    use scirs2_core::ndarray::Array1;
819
820    fn make_optimizer(seed: u64) -> NtmOptimizer<f64> {
821        NtmOptimizer::new(NtmOptimizerConfig {
822            seed,
823            ..Default::default()
824        })
825        .expect("default configuration must be valid")
826    }
827
828    fn l2(a: &Array1<f64>) -> f64 {
829        a.iter().map(|x| x * x).sum::<f64>().sqrt()
830    }
831
832    fn is_distribution(weights: &Array1<f64>) -> bool {
833        let non_negative = weights.iter().all(|&w| w >= -1e-12);
834        let sum: f64 = weights.iter().sum();
835        non_negative && (sum - 1.0).abs() < 1e-9
836    }
837
838    fn entropy(weights: &Array1<f64>) -> f64 {
839        weights
840            .iter()
841            .filter(|&&w| w > 1e-15)
842            .map(|&w| -w * w.ln())
843            .sum()
844    }
845
846    #[test]
847    fn test_ntm_preserves_shape() {
848        for len in [1usize, 3, 7, 16, 40, 100] {
849            let mut opt = make_optimizer(7);
850            let params = Array1::from_shape_fn(len, |i| (i as f64) * 0.1 + 0.5);
851            let grads = Array1::from_shape_fn(len, |i| (i as f64) * 0.01 + 0.2);
852            let out = opt.step(&params, &grads).expect("step should succeed");
853            assert_eq!(out.len(), params.len(), "output length must match input");
854        }
855    }
856
857    #[test]
858    fn test_ntm_deterministic_same_seed() {
859        let params = Array1::from_vec(vec![1.0, -2.0, 3.0, -4.0, 5.0, 0.5, 0.25, 0.1]);
860        let grads = Array1::from_vec(vec![0.1, 0.2, -0.3, 0.4, -0.5, 0.05, 0.02, 0.3]);
861
862        let mut a = make_optimizer(321);
863        let mut b = make_optimizer(321);
864        let out_a = a.step(&params, &grads).expect("a step");
865        let out_b = b.step(&params, &grads).expect("b step");
866
867        for (x, y) in out_a.iter().zip(out_b.iter()) {
868            assert!(
869                (x - y).abs() < 1e-12,
870                "same seed must give identical output: {x} vs {y}"
871            );
872        }
873    }
874
875    #[test]
876    fn test_ntm_different_seed_differs() {
877        let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
878        let grads = Array1::from_vec(vec![0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3]);
879
880        let mut a = make_optimizer(1);
881        let mut b = make_optimizer(2);
882        let out_a = a.step(&params, &grads).expect("a step");
883        let out_b = b.step(&params, &grads).expect("b step");
884
885        let diff: f64 = out_a
886            .iter()
887            .zip(out_b.iter())
888            .map(|(x, y)| (x - y).abs())
889            .sum();
890        assert!(
891            diff > 0.0,
892            "different seeds should produce different updates"
893        );
894    }
895
896    #[test]
897    fn test_ntm_addressing_is_distribution_every_step() {
898        // After sharpening, the addressing weights must be a valid probability
899        // distribution (non-negative, sum ≈ 1) at every step.
900        let mut opt = make_optimizer(17);
901        let params = Array1::from_shape_fn(24, |i| 0.5 + 0.1 * (i as f64));
902        let grads = Array1::from_shape_fn(24, |i| 0.3 - 0.02 * (i as f64));
903
904        for _ in 0..25 {
905            opt.step(&params, &grads).expect("step should succeed");
906            assert!(
907                is_distribution(opt.last_address_weights()),
908                "addressing weights must form a valid probability distribution"
909            );
910            assert_eq!(
911                opt.last_address_weights().len(),
912                opt.num_locations(),
913                "weights span all memory locations"
914            );
915        }
916    }
917
918    #[test]
919    fn test_ntm_content_addressing_is_distribution() {
920        let memory = Array2::from_shape_fn((6, 4), |(i, j)| 0.1 * (i as f64) - 0.05 * (j as f64));
921        let key = Array1::from_vec(vec![0.2, -0.1, 0.3, 0.05]);
922        let w_c = content_addressing(&memory, &key, 2.0);
923        assert!(
924            is_distribution(&w_c),
925            "content weights must be a distribution"
926        );
927    }
928
929    #[test]
930    fn test_ntm_content_addressing_prefers_matching_row() {
931        // A key equal to a memory row should give that row the largest weight when
932        // the key strength is high.
933        let memory = Array2::from_shape_fn((4, 3), |(i, j)| (i as f64) + 0.1 * (j as f64));
934        let key = memory.row(2).to_owned();
935        let w_c = content_addressing(&memory, &key, 30.0);
936        let best = w_c
937            .iter()
938            .enumerate()
939            .fold(
940                (0usize, f64::MIN),
941                |(bi, bv), (i, &v)| {
942                    if v > bv {
943                        (i, v)
944                    } else {
945                        (bi, bv)
946                    }
947                },
948            )
949            .0;
950        assert_eq!(
951            best, 2,
952            "content addressing should focus on the matching row"
953        );
954    }
955
956    #[test]
957    fn test_ntm_interpolation_blends() {
958        let content = Array1::from_vec(vec![1.0, 0.0, 0.0, 0.0]);
959        let prev = Array1::from_vec(vec![0.0, 0.0, 0.0, 1.0]);
960        let blended = interpolate(&content, &prev, 0.25);
961        assert!((blended[0] - 0.25).abs() < 1e-12);
962        assert!((blended[3] - 0.75).abs() < 1e-12);
963        assert!(
964            is_distribution(&blended),
965            "blend of distributions is a distribution"
966        );
967    }
968
969    #[test]
970    fn test_ntm_circular_shift_rotates() {
971        // A shift distribution focused on +1 must rotate the weights by one slot.
972        let offsets = build_shift_offsets(1); // [-1, 0, 1]
973        let gated = Array1::from_vec(vec![1.0, 0.0, 0.0, 0.0]);
974        let shift_right = Array1::from_vec(vec![0.0, 0.0, 1.0]); // offset +1
975        let shifted = circular_shift(&gated, &shift_right, &offsets);
976        assert!(
977            (shifted[1] - 1.0).abs() < 1e-12,
978            "mass should move to index 1"
979        );
980        assert!(is_distribution(&shifted));
981
982        let shift_left = Array1::from_vec(vec![1.0, 0.0, 0.0]); // offset -1
983        let shifted_left = circular_shift(&gated, &shift_left, &offsets);
984        assert!(
985            (shifted_left[3] - 1.0).abs() < 1e-12,
986            "leftward shift wraps to the last index"
987        );
988    }
989
990    #[test]
991    fn test_ntm_sharpening_concentrates_distribution() {
992        // Larger gamma must reduce the entropy of the distribution.
993        let base = softmax(&Array1::from_vec(vec![0.4, 0.1, 0.3, 0.2, 0.0]));
994        let mild = sharpen(&base, 1.0);
995        let sharp = sharpen(&base, 8.0);
996        assert!(is_distribution(&mild));
997        assert!(is_distribution(&sharp));
998        assert!(
999            entropy(&sharp) < entropy(&mild),
1000            "sharpening with large gamma must decrease entropy: {} !< {}",
1001            entropy(&sharp),
1002            entropy(&mild)
1003        );
1004    }
1005
1006    #[test]
1007    fn test_ntm_write_then_read_recovers_content() {
1008        // Writing with a one-hot weight and full erase, then reading with the same
1009        // one-hot weight, must recover the written content.
1010        let mut memory =
1011            Array2::from_shape_fn((5, 3), |(i, j)| 0.3 * (i as f64) - 0.1 * (j as f64));
1012        let mut weights = Array1::<f64>::zeros(5);
1013        weights[2] = 1.0; // fully focus on location 2
1014        let erase = Array1::from_elem(3, 1.0); // erase everything at that location
1015        let content = Array1::from_vec(vec![0.7, -0.4, 0.9]);
1016
1017        write_memory(&mut memory, &weights, &erase, &content);
1018        let read = read_memory(&memory, &weights);
1019
1020        for (r, c) in read.iter().zip(content.iter()) {
1021            assert!(
1022                (r - c).abs() < 1e-12,
1023                "read must recover the written content"
1024            );
1025        }
1026    }
1027
1028    #[test]
1029    fn test_ntm_write_leaves_other_locations_unchanged() {
1030        let mut memory = Array2::from_shape_fn((4, 2), |(i, j)| (i as f64) + (j as f64));
1031        let original = memory.clone();
1032        let mut weights = Array1::<f64>::zeros(4);
1033        weights[1] = 1.0;
1034        let erase = Array1::from_elem(2, 1.0);
1035        let add = Array1::from_vec(vec![5.0, 6.0]);
1036
1037        write_memory(&mut memory, &weights, &erase, &add);
1038
1039        for i in [0usize, 2, 3] {
1040            for j in 0..2 {
1041                assert!(
1042                    (memory[[i, j]] - original[[i, j]]).abs() < 1e-12,
1043                    "untouched locations must be unchanged"
1044                );
1045            }
1046        }
1047        assert!((memory[[1, 0]] - 5.0).abs() < 1e-12);
1048        assert!((memory[[1, 1]] - 6.0).abs() < 1e-12);
1049    }
1050
1051    #[test]
1052    fn test_ntm_convergence_quadratic() {
1053        // f(x) = ||x||^2, grad = 2x. Repeated steps must reduce ||x|| substantially.
1054        let mut opt = make_optimizer(42);
1055        let mut x = Array1::from_vec(vec![
1056            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,
1057        ]);
1058        let initial = l2(&x);
1059
1060        for _ in 0..400 {
1061            let grad = x.mapv(|v| 2.0 * v);
1062            x = opt.step(&x, &grad).expect("step should succeed");
1063        }
1064
1065        let final_norm = l2(&x);
1066        assert!(
1067            final_norm < 0.5 * initial,
1068            "norm should reduce substantially over many iterations: {initial} -> {final_norm}"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_ntm_memory_evolves_across_steps() {
1074        let mut opt = make_optimizer(7);
1075        let params = Array1::from_shape_fn(12, |i| 0.5 + 0.1 * (i as f64));
1076        let grads = Array1::from_shape_fn(12, |i| 0.3 - 0.02 * (i as f64));
1077
1078        let _ = opt.step(&params, &grads).expect("first step");
1079        let memory_after_first = opt.memory().clone();
1080
1081        let _ = opt.step(&params, &grads).expect("second step");
1082        let memory_after_second = opt.memory().clone();
1083
1084        let changed: f64 = memory_after_first
1085            .iter()
1086            .zip(memory_after_second.iter())
1087            .map(|(a, b)| (a - b).abs())
1088            .sum();
1089        assert!(changed > 0.0, "external memory should evolve across steps");
1090        assert_eq!(opt.get_state().step_count, 2);
1091    }
1092
1093    #[test]
1094    fn test_ntm_num_slots_clamped() {
1095        let cfg = NtmOptimizerConfig {
1096            num_slots: 1000,
1097            seed: 5,
1098            ..Default::default()
1099        };
1100        let mut opt = NtmOptimizer::new(cfg).expect("config valid");
1101        let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
1102        let grads = Array1::from_vec(vec![0.5, 0.5, 0.5, 0.5]);
1103        let out = opt.step(&params, &grads).expect("step should succeed");
1104        assert_eq!(out.len(), 4);
1105        assert_eq!(
1106            opt.num_slots(),
1107            4,
1108            "num_slots must clamp to the parameter count"
1109        );
1110    }
1111
1112    #[test]
1113    fn test_ntm_single_parameter() {
1114        let mut opt = make_optimizer(13);
1115        let params = Array1::from_vec(vec![5.0]);
1116        let grads = Array1::from_vec(vec![2.0 * 5.0]);
1117        let out = opt.step(&params, &grads).expect("step should succeed");
1118        assert_eq!(out.len(), 1);
1119        assert_eq!(opt.num_slots(), 1);
1120        assert!(
1121            out[0].abs() < params[0].abs(),
1122            "single param should move toward 0"
1123        );
1124    }
1125
1126    #[test]
1127    fn test_ntm_trait_methods() {
1128        let mut opt = make_optimizer(11);
1129        assert_eq!(opt.name(), "NtmOptimizer");
1130
1131        let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1132        let grads = Array1::from_vec(vec![0.1, 0.1, 0.1, 0.1, 0.1]);
1133        let _ = opt.step(&params, &grads).expect("step should succeed");
1134
1135        let state = opt.get_state();
1136        assert_eq!(state.step_count, 1);
1137        assert!(state.grad_norm_ema > 0.0);
1138        assert!(state.current_lr > 0.0);
1139
1140        opt.set_learning_rate(0.5);
1141        assert!((opt.get_learning_rate() - 0.5).abs() < 1e-12);
1142    }
1143
1144    #[test]
1145    fn test_ntm_dimension_mismatch() {
1146        let mut opt = make_optimizer(3);
1147        let params = Array1::from_vec(vec![1.0, 2.0]);
1148        let grads = Array1::from_vec(vec![0.1, 0.2, 0.3]);
1149        assert!(opt.step(&params, &grads).is_err());
1150    }
1151
1152    #[test]
1153    fn test_ntm_empty_params_error() {
1154        let mut opt = make_optimizer(3);
1155        let params = Array1::<f64>::zeros(0);
1156        let grads = Array1::<f64>::zeros(0);
1157        assert!(opt.step(&params, &grads).is_err());
1158    }
1159
1160    #[test]
1161    fn test_ntm_invalid_config() {
1162        assert!(NtmOptimizer::<f64>::new(NtmOptimizerConfig {
1163            num_slots: 0,
1164            ..Default::default()
1165        })
1166        .is_err());
1167        assert!(NtmOptimizer::<f64>::new(NtmOptimizerConfig {
1168            num_locations: 0,
1169            ..Default::default()
1170        })
1171        .is_err());
1172        assert!(NtmOptimizer::<f64>::new(NtmOptimizerConfig {
1173            mem_width: 0,
1174            ..Default::default()
1175        })
1176        .is_err());
1177        assert!(NtmOptimizer::<f64>::new(NtmOptimizerConfig {
1178            hidden_dim: 0,
1179            ..Default::default()
1180        })
1181        .is_err());
1182        assert!(NtmOptimizer::<f64>::new(NtmOptimizerConfig {
1183            shift_range: 0,
1184            ..Default::default()
1185        })
1186        .is_err());
1187        assert!(NtmOptimizer::<f64>::new(NtmOptimizerConfig {
1188            base_lr: 0.0,
1189            ..Default::default()
1190        })
1191        .is_err());
1192        assert!(NtmOptimizer::<f64>::new(NtmOptimizerConfig {
1193            momentum_decay: 1.0,
1194            ..Default::default()
1195        })
1196        .is_err());
1197    }
1198
1199    #[test]
1200    fn test_ntm_addressing_pipeline_distribution() {
1201        // The full addressing pipeline must yield a valid distribution regardless
1202        // of the head parameters.
1203        let memory = Array2::from_shape_fn((8, 4), |(i, j)| 0.05 * (i as f64) + 0.1 * (j as f64));
1204        let prev = Array1::from_elem(8, 1.0 / 8.0);
1205        let offsets = build_shift_offsets(2);
1206        let heads = HeadParams {
1207            key: Array1::from_vec(vec![0.3, -0.2, 0.1, 0.4]),
1208            beta: 3.5,
1209            gate: 0.6,
1210            shift: softmax(&Array1::from_vec(vec![0.2, 0.5, 0.1, 0.0, 0.3])),
1211            gamma: 4.0,
1212            erase: Array1::from_elem(4, 0.5),
1213            add: Array1::from_vec(vec![0.1, -0.1, 0.2, 0.0]),
1214        };
1215        let weights = address(&memory, &heads, &prev, &offsets);
1216        assert_eq!(weights.len(), 8);
1217        assert!(
1218            is_distribution(&weights),
1219            "pipeline output must be a distribution"
1220        );
1221    }
1222}