1pub 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
71const CHUNK_FEATURE_DIM: usize = 6;
77
78#[derive(Debug, Clone)]
83pub struct NtmOptimizerConfig {
84 pub num_slots: usize,
86 pub num_locations: usize,
88 pub mem_width: usize,
90 pub hidden_dim: usize,
92 pub shift_range: usize,
95 pub mem_init_scale: f64,
97 pub base_lr: f64,
99 pub momentum_decay: f64,
101 pub rms_decay: f64,
103 pub max_grad_norm: f64,
105 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
127fn cast<T: Float>(value: f64) -> T {
129 T::from(value).unwrap_or_else(T::zero)
130}
131
132fn 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
142fn softplus<T: Float>(x: T) -> T {
144 x.max(T::zero()) + (T::one() + (-x.abs()).exp()).ln()
145}
146
147fn 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
159fn 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
172fn 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
185fn 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#[derive(Debug, Clone)]
200struct HeadParams<T: Float + Debug + Send + Sync + 'static> {
201 key: Array1<T>,
203 beta: T,
205 gate: T,
207 shift: Array1<T>,
209 gamma: T,
211 erase: Array1<T>,
213 add: Array1<T>,
215}
216
217#[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 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 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 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 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
310fn 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
332fn 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
341fn 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
360fn 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
377fn 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
393fn 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
406fn 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
419fn 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
463fn 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
474fn 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
488fn build_shift_offsets(shift_range: usize) -> Vec<isize> {
490 let range = shift_range as isize;
491 (-range..=range).collect()
492}
493
494#[derive(Debug, Clone)]
500pub struct NtmOptimizer<T: Float + Debug + Send + Sync + 'static> {
501 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 controller: NtmController<T>,
517
518 initial_memory: Array2<T>,
520
521 memory: Array2<T>,
523 prev_weights: Array1<T>,
524 prev_read: Array1<T>,
525 last_weights: Array1<T>,
526
527 param_len: usize,
529 num_slots: usize,
530 chunk_bounds: Vec<(usize, usize)>,
531
532 grad_ema: Array1<T>,
534 grad_sq_ema: Array1<T>,
535
536 step_count: usize,
538 current_lr: T,
539 grad_norm_ema: T,
540}
541
542impl<T: Float + Debug + Send + Sync + 'static> NtmOptimizer<T> {
543 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 pub fn num_slots(&self) -> usize {
658 self.num_slots
659 }
660
661 pub fn num_locations(&self) -> usize {
663 self.num_locations
664 }
665
666 pub fn mem_width(&self) -> usize {
668 self.mem_width
669 }
670
671 pub fn hidden_dim(&self) -> usize {
673 self.hidden_dim
674 }
675
676 pub fn shift_range(&self) -> usize {
678 self.shift_range
679 }
680
681 pub fn seed(&self) -> u64 {
683 self.seed
684 }
685
686 pub fn memory(&self) -> &Array2<T> {
688 &self.memory
689 }
690
691 pub fn last_address_weights(&self) -> &Array1<T> {
693 &self.last_weights
694 }
695
696 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 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 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 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 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 let update = (&scale_vector * &m_hat).mapv(|u| u * base_lr);
783 let new_params = params - &update;
784
785 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(¶ms, &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(¶ms, &grads).expect("a step");
865 let out_b = b.step(¶ms, &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(¶ms, &grads).expect("a step");
883 let out_b = b.step(¶ms, &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 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(¶ms, &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 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 let offsets = build_shift_offsets(1); 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]); 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]); 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 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 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; let erase = Array1::from_elem(3, 1.0); 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 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(¶ms, &grads).expect("first step");
1079 let memory_after_first = opt.memory().clone();
1080
1081 let _ = opt.step(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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 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}