1use scirs2_core::ndarray::{Array, Array1, Array2, Dimension, IxDyn, ScalarOperand};
41use scirs2_core::numeric::Float;
42use scirs2_core::random::Random;
43use std::fmt::Debug;
44
45use crate::error::{OptimError, Result};
46use crate::optimizers::Optimizer;
47
48const EPSILON: f64 = 1e-12;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum AddressingMode {
58 Content,
61 Location,
65 Hybrid,
70}
71
72#[derive(Debug, Clone)]
79pub struct NtmConfig<A: Float + ScalarOperand + Debug> {
80 pub memory_slots: usize,
82 pub memory_width: usize,
84 pub learning_rate: A,
86 pub read_sharpness: A,
88 pub erase_gate: A,
91 pub addressing_mode: AddressingMode,
93 pub memory_weight: A,
95 pub gradient_weight: A,
97 pub seed: u64,
101}
102
103impl<A: Float + ScalarOperand + Debug> Default for NtmConfig<A> {
104 fn default() -> Self {
105 Self {
106 memory_slots: 32,
107 memory_width: 16,
108 learning_rate: A::from(0.01).unwrap_or_else(A::zero),
109 read_sharpness: A::from(1.0).unwrap_or_else(A::one),
110 erase_gate: A::from(0.5).unwrap_or_else(A::zero),
111 addressing_mode: AddressingMode::Hybrid,
112 memory_weight: A::from(0.3).unwrap_or_else(A::zero),
113 gradient_weight: A::from(0.7).unwrap_or_else(A::one),
114 seed: 42,
115 }
116 }
117}
118
119pub struct NtmOptimizer<A: Float + ScalarOperand + Debug> {
144 config: NtmConfig<A>,
146 memory: Array2<A>,
148 prev_read_weights: Array1<A>,
150 prev_write_weights: Array1<A>,
152 rng_seed: u64,
154 step_count: usize,
156}
157
158impl<A: Float + ScalarOperand + Debug> Debug for NtmOptimizer<A> {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.debug_struct("NtmOptimizer")
161 .field("memory_slots", &self.config.memory_slots)
162 .field("memory_width", &self.config.memory_width)
163 .field("learning_rate", &self.config.learning_rate)
164 .field("addressing_mode", &self.config.addressing_mode)
165 .field("rng_seed", &self.rng_seed)
166 .field("step_count", &self.step_count)
167 .finish()
168 }
169}
170
171impl<A: Float + ScalarOperand + Debug> NtmOptimizer<A> {
172 pub fn new(memory_slots: usize, memory_width: usize, learning_rate: A) -> Self {
179 let config = NtmConfig::<A> {
180 memory_slots,
181 memory_width,
182 learning_rate,
183 ..NtmConfig::<A>::default()
184 };
185 Self::with_config(config)
186 }
187
188 pub fn with_config(config: NtmConfig<A>) -> Self {
195 let slots = config.memory_slots;
196 let width = config.memory_width;
197 let seed = config.seed;
198 let _rng: Random<scirs2_core::random::rngs::StdRng> = Random::seed(seed);
201
202 let memory = Array2::<A>::zeros((slots, width));
203 let prev_read = Array1::<A>::zeros(slots);
204 let prev_write = Array1::<A>::zeros(slots);
205
206 Self {
207 config,
208 memory,
209 prev_read_weights: prev_read,
210 prev_write_weights: prev_write,
211 rng_seed: seed,
212 step_count: 0,
213 }
214 }
215
216 pub fn with_read_sharpness(mut self, beta: A) -> Self {
218 self.config.read_sharpness = beta;
219 self
220 }
221
222 pub fn with_erase_gate(mut self, gate: A) -> Self {
224 self.config.erase_gate = gate;
225 self
226 }
227
228 pub fn with_addressing(mut self, mode: AddressingMode) -> Self {
230 self.config.addressing_mode = mode;
231 self
232 }
233
234 pub fn with_memory_weight(mut self, weight: A) -> Self {
236 self.config.memory_weight = weight;
237 self
238 }
239
240 pub fn with_gradient_weight(mut self, weight: A) -> Self {
242 self.config.gradient_weight = weight;
243 self
244 }
245
246 pub fn with_seed(mut self, seed: u64) -> Self {
248 self.config.seed = seed;
249 self.rng_seed = seed;
250 let _rng: Random<scirs2_core::random::rngs::StdRng> = Random::seed(seed);
251 self
252 }
253
254 pub fn config(&self) -> &NtmConfig<A> {
256 &self.config
257 }
258
259 pub fn memory(&self) -> &Array2<A> {
261 &self.memory
262 }
263
264 pub fn memory_mut(&mut self) -> &mut Array2<A> {
267 &mut self.memory
268 }
269
270 pub fn last_read_weights(&self) -> &Array1<A> {
272 &self.prev_read_weights
273 }
274
275 pub fn last_write_weights(&self) -> &Array1<A> {
277 &self.prev_write_weights
278 }
279
280 pub fn step_count(&self) -> usize {
282 self.step_count
283 }
284
285 pub fn reset(&mut self) {
288 self.memory.fill(A::zero());
289 self.prev_read_weights.fill(A::zero());
290 self.prev_write_weights.fill(A::zero());
291 self.step_count = 0;
292 let _rng: Random<scirs2_core::random::rngs::StdRng> = Random::seed(self.rng_seed);
295 }
296
297 fn validate_config(&self) -> Result<()> {
302 if self.config.memory_slots == 0 {
303 return Err(OptimError::InvalidConfig(
304 "NtmOptimizer: memory_slots must be > 0".to_string(),
305 ));
306 }
307 if self.config.memory_width == 0 {
308 return Err(OptimError::InvalidConfig(
309 "NtmOptimizer: memory_width must be > 0".to_string(),
310 ));
311 }
312 Ok(())
313 }
314
315 fn build_key<D: Dimension>(&self, gradients: &Array<A, D>) -> Array1<A> {
320 let w = self.config.memory_width;
321 let mut key = Array1::<A>::zeros(w);
322 if w == 0 {
323 return key;
324 }
325 let flat: Vec<A> = gradients.iter().copied().collect();
326 let n = flat.len();
327 if n == 0 {
328 return key;
329 }
330
331 if n >= w {
335 for j in 0..w {
336 let lo = (j * n) / w;
337 let hi = ((j + 1) * n) / w;
338 let hi_safe = hi.max(lo + 1).min(n);
339 let mut acc = A::zero();
340 let mut count: usize = 0;
341 for value in flat.iter().take(hi_safe).skip(lo) {
342 acc = acc + *value;
343 count += 1;
344 }
345 if count > 0 {
346 let denom = A::from(count).unwrap_or_else(A::one);
347 key[j] = acc / denom;
348 }
349 }
350 } else {
351 for (i, value) in flat.iter().enumerate() {
353 let j = (i * w) / n;
354 key[j] = key[j] + *value;
355 }
356 let mut counts = vec![0_usize; w];
360 for i in 0..n {
361 let j = (i * w) / n;
362 counts[j] += 1;
363 }
364 for (j, c) in counts.iter().enumerate() {
365 if *c > 1 {
366 let denom = A::from(*c).unwrap_or_else(A::one);
367 key[j] = key[j] / denom;
368 }
369 }
370 }
371
372 let w_a = A::from(w).unwrap_or_else(A::one);
374 let mean = key.iter().copied().fold(A::zero(), |acc, x| acc + x) / w_a;
375 let mut var = A::zero();
376 for value in key.iter() {
377 let d = *value - mean;
378 var = var + d * d;
379 }
380 var = var / w_a;
381 let eps = A::from(EPSILON).unwrap_or_else(A::epsilon);
382 let std = var.sqrt() + eps;
383 for v in key.iter_mut() {
384 *v = (*v - mean) / std;
385 }
386 key
387 }
388
389 fn cosine_similarity(a: &Array1<A>, b: &Array1<A>) -> A {
392 let eps = A::from(EPSILON).unwrap_or_else(A::epsilon);
393 let mut dot = A::zero();
394 let mut na = A::zero();
395 let mut nb = A::zero();
396 for (x, y) in a.iter().zip(b.iter()) {
397 dot = dot + (*x) * (*y);
398 na = na + (*x) * (*x);
399 nb = nb + (*y) * (*y);
400 }
401 let denom = na.sqrt() * nb.sqrt() + eps;
402 let sim = dot / denom;
403 let one = A::one();
404 let neg_one = -one;
405 if sim > one {
406 one
407 } else if sim < neg_one {
408 neg_one
409 } else {
410 sim
411 }
412 }
413
414 fn sharpened_softmax(sims: &Array1<A>, beta: A) -> Array1<A> {
417 let n = sims.len();
418 let mut out = Array1::<A>::zeros(n);
419 if n == 0 {
420 return out;
421 }
422 let mut max_val = sims[0] * beta;
424 for value in sims.iter().take(n).skip(1) {
425 let scaled = *value * beta;
426 if scaled > max_val {
427 max_val = scaled;
428 }
429 }
430 let mut sum = A::zero();
431 for (i, value) in sims.iter().enumerate() {
432 let exp_val = (*value * beta - max_val).exp();
433 out[i] = exp_val;
434 sum = sum + exp_val;
435 }
436 if sum > A::zero() {
437 for v in out.iter_mut() {
438 *v = *v / sum;
439 }
440 } else {
441 let denom = A::from(n).unwrap_or_else(A::one);
443 for v in out.iter_mut() {
444 *v = A::one() / denom;
445 }
446 }
447 out
448 }
449
450 fn shift_right(src: &Array1<A>) -> Array1<A> {
452 let n = src.len();
453 let mut out = Array1::<A>::zeros(n);
454 if n == 0 {
455 return out;
456 }
457 for i in 0..n {
458 let prev_index = (i + n - 1) % n;
459 out[i] = src[prev_index];
460 }
461 out
462 }
463
464 fn compute_attention(
467 &self,
468 content_weights: &Array1<A>,
469 prev_weights: &Array1<A>,
470 ) -> Array1<A> {
471 match self.config.addressing_mode {
472 AddressingMode::Content => content_weights.clone(),
473 AddressingMode::Location => Self::shift_right(prev_weights),
474 AddressingMode::Hybrid => {
475 let n = content_weights.len();
476 let shifted = Self::shift_right(prev_weights);
477 let half = A::from(0.5).unwrap_or_else(|| A::one() / (A::one() + A::one()));
478 let mut mix = Array1::<A>::zeros(n);
479 for i in 0..n {
480 mix[i] = half * content_weights[i] + half * shifted[i];
481 }
482 let beta = self.config.read_sharpness;
487 let mut sharpened = Array1::<A>::zeros(n);
488 let mut sum = A::zero();
489 let zero = A::zero();
490 for i in 0..n {
491 let base = if mix[i] < zero { zero } else { mix[i] };
492 let powed = base.powf(beta);
493 sharpened[i] = powed;
494 sum = sum + powed;
495 }
496 if sum > A::zero() {
497 for v in sharpened.iter_mut() {
498 *v = *v / sum;
499 }
500 } else {
501 let denom = A::from(n).unwrap_or_else(A::one);
502 for v in sharpened.iter_mut() {
503 *v = A::one() / denom;
504 }
505 }
506 sharpened
507 }
508 }
509 }
510
511 fn read_from_memory(&self, weights: &Array1<A>) -> Array1<A> {
513 let w = self.config.memory_width;
514 let n = self.config.memory_slots;
515 let mut read = Array1::<A>::zeros(w);
516 for j in 0..w {
517 let mut acc = A::zero();
518 for i in 0..n {
519 acc = acc + weights[i] * self.memory[(i, j)];
520 }
521 read[j] = acc;
522 }
523 read
524 }
525
526 fn write_to_memory(&mut self, weights: &Array1<A>, key: &Array1<A>) {
529 let n = self.config.memory_slots;
530 let w = self.config.memory_width;
531 let erase = self.config.erase_gate;
532 let one = A::one();
533 for i in 0..n {
534 let w_i = weights[i];
535 for j in 0..w {
536 let e_j = erase * key[j];
537 let factor = one - w_i * e_j;
538 let current = self.memory[(i, j)];
539 self.memory[(i, j)] = current * factor + w_i * key[j];
540 }
541 }
542 }
543
544 fn tile_read_vector<D: Dimension>(
548 &self,
549 read: &Array1<A>,
550 params: &Array<A, D>,
551 ) -> Result<Array<A, D>> {
552 let shape: Vec<usize> = params.shape().to_vec();
553 let total: usize = shape.iter().product();
554 let w = read.len();
555 let mut buf: Vec<A> = Vec::with_capacity(total);
556 if total == 0 {
557 } else if w == 0 {
559 for _ in 0..total {
562 buf.push(A::zero());
563 }
564 } else {
565 for i in 0..total {
566 buf.push(read[i % w]);
567 }
568 }
569 let dyn_arr = Array::<A, IxDyn>::from_shape_vec(IxDyn(&shape), buf).map_err(|err| {
570 OptimError::ComputationError(format!(
571 "NtmOptimizer: failed to reshape tiled read vector: {err}"
572 ))
573 })?;
574 dyn_arr.into_dimensionality::<D>().map_err(|err| {
575 OptimError::DimensionMismatch(format!(
576 "NtmOptimizer: failed to project tiled read vector into target dimension: {err}"
577 ))
578 })
579 }
580}
581
582impl<A, D> Optimizer<A, D> for NtmOptimizer<A>
583where
584 A: Float + ScalarOperand + Debug,
585 D: Dimension,
586{
587 fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
588 self.validate_config()?;
589
590 if params.shape() != gradients.shape() {
591 return Err(OptimError::DimensionMismatch(format!(
592 "NtmOptimizer::step: parameters have shape {:?} but gradients have shape {:?}",
593 params.shape(),
594 gradients.shape()
595 )));
596 }
597
598 let key = self.build_key(gradients);
600
601 let n = self.config.memory_slots;
603 let mut content_scores = Array1::<A>::zeros(n);
604 for i in 0..n {
605 let row = self.memory.row(i).to_owned();
606 content_scores[i] = Self::cosine_similarity(&key, &row);
607 }
608 let content_weights = Self::sharpened_softmax(&content_scores, self.config.read_sharpness);
609
610 let prev_read = self.prev_read_weights.clone();
612 let read_weights = self.compute_attention(&content_weights, &prev_read);
613
614 let read_vector = self.read_from_memory(&read_weights);
616
617 let write_weights = read_weights.clone();
619
620 self.write_to_memory(&write_weights, &key);
622
623 let tiled = self.tile_read_vector(&read_vector, params)?;
625 let g_w = self.config.gradient_weight;
626 let m_w = self.config.memory_weight;
627 let update = &(gradients * g_w) + &(&tiled * m_w);
628 let new_params = params - &(&update * self.config.learning_rate);
629
630 self.prev_read_weights = read_weights;
632 self.prev_write_weights = write_weights;
633 self.step_count = self.step_count.saturating_add(1);
634
635 Ok(new_params)
636 }
637
638 fn get_learning_rate(&self) -> A {
639 self.config.learning_rate
640 }
641
642 fn set_learning_rate(&mut self, learning_rate: A) {
643 self.config.learning_rate = learning_rate;
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use scirs2_core::ndarray::Array1;
651
652 #[test]
657 fn test_default_config_values() {
658 let cfg: NtmConfig<f64> = NtmConfig::default();
659 assert_eq!(cfg.memory_slots, 32);
660 assert_eq!(cfg.memory_width, 16);
661 assert!((cfg.learning_rate - 0.01).abs() < 1e-12);
662 assert!((cfg.read_sharpness - 1.0).abs() < 1e-12);
663 assert!((cfg.erase_gate - 0.5).abs() < 1e-12);
664 assert_eq!(cfg.addressing_mode, AddressingMode::Hybrid);
665 assert!((cfg.memory_weight - 0.3).abs() < 1e-12);
666 assert!((cfg.gradient_weight - 0.7).abs() < 1e-12);
667 assert_eq!(cfg.seed, 42);
668 }
669
670 #[test]
671 fn test_builder_pattern_chains() {
672 let opt: NtmOptimizer<f64> = NtmOptimizer::new(8, 4, 0.01)
673 .with_read_sharpness(2.5)
674 .with_erase_gate(0.25)
675 .with_addressing(AddressingMode::Content)
676 .with_memory_weight(0.4)
677 .with_gradient_weight(0.6)
678 .with_seed(7);
679 let cfg = opt.config();
680 assert!((cfg.read_sharpness - 2.5).abs() < 1e-12);
681 assert!((cfg.erase_gate - 0.25).abs() < 1e-12);
682 assert_eq!(cfg.addressing_mode, AddressingMode::Content);
683 assert!((cfg.memory_weight - 0.4).abs() < 1e-12);
684 assert!((cfg.gradient_weight - 0.6).abs() < 1e-12);
685 assert_eq!(cfg.seed, 7);
686 }
687
688 #[test]
689 fn test_new_initializes_memory_to_zero() {
690 let opt: NtmOptimizer<f64> = NtmOptimizer::new(4, 3, 0.01);
691 for &v in opt.memory().iter() {
692 assert_eq!(v, 0.0);
693 }
694 for &v in opt.last_read_weights().iter() {
695 assert_eq!(v, 0.0);
696 }
697 for &v in opt.last_write_weights().iter() {
698 assert_eq!(v, 0.0);
699 }
700 assert_eq!(opt.step_count(), 0);
701 }
702
703 #[test]
704 fn test_memory_dims_match_config() {
705 let opt: NtmOptimizer<f64> = NtmOptimizer::new(11, 5, 0.01);
706 assert_eq!(opt.memory().shape(), &[11, 5]);
707 assert_eq!(opt.last_read_weights().len(), 11);
708 assert_eq!(opt.last_write_weights().len(), 11);
709 }
710
711 #[test]
716 fn test_step_returns_same_shape_as_params() {
717 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(8, 4, 0.01);
718 let params = Array1::from_vec(vec![1.0, -1.0, 0.5, 2.0, -0.25]);
719 let grads = Array1::from_vec(vec![0.1, -0.2, 0.3, 0.0, -0.5]);
720 let next = opt.step(¶ms, &grads).expect("step failed");
721 assert_eq!(next.shape(), params.shape());
722 }
723
724 #[test]
725 fn test_step_changes_params() {
726 let mut opt: NtmOptimizer<f64> =
727 NtmOptimizer::new(8, 4, 0.1).with_addressing(AddressingMode::Content);
728 let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
729 let grads = Array1::from_vec(vec![0.5, -0.5, 0.25, -0.75]);
730 let next = opt.step(¶ms, &grads).expect("step failed");
731 let mut diff_total = 0.0_f64;
732 for (a, b) in next.iter().zip(params.iter()) {
733 diff_total += (a - b).abs();
734 }
735 assert!(
736 diff_total > 1e-6,
737 "non-zero gradient must update at least one parameter"
738 );
739 }
740
741 #[test]
742 fn test_zero_gradients_minimal_change() {
743 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(8, 4, 0.1);
744 let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
745 let grads = Array1::<f64>::zeros(4);
746 let next = opt.step(¶ms, &grads).expect("step failed");
747 for (a, b) in next.iter().zip(params.iter()) {
749 assert!(
750 (a - b).abs() < 1e-9,
751 "zero grads + zero memory must leave params unchanged (a={a}, b={b})"
752 );
753 }
754 }
755
756 #[test]
757 fn test_step_count_increments() {
758 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(8, 4, 0.01);
759 let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
760 let grads = Array1::from_vec(vec![0.1, -0.1, 0.2, -0.2]);
761 assert_eq!(opt.step_count(), 0);
762 let _ = opt.step(¶ms, &grads).expect("step 1 failed");
763 assert_eq!(opt.step_count(), 1);
764 let _ = opt.step(¶ms, &grads).expect("step 2 failed");
765 let _ = opt.step(¶ms, &grads).expect("step 3 failed");
766 assert_eq!(opt.step_count(), 3);
767 }
768
769 #[test]
774 fn test_addressing_mode_content_uses_pure_content() {
775 let mut opt: NtmOptimizer<f64> =
779 NtmOptimizer::new(5, 3, 0.01).with_addressing(AddressingMode::Content);
780 let params = Array1::from_vec(vec![0.0, 0.0, 0.0]);
781 let grads = Array1::from_vec(vec![1.0, -1.0, 0.5]);
782 let _ = opt.step(¶ms, &grads).expect("step failed");
783 let w = opt.last_read_weights();
784 for v in w.iter() {
786 assert!(
787 (*v - 0.2).abs() < 1e-6,
788 "Content mode with zero memory must yield uniform attention (got {v})"
789 );
790 }
791 }
792
793 #[test]
794 fn test_addressing_mode_location_shifts_weights() {
795 let mut opt: NtmOptimizer<f64> =
799 NtmOptimizer::new(5, 3, 0.01).with_addressing(AddressingMode::Location);
800 let _ = opt
804 .step(
805 &Array1::<f64>::zeros(3),
806 &Array1::from_vec(vec![1.0, 0.0, 0.0]),
807 )
808 .expect("warmup step failed");
809 let mut opt2: NtmOptimizer<f64> =
815 NtmOptimizer::new(5, 3, 0.01).with_addressing(AddressingMode::Content);
816 let _ = opt2
819 .step(
820 &Array1::<f64>::zeros(3),
821 &Array1::from_vec(vec![1.0, -1.0, 0.5]),
822 )
823 .expect("seed step failed");
824 let mut opt3 = opt2;
829 let _ = &mut opt3; let mut opt_loc: NtmOptimizer<f64> =
840 NtmOptimizer::new(4, 2, 0.01).with_addressing(AddressingMode::Content);
841 {
843 let mem = opt_loc.memory_mut();
844 mem[(0, 0)] = 1.0;
845 mem[(0, 1)] = 0.0;
846 mem[(1, 0)] = 0.0;
847 mem[(1, 1)] = 1.0;
848 mem[(2, 0)] = -1.0;
849 mem[(2, 1)] = 0.0;
850 mem[(3, 0)] = 0.0;
851 mem[(3, 1)] = -1.0;
852 }
853 let _ = opt_loc
854 .step(
855 &Array1::<f64>::zeros(4),
856 &Array1::from_vec(vec![1.0, 0.0, -1.0, 0.0]),
857 )
858 .expect("content step failed");
859 let before = opt_loc.last_read_weights().clone();
860
861 let mut opt_loc2: NtmOptimizer<f64> =
863 NtmOptimizer::new(4, 2, 0.01).with_addressing(AddressingMode::Location);
864 {
866 let mem = opt_loc2.memory_mut();
867 for ((i, j), v) in opt_loc.memory().indexed_iter() {
868 mem[(i, j)] = *v;
869 }
870 }
871 let _ = opt_loc2
874 .step(
875 &Array1::<f64>::zeros(4),
876 &Array1::from_vec(vec![1.0, 0.0, -1.0, 0.0]),
877 )
878 .expect("warmup for location failed");
879
880 let shifted = NtmOptimizer::<f64>::shift_right(&before);
888 let mut a: Vec<f64> = before.iter().copied().collect();
890 let mut b: Vec<f64> = shifted.iter().copied().collect();
891 a.sort_by(|x, y| x.partial_cmp(y).expect("sort"));
892 b.sort_by(|x, y| x.partial_cmp(y).expect("sort"));
893 for (x, y) in a.iter().zip(b.iter()) {
894 assert!(
895 (x - y).abs() < 1e-12,
896 "Location-mode shift must permute the previous weights"
897 );
898 }
899 let max_diff = before
902 .iter()
903 .zip(shifted.iter())
904 .map(|(x, y)| (x - y).abs())
905 .fold(0.0_f64, f64::max);
906 assert!(
907 max_diff > 1e-9,
908 "Location shift on a non-uniform vector must yield a different vector"
909 );
910 }
911
912 #[test]
913 fn test_addressing_mode_hybrid_combines() {
914 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(6, 3, 0.01)
917 .with_addressing(AddressingMode::Hybrid)
918 .with_read_sharpness(2.0);
919 {
921 let mem = opt.memory_mut();
922 for i in 0..6 {
923 mem[(i, 0)] = i as f64 * 0.1;
924 mem[(i, 1)] = (5 - i) as f64 * 0.1;
925 mem[(i, 2)] = ((i as f64) - 2.5) * 0.1;
926 }
927 }
928 let params = Array1::<f64>::zeros(3);
929 let grads = Array1::from_vec(vec![0.5, -0.5, 0.5]);
930 let _ = opt.step(¶ms, &grads).expect("step failed");
931 let weights = opt.last_read_weights();
932 let mut total = 0.0_f64;
933 for v in weights.iter() {
934 assert!(*v >= -1e-12, "Hybrid weights must be non-negative");
935 total += *v;
936 }
937 assert!(
938 (total - 1.0).abs() < 1e-6,
939 "Hybrid weights must sum to one (got {total})"
940 );
941 }
942
943 #[test]
948 fn test_memory_updated_after_step() {
949 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(4, 3, 0.01);
950 let before = opt.memory().clone();
951 let params = Array1::from_vec(vec![1.0, -1.0, 0.5]);
952 let grads = Array1::from_vec(vec![0.5, 0.5, -0.5]);
953 let _ = opt.step(¶ms, &grads).expect("step failed");
954 let after = opt.memory();
955 let mut diff = 0.0_f64;
956 for (a, b) in after.iter().zip(before.iter()) {
957 diff += (a - b).abs();
958 }
959 assert!(
960 diff > 1e-9,
961 "memory must change after a step with non-zero key"
962 );
963 }
964
965 #[test]
966 fn test_read_weights_sum_to_one() {
967 let mut opt: NtmOptimizer<f64> =
968 NtmOptimizer::new(7, 4, 0.01).with_addressing(AddressingMode::Content);
969 let params = Array1::from_vec(vec![1.0, 2.0, -1.0, 0.5]);
970 let grads = Array1::from_vec(vec![0.3, -0.1, 0.4, -0.2]);
971 let _ = opt.step(¶ms, &grads).expect("step failed");
972 let total: f64 = opt.last_read_weights().iter().sum();
973 assert!(
974 (total - 1.0).abs() < 1e-6,
975 "read attention must sum to one (got {total})"
976 );
977 }
978
979 #[test]
980 fn test_reset_clears_memory_and_weights() {
981 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(4, 3, 0.01);
982 let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
983 let grads = Array1::from_vec(vec![0.5, -0.5, 0.25]);
984 let _ = opt.step(¶ms, &grads).expect("step 1 failed");
985 let _ = opt.step(¶ms, &grads).expect("step 2 failed");
986 assert_eq!(opt.step_count(), 2);
987 opt.reset();
988 assert_eq!(opt.step_count(), 0);
989 for v in opt.memory().iter() {
990 assert_eq!(*v, 0.0);
991 }
992 for v in opt.last_read_weights().iter() {
993 assert_eq!(*v, 0.0);
994 }
995 for v in opt.last_write_weights().iter() {
996 assert_eq!(*v, 0.0);
997 }
998 }
999
1000 #[test]
1001 fn test_seed_reproducibility() {
1002 let mut a: NtmOptimizer<f64> = NtmOptimizer::new(6, 4, 0.05).with_seed(123);
1008 let mut b: NtmOptimizer<f64> = NtmOptimizer::new(6, 4, 0.05).with_seed(123);
1009 let params = Array1::from_vec(vec![1.0, -1.0, 0.5, 2.0, -0.3, 0.0]);
1010 let grads = Array1::from_vec(vec![0.2, -0.4, 0.1, 0.0, -0.2, 0.3]);
1011 for _ in 0..5 {
1012 let na = a.step(¶ms, &grads).expect("a.step failed");
1013 let nb = b.step(¶ms, &grads).expect("b.step failed");
1014 for (x, y) in na.iter().zip(nb.iter()) {
1015 assert!(
1016 (x - y).abs() < 1e-12,
1017 "seeded NTMs must produce identical outputs (a={x}, b={y})"
1018 );
1019 }
1020 }
1021 }
1022
1023 #[test]
1028 fn test_get_set_learning_rate() {
1029 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(4, 3, 0.05);
1030 let lr_before =
1033 <NtmOptimizer<f64> as Optimizer<f64, scirs2_core::ndarray::Ix1>>::get_learning_rate(
1034 &opt,
1035 );
1036 assert!((lr_before - 0.05).abs() < 1e-12);
1037 <NtmOptimizer<f64> as Optimizer<f64, scirs2_core::ndarray::Ix1>>::set_learning_rate(
1038 &mut opt, 0.123,
1039 );
1040 let lr_after =
1041 <NtmOptimizer<f64> as Optimizer<f64, scirs2_core::ndarray::Ix1>>::get_learning_rate(
1042 &opt,
1043 );
1044 assert!((lr_after - 0.123).abs() < 1e-12);
1045 }
1046
1047 #[test]
1048 fn test_dimension_mismatch_errors() {
1049 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(4, 3, 0.01);
1050 let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1051 let grads = Array1::from_vec(vec![0.1, 0.2]); let err = opt.step(¶ms, &grads);
1053 assert!(matches!(err, Err(OptimError::DimensionMismatch(_))));
1054 }
1055
1056 #[test]
1057 fn test_convergence_on_quadratic() {
1058 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(8, 4, 0.05)
1063 .with_addressing(AddressingMode::Content)
1064 .with_gradient_weight(1.0)
1065 .with_memory_weight(0.0)
1066 .with_seed(2024);
1067 let mut x = Array1::from_vec(vec![2.0]);
1068 for _ in 0..100 {
1069 let g = x.mapv(|v| 2.0 * v);
1070 x = opt.step(&x, &g).expect("step failed");
1071 }
1072 assert!(
1073 x[0].abs() < 2.0,
1074 "convergence test must reduce |x| below initial value (got {})",
1075 x[0]
1076 );
1077 assert!(
1081 x[0].abs() < 1e-2,
1082 "convergence must drive x close to zero (got {})",
1083 x[0]
1084 );
1085 }
1086
1087 #[test]
1088 fn test_zero_memory_slots_errors() {
1089 let cfg = NtmConfig::<f64> {
1090 memory_slots: 0,
1091 ..NtmConfig::<f64>::default()
1092 };
1093 let mut opt = NtmOptimizer::with_config(cfg);
1094 let params = Array1::from_vec(vec![1.0, 2.0]);
1095 let grads = Array1::from_vec(vec![0.1, 0.2]);
1096 let err = opt.step(¶ms, &grads);
1097 assert!(matches!(err, Err(OptimError::InvalidConfig(_))));
1098 }
1099
1100 #[test]
1101 fn test_zero_memory_width_errors() {
1102 let cfg = NtmConfig::<f64> {
1103 memory_width: 0,
1104 ..NtmConfig::<f64>::default()
1105 };
1106 let mut opt = NtmOptimizer::with_config(cfg);
1107 let params = Array1::from_vec(vec![1.0, 2.0]);
1108 let grads = Array1::from_vec(vec![0.1, 0.2]);
1109 let err = opt.step(¶ms, &grads);
1110 assert!(matches!(err, Err(OptimError::InvalidConfig(_))));
1111 }
1112
1113 #[test]
1118 fn test_shift_right_is_circular() {
1119 let v = Array1::from_vec(vec![1.0_f64, 2.0, 3.0, 4.0]);
1120 let s = NtmOptimizer::<f64>::shift_right(&v);
1121 assert_eq!(s, Array1::from_vec(vec![4.0, 1.0, 2.0, 3.0]));
1122 let empty: Array1<f64> = Array1::zeros(0);
1124 let s_empty = NtmOptimizer::<f64>::shift_right(&empty);
1125 assert_eq!(s_empty.len(), 0);
1126 }
1127
1128 #[test]
1129 fn test_cosine_similarity_basic() {
1130 let a = Array1::from_vec(vec![1.0_f64, 0.0, 0.0]);
1135 let b = Array1::from_vec(vec![1.0_f64, 0.0, 0.0]);
1136 let s = NtmOptimizer::<f64>::cosine_similarity(&a, &b);
1137 assert!((s - 1.0).abs() < 1e-6, "expected ~1.0, got {s}");
1138 let c = Array1::from_vec(vec![-1.0_f64, 0.0, 0.0]);
1139 let s2 = NtmOptimizer::<f64>::cosine_similarity(&a, &c);
1140 assert!((s2 + 1.0).abs() < 1e-6, "expected ~-1.0, got {s2}");
1141 let d = Array1::from_vec(vec![0.0_f64, 1.0, 0.0]);
1142 let s3 = NtmOptimizer::<f64>::cosine_similarity(&a, &d);
1143 assert!(s3.abs() < 1e-6, "expected ~0.0, got {s3}");
1144 }
1145
1146 #[test]
1147 fn test_step_2d_array_shapes_round_trip() {
1148 let mut opt: NtmOptimizer<f64> = NtmOptimizer::new(8, 4, 0.01);
1150 let params = scirs2_core::ndarray::Array2::<f64>::zeros((3, 5));
1151 let grads = scirs2_core::ndarray::Array2::<f64>::from_elem((3, 5), 0.1);
1152 let next = opt.step(¶ms, &grads).expect("2D step failed");
1153 assert_eq!(next.shape(), &[3, 5]);
1154 }
1155}