diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6b9b9d1..cae1a0c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -356,13 +356,16 @@ teach("paris is the capital of france") ### When a Layer Grows -Real new-depth layer growth is a later milestone. Stage 7 widens the current -hidden layer and keeps the engine in its proven two-layer shape. +Stage 17 adds real new-depth layer growth. Hidden layers are all layers before +the final output layer. The output layer reads a flattened vector of every +hidden activation, so adding a new hidden layer appends new output columns +without rewriting older bound answer columns. A new layer is added when: -- All neurons in every layer are `Frozen` or `Guarded` -- Loss is still above threshold after MAX_LAYER_ATTEMPTS -- Network depth is below MAX_LAYERS +- Existing hidden memory is saturated (`Guarded` or `Frozen`) or the deepest + hidden layer has reached `MAX_NEURONS_PER_LAYER` +- Loss is still above threshold after update attempts +- Network depth is below `MAX_LAYERS` ### Growth is Bounded diff --git a/BENCHMARKS.md b/BENCHMARKS.md index b8224e8..e24c40f 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -6,12 +6,12 @@ Mode: `full` | ID | Benchmark | Result | Unit | Detail | |---|---|---:|---|---| -| B1 | single teach | 0.0384 | ms/op | 25 iterations | -| B2 | single ask | 0.0190 | ms/op | 200 iterations | -| B3 | .manas save | 0.4677 | ms/op | 25 iterations | -| B4 | .manas load | 0.1514 | ms/op | 50 iterations | -| B5 | tokenizer 1000 words | 0.3946 | ms/op | 100 iterations | -| B6 | anti-forgetting proof | 0.3630 | s | single fixed seed | +| B1 | single teach | 0.0446 | ms/op | 25 iterations | +| B2 | single ask | 0.0187 | ms/op | 200 iterations | +| B3 | .manas save | 0.1718 | ms/op | 25 iterations | +| B4 | .manas load | 0.1518 | ms/op | 50 iterations | +| B5 | tokenizer 1000 words | 0.3843 | ms/op | 100 iterations | +| B6 | anti-forgetting proof | 0.4043 | s | single fixed seed | | B7 | 1000-neuron footprint | 419.6875 | KiB | estimated heap footprint, total=1000 | | B8 | brain growth per fact | 768.8750 | bytes/fact | n=32, min=646, max=4534 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2faa1a5..db6933c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Manas uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Stage 14 — Inspect, neurons, and debug commands. - Stage 15 — Compression and forget command. - Stage 16 — Benchmarks and test suite. +- Stage 17 — Layer growth. ### Added @@ -114,10 +115,18 @@ Manas uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). persistence, growth, protection, compression, freshness, and ingestion. - Added CI benchmark smoke coverage through `cargo bench -p manas-benches -- --quick`. +- Added real hidden-layer growth in `manas-core`; `grow_layer` now inserts a new + hidden layer before the output layer instead of widening layer 0. +- Generalized forward caches, backpropagation, trainer growth, bound readout, + diagnostics, and `.manas` validation for multi-hidden-layer networks. +- Preserved older bound-memory answers by keeping output weights connected to a + flattened hidden-activation readout. +- Added Stage 17 tests for layer growth, max-layer bounds, deep gradients, + trainer-triggered layer insertion, deep persistence, and CLI inspect/query. ### Next -- Stage 17 — Layer growth. +- Stage 18 — Internet agent. --- diff --git a/README.md b/README.md index af0f8b3..c3a5d39 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,8 @@ Manas v2 is in active development. The roadmap follows a strict rule: | Stage 14 | Inspect, neurons, and debug commands | Complete | | Stage 15 | Compression and forget command | Complete | | Stage 16 | Benchmarks and test suite | Complete | -| Stage 17+ | Layer growth, future agents | Planned | +| Stage 17 | Layer growth | Complete | +| Stage 18+ | Internet agent, language generation | Planned | Stages 1 and 2 are preserved as a standalone proof in `manas-core/src/experiment.rs`. Stage 3 promotes the proven engine into @@ -168,6 +169,9 @@ Stage 16 adds a dedicated benchmark crate, committed benchmark report, CI benchmark smoke test, and an eight-test integration gate covering the demo, anti-forgetting, persistence, growth, protection, compression, freshness, and ingestion. +Stage 17 adds real hidden-layer growth: when existing hidden memory is saturated, +Manas can insert a new hidden layer before the output layer while keeping older +bound answer columns intact. Run the proof: @@ -247,6 +251,13 @@ Run the Stage 16 integration proof: cargo test -p manas-cli stage16 ``` +Run the Stage 17 layer-growth proof: + +```bash +cargo test layer_growth +cargo test stage17 +``` + Run the benchmarks: ```bash diff --git a/ROADMAP.md b/ROADMAP.md index f4a4615..9049503 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -85,7 +85,7 @@ from Stage 2 onward.** | Stage 14 | Inspect, neurons, and debug commands | Complete | | Stage 15 | Compression and forget command | Complete | | Stage 16 | Benchmarks and test suite | Complete | -| Stage 17 | Layer growth | Planned | +| Stage 17 | Layer growth | Complete | | Stage 18 | Internet agent (future) | Planned | | Stage 19 | Language generation (future) | Planned | @@ -2054,11 +2054,26 @@ fn deeper_network_can_represent_more_facts() { ### Done When -- [ ] All layer growth tests pass -- [ ] Anti-forgetting test passes with 2-layer networks -- [ ] Layer count never exceeds `MAX_LAYERS` -- [ ] `manas inspect` shows correct layer count and per-layer stats -- [ ] `cargo test -p manas-core layer_growth` passes clean +- [x] All layer growth tests pass +- [x] Anti-forgetting test passes with 2-layer networks +- [x] Layer count never exceeds `MAX_LAYERS` +- [x] `manas inspect` shows correct layer count and per-layer stats +- [x] `cargo test -p manas-core layer_growth` passes clean + +### Stage 17 Implementation Notes + +- Generalized `Network` to support multiple hidden layers plus a final output + layer. +- Kept old bound answer columns stable by making the output layer read a + flattened vector of all hidden activations. +- Changed `grow_layer` from Stage 7 compatibility widening into real hidden + layer insertion before the output layer. +- Generalized forward caches, backpropagation, bound readout, trainer growth, + diagnostics, and `.manas` validation for deep networks. +- Kept compression conservative: hidden compaction still only runs on the + original two-layer topology. +- Added Stage 17 coverage for core growth, deep gradients, trainer layer growth, + deep persistence, and CLI inspect/query. --- diff --git a/manas-cli/tests/stage17_layer_growth.rs b/manas-cli/tests/stage17_layer_growth.rs new file mode 100644 index 0000000..b8f2f11 --- /dev/null +++ b/manas-cli/tests/stage17_layer_growth.rs @@ -0,0 +1,123 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use manas_core::Network; +use manas_learn::Trainer; +use manas_store::{BrainState, ManasBrain, VocabEntry}; + +const EMBED_DIM: usize = 32; +const LEARNING_RATE: f32 = 0.01; + +#[test] +fn stage17_cli_inspects_and_queries_deep_brain() { + let dir = temp_dir("cli-deep"); + let mut network = Network::new_empty(EMBED_DIM); + let mut trainer = Trainer::with_seed(42, EMBED_DIM, LEARNING_RATE); + + trainer + .learn(&mut network, "cat", "small animal with fur") + .unwrap(); + trainer + .learn(&mut network, "paris", "city in france") + .unwrap(); + for neuron in &mut network.layers[0].neurons { + neuron.guard_all(); + } + let report = trainer + .learn(&mut network, "rust", "systems programming language") + .unwrap(); + assert_eq!(report.layers_grown, 1); + assert_eq!(network.layer_count(), 3); + + ManasBrain::new(dir.join("brain.manas")) + .save_state(&BrainState::new(network, store_vocab(&trainer))) + .unwrap(); + + let inspect = run(&dir, &["inspect"]); + assert_success(&inspect); + let inspect_stdout = stdout(&inspect); + assert!( + inspect_stdout.contains("total layers : 3"), + "{inspect_stdout}" + ); + assert!( + inspect_stdout.contains("layers grown : 1"), + "{inspect_stdout}" + ); + assert!( + inspect_stdout.contains("layer 2 id=2 activation=linear"), + "{inspect_stdout}" + ); + + let ask = run(&dir, &["ask", "What is Rust?"]); + assert_success(&ask); + let ask_stdout = stdout(&ask); + assert!( + ask_stdout.contains("Answered from\n neural weights"), + "{ask_stdout}" + ); + assert!( + ask_stdout.contains("systems") || ask_stdout.contains("programming"), + "{ask_stdout}" + ); + + cleanup_dir(dir); +} + +fn store_vocab(trainer: &Trainer) -> Vec { + trainer + .encoder + .export_vocab() + .into_iter() + .map(|entry| VocabEntry { + token: entry.token, + id: entry.id, + embedding: entry.embedding, + }) + .collect() +} + +fn run(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_manas")) + .args(args) + .current_dir(dir) + .output() + .unwrap() +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "status: {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + stdout(output), + stderr(output) + ); +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn temp_dir(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "manas-stage17-{name}-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&dir).unwrap(); + dir +} + +fn cleanup_dir(path: PathBuf) { + let _ = fs::remove_dir_all(path); +} diff --git a/manas-core/src/network.rs b/manas-core/src/network.rs index 1cc6767..3e96598 100644 --- a/manas-core/src/network.rs +++ b/manas-core/src/network.rs @@ -13,6 +13,7 @@ const GRAD_CLIP: f32 = 1.0; const DEFAULT_SEED: u64 = 42; const RIDGE: f32 = 1.0e-4; const ANCHOR_CONSTRAINT_WEIGHT: f32 = 50.0; +const READOUT_TIE_EPSILON: f32 = 1.0e-3; /// Borrowed vector pair used by consolidation and readout fitting. #[derive(Clone, Copy)] @@ -32,6 +33,7 @@ pub struct NeuronGradients { #[derive(Clone, Debug)] pub struct ForwardCache { pub input: Vec, + pub hidden_layers: Vec>, pub hidden: Vec, pub output: Vec, } @@ -39,7 +41,12 @@ pub struct ForwardCache { /// Output reconstructed from one hidden neuron selected by activation. #[derive(Clone, Debug, PartialEq)] pub struct HiddenReadout { + /// Backward-compatible alias for `global_hidden_index`. pub hidden_index: usize, + pub layer_index: usize, + pub neuron_index: usize, + pub global_hidden_index: usize, + pub neuron_id: u64, pub activation: f32, pub output: Vec, } @@ -154,11 +161,12 @@ impl Network { ) -> Result { validate_persisted_layers(input_dim, &layers)?; - let hidden_dim = layers[0].neurons.len(); - let output_dim = if layers[1].neurons.is_empty() { + let output_layer_index = layers.len() - 1; + let hidden_dim = hidden_feature_count_from_layers(&layers); + let output_dim = if layers[output_layer_index].neurons.is_empty() { input_dim } else { - layers[1].neurons.len() + layers[output_layer_index].neurons.len() }; let total_neurons = layers.iter().map(|layer| layer.neurons.len() as u64).sum(); let next_id = layers @@ -188,15 +196,25 @@ impl Network { } pub fn forward_with_cache(&self, input: &[f32]) -> ForwardCache { - let hidden = self.layers[0].forward(input); - let output = if self.layers[1].neurons.is_empty() { + let mut hidden_layers = Vec::with_capacity(self.layers.len().saturating_sub(1)); + let mut layer_input = input.to_vec(); + + for layer in self.hidden_layers() { + let activations = layer.forward(&layer_input); + layer_input = activations.clone(); + hidden_layers.push(activations); + } + + let hidden = flatten_hidden_layers(&hidden_layers); + let output = if self.output_layer().neurons.is_empty() { vec![0.0; self.output_dim] } else { - self.layers[1].forward(&hidden) + self.output_layer().forward(&hidden) }; ForwardCache { input: input.to_vec(), + hidden_layers, hidden, output, } @@ -226,46 +244,53 @@ impl Network { } pub fn grow_neuron(&mut self, layer_id: u32, input_size: usize) -> Result { - if self.layers.len() != 2 { - return Err(ManasError::GrowthFailed(format!( - "Stage 7 growth expects exactly 2 layers, found {}", - self.layers.len() - ))); - } - if layer_id != 0 { + let layer_index = self + .layers + .iter() + .position(|layer| layer.id == layer_id) + .ok_or(ManasError::LayerNotFound(layer_id))?; + if layer_index >= self.output_layer_index() { return Err(ManasError::LayerNotFound(layer_id)); } - if input_size != self.input_dim { + + let expected_input_size = self.hidden_layer_input_size(layer_index)?; + if input_size != expected_input_size { return Err(ManasError::GrowthFailed(format!( "input size mismatch for growth: expected {}, found {}", - self.input_dim, input_size + expected_input_size, input_size ))); } - if self.layers[0].neurons.len() >= MAX_NEURONS_PER_LAYER { + if self.layers[layer_index].neurons.len() >= MAX_NEURONS_PER_LAYER { return Err(ManasError::GrowthFailed(format!( - "layer 0 reached max hidden neurons ({MAX_NEURONS_PER_LAYER})" + "layer {layer_id} reached max hidden neurons ({MAX_NEURONS_PER_LAYER})" ))); } let hidden_id = self.next_id; self.next_id = self.next_id.saturating_add(1); - let hidden_limit = xavier_limit(self.input_dim, self.hidden_dim.saturating_add(1)); + let hidden_limit = xavier_limit( + expected_input_size, + self.layers[layer_index].neurons.len().saturating_add(1), + ); let mut hidden_rng = SplitMix64::new(growth_seed(hidden_id)); let hidden_neuron = Neuron::random( hidden_id, &mut hidden_rng, - self.input_dim, + expected_input_size, hidden_limit, Activation::Tanh, ); - self.layers[0].neurons.push(hidden_neuron); - self.hidden_dim = self.layers[0].neurons.len(); + let global_hidden_index = self.global_hidden_insert_index(layer_index)?; + self.layers[layer_index].neurons.push(hidden_neuron); + self.hidden_dim = self.hidden_feature_count(); self.total_neurons = self.total_neurons.saturating_add(1); - if self.layers[1].neurons.is_empty() { + self.extend_next_hidden_layer_for_new_input(layer_index); + + if self.output_layer().neurons.is_empty() { self.bootstrap_output_layer(); } else { - self.extend_output_layer_for_hidden_neuron(); + self.insert_output_edge_for_hidden_neuron(global_hidden_index); } Ok(hidden_id) @@ -276,15 +301,102 @@ impl Network { input_size: usize, neuron_count: usize, ) -> Result { - if self.layer_count() >= MAX_LAYERS && self.layers.len() > 2 { + if self.layer_count() >= MAX_LAYERS { return Err(ManasError::GrowthFailed(format!( "network reached max layers ({MAX_LAYERS})" ))); } + if neuron_count == 0 { + return Err(ManasError::GrowthFailed( + "new layer must contain at least one neuron".to_string(), + )); + } + if neuron_count > MAX_NEURONS_PER_LAYER { + return Err(ManasError::GrowthFailed(format!( + "new layer requested {neuron_count} neurons, max is {MAX_NEURONS_PER_LAYER}" + ))); + } + + let output_index = self.output_layer_index(); + let expected_input_size = if output_index == 0 { + self.input_dim + } else { + self.layers[output_index - 1].neurons.len() + }; + if expected_input_size == 0 { + return Err(ManasError::GrowthFailed( + "cannot grow a new layer before an empty hidden layer".to_string(), + )); + } + if input_size != expected_input_size { + return Err(ManasError::GrowthFailed(format!( + "input size mismatch for layer growth: expected {}, found {}", + expected_input_size, input_size + ))); + } + + let new_layer_id = output_index as u32; + let hidden_limit = xavier_limit(input_size, neuron_count); + let mut neurons = Vec::with_capacity(neuron_count); for _ in 0..neuron_count { - self.grow_neuron(0, input_size)?; + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + let mut rng = SplitMix64::new(growth_seed(id)); + neurons.push(Neuron::random( + id, + &mut rng, + input_size, + hidden_limit, + Activation::Tanh, + )); + } + + self.layers.insert( + output_index, + Layer { + id: new_layer_id, + neurons, + activation: Activation::Tanh, + }, + ); + self.renumber_layers(); + self.total_neurons = self + .total_neurons + .saturating_add(neuron_count.try_into().unwrap_or(u64::MAX)); + let old_hidden_dim = self.hidden_dim; + self.hidden_dim = self.hidden_feature_count(); + + if self.output_layer().neurons.is_empty() { + self.bootstrap_output_layer(); + } else { + for global_index in old_hidden_dim..self.hidden_dim { + self.insert_output_edge_for_hidden_neuron(global_index); + } } - Ok(0) + + Ok(self.layers[output_index].id) + } + + pub fn should_grow_layer(&self) -> bool { + if self.layer_count() >= MAX_LAYERS { + return false; + } + + let hidden_layers = self.hidden_layers(); + if hidden_layers.is_empty() || hidden_layers.iter().all(|layer| layer.neurons.is_empty()) { + return false; + } + + let all_hidden_saturated = hidden_layers + .iter() + .flat_map(|layer| layer.neurons.iter()) + .all(|neuron| !matches!(neuron.protection_level, ProtectionLevel::Open)); + let deepest_full = hidden_layers + .last() + .map(|layer| layer.neurons.len() >= MAX_NEURONS_PER_LAYER) + .unwrap_or(false); + + all_hidden_saturated || deepest_full } pub fn neuron_count(&self) -> u64 { @@ -332,12 +444,11 @@ impl Network { ))); } - let index = self.layers[0] - .neurons - .iter() - .position(|neuron| neuron.id == neuron_id) + let (layer_index, neuron_index) = self + .hidden_location_by_id(neuron_id) .ok_or(ManasError::NeuronNotFound(neuron_id))?; - self.key_hidden_neurons_to_input(input, &[index], false); + let layer_input = self.hidden_input_for_layer(input, layer_index)?; + self.key_hidden_neuron_to_vector(layer_index, neuron_index, &layer_input, false); Ok(()) } @@ -361,27 +472,19 @@ impl Network { target.len() ))); } - if self.layers.len() != 2 { - return Err(ManasError::InvalidNetwork(format!( - "bound readout expects exactly 2 layers, found {}", - self.layers.len() - ))); - } - if self.layers[1].neurons.len() != self.output_dim { + if self.output_layer().neurons.len() != self.output_dim { return Err(ManasError::InvalidNetwork(format!( "output layer has {} neurons, expected {}", - self.layers[1].neurons.len(), + self.output_layer().neurons.len(), self.output_dim ))); } - let hidden_index = self.layers[0] - .neurons - .iter() - .position(|neuron| neuron.id == neuron_id) + let (layer_index, neuron_index) = self + .hidden_location_by_id(neuron_id) .ok_or(ManasError::NeuronNotFound(neuron_id))?; if !matches!( - self.layers[0].neurons[hidden_index].protection_level, + self.layers[layer_index].neurons[neuron_index].protection_level, ProtectionLevel::Open ) { return Err(ManasError::InvalidNetwork(format!( @@ -389,79 +492,82 @@ impl Network { ))); } - self.key_hidden_neurons_to_input(input, &[hidden_index], false); - self.layers[0].neurons[hidden_index].activation_count = self.layers[0].neurons - [hidden_index] + let layer_input = self.hidden_input_for_layer(input, layer_index)?; + self.key_hidden_neuron_to_vector(layer_index, neuron_index, &layer_input, false); + self.layers[layer_index].neurons[neuron_index].activation_count = self.layers[layer_index] + .neurons[neuron_index] .activation_count .saturating_add(1); + let global_hidden_index = self + .global_hidden_index(layer_index, neuron_index) + .ok_or_else(|| ManasError::InvalidNetwork("hidden index out of range".to_string()))?; - for (output_neuron, target_value) in self.layers[1].neurons.iter_mut().zip(target.iter()) { - if hidden_index >= output_neuron.weights.len() { + for (output_neuron, target_value) in self + .output_layer_mut() + .neurons + .iter_mut() + .zip(target.iter()) + { + if global_hidden_index >= output_neuron.weights.len() { return Err(ManasError::InvalidNetwork(format!( "output neuron {} is missing hidden weight {}", - output_neuron.id, hidden_index + output_neuron.id, global_hidden_index ))); } - output_neuron.weights[hidden_index] = *target_value; + output_neuron.weights[global_hidden_index] = *target_value; } - Ok(hidden_index) + Ok(global_hidden_index) } pub fn readout_from_best_hidden(&self, input: &[f32]) -> Option { if input.len() != self.input_dim - || self.layers.len() != 2 - || self.layers[0].neurons.is_empty() - || self.layers[1].neurons.is_empty() + || self.hidden_feature_count() == 0 + || self.output_layer().neurons.is_empty() { return None; } - let hidden = self.layers[0].forward(input); - let (hidden_index, activation) = hidden - .iter() - .enumerate() - .max_by(|left, right| { - left.1 - .partial_cmp(right.1) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .map(|(index, activation)| (index, *activation))?; + let cache = self.forward_with_cache(input); + let (global_hidden_index, activation) = best_hidden_activation(&cache.hidden)?; if activation <= f32::EPSILON { return None; } - let output = self.layers[1] + let output = self + .output_layer() .neurons .iter() .map(|output_neuron| { output_neuron .weights - .get(hidden_index) + .get(global_hidden_index) .copied() .unwrap_or(0.0) }) .map(|weight| weight * activation) .collect::>(); + let (layer_index, neuron_index) = + self.hidden_location_by_global_index(global_hidden_index)?; + let neuron_id = self.layers[layer_index].neurons[neuron_index].id; Some(HiddenReadout { - hidden_index, + hidden_index: global_hidden_index, + layer_index, + neuron_index, + global_hidden_index, + neuron_id, activation, output, }) } pub fn keyed_hidden_memory(&self) -> bool { - self.layers - .first() - .map(|layer| { - layer - .neurons - .iter() - .all(|neuron| matches!(neuron.activation, Activation::Keyed)) - }) - .unwrap_or(false) + self.hidden_layers() + .iter() + .flat_map(|layer| layer.neurons.iter()) + .any(|neuron| matches!(neuron.activation, Activation::Keyed)) } pub fn merge_remove_hidden_neurons( @@ -757,7 +863,7 @@ impl Network { let weights = solve_linear_system(gram.clone(), rhs)?; for (feature_index, hidden_index) in open_indices.iter().enumerate() { - let output_neuron = &mut self.layers[1].neurons[output_dim]; + let output_neuron = &mut self.output_layer_mut().neurons[output_dim]; if matches!( output_neuron.weight_protection[*hidden_index], ProtectionLevel::Open @@ -779,7 +885,7 @@ impl Network { } pub fn frozen_output_edge_count(&self) -> usize { - self.layers[1] + self.output_layer() .neurons .iter() .map(|neuron| { @@ -912,7 +1018,7 @@ impl Network { .zip(alpha.iter()) .map(|(row, alpha_value)| row[feature_index] * alpha_value) .sum::(); - let output_neuron = &mut self.layers[1].neurons[output_dim]; + let output_neuron = &mut self.output_layer_mut().neurons[output_dim]; output_neuron.weights[*hidden_index] = weight; output_neuron.weight_protection[*hidden_index] = ProtectionLevel::Frozen; } @@ -922,14 +1028,14 @@ impl Network { } fn freeze_output_biases(&mut self) { - for output_neuron in &mut self.layers[1].neurons { + for output_neuron in &mut self.output_layer_mut().neurons { output_neuron.bias = 0.0; output_neuron.bias_protection = ProtectionLevel::Frozen; } } fn residual_after_frozen_output(&self, hidden: &[f32], target: &[f32]) -> Vec { - self.layers[1] + self.output_layer() .neurons .iter() .enumerate() @@ -965,33 +1071,188 @@ impl Network { fn bootstrap_output_layer(&mut self) { let output_limit = xavier_limit(self.hidden_dim, self.output_dim); - self.layers[1].neurons = (0..self.output_dim) - .map(|_| { - let id = self.next_id; - self.next_id = self.next_id.saturating_add(1); - let mut rng = SplitMix64::new(growth_seed(id)); - Neuron::random( - id, - &mut rng, - self.hidden_dim, - output_limit, - Activation::Linear, - ) - }) - .collect(); + let mut neurons = Vec::with_capacity(self.output_dim); + for _ in 0..self.output_dim { + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + let mut rng = SplitMix64::new(growth_seed(id)); + neurons.push(Neuron::random( + id, + &mut rng, + self.hidden_dim, + output_limit, + Activation::Linear, + )); + } + self.output_layer_mut().neurons = neurons; self.total_neurons = self .total_neurons .saturating_add(self.output_dim.try_into().unwrap_or(u64::MAX)); } - fn extend_output_layer_for_hidden_neuron(&mut self) { - let output_limit = xavier_limit(self.hidden_dim, self.output_dim); - for output_neuron in &mut self.layers[1].neurons { - let mut rng = SplitMix64::new(growth_seed(output_neuron.id ^ self.hidden_dim as u64)); + fn insert_output_edge_for_hidden_neuron(&mut self, global_hidden_index: usize) { + for output_neuron in &mut self.output_layer_mut().neurons { + output_neuron.weights.insert(global_hidden_index, 0.0); output_neuron - .weights - .push(rng.uniform_range(-output_limit, output_limit)); - output_neuron.weight_protection.push(ProtectionLevel::Open); + .weight_protection + .insert(global_hidden_index, ProtectionLevel::Open); + } + } + + fn output_layer_index(&self) -> usize { + self.layers.len().saturating_sub(1) + } + + fn output_layer(&self) -> &Layer { + &self.layers[self.output_layer_index()] + } + + fn output_layer_mut(&mut self) -> &mut Layer { + let index = self.output_layer_index(); + &mut self.layers[index] + } + + fn hidden_layers(&self) -> &[Layer] { + let output_index = self.output_layer_index(); + &self.layers[..output_index] + } + + fn hidden_feature_count(&self) -> usize { + hidden_feature_count_from_layers(&self.layers) + } + + fn hidden_layer_input_size(&self, layer_index: usize) -> Result { + if layer_index >= self.output_layer_index() { + return Err(ManasError::LayerNotFound( + self.layers + .get(layer_index) + .map(|layer| layer.id) + .unwrap_or(u32::MAX), + )); + } + Ok(if layer_index == 0 { + self.input_dim + } else { + self.layers[layer_index - 1].neurons.len() + }) + } + + fn hidden_input_for_layer( + &self, + input: &[f32], + layer_index: usize, + ) -> Result, ManasError> { + if layer_index >= self.output_layer_index() { + return Err(ManasError::LayerNotFound( + self.layers + .get(layer_index) + .map(|layer| layer.id) + .unwrap_or(u32::MAX), + )); + } + + let mut layer_input = input.to_vec(); + for layer in &self.layers[..layer_index] { + layer_input = layer.forward(&layer_input); + } + Ok(layer_input) + } + + fn global_hidden_insert_index(&self, layer_index: usize) -> Result { + if layer_index >= self.output_layer_index() { + return Err(ManasError::LayerNotFound( + self.layers + .get(layer_index) + .map(|layer| layer.id) + .unwrap_or(u32::MAX), + )); + } + Ok(self.layers[..layer_index] + .iter() + .map(|layer| layer.neurons.len()) + .sum::() + + self.layers[layer_index].neurons.len()) + } + + pub fn global_hidden_index(&self, layer_index: usize, neuron_index: usize) -> Option { + if layer_index >= self.output_layer_index() + || neuron_index >= self.layers.get(layer_index)?.neurons.len() + { + return None; + } + Some( + self.layers[..layer_index] + .iter() + .map(|layer| layer.neurons.len()) + .sum::() + + neuron_index, + ) + } + + pub fn hidden_location_by_global_index( + &self, + global_hidden_index: usize, + ) -> Option<(usize, usize)> { + let mut offset = 0; + for (layer_index, layer) in self.hidden_layers().iter().enumerate() { + let next_offset = offset + layer.neurons.len(); + if global_hidden_index < next_offset { + return Some((layer_index, global_hidden_index - offset)); + } + offset = next_offset; + } + None + } + + pub fn hidden_location_by_id(&self, neuron_id: u64) -> Option<(usize, usize)> { + self.hidden_layers() + .iter() + .enumerate() + .find_map(|(layer_index, layer)| { + layer + .neurons + .iter() + .position(|neuron| neuron.id == neuron_id) + .map(|neuron_index| (layer_index, neuron_index)) + }) + } + + fn key_hidden_neuron_to_vector( + &mut self, + layer_index: usize, + neuron_index: usize, + input: &[f32], + freeze: bool, + ) { + let mut key = input.to_vec(); + normalize_in_place(&mut key); + + let neuron = &mut self.layers[layer_index].neurons[neuron_index]; + for (weight, key_value) in neuron.weights.iter_mut().zip(key.iter()) { + *weight = *key_value; + } + neuron.bias = 0.0; + neuron.activation = Activation::Keyed; + if freeze { + neuron.freeze_all(); + } + } + + fn extend_next_hidden_layer_for_new_input(&mut self, layer_index: usize) { + let next_layer_index = layer_index + 1; + if next_layer_index >= self.output_layer_index() { + return; + } + + for neuron in &mut self.layers[next_layer_index].neurons { + neuron.weights.push(0.0); + neuron.weight_protection.push(ProtectionLevel::Open); + } + } + + fn renumber_layers(&mut self) { + for (index, layer) in self.layers.iter_mut().enumerate() { + layer.id = index as u32; } } @@ -1000,11 +1261,46 @@ impl Network { } } +fn hidden_feature_count_from_layers(layers: &[Layer]) -> usize { + layers + .iter() + .take(layers.len().saturating_sub(1)) + .map(|layer| layer.neurons.len()) + .sum() +} + +fn flatten_hidden_layers(hidden_layers: &[Vec]) -> Vec { + let total = hidden_layers.iter().map(Vec::len).sum(); + let mut hidden = Vec::with_capacity(total); + for layer in hidden_layers { + hidden.extend_from_slice(layer); + } + hidden +} + +fn best_hidden_activation(hidden: &[f32]) -> Option<(usize, f32)> { + let mut best: Option<(usize, f32)> = None; + for (index, activation) in hidden.iter().copied().enumerate() { + let replace = match best { + None => true, + Some((best_index, best_activation)) => { + activation > best_activation + READOUT_TIE_EPSILON + || ((activation - best_activation).abs() <= READOUT_TIE_EPSILON + && index < best_index) + } + }; + if replace { + best = Some((index, activation)); + } + } + best +} + fn validate_persisted_layers(input_dim: usize, layers: &[Layer]) -> Result<(), ManasError> { - if layers.len() != 2 { + if !(2..=MAX_LAYERS).contains(&layers.len()) { return Err(ManasError::InvalidNetwork(format!( - "Stage 4 persistence expects exactly 2 layers, found {}", - layers.len() + "network expects between 2 and {MAX_LAYERS} layers, found {}", + layers.len(), ))); } if input_dim == 0 { @@ -1012,25 +1308,39 @@ fn validate_persisted_layers(input_dim: usize, layers: &[Layer]) -> Result<(), M "input dimension must be greater than zero".to_string(), )); } - if layers[0].neurons.is_empty() && !layers[1].neurons.is_empty() { + + let output_index = layers.len() - 1; + let hidden_feature_count = hidden_feature_count_from_layers(layers); + let output_neurons = layers[output_index].neurons.len(); + let any_hidden = hidden_feature_count > 0; + + if !any_hidden && output_neurons > 0 { return Err(ManasError::InvalidNetwork( - "empty hidden layer cannot have output neurons".to_string(), + "empty hidden layers cannot have output neurons".to_string(), )); } - if !layers[0].neurons.is_empty() && layers[1].neurons.is_empty() { + if any_hidden && output_neurons == 0 { return Err(ManasError::InvalidNetwork( - "non-empty hidden layer requires output neurons".to_string(), + "non-empty hidden layers require output neurons".to_string(), )); } + for (layer_index, layer) in layers[..output_index].iter().enumerate() { + if layer.neurons.is_empty() && any_hidden { + return Err(ManasError::InvalidNetwork(format!( + "hidden layer {layer_index} is empty in a non-empty network" + ))); + } + } - let hidden_dim = layers[0].neurons.len(); let mut seen_ids = HashSet::new(); for (layer_index, layer) in layers.iter().enumerate() { let expected_weights = if layer_index == 0 { input_dim + } else if layer_index == output_index { + hidden_feature_count } else { - hidden_dim + layers[layer_index - 1].neurons.len() }; for neuron in &layer.neurons { @@ -1359,6 +1669,108 @@ mod tests { )); } + #[test] + fn layer_growth_should_grow_when_hidden_neurons_are_saturated() { + let mut network = Network::new_empty(4); + assert!(!network.should_grow_layer()); + + network.grow_neuron(0, 4).unwrap(); + assert!(!network.should_grow_layer()); + + network.layers[0].neurons[0].guard_all(); + assert!(network.should_grow_layer()); + } + + #[test] + fn layer_growth_inserts_hidden_layer_before_output_and_preserves_readout() { + let mut network = Network::new_empty(4); + let cat_id = network.grow_neuron(0, 4).unwrap(); + let paris_id = network.grow_neuron(0, 4).unwrap(); + let cat_input = [1.0, 0.0, 0.0, 0.0]; + let paris_input = [0.0, 1.0, 0.0, 0.0]; + let rust_input = [0.0, 0.0, 1.0, 0.0]; + let cat_target = [0.9, 0.1, 0.0, 0.0]; + let paris_target = [0.0, 0.9, 0.1, 0.0]; + let rust_target = [0.0, 0.0, 0.9, 0.1]; + + network + .bind_hidden_neuron_to_fact(cat_id, &cat_input, &cat_target) + .unwrap(); + network + .bind_hidden_neuron_to_fact(paris_id, &paris_input, &paris_target) + .unwrap(); + let cat_before = network.readout_from_best_hidden(&cat_input).unwrap().output; + let paris_before = network + .readout_from_best_hidden(&paris_input) + .unwrap() + .output; + + let new_layer_id = network.grow_layer(2, 1).unwrap(); + let rust_id = network + .layers + .iter() + .find(|layer| layer.id == new_layer_id) + .and_then(|layer| layer.neurons.last()) + .map(|neuron| neuron.id) + .unwrap(); + network + .bind_hidden_neuron_to_fact(rust_id, &rust_input, &rust_target) + .unwrap(); + + assert_eq!(network.layer_count(), 3); + assert_eq!(network.hidden_dim, 3); + assert_eq!(network.layers[2].neurons.len(), 4); + assert!( + network.layers[2] + .neurons + .iter() + .all(|neuron| neuron.weights.len() == 3) + ); + assert_vectors_close( + &network.readout_from_best_hidden(&cat_input).unwrap().output, + &cat_before, + ); + assert_vectors_close( + &network + .readout_from_best_hidden(&paris_input) + .unwrap() + .output, + &paris_before, + ); + assert_vectors_close( + &network + .readout_from_best_hidden(&rust_input) + .unwrap() + .output, + &rust_target, + ); + assert_eq!( + network + .readout_from_best_hidden(&rust_input) + .unwrap() + .layer_index, + 1 + ); + } + + #[test] + fn layer_growth_respects_max_layers() { + let mut network = Network::new_empty(4); + network.grow_neuron(0, 4).unwrap(); + + while network.layer_count() < MAX_LAYERS { + let input_size = network.layers[network.layer_count() - 2].neurons.len(); + network.grow_layer(input_size, 1).unwrap(); + } + + let input_size = network.layers[network.layer_count() - 2].neurons.len(); + assert!(matches!( + network.grow_layer(input_size, 1), + Err(ManasError::GrowthFailed(_)) + )); + assert_eq!(network.layer_count(), MAX_LAYERS); + } + #[test] fn count_methods_track_growth_and_protection() { let mut network = Network::new_empty(8); diff --git a/manas-learn/src/backprop.rs b/manas-learn/src/backprop.rs index dc3104e..4df27f0 100644 --- a/manas-learn/src/backprop.rs +++ b/manas-learn/src/backprop.rs @@ -21,15 +21,15 @@ pub fn mse_loss(actual: &[f32], expected: &[f32]) -> Result { / actual.len() as f32) } -/// Compute gradients for the proven two-layer associative network. +/// Compute gradients for the associative network. pub fn compute_gradients( network: &Network, input: &[f32], target: &[f32], ) -> Result<(f32, Vec<(u64, NeuronGradients)>), ManasError> { - if network.layers.len() != 2 { + if network.layers.len() < 2 { return Err(ManasError::InvalidNetwork( - "Stage 3 trainer expects a two-layer network".to_string(), + "trainer expects at least one hidden layer and one output layer".to_string(), )); } if target.len() != network.output_dim { @@ -40,6 +40,16 @@ pub fn compute_gradients( ))); } + let output_layer_index = network.layers.len() - 1; + let output_layer = &network.layers[output_layer_index]; + if output_layer.neurons.len() != network.output_dim { + return Err(ManasError::InvalidNetwork(format!( + "output layer has {} neurons, expected {}", + output_layer.neurons.len(), + network.output_dim + ))); + } + let cache = network.forward_with_cache(input); let loss = mse_loss(&cache.output, target)?; @@ -47,34 +57,72 @@ pub fn compute_gradients( .output .iter() .zip(target.iter()) - .zip(network.layers[1].neurons.iter()) + .zip(output_layer.neurons.iter()) .map(|((actual, expected), neuron)| { let error_grad = 2.0 * (actual - expected) / network.output_dim as f32; error_grad * neuron.derivative_from_output(*actual) }) .collect::>(); - let mut hidden_deltas = vec![0.0; network.hidden_dim]; - for (output_delta, output_neuron) in output_deltas.iter().zip(network.layers[1].neurons.iter()) - { - for (hidden_delta, weight) in hidden_deltas.iter_mut().zip(output_neuron.weights.iter()) { - *hidden_delta += output_delta * weight; + let mut hidden_deltas = cache + .hidden_layers + .iter() + .map(|layer| vec![0.0; layer.len()]) + .collect::>(); + for output_neuron in &output_layer.neurons { + if output_neuron.weights.len() != cache.hidden.len() { + return Err(ManasError::InvalidNetwork(format!( + "output neuron {} has {} weights, expected {}", + output_neuron.id, + output_neuron.weights.len(), + cache.hidden.len() + ))); + } + } + + let mut global_hidden_index = 0; + for layer_deltas in &mut hidden_deltas { + for hidden_delta in layer_deltas { + for (output_delta, output_neuron) in + output_deltas.iter().zip(output_layer.neurons.iter()) + { + *hidden_delta += output_delta * output_neuron.weights[global_hidden_index]; + } + global_hidden_index += 1; } } - for ((hidden_delta, hidden_activation), hidden_neuron) in hidden_deltas - .iter_mut() - .zip(cache.hidden.iter()) - .zip(network.layers[0].neurons.iter()) - { - *hidden_delta *= hidden_neuron.derivative_from_output(*hidden_activation); + for layer_index in (0..output_layer_index).rev() { + for (neuron_index, hidden_delta) in hidden_deltas[layer_index].iter_mut().enumerate() { + let activation = cache.hidden_layers[layer_index][neuron_index]; + let neuron = &network.layers[layer_index].neurons[neuron_index]; + *hidden_delta *= neuron.derivative_from_output(activation); + } + + if layer_index > 0 { + let current_deltas = hidden_deltas[layer_index].clone(); + for (neuron, delta) in network.layers[layer_index] + .neurons + .iter() + .zip(current_deltas.iter()) + { + for (previous_delta, weight) in hidden_deltas[layer_index - 1] + .iter_mut() + .zip(neuron.weights.iter()) + { + *previous_delta += delta * weight; + } + } + } } - let mut gradients = - Vec::with_capacity(network.layers[0].neurons.len() + network.layers[1].neurons.len()); + let hidden_neuron_count = network.layers[..output_layer_index] + .iter() + .map(|layer| layer.neurons.len()) + .sum::(); + let mut gradients = Vec::with_capacity(hidden_neuron_count + output_layer.neurons.len()); - for (output_neuron, output_delta) in network.layers[1].neurons.iter().zip(output_deltas.iter()) - { + for (output_neuron, output_delta) in output_layer.neurons.iter().zip(output_deltas.iter()) { gradients.push(( output_neuron.id, NeuronGradients { @@ -88,19 +136,29 @@ pub fn compute_gradients( )); } - for (hidden_neuron, hidden_delta) in network.layers[0].neurons.iter().zip(hidden_deltas.iter()) - { - gradients.push(( - hidden_neuron.id, - NeuronGradients { - weight_gradients: cache - .input - .iter() - .map(|input_value| hidden_delta * input_value) - .collect(), - bias_gradient: *hidden_delta, - }, - )); + for (layer_index, layer_deltas) in hidden_deltas.iter().enumerate().take(output_layer_index) { + let layer_input = if layer_index == 0 { + &cache.input + } else { + &cache.hidden_layers[layer_index - 1] + }; + + for (hidden_neuron, hidden_delta) in network.layers[layer_index] + .neurons + .iter() + .zip(layer_deltas.iter()) + { + gradients.push(( + hidden_neuron.id, + NeuronGradients { + weight_gradients: layer_input + .iter() + .map(|input_value| hidden_delta * input_value) + .collect(), + bias_gradient: *hidden_delta, + }, + )); + } } Ok((loss, gradients)) @@ -124,3 +182,30 @@ fn dot(left: &[f32], right: &[f32]) -> f32 { .map(|(left_value, right_value)| left_value * right_value) .sum() } + +#[cfg(test)] +mod tests { + use super::*; + use manas_core::Network; + + #[test] + fn layer_growth_gradients_support_deep_network() { + let mut network = Network::new_empty(4); + network.grow_neuron(0, 4).unwrap(); + network.grow_layer(1, 1).unwrap(); + + let input = [0.5, -0.25, 0.75, 0.1]; + let target = [0.1, 0.2, 0.3, 0.4]; + let (loss, gradients) = compute_gradients(&network, &input, &target).unwrap(); + + assert!(loss.is_finite()); + assert_eq!(gradients.len(), network.neuron_count() as usize); + assert!(gradients.iter().all(|(_, gradient)| { + gradient + .weight_gradients + .iter() + .all(|value| value.is_finite()) + && gradient.bias_gradient.is_finite() + })); + } +} diff --git a/manas-learn/src/diagnostics.rs b/manas-learn/src/diagnostics.rs index 8b3178f..017af3c 100644 --- a/manas-learn/src/diagnostics.rs +++ b/manas-learn/src/diagnostics.rs @@ -305,8 +305,8 @@ pub fn trace_query( if encoded { if network.keyed_hidden_memory() { if let Some(readout) = network.readout_from_best_hidden(&input) { - variant.hidden_index = Some(readout.hidden_index); - variant.hidden_neuron_id = hidden_neuron_id(network, readout.hidden_index); + variant.hidden_index = Some(readout.global_hidden_index); + variant.hidden_neuron_id = Some(readout.neuron_id); variant.hidden_activation = readout.activation; if let Some(decoded) = @@ -463,10 +463,11 @@ fn update_best(best: &mut Option, candidate: TraceCandidate) { } fn hidden_neuron_id(network: &Network, hidden_index: usize) -> Option { + let (layer_index, neuron_index) = network.hidden_location_by_global_index(hidden_index)?; network .layers - .first() - .and_then(|layer| layer.neurons.get(hidden_index)) + .get(layer_index) + .and_then(|layer| layer.neurons.get(neuron_index)) .map(|neuron| neuron.id) } @@ -489,27 +490,34 @@ fn top_hidden_activations( input: &[f32], limit: usize, ) -> Vec { - let Some(layer) = network.layers.first() else { - return Vec::new(); - }; let cache = network.forward_with_cache(input); - let mut rows = layer - .neurons + let mut global_hidden_index = 0; + let mut rows = Vec::new(); + + for (layer_index, layer) in network + .layers .iter() - .zip(cache.hidden.iter()) + .take(network.layers.len().saturating_sub(1)) .enumerate() - .map( - |(neuron_index, (neuron, activation))| NeuronActivationDiagnostic { - layer_index: 0, + { + for (neuron_index, neuron) in layer.neurons.iter().enumerate() { + let activation = cache + .hidden + .get(global_hidden_index) + .copied() + .unwrap_or(0.0); + rows.push(NeuronActivationDiagnostic { + layer_index, layer_id: layer.id, neuron_index, neuron_id: neuron.id, - activation: *activation, + activation, protection: neuron.protection_level, source_label: source_label(&neuron.source), - }, - ) - .collect::>(); + }); + global_hidden_index += 1; + } + } rows.sort_by(|left, right| { right @@ -527,7 +535,7 @@ fn top_output_values( output: &[f32], limit: usize, ) -> Vec { - let output_layer = network.layers.get(1); + let output_layer = network.layers.last(); let mut rows = output .iter() .enumerate() diff --git a/manas-learn/src/trainer.rs b/manas-learn/src/trainer.rs index f8c715e..35b473b 100644 --- a/manas-learn/src/trainer.rs +++ b/manas-learn/src/trainer.rs @@ -68,14 +68,21 @@ pub struct Trainer { } enum BoundHiddenSelection { - BindExisting(usize), - ReadOnly(usize), + BindExisting(HiddenSelection), + ReadOnly(HiddenSelection), Grow, } +#[derive(Clone, Copy, Debug)] +struct HiddenSelection { + layer_index: usize, + neuron_index: usize, + neuron_id: u64, +} + struct BoundQueryCandidate { decoded: DecodedAnswer, - hidden_index: usize, + selection: HiddenSelection, score: f32, } @@ -162,11 +169,19 @@ impl Trainer { let loss_before = loss_for_fact(network, &fact)?; let mut loss_after = loss_before; let mut neurons_grown = 0; + let mut layers_grown = 0; let mut update_applied = false; - if network.layers[0].neurons.is_empty() || network.layers[1].neurons.is_empty() { - grow_for_fact(network, &fact)?; + if network.layers[0].neurons.is_empty() + || network + .layers + .last() + .map(|layer| layer.neurons.is_empty()) + .unwrap_or(true) + { + let growth = grow_for_fact(network, &fact)?; neurons_grown += 1; + layers_grown += growth.layers_grown; update_applied = true; loss_after = loss_for_fact(network, &fact)?; } @@ -184,8 +199,9 @@ impl Trainer { } if loss_after > self.growth_threshold { - grow_for_fact(network, &fact)?; + let growth = grow_for_fact(network, &fact)?; neurons_grown += 1; + layers_grown += growth.layers_grown; update_applied = true; loss_after = loss_for_fact(network, &fact)?; } @@ -204,7 +220,7 @@ impl Trainer { loss_before, loss_after, neurons_grown, - layers_grown: 0, + layers_grown, neurons_promoted: protection_report.neurons_promoted, neurons_frozen: protection_report.neurons_frozen, total_neurons: network.neuron_count(), @@ -223,33 +239,40 @@ impl Trainer { let activation_counts_before = activation_counts_by_id(network); let loss_before = loss_for_bound_fact(network, fact)?; let mut neurons_grown = 0; + let mut layers_grown = 0; let mut update_applied = false; - let hidden_index = match select_bound_hidden(network, &fact.input) { - BoundHiddenSelection::BindExisting(index) => { - let neuron_id = network.layers[0].neurons[index].id; + let selection = match select_bound_hidden(network, &fact.input) { + BoundHiddenSelection::BindExisting(selection) => { update_applied = true; - network.bind_hidden_neuron_to_fact(neuron_id, &fact.input, &fact.target)? + network.bind_hidden_neuron_to_fact( + selection.neuron_id, + &fact.input, + &fact.target, + )?; + selection } - BoundHiddenSelection::ReadOnly(index) => index, + BoundHiddenSelection::ReadOnly(selection) => selection, BoundHiddenSelection::Grow => { - let neuron_id = network.grow_neuron(0, fact.input.len())?; + let growth = grow_bound_memory_for_fact(network, fact)?; + layers_grown += growth.layers_grown; neurons_grown += 1; update_applied = true; - network.bind_hidden_neuron_to_fact(neuron_id, &fact.input, &fact.target)? + network.bind_hidden_neuron_to_fact(growth.neuron_id, &fact.input, &fact.target)?; + hidden_selection_by_id(network, growth.neuron_id)? } }; let loss_after = loss_for_bound_fact(network, fact)?; refresh_learning_metadata(network, &activation_counts_before, now_secs); let protection_report = self.update_protection_levels_at(network, now_secs); - assign_metadata_to_hidden_index(network, hidden_index, source, freshness); + assign_metadata_to_hidden_selection(network, selection, source, freshness); Ok(LearnReport { loss_before, loss_after, neurons_grown, - layers_grown: 0, + layers_grown, neurons_promoted: protection_report.neurons_promoted, neurons_frozen: protection_report.neurons_frozen, total_neurons: network.neuron_count(), @@ -396,9 +419,14 @@ impl Trainer { .map(|current| score > current.score) .unwrap_or(true) { + let selection = HiddenSelection { + layer_index: readout.layer_index, + neuron_index: readout.neuron_index, + neuron_id: readout.neuron_id, + }; best = Some(BoundQueryCandidate { decoded, - hidden_index: readout.hidden_index, + selection, score, }); } @@ -410,8 +438,8 @@ impl Trainer { let freshness_warning = network .layers - .first() - .and_then(|layer| layer.neurons.get(best.hidden_index)) + .get(best.selection.layer_index) + .and_then(|layer| layer.neurons.get(best.selection.neuron_index)) .and_then(|neuron| staleness_warning(neuron, unix_now_secs())); QueryResult { @@ -460,24 +488,29 @@ fn select_bound_hidden(network: &Network, input: &[f32]) -> BoundHiddenSelection }; let Some(neuron) = network .layers - .first() - .and_then(|layer| layer.neurons.get(readout.hidden_index)) + .get(readout.layer_index) + .and_then(|layer| layer.neurons.get(readout.neuron_index)) else { return BoundHiddenSelection::Grow; }; + let selection = HiddenSelection { + layer_index: readout.layer_index, + neuron_index: readout.neuron_index, + neuron_id: readout.neuron_id, + }; if readout.activation >= EXACT_REUSE_ACTIVATION { return if matches!(neuron.protection_level, ProtectionLevel::Open) { - BoundHiddenSelection::BindExisting(readout.hidden_index) + BoundHiddenSelection::BindExisting(selection) } else { - BoundHiddenSelection::ReadOnly(readout.hidden_index) + BoundHiddenSelection::ReadOnly(selection) }; } if readout.activation >= BOUND_REUSE_ACTIVATION && matches!(neuron.protection_level, ProtectionLevel::Open) { - BoundHiddenSelection::BindExisting(readout.hidden_index) + BoundHiddenSelection::BindExisting(selection) } else { BoundHiddenSelection::Grow } @@ -638,16 +671,23 @@ fn not_enough() -> QueryResult { } } -fn grow_for_fact(network: &mut Network, fact: &EncodedFact) -> Result<(), ManasError> { - let neuron_id = network.grow_neuron(0, fact.input.len())?; - network.key_hidden_neuron_to_input(neuron_id, &fact.input)?; +fn grow_for_fact(network: &mut Network, fact: &EncodedFact) -> Result { + let growth = grow_bound_memory_for_fact(network, fact)?; + network.key_hidden_neuron_to_input(growth.neuron_id, &fact.input)?; + if let Some((layer_index, neuron_index)) = network.hidden_location_by_id(growth.neuron_id) { + network.layers[layer_index].neurons[neuron_index].activation_count = + network.layers[layer_index].neurons[neuron_index] + .activation_count + .saturating_add(1); + } network.fit_open_output_weights_to_facts( &[TrainingExample { input: &fact.input, target: &fact.target, }], &[], - ) + )?; + Ok(growth) } fn assign_metadata_to_best_hidden( @@ -656,58 +696,154 @@ fn assign_metadata_to_best_hidden( source: Source, freshness: FreshnessCategory, ) { - let Some(index) = best_open_hidden_index(network, input) else { + let Some(selection) = best_open_hidden_selection(network, input) else { return; }; - network.layers[0].neurons[index].source = source; - network.layers[0].neurons[index].freshness_category = freshness as u8; + assign_metadata_to_hidden_selection(network, selection, source, freshness); } -fn assign_metadata_to_hidden_index( +fn assign_metadata_to_hidden_selection( network: &mut Network, - hidden_index: usize, + selection: HiddenSelection, source: Source, freshness: FreshnessCategory, ) { if let Some(neuron) = network .layers - .get_mut(0) - .and_then(|layer| layer.neurons.get_mut(hidden_index)) + .get_mut(selection.layer_index) + .and_then(|layer| layer.neurons.get_mut(selection.neuron_index)) { neuron.source = source; neuron.freshness_category = freshness as u8; } } +fn best_open_hidden_selection(network: &Network, input: &[f32]) -> Option { + let cache = network.forward_with_cache(input); + let mut global_hidden_index = 0; + let mut best: Option<(HiddenSelection, f32)> = None; + + for (layer_index, layer) in network + .layers + .iter() + .take(network.layers.len().saturating_sub(1)) + .enumerate() + { + for (neuron_index, neuron) in layer.neurons.iter().enumerate() { + let activation = cache + .hidden + .get(global_hidden_index) + .copied() + .unwrap_or_else(|| neuron.activate(input)) + .abs(); + if !matches!(neuron.protection_level, ProtectionLevel::Frozen) + && best + .as_ref() + .map(|(_, best_activation)| activation > *best_activation) + .unwrap_or(true) + { + best = Some(( + HiddenSelection { + layer_index, + neuron_index, + neuron_id: neuron.id, + }, + activation, + )); + } + global_hidden_index += 1; + } + } + + best.map(|(selection, _)| selection) +} + +#[cfg(test)] fn best_open_hidden_index(network: &Network, input: &[f32]) -> Option { - network.layers.first().and_then(|layer| { - layer - .neurons - .iter() - .enumerate() - .filter(|(_, neuron)| !matches!(neuron.protection_level, ProtectionLevel::Frozen)) - .map(|(index, neuron)| (index, neuron.activate(input).abs())) - .max_by(|left, right| { - left.1 - .partial_cmp(&right.1) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .map(|(index, _)| index) - }) + let selection = best_open_hidden_selection(network, input)?; + network.global_hidden_index(selection.layer_index, selection.neuron_index) } fn best_hidden_neuron<'a>(network: &'a Network, input: &[f32]) -> Option<&'a manas_core::Neuron> { - network.layers.first().and_then(|layer| { - layer.neurons.iter().max_by(|left, right| { - left.activate(input) - .abs() - .partial_cmp(&right.activate(input).abs()) - .unwrap_or(std::cmp::Ordering::Equal) - }) + let selection = best_open_hidden_selection(network, input)?; + network + .layers + .get(selection.layer_index) + .and_then(|layer| layer.neurons.get(selection.neuron_index)) +} + +fn hidden_selection_by_id( + network: &Network, + neuron_id: u64, +) -> Result { + let (layer_index, neuron_index) = network + .hidden_location_by_id(neuron_id) + .ok_or(ManasError::NeuronNotFound(neuron_id))?; + Ok(HiddenSelection { + layer_index, + neuron_index, + neuron_id, }) } +struct BoundGrowth { + neuron_id: u64, + layers_grown: u32, +} + +fn grow_bound_memory_for_fact( + network: &mut Network, + fact: &EncodedFact, +) -> Result { + if network.should_grow_layer() { + let input_size = network + .layers + .get(network.layers.len().saturating_sub(2)) + .map(|layer| layer.neurons.len()) + .unwrap_or(fact.input.len()); + let layer_id = network.grow_layer(input_size, 1)?; + let neuron_id = network + .layers + .iter() + .find(|layer| layer.id == layer_id) + .and_then(|layer| layer.neurons.last()) + .map(|neuron| neuron.id) + .ok_or_else(|| { + ManasError::GrowthFailed("new layer did not create a neuron".to_string()) + })?; + Ok(BoundGrowth { + neuron_id, + layers_grown: 1, + }) + } else { + let layer_index = deepest_growable_hidden_layer(network); + let input_size = if layer_index == 0 { + fact.input.len() + } else { + network.layers[layer_index - 1].neurons.len() + }; + let layer_id = network.layers[layer_index].id; + let neuron_id = network.grow_neuron(layer_id, input_size)?; + Ok(BoundGrowth { + neuron_id, + layers_grown: 0, + }) + } +} + +fn deepest_growable_hidden_layer(network: &Network) -> usize { + network + .layers + .iter() + .take(network.layers.len().saturating_sub(1)) + .enumerate() + .rev() + .find(|(_, layer)| layer.neurons.len() < manas_core::MAX_NEURONS_PER_LAYER) + .map(|(index, _)| index) + .unwrap_or(0) +} + #[cfg(test)] mod tests { use super::*; @@ -762,6 +898,56 @@ mod tests { assert!(network.neuron_count() >= neurons_after_cat); } + #[test] + fn layer_growth_trainer_adds_layer_when_hidden_memory_is_saturated() { + let mut network = Network::new_empty(32); + let mut trainer = Trainer::new(0.01); + + trainer + .learn(&mut network, "cat", "small animal with fur") + .unwrap(); + trainer + .learn( + &mut network, + "Eiffel Tower", + "located in Paris France and built in 1889", + ) + .unwrap(); + for neuron in &mut network.layers[0].neurons { + neuron.guard_all(); + } + let layers_before = network.layer_count(); + + let report = trainer + .learn( + &mut network, + "Einstein", + "theory of relativity in the early 20th century", + ) + .unwrap(); + + assert_eq!(report.layers_grown, 1); + assert_eq!(network.layer_count(), layers_before + 1); + assert_eq!(report.total_neurons, network.neuron_count()); + + let cat = trainer.query(&network, "What is a cat?").unwrap(); + let einstein = trainer + .query(&network, "What did Einstein develop?") + .unwrap(); + assert_eq!(cat.answered_from, AnswerSource::NeuralWeights); + assert_eq!(einstein.answered_from, AnswerSource::NeuralWeights); + assert!( + cat.answer.contains("animal") || cat.answer.contains("fur"), + "{}", + cat.answer + ); + assert!( + einstein.answer.contains("theory") || einstein.answer.contains("relativity"), + "{}", + einstein.answer + ); + } + #[test] fn growth_learn_report_records_growth_and_loss() { let mut network = Network::new_empty(32); diff --git a/manas-store/src/lib.rs b/manas-store/src/lib.rs index 0c224f6..a6ee23e 100644 --- a/manas-store/src/lib.rs +++ b/manas-store/src/lib.rs @@ -478,21 +478,25 @@ fn validate_vocab_entries(entries: &[VocabEntry], embed_dim: usize) -> Result Result<(), ManasError> { - if network.layers.len() != 2 { - return Err(ManasError::InvalidNetwork(format!( - "Stage 4 persistence expects exactly 2 layers, found {}", - network.layers.len() - ))); - } - if network.layers[0].neurons.len() != network.hidden_dim { + let hidden_dim = network + .layers + .iter() + .take(network.layers.len().saturating_sub(1)) + .map(|layer| layer.neurons.len()) + .sum::(); + if hidden_dim != network.hidden_dim { return Err(ManasError::InvalidNetwork( - "hidden dimension does not match hidden layer size".to_string(), + "hidden dimension does not match hidden layer feature count".to_string(), )); } - if network.layers[1].neurons.is_empty() { - if !network.layers[0].neurons.is_empty() { + let output_layer = network + .layers + .last() + .ok_or_else(|| ManasError::InvalidNetwork("network has no layers".to_string()))?; + if output_layer.neurons.is_empty() { + if hidden_dim > 0 { return Err(ManasError::InvalidNetwork( - "non-empty hidden layer requires output neurons".to_string(), + "non-empty hidden layers require output neurons".to_string(), )); } if network.output_dim != network.input_dim { @@ -500,7 +504,7 @@ fn validate_network_for_save(network: &Network) -> Result<(), ManasError> { "empty network output dimension must match input dimension".to_string(), )); } - } else if network.layers[1].neurons.len() != network.output_dim { + } else if output_layer.neurons.len() != network.output_dim { return Err(ManasError::InvalidNetwork( "output dimension does not match output layer size".to_string(), )); diff --git a/manas-store/tests/persistence.rs b/manas-store/tests/persistence.rs index 08b24f5..d2cdcc8 100644 --- a/manas-store/tests/persistence.rs +++ b/manas-store/tests/persistence.rs @@ -310,6 +310,54 @@ fn grown_empty_network_survives_save_and_load() { cleanup(&path); } +#[test] +fn deep_network_survives_save_and_load() { + let path = temp_path("deep"); + let mut network = Network::new_empty(4); + let cat_id = network.grow_neuron(0, 4).unwrap(); + let paris_id = network.grow_neuron(0, 4).unwrap(); + let cat_input = [1.0, 0.0, 0.0, 0.0]; + let paris_input = [0.0, 1.0, 0.0, 0.0]; + let rust_input = [0.0, 0.0, 1.0, 0.0]; + let cat_target = [0.9, 0.1, 0.0, 0.0]; + let paris_target = [0.0, 0.9, 0.1, 0.0]; + let rust_target = [0.0, 0.0, 0.9, 0.1]; + + network + .bind_hidden_neuron_to_fact(cat_id, &cat_input, &cat_target) + .unwrap(); + network + .bind_hidden_neuron_to_fact(paris_id, &paris_input, &paris_target) + .unwrap(); + let layer_id = network.grow_layer(2, 1).unwrap(); + let rust_id = network + .layers + .iter() + .find(|layer| layer.id == layer_id) + .and_then(|layer| layer.neurons.last()) + .map(|neuron| neuron.id) + .unwrap(); + network + .bind_hidden_neuron_to_fact(rust_id, &rust_input, &rust_target) + .unwrap(); + + let expected = network.readout_from_best_hidden(&rust_input).unwrap(); + let brain = ManasBrain::new(&path); + brain.save(&network).unwrap(); + let loaded = brain.load().unwrap(); + let metadata = brain.metadata().unwrap(); + let loaded_readout = loaded.readout_from_best_hidden(&rust_input).unwrap(); + + assert_eq!(loaded.layer_count(), 3); + assert_eq!(loaded.hidden_dim, 3); + assert_eq!(metadata.layer_count, 3); + assert_eq!(loaded_readout.layer_index, expected.layer_index); + assert_eq!(loaded_readout.neuron_id, expected.neuron_id); + assert_eq!(loaded_readout.output, expected.output); + + cleanup(&path); +} + #[test] fn anchor_consolidated_network_survives_save_load() { let path = temp_path("anchors");