diff --git a/src/neuralnet.rs b/src/neuralnet.rs index 31658cd..69a04bf 100644 --- a/src/neuralnet.rs +++ b/src/neuralnet.rs @@ -678,12 +678,13 @@ impl NeuralNetwork { pub fn remove_cycles(&mut self) { let mut visited = HashMap::new(); let mut edges_to_remove: HashSet = HashSet::new(); + let mut path = Vec::new(); for i in 0..I { self.remove_cycles_dfs( &mut visited, &mut edges_to_remove, - None, + &mut path, NeuronLocation::Input(i), ); } @@ -693,7 +694,7 @@ impl NeuralNetwork { for i in 0..self.hidden_layers.len() { let loc = NeuronLocation::Hidden(i); if !visited.contains_key(&loc) { - self.remove_cycles_dfs(&mut visited, &mut edges_to_remove, None, loc); + self.remove_cycles_dfs(&mut visited, &mut edges_to_remove, &mut path, loc); } } @@ -704,22 +705,23 @@ impl NeuralNetwork { } } - // colored dfs + // colored dfs using an explicit path stack so the back-edge (parent → current) + // is always identified correctly, regardless of HashMap iteration order. fn remove_cycles_dfs( &mut self, visited: &mut HashMap, edges_to_remove: &mut HashSet, - prev: Option, + path: &mut Vec, current: NeuronLocation, ) { if let Some(&existing) = visited.get(¤t) { if existing == 0 { - // part of current dfs - found a cycle - // prev must exist here since visited would be empty on first call. - let prev = prev.unwrap(); - if self[prev].outputs.contains_key(¤t) { + // part of current dfs path - found a cycle. + // path.last() is the node that just tried to visit `current`, + // so path.last() → current is the back-edge to remove. + if let Some(&parent) = path.last() { edges_to_remove.insert(Connection { - from: prev, + from: parent, to: current, }); } @@ -730,12 +732,14 @@ impl NeuralNetwork { } visited.insert(current, 0); + path.push(current); let outputs = self[current].outputs.keys().cloned().collect::>(); for loc in outputs { - self.remove_cycles_dfs(visited, edges_to_remove, Some(current), loc); + self.remove_cycles_dfs(visited, edges_to_remove, path, loc); } + path.pop(); visited.insert(current, 1); } diff --git a/src/tests.rs b/src/tests.rs index 067c59e..585ea79 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -512,3 +512,446 @@ mod serde { }); } } + +#[allow(dead_code)] +fn find_cycle_helper( + net: &NeuralNetwork, +) -> Option> { + use std::collections::HashMap as HM; + fn dfs( + net: &NeuralNetwork, + loc: NeuronLocation, + visited: &mut HM, + path: &mut Vec, + ) -> Option> { + if let Some(&in_progress) = visited.get(&loc) { + if in_progress { + let s = path.iter().position(|&x| x == loc).unwrap(); + return Some(path[s..].to_vec()); + } + return None; + } + visited.insert(loc, true); + path.push(loc); + for loc2 in net[loc].outputs.keys() { + if let Some(c) = dfs(net, *loc2, visited, path) { + return Some(c); + } + } + path.pop(); + visited.insert(loc, false); + None + } + let mut visited = HM::new(); + for i in 0..I { + if let Some(c) = dfs(net, NeuronLocation::Input(i), &mut visited, &mut vec![]) { + return Some(c); + } + } + for i in 0..net.hidden_layers.len() { + let loc = NeuronLocation::Hidden(i); + if !visited.contains_key(&loc) { + if let Some(c) = dfs(net, loc, &mut visited, &mut vec![]) { + return Some(c); + } + } + } + None +} + +#[test] +fn debug_locate_cycle_source() { + // Run with no mutations to see if remove_cycles itself fails + let mut settings_no_mut = ReproductionSettings::default(); + settings_no_mut.mutation_passes = 0; + + let mut found_no_mut = false; + let mut found_with_mut = false; + + 'outer: for seed in 0..300u64 { + let mut rng = StdRng::seed_from_u64(seed); + let mut net1 = NeuralNetwork::<10, 10>::new(&mut rng); + let mut net2 = NeuralNetwork::<10, 10>::new(&mut rng); + for iter in 0..100usize { + let a = net1.crossover(&net2, &settings_no_mut, 0.25, &mut rng); + let b = net2.crossover(&net1, &settings_no_mut, 0.25, &mut rng); + if let Some(cycle) = find_cycle_helper(&a) { + println!( + "remove_cycles FAILED: seed={} iter={} (a): {:?}", + seed, iter, cycle + ); + found_no_mut = true; + break 'outer; + } + if let Some(cycle) = find_cycle_helper(&b) { + println!( + "remove_cycles FAILED: seed={} iter={} (b): {:?}", + seed, iter, cycle + ); + found_no_mut = true; + break 'outer; + } + net1 = a; + net2 = b; + } + } + if !found_no_mut { + println!( + "remove_cycles seems correct (no cycles in 300 seeds x 100 iters without mutation)" + ); + } + + // Run with mutations to see if mutation introduces cycles + let settings_with_mut = ReproductionSettings::default(); // mutation_passes = 3 + + 'outer2: for seed in 0..300u64 { + let mut rng = StdRng::seed_from_u64(seed); + let mut net1 = NeuralNetwork::<10, 10>::new(&mut rng); + let mut net2 = NeuralNetwork::<10, 10>::new(&mut rng); + for iter in 0..50usize { + let a = net1.crossover(&net2, &settings_with_mut, 0.25, &mut rng); + let b = net2.crossover(&net1, &settings_with_mut, 0.25, &mut rng); + if let Some(cycle) = find_cycle_helper(&a) { + println!( + "Mutation introduced cycle: seed={} iter={} (a): {:?}", + seed, iter, cycle + ); + found_with_mut = true; + break 'outer2; + } + if let Some(cycle) = find_cycle_helper(&b) { + println!( + "Mutation introduced cycle: seed={} iter={} (b): {:?}", + seed, iter, cycle + ); + found_with_mut = true; + break 'outer2; + } + net1 = a; + net2 = b; + } + } + if !found_with_mut { + println!("Mutations don't introduce cycles either (no cycles found)"); + } + + assert!(!found_no_mut, "remove_cycles is broken"); + assert!( + !found_with_mut, + "mutation is adding cycles (is_connection_safe is broken)" + ); +} + +#[test] +fn debug_find_bad_connection() { + // Reproduce: seed=0, iter=47 introduces a cycle + let mut rng = StdRng::seed_from_u64(0); + let mut net1 = NeuralNetwork::<10, 10>::new(&mut rng); + let mut net2 = NeuralNetwork::<10, 10>::new(&mut rng); + let settings = ReproductionSettings::default(); + + for iter in 0..47usize { + let a = net1.crossover(&net2, &settings, 0.25, &mut rng); + let b = net2.crossover(&net1, &settings, 0.25, &mut rng); + net1 = a; + net2 = b; + } + + // Now do crossover 47 (iter=47) step by step + // net1.crossover(&net2...) produces 'a' + // Try with mutation_passes=0 to see if problem is in merge or mutations + let mut settings_0 = settings.clone(); + settings_0.mutation_passes = 0; + let a0 = net1.crossover(&net2, &settings_0, 0.25, &mut StdRng::seed_from_u64(47_000)); + let cyc0 = find_cycle_helper(&a0); + println!("iter=47, mutation_passes=0 cycle: {:?}", cyc0); + + let mut settings_1 = settings.clone(); + settings_1.mutation_passes = 1; + let a1 = net1.crossover(&net2, &settings_1, 0.25, &mut StdRng::seed_from_u64(47_000)); + let cyc1 = find_cycle_helper(&a1); + println!("iter=47, mutation_passes=1 cycle: {:?}", cyc1); + + let mut settings_2 = settings.clone(); + settings_2.mutation_passes = 2; + let a2 = net1.crossover(&net2, &settings_2, 0.25, &mut StdRng::seed_from_u64(47_000)); + let cyc2 = find_cycle_helper(&a2); + println!("iter=47, mutation_passes=2 cycle: {:?}", cyc2); + + let a3 = net1.crossover(&net2, &settings, 0.25, &mut StdRng::seed_from_u64(47_000)); + let cyc3 = find_cycle_helper(&a3); + println!("iter=47, mutation_passes=3 cycle: {:?}", cyc3); + + // Also check net2.crossover(net1) + let b0 = net2.crossover(&net1, &settings_0, 0.25, &mut StdRng::seed_from_u64(47_001)); + println!( + "iter=47 b, mutation_passes=0 cycle: {:?}", + find_cycle_helper(&b0) + ); + let b3 = net2.crossover(&net1, &settings, 0.25, &mut StdRng::seed_from_u64(47_001)); + println!( + "iter=47 b, mutation_passes=3 cycle: {:?}", + find_cycle_helper(&b3) + ); +} + +#[test] +fn debug_find_bad_connection2() { + // Reproduce: seed=0, iter=47 introduces a cycle + // Must use the SAME rng throughout + let mut rng = StdRng::seed_from_u64(0); + let mut net1 = NeuralNetwork::<10, 10>::new(&mut rng); + let mut net2 = NeuralNetwork::<10, 10>::new(&mut rng); + let settings = ReproductionSettings::default(); + + for iter in 0..47usize { + let a = net1.crossover(&net2, &settings, 0.25, &mut rng); + let b = net2.crossover(&net1, &settings, 0.25, &mut rng); + net1 = a; + net2 = b; + } + + println!("net1 hidden len: {}", net1.hidden_layers.len()); + println!("net2 hidden len: {}", net2.hidden_layers.len()); + + // Now at iteration 47, the actual test does: + // a = net1.crossover(&net2, ...) + // b = net2.crossover(&net1, ...) + // And the cycle shows up in 'a' + + // Test 'a' with 0, 1, 2, 3 mutation passes, using the current rng state + let settings_3 = settings.clone(); + + // We need separate snapshots of rng state for each test + // But since we can't clone StdRng, let's just do it sequentially + + // Do 0 mutations + let mut s0 = settings.clone(); + s0.mutation_passes = 0; + // Can't replay rng here... let's just do the actual crossover and check + + // Let's just do the full mutation=3 crossover and check step-by-step + // by doing the crossover merge first (mutation_passes=0) + // and checking after each mutation pass + let a = net1.crossover(&net2, &settings_3, 0.25, &mut rng); + let cycle = find_cycle_helper(&a); + println!("a (full crossover) cycle: {:?}", cycle); + + let b = net2.crossover(&net1, &settings_3, 0.25, &mut rng); + let cycle_b = find_cycle_helper(&b); + println!("b (full crossover) cycle: {:?}", cycle_b); +} + +#[test] +fn debug_add_connection_cycle() { + // Try to find a case where add_connection adds a cyclic connection + let settings_with_mut = ReproductionSettings::default(); + + for seed in 0..100u64 { + let mut rng = StdRng::seed_from_u64(seed); + let mut net1 = NeuralNetwork::<10, 10>::new(&mut rng); + let mut net2 = NeuralNetwork::<10, 10>::new(&mut rng); + + for iter in 0..50usize { + let a = net1.crossover(&net2, &settings_with_mut, 0.25, &mut rng); + let b = net2.crossover(&net1, &settings_with_mut, 0.25, &mut rng); + + // Double-check: is_connection_safe should return false for any existing cycle + for i in 0..10usize { + let from = NeuronLocation::Input(i); + let n = &a[from]; + for &to in n.outputs.keys() { + // Check if is_connection_safe correctly returns false for reverse connection + if let NeuronLocation::Hidden(_) | NeuronLocation::Output(_) = to { + // don't test + } + } + } + + let cycle_a = find_cycle_helper(&a); + let cycle_b = find_cycle_helper(&b); + + if cycle_a.is_some() || cycle_b.is_some() { + println!( + "seed={} iter={} cycle_a={:?} cycle_b={:?}", + seed, iter, cycle_a, cycle_b + ); + // Print the first cycle node's connections + if let Some(ref cycle) = cycle_a { + for &node in cycle { + println!( + " {:?} -> {:?}", + node, + a[node].outputs.keys().collect::>() + ); + } + } + // Check if is_connection_safe would detect the cycle + if let Some(ref cycle) = cycle_a { + let n = cycle.len(); + for i in 0..n { + let from = cycle[i]; + let to = cycle[(i + 1) % n]; + // The edge from -> to creates a cycle, so is_connection_safe should return false + // But does it? + let safe = a.is_connection_safe(Connection { from, to }); + println!(" is_connection_safe({:?} -> {:?}) = {}", from, to, safe); + // If safe returns true, that means the check is broken + // (this connection already exists, but it should also detect the EXISTING cycle) + } + } + return; + } + + net1 = a; + net2 = b; + } + } + println!("No cycles found!"); +} + +#[test] +fn debug_split_creates_cycle() { + // Check whether split_connection creates cycles + let settings_with_mut = ReproductionSettings::default(); + + for seed in 0..50u64 { + let mut rng = StdRng::seed_from_u64(seed); + let mut net1 = NeuralNetwork::<10, 10>::new(&mut rng); + let mut net2 = NeuralNetwork::<10, 10>::new(&mut rng); + + for iter in 0..50usize { + let a = net1.crossover(&net2, &settings_with_mut, 0.25, &mut rng); + let b = net2.crossover(&net1, &settings_with_mut, 0.25, &mut rng); + + // Check if there's a cycle in the networks just from split operations + // We'll do this by checking after each mutation pass manually + // For now, just check that we can use is_connection_safe consistently + + // Verify: for every existing edge u->v, is_connection_safe(v->u) should be false + // (since u->v exists, v->u would create a 2-cycle) + let mut found_issue = false; + for i in 0..10usize { + let from = NeuronLocation::Input(i); + let outputs = a[from].outputs.keys().cloned().collect::>(); + for to in outputs { + // Edge from->to exists. Adding to->from should be unsafe. + // But to->from might be blocked by "from.is_output()" or "to.is_input()" checks. + if !to.is_input() && !from.is_output() { + // If from is Input, then to is not Input. to->from = to->Input(i). + // is_connection_safe checks "connection.to.is_input()" -> false. + // So it returns false. Expected. + } + } + } + for i in 0..a.hidden_layers.len() { + let from = NeuronLocation::Hidden(i); + let outputs = a[from].outputs.keys().cloned().collect::>(); + for to in outputs { + // Edge from->to (Hidden(i)->to). Can we add to->Hidden(i)? + if to.is_hidden() || to.is_input() { + // to->Hidden(i): to is not output (hidden or input) + // is_connection_safe checks: + // - to.is_output() -> false if to is hidden/input ✓ + // Wait, "to" here is the "from" in the reverse connection! + let rev_conn = Connection { from: to, to: from }; + // Check if connection.from.is_output() blocks it + if to.is_output() { + // "from.is_output()" in is_connection_safe returns false + assert!(!a.is_connection_safe(rev_conn)); + } else if from.is_input() { + // "to.is_input()" in is_connection_safe returns false + assert!(!a.is_connection_safe(rev_conn)); + } else { + // Should be unsafe since there's already from->to + if a.is_connection_safe(rev_conn) { + println!("BUG: seed={} iter={}: is_connection_safe says {:?}->{:?} is SAFE but {:?}->{:?} exists!", + seed, iter, to, from, from, to); + // Print the cycle path + println!( + " {:?} outputs: {:?}", + from, + a[from].outputs.keys().collect::>() + ); + println!( + " {:?} outputs: {:?}", + to, + a[to].outputs.keys().collect::>() + ); + found_issue = true; + } + } + } + } + } + + if found_issue { + return; + } + + net1 = a; + net2 = b; + } + } + println!("No issues found!"); +} + +#[test] +fn debug_find_mutation_pass_cycle() { + let settings = ReproductionSettings::default(); + let mut settings0 = settings.clone(); + settings0.mutation_passes = 0; + let mut settings1 = settings.clone(); + settings1.mutation_passes = 1; + let mut settings2 = settings.clone(); + settings2.mutation_passes = 2; + let mut settings3 = settings.clone(); // mutation_passes = 3 + + 'outer: for seed in 0..100u64 { + let mut rng = StdRng::seed_from_u64(seed); + let mut net1 = NeuralNetwork::<10, 10>::new(&mut rng); + let mut net2 = NeuralNetwork::<10, 10>::new(&mut rng); + + for iter in 0..50usize { + // We can't easily replay RNG, so just check the final result + let a = net1.crossover(&net2, &settings3, 0.25, &mut rng); + let b = net2.crossover(&net1, &settings3, 0.25, &mut rng); + + // Verify that a network that claims to be acyclic is consistent with is_connection_safe + // For a network with cycle C->D->E->C, is_connection_safe(E->C) should be false + // But it might be TRUE (that's the bug) + if let Some(cycle) = find_cycle_helper(&a) { + println!("seed={} iter={} CYCLE in a: {:?}", seed, iter, cycle); + // Find the specific edge in the cycle that is_connection_safe missed + for i in 0..cycle.len() { + let from = cycle[i]; + let to = cycle[(i + 1) % cycle.len()]; + // This edge exists in the network + println!(" Edge {:?} -> {:?} exists. Checking if is_connection_safe would allow adding it again:", from, to); + // Check reverse edge + if !to.is_input() && !from.is_output() { + let safe = a.is_connection_safe(Connection { from: to, to: from }); + println!( + " is_connection_safe({:?} -> {:?}) = {} (should be false for cycle)", + to, from, safe + ); + } + } + println!(" Full network connections:"); + for i in 0..a.hidden_layers.len() { + let n = &a[NeuronLocation::Hidden(i)]; + println!( + " Hidden({}) -> {:?}", + i, + n.outputs.keys().collect::>() + ); + } + break 'outer; + } + + net1 = a; + net2 = b; + } + } + println!("Done searching"); +}